test: add visual regression test

This commit is contained in:
ArcticLampyrid committed 2026-04-26 03:44:06 +08:00
1 parent 68fb7b3acb
commit cf83253d8f
14 files changed
+1115

No files matched your search

+6
View File
@@ -65,6 +65,12 @@ sigstore-verify = { version = "0.6.5", default-features = false }
ctor = "0.10"
env_logger = "0.11"
winprint = { path = ".", features = ["test-utils"], default-features = false }
anyhow = "1"
futures = "0.3"
image = { version = "0.25", default-features = false, features = ["png"] }
image-compare = "0.5"
print_raster = "0.1"
tiff = "0.11"
[features]
default = ["pdfium"]
+23
View File
@@ -13,6 +13,29 @@ This crate interfaces with several Windows API functions, necessitating the use
### Testing on Windows
Just run `cargo test` on a Windows machine, null printer will be automatically created and used for testing. The tests cover basic printer operations like getting printer information, writing to the printer, etc.
Three visual regression tests also run under the `test-utils` feature (plus
`pdfium` for the PDF test):
- `tests/visual_regression_pdfium.rs` — `PdfiumPrinter`
- `tests/visual_regression_xps.rs` — `XpsPrinter`
- `tests/visual_regression_image.rs` — `ImagePrinter`
Each prints the matching origin file from
`test_data/visual_regression/<kind>/origin.*` through a virtual Microsoft PWG
Raster printer, decodes the PWG bitstream, and SSIM-compares each page
against the corresponding `expected.tiff` rendered by a deliberately
**different** renderer (Poppler for PDF, libgxps for XPS, ImageMagick for
Image). That second-source property means a shared regression between
renderer and baseline would have to coincidentally produce matching pixels
for the test to silently pass. Set `WINPRINT_DUMP_DIR=<path>` to have failing
tests dump the actual/expected page PNGs for inspection.
Day-to-day development never needs to regenerate these fixtures — the repo
ships them and `cargo test` consumes them as-is. If you do need to rebuild
them (e.g. after tweaking the source drawing or bumping a baseline renderer),
run `python utils/gen_visual_regression_data/run.py`; see the README in that
directory for tool prerequisites and determinism guarantees.
For testing with multiple features in one command, [`cargo hack`](https://github.com/taiki-e/cargo-hack) may be used for convenience.
### Testing on Linux Host
+161
View File
@@ -0,0 +1,161 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R /F3 4 0 R /F4 6 0 R /F5 7 0 R /F6 8 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/BaseFont /Helvetica-Oblique /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
>>
endobj
5 0 obj
<<
/Contents 15 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
6 0 obj
<<
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F4 /Subtype /Type1 /Type /Font
>>
endobj
7 0 obj
<<
/BaseFont /Times-Roman /Encoding /WinAnsiEncoding /Name /F5 /Subtype /Type1 /Type /Font
>>
endobj
8 0 obj
<<
/BaseFont /Symbol /Name /F6 /Subtype /Type1 /Type /Font
>>
endobj
9 0 obj
<<
/Contents 16 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
10 0 obj
<<
/Contents 17 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
11 0 obj
<<
/Contents 18 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
/ExtGState <<
/gRLs0 <<
/ca .6
>> /gRLs1 <<
/ca 1
>>
>> /Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
12 0 obj
<<
/PageMode /UseNone /Pages 14 0 R /Type /Catalog
>>
endobj
13 0 obj
<<
/Author (winprint.rs) /CreationDate (D:20231114221320+00'00') /Creator (anonymous) /Keywords () /ModDate (D:20231114221320+00'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (Visual regression test input) /Title (winprint.rs visual regression fixture) /Trapped /False
>>
endobj
14 0 obj
<<
/Count 4 /Kids [ 5 0 R 9 0 R 10 0 R 11 0 R ] /Type /Pages
>>
endobj
15 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 855
>>
stream
Gatn%9lJKG&;KZL'dI^L*erM(mpk@S#cWW;qLZX3C7Sa'S]XMPr-N&-G$(4TD+h-F`A>ic47fU$#X2hrI<YfqQ14cBJg;bR+>mm^Ddcp(:m*Xn!"q;/,bT0gFF\PDB_YTj!ZH(p9JB#_=KmJ$&j]a<p1hDUq;ac<p]T$cpa"8\nbK`^;)Fu/JTH*&84#BdTAXR+fH4;,J(^>5^/r@!;#Z"9Wdcf:MaU8=a"s5T=>PeX\(Ob(ZT.0$(FlO`)2_MS]N3hq9gBXGE%n?>(eu<87S=ooNP])EpF?t-CUR-/\.lYD`\e[/MCrWhqbfQY]NH0gPf`aB`mOtC?3,q.ZK!/BW/)U/jl8rA;)>osD,iGeW7@&hR1i3W/=.04M?aP)9n^ZI%V&O+jjao",@'W^'a)UdpZoJFJrA72N]YbFWO@\2f*&;?eQ)k=U8BRBZ\bLl@TaE*k0m(EMkUp*H]$k&Q(3^n/)WkO&`>#XMrqBT`bR5/<of2_YQqDF")C(C(O9Y)]Z<0Dr*&L^LDNFm\\@dt)$t:.1j>T&K"<e,_!G??2(rB=JVeIb26qXJ2pEs5hp':[@T5P?oc-REkCd%o*c<1lXG@[L7u2BPp3@_lrL3A!3M$`pI@X)%HZm6)[^3hVZlQuJeRkEX`!<ai.@[ab,U7nR]Zg9PH1Pu2ZT%V;WHNFU1R`BYL!)!eL-e0rA*IWZVS5/?>NX*HgMQm'<@pVD<,/d[$lbYuXWq^+/t1#6QKJCpCcMUqfK@`s"!ah=_fDQUmUfmR*O7+bh[ADNcNTIA]i*WU*gO1fVHbcsoA%pA$/6^&WfLf\Wj^WheaZ``)VEWU:M4"b2st@(Sph!]i\)8Vo3V~>endstream
endobj
16 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1241
>>
stream
Gatm:9lo#Z&A@ZcqWPHUaaAU!(2"Y#X1Z%s6e,LjO^Se7$V?J6D>)S"O+]3#(!<LZTNZlChg=08h(8gFhlO6W28I`N5(7(Y36[](,`sdmL$REF0[>o+5WC]#0d+>P,ootL,Y(pG*)YL):ehU?PQoBhd%!Jb@<k%&7""%S(7t]8&YCkRGPLqtgI[Nt.-?W7;GK3:#%hX1S,(Y?VTh$jo`pk4LY;LKXd)O`\bs3M$9>%c"0fB_otV)WBY1q/a.S^\`mcG3[,[0tiKblA>RU>OB@"9uo)rm_^]PniAh+BV2IZRfO&b_5_m4N@n!eH&6h0LnB^g22r3M)GOcki_L)VfrJLY->[K?hpruW.uPB<kAOg2Rm)RM4e=t5Oi>[A/u!WdTjdr1lIn)4&OS*T=#7pqQ5O!aQ7NtausW<YP84_EZn`ZmrK<G+n[pr/=a^+Z=O3[1Ef;Ym6B;uuU_QI'c:(Nl.i4]'.P<8(G5A5_k(8/Xjl&?ItL@I<C(qdQ,WLS=Jm/.^Zq0j5JH4tK.'`Vt0[=79T6MAY$#GjcI<)#(&HqDtE]\>P>hOcEm*,*gX,fH!s&adaZ?.t.VH;gG!rK2TmH6J\@<,=ZZ#f`4uBMLcXf"F??l-BDRF/,)F^ki8")@o);`m&aVQOCTNZLI\4cj'#m_j7^R]MpE@/CaU7..^3.:2BFp9LNN2&_Y:(NDo-U!\P8NS?S\Q=AXW?OMY[GdK$n=71cuZgNJp=qUSBPW=Bro+3Yp>P\;nON/?WK&*Ac`\]r;Tq(eQkMe*S'_W(%4YC>_uXK4#ZH"r]nj,(A"'PXc2:5g/ZeX$-mim"$+\]5sL.Z_'=`?Z_CEb+Qcq:8E[(pu.t5>(:G@^.Mj`g2dE!,Uh#IE82FV&M.nQ'aCq_9#8(f]*Ga$?u665=I'P7*I;4VAmEAbHZ_NgIbfG/Wku/Qku_0R#;oG;33#s0O&hs/19$GD9$.n,O%#>l!"!ok(3o7U?=_\.Z$B;thB[B8H"[QSYJ-H\g@rFR@4MhSGhS?"Qhj/9lO-gL/48d$nPtT6PC*E&/<m""C)[cV"ZF(WKd)R&C(GL^pC1(nNIK9'7Gr7$L$)9NOpmo0b1CbaU..\[/ZO7d!ee,:M/A)&p1QkuF]0#N*aZ^X=[B_N2V;@*ncG9nOrBV%FSbkV(j]&&>2#dJrbtot:r8QMiO."F3:SQ9R3>T:$hu+np;?8Dj<]Q<OK'@YDAcb"9RJcAb2ICM9`>2_!,rS~>endstream
endobj
17 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1295
>>
stream
Gau0C9lCt@%)),5i2LC2@#*fj?3@RJoMF$up0n<9j476>a_CVCqdu[qR9_[9F"d`1=7da5aPi5fOMaTNn,5&iq]5Y1Z]HU&mR]NfgAOW.m3WU6&.>aXnj/'`C%-h:&(:QZ.2<>],)$Y3;hu-Q/ZR7hNpbe!Y_Wh*Z[F.Ae\2C2Mg`\.6H(G[.I`UnZ9"UkpIsK%I(4U844/7oo6U?0fA5ZrgAOUTG8K)'Z^Wu@[D`,9/r#A)hejP(a9F_^.QMaVC,N.cZET[+'YUc4519$FMh*H)P\;%b8JJM9p_&]IiFH\/ZCaMlBCU74JIADi2q.tg2lJSO63oaub%A^4,OJoY[n*1D82XB,h(2i#\"@V=lpO_[`6kFJNB!9km!R6F-:K6!0d07**QK-^Buhh[^j#>lJJ\!>Mat03aaR+k1%bXi'i*0I@&QXX_(O3Xl4&&t+i2iD$\PoW!k$%ITbcA,c8N%N2E],G7q-H]j,f?;/%)Ho2Gi>%c*,RCAKc$73KjV849FCpioooA?mm1P0%j$tM5-IK-qWcfqS?'nFH0X=2/KpI9BnLuj9L0m"F?&-6T+#NdT([9:)n8Pf,lm0ZWaSS2LI1mQE>$tQN^g"M=Wp9PQ\apdS-(A'm(*:?*^*8eKV,ng/&]4mL%cJPB:tRAc[4&l'A\A:MfO08>+R:2,n)B/7$5=Xq89GLt:li4/9]Inau]3'*nAmp)#!o!i$Hk=Bqjoa`3><I.)2kP;ci*[Id1e\S[Z4lC-OcTNW<FVtN(T$88SMO8B8gRE5dkiLRnG-/4uLAC74Q2d)]nWo7-V8%pG*mURTDha#aFKj^@s]5r6r]%tJ!f=mpii_7qH1=G'ACj.XtNE<[(bTL^DkZd_*SkZV-=kAuKh>PcM-jPJBHFoH`3IoQlp`9^UC`\H9<n,_UijW#EY_ff?7dNRH>XMHJKZQ>AZ"u+U@1,NH(p.>f(/sEZWjc*;J&/?q@@,Rt[Wk9=<`lF-kD`u(2+&4'VN'6)j);2idT@5b=RYB-\B:rj_p>tfRJ3&eCPuJElA3DcP&]D]fO?i?[GAFIKpqTJ(,_IpSbTH*Z&\\AC7,:IBW9_:9-dfc>Y8VP&Zt:/:_DrdVnQFfIs[n#dsI%)E*3`MX].Cb[O;8`4YOF"_4`4B'(bAsc\F]Q#Ho^.L?b`piM4@Nb$p*,>`2W+"&9m%(130r,S.1,ab0"A%Z^c=K&l()gh-FeVo13Nc0>aGRnp!>k0K*DQfI,bQ&d<hqsj=Cp'H0-iHTQ-4aoN?rSp,GT?p+-^crV,Z<bRdqA0Y,Q/V~>endstream
endobj
18 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 8881
>>
stream
GatU9ac:5YESS=4\1>Jjii&`U@;E[P`de;5km\e7":^3R8U:)Y8&VC.k.?CF-XcIq=0L:)\u*]o+l[Tm-2`u"n)+-'p%k.r?GH<Jr6"-oc0nuL?1%=05J7!hRkIhqg%p##V2T$^Fk'?jXk-A+J,'\;Nj12eUVCWu<JL0J03@P;]RK_Fot/!<!^us=D\deIj[oB5YJ0Psot6(.b%5kWB?aDYP1k//o7tq)rr#XMrn@t@rV3:_?[qVBa8c/,Vr*"$a.NCsp!(7d^A7*n]Z-L!md`b'r34]<%J?@-rVjE1qZY8&f1]39XB,+a`bb=>VtI*oZLY&/]5jM=<N_Tf_`CK'n5p]X^4]Y<#d_P9D_UM"WRMu@=fZ#>n_L1rY@2as3q;I\@9-!A$p<B=I@jKBf.+=!7^H!aKqi>1hC$kN<GNMXdGUg>'\;DV;Y(eYjk+83aB[_roD>=Q^2dF@2O`2YQai+Q5,hC4Z^#!p0l.FC+1:k6#%Q9M3"6IaDT8Tp>)t[<(u8:KR*jL'j0RqC3R)Z*3Ec]"[ass?.d_XVZX,XSV^6t!F1b9Thl:`<!6M4W@]l0RHH^&/.GG,f"\3-5.M\TE;d/F#"cFHg.A]_4N/*HrjdNUMfO+]0orY'2>JGoD)f71/&5:9;;GHC1V/^XY(>A+kWH3W+J%1<)dM:T8Shb3hXQ98Ci:F*08o-20fc\M/mrQXN?$#/\ap^69^mI8I6r!hS1<1_!-bbQ:/D''\ehuAk6SM0f>l/bj#TQ>sSO/)BcS]"DFn$#l%F]QGNY"nPP=n#:IS;6c.sTCB;L7F;ci3DG3A2AM)=jB/D$QDPQ1:4UDXPn=.pSMXXtVKca.,c6U.tb+'3:tVHh[kkNQZVY*PTr@m]ihSCsOQTCQ0Aj"^WU20]V+;i'4I8(0d51ED#3K>1.9^'hl;k*AW>c`;rY,oeEH"n%^CQ;\Q'.qpS-dH1frZL&`UX\A4*Ug`&pSZkj+T<\8$*1_"@$I7gVNPVltWn@t4rD"_r`c)qA6(kYtT$f#fQ:24+O$`^X8#r8))_U8VKd*\mP@5)ZSmHGgr?>7@GVqPe"<8'a9ofaZXIQ_fF&H$X=jm5bZ@0:_j$5l1$,BI^@PZN3akm;RNMQQAW1'&6Eq>6tLq[<V_&K"V[_U;VnVj$?Akm.\5&u.]U0V4IHGA7nE.1MUK@-5p0oYiBfU""gpn3bsZ7gu2I_<;.I'.[lVfjddP>m$47#f^W@$KDgajtt)3<=[[`DE#eY>0UWkUEK:??;QH-OD+_"kN@7g1LH&iMni";,[eif:1CZ47Q1\50IMQ"iA%=b0A'k"n/2;R7MnO)O%HH(RTs#k4MmjV]u8&0CC)/GAR+/]`a_WAiL=oPlliIn%:uk1S]'ep1buO$)]'?89RfR#f<c5QHAG$M#X_;W5,XQ!Y(W2&1E4QUi,9Z#U83Z>@>2uJNZ8EG4dX[U;*+,uR,>6t=kDk_f&rPMSG\=_pGCGY3Wd(:S8u4'MYTDg+foI$KLkQQb4UXX2*1.a/oVgA+&VP"a>(GbbF)q$Nkf/TGF3XZXf3QAi<$L=hksH%'ho#sCH=_3YC0*DPY2NU?kE6TI='(s.2>4!(3=gCf$+BdZT[G-/tAl%*AI?+=m;\E>XYRUb+j]O?>&nOopOB@9!uHUP`QpDH'U=LMS4@m;VQo`,'NMgqnQLRK'`k#Y%U*1cAn525/qY=l^Bgj(kt4Vhi[cQ6*t[koS'ASABkIHoPg@t2&Q5u*B`[BO$CGH0r$Z%bonk[1hjaip@&T0iAR'sK/XA:@!RNb:N%qQE?38b'b#_i+j>3-#A^DNL/-UhXPY8/XpS^SL?UCQ&Metn)KUP)5kl@3?#Za9UPGaLe/4-5hC$kF's6R-LA-5GL%#RN=elKI4r>T()WTGnP(i=+&2A/6E.th\O#]q+VU4:kn?cG/^cghu3]F3u&5:?$.u]_\0dkP_ESZ:U)KH8%i+oC+_\aVrO=_UK@Hi9=Pb+)-GO$XYdoh#"9RjV[(:m!Gf`K#Q<].8$muq4W46ZJe[sR6t+&g%c7f*I4l?8Y9$-t'>eokbCYi570)]#t_biu5d=O<FX4<4R*gkb>b0Y+N_JS1SbJlV4Oo6Mt(B#5Zk;_8'm`cRTaTI[H/3Hr;=Ao*.:1iJc(.X)8H]fLFb&Z@SK.WA/gV-M8km.*Yg`Y3tX<^s8QnJ#o/G0M(LHH%8^7br&scAX21bh2=88Er&#rGFYR0_8n:ZE@T=V_qu"aJb\1(?BoRQ?At`EoF!"'b#gi,=(\Cnd(uZg^d)j/B*!CJ\*\kdO]-gA_[dG@6,>]YnAU(;(t0'%$o[n<H"7IeIqJ!)(SBR.!8:%2mUZ5@9DQm-"I<9+9p<$jK>(lL(?C#m6^LX3dbnZKjFgpnVo;EG*IH\@hS'&@f^7=)&K.CHaGSpacn4'j[SGYX3B45q?VJXY:iQ(,DB=hHX1Yh[g!s//4Z[@Y]W"3$o5tq\njI3_fMca?SS`COc.:-'0H`u\tifa;'moJ)*)uk]%-\,.l(sf)M_aX4,qU#)Oj3LBrl'6qrDRXb18o?i5+/B).h7i!2d!2ZXJgT&MXC_(Saif[I+.u8_6!WP>m=aaXa,s_&s0#q&2'"^LE27=i:_nMpN:G.n<d47U+b(oh)<d7UV,Dn-urKd6:rCm]]gRmO_rTZq2S_0QPi)ifSOp`YIgo@nGf\W&IEn\7aTe=Q,i/@L.UF#,1Z=dmfR8DVMj\#nqlXFDLZe?)cZB=`tK(&LK?2@?\;ckcV;I'="+G$kZnl0Y#)8QLG(M;e5'uH!hDWU(L$8*GMK\L9\1G':dlIYe\MP8H?^Z@h6lPY*6a5*2J]#'/9UOPVgO6XKmX1K^7R3%'a1a0U9=M_PAL4`hm<k1otV"W#IjD)mh\HP&4\XMSl[4*K),')4?G(#=s@_eK#FkgS%^[+bZjjUUMGrY-#*ad\A?1XN6Q!Fp*`WCu\+D0^B?O/HSjE7L@[2[+iUMpkE4*4%?G>U8!+c^oi`f(riHHL1qjK6sulA4N"8*FC0!'Vu%@_[ps9p%Y#e*_uZ0Dj/B$]1IZtR^``31<E`-QDFA2Q406F'2bO5R(o/Udap*p0rE_&tQmsFV":"`IZY[=u\1rY7*2Wst0HK\Im'<lZ/JL028"!3u$lo#)>rLIGh!R8a\7&LtNY,^X_7T'>V8U^.=Amd"29LR90V`npSn>ReKRRo"f!f-G(tO4KQ;pGP<RN<SaLm*M(sJl#-kY.S[Moi?LLGTm7Y8HG(E$f'pM$f_c06o.8qMU+//`"blE/=>Ymu^]8MIRe6P!5e[+F+r2f5X7LK]WpXiMhD#amSG>4u62Grs#s0jKjH8b-8sf41'J;kbNS__iR+/4if8&jH9PZ+_7=^t<Nl%!K,!5=JOn?q-@_A%9KgP?7o&h2<E]C(@SE*#Pt^AWs[]4aZOY-`=)3-jKs">\0*Z=.-MYk/h=IM*&_/YtZ(o`@]QI_TM\/),V.XDNuiT9O0pj@-0L(/'<Hph,W->;Gq4b2r.]C_B%[P(c*k7;_"NH'R&:C9FGlKD"Du3QAV^9AtC>.f!f-/R"k6bSZ=W9C(]On&ZaY@##$8$S'@[=PC7d_QH`@W`p.`+!Rm-=FnKD*1UYaX2g7'k7N!lhf6(Y'&##FcgW$T&e"[>=L>-(K6(AUW2B5M:DoM!bMu_<<pPc]bQ=S]c1cc&GgO)$^K.51pqeYj^mBo&8D6.Wq#VZ_*Tb2FKZj<EDh7;NIh%&s))KUD%UdeiZ^t(V*D(KSF#V\71<F+'.bKY)][.##h`U+KCL5\?W!m@\D!mE2#VFCkG/]6VTTratB/h3s4K34B<daG_=dW!%.CkUs<^l0]A"VXLC-a,#C7[Du#Bj3#YImIVqmrgJT'uF)A#GsoR0.\Qp.PV8JG:*F,@4Y$#WN[^PA]IZuDSq&9bR!cH&B>;!APZplVr;'HZ+M+C>Tp#D1.n%d,`S28__t2FdU/*p,T3Gp!4:L]U]])?r0N<<<CVgm=gj;qoA%hthi@56.]tI.V<(p:J?,gmQke_t<*X.C8tJ$l20/BFe6([i&R^^j%p:3kPDji:CtLEn8q94g:h>p%?J,Sdbja]CEfUi@&;W#GB;/'0<Z,pT)eDGE0GU`q.g)]klY3`mW@?A!62h7p*u4E/\PkjLdrSGtI61t786(hSZ?A*6$bbXfktqKjnspr)X=_Q(8$AMQ)@P>J-ic&bGmPfn)>=ui;c@1[4%=b:O\N,SjB](Z0nuU'`Y*KS!J;>!a=ardYHT7<*Mu=E0KCBE-!R`WFj%77mY5_X*%tZQk6L/I-\j/;jUh^e_\A'?2a9pF#Y\<``o'CG7^J7Jj[Ua9J>l*D7VeudbXE\kfEQJ_EY0-eLX;-md<D^GjERlQ;9D=8emu8GOI*6X$^_T(+/N3B#&/qX_$s1R1cbI'4%d$hbS9`rE$$rs-?mcBO&@S1K+jlf`)bNI`j`3+[pc^C/qPjSPV[*6V29d+YYeXS(P(b<JKeK@pb\A,qjUJ9K$p..ZD98X89`C(KQ7)-h"W3?/M]VOV.Hi"bcR+fTGVqU9U]*(,0IW$N_V2.Z?/6l?edLQGjoR7Y*06dm%V7'r^fJ^iH;&nPDd54:gn]rE6='q\47k^5b[CpX32]p#1J>dp5Vm#!X#@Bj^mgYUD_%G7b>b1N`&beHHe4gS/Mf@H#o4PPDd63]GI?h1Vui('b7QmEWMQ/CW.WIPQVjFm2_Hs"N3_.2Kg#sQ?BM,UQUPNXIEog`QlDGSS[DDj8p`6k%#..NJ"e'UDmg'j<?9Vlo*KR7AiD=So>d2>QCT9M0T\`e>0okAjIAJCZHZn6]F'0^#(5nbhs,E!(`l!'BGJ#/U"GD;78kF!WsBPgmL^,+Nj>.kE3gh:ae>8+9I$Q243[JmA#c$TYS*of\W#20!_)*"`b3>AZGBu=bk"KHc6f@"`aX2?5HU64Y'hAb&[6'oPkJ-fokRhLoE6KCOUS8+;[9\CA$T@`DQaC'@[u?ol1QX3&iO/*lBh#hE2iDqR?MLine truncated
endobj
xref
0 19
0000000000 65535 f
0000000061 00000 n
0000000142 00000 n
0000000249 00000 n
0000000361 00000 n
0000000476 00000 n
0000000681 00000 n
0000000786 00000 n
0000000895 00000 n
0000000972 00000 n
0000001177 00000 n
0000001383 00000 n
0000001645 00000 n
0000001715 00000 n
0000002025 00000 n
0000002105 00000 n
0000003051 00000 n
0000004384 00000 n
0000005771 00000 n
trailer
<<
/ID
[<b4f4b7def201a8dc448f13edf7db5997><b4f4b7def201a8dc448f13edf7db5997>]
% ReportLab generated PDF document -- digest (opensource)
/Info 13 0 R
/Root 12 0 R
/Size 19
>>
startxref
14744
%%EOF
Binary file not shown.
Binary file not shown.
+114
View File
@@ -0,0 +1,114 @@
use futures::{executor::block_on, io::BufReader as FutBufReader, AsyncReadExt};
use image::{DynamicImage, GrayImage, RgbImage, RgbaImage};
use print_raster::{
model::cups::{CupsColorOrder, CupsColorSpace},
reader::{cups::unified::CupsRasterUnifiedReader, RasterPageReader, RasterReader},
};
use std::path::Path;
use std::{
fs::File,
io::{self, Read},
pin::pin,
};
use tiff::decoder::{Decoder as TiffDecoder, DecodingResult as TiffDecodingResult};
use tiff::ColorType;
/// Read every page of `path` as a PWG raster file.
pub fn load_pages_from_pwg(path: &Path) -> anyhow::Result<Vec<DynamicImage>> {
let mut bytes = Vec::new();
File::open(path)?.read_to_end(&mut bytes)?;
block_on(async move {
let reader = FutBufReader::new(futures::io::Cursor::new(&bytes[..]));
let reader = pin!(reader);
let reader = CupsRasterUnifiedReader::new(reader)
.await
.map_err(|e| anyhow::anyhow!("open CUPS/PWG raster: {e:?}"))?;
let mut pages: Vec<DynamicImage> = Vec::new();
let mut next = reader
.next_page()
.await
.map_err(|e| anyhow::anyhow!("read first page: {e:?}"))?;
while let Some(mut page) = next {
let header = page.header().clone();
if header.v1.color_order != CupsColorOrder::Chunky {
return Err(anyhow::anyhow!(
"unsupported color order: {:?}",
header.v1.color_order
));
}
let mut data = Vec::<u8>::new();
page.content_mut()
.read_to_end(&mut data)
.await
.map_err(io::Error::other)?;
let w = header.v1.width;
let h = header.v1.height;
let image = match (header.v1.color_space, header.v1.bits_per_pixel) {
(CupsColorSpace::sRGB, 24) | (CupsColorSpace::RGB, 24) => DynamicImage::ImageRgb8(
RgbImage::from_raw(w, h, data)
.ok_or_else(|| anyhow::anyhow!("invalid RGB pixel data"))?,
),
(CupsColorSpace::sGray, 8) | (CupsColorSpace::Gray, 8) => DynamicImage::ImageLuma8(
GrayImage::from_raw(w, h, data)
.ok_or_else(|| anyhow::anyhow!("invalid Gray pixel data",))?,
),
(cs, bpp) => {
return Err(anyhow::anyhow!(
"unsupported pixel data format: {:?} with {} bits per pixel",
cs,
bpp
))
}
};
pages.push(image);
next = page
.next_page()
.await
.map_err(|e| anyhow::anyhow!("advance page: {e:?}"))?;
}
Ok(pages)
})
}
/// Load a multi-page TIFF.
pub fn load_pages_from_tiff(path: &Path) -> anyhow::Result<Vec<DynamicImage>> {
let file = std::io::BufReader::new(std::fs::File::open(path)?);
let mut decoder = TiffDecoder::new(file)?;
let mut pages = Vec::new();
loop {
let (w, h) = decoder.dimensions()?;
let color = decoder.colortype()?;
let raw = decoder.read_image()?;
let image: DynamicImage = match (color, raw) {
(ColorType::RGB(8), TiffDecodingResult::U8(data)) => {
DynamicImage::ImageRgb8(RgbImage::from_raw(w, h, data).ok_or_else(|| {
anyhow::anyhow!("TIFF page {} dimensions / buffer mismatch", pages.len())
})?)
}
(ColorType::RGBA(8), TiffDecodingResult::U8(data)) => {
DynamicImage::ImageRgba8(RgbaImage::from_raw(w, h, data).ok_or_else(|| {
anyhow::anyhow!("TIFF page {} dimensions / buffer mismatch", pages.len())
})?)
}
(ColorType::Gray(8), TiffDecodingResult::U8(data)) => {
DynamicImage::ImageLuma8(GrayImage::from_raw(w, h, data).ok_or_else(|| {
anyhow::anyhow!("TIFF page {} dimensions / buffer mismatch", pages.len())
})?)
}
(ct, _) => return Err(anyhow::anyhow!("unsupported TIFF color type: {ct:?}")),
};
pages.push(image);
if !decoder.more_images() {
break;
}
decoder.next_image()?;
}
Ok(pages)
}
+6
View File
@@ -0,0 +1,6 @@
//! Test helpers shared across integration tests.
#![cfg(all(windows, feature = "test-utils"))]
pub mod loader;
pub mod print;
pub mod visual;
+70
View File
@@ -0,0 +1,70 @@
use image::DynamicImage;
use std::{path::Path, time::Duration};
use winprint::{
printer::PrinterDevice,
test_utils::file_device::{FilePrinterDevice, PwgRaster},
};
use super::loader;
pub fn print_pages<F>(print: F) -> Vec<DynamicImage>
where
F: FnOnce(&PrinterDevice),
{
let device = FilePrinterDevice::<PwgRaster>::new().expect("install virtual PWG printer");
print(device.device());
wait_for_stable_file(device.file_path(), Duration::from_mins(10))
.expect("PWG port file should settle after print");
let actual_pages =
loader::load_pages_from_pwg(device.file_path()).expect("decode PWG raster output");
return actual_pages;
}
/// Poll until `path` exists, is non-empty, and its size stops changing for
/// a few consecutive checks (i.e. the writer is done). Returns
/// `ErrorKind::TimedOut` if `timeout` elapses first.
fn wait_for_stable_file(path: &Path, timeout: Duration) -> std::io::Result<()> {
const STABLE_POLLS_REQUIRED: u32 = 3;
const POLL_INTERVAL: Duration = Duration::from_secs(1);
let deadline = std::time::Instant::now() + timeout;
let mut last_size: Option<u64> = None;
let mut stable = 0u32;
loop {
match std::fs::metadata(path) {
Ok(meta) if meta.len() > 0 => {
if Some(meta.len()) == last_size {
stable += 1;
if stable >= STABLE_POLLS_REQUIRED {
return Ok(());
}
} else {
stable = 0;
last_size = Some(meta.len());
}
}
Ok(_) => {
// Exists but still empty: keep the stability counter reset
// and wait for the first bytes to arrive.
stable = 0;
last_size = None;
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
stable = 0;
last_size = None;
}
Err(e) => return Err(e),
}
if std::time::Instant::now() >= deadline {
return Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
format!(
"timed out waiting for spooler to flush {} (last size = {:?})",
path.display(),
last_size
),
));
}
std::thread::sleep(POLL_INTERVAL);
}
}
+88
View File
@@ -0,0 +1,88 @@
//! Visual similarity comparison for regression tests.
use crate::common::loader;
use image::{imageops::FilterType, DynamicImage, Rgb, RgbImage};
use std::path::PathBuf;
pub const DEFAULT_MIN_SCORE: f64 = 0.90;
/// Letterbox `img` into a `(container_w, container_h)` canvas, preserving
/// its aspect ratio and padding the rest with white.
fn fit_and_pad(img: &RgbImage, container_w: u32, container_h: u32) -> RgbImage {
let (w, h) = img.dimensions();
let scale = (container_w as f64 / w as f64).min(container_h as f64 / h as f64);
let new_w = ((w as f64 * scale).round() as u32).clamp(1, container_w);
let new_h = ((h as f64 * scale).round() as u32).clamp(1, container_h);
let resized = image::imageops::resize(img, new_w, new_h, FilterType::Lanczos3);
if new_w == container_w && new_h == container_h {
// Fast path: no letterboxing needed.
return resized;
}
let mut canvas = RgbImage::from_pixel(container_w, container_h, Rgb([255, 255, 255]));
let x = ((container_w - new_w) / 2) as i64;
let y = ((container_h - new_h) / 2) as i64;
image::imageops::overlay(&mut canvas, &resized, x, y);
canvas
}
pub fn compare_single_page(actual: &DynamicImage, expected: &DynamicImage) -> f64 {
let actual_rgb = actual.to_rgb8();
let expected_rgb = expected.to_rgb8();
let (w, h) = expected_rgb.dimensions();
let resized_actual = fit_and_pad(&actual_rgb, w, h);
let result = image_compare::rgb_hybrid_compare(&resized_actual, &expected_rgb)
.expect("SSIM comparison failed");
result.score
}
/// Expectation for a single visual regression test.
pub struct VisualExpectation {
// Path of expected image, in TIFF format.
pub expected_path: PathBuf,
/// Minimum acceptable score per page.
pub min_score: f64,
}
impl VisualExpectation {
pub const fn new(expected_tiff_path: PathBuf) -> Self {
Self {
expected_path: expected_tiff_path,
min_score: DEFAULT_MIN_SCORE,
}
}
#[allow(dead_code)]
pub const fn with_min_score(mut self, v: f64) -> Self {
self.min_score = v;
self
}
pub fn assert(&self, actual_pages: &[DynamicImage]) {
let expected_pages =
loader::load_pages_from_tiff(&self.expected_path).expect("load expected TIFF");
assert_eq!(
actual_pages.len(),
expected_pages.len(),
"page count mismatch: actual={}, expected={}",
actual_pages.len(),
expected_pages.len()
);
let mut failures = Vec::new();
for (i, (actual_image, expected_image)) in
actual_pages.iter().zip(expected_pages.iter()).enumerate()
{
let score = compare_single_page(actual_image, expected_image);
if score < self.min_score {
failures.push(format!("page {}, score: {:.2}", i + 1, score));
}
}
assert!(
failures.is_empty(),
"visual regression failures:\n - {}",
failures.join("\n - ")
);
}
}
+36
View File
@@ -0,0 +1,36 @@
//! End-to-end visual regression for [`ImagePrinter`].
#![cfg(all(windows, feature = "test-utils"))]
mod common;
use common::print;
use common::visual::VisualExpectation;
use winprint::{
printer::{FilePrinter, ImagePrinter},
ticket::{
FeatureOptionPackWithPredefined, PredefinedMediaName, PrintCapabilities, PrintTicket,
},
};
#[test]
fn image_visual_regression() {
let _ = env_logger::try_init();
let visual_regression_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("test_data")
.join("visual_regression");
let expectation = VisualExpectation::new(visual_regression_dir.join("data.tiff"));
let actual_pages = print::print_pages(|device| {
let origin = visual_regression_dir.join("data.tiff");
let printer = ImagePrinter::new(device.clone());
let capabilities = PrintCapabilities::fetch(&device).unwrap();
let media_size = capabilities
.page_media_sizes()
.find(|x| x.as_predefined_name() == Some(PredefinedMediaName::ISOA4))
.unwrap();
let print_ticket: PrintTicket = media_size.into();
printer
.print(origin.as_path(), print_ticket)
.expect("failed to print the document");
});
expectation.assert(&actual_pages);
}
+36
View File
@@ -0,0 +1,36 @@
//! End-to-end visual regression for [`PdfiumPrinter`].
#![cfg(all(windows, feature = "test-utils", feature = "pdfium"))]
mod common;
use common::print;
use common::visual::VisualExpectation;
use winprint::{
printer::{FilePrinter, PdfiumPrinter},
ticket::{
FeatureOptionPackWithPredefined, PredefinedMediaName, PrintCapabilities, PrintTicket,
},
};
#[test]
fn pdfium_visual_regression() {
let _ = env_logger::try_init();
let visual_regression_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("test_data")
.join("visual_regression");
let expectation = VisualExpectation::new(visual_regression_dir.join("data.tiff"));
let actual_pages = print::print_pages(|device| {
let origin = visual_regression_dir.join("data.pdf");
let printer = PdfiumPrinter::new(device.clone());
let capabilities = PrintCapabilities::fetch(&device).unwrap();
let media_size = capabilities
.page_media_sizes()
.find(|x| x.as_predefined_name() == Some(PredefinedMediaName::ISOA4))
.unwrap();
let print_ticket: PrintTicket = media_size.into();
printer
.print(origin.as_path(), print_ticket)
.expect("failed to print the document");
});
expectation.assert(&actual_pages);
}
+37
View File
@@ -0,0 +1,37 @@
//! End-to-end visual regression for [`XpsPrinter`].
#![cfg(all(windows, feature = "test-utils"))]
mod common;
use common::print;
use common::visual::VisualExpectation;
use std::path::Path;
use winprint::{
printer::{FilePrinter, XpsPrinter},
ticket::{
FeatureOptionPackWithPredefined, PredefinedMediaName, PrintCapabilities, PrintTicket,
},
};
#[test]
fn xps_visual_regression() {
let _ = env_logger::try_init();
let visual_regression_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("test_data")
.join("visual_regression");
let expectation = VisualExpectation::new(visual_regression_dir.join("data.tiff"));
let actual_pages = print::print_pages(|device| {
let origin = visual_regression_dir.join("data.xps");
let printer = XpsPrinter::new(device.clone());
let capabilities = PrintCapabilities::fetch(&device).unwrap();
let media_size = capabilities
.page_media_sizes()
.find(|x| x.as_predefined_name() == Some(PredefinedMediaName::ISOA4))
.unwrap();
let print_ticket: PrintTicket = media_size.into();
printer
.print(origin.as_path(), print_ticket)
.expect("failed to print the document");
});
expectation.assert(&actual_pages);
}
@@ -0,0 +1,78 @@
# `gen_visual_regression_data`
Regenerates every fixture under `test_data/visual_regression/` from scratch.
> [!NOTE]
> **You usually don't need to run this.** The repo ships the fixtures
> pre-generated and version-controlled. `cargo test` uses them as-is, and any
> given run of this script produces byte-identical files, so re-running it
> just yields the same data that's already there.
## When to run
Re-generate the fixtures only when one of these applies:
- The drawing code in `run.py` changed (different colour swatches, new
pages, a new probe for a known regression, …).
- A new printer flavour is being added under `test_data/visual_regression/`
and needs matching fixtures.
- A bump in one of the upstream renderers produces a meaningfully different
baseline and the committed expected images need to be refreshed.
If you just want to run the tests, skip this script entirely.
## What it produces
```text
test_data/visual_regression/
├── data.pdf
├── data.tiff
└── data.xps
```
## Determinism
Byte-identical re-runs are non-negotiable — the fixtures are committed and
any non-determinism would churn the repo on every regeneration. The script
pins every known timestamp source:
- `SOURCE_DATE_EPOCH=1700000000` is set before any tool runs (reportlab and
Ghostscript's `xpswrite` both honour it for file timestamps / ZIP mtimes).
- reportlab runs in `invariant=True` mode so it doesn't embed the current
time, random IDs, or creator/producer fingerprints.
- All intermediate per-page TIFFs are written with `-tiffcompression none`
and TIFFs are packed with `tiffcp -c zip`, avoiding the tagged mtimes and
random-ordered dictionary entries that some encoders emit.
If two back-to-back runs on the same machine produce different bytes,
that's a bug worth chasing before committing fixtures.
## Dependencies
The script is pure Python 3, but it shells out to a handful of command-line
tools. On Arch Linux:
```bash
sudo pacman -S python python-reportlab poppler libtiff ghostscript
```
| Tool | Package (Arch) | Role |
| ----------- | ------------------ | ----------------------- |
| `python3` | `python` | Host language |
| `reportlab` | `python-reportlab` | Draws `pdf/origin.pdf` |
| `tiffcp` | `libtiff` | Packs TIFFs |
| `gs` | `ghostscript` | Converts PDF → XPS/TIFF |
The script fails fast with a friendly message if any of the binaries is
missing from `PATH`.
## Usage
From anywhere:
```bash
python utils/gen_visual_regression_data/run.py
```
Output goes straight into `test_data/visual_regression/`, overwriting any
existing files.
+460
View File
@@ -0,0 +1,460 @@
#!/usr/bin/env python3
from __future__ import annotations
import hashlib
import math
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
from reportlab.lib.units import mm
# ---------------------------------------------------------------------------
# Layout
# ---------------------------------------------------------------------------
SCRIPT_DIR = Path(__file__).resolve().parent
REPO_ROOT = SCRIPT_DIR.parent.parent
VR_DIR = REPO_ROOT / "test_data" / "visual_regression"
PAGE_SIZE = A4
PAGE_W, PAGE_H = PAGE_SIZE
# Baseline rendering DPI.
BASELINE_DPI = 300
TIFF_DOWN_SCALE_FACTOR = 4
# Pin reportlab / Ghostscript output timestamps for byte-stable output.
os.environ.setdefault("SOURCE_DATE_EPOCH", "1700000000")
# ---------------------------------------------------------------------------
# Tool discovery
# ---------------------------------------------------------------------------
REQUIRED_TOOLS = ("gs", "tiffcp")
def _require_tools() -> None:
missing = [t for t in REQUIRED_TOOLS if shutil.which(t) is None]
if missing:
raise SystemExit("Missing required tools: {}\n".format(", ".join(missing)))
def _run(cmd: list[str]) -> None:
"""Run a subprocess, surfacing a readable error on failure."""
subprocess.run(cmd, capture_output=True, text=True, check=True)
# ---------------------------------------------------------------------------
# data.pdf — reportlab drawing
# ---------------------------------------------------------------------------
def _draw_page_header(c: canvas.Canvas, page_no: int, title: str) -> None:
c.setFillColor(colors.HexColor("#1f3a5f"))
c.rect(0, PAGE_H - 22 * mm, PAGE_W, 22 * mm, fill=1, stroke=0)
c.setFillColor(colors.white)
c.setFont("Helvetica-Bold", 18)
c.drawString(15 * mm, PAGE_H - 14 * mm, title)
c.setFont("Helvetica", 10)
c.drawRightString(PAGE_W - 15 * mm, PAGE_H - 14 * mm, f"Page {page_no}")
def _draw_footer(c: canvas.Canvas, page_no: int) -> None:
c.setFillColor(colors.HexColor("#777777"))
c.setFont("Helvetica-Oblique", 8)
c.drawCentredString(
PAGE_W / 2,
10 * mm,
f"winprint.rs visual regression fixture — page {page_no}",
)
def _page_color_swatches(c: canvas.Canvas) -> None:
"""Page 1: a grid of saturated primary / secondary colour swatches."""
_draw_page_header(c, 1, "Color Swatches")
swatches = [
("Red", "#E53935"),
("Green", "#43A047"),
("Blue", "#1E88E5"),
("Cyan", "#00ACC1"),
("Magenta", "#D81B60"),
("Yellow", "#FDD835"),
("Orange", "#FB8C00"),
("Purple", "#8E24AA"),
("Teal", "#00897B"),
("Pink", "#EC407A"),
("Lime", "#C0CA33"),
("Indigo", "#3949AB"),
]
margin = 20 * mm
grid_w = PAGE_W - 2 * margin
grid_h = PAGE_H - 60 * mm
cols, rows = 3, 4
cell_w = grid_w / cols
cell_h = grid_h / rows
for i, (name, hexv) in enumerate(swatches):
col = i % cols
row = i // cols
x = margin + col * cell_w
y = PAGE_H - 40 * mm - (row + 1) * cell_h
c.setFillColor(colors.HexColor(hexv))
c.rect(x + 3, y + 3, cell_w - 6, cell_h - 14, fill=1, stroke=0)
c.setFillColor(colors.black)
c.setFont("Helvetica-Bold", 11)
c.drawString(x + 6, y, f"{name} {hexv}")
_draw_footer(c, 1)
def _page_text_styles(c: canvas.Canvas) -> None:
"""Page 2: varied fonts, sizes, weights, colours, alignments."""
_draw_page_header(c, 2, "Text Styles")
y = PAGE_H - 40 * mm
c.setFillColor(colors.black)
c.setFont("Helvetica", 8)
c.drawString(
20 * mm, y, "Helvetica 8pt — The quick brown fox jumps over the lazy dog."
)
y -= 8 * mm
c.setFont("Helvetica", 12)
c.drawString(
20 * mm, y, "Helvetica 12pt — The quick brown fox jumps over the lazy dog."
)
y -= 10 * mm
c.setFont("Helvetica-Bold", 16)
c.drawString(20 * mm, y, "Helvetica-Bold 16pt — SHARP EDGES")
y -= 12 * mm
c.setFont("Helvetica-Oblique", 14)
c.drawString(20 * mm, y, "Helvetica-Oblique 14pt — slanted")
y -= 12 * mm
c.setFont("Courier", 12)
c.drawString(20 * mm, y, "Courier 12pt — monospace 0123456789")
y -= 12 * mm
c.setFont("Times-Roman", 14)
c.drawString(20 * mm, y, "Times-Roman 14pt — serif")
y -= 14 * mm
# Coloured text.
c.setFillColor(colors.HexColor("#C62828"))
c.setFont("Helvetica-Bold", 14)
c.drawString(20 * mm, y, "Red bold text")
c.setFillColor(colors.HexColor("#2E7D32"))
c.drawString(70 * mm, y, "Green bold text")
c.setFillColor(colors.HexColor("#1565C0"))
c.drawString(120 * mm, y, "Blue bold text")
y -= 14 * mm
# Alignment samples.
c.setFillColor(colors.black)
c.setFont("Helvetica", 10)
c.drawString(20 * mm, y, "Left aligned")
c.drawCentredString(PAGE_W / 2, y, "Centre aligned")
c.drawRightString(PAGE_W - 20 * mm, y, "Right aligned")
y -= 14 * mm
# Extended-Latin & symbols — reportlab's built-in fonts don't cover CJK,
# so we stay Latin-only to avoid needing external font files.
c.setFont("Helvetica", 10)
for line in (
"Latin-1 supplement: À Á Â Ã Ä Å Æ Ç È É Ê Ë Ì Í Î Ï",
"Symbols: € £ ¥ © ® ™ ± × ÷ ≈ ≠ ≤ ≥",
"Numerals: 0 1 2 3 4 5 6 7 8 9",
):
c.drawString(20 * mm, y, line)
y -= 8 * mm
# Filled paragraph block (coloured rectangle with clipped text).
c.setFillColor(colors.HexColor("#FFF3E0"))
c.rect(20 * mm, 30 * mm, PAGE_W - 40 * mm, 35 * mm, fill=1, stroke=1)
c.setFillColor(colors.HexColor("#333333"))
c.setFont("Helvetica", 9)
txt = c.beginText(22 * mm, 55 * mm)
for line in (
"This paragraph is drawn inside a coloured rectangle to exercise",
"anti-aliased edges, fill patterns, and clipped text rendering on the",
"PWG raster path. Any visual regression in glyph kerning, colour fill,",
"or stroke width should show up as an SSIM drop on this page.",
):
txt.textLine(line)
c.drawText(txt)
_draw_footer(c, 2)
def _page_shapes(c: canvas.Canvas) -> None:
"""Page 3: filled circles, stroked rectangles, diagonal lines, polygons."""
_draw_page_header(c, 3, "Shapes & Strokes")
# Filled circles.
cx0 = 50 * mm
cy = PAGE_H - 70 * mm
for i, (r, hv) in enumerate(
(
(14 * mm, "#FFB300"),
(11 * mm, "#039BE5"),
(8 * mm, "#6A1B9A"),
(5 * mm, "#00897B"),
)
):
c.setFillColor(colors.HexColor(hv))
c.circle(cx0 + i * 35 * mm, cy, r, fill=1, stroke=0)
# Stroked rectangles with varying line widths.
y = PAGE_H - 110 * mm
for i, w in enumerate((0.5, 1.0, 2.0, 3.5, 6.0)):
c.setStrokeColor(colors.HexColor("#37474F"))
c.setLineWidth(w)
c.rect(20 * mm + i * 32 * mm, y, 25 * mm, 15 * mm, fill=0, stroke=1)
c.setFillColor(colors.HexColor("#37474F"))
c.setFont("Helvetica", 8)
c.drawString(20 * mm + i * 32 * mm, y - 5 * mm, f"{w} pt")
# Diagonal lines.
c.setStrokeColor(colors.HexColor("#E53935"))
c.setLineWidth(1.2)
for i in range(12):
x1 = 20 * mm + i * 14 * mm
c.line(x1, 100 * mm, x1 + 10 * mm, 140 * mm)
# Triangle via path.
p = c.beginPath()
p.moveTo(60 * mm, 70 * mm)
p.lineTo(110 * mm, 70 * mm)
p.lineTo(85 * mm, 110 * mm)
p.close()
c.setFillColor(colors.HexColor("#FFD54F"))
c.setStrokeColor(colors.HexColor("#5D4037"))
c.setLineWidth(2)
c.drawPath(p, fill=1, stroke=1)
# Hexagon via path.
p = c.beginPath()
p.moveTo(120 * mm, 70 * mm)
for n in range(1, 7):
angle = 2 * math.pi * n / 6
p.lineTo(
150 * mm + 20 * mm * math.cos(angle),
90 * mm + 20 * mm * math.sin(angle),
)
p.close()
c.setFillColor(colors.HexColor("#81C784"))
c.setStrokeColor(colors.HexColor("#1B5E20"))
c.drawPath(p, fill=1, stroke=1)
_draw_footer(c, 3)
def _page_gradient(c: canvas.Canvas) -> None:
"""Page 4: faux gradients, overlapping translucent circles, grey ramp."""
_draw_page_header(c, 4, "Gradients & Overlaps")
strips = 200
x0 = 20 * mm
w = PAGE_W - 40 * mm
step = w / strips
# Horizontal gradient strip: red → yellow → green.
y0 = PAGE_H - 80 * mm
h = 30 * mm
for i in range(strips):
t = i / (strips - 1)
if t < 0.5:
u = t / 0.5
r = 0xE5 * (1 - u) + 0xFD * u
g = 0x39 * (1 - u) + 0xD8 * u
b = 0x35 * (1 - u) + 0x35 * u
else:
u = (t - 0.5) / 0.5
r = 0xFD * (1 - u) + 0x43 * u
g = 0xD8 * (1 - u) + 0xA0 * u
b = 0x35 * (1 - u) + 0x47 * u
c.setFillColorRGB(r / 255, g / 255, b / 255)
c.rect(x0 + i * step, y0, step + 0.5, h, fill=1, stroke=0)
# Vertical-ish gradient strip: blue → white.
y0 = PAGE_H - 130 * mm
for i in range(strips):
t = i / (strips - 1)
r = 0x1E * (1 - t) + 0xFF * t
g = 0x88 * (1 - t) + 0xFF * t
b = 0xE5 * (1 - t) + 0xFF * t
c.setFillColorRGB(r / 255, g / 255, b / 255)
c.rect(x0 + i * step, y0, step + 0.5, 20 * mm, fill=1, stroke=0)
# Overlapping translucent circles (alpha blending).
c.saveState()
c.setFillAlpha(0.6)
for i, hv in enumerate(("#E53935", "#1E88E5", "#43A047")):
c.setFillColor(colors.HexColor(hv))
c.circle(70 * mm + i * 25 * mm, 65 * mm, 25 * mm, fill=1, stroke=0)
c.restoreState()
# Black-to-white grayscale ramp.
ramp_y = 30 * mm
ramp_h = 12 * mm
steps = 16
sw = w / steps
for i in range(steps):
v = i / (steps - 1)
c.setFillColorRGB(v, v, v)
c.rect(20 * mm + i * sw, ramp_y, sw + 0.5, ramp_h, fill=1, stroke=0)
_draw_footer(c, 4)
def generate_pdf(out_path: Path) -> None:
out_path.parent.mkdir(parents=True, exist_ok=True)
c = canvas.Canvas(
str(out_path),
pagesize=PAGE_SIZE,
invariant=True, # strip dynamic metadata for byte-stable output
)
c.setTitle("winprint.rs visual regression fixture")
c.setAuthor("winprint.rs")
c.setSubject("Visual regression test input")
_page_color_swatches(c)
c.showPage()
_page_text_styles(c)
c.showPage()
_page_shapes(c)
c.showPage()
_page_gradient(c)
c.showPage()
c.save()
# ---------------------------------------------------------------------------
# data.tiff — Ghostscript tiff24nc
# ---------------------------------------------------------------------------
def convert_pdf_to_tiff(pdf_path: Path, out_path: Path) -> None:
"""Render the PDF into a multi-page TIFF via Ghostscript's tiff24nc device."""
out_path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile() as gs_out:
_run(
[
"gs",
"-dNOPAUSE",
"-dBATCH",
"-dSAFER",
"-dQUIET",
"-sDEVICE=tiffscaled24",
"-dTIFFDateTime=false",
f"-r{BASELINE_DPI*TIFF_DOWN_SCALE_FACTOR}",
f"-dDownScaleFactor={TIFF_DOWN_SCALE_FACTOR}",
f"-sOutputFile={gs_out.name}",
str(pdf_path),
]
)
_run(["tiffcp", "-c", "zip", str(gs_out.name), str(out_path)])
# ---------------------------------------------------------------------------
# data.xps — Ghostscript xpswrite
# ---------------------------------------------------------------------------
def convert_pdf_to_xps(pdf_path: Path, out_path: Path) -> None:
"""Convert the PDF into an XPS package via Ghostscript.
Ghostscript's xpswrite device honours SOURCE_DATE_EPOCH for the ZIP
entries' mtime, which keeps the resulting .xps byte-stable. -dSAFER
disables any surprising PostScript side-effects from the input.
"""
out_path.parent.mkdir(parents=True, exist_ok=True)
_run(
[
"gs",
"-dNOPAUSE",
"-dBATCH",
"-dSAFER",
"-dQUIET",
"-sDEVICE=xpswrite",
f"-sOutputFile={out_path}",
str(pdf_path),
]
)
# ---------------------------------------------------------------------------
# Utilities
# ---------------------------------------------------------------------------
def calculate_sha256(filename):
"""Calculate the SHA256 hash of a file."""
h = hashlib.sha256()
with open(filename, "rb") as file:
# Read file in chunks to handle large files efficiently
while chunk := file.read(8192):
h.update(chunk)
return h.hexdigest()
# ---------------------------------------------------------------------------
# Orchestration
# ---------------------------------------------------------------------------
def main() -> int:
_require_tools()
VR_DIR.mkdir(parents=True, exist_ok=True)
files_before = {
p.name: calculate_sha256(p) for p in VR_DIR.iterdir() if p.is_file()
}
pdf_path = VR_DIR / "data.pdf"
tiff_path = VR_DIR / "data.tiff"
xps_path = VR_DIR / "data.xps"
print("[1/3] Generating PDF...")
generate_pdf(pdf_path)
print("[2/3] Generating TIFF...")
convert_pdf_to_tiff(pdf_path, tiff_path)
print("[3/3] Generating XPS...")
convert_pdf_to_xps(pdf_path, xps_path)
files_after = {p.name: calculate_sha256(p) for p in VR_DIR.iterdir() if p.is_file()}
print()
print("wrote under:", VR_DIR)
keys = set(files_before.keys()) | set(files_after.keys())
for name in sorted(keys):
before = files_before.get(name)
after = files_after.get(name)
if before is None:
print(f" + {name} (new)")
print(f" SHA256: {after}")
elif after is None:
print(f" - {name} (deleted)")
elif before != after:
print(f" * {name} (changed)")
print(f" SHA256: {after}")
else:
print(f" {name} (unchanged)")
print(f" SHA256: {after}")
return 0
if __name__ == "__main__":
sys.exit(main())