Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<</PDFTRON<</LastModified(D:20260729134217Z)/Private/Watermark>>>>`. 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
Expand Down
1 change: 1 addition & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
11 changes: 9 additions & 2 deletions src/DeterministicPdf/PdfNormalizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ namespace DeterministicPdf;

/// <summary>
/// Neutralizes the non-deterministic fields of a PDF (the trailer <c>/ID</c>, the document
/// information <c>/CreationDate</c> and <c>/ModDate</c>, and the equivalent XMP metadata dates and
/// identifiers) so that the same source document always produces byte-identical output.
/// information <c>/CreationDate</c> and <c>/ModDate</c>, the page and page-piece
/// <c>/LastModified</c>, and the equivalent XMP metadata dates and identifiers) so that the same
/// source document always produces byte-identical output.
/// </summary>
/// <remarks>
/// Neutralizing the values (dates and identifiers) is done in place and is length-preserving: only the
Expand Down Expand Up @@ -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<</PDFTRON<</LastModified(D:...)/Private/Watermark>>>>).
ZeroPdfString(data, "/LastModified"u8, Fill.Digits);

// Trailer / cross-reference-stream file identifier: /ID [<...> <...>].
ZeroFileId(data);

Expand Down
27 changes: 24 additions & 3 deletions src/Tests/PdfNormalizerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<</PDFTRON<</LastModified(D:20260729134217Z)/Private/Watermark>>>>";
var expected = "/PieceInfo<</PDFTRON<</LastModified(D:00000000000000Z)/Private/Watermark>>>>";
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()
{
Expand Down Expand Up @@ -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) <xmp:CreateDate/>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) <xmp:CreateDate/>2024";
await Assert.That(Normalize(input)).IsEqualTo(input);
}

Expand Down
Loading