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 0000000..fd00dba Binary files /dev/null and b/src/Tests/sample-fop.pdf differ 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; } }