mirror of
https://github.com/storytold/winprint.rs.git
synced 2026-10-13 15:09:10 +00:00
test: add visual regression test
This commit is contained in:
1 parent
68fb7b3acb
commit
cf83253d8f
14 files changed
+1115
No files matched your search
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 - ")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
Executable
+460
@@ -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())
|
||||
Reference in new issue
Block a user