diff --git a/readme.md b/readme.md
index c2af908..0e5ae5d 100644
--- a/readme.md
+++ b/readme.md
@@ -77,7 +77,7 @@ public Task VerifyPdfStream()
return Verify(stream, "pdf");
}
```
-snippet source | anchor
+snippet source | anchor
@@ -98,7 +98,7 @@ public Task VerifyPreserveTransparency() =>
VerifyFile("sample.pdf")
.PreserveTransparency();
```
-snippet source | anchor
+snippet source | anchor
@@ -112,7 +112,7 @@ public Task VerifyPageDimensions() =>
VerifyFile("sample.pdf")
.PageDimensions(new(1080, 1920));
```
-snippet source | anchor
+snippet source | anchor
## VerifySinglePage
@@ -134,7 +134,7 @@ public Task VerifySecondPage()
return Verify(stream, "pdf").SinglePage(1);
}
```
-snippet source | anchor
+snippet source | anchor
## File Samples
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index c9eea03..308b28a 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -2,7 +2,7 @@
CS1591;CS0649;NU1608;NU1109
- 3.3.2
+ 3.5.0
1.0.0
preview
false
diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props
index 2a57a02..115195f 100644
--- a/src/Directory.Packages.props
+++ b/src/Directory.Packages.props
@@ -4,6 +4,7 @@
true
+
diff --git a/src/Tests/FopTests.VerifyFopRenderedPdf#pdf.verified.pdf b/src/Tests/FopTests.VerifyFopRenderedPdf#pdf.verified.pdf
new file mode 100644
index 0000000..604850e
Binary files /dev/null and b/src/Tests/FopTests.VerifyFopRenderedPdf#pdf.verified.pdf differ
diff --git a/src/Tests/FopTests.VerifyFopRenderedPdf.verified.png b/src/Tests/FopTests.VerifyFopRenderedPdf.verified.png
new file mode 100644
index 0000000..9b6b6ed
Binary files /dev/null and b/src/Tests/FopTests.VerifyFopRenderedPdf.verified.png differ
diff --git a/src/Tests/FopTests.VerifyFopRenderedPdf.verified.txt b/src/Tests/FopTests.VerifyFopRenderedPdf.verified.txt
new file mode 100644
index 0000000..99681b7
--- /dev/null
+++ b/src/Tests/FopTests.VerifyFopRenderedPdf.verified.txt
@@ -0,0 +1,11 @@
+{
+ Version: 1.4,
+ PageCount: 1,
+ Pages: [
+ {
+ Text:
+Verify.DocNet
+A deterministic PDF produced by Apache FOP for snapshot testing.
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/Tests/GlobalUsings.cs b/src/Tests/GlobalUsings.cs
index 1eb3bfd..68c1a56 100644
--- a/src/Tests/GlobalUsings.cs
+++ b/src/Tests/GlobalUsings.cs
@@ -1,4 +1,5 @@
-global using System.Buffers.Binary;
+global using DeterministicPdf;
+global using System.Buffers.Binary;
global using System.Diagnostics;
global using System.IO.Compression;
global using Docnet.Core;
diff --git a/src/Tests/PdfNormalizerTests.cs b/src/Tests/PdfNormalizerTests.cs
index e440061..cfaafed 100644
--- a/src/Tests/PdfNormalizerTests.cs
+++ b/src/Tests/PdfNormalizerTests.cs
@@ -1,125 +1,12 @@
+// The neutralizing algorithm itself is owned and tested by the DeterministicPdf package. What is
+// worth asserting here is the wiring: that this package applies it, and that a normalized document
+// is still loadable by pdfium.
public class PdfNormalizerTests
{
- [Test]
- public async Task NeutralizesVolatileValues()
- {
- var input =
- "/ID [ <1122334455667788>] " +
- "/CreationDate(D:20240115093000+05'30') " +
- "/ModDate(D:20240115093000Z) " +
- "2024-01-15T09:30:00+05:30" +
- "2024-01-15T09:30:00Z" +
- "2024-01-15T09:30:00Z" +
- "uuid:0f7b2c9a-1234-5678-9abc-def012345678" +
- "xmp.iid:1a2b3c4d";
- var expected =
- "/ID [<0000000000000000> <0000000000000000>] " +
- "/CreationDate(D:00000000000000+00'00') " +
- "/ModDate(D:00000000000000Z) " +
- "0000-00-00T00:00:00+00:00" +
- "0000-00-00T00:00:00Z" +
- "0000-00-00T00:00:00Z" +
- $"{new string('0', 41)}" +
- $"{new string('0', 16)}";
- await Assert.That(Normalize(input)).IsEqualTo(expected);
- }
-
- [Test]
- public async Task 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";
- await Assert.That(Normalize(input)).IsEqualTo(expected);
- }
-
- [Test]
- public async Task 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";
- await Assert.That(Normalize(input)).IsEqualTo(expected);
- }
-
- [Test]
- public async Task 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
-
-
- """;
- await Assert.That(Normalize(input)).IsEqualTo(expected);
- }
-
- [Test]
- public async Task 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";
- await Assert.That(Normalize(input)).IsEqualTo(expected);
- }
-
- [Test]
- public async Task CollapsesDifferingValuesToTheSameOutput()
- {
- // The same producer emits a stable structure across runs, so two documents differing only
- // in the volatile digits/hex normalize to identical bytes.
- var a = "/ID [] /CreationDate(D:20240115093000+05'30')";
- var b = "/ID [<99887766>] /CreationDate(D:19991231235959+11'45')";
- await Assert.That(a).IsNotEqualTo(b);
- await Assert.That(Normalize(a)).IsEqualTo(Normalize(b));
- }
-
- [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";
- await Assert.That(Normalize(input)).IsEqualTo(input);
- }
-
- [Test]
- public async Task HandlesUnterminatedFileIdWithoutOverrunning()
- {
- // A truncated /ID whose string runs to the end of the buffer with no closing '>'/')' (and no
- // closing ']') must not scan past the buffer: the value is zeroed as far as it exists and the
- // scan stops cleanly rather than throwing. Hex string form (no closing '>'):
- await Assert.That(Normalize("/ID [0000-00-00T00:00:00+00:00");
@@ -142,14 +28,22 @@ public async Task NeutralizesFopStyleXmp()
}
[Test]
- public async Task IsIdempotent()
+ public async Task CanonicalizesXmpAcrossJreSerializers()
{
- // A second pass has nothing left to change: normalizing already-normalized bytes is a no-op.
- var once = File.ReadAllBytes("sample.pdf");
- PdfNormalizer.Normalize(once);
- var twice = (byte[])once.Clone();
- PdfNormalizer.Normalize(twice);
- await Assert.That(twice).IsEquivalentTo(once);
+ // The same FOP document rendered on two machines. Apache FOP serializes the XMP packet through
+ // the platform's XML writer, so the JDK decides the indentation: one build emits a compact
+ // packet, the other indents every element, and the raw bytes differ. Once normalized, the pdf
+ // snapshot must collapse to identical bytes on both, and still load.
+ var compactRaw = await File.ReadAllBytesAsync("sample-fop-compact.pdf");
+ var indentedRaw = await File.ReadAllBytesAsync("sample-fop-indented.pdf");
+ await Assert.That(compactRaw.SequenceEqual(indentedRaw)).IsFalse();
+
+ var compact = PdfNormalizer.Normalize(compactRaw);
+ var indented = PdfNormalizer.Normalize(indentedRaw);
+ await Assert.That(compact).IsEquivalentTo(indented);
+
+ using var reader = DocLib.Instance.GetDocReader(compact, new(scalingFactor: 2));
+ await Assert.That(reader.GetPageCount()).IsEqualTo(1);
}
[Test]
@@ -157,18 +51,11 @@ public async Task NormalizedSinglePageSplitStillLoads()
{
// A page subset is re-serialized by pdfium (reintroducing volatile fields) then normalized;
// it must remain a valid one-page document.
- var data = File.ReadAllBytes("sample.pdf");
+ var data = await File.ReadAllBytesAsync("sample.pdf");
var split = DocLib.Instance.Split(data, 1, 1);
- PdfNormalizer.Normalize(split);
+ split = PdfNormalizer.Normalize(split);
using var reader = DocLib.Instance.GetDocReader(split, new(scalingFactor: 2));
await Assert.That(reader.GetPageCount()).IsEqualTo(1);
}
-
- static string Normalize(string value)
- {
- var bytes = Encoding.Latin1.GetBytes(value);
- PdfNormalizer.Normalize(bytes);
- return Encoding.Latin1.GetString(bytes);
- }
}
diff --git a/src/Tests/Samples.SkipPdfNormalization#00.verified.png b/src/Tests/Samples.SkipPdfNormalization#00.verified.png
new file mode 100644
index 0000000..88503e0
Binary files /dev/null and b/src/Tests/Samples.SkipPdfNormalization#00.verified.png differ
diff --git a/src/Tests/Samples.SkipPdfNormalization#01.verified.png b/src/Tests/Samples.SkipPdfNormalization#01.verified.png
new file mode 100644
index 0000000..35bf447
Binary files /dev/null and b/src/Tests/Samples.SkipPdfNormalization#01.verified.png differ
diff --git a/src/Tests/Samples.SkipPdfNormalization#pdf.verified.pdf b/src/Tests/Samples.SkipPdfNormalization#pdf.verified.pdf
new file mode 100644
index 0000000..21053ab
Binary files /dev/null and b/src/Tests/Samples.SkipPdfNormalization#pdf.verified.pdf differ
diff --git a/src/Tests/Samples.SkipPdfNormalization.verified.txt b/src/Tests/Samples.SkipPdfNormalization.verified.txt
new file mode 100644
index 0000000..a3766c3
--- /dev/null
+++ b/src/Tests/Samples.SkipPdfNormalization.verified.txt
@@ -0,0 +1,29 @@
+{
+ Version: 1.4,
+ PageCount: 2,
+ Pages: [
+ {
+ Text:
+ A Simple PDF File
+ This is a small demonstration .pdf file -
+ just for use in the Virtual Mechanics tutorials. More text. And more
+ text. And more text. And more text. And more text.
+ And more text. And more text. And more text. And more text. And more
+ text. And more text. Boring, zzzzz. And more text. And more text. And
+ more text. And more text. And more text. And more text. And more text.
+ And more text. And more text.
+ And more text. And more text. And more text. And more text. And more
+ text. And more text. And more text. Even more. Continued on page 2 ...
+ },
+ {
+ Index: 1,
+ Text:
+ Simple PDF File 2
+ ...continued from page 1. Yet more text. And more text. And more text.
+ And more text. And more text. And more text. And more text. And more
+ text. Oh, how boring typing this stuff. But not as boring as watching
+ paint dry. And more text. And more text. And more text. And more text.
+ Boring. More, a little more text. The end, and just as well.
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/Tests/Samples.VerifyPageDimensions#pdf.verified.pdf b/src/Tests/Samples.VerifyPageDimensions#pdf.verified.pdf
index acb31ef..f2036d9 100644
Binary files a/src/Tests/Samples.VerifyPageDimensions#pdf.verified.pdf and b/src/Tests/Samples.VerifyPageDimensions#pdf.verified.pdf differ
diff --git a/src/Tests/Samples.VerifyPdf#pdf.verified.pdf b/src/Tests/Samples.VerifyPdf#pdf.verified.pdf
index acb31ef..f2036d9 100644
Binary files a/src/Tests/Samples.VerifyPdf#pdf.verified.pdf and b/src/Tests/Samples.VerifyPdf#pdf.verified.pdf differ
diff --git a/src/Tests/Samples.VerifyPdfStream#pdf.verified.pdf b/src/Tests/Samples.VerifyPdfStream#pdf.verified.pdf
index acb31ef..f2036d9 100644
Binary files a/src/Tests/Samples.VerifyPdfStream#pdf.verified.pdf and b/src/Tests/Samples.VerifyPdfStream#pdf.verified.pdf differ
diff --git a/src/Tests/Samples.VerifyPreserveTransparency#pdf.verified.pdf b/src/Tests/Samples.VerifyPreserveTransparency#pdf.verified.pdf
index acb31ef..f2036d9 100644
Binary files a/src/Tests/Samples.VerifyPreserveTransparency#pdf.verified.pdf and b/src/Tests/Samples.VerifyPreserveTransparency#pdf.verified.pdf differ
diff --git a/src/Tests/Samples.cs b/src/Tests/Samples.cs
index 147e19d..7df807c 100644
--- a/src/Tests/Samples.cs
+++ b/src/Tests/Samples.cs
@@ -8,6 +8,15 @@ public Task VerifyPdf() =>
#endregion
+ #region SkipPdfNormalization
+
+ [Test]
+ public Task SkipPdfNormalization() =>
+ VerifyFile("sample.pdf")
+ .SkipPdfNormalization();
+
+ #endregion
+
#region PreserveTransparency
[Test]
diff --git a/src/Tests/SkipPdfNormalizationTests.NormalizedSnapshotHoldsTheNeutralizedBytes#00.verified.png b/src/Tests/SkipPdfNormalizationTests.NormalizedSnapshotHoldsTheNeutralizedBytes#00.verified.png
new file mode 100644
index 0000000..88503e0
Binary files /dev/null and b/src/Tests/SkipPdfNormalizationTests.NormalizedSnapshotHoldsTheNeutralizedBytes#00.verified.png differ
diff --git a/src/Tests/SkipPdfNormalizationTests.NormalizedSnapshotHoldsTheNeutralizedBytes#01.verified.png b/src/Tests/SkipPdfNormalizationTests.NormalizedSnapshotHoldsTheNeutralizedBytes#01.verified.png
new file mode 100644
index 0000000..35bf447
Binary files /dev/null and b/src/Tests/SkipPdfNormalizationTests.NormalizedSnapshotHoldsTheNeutralizedBytes#01.verified.png differ
diff --git a/src/Tests/SkipPdfNormalizationTests.NormalizedSnapshotHoldsTheNeutralizedBytes#pdf.verified.pdf b/src/Tests/SkipPdfNormalizationTests.NormalizedSnapshotHoldsTheNeutralizedBytes#pdf.verified.pdf
new file mode 100644
index 0000000..f2036d9
Binary files /dev/null and b/src/Tests/SkipPdfNormalizationTests.NormalizedSnapshotHoldsTheNeutralizedBytes#pdf.verified.pdf differ
diff --git a/src/Tests/SkipPdfNormalizationTests.NormalizedSnapshotHoldsTheNeutralizedBytes.verified.txt b/src/Tests/SkipPdfNormalizationTests.NormalizedSnapshotHoldsTheNeutralizedBytes.verified.txt
new file mode 100644
index 0000000..a3766c3
--- /dev/null
+++ b/src/Tests/SkipPdfNormalizationTests.NormalizedSnapshotHoldsTheNeutralizedBytes.verified.txt
@@ -0,0 +1,29 @@
+{
+ Version: 1.4,
+ PageCount: 2,
+ Pages: [
+ {
+ Text:
+ A Simple PDF File
+ This is a small demonstration .pdf file -
+ just for use in the Virtual Mechanics tutorials. More text. And more
+ text. And more text. And more text. And more text.
+ And more text. And more text. And more text. And more text. And more
+ text. And more text. Boring, zzzzz. And more text. And more text. And
+ more text. And more text. And more text. And more text. And more text.
+ And more text. And more text.
+ And more text. And more text. And more text. And more text. And more
+ text. And more text. And more text. Even more. Continued on page 2 ...
+ },
+ {
+ Index: 1,
+ Text:
+ Simple PDF File 2
+ ...continued from page 1. Yet more text. And more text. And more text.
+ And more text. And more text. And more text. And more text. And more
+ text. Oh, how boring typing this stuff. But not as boring as watching
+ paint dry. And more text. And more text. And more text. And more text.
+ Boring. More, a little more text. The end, and just as well.
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/Tests/SkipPdfNormalizationTests.SkippedSnapshotHoldsTheProducerBytes#00.verified.png b/src/Tests/SkipPdfNormalizationTests.SkippedSnapshotHoldsTheProducerBytes#00.verified.png
new file mode 100644
index 0000000..88503e0
Binary files /dev/null and b/src/Tests/SkipPdfNormalizationTests.SkippedSnapshotHoldsTheProducerBytes#00.verified.png differ
diff --git a/src/Tests/SkipPdfNormalizationTests.SkippedSnapshotHoldsTheProducerBytes#01.verified.png b/src/Tests/SkipPdfNormalizationTests.SkippedSnapshotHoldsTheProducerBytes#01.verified.png
new file mode 100644
index 0000000..35bf447
Binary files /dev/null and b/src/Tests/SkipPdfNormalizationTests.SkippedSnapshotHoldsTheProducerBytes#01.verified.png differ
diff --git a/src/Tests/SkipPdfNormalizationTests.SkippedSnapshotHoldsTheProducerBytes#pdf.verified.pdf b/src/Tests/SkipPdfNormalizationTests.SkippedSnapshotHoldsTheProducerBytes#pdf.verified.pdf
new file mode 100644
index 0000000..21053ab
Binary files /dev/null and b/src/Tests/SkipPdfNormalizationTests.SkippedSnapshotHoldsTheProducerBytes#pdf.verified.pdf differ
diff --git a/src/Tests/SkipPdfNormalizationTests.SkippedSnapshotHoldsTheProducerBytes.verified.txt b/src/Tests/SkipPdfNormalizationTests.SkippedSnapshotHoldsTheProducerBytes.verified.txt
new file mode 100644
index 0000000..a3766c3
--- /dev/null
+++ b/src/Tests/SkipPdfNormalizationTests.SkippedSnapshotHoldsTheProducerBytes.verified.txt
@@ -0,0 +1,29 @@
+{
+ Version: 1.4,
+ PageCount: 2,
+ Pages: [
+ {
+ Text:
+ A Simple PDF File
+ This is a small demonstration .pdf file -
+ just for use in the Virtual Mechanics tutorials. More text. And more
+ text. And more text. And more text. And more text.
+ And more text. And more text. And more text. And more text. And more
+ text. And more text. Boring, zzzzz. And more text. And more text. And
+ more text. And more text. And more text. And more text. And more text.
+ And more text. And more text.
+ And more text. And more text. And more text. And more text. And more
+ text. And more text. And more text. Even more. Continued on page 2 ...
+ },
+ {
+ Index: 1,
+ Text:
+ Simple PDF File 2
+ ...continued from page 1. Yet more text. And more text. And more text.
+ And more text. And more text. And more text. And more text. And more
+ text. Oh, how boring typing this stuff. But not as boring as watching
+ paint dry. And more text. And more text. And more text. And more text.
+ Boring. More, a little more text. The end, and just as well.
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/Tests/SkipPdfNormalizationTests.cs b/src/Tests/SkipPdfNormalizationTests.cs
new file mode 100644
index 0000000..98ad7c1
--- /dev/null
+++ b/src/Tests/SkipPdfNormalizationTests.cs
@@ -0,0 +1,24 @@
+// The Samples entry for SkipPdfNormalization only proves the setting round-trips through a
+// verification. These assert the wiring it is actually there for: with the setting the snapshotted
+// bytes are the producer's own, and without it they are the normalized ones.
+public class SkipPdfNormalizationTests
+{
+ [Test]
+ public async Task SampleIsNotAlreadyNormalized()
+ {
+ // The premise the two tests below rest on. If sample.pdf were already byte-identical to its
+ // normalized form they would both pass while asserting nothing.
+ var raw = await File.ReadAllBytesAsync("sample.pdf");
+
+ await Assert.That(PdfNormalizer.Normalize(raw)).IsNotEquivalentTo(raw);
+ }
+
+ [Test]
+ public async Task SkippedSnapshotHoldsTheProducerBytes() =>
+ await Verify(new MemoryStream(await File.ReadAllBytesAsync("sample.pdf")), "pdf")
+ .SkipPdfNormalization();
+
+ [Test]
+ public async Task NormalizedSnapshotHoldsTheNeutralizedBytes() =>
+ await Verify(new MemoryStream(await File.ReadAllBytesAsync("sample.pdf")), "pdf");
+}
diff --git a/src/Tests/Tests.csproj b/src/Tests/Tests.csproj
index 58a5126..4a3a26d 100644
--- a/src/Tests/Tests.csproj
+++ b/src/Tests/Tests.csproj
@@ -21,7 +21,7 @@
PreserveNewest
-
+
PreserveNewest
diff --git a/src/Tests/sample-fop-compact.pdf b/src/Tests/sample-fop-compact.pdf
new file mode 100644
index 0000000..0505c72
Binary files /dev/null and b/src/Tests/sample-fop-compact.pdf differ
diff --git a/src/Tests/sample-fop-indented.pdf b/src/Tests/sample-fop-indented.pdf
new file mode 100644
index 0000000..a03dcbf
Binary files /dev/null and b/src/Tests/sample-fop-indented.pdf differ
diff --git a/src/Verify.DocNet/GlobalUsings.cs b/src/Verify.DocNet/GlobalUsings.cs
index f21bb70..a1403df 100644
--- a/src/Verify.DocNet/GlobalUsings.cs
+++ b/src/Verify.DocNet/GlobalUsings.cs
@@ -1,4 +1,5 @@
-global using System.Buffers.Binary;
+global using DeterministicPdf;
+global using System.Buffers.Binary;
global using System.IO.Compression;
global using System.IO.Hashing;
global using Docnet.Core;
diff --git a/src/Verify.DocNet/PdfNormalizer.cs b/src/Verify.DocNet/PdfNormalizer.cs
deleted file mode 100644
index dd32fa4..0000000
--- a/src/Verify.DocNet/PdfNormalizer.cs
+++ /dev/null
@@ -1,349 +0,0 @@
-///
-/// 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 snapshot output.
-///
-///
-/// All edits are performed directly on the bytes and are length-preserving: only the mutable
-/// characters inside each value are overwritten, so every cross-reference offset stays valid and
-/// the file never has to be re-serialized. The scan is plaintext, so a value that has been
-/// compressed away (inside an /ObjStm object stream or a flate-compressed XMP packet) no
-/// longer appears literally and is therefore left as-is.
-///
-/// Targets unencrypted documents. Encrypted PDFs seed their encryption key from the trailer
-/// /ID; zeroing it would leave the document undecryptable, so encrypted input should not be
-/// passed here.
-///
-///
-static class PdfNormalizer
-{
- enum Fill
- {
- // Zero the ASCII digits only, keeping separators (leaves a readable date).
- Digits,
-
- // Zero the hexadecimal digits (for hex string <...> values).
- Hex,
-
- // Zero every non-whitespace byte (for opaque identifiers).
- All
- }
-
- public static void Normalize(byte[] data)
- {
- // Document information dictionary dates.
- ZeroPdfString(data, "/CreationDate"u8, Fill.Digits);
- ZeroPdfString(data, "/ModDate"u8, Fill.Digits);
-
- // Trailer / cross-reference-stream file identifier: /ID [<...> <...>].
- ZeroFileId(data);
-
- // XMP metadata dates (uncompressed metadata streams only).
- ZeroXmpElement(data, ""u8, Fill.Digits);
-
- // XMP per-generation identifiers.
- ZeroXmpElement(data, "".
- static void ZeroPdfString(byte[] data, ReadOnlySpan key, Fill fill)
- {
- var pos = 0;
- while (true)
- {
- var hit = data.AsSpan(pos).IndexOf(key);
- if (hit < 0)
- {
- return;
- }
-
- var i = pos + hit + key.Length;
- pos = i;
-
- i = SkipWhitespace(data, i);
- if (i >= data.Length)
- {
- return;
- }
-
- if (data[i] == (byte) '(')
- {
- var start = i + 1;
- var end = FindLiteralEnd(data, start);
- Overwrite(data, start, end, fill);
- pos = end;
- }
- else if (data[i] == (byte) '<' && (i + 1 >= data.Length || data[i + 1] != (byte) '<'))
- {
- var start = i + 1;
- var end = FindByte(data, start, (byte) '>');
- Overwrite(data, start, end, Fill.Hex);
- pos = end;
- }
- }
- }
-
- // Finds "/ID" followed by an array and zeroes each string element. Anything not shaped like the
- // identifier array (for example the "/IDTree" name-tree key) is skipped.
- static void ZeroFileId(byte[] data)
- {
- var key = "/ID"u8;
- var pos = 0;
- while (true)
- {
- var hit = data.AsSpan(pos).IndexOf(key);
- if (hit < 0)
- {
- return;
- }
-
- var i = pos + hit + key.Length;
- pos = i;
-
- i = SkipWhitespace(data, i);
- if (i >= data.Length || data[i] != (byte) '[')
- {
- continue;
- }
-
- i++;
- while (i < data.Length && data[i] != (byte) ']')
- {
- if (data[i] == (byte) '<')
- {
- var start = i + 1;
- i = FindByte(data, start, (byte) '>');
- Overwrite(data, start, i, Fill.Hex);
- if (i < data.Length)
- {
- i++;
- }
- }
- else if (data[i] == (byte) '(')
- {
- var start = i + 1;
- i = FindLiteralEnd(data, start);
- Overwrite(data, start, i, Fill.All);
- if (i < data.Length)
- {
- i++;
- }
- }
- else
- {
- i++;
- }
- }
-
- pos = i;
- }
- }
-
- // Finds an XMP element by its opening tag and zeroes the text content up to the next '<'.
- static void ZeroXmpElement(byte[] data, ReadOnlySpan 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 -1;
- }
-
- var i = pos + hit + openTag.Length;
- pos = i;
-
- // Reject a longer element name that merely shares this prefix.
- if (i < data.Length && data[i] != (byte) '>' && data[i] != (byte) '/' && !IsWhitespace(data[i]))
- {
- continue;
- }
-
- // Skip the remainder of the opening tag, remembering the last significant byte so a
- // self-closing "" can be detected.
- var lastSignificant = (byte) 0;
- while (i < data.Length && data[i] != (byte) '>')
- {
- if (!IsWhitespace(data[i]))
- {
- lastSignificant = data[i];
- }
-
- i++;
- }
-
- if (i >= data.Length)
- {
- return -1;
- }
-
- i++;
- pos = i;
- if (lastSignificant == (byte) '/')
- {
- continue;
- }
-
- return i;
- }
- }
-
- static void Overwrite(byte[] data, int start, int end, Fill fill)
- {
- for (var i = start; i < end; i++)
- {
- var c = data[i];
- var replace = fill switch
- {
- Fill.Digits => IsDigit(c),
- Fill.Hex => IsHexDigit(c),
- _ => !IsWhitespace(c)
- };
- if (replace)
- {
- data[i] = (byte) '0';
- }
- }
- }
-
- // Returns the index of the ')' that closes the literal string starting at 'start', honoring
- // backslash escapes and balanced parentheses, or the end of the buffer if unterminated.
- static int FindLiteralEnd(byte[] data, int start)
- {
- var depth = 1;
- var i = start;
- while (i < data.Length)
- {
- var c = data[i];
- if (c == (byte) '\\')
- {
- i += 2;
- continue;
- }
-
- if (c == (byte) '(')
- {
- depth++;
- }
- else if (c == (byte) ')')
- {
- depth--;
- if (depth == 0)
- {
- return i;
- }
- }
-
- i++;
- }
-
- return data.Length;
- }
-
- static int FindByte(byte[] data, int start, byte target)
- {
- var i = start;
- while (i < data.Length && data[i] != target)
- {
- i++;
- }
-
- return i;
- }
-
- static int SkipWhitespace(byte[] data, int i)
- {
- while (i < data.Length && IsWhitespace(data[i]))
- {
- i++;
- }
-
- return i;
- }
-
- static bool IsDigit(byte b) =>
- b is >= (byte) '0' and <= (byte) '9';
-
- static bool IsHexDigit(byte b) =>
- b is >= (byte) '0' and <= (byte) '9' or >= (byte) 'a' and <= (byte) 'f' or >= (byte) 'A' and <= (byte) 'F';
-
- static bool IsWhitespace(byte b) =>
- b is (byte) ' ' or (byte) '\t' or (byte) '\r' or (byte) '\n' or (byte) '\f' or 0;
-}
diff --git a/src/Verify.DocNet/Verify.DocNet.csproj b/src/Verify.DocNet/Verify.DocNet.csproj
index 94f384f..a444f37 100644
--- a/src/Verify.DocNet/Verify.DocNet.csproj
+++ b/src/Verify.DocNet/Verify.DocNet.csproj
@@ -4,6 +4,7 @@
+
diff --git a/src/Verify.DocNet/VerifyDocNet.cs b/src/Verify.DocNet/VerifyDocNet.cs
index 7fc14fe..1d7ca67 100644
--- a/src/Verify.DocNet/VerifyDocNet.cs
+++ b/src/Verify.DocNet/VerifyDocNet.cs
@@ -112,4 +112,36 @@ static bool GetPreserveTransparency(this IReadOnlyDictionary set
return false;
}
+
+ ///
+ /// Snapshots the pdf bytes exactly as produced, skipping the normalization that neutralizes the
+ /// trailer /ID, the /CreationDate and /ModDate, and the XMP dates and
+ /// identifiers. Use it when the producer already emits byte-deterministic documents, since
+ /// normalizing them again copies the whole buffer, rescans it, and — when the XMP packet is
+ /// canonicalized — rebuilds it and repairs the cross-reference table, all to change nothing.
+ ///
+ ///
+ /// Only skip this when the producer is genuinely deterministic. Without it a freshly generated
+ /// pdf carries a wall-clock /CreationDate and a fresh /ID, so the snapshot differs
+ /// on every run.
+ ///
+ /// The XMP canonicalization is worth calling out because it is the pass that changes bytes for
+ /// an already-deterministic producer: it collapses the packet's whitespace, so enabling or
+ /// disabling this setting on an existing suite shifts the stored .verified.pdf even
+ /// though nothing about the document changed. Expect to re-accept those snapshots once.
+ ///
+ ///
+ public static void SkipPdfNormalization(this VerifySettings settings) =>
+ settings.Context["VerifyDocNetSkipNormalization"] = true;
+
+ ///
+ public static SettingsTask SkipPdfNormalization(this SettingsTask settings)
+ {
+ settings.CurrentSettings.SkipPdfNormalization();
+ return settings;
+ }
+
+ static bool GetNormalize(this IReadOnlyDictionary settings) =>
+ !settings.TryGetValue("VerifyDocNetSkipNormalization", out var value) ||
+ value is not true;
}
\ No newline at end of file
diff --git a/src/Verify.DocNet/VerifyDocNet_Pdf.cs b/src/Verify.DocNet/VerifyDocNet_Pdf.cs
index 2c376bf..bbc9831 100644
--- a/src/Verify.DocNet/VerifyDocNet_Pdf.cs
+++ b/src/Verify.DocNet/VerifyDocNet_Pdf.cs
@@ -24,10 +24,14 @@ static ConversionResult Convert(string? name, Stream stream, IReadOnlyDictionary
bytes :
DocLib.Instance.Split(bytes, start, endExclusive - 1);
- // Neutralize the volatile fields for the pdf snapshot. When the whole document is reused
- // this must happen only after the reader, which reads lazily from the same buffer, has been
- // released.
- PdfNormalizer.Normalize(pdfBytes);
+ if (settings.GetNormalize())
+ {
+ // Neutralize the volatile fields for the pdf snapshot. When the whole document is reused
+ // this must happen only after the reader, which reads lazily from the same buffer, has
+ // been released.
+ pdfBytes = PdfNormalizer.Normalize(pdfBytes);
+ }
+
targets.Add(
new("pdf", new MemoryStream(pdfBytes), "pdf")
{