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") {