From c3841dcead0d6ef7fad942f6320f1ee6952bf22a Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Wed, 22 Jul 2026 23:23:53 +1000 Subject: [PATCH] Add a deterministic pdf target Mirrors ConvertSvg, which emits the source document alongside the render. The bytes go through DeterministicPdf so the snapshot does not depend on the volatile fields the producer stamps in. A password protected pdf now throws rather than silently omitting the target: the trailer /ID seeds the encryption key, so neutralizing it would leave the document undecryptable. The check runs before rendering, which is the expensive part. Use ExcludeTargets("pdf") to verify only the rendered pages. --- src/Directory.Build.props | 2 +- src/Directory.Packages.props | 1 + src/Tests/PasswordSamples.cs | 17 +++++++- src/Tests/Tests.VerifyPdf.verified.pdf | Bin 0 -> 2715 bytes .../Tests.VerifyPdfWithName#name.verified.pdf | Bin 0 -> 2715 bytes src/Verify.ImageMagick/GlobalUsings.cs | 3 +- .../Verify.ImageMagick.csproj | 1 + .../VerifyImageMagick_Pdf.cs | 37 +++++++++++++++++- 8 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 src/Tests/Tests.VerifyPdf.verified.pdf create mode 100644 src/Tests/Tests.VerifyPdfWithName#name.verified.pdf diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 22e61ff0..640ee5fe 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ CS1591 - 3.8.8 + 3.9.0 1.0.0 enable preview diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 647cef08..9ee0eae6 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -5,6 +5,7 @@ + diff --git a/src/Tests/PasswordSamples.cs b/src/Tests/PasswordSamples.cs index 15b894e7..834588dd 100644 --- a/src/Tests/PasswordSamples.cs +++ b/src/Tests/PasswordSamples.cs @@ -1,8 +1,21 @@ [TestFixture] public class PasswordSamples { + // A password protected pdf cannot produce a deterministic pdf target, so the pdf target is + // excluded and only the rendered pages are verified. [Test] public Task PasswordSample() => VerifyFile("password.pdf") - .ImageMagickPdfPassword("password"); -} \ No newline at end of file + .ImageMagickPdfPassword("password") + .ExcludeTargets("pdf"); + + [Test] + public void PasswordWithPdfTargetThrows() + { + var exception = Assert.ThrowsAsync( + () => VerifyFile("password.pdf") + .ImageMagickPdfPassword("password"))!; + + Assert.That(exception.Message, Does.Contain("""ExcludeTargets("pdf")""")); + } +} diff --git a/src/Tests/Tests.VerifyPdf.verified.pdf b/src/Tests/Tests.VerifyPdf.verified.pdf new file mode 100644 index 0000000000000000000000000000000000000000..b6cdb4cc332d72a9b4fb5c4b04d34a0729c299fc GIT binary patch literal 2715 zcmbtW2~ZPP7#>@-H8qxMQK^D!6a|I7l8_KdR0yaoK@AiYyq4?>tR%Z}cY!3-8MSy- z8AY_}jE<$Fj@Hq!h&o3mIt&Prx(@T3oNeGup$JT57UjvPYE(K!Br_85 zo5uo2F_1YML!|OHf#vvoViL!X<|QsLHkkr((W;u`rg07#h(wx$k%1XNM?ja7RcO3JRekk5I zW#Yo4%HQ3WAJ$HEdvch~bq&2w;@!VKSy9ZIAN(*@YB=>w8$RDUBx70L_V^!%GP&0@ zF`w5*-1j!h`JoJX*!f8FGgG^Lw6B85vANNrD4mm>WG$9<>u=41vGBRwTZ@%yGfZWMjsLLm!hVNLE z%vDYI))Zcf`y_Qx@vYh^saG$mq8FWB@!NCzG1sA{=_{L}jsxXtpK(*CzIS>~X)LYS zG$w)?dv;0PvVp_D@b#E+;9yVwBUi&wQ)>gWX2)n`;|*-)+`H<85Y{H zMC2Uw)dzi{{;)+hAjVinn=zN&SNu0#uu>x6k83>}Jqy!fk*2UU!o8XLv8V49z zXmMyZZ~||JdJ$tbkgcwqSi&N(ZJgbEC8L!j~&X`5_G52%n{Pq#2EX{6i5*b0G+ z<$-*TH>!LGkEk$?HL50QEu_Vj0t&dXUJ+z^GqP;nLR%uM8fj7_xeaco%L%)Kxt$JP zGPsQ@2Ffciz-MG8RG1$k7aCP&56_^+Xkc4t9j?>Ig?5I8vvxsrN~k$17+PfQ1}lWW z4h%;|RY)UC)VLt#6BIm)1W6M#9S;%ll82WWcRVi*3Pdmi$tH3x8MYNe5t%%pNH(fe zif*st!rf?z%S*VwcvJxaCkP5x6D>%%(0@m|85cy7t&kSW+p$h3fhvSa3<2Vp{v&ac zP-#IgxCc&`!Otat*kMAfiTB@GBne3b@0=u}4JgD*5VHiq5y&2bY@T=x4e+Rg6s=&* zp74cf_jKeJ1o^!z7K2%^6*=LD!Z@>kNv#gUn+E>sTl)sWWdl)yB!JPV04it-fUNLU z!Xd7tI2cJXvB1t0IphGo&8#AcK#U4u7~E063DOolwj2&PXgOXmgHct24o|xmm|FyP zC5cg_GPpi0L9113F}osQqM*>B3*!SON)O*BtxdEJeulI*C1~53^mJPjMJ2QiPtomB zw7#9AhK9=1oKY4T&H+U94VcM!0bCGa6$BYWN^2#?@pb`2!U-SExk-MfxBQqoMj$bl TWJZ+z4W!k2oiaK)b$r@CD3F>K literal 0 HcmV?d00001 diff --git a/src/Tests/Tests.VerifyPdfWithName#name.verified.pdf b/src/Tests/Tests.VerifyPdfWithName#name.verified.pdf new file mode 100644 index 0000000000000000000000000000000000000000..b6cdb4cc332d72a9b4fb5c4b04d34a0729c299fc GIT binary patch literal 2715 zcmbtW2~ZPP7#>@-H8qxMQK^D!6a|I7l8_KdR0yaoK@AiYyq4?>tR%Z}cY!3-8MSy- z8AY_}jE<$Fj@Hq!h&o3mIt&Prx(@T3oNeGup$JT57UjvPYE(K!Br_85 zo5uo2F_1YML!|OHf#vvoViL!X<|QsLHkkr((W;u`rg07#h(wx$k%1XNM?ja7RcO3JRekk5I zW#Yo4%HQ3WAJ$HEdvch~bq&2w;@!VKSy9ZIAN(*@YB=>w8$RDUBx70L_V^!%GP&0@ zF`w5*-1j!h`JoJX*!f8FGgG^Lw6B85vANNrD4mm>WG$9<>u=41vGBRwTZ@%yGfZWMjsLLm!hVNLE z%vDYI))Zcf`y_Qx@vYh^saG$mq8FWB@!NCzG1sA{=_{L}jsxXtpK(*CzIS>~X)LYS zG$w)?dv;0PvVp_D@b#E+;9yVwBUi&wQ)>gWX2)n`;|*-)+`H<85Y{H zMC2Uw)dzi{{;)+hAjVinn=zN&SNu0#uu>x6k83>}Jqy!fk*2UU!o8XLv8V49z zXmMyZZ~||JdJ$tbkgcwqSi&N(ZJgbEC8L!j~&X`5_G52%n{Pq#2EX{6i5*b0G+ z<$-*TH>!LGkEk$?HL50QEu_Vj0t&dXUJ+z^GqP;nLR%uM8fj7_xeaco%L%)Kxt$JP zGPsQ@2Ffciz-MG8RG1$k7aCP&56_^+Xkc4t9j?>Ig?5I8vvxsrN~k$17+PfQ1}lWW z4h%;|RY)UC)VLt#6BIm)1W6M#9S;%ll82WWcRVi*3Pdmi$tH3x8MYNe5t%%pNH(fe zif*st!rf?z%S*VwcvJxaCkP5x6D>%%(0@m|85cy7t&kSW+p$h3fhvSa3<2Vp{v&ac zP-#IgxCc&`!Otat*kMAfiTB@GBne3b@0=u}4JgD*5VHiq5y&2bY@T=x4e+Rg6s=&* zp74cf_jKeJ1o^!z7K2%^6*=LD!Z@>kNv#gUn+E>sTl)sWWdl)yB!JPV04it-fUNLU z!Xd7tI2cJXvB1t0IphGo&8#AcK#U4u7~E063DOolwj2&PXgOXmgHct24o|xmm|FyP zC5cg_GPpi0L9113F}osQqM*>B3*!SON)O*BtxdEJeulI*C1~53^mJPjMJ2QiPtomB zw7#9AhK9=1oKY4T&H+U94VcM!0bCGa6$BYWN^2#?@pb`2!U-SExk-MfxBQqoMj$bl TWJZ+z4W!k2oiaK)b$r@CD3F>K literal 0 HcmV?d00001 diff --git a/src/Verify.ImageMagick/GlobalUsings.cs b/src/Verify.ImageMagick/GlobalUsings.cs index 29c4d80f..6c12e9e4 100644 --- a/src/Verify.ImageMagick/GlobalUsings.cs +++ b/src/Verify.ImageMagick/GlobalUsings.cs @@ -1,4 +1,5 @@ -global using System.Diagnostics.CodeAnalysis; +global using DeterministicPdf; +global using System.Diagnostics.CodeAnalysis; global using EmptyFiles; global using ImageMagick; global using ImageMagick.Formats; diff --git a/src/Verify.ImageMagick/Verify.ImageMagick.csproj b/src/Verify.ImageMagick/Verify.ImageMagick.csproj index af946a4e..30b25d9d 100644 --- a/src/Verify.ImageMagick/Verify.ImageMagick.csproj +++ b/src/Verify.ImageMagick/Verify.ImageMagick.csproj @@ -3,6 +3,7 @@ net48;net6.0;net7.0;net8.0;net9.0;net10.0 + diff --git a/src/Verify.ImageMagick/VerifyImageMagick_Pdf.cs b/src/Verify.ImageMagick/VerifyImageMagick_Pdf.cs index 68e97b4a..2127aac0 100644 --- a/src/Verify.ImageMagick/VerifyImageMagick_Pdf.cs +++ b/src/Verify.ImageMagick/VerifyImageMagick_Pdf.cs @@ -16,8 +16,23 @@ internal static ConversionResult Convert(string? name, Stream stream, IReadOnlyD var magickSettings = context.MagickReadSettings(); magickSettings.Format = magickFormat; var password = context.PdfPassword(); + var includePdf = !context.IsTargetExcluded("pdf"); if (password != null) { + // Checked before rendering, which is the expensive part, so the failure is immediate. + // An encrypted document cannot have a deterministic pdf snapshot: the trailer /ID seeds + // the encryption key, so neutralizing it would leave the document undecryptable. Rather + // than silently omitting the target, which would make the snapshot set differ from an + // unencrypted document for no visible reason, this is explicit. + if (includePdf) + { + throw new( + """ + A password protected pdf cannot produce a deterministic pdf target, since the trailer /ID seeds the encryption key and neutralizing it would leave the document undecryptable. + Exclude the pdf target to verify only the rendered pages: ExcludeTargets("pdf") + """); + } + magickSettings.SetDefines( new PdfReadDefines { @@ -25,6 +40,10 @@ internal static ConversionResult Convert(string? name, Stream stream, IReadOnlyD }); } + // Made seekable so the source document can be re-read for the pdf target after + // MagickImageCollection has consumed it. + stream = WrapStream(stream); + using var images = new MagickImageCollection(); images.Read(stream, magickSettings); var count = images.Count; @@ -47,6 +66,22 @@ internal static ConversionResult Convert(string? name, Stream stream, IReadOnlyD streams.Add(memoryStream); } - return new(null, streams.Select(_ => new Target("png", _, name))); + List targets = []; + + // The pdf snapshot is always the full document, regardless of PagesToInclude, which trims + // only the rendered pages above. Mirrors the svg target in ConvertSvg, which likewise emits + // the source document alongside the render. + if (includePdf) + { + stream.Position = 0; + targets.Add( + new("pdf", PdfNormalizer.Normalize(stream), name, performConversion: false) + { + BypassComparersForSubsequentOnDifference = true + }); + } + + targets.AddRange(streams.Select(_ => new Target("png", _, name))); + return new(null, targets); } } \ No newline at end of file