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