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);
}