From 9774a5cc5f95421d187ddbe28619d21b537cabe1 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Tue, 14 Jul 2026 21:02:50 +1000 Subject: [PATCH] Normalize Dublin Core dc:date, including the rdf:Seq array form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dc:date is an XMP ordered array (seq Date), so spec-compliant producers (current Apache FOP) nest the render timestamp in rdf:Seq/rdf:li rather than as direct text content. Zeroing only the element's own text — as the xmp:* dates are handled — left the value untouched, so the #pdf snapshot target from #516 stayed non-deterministic for FOP output. Add ZeroXmpElementTree, which descends through child markup and zeroes the digits of every text node up to the matching close tag, scoped to dc:date so sibling arrays (dc:subject, ...) are left intact. The shared open-tag scan is factored into NextXmpElementContent; the simple-element path is behaviour-identical. Covered by unit tests for the flat and rdf:Seq forms plus a committed FOP-style sample (sample-fop.pdf) carrying an uncompressed XMP packet. --- src/Directory.Build.props | 2 +- src/Tests/PdfNormalizerTests.cs | 76 +++++++++++++++++++++++++++ src/Tests/Tests.csproj | 3 ++ src/Tests/sample-fop.pdf | Bin 0 -> 1389 bytes src/Verify.DocNet/PdfNormalizer.cs | 81 ++++++++++++++++++++++++++--- 5 files changed, 155 insertions(+), 7 deletions(-) create mode 100644 src/Tests/sample-fop.pdf diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 06d3ff8..3eb957b 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ CS1591;CS0649;NU1608;NU1109 - 3.3.0 + 3.3.1 1.0.0 preview false diff --git a/src/Tests/PdfNormalizerTests.cs b/src/Tests/PdfNormalizerTests.cs index 338ffbf..125bd08 100644 --- a/src/Tests/PdfNormalizerTests.cs +++ b/src/Tests/PdfNormalizerTests.cs @@ -25,6 +25,66 @@ public void NeutralizesVolatileValues() Assert.That(Normalize(input), Is.EqualTo(expected)); } + [Test] + public void NeutralizesDublinCoreDate() + { + // Some producers (for example older Apache FOP) write the render time straight into the + // Dublin Core element as simple text content. + var input = "2024-01-15T09:30:00+05:30"; + var expected = "0000-00-00T00:00:00+00:00"; + Assert.That(Normalize(input), Is.EqualTo(expected)); + } + + [Test] + public void NeutralizesDublinCoreDateSeq() + { + // Per the XMP spec dc:date is an ordered array (seq Date), so a spec-compliant producer + // (current Apache FOP) nests the render time in rdf:Seq/rdf:li rather than as direct text. + var input = "2024-01-15T09:30:00+05:30"; + var expected = "0000-00-00T00:00:00+00:00"; + Assert.That(Normalize(input), Is.EqualTo(expected)); + } + + [Test] + public void NeutralizesDublinCoreDateSeqWithWhitespaceAndMultipleEntries() + { + // Pretty-printed with indentation and more than one date in the sequence: markup and + // whitespace are preserved while every date value is zeroed. + var input = + """ + + + 2024-01-15T09:30:00+05:30 + 2019-12-31T23:59:59Z + + + """; + var expected = + """ + + + 0000-00-00T00:00:00+00:00 + 0000-00-00T00:00:00Z + + + """; + Assert.That(Normalize(input), Is.EqualTo(expected)); + } + + [Test] + public void LeavesNonDateRdfArraysUntouched() + { + // The rdf:li descent is scoped to dc:date, so digits in a sibling array (here dc:subject) + // must survive. + var input = + "topic 2024" + + "2024-01-15T09:30:00Z"; + var expected = + "topic 2024" + + "0000-00-00T00:00:00Z"; + Assert.That(Normalize(input), Is.EqualTo(expected)); + } + [Test] public void CollapsesDifferingValuesToTheSameOutput() { @@ -55,6 +115,22 @@ public void NormalizedDocumentStillLoads() Assert.That(reader.GetPageCount(), Is.EqualTo(2)); } + [Test] + public void NeutralizesFopStyleXmp() + { + // sample-fop.pdf carries an uncompressed FOP-style XMP packet whose dc:date render time is + // nested in rdf:Seq/rdf:li. It must be neutralized while the document still loads. + var data = File.ReadAllBytes("sample-fop.pdf"); + PdfNormalizer.Normalize(data); + + var text = Encoding.Latin1.GetString(data); + Assert.That(text, Does.Contain("0000-00-00T00:00:00+00:00")); + Assert.That(text, Does.Not.Contain("2024-01-15")); + + using var reader = DocLib.Instance.GetDocReader(data, new(scalingFactor: 2)); + Assert.That(reader.GetPageCount(), Is.EqualTo(1)); + } + [Test] public void IsIdempotent() { diff --git a/src/Tests/Tests.csproj b/src/Tests/Tests.csproj index 7b335ef..c9e2a46 100644 --- a/src/Tests/Tests.csproj +++ b/src/Tests/Tests.csproj @@ -22,5 +22,8 @@ PreserveNewest + + PreserveNewest + diff --git a/src/Tests/sample-fop.pdf b/src/Tests/sample-fop.pdf new file mode 100644 index 0000000000000000000000000000000000000000..fd00dba26e6dfba888ceb7eaccc6c8eed99fbe17 GIT binary patch literal 1389 zcma)6!A{#i5WV{=My?Q`D%o8-PGZY3f|#hNq(lU@RS^fr-Xt!GZPpvbqrgWY4uh)^eB~mK(*%sB zeT<0-9!NC(KyoJk2efVwo-Rd7xM>lH4hJdVnv(9sE~rqI(*+BAPna{$PtitEbPU%p5}2>veN& zHd8Sp%(iVp4PqD#L^SfpL6oo==gi&8ZT z319FfK59-_la=|PS`<3iP+Tc*wa5ISvZ_#v*i(6ltMTNJDQ~9t}u(impx6s(NCJug2U#s53zis1gmq8);RKqdVaCEjG zv@OfDU0B+tGsPWPUVnVnZY!+6a#tr}N~AYO1$uCsBx;n2oak)~?%lxyrF znWmsN1g^9TA8uXB46|jmJ6-$qtnZ%>_H7T`!MWc*JGHx=w$(BXM)z&+uA1OL$nyS- MG2E^P=T`&m9}U`v6951J literal 0 HcmV?d00001 diff --git a/src/Verify.DocNet/PdfNormalizer.cs b/src/Verify.DocNet/PdfNormalizer.cs index d85d37c..93cd993 100644 --- a/src/Verify.DocNet/PdfNormalizer.cs +++ b/src/Verify.DocNet/PdfNormalizer.cs @@ -43,6 +43,11 @@ public static void Normalize(byte[] data) ZeroXmpElement(data, ""u8, Fill.Digits); + // XMP per-generation identifiers. ZeroXmpElement(data, " openTag, Fill fill) { var pos = 0; + while (true) + { + var start = NextXmpElementContent(data, openTag, ref pos); + if (start < 0) + { + return; + } + + var end = FindByte(data, start, (byte) '<'); + Overwrite(data, start, end, fill); + pos = end; + } + } + + // Like ZeroXmpElement, but descends through child markup and zeroes the content of every text + // node up to the matching close tag. XMP array properties (for example dc:date, a "seq Date") + // wrap their value in an rdf:Seq/rdf:li list, so the volatile value is not direct text content of + // the named element and ZeroXmpElement alone would step over it. + static void ZeroXmpElementTree(byte[] data, ReadOnlySpan openTag, ReadOnlySpan closeTag, Fill fill) + { + var pos = 0; + while (true) + { + var start = NextXmpElementContent(data, openTag, ref pos); + if (start < 0) + { + return; + } + + var closeHit = data.AsSpan(start).IndexOf(closeTag); + if (closeHit < 0) + { + return; + } + + var end = start + closeHit; + var i = start; + while (i < end) + { + // Skip markup so only text nodes are altered, never element or attribute names. + if (data[i] == (byte) '<') + { + i = FindByte(data, i, (byte) '>'); + if (i < end) + { + i++; + } + + continue; + } + + var textEnd = FindByte(data, i, (byte) '<'); + Overwrite(data, i, textEnd, fill); + i = textEnd; + } + + pos = end; + } + } + + // Locates the next element whose opening tag is 'openTag', returning the index of its content + // (the byte after '>'), or -1 when no further match exists. A longer look-alike name or a + // self-closing tag is skipped internally. 'pos' is advanced past the opening tag so scanning can + // resume from the returned index. + static int NextXmpElementContent(byte[] data, ReadOnlySpan openTag, ref int pos) + { while (true) { var hit = data.AsSpan(pos).IndexOf(openTag); if (hit < 0) { - return; + return -1; } var i = pos + hit + openTag.Length; @@ -174,19 +245,17 @@ static void ZeroXmpElement(byte[] data, ReadOnlySpan openTag, Fill fill) if (i >= data.Length) { - return; + return -1; } i++; + pos = i; if (lastSignificant == (byte) '/') { continue; } - var start = i; - var end = FindByte(data, start, (byte) '<'); - Overwrite(data, start, end, fill); - pos = end; + return i; } }