From be27d0d8f72b2570fdbb4369e5176725896bb382 Mon Sep 17 00:00:00 2001 From: Ev Haus Date: Sun, 12 Oct 2025 14:52:22 -0700 Subject: [PATCH] feat: adds combinePages option to diff per-page (#97) --- src/compare-pdf-to-snapshot.test.ts | 7 +- src/compare-pdf-to-snapshot.ts | 84 +++++++++++++++--- .../two-page-separate-pages_1.png | Bin 0 -> 21196 bytes .../two-page-separate-pages_2.png | Bin 0 -> 21525 bytes 4 files changed, 76 insertions(+), 15 deletions(-) create mode 100644 src/test-data/pdf2png-expected/__snapshots__/two-page-separate-pages_1.png create mode 100644 src/test-data/pdf2png-expected/__snapshots__/two-page-separate-pages_2.png diff --git a/src/compare-pdf-to-snapshot.test.ts b/src/compare-pdf-to-snapshot.test.ts index a3e2ca6..10a3604 100644 --- a/src/compare-pdf-to-snapshot.test.ts +++ b/src/compare-pdf-to-snapshot.test.ts @@ -115,7 +115,12 @@ describe('comparePdfToSnapshot()', () => { })) it('two-page.pdf', () => testPdf2png(twoPage, 'two-page')) it('two-page.pdf buffer', () => readFile(twoPage).then((x) => testPdf2png(x, 'two-page'))) - }) + it('two-page-separate-pages (combinePages: false)', () => { + return testPdf2png(twoPagePdfPath, 'two-page-separate-pages', { + combinePages: false + }) + }) + }); describe('mask regions', () => { const blueMask: RegionMask = { diff --git a/src/compare-pdf-to-snapshot.ts b/src/compare-pdf-to-snapshot.ts index 8b47c61..c802345 100644 --- a/src/compare-pdf-to-snapshot.ts +++ b/src/compare-pdf-to-snapshot.ts @@ -1,5 +1,5 @@ import * as path from 'node:path' -import { access, mkdir, unlink } from 'node:fs/promises' +import { access, mkdir, unlink, readdir } from 'node:fs/promises' import { pdf2png } from './pdf2png/pdf2png' import { compareImages } from './compare-images' import { Jimp, JimpInstance } from 'jimp' @@ -90,6 +90,12 @@ const maskImgWithRegions = * fields is inlined. */ export type CompareOptions = { + /** + * Whether to combine all pages into a single image. + * + * @defaultValue true + */ + combinePages?: boolean /** * Number value for error tolerance in the range [0, 1]. * @@ -142,10 +148,25 @@ export async function comparePdfToSnapshot( options?: CompareOptions, ): Promise { const mergedOptions = mergeOptionsWithDefaults(options) - const snapshotContext = await createSnapshotContext(snapshotDir, snapshotName) + const snapshotContext = await createSnapshotContext(snapshotDir, snapshotName, mergedOptions) + + // When combinePages is false, we need to process each page as a separate context + if (Array.isArray(snapshotContext)) { + try { + // Ensure snapshots exists for all pages. If any are missing, we + // should re-generate all of them. + for (const context of snapshotContext) { + await access(context.path) + } + + return compareWithSnapshot(pdf, snapshotContext, mergedOptions) + } catch { + return handleMissingSnapshot(pdf, snapshotContext[0], mergedOptions) + } + } + // Check if snapshot exits and handle accordingly try { - // Check if snapshot exits and handle accordingly await access(snapshotContext.path) return compareWithSnapshot(pdf, snapshotContext, mergedOptions) } catch { @@ -163,6 +184,7 @@ type SnapshotContext = { function mergeOptionsWithDefaults(options?: CompareOptions): Required { return { + combinePages: options?.combinePages ?? true, maskRegions: options?.maskRegions ?? (() => []), pdf2PngOptions: options?.pdf2PngOptions ?? { dpi: Dpi.High }, failOnMissingSnapshot: options?.failOnMissingSnapshot ?? false, @@ -180,7 +202,8 @@ export const snapshotsDirName = SNAPSHOTS_DIR_NAME async function createSnapshotContext( snapshotDir: string, snapshotName: string, -): Promise { + options: Required, +): Promise> { const dirPath = path.join(snapshotDir, SNAPSHOTS_DIR_NAME) try { await access(dirPath) @@ -190,6 +213,21 @@ async function createSnapshotContext( const basePath = path.join(dirPath, snapshotName) + // When combinePages is false, we need to create a separate snapshot for each page + if (options.combinePages === false) { + const files = await readdir(dirPath) + return files.filter((file: string) => file.startsWith(snapshotName)).map((file: string) => { + const fileNameWithoutExt = file.substring(0, file.lastIndexOf('.')) + return ({ + name: snapshotName, + dirPath, + path: path.join(dirPath, file), + diffPath: path.join(dirPath, `${fileNameWithoutExt}.diff.png`), + newPath: path.join(dirPath, `${fileNameWithoutExt}.new.png`), + }) + }) + } + return { name: snapshotName, dirPath, @@ -202,7 +240,7 @@ async function createSnapshotContext( async function handleMissingSnapshot( pdf: string | Buffer, snapshotContext: SnapshotContext, - { failOnMissingSnapshot, maskRegions, pdf2PngOptions }: Required, + { combinePages, failOnMissingSnapshot, maskRegions, pdf2PngOptions }: Required, ): Promise { if (failOnMissingSnapshot) { return false @@ -210,32 +248,50 @@ async function handleMissingSnapshot( // Generate snapshot if missing const images = await pdf2png(pdf, pdf2PngOptions).then(maskImgWithRegions(maskRegions)) - await writeImages(snapshotContext.path)(images) + await writeImages(snapshotContext.path, combinePages)(images) return true } -async function compareWithSnapshot( - pdf: string | Buffer, +async function compareContext( snapshotContext: SnapshotContext, - { maskRegions, pdf2PngOptions, tolerance }: Required, -): Promise { - const images = await pdf2png(pdf, pdf2PngOptions).then(maskImgWithRegions(maskRegions)) + images: ReadonlyArray, + { combinePages, tolerance }: Required +) { const result = await compareImages(snapshotContext.path, images, { tolerance }) if (result.equal) { await removeIfExists(snapshotContext.diffPath) await removeIfExists(snapshotContext.newPath) - return true } - await writeImages(snapshotContext.newPath)(images) - await writeImages(snapshotContext.diffPath)(result.diffs.map((x) => x.diff)) + await writeImages(snapshotContext.newPath, combinePages)(images) + await writeImages(snapshotContext.diffPath, combinePages)(result.diffs.map((x) => x.diff)) return false } +async function compareWithSnapshot( + pdf: string | Buffer, + snapshotContext: SnapshotContext | Array, + options: Required, +): Promise { + const { maskRegions, pdf2PngOptions } = options; + const images = await pdf2png(pdf, pdf2PngOptions).then(maskImgWithRegions(maskRegions)) + + if (Array.isArray(snapshotContext)) { + let results: Array> = []; + for (let i = 0, l = snapshotContext.length; i < l; i++) { + results.push(compareContext(snapshotContext[i], [images[i]], options)); + } + + return (await Promise.all(results)).every((result) => result) + } + + return await compareContext(snapshotContext, images, options) +} + async function removeIfExists(filePath: string): Promise { try { await unlink(filePath) diff --git a/src/test-data/pdf2png-expected/__snapshots__/two-page-separate-pages_1.png b/src/test-data/pdf2png-expected/__snapshots__/two-page-separate-pages_1.png new file mode 100644 index 0000000000000000000000000000000000000000..f13af5ad5efcf55f2b7bda885049008019a61c96 GIT binary patch literal 21196 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 Dy&rbt literal 0 HcmV?d00001 diff --git a/src/test-data/pdf2png-expected/__snapshots__/two-page-separate-pages_2.png b/src/test-data/pdf2png-expected/__snapshots__/two-page-separate-pages_2.png new file mode 100644 index 0000000000000000000000000000000000000000..fd5d1c9a571f213297f2cbc8dde934b6380cadf1 GIT binary patch literal 21525 zcmeHPhgVeBx5koaY{Y_HFcB1o0b@Z#MN~utlsW?rH7Y0_K|nfcED=!zVXz=5ouTQ_ zq#L5rEObN!l%f<7K~OsH+a&L;_aD6VdyA|rGLe}(_nx!&*Y-&+Yp5yBowa2?+ccF!M+JN%7`I<^lrRZ07YC z^{1|*_ivsF(3tMGK+}8m>$?JrKmU1P>NW-KrKSOP`;>MsjrenF*zVOoUDpuWJOAvT z4l|F;KOk5nJD$~ex-0c%u}awCu-|aQ7-*9p6il}1+*NXUV-)&r0$a`ixeR};% z$xwf%V~yeIcBcnNpUxT`?e+?4tAImj^5P_@R1>1q&89G(Ver;o`-PpyC%V*hi0s z6$fod!mV`pzQearCLPp=+4#__@eY>Y`D8K<70@nr)O^Vs%{=nd%IliN#At%cUyHR=Sm0Waf@`=tsT_Zo!56^FT|M}<7_!b-W z{f{N3Hg43+yYS@6zEQtVjd{j0HgA6x7}3lg9~;iJYre>QwQD#&^K^G&Sax5zDo;N> zk=f`uoFHZJXf-bIHpG4U$eblo1{zjY@$*D>#YaSl4h{~k-?r`W+~r#|3a+iHisx(= zN;~yY&7;3cm!D!)t2J%LJeHkW$j(?_`LV;?-qsS!{PREQJvw=x$^pw!v{*<;tZtyE znSFon52FF*6;ZMG?(MEiw+(7VMS6RC`$t;U@?8g8gV={-wIWS2JV!IJOHovHS`UvZ zR>f#49J;f$>h;58>`Vvqz?OG^{h^oZo`GAw7Z9+h{*j$R6_2f*pqJYBWTCn^ZuP>` zr%%m0s}r`Le0Rp`%90H_d-tAW3ho-X_a@ac+CL&H%3^F}ke^~*r!zX#udRt)kNDo; zroLE2L?q|i8%xGZ`LP~B!$&9gjE#*AeGMIFmSE2V@7_)D7Lu@d`^e6I@BaN4j~+c5 z5D*aY_a#9I2?>58kAhhhr1twk4la0 zeXh^yugM!@>n0fmhK7dXPO8EbXFGOQAG*3irrP$G_3M={U%srOrdEAl;Swv+Kvvr7 zwZ_%u(poi%hU_dSYoXTSKnd56cTbzNmq%XNtDu0ZB;lhLSXg{Y$HpenqKX&!;l-U! z6fp~L>Q{a86!R%ziGUT zLzgSpu5qm^V>Edh_s@A2rD?3BtG=`CSAZ)`MH@)c$`=6VDM z2girGkMtCHU%8^ad-s`HCC{H%0uKAzqeFSyPJMV@hE?Ik#E83G!5bVLQnDpE{C#P+ zr7f|1P7G!2o%D&9D@~6d=V84%YLY^|`JSV$ib_f)Tb;lBE^YJn@CB>{W78(BjEszq zvhYh|c*v4X$DYlJjEs!6sn1+4V-q}9as0*YNPLUwi4$?#>a$MQbX3I&{rvO2{07fq zYeI71W91s(oni0)zGOMr+sY_Lqog|b90gb$P~>DeC0v}dEG#&9rOWVe<9H(4&k`Ni z>%5civQxM|K0!a-g6NJ=1Rc({1C+{%iHQ|$GA&-@qMv;Hs!pQ8dQ}sX2wKVU(Z+H1 zo;_!+?rgR8k7UJa-In*t4e=Z@AJqbQYDXO`TVb31i*G@A7@=i(gt99TsVf4+Kv4D`~Wb)!(#;WdES5yeipMM{P60^z!{+w0YSqc~D__bbM7oq`H zOZok;spt)5?t1O0LLH#(=*k=G)OC!ELZZ||rBq+smX>zE-~eNyW* z`^qO8<+v7gId!5cUD0ojgT1Dt1F+E5pK6oYudn~2(AOGdTwES?Xa!^kyX#GA#j4Y9 z@BcAFup@MQB>H8DTpSjh&0{Hh4ON7i%$_^93OI)DSSuzfTDZyT^>07Sm{C%mXt-l^ z5xNv#XO6OdljoI(bz}pugcdJcxJDI}!9t^=mUwo}&lpT_DN35(_qkp_{hUmryY|9$ zY5|duCCg>)BlOa35@?MZy~cAUYIw(KHI!3%xbH6BhTMU@7Y2uhiv0rvBWsMk3`>GG z=mPAd%}Q6Nf-CUoRVay6k71|QmKL)of6niyO^yaguCv9vw6WQ(JogoiUj^<=sa@}myk z3PbaoQ-^)%?3B>g(c%6mC`=T7;nJlz@W8BDv$V9d{7JH*7YLP??{JDI36|{c;bBTp z!eRxuySpz!mG1Hw5|@)R+_-V$(5K{AEj`YC-|B27fZ@#rzCki*vrEFR2Od6r$WO6t zG$MEf@V9n$#^LKmn?=S;pI%uaZP&Dq9jm1nuH>t81Z1;(tJTf7Ht9?%g)>v8RYFQw zzkaZ%gezHrDoVAg-Bdd=I?6r_YRPo!;^nw~FD`$0Olj`Y-*yvmK&*c2s*46eN^IDm zzHse94Iug!?9OP&wUx5J>f<^;2nd|1yWxB0C{r)xK&X4~WhO3C4Onc!g4q6jajv$R zSquR$de^-4-X4pxunShJwmXzcBxQ#|+**MAhUyx}qEJdjPVMDlw3mE9x5Dhj%yH^#4~{6fSk|Q_ z41Mq3Q1UU2FW}j5DUC>#z!vLIuOH456s$p&r8;%#fz%EvT$&ds zBQGz{A^AoSSyoo&+Lc;k;^P;Idqb;^MU<9mbiID4P+MCI!E-~{bKr=J8{UEBfOx__V^*3ewhZws6)S zx*ai5@uQu_<62lI=CmK@l=9edxNb|=!vbOL;cs1m{{CSb@HF0M|uHNGk=rw0J5z&DY&R}GSig}%sKh6nezhG^#LCWX-Zu~fz1 zP~%u_HnkSBBI;0uwz^y-Xs9p^wE6Vev*Pj`x6{^dA89tw)6NyyWnld#Mbp(a^VcAkZ=3>@^?hnIKb@&V(C*RTJ~G4kkV zkHW6-uP)oXA60(C6q1KwoSSX)1LS@wlx-zyRj4}2SRU)8&PA6~mo<9rVc!?`b2xb^S_7SW>4a^s_ zZftbeF_{D(ihZa+e(XM~h4v>6O0^B@Wv0bMgQkm`Y)x^9e z@ho*#IYM|uR!#JWhdY+XuigIuwMt97k>_Pmym=G z%TCy(Wj=XtF16Wj*b+%tPiT(fmoKZKdrdhxuBp`t`s+wsLGn1nwZU5M-Mg0+3~O5G z1TuGV`<(I7;nVAl5QOH~a0U^jiK>c9xov_|*Bc865+>lE!Cbp`KNpwk%pLxGqoh>} zJBUJPkpy=D^7$YcCAF-R@6Rlz^t`;LYFmJOw15 zwSr~$PlwScUJMp$dh0bl3eWY>MjFCF2Y}C7gs15TyQe*Rw5{HU96Ab!zi;2Z7?nT< z_8*RWs{Ia_TT;#bJn#E1i{t1Hyy6PiF3yVHOI?KNhAeb;ZFVw?K9>U7gyzdr2Z z)*cz;Ffw2V0zE`uWCvhdqYtvorsl8CI&Hh`7%Kqq)OL1;u!h~P(LU|gj~`1mx%Qdi z7s2a7ocE?lTUNKFJBM1>&OCH`OIbW8*+BwKbyY3ug*I&g=py+$_EboX;t7WQuAV)=g1=~D7{b> zEvzrSmwWEqRFLNZMe~O245%Ge@R>6LwR}Fm&+`0*3$chbe&LhQ2%0%_=5;EF2Gq!o zTD>%@SbvA^2E(oCb&|Fe1SCwD77dN-Q>RWfQKk}GFD9l4Fnp7I!k769V(3j> zWt4~*s7O;Y(I6uX%ByX-JFf%pX*yw@Wmxgh@BJ_6=Ow< zii-GmcX{Y3;@3fWd3oN=FeY~pJ*96x@v=}SPDe6O6xs^fKT>}WLNt~?%u)##On1i) zWmyaR-mi_Di>6e>9>PQ@5{S&W?Ca}mMbXjz|M`p)o_ z8j~unzk`jAF8V-(+Vu1apFmK|yp|tH;kaD!(v$iODS)+YrznKuBOp?qtTsYyw3vDOhJ6E&5w+KzDYqbu=*4VyM^HkHs#3|7&~us5YoXB*~u8m57{ z^kvqd_VMu{>D+;)*^2x2iNh)4+9)hG@E7YRaK-n`uPj)hv?G6Cq_j<57pu$)MuiqO z_Y|fbSL};yL{*cFavUULSh4NOb1*?XniQSci zUG|~g0~zdcs7u}qVdvdgy)!SAcF&(WZJOx{$T!jt#+?NsUX?%t6Z7i$TW|+q0CfxK z1=r@QGJTD!Wn^@3tUcs!4dX^hwYPt0=mx+-S$E`#S0Qg6*@ctQz|#CMeYzR=oewxG z(uvg)gGbStAt+pl;;YQ{aDxU^PJ=OR*ZFzrQgMC2&=w>Yva!&$+dYO-m?&Th@Vrlf z6lzh8He5XCi>2ZIi=cvz`e+_O;iP@~WECiiB22SyEp9*l*5%_}K5Ttkv;mb5;wxE6 zREt>A^XCBx(1U!cDossIegK25VrOS(0dw)+x9^uu_zR`O_9yT;?&Ft^6pfTR_e7|N z&YnGchSU!ua3gVmy`+cftVz-ZsZPftI&YrpJ@FX!_IyuCY;l3G+d?_r~c=>V$ zF_P(o90XA4VD%j&X3m)tASo-$QHwLm$+W`Cvz7gC+?XIA4%&mt%s(O`q7YcZx`~zR zBET;q=!s*;ac%a4Fs#<81(&K1zP}`5GJDo6{|ZVK{DbAajMAV}3lR{6V|h9Bz(jvf zW$l{Iduyj!uxk}n)tZ1koM0;XBPe-<>DTMJbZ~Xxd>F1nLrd}JGsXA* z{`);EGQ_EH58>y}Pv_7-LAT&P7n0^^0t&pIKR-BHM)6o0o;gmGCIVi2laIe(Ay(xe z#!W@W;j&YBa9|(?^7Eh7xwdKt#TdXa4i&JzBKqH?4wVQb38q;|EoD~zBzJ1QfJ2B* zpUOIYx+!Ix%e;cyRffP~`$HR-RNvb(t=?zes_lhomNim=qL_0iU0dl#8AvZaS%Kk7 zls}CI(x6gnv0@VX=mYjjEH|(tW*fkQYmK&N6rpO?-r2anfm-Xz<;!K=l1PowNtl_? z4yHiQkiWF67d%^9i%<8t!LsqJVq;?&FYnmq#!@7} z#Uv^pPO`PNwY_GhpkRD#cZQg5UKR)JCNBgC|t90`~Dg6q}RAdtiWlu%g&i=mQFXgL^I__zWf37>31`OV7CGU=>kf!X+X32`14ZL4LK1F zIc_BwSZ@6H)2p^e7}K)xS3*`mtOtl;Aumu#Ar1~iQZRUh`E03Ud%0q|zwD_Gg|A+z zbC(!7?;*W+q`ke}_5Fn@>gz-akb(E))98l6B72Nq*E1L@>F1Cdv|U(YL=bWWK&_&p;vb3Jm}HFA zL~9lKi)}yk!6zFjqBuT*_jJ@}rCXtjZ=_@RI$CY?(OL$b$l{5)Lk?LW@KncfI z>o?+PawI!idw_R zFbHNsLb5?mjuJo3npN(zZ1Wa`6@=k5dPJ7J2*w>P4`IC?k02{6%df<81oJX+G$Kbq5EO$gh?|hg zYoc_KAumFh0xKvtKJpcJ=})o;Mhk?IVIU=BS{=`E9UJ_ZYS+A<#1x*5W_T1W;xil? zp68AACk=@ix%VhqH)L~+y+%`!>Lg)hX`t8v)B(u~ff=z^;!tx)1t`F$M7@3%g~&+d zfB5l79wNmKO#a9yK{i?<93x4Y^Y!J9P0tFed29)qCEk&-tC zKngP>iUcaC*MLMtG!dhGlhzZ%5pe>ND%jx2=;%)Td#S&eYGs1H48FOeWJ6x1p!~=^ zbgu={F$9#lc54sbyhnurO;L`wrr`L4+`zj(NSwgFrFxBfQofl+=pZQ9cipBMTETS1 z=HoszOAI}R@t|I&Lo9IcYUC48g`GMTgxVGf@*6@q{~Ab2?bbfbqZ7t_V|Dv9!aC@!$J&JDV#_@=3qt5Jrk3E8oEFb z)3rjJn|`7>4%Iz?dLkNqL5BaoV`M0Ra0E@U;t06%D0J0FMqr}|C#;LEpz%>;L_`~m zkd7umBoaLod-SRIP!0hc!Zp{n`l@es~gVA8@zMR3N4YVragj`h{lu6bTIE#bzZ z;D&k%M2KLph(#Y@+jNaR*yM*|G{a3wUcS^JlLXJjC*X$st3u%9*jD-rEnov4B#w9y z&Zct1dzeIU301l)sL^q#&-_KGyz$qMuY%H{R?IO{w5ZQawGl$8RpCrP-@88qJAfAz zh(g!d;vOq5iFn2c%efX3QzI7W#|Z#4WWL2kMP+cyt=7o+huXq@7hz~gGs>Q(rwnoN zQh+!BeLW>O3ovU(Bama-?)EJnmNdntetYe9r%y2;;5LkR7)3O;LP>O{))-Y_!08AA z%NhJ|hw*%AIQq^AHm+)eA5P8~=QsC|R!m<4CfpBO*d{KY9u`m}8)$-jthli7y6N+q8!_EY zv%-mv(lEs<6m-M@MCs4=`z*bWm+i;l5EMn+znn!W;M~L-c9M}H#YuRKXaxH-K;t{0 zZ6W!!CPFM4TyyCmupXL6vQBqb1HB>8rO=iVqN4lg)C5k8NYVaNqDBIYGEBcUO2wc{ z64+kI>g}`YXU&_ZgXj+Jw4^?s4l@9-10qppG|6HGR93QRYtulgabT*6N%8+WQqlXZ z_C#nqZj%q_-QSQqHe`7hl8_7ZU_tMcZE$QY7NY!}qA0W(nKMYWgUA#xv$;$Dx9K^q z58mhnOGhZ*U$Sv5T9k(9u(n2p#KcgAjz5^Lz7YY0C4evmbW}B6Wa7l=$-1S`ql-5fF-!0Zn1)|R#>rd-8GCZL5Gt0h9~>E4GXN>v zU#cKj#K+iLuD?>7&V`Q6bccPlQ3eh}2nWEGZt)y(p5RLC5TA~<;9ZYJF8Jc7BnIi` zI8<>X5}ZWy$XRvRo?Qd)rg3meA_*>xZfYQ$PMoGkKynq)Xa-Wu0`oRHpoZBbZ5j@& z@o)@y;|FO**O`A1)=1dJ7)Ib%i!@J-4K2gF?uF$e& zv8b6gnuveO)&Js~g>m=x{#~BSE@?w1Gi&As%h{(T(V#kk69+<_N2nb9b zDxO#dfywjIlV{2&Dq-@t{67uxe|}v4@%S;pYxvJD1b!Bf9lo^nqF^cA*yBCZ=pQrY z($AG!=;z7FU(hm4F3DsuO!miQ8%$y%aA1-qCJ7Q{Fewct)e_2JQYVr$n#9COOq|5T z$u^iYiiC!f(qK{=pbY*)rNM)5?{3onnPkfKWG}O0160uesgFXpJ}FHmr3uPl5|bw} zc@oGb+hB6WL!|g0DoyCtC;5Dm&rt@Gd_KwNlR!4v2LAzkK1cSiYwD}tNiHS&QdU&k Km$>)$bN>Yl^AQvP literal 0 HcmV?d00001