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
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<Project>
<PropertyGroup>
<NoWarn>CS1591;CS0649;NU1608;NU1109</NoWarn>
<Version>3.3.0</Version>
<Version>3.3.1</Version>
<AssemblyVersion>1.0.0</AssemblyVersion>
<LangVersion>preview</LangVersion>
<SignAssembly>false</SignAssembly>
Expand Down
76 changes: 76 additions & 0 deletions src/Tests/PdfNormalizerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dc:date> element as simple text content.
var input = "<dc:date>2024-01-15T09:30:00+05:30</dc:date>";
var expected = "<dc:date>0000-00-00T00:00:00+00:00</dc:date>";
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 = "<dc:date><rdf:Seq><rdf:li>2024-01-15T09:30:00+05:30</rdf:li></rdf:Seq></dc:date>";
var expected = "<dc:date><rdf:Seq><rdf:li>0000-00-00T00:00:00+00:00</rdf:li></rdf:Seq></dc:date>";
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 =
"""
<dc:date>
<rdf:Seq>
<rdf:li>2024-01-15T09:30:00+05:30</rdf:li>
<rdf:li>2019-12-31T23:59:59Z</rdf:li>
</rdf:Seq>
</dc:date>
""";
var expected =
"""
<dc:date>
<rdf:Seq>
<rdf:li>0000-00-00T00:00:00+00:00</rdf:li>
<rdf:li>0000-00-00T00:00:00Z</rdf:li>
</rdf:Seq>
</dc:date>
""";
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 =
"<dc:subject><rdf:Bag><rdf:li>topic 2024</rdf:li></rdf:Bag></dc:subject>" +
"<dc:date><rdf:Seq><rdf:li>2024-01-15T09:30:00Z</rdf:li></rdf:Seq></dc:date>";
var expected =
"<dc:subject><rdf:Bag><rdf:li>topic 2024</rdf:li></rdf:Bag></dc:subject>" +
"<dc:date><rdf:Seq><rdf:li>0000-00-00T00:00:00Z</rdf:li></rdf:Seq></dc:date>";
Assert.That(Normalize(input), Is.EqualTo(expected));
}

[Test]
public void CollapsesDifferingValuesToTheSameOutput()
{
Expand Down Expand Up @@ -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("<rdf:li>0000-00-00T00:00:00+00:00</rdf:li>"));
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()
{
Expand Down
3 changes: 3 additions & 0 deletions src/Tests/Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,8 @@
<None Update="sample.*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="sample-fop.pdf">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
Binary file added src/Tests/sample-fop.pdf
Binary file not shown.
81 changes: 75 additions & 6 deletions src/Verify.DocNet/PdfNormalizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ public static void Normalize(byte[] data)
ZeroXmpElement(data, "<xmp:ModifyDate"u8, Fill.Digits);
ZeroXmpElement(data, "<xmp:MetadataDate"u8, Fill.Digits);

// Dublin Core date. Unlike the xmp:* dates above it is an ordered array (seq Date), so the
// value is nested inside rdf:Seq/rdf:li rather than being direct text content of the element
// (this is what Apache FOP emits).
ZeroXmpElementTree(data, "<dc:date"u8, "</dc:date>"u8, Fill.Digits);

// XMP per-generation identifiers.
ZeroXmpElement(data, "<xmpMM:DocumentID"u8, Fill.All);
ZeroXmpElement(data, "<xmpMM:InstanceID"u8, Fill.All);
Expand Down Expand Up @@ -142,12 +147,78 @@ static void ZeroFileId(byte[] data)
static void ZeroXmpElement(byte[] data, ReadOnlySpan<byte> 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<byte> openTag, ReadOnlySpan<byte> 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<byte> 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;
Expand All @@ -174,19 +245,17 @@ static void ZeroXmpElement(byte[] data, ReadOnlySpan<byte> 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;
}
}

Expand Down
Loading