diff --git a/PdfSharpCore.Test/IO/ModificationDateTests.cs b/PdfSharpCore.Test/IO/ModificationDateTests.cs new file mode 100644 index 00000000..19b9011d --- /dev/null +++ b/PdfSharpCore.Test/IO/ModificationDateTests.cs @@ -0,0 +1,125 @@ +using System; +using System.IO; +using System.Text; +using FluentAssertions; +using PdfSharpCore.Pdf; +using PdfSharpCore.Pdf.IO; +using Xunit; + +namespace PdfSharpCore.Test.IO +{ + /// + /// Opening a document for modification stamped it with the current time there and then, before + /// anything had been modified and whether or not it was ever written. Reading a document to look + /// at its dates changed the date being looked at, which left no way to tell a document that had + /// been edited from one that had only been opened. + /// See https://github.com/ststeiger/PdfSharpCore/issues/365. + /// + public class ModificationDateTests + { + private static readonly DateTime ADateInTheFile = new DateTime(2001, 2, 3, 4, 5, 6); + + [Fact] + public void OpeningADocumentForModificationReportsTheDateTheFileCarries() + { + var pdf = ADocumentModifiedOn(ADateInTheFile); + + var opened = Pdf.IO.PdfReader.Open(new MemoryStream(pdf, false), PdfDocumentOpenMode.Modify); + + // Reading the same file without asking to modify it never stamped anything, so it says what + // the file says. Comparing the two keeps the question away from how a date is spelled in a + // file and asks only whether opening for modification answers differently. + var readOnly = Pdf.IO.PdfReader.Open(new MemoryStream(pdf, false), PdfDocumentOpenMode.ReadOnly); + opened.Info.ModificationDate.Should().Be(readOnly.Info.ModificationDate); + } + + [Fact] + public void OpeningADocumentWithNoModificationDateForModificationDoesNotGiveItOne() + { + var pdf = ADocumentModifiedOn(null); + + var opened = Pdf.IO.PdfReader.Open(new MemoryStream(pdf, false), PdfDocumentOpenMode.Modify); + + opened.Info.ModificationDate.Should().Be(DateTime.MinValue); + } + + [Fact] + public void WritingADocumentOpenedForModificationStampsItWithTheTimeItWasWritten() + { + var pdf = ADocumentModifiedOn(ADateInTheFile); + var document = Pdf.IO.PdfReader.Open(new MemoryStream(pdf, false), PdfDocumentOpenMode.Modify); + + var before = DateTime.Now; + using var written = new MemoryStream(); + document.Save(written, false); + + document.Info.ModificationDate.Should().BeOnOrAfter(before).And.BeOnOrBefore(DateTime.Now); + Encoding.ASCII.GetString(written.ToArray()).Should().Contain("/ModDate"); + } + + /// + /// The date is stamped by setting the element rather than the property, so that the stamp of + /// one save is not mistaken for a date the caller chose when the next save comes around. + /// + [Fact] + public void WritingADocumentTwiceStampsItTwice() + { + var pdf = ADocumentModifiedOn(ADateInTheFile); + var document = Pdf.IO.PdfReader.Open(new MemoryStream(pdf, false), PdfDocumentOpenMode.Modify); + + using var first = new MemoryStream(); + document.Save(first, false); + var afterTheFirstSave = document.Info.ModificationDate; + + var between = DateTime.Now; + using var second = new MemoryStream(); + document.Save(second, false); + + document.Info.ModificationDate.Should().BeOnOrAfter(between); + afterTheFirstSave.Should().BeOnOrBefore(between); + } + + [Fact] + public void WritingADocumentDoesNotStampOverAModificationDateTheCallerChose() + { + var pdf = ADocumentModifiedOn(ADateInTheFile); + var document = Pdf.IO.PdfReader.Open(new MemoryStream(pdf, false), PdfDocumentOpenMode.Modify); + + var chosen = new DateTime(1999, 12, 31, 23, 59, 58); + document.Info.ModificationDate = chosen; + using var written = new MemoryStream(); + document.Save(written, false); + + document.Info.ModificationDate.Should().Be(chosen); + } + + /// + /// A document that was never read from a file is dated by its creation date alone, as it + /// always has been. Nothing about it has been modified, so there is no modification to date. + /// + [Fact] + public void WritingANewlyAuthoredDocumentDoesNotStampIt() + { + var document = new PdfDocument(); + document.AddPage(); + + using var written = new MemoryStream(); + document.Save(written, false); + + document.Info.ModificationDate.Should().Be(DateTime.MinValue); + Encoding.ASCII.GetString(written.ToArray()).Should().NotContain("/ModDate"); + } + + private static byte[] ADocumentModifiedOn(DateTime? modificationDate) + { + var document = new PdfDocument(); + document.AddPage(); + if (modificationDate.HasValue) + document.Info.ModificationDate = modificationDate.Value; + + using var pdf = new MemoryStream(); + document.Save(pdf, false); + return pdf.ToArray(); + } + } +} diff --git a/PdfSharpCore/Pdf.IO/PdfReader.cs b/PdfSharpCore/Pdf.IO/PdfReader.cs index 2cdb0b90..e42a2813 100644 --- a/PdfSharpCore/Pdf.IO/PdfReader.cs +++ b/PdfSharpCore/Pdf.IO/PdfReader.cs @@ -582,8 +582,9 @@ public static PdfDocument Open(Stream stream, string password, PdfDocumentOpenMo document.Internals.SecondDocumentID = PdfEncoders.RawEncoding.GetString(agTemp, 0, agTemp.Length); } - // Change modification date - document.Info.ModificationDate = DateTime.Now; + // The modification date is not stamped here. It is stamped when the document is + // written, in PdfDocument.PrepareForSave, so that opening a document to read its + // dates does not change the date it is read for. // Remove all unreachable objects int removed = document._irefTable.Compact(); diff --git a/PdfSharpCore/Pdf/PdfDocument.cs b/PdfSharpCore/Pdf/PdfDocument.cs index 674ef4b0..741f71b8 100644 --- a/PdfSharpCore/Pdf/PdfDocument.cs +++ b/PdfSharpCore/Pdf/PdfDocument.cs @@ -412,6 +412,16 @@ internal override void PrepareForSave() } info.Elements.SetString(PdfDocumentInformation.Keys.Producer, producer); + // Stamp a document opened for modification with the time it was written. This used to be + // done when the document was opened, which meant that reading one to look at its dates + // changed the very date being looked at, whether or not anything was ever written. Set the + // element rather than the property, so that saving twice stamps twice and neither save is + // taken for a date the caller chose. + // IsImported tells a document PdfReader opened from a newly created one, which shares the + // default open mode of Modify and is dated by its creation date alone, as it always was. + if (IsImported && _openMode == PdfDocumentOpenMode.Modify && !info.ModificationDateIsTheCallersOwn) + info.Elements.SetDateTime(PdfDocumentInformation.Keys.ModDate, DateTime.Now); + // Prepare used fonts. if (_fontTable != null) _fontTable.PrepareForSave(); diff --git a/PdfSharpCore/Pdf/PdfDocumentInformation.cs b/PdfSharpCore/Pdf/PdfDocumentInformation.cs index 55116c30..a6ab948b 100644 --- a/PdfSharpCore/Pdf/PdfDocumentInformation.cs +++ b/PdfSharpCore/Pdf/PdfDocumentInformation.cs @@ -117,9 +117,20 @@ public DateTime CreationDate public DateTime ModificationDate { get { return Elements.GetDateTime(Keys.ModDate, DateTime.MinValue); } - set { Elements.SetDateTime(Keys.ModDate, value); } + set + { + Elements.SetDateTime(Keys.ModDate, value); + ModificationDateIsTheCallersOwn = true; + } } + /// + /// Gets a value indicating whether the caller has set the modification date itself. Writing a + /// document that was opened for modification stamps it with the time it was written, but not + /// over a date the caller has chosen. A date read from a file is not the caller's own. + /// + internal bool ModificationDateIsTheCallersOwn { get; private set; } + // TODO CustomProperties and meta data /// @@ -128,7 +139,7 @@ public DateTime ModificationDate internal sealed class Keys : KeysBase { /// - /// (Optional; PDF 1.1) The document’s title. + /// (Optional; PDF 1.1) The document�s title. /// [KeyInfo(KeyType.String | KeyType.Optional)] public const string Title = "/Title";