diff --git a/claude.md b/claude.md index b18a02a..0d6456f 100644 --- a/claude.md +++ b/claude.md @@ -26,6 +26,12 @@ Two places record the same volatile information, and both must be handled: Plus the trailer file identifier `/ID [<...> <...>]`. +A third, per-producer stamp is `/LastModified` in a page or page-piece (`/PieceInfo`) dictionary. It is +a plain date string like `/ModDate`, so the same `ZeroPdfString` pass covers it. PDFTron writes one +onto the form XObject it uses for a watermark: +`/PieceInfo<>>>`. Note the value can +follow the key with no separating whitespace. + ### Why values are zeroed rather than removed Zeroing is **length-preserving**, so every offset in the cross-reference table stays valid and no diff --git a/readme.md b/readme.md index 66fa650..102ba7b 100644 --- a/readme.md +++ b/readme.md @@ -19,6 +19,7 @@ A PDF records when it was produced and stamps every render with fresh identifier * The trailer file identifier `/ID [<...> <...>]` * The document information dictionary dates `/CreationDate` and `/ModDate` + * The page and page-piece dictionary date `/LastModified`, which a producer stamps with a wall-clock time for its own private data (PDFTron writes one onto the form XObject it uses for a watermark) * The XMP metadata dates `xmp:CreateDate`, `xmp:ModifyDate`, and `xmp:MetadataDate` * The Dublin Core `dc:date`, whether written as direct text content or nested in an `rdf:Seq`/`rdf:li` array * The XMP per-generation identifiers `xmpMM:DocumentID`, `xmpMM:InstanceID`, and `xmpMM:OriginalDocumentID` diff --git a/src/DeterministicPdf/PdfNormalizer.cs b/src/DeterministicPdf/PdfNormalizer.cs index 5ecafd5..69e4a6c 100644 --- a/src/DeterministicPdf/PdfNormalizer.cs +++ b/src/DeterministicPdf/PdfNormalizer.cs @@ -2,8 +2,9 @@ namespace DeterministicPdf; /// /// Neutralizes the non-deterministic fields of a PDF (the trailer /ID, the document -/// information /CreationDate and /ModDate, and the equivalent XMP metadata dates and -/// identifiers) so that the same source document always produces byte-identical output. +/// information /CreationDate and /ModDate, the page and page-piece +/// /LastModified, and the equivalent XMP metadata dates and identifiers) so that the same +/// source document always produces byte-identical output. /// /// /// Neutralizing the values (dates and identifiers) is done in place and is length-preserving: only the @@ -59,6 +60,12 @@ static byte[] NormalizeCore(byte[] data) ZeroPdfString(data, "/CreationDate"u8, Fill.Digits); ZeroPdfString(data, "/ModDate"u8, Fill.Digits); + // Page and page-piece dictionary modification date. A producer stamps a wall-clock time here + // for its own private data, so it changes on every render even when nothing about the document + // did (PDFTron writes one onto the form XObject it uses for a watermark: + // /PieceInfo<>>>). + ZeroPdfString(data, "/LastModified"u8, Fill.Digits); + // Trailer / cross-reference-stream file identifier: /ID [<...> <...>]. ZeroFileId(data); diff --git a/src/Tests/PdfNormalizerTests.cs b/src/Tests/PdfNormalizerTests.cs index 5e3f4e2..8b581c4 100644 --- a/src/Tests/PdfNormalizerTests.cs +++ b/src/Tests/PdfNormalizerTests.cs @@ -24,6 +24,27 @@ public async Task NeutralizesVolatileValues() await Assert.That(Normalize(input)).IsEqualTo(expected); } + [Test] + public async Task NeutralizesPieceInfoLastModified() + { + // A producer stamps a wall-clock time into the /LastModified of a page-piece dictionary for + // its own private data. PDFTron does this on the form XObject it uses for a watermark, so the + // value changes on every render even though nothing about the document did. Note the value + // follows the key with no separating whitespace. + var input = "/PieceInfo<>>>"; + var expected = "/PieceInfo<>>>"; + await Assert.That(Normalize(input)).IsEqualTo(expected); + } + + [Test] + public async Task NeutralizesPageLastModified() + { + // The page dictionary form: /LastModified is required on a page that carries a /PieceInfo. + var input = "/Type /Page /LastModified (D:20240115093000+05'30') /PieceInfo 12 0 R"; + var expected = "/Type /Page /LastModified (D:00000000000000+00'00') /PieceInfo 12 0 R"; + await Assert.That(Normalize(input)).IsEqualTo(expected); + } + [Test] public async Task NeutralizesDublinCoreDate() { @@ -98,9 +119,9 @@ public async Task CollapsesDifferingValuesToTheSameOutput() [Test] public async Task LeavesLookalikeKeysUntouched() { - // /IDTree is a name-tree key (not the file identifier), /ModDateStamp is a different name, - // and a self-closing date element has no content: none should be altered. - var input = "/IDTree [1 2] /ModDateStamp(20240101) 2024"; + // /IDTree is a name-tree key (not the file identifier), /ModDateStamp and /LastModifiedBy are + // different names, and a self-closing date element has no content: none should be altered. + var input = "/IDTree [1 2] /ModDateStamp(20240101) /LastModifiedBy(2024) 2024"; await Assert.That(Normalize(input)).IsEqualTo(input); }