From 055839655ed44b386a22ef1d724575ca62767cbd Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sun, 6 Sep 2026 08:35:39 +0200 Subject: [PATCH] test: the monkey edits both zones, and the runs compare what is drawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Header and footer go first, each with its own ops, and a variant flag is flipped while a zone is open. The round trip compares a list's drawn kind and marker rather than its node type and style name — a list that loses its name to a formatted marker carries the style's own char — and skips the marks of an inline atom, which no file keeps. A caret is snapped to a text position, a swallowed File-menu click is retried, and WebKit's converging ResizeObserver round is no page error. A new corpus pair brings all six zones through the import path; the baseline holds it and a re-recorded showcase entry. --- CLAUDE.md | 2 +- docs/headless-testing.md | 56 ++++- tests/browser.mjs | 5 +- tests/corpus/16-hf-variants.docx | Bin 0 -> 13777 bytes tests/corpus/16-hf-variants.odt | Bin 0 -> 45054 bytes tests/dom/run.mjs | 33 ++- tests/layout/baseline.json | 175 ++++++++------- tests/layout/run.mjs | 2 +- tests/monkey/run.mjs | 295 ++++++++++++++++++++++---- tests/normalize.ts | 13 +- tests/render-parity/make-fixtures.mjs | 19 ++ tests/render-parity/run.mjs | 2 +- tests/schemaValidate.ts | 9 +- tests/smoke/run.mjs | 2 +- 14 files changed, 487 insertions(+), 126 deletions(-) create mode 100644 tests/corpus/16-hf-variants.docx create mode 100644 tests/corpus/16-hf-variants.odt diff --git a/CLAUDE.md b/CLAUDE.md index d97ea8a..f261c19 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ npm run test:lo # LibreOffice legs: round trip, fuzz re-read, ODT/DOCX rend npm run test:smoke # boots the dist/ build headless (tests/smoke/run.mjs); BROWSER=firefox|webkit npm run test:dom # pagination + editing in the real browser (tests/dom/run.mjs); same BROWSER npm run test:layout # page counts vs LibreOffice, layout invariants, page starts vs baseline.json (tests/layout/) -npm run test:monkey # random editing under invariants: schema, undo/redo, the saved file reads back (tests/monkey/) +npm run test:monkey # random editing, body + header/footer, under invariants: schema, undo/redo, the saved file reads back npm run test:coverage # vitest + v8 coverage over src/ → coverage/index.html npm run test:parity # render parity vs LibreOffice (tests/render-parity/README.md) node scripts/make-thesaurus.mjs; node scripts/collect-licenses.mjs # re-vendor public/thesaurus/ (MyThes); regenerate public/licenses.txt diff --git a/docs/headless-testing.md b/docs/headless-testing.md index fac25ab..a0fa51e 100644 --- a/docs/headless-testing.md +++ b/docs/headless-testing.md @@ -40,7 +40,61 @@ text box in a note breaks the ODT's XML, and a table in a cell is dropped on sav commands now refuse there; a page break inside a columns section, which the editor never shows and only DOCX writes, is now cleared as the blocks are wrapped; an `.odt` dropped every block of a list item after the first, and a header row that the table did not ask to -repeat came back as ordinary cells. +repeat came back as ordinary cells. Then: setting one of a heading's two vertical margins +zeroed the other in the `.odt` (LibreOffice pairs them); a page break before a list item +reached neither file; a list item's further blocks lost their own alignment, spacing and +line height in the `.odt`; a run could be subscript and superscript at once; and typing +over a selection that spans two blocks let the contenteditable's own span land a whole CSS +font stack, and a weight already saying `bold`, in the document. A list in a table cell +shifted every body list item's own style by as many items as the cell held; and a heading +inside a list item came back from the `.odt` as a paragraph. In a note's own text the +`.docx` lost a formula, a ruby and a leading tab, and a heading in a table cell lost the +margin it did not set; a list toggle could pull a note anchor into a text box, where the +`.docx` dropped anchor and note without a word. A running-head or date field in a zone +lost its own formatting in the `.docx`: the library's simple field writes a bare run. +Typing over a selection spanning two header cells left a half-header first row, which no +file keeps. One finding was the checker's own: libxml2's RelaxNG cannot match an element +the ODF schema declares by name class inside a `` (the statistic fields), so a +formatted page count in a zone read as invalid — LibreOffice writes the same span. +The run's own `focus(pos)` is snapped to the next text position: TipTap makes the selection +wherever it is told, and Enter on a caret between two list items throws out of prosemirror — +a place the view itself never puts one. +In Firefox: a page break set inside a text box, which neither format keeps, so the same +plugin now sweeps it from a table cell and a note body too; and the run's 1x1 PNG had a +truncated IDAT, which only Firefox reports — the same broken bytes were in eleven fixtures. +WebKit raises `ResizeObserver loop completed with undelivered notifications` as a page +error — the text box refits the wrapper it observes, so the browser defers the rest of the +round. That converging loop is the one message `openApp` drops. +A comment over a line break rides the `hardBreak` too, which no file carries and the +comment's own text runs keep — `normalize` skips the marks of an inline atom, the picture +and the formula with it. +Then the table run: promoting a cell to a header dropped every attr it had; a replace +reaching into a table left the cell it landed in without its column weight, so redo (which +replays the recorded step) restored one the edit itself had lost; a list toggle wrapped a +table into a list item, where the `.odt` drops it; and a heading in a cell came back from +the `.docx` carrying its size as direct formatting. A list's `listStyleName` is no longer +compared: direct formatting on a marker makes the file keep the resolved automatic clone +instead, and `corpus.test.ts` holds the plain round trip. Its marker char goes the same +way — a list that lost its name carries the char the style drew, so both sides compare the +drawn one, as the kind already did. +Still open: a run set to the body's own font, in a columns section, kept its family on the +held side once in four runs of the same seed chain — the flow rewrites such a document +between frames, which is why that leg leaves the history unchecked too. +Both header/footer zones go first, before the body ops can cut the document to one page. +The run draws the two variant flags and the zone distances from the ribbon's Insert tab +(Options) — with them on, a double-click on pages 1 to 3 in turn is one session per +variant — and edits each with `MONKEY_HF_OPS` ops of the zone's own schema: runs, breaks, +the insert bar's three fields, inline images, left by Done. `16-hf-variants` brings the +same six zones in through the import path instead. Then one flag is flipped **while a +zone is open**, which ends the edit (`App.svelte` drops `hfActive` with it): the text +typed before the flip has to be in the zones all the same. A zone is no part of the +document, so the saved file is the only thing that reports on it: the round trip holds +the zones the app's storage keeps against the ones the importer gives back, per section +and per variant in play. Undo is not among the zone's keys — a pass falls into one +history group, so a single Mod+Z would empty the zone and the round trip would compare +nothing. Every run opens its document through `input.file-input[accept*=".odt"]`: the +Insert tab carries a second `.file-input` for pictures, and a bare class selector picks +whichever the ribbon happens to be showing. `BROWSER=chromium|firefox|webkit` picks the engine (Chromium by default; the others via `npx playwright-core install firefox webkit`). CI runs both on all three, one per matrix diff --git a/tests/browser.mjs b/tests/browser.mjs index e47d651..8f01941 100644 --- a/tests/browser.mjs +++ b/tests/browser.mjs @@ -58,7 +58,10 @@ export async function openApp(port) { console.log(`engine: ${name} ${browser.version()}`); const page = await browser.newPage({ viewport: { width: 1400, height: 1000 }, locale: 'en-US' }); const pageErrors = []; - page.on('pageerror', (err) => pageErrors.push(String(err))); + // The text box's ResizeObserver refits the wrapper it observes, so the browser defers + // the rest of the round to the next frame and reports it — a converging loop, not an error. + const benign = /ResizeObserver loop/; + page.on('pageerror', (err) => { if (!benign.test(String(err))) pageErrors.push(String(err)); }); page.on('dialog', (d) => d.accept()); return { browser, page, pageErrors }; } diff --git a/tests/corpus/16-hf-variants.docx b/tests/corpus/16-hf-variants.docx new file mode 100644 index 0000000000000000000000000000000000000000..422b286e57e40c752e8bf9025cf9ebec13cc224e GIT binary patch literal 13777 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 literal 0 HcmV?d00001 diff --git a/tests/corpus/16-hf-variants.odt b/tests/corpus/16-hf-variants.odt new file mode 100644 index 0000000000000000000000000000000000000000..2645b0965f810bd75ed75cde6bcccf3414472e9a GIT binary patch literal 45054 zcmb5V1#Da~x9=NfXsBUoxMAir%*@OT6J}^Q4Kp)y(l9emm}$ZdGdIxW&3CTO)p@$o zeR?BVW9{*_>^X;4lc*(auC^30 zb-uqUeV!WCJztjfvPM~*xjW42lJn*X*7j^Kni@X3S1>snc0S)KSY(wa_*i963nq&S>$E0K1W8{+OpmPqw4NMLUl z5B*r4Qe;rtA8``?u86s7af?>iifW%hjbL!w@5n5Re9)XhL z&utfYN-&TR5dUk^|NR92_l&`nFW@5P2(YxVa(DT!XXnbs{QqvUsjI7NXlQ71a&mET5d;Dq92}gVpWol#e|&s^yZ`+B9L4#~0InfA zN$a>mKpnXwuY>vQZ}joWPb$2=4y??sl6`#y zX`gzmV@imAkswZh@Bf?{dqQ{kyjLC&TSnLT(Hc=jS`}aquBrc1el+u~cr82|ds2S< z|M;=5A@=-}^!s1sM^b<&+x&xlYE8aF)5YOlYij56Ci5jp)yI7;yr6DWb7H2z`!M~A2YMCM)l`JpCU20kgQ`rdn=rwvkvlA-1!v~B;JS%rvzm@Qy6Y> zyHs-=XRoeXpC4|EMgw$dxCswiYDJ^$>J@pcD#n{K1IAhh5UV_X-i>Tm-MZfW*|>P4 z-#hZP`f#rovRY{jjNg}v>}bbukw+|%W$aK9Llk`aL;s62Vn@o|pCyMX3IhhURJ~5+ zL9?+Mz_t3f^A+yUkA!uJt5Gyp1 zv&F3N60ZQx8eRgeWLF;BPljSeUly73DB<>RRzWa-hDczFf6#`Qh__lz({UE)qIZQ2 zTF%C;@8AWN$$hqIfYW!SPki}7W?2KL%EDOn(tCDplm2nZ$&fv>qP2U zh)2O8_q)`A8=M@!Ol?V8WQSP2438lrDTia`33ln{Ua(CGdu3s-%q#3edCp;OGjYJX z^>CPRl&#cOFh}B1WSE7C1)`2c%o##WNR`E5W-M{nbtzGZ2DzMho_InHDUV24?l0Xz zV%KK-J*gW*RyC`mxa$506j>{Ln=soOzgltX>hr{!zD_MdL3G@LT*U^FZd4ugq*e$~ zB|dE|9>!tg8;Ct~TX_z@91ZQN6-6KP4SDmYQrn6$M>Q#)E>)T11RIZ}<=TLy`~{Ev zf{T_Aj!_-L5MwnNVLQaoM2(fwvqBGIt1q-0LLzeac**VzO4+nssT$2x#EQy64?G62 zXQ4gHZ73n*NUFEiJv&y+QEtiRTgQB)ROND{57Fvd%(WuZ9!=dO+uu2v1OVRj>0Fa} z6{rh(b_*S1=^1Sa?`afCc>zYAI_(i>07wy5tU z`qvtqPo6~$_F>`vMVBNwI#=sqyEo-U7&J3p2Jw8KBqEgKnCoX`ZnDl}?gm(%Fa93F zsyBXL6YqL3ApZUxQyRp6%SS>)B20krIHh)SuFXF#XEH7ufg$U9CgA3I@|0L1x3xsh z>B*Drn;|E(p+%l>%hWFIQ= zxZfDs?>3XRzpL)J_xGxYm}feTMjVecRY$Z@6+dE$UJ!G!UlLoFl!6bU((s>pL=yb6 zN?%HLXRbVgIf+W!e4Avh`&sYchn}R=83}Mje#s?kwu{-oCgD@ye;fp$R4SzEG<7>$ z?JmPIyNiTC=~HD2E^YHVxUz(224&g3+xUikcoh9=6ZX2VP= zcRAPV%PdhvvukPxK_^jyL^c+@EUUc`*NckyZw4c&hQc7q?QbmcLB?SJDVp&x zd3KAwGQBsMw;a)zq((mc{=b2z6 zZ-J-cu7-&F7KP?iAO8gdj}#((3KV6^h{y-Y3H^~QL6uAtYYag~!i8NBWZ@J%gUk@Z zYn)yh4HsW){Rm^kyGA++*Y&*>8--4zg-|`g}(+Xy56ylfZJ59L?nC$*;8B94eh3NAG~r$#g`+t_C0WEB5H|=k1ys1 zhoQA5NeGyjN!UG4tCJBXSI(xM*yc?+xUjvDWkA;(99vz=+#OgoAp46isDly&+j-H# z_ItmvLW=^Z}M*|{?38^NQOq~#kh^AwRE6E1dyhM)n-rxYI6<<55v6{t(kiv5B zU+k=D_RTcGZk=6J9fdKwkmD+;)jj|HK{tzU{A6!Ls?ub*F`PnOwiq`XoNo!yVI}<(7w?v1zJDKsLUeQn$aL3j|gScGfSv#;zW0X75mmZv^ z%hc_|*rj5IG;x2_Yki&`wJUmB{@cLiY};tG{m^TJulrc7^l{HzHLiW|8QM7De$k@< zpYy#hu}Gb0vRZ=kS^4U@0gIy_v%h$MN@;j{)eh;+t?z%y%5)i6O}nZ((aov&x}6mm z^Tk18Sjw7-hqD0Yf~A9RF{Kr=Kl7#t*J?nTa;PvDK4Q;Pjcwm|VZ#RUryFcbo7`($ z4*?{Tj6h+jMbe3lq625NZHi1HvO6IenBso#KpCFg z8d>FY7T5{|_!c?=)nuss{VVIT9Ty0lrUyoQginmg!NnaIJXK_y16{=G1^(aS{rYsV z!MUja*vU^W0<+sH7uf`p5RFmn)$oN7AI!W3YgUlZ*34^xq0|VKqE>gr8iM^$vZ&I_WetP)@o_qbjaBY)Zv5G2_;2!ZO(7IsP(V zxrN*g2)g%8$@ZlmJJ3$aPEU@e3;5;q;I}(HU!4E-dHO0qP-G^`p+1yF65Lb)V?4Cal({Bst{BpoGG|8wUVCQLLJL0 zb}_XkQMHaZs-znCxfYK^_9fW6U3$u<;N@A3-T^MvYDR7zG>xxcAQXc!YY+3ZE+4;N zcUIuP1-6O@OYR39d#)if|1;E6F!h^&SeQ~+R*+=sz!h~cLFPA%qY{luiu3+6O|1EC z)#AJx0#1HKTX;?lr@k9yO)Ldlzkz`$(at&Aig6>6IWOy?r4NP%IN!b`6+&>1?j`4P zY`%5A!gQqi3w=gHvyin|pJ$f~*@V)#WaO(g(86ClE^vub*GteT>r*ujtw2gs>0<8* zva49bslJeIvV!+Qh3QF(ZACfN>QDGB?f2(77^RZC*4lfjX(^*{$rK}Y&HNCUs*S9U zt^|uN6~Jf_C{(V9<`7P6>uJLHhKG(Dn~vL}jplGPXy8{$HUi#j3FtR3gcm#yE|QAy z3VxM*Rf`ocRt1iZyB)f&fjG)NrwqP}juuW(ueB^nU|k_Zg;+O%=1|grj=RNxj!)04 z43zSFO`(J{w;mat?Fly}+bphVoJEEKi0UiT@$$`MkxZ52#9;5&cT)VRhX9p5Yu}wQ zd&&l5ca9~)I~7D-?&YkYyoRu=t&ELZl3h7G!(5Kd`mXb!-TjyU(sK@|Oq zx(yQMf|C9MN&V_|N;AuN-<|K*UC@C6%A{pb!f#5w~Ja0r{K+FYSO?Kbo zg0%WQu+<%dVNn$6;t6|oSbD7#$4O|?7uM@qaI68g#?Y*?S;blemB=+c>R1maLDa2X zj@n4Hqq3I#wsEhI#eCmtB$vf&pbh3ETbt!z$KMrOBx`!}ozE9)ipuHnP!rmsW-*i3ph!DSvbi8P``^@V;@TX)ai(&{Zq&e&jVRI{G<$?2UN0yhlj^R1A8!r-lmu>7S=aS^#=b(rB?(r6PYqI~h>3 znE&gvp&WaWN~&gKWWm{uNy0$P&QeAzx)e^NqYy40-m?oa;dOgeQB+~92t}X@5P+N< zZj^=q@=!%Wp72pR0#pE+7JN?bZVTvvJqWz+?r3B&q5fjPsYjFX^}~{bSPwQ%koc_i zQye}Ak(?!b)r}*! zIlHA_M9Nelxc2Uv-{m!YZNyv12Gf=ckQ4HR@dpG{0hIMB3*>@$4bh!JDcQzu`voYz zs(>UQA9K5aw)0qWl@c%()>Ui}Rg`2L891$ziR_fUfrW1V1zmfaiDcYu5SUNP4RNUm zM5U#+ue)y`hyyRK3>ijQfLLQOGRr~80yULFgwY35w2K+0DnOXynuzREHk`e}_T;OZ z3+igDKtgZFERpc9)j7wZ-nBbiq6H>S>v#Asc-hUeu%e5VoE!a(fvPg_6qCiLiK!6$ zhSu>HWwjx2^svcaAaV80TygC6ZPauwsBsw{s4K)qk!RpRR@{h!ZJlchH7btSs({x! zS*Irs9yYu=9jPw#Od#ZQBsn@AE0J*AMFj7tVF6?R7zRUN0Huv+FcS$8ff>8ad4ja$ zcL6(Nu%i`kfqqS?-;Myo2cJJBsqD@x6z^iz}_FG)d$XptwkS%<;R@q!2m9!2ZM`%!4H1MzA73(gv) zl?C~lc_Y&~I^qu;7+RshZKU`f1u{b+ma5=p3DJqD5Pg>cEv&9vWA3cP6r0h0{@!5G z>S&#Uvl;>}`1Y}~!Q{0T(&|X^LS~wT(aK%u_jK=(CDKD{+|oHt?Ayt?_``O|iWLcM=>kCGf1;rEveXOH^) z?a`e4X^39Gqq02IIR|6^Q%8zJuSl-5PHQuNY0ob17kDr>|%?#gG6=D?J}oz`jh!yeVd4fdEObd@60Q zhN2XfcP5!ag`;c-Z$BJQcOuVaJ_4e^l!w!FF+#9I@(BgYEK3#{mC8eHaDWak%r28G zRz{!0vT>=#XaW0m;q#Q6zfcY$9*>Ed_rc1Ypz!6LNJj_%Tkt(nK1>{GO7`9 zy<~M_60ipI8Co?YV{m&mltS3LhRf{m=ONt$VV#+dyJ+JHC||P=`qz9gBVO+R4Cy1$ zp@L7xeF0V@1XEjSxuI*)w)e0cM%XzZuc~hRr#8Z3SANPIuH5+hy>d*f?P3sXuoCqD3t9yAoNg2lEi%1Qg8 z$vV8Pos#Dei3`5ni&-;> z&$8G#U^4uuNBE!b2IT8|;P!Y7_w54HWyA&&;x>%u5`}{*JUm%|Gz5LvA7b2*tc8(; zDOQHadnpVIx2u1Kaljk_hr7n7LWCajk9J6&Kko{`4}yct7`u1@dYxTzZ7(6jaA6@!RGH?b-6 zyaZGLs(f_ZCUTB++#v;&tLskArJ!bs4sss2-U854eK}}K`7zL$asKRE5VG69eJBJ( zTz2>gHoI(`*z~9mXun%+Oxn&SvgEJ`uTI4tb~4b_ug9-^rr+*b<>Is}OWcj{rAiF6AQ4D#dDRn2X(erKpv2>P776=1OrR-|)d`9~ zU&zsNlQUko;RM&f==au{jAA->R*noG43inrt-`#q$m&QJhpSNZqF^z#hUU2%air(C zey@CBk(^-(ZDx_GAfBNw98eg!!=I51R+_?DkL^gqP>sJt=ovw>*oCQ~98iX0{v8}! zvhkgXE>u7*&tksE|7<#bS<8p0(mVlrHo7GYQfv8ZHf@Y6>Cy+KBNXfX4RqaTIh=b4 zk}4f7f0Fm^q`c8W_0JyfmvXC*vL>@Px*5`ksi)(|9(>Bv5SRN}CwG#zc>YbFgx3hZ zCFJKVJt>LCyU99@pLM>edb!VetJVQV6>rvOGl&)-$KIT!K4158^#8~Th9z>Zi~9dC z?Q7ae_QdFYYPt_W+Hnoud8H2ZfOW5^xDM(2`@9L~9k!_J?L?QcuQKHyQV#XkA<~}v zef0^svq{r`E31yCGryN)%#R|l;874z1qw>HIHjK|RJ!nNl>!y_I(r|AM0M71Q#9IX zdqAG<0WLcd`Jk6zmqkcq+kPVq{54Y?Qtjs!w)AEk;}TjkCpwU?mlC8l_iuCp8>gGs zQJx;}uAINUF}fV~v&Zrvy$D93-I6}jM$ zBj%#V@GLJ5ZpP$s8B(y5JLUS)~@RHHfjaq zI={K`Na^nCc*%qVT_^puaB|KKuew;Tl&U0wu_yv+C_PZpEEOmtHipJ`HB+#u2`)|D zK85#9Yr+s%Gql`_mWk;yCNAIw<+LI&X?$va`zse6rrem1{O7nrJHMEXW(a-9hAsr^87`_&USzZ{(|6HH>u(FU#*6w^tYyG} z6j**1P}Bs9a%R~HlSe>?6;ucEHqml7C4?KM!Z3%)L&npK0H+}~|K6+T_P5^v=4?{ovRhpdS&S0Pq-1&NtWRcrzbWnPQI}%QZeD=o3XP+UDKOMN@!Ss zj3|YS>=&0EMaxS4=r;UUp8YV<)0KAfvT1v6bTR!_+RnEvBFg=|#k}z`vfYi2b!1d- z`(lc33^Kk`15H0=$2+{tvR@wPRS|3fkY|P%92p+cM({J~?9SOO+};V?JL-$t9CSP& zI$QS9Vnnd#*IN(KBv@{~zzr+?dEjj)o>A&;ugq0!4_R@nIO}~WCZ6D(S}aHwj#wf$ zm&Dap;YLXz2{T1W5a1qkYC7^Z(HhJ_C~tCDgKCjS!q}zhK9IQq{vegKg?`kq>wiRso*S6m`oW3gz8JX5 z`k-aU>7KlHj>}loPQR=&?`?0ZBKB@O?WavYC`pP|oVu>_pVG^sTV;D!zua+~YV^sO zFH>0WvQx8E19&rUEuUcMxX<^mx#;bFx(-6IFsnN3U|@w*^oX7LWiAbue7GDP`HbFx z#fjbgbp=`Y=^DrjlSs?<#YJcz2)}mF|m!(M_PKI6{Wq6u4)F7*ndC3@Q%qJtw zHO<=2AlZwchK6TTC$2tiS|dtHB2RnXD=19csE&1JeC0&t-}RU?eB+ZkIWl1Jxosi4 zD1Nxg6*oq^ZbXF{k;N7(EkHuJFmAg?O9P&t>sX}kmaaj40aYDjNSQX6aOeWat=88x zrIT`XYWKj(F9L~!eVe~EV<`~bku;eS#SlLe^!^?HX-+R70^3~1%%y_2Dy;KrIMj`C z-2D5YoPyJgRyBfHa*1?U*>4#F8NIaJPSIZv;p)tj59Jvog2kTU^-0z=>>ngM1`?iI zXgg`S$Mk^HxKwEbbL$D*@X*pNer&jB^WVJDXqUuYqGJ3Ds!UP_c%~Ctbx{Ivw8`t^zv$f6iSjsa}(-a{=DPVw>y3Y0H=Q(fAaOZl|4=}ABZ99ts0Q_@%&}I-blady4DBusXeP5 zJZ>HRXnpPxyUabho_rf+?g*%=TJ<}cP0q4QGOb|{BR#mBw|d^nHW8K5s|RI+>Y?@?_sLP_{#4v^?|uNv3DZ9?LUyXK0DIBvIi=596=?(Sni{y>Ls zWW(m8Ce>~6F6BS_dVjMvsv*0`>n1b`iY@$hNZo}gLdm&%$gBCs+KrNA$)}<^!reO` z&qsp8jO8l-*j27ubqZcqP|R5VSVjFu)Y1q+5(ixK1OsGJ3)l|{Iy@)={Wob?jk zozrpylf{#Q7yHWw5R&37qEfQ$EDYh`=KLelal?D}Di;{XQLJH&?uw3;s{-1^fU#xKlj2S#e3txl+%QY->JzSsZn7~P8d@oYsWxVv#g4VZ zTcXUsY3Hhq+LS4Jyc?O0O7OUMNAOw3L!sp5k6PG%#=3~rYofB!_7{Or07}4Vpn8TQ zZ#Ss4K2TJ;!#Uk&a<2snX`oV#^>6t%RS6{uAALXF&h^n22yU}H2k2?L`N%9s2 z9pj(p<=JO8a|P&E?LOn%9{L9Mo73oRjK?e9ijRGdBg78+f(|c#!>g`veU~WoV$;&3 z87?7`@yF=)i}p)RmVOkTKuQ}D(1i{2d~mQPI)A7ZSMR_YzD}`FTdSU)KoBKiI1`8O z92W5!YZG$y{|ayJaSh;G4d^oLnX}(~^b+Z4xZpgb<|&z*Q$PCDlxKk5g)R`BF}$Yl z6&$$9;0DHKg=#_g9-D;4~euH88zi5nc? z@R)2{p%SjMY0GoL+xW?&#Ll;wRpp;g$Dsgj!6xvU}#1Ad0?FqF!WXLHa<$X{Q~V5TwJ z*TCrSe(=P+rBiDJo2f&~k>gtRPjAw?)419fBJZ5M&)Q9BtAySZW6(UdHdqpZMWv!0sOZ z>4}wYXu!J;*I6lc2CM4X2|2d%ofpJ)DTIm4H;x||g`W-5TmbrJg74#ljZn~2Kj*E~D93Z`l z;Q^)HeAaFjZ;|zY!Hr)r?a_rpRaJc<2!zQ|Tvd$UbuU3iemDxP62`WunUWn)3Yz=Nxlkq950LfAsf9fIC}^i9Md4ca`VDn;b?NAgop?t~oJ7>M)Eq@0U^ zT*Ez-l-j2LYABFEbxKmR9H0U?5e81fgwdQo6r`5ndvx#6bb9>N{}c9#TBvvLz7 z^NZCRJ5lhdsnnc@5@A@^i-^72(QzBc&^J$rEN_j=7z9qrDqWP-@H5MXQ0IXqopj~h z{TMsb!yBX}{#++_6)$!74ao&W`M(X2aHQ5qiI@nJI-8mkwI61%*x%;u4^U`BM;6FU zPoo`D<|m+$&HBpB4mCEK@vBLviYuGVPz6l9+c`&WM*vV1F+e9cY$!|yYBr_33)&2WC$-7*f)rN=V*sw|xmCoj)jlLP#UcYwVXA=j&8{;d^1Jr@&0jfM8) zRo~o9WQNH>Djx4T_!)0SUKdgljdpV}1qVu8&TgS@0%}W8-IiZX&0>TAJH!fK@GVRw z(zSgTRW?xPjDXX?{GqTE#SJwK6~uGBJMXxGMoo=kZuS+=l<1YZ=1vUH#$X9UoWXk9 zCGsUHgkoJB^(=fNq8U|2TL<>suY@iOxR)udc=P=xG8FR5ww~?~!V{-SW5B_cwIrSa!L4WRTRDoGv>zMZbwRnfONXLABl`Vu;00ODyW9^8xfz*cVP-fTFv(7s$ zChnu?#rUHA1Sda11!ow6YYv7&?Vs}RmFU-Yb>4qrmvPZ4P1hn5cAE|5CeWZ0O`U7r zbag7}-tZ-3@I&rPQC14=@R7n08wUoe(=h!YyV!PLysrAgO{Gg9%EF;Xf~q2_=}N;n zhfx;FTg&&mlv%zG!iI(>UfJ48cLbf+AWED$33|Ch{3rkrU>S1fkP3C@ys(n|a)BV0 z{$qyDp@ISwBNj298jTAgtR7y&i&!=0oRSu7(&QwFb)mc<{&G*GJE)gu6(@f=B)S!5PdYF#!I3d-D6n^{Jiw>4n6elEiukjhq@H@q>*|EErq!$)WI`@0&wGUgL2|*p+Jh>XIL(-}-MTa1%)d@}ssA7=O7#-&? zgnFDGKz<`NuDwvOP@OO9uR1VxLApZ)92Q;Iml@)#B5<;sb7f9SWuOaRxm@k%;QI0# za9+A#dTLRmH9u1X~LRg7=Xc<($ zxd4^vbFQQUSXwbu0qic(vHAnrSkF3Kyy&`Uxl2Y?dAnnbQ~-W!tk||JlWdrQuce*f z#!ub?U)sZhZ7JYxk!nJq2N+5Lw-ZZLq=Csc`+SbP1`xBsJ%dCvN0{i4Z9kvkIh(HJ zScSuOf=y0yPPT=p0;}(e=^e3N2HRreP?d6Xgmtt@lUP~gUVWGzh?pNNI zxt@O>Rae>6so1qFKvY!#Q=l(CG~Q^Vd%CQ1o=!6AZmDLtndN$!jY$TID4iK`C9b=4 zKe9N96CD1as5`?C(lj2MaOX=LB3r5BX1`e?!)O;Z?&og}OYDc1BU?45DgZu2n-9mK z<*!DJ+ARtYdHlSb#>P7G{yy@R%alcAhnB=??JQrO#Ebj(Oy&A)QnERz@(Y?+-R{g! zD|m2Y&gJoqF9k3w2SYl3Wh|NjbcBcI*=<(Q7FNEGauy`;XUY5iXjV8j`5SBUAyy31 zt{u>n)%!A-oYf0HXK>ODZYzD{Gcl1yf3?|dljHt=^?O){e>lircl&W;aVnT1-41P- z*&&8HGLj$~wPo4t18@0Y5MdDhsefk?m}(v4z{Nr6I1m#$(p4y?eLk$z|)@P__2`C9}h@9x&>?G0ZAwdoo)hOyg=~hTp5Kr)!%F7|ray zocn>V(Z=4S)Gp`W&K<%Nhx6l)g?f(e4$R$8!nM!KEnipU_>ZiuhfW<`*Prf#GUdjH zTBfF;>o@EvXEg>*AXayNKE?L2k$Xd*6j^!sToI_I;#$aBem)AryfHRw`6Ovn7z>Y# zI61~Z@;Bn|!es-x9C36Z@)7C`Kgo6_>PF5^spRM;g-Ycf$C-XQ3wq^GD&bfS({L)a z>a8k!-FDM#&!MxZ+q*sY=$MFtY=%xIAgW}sCz3AB$zwCLM+{z+3^!{1Q-Z&^l^`$c zL6=}LNIW?PHu3jUB&^8xMX(|Hwb@A;<#JQk?hBbOba7FpW#2CsSDQ^npaR=JCnjj7 z;yHKRL+XiP?f!A95VxrNclyuok{zsch!4bbWq&0)ZZ&U5yOKU+471Q5Y|%t~G9s4oK%B^zi6qG*#c z^|!gW#*7dd8Z&2EOyyjZ(I1wv;}A#z6*wQvxf>W}X}RCXr_{; z4$i67p%40lL0h>xK|&f{-^v0- zif}~o_1!Eya)L2M_hPeKBw^B`o7(pWQQ6VVK-m=Hg(50ZM+Gb=0!=eV25n-4C*!+F zxv3ocf}nZZmr#F3{Jua@2=gYWSO9aSbrU3UxY43S!$cQfIeEJS zdP;vtF7*-mUVC+gjQqWG$NfY)B>D`{&pmjzYW=#+G#PW_?UqkM!rR@Oir6ITaPy{g zTtH*~CarhJYx?2t>eG#MnQEj4$U9-R3i##A)zur_v0aXF#GkshS&H*4Y4~{mr7iUA z0b1c-WR(P=dz6a;-RA6x!0MmYz!11Q6|LS4_Vi1&r_EFX&m3>dh7_N@USCU8LxC@T zhjp%-oOBNS=R8=TygvR>?W?~^q{luNoaL7;&_cn_lunvECJC#XIq~Pwmf=)u;Iv4aaNmq_<2; z27YtInYid>2E=gY5-`SRb39ouVh!*}=FvrE8bJ42x_4X;p1&XU+Re_jx&Uz{Z6b= z=@%Le8bfQ`&+%Pbg!_uI;!t`AkEbxDYf|$RSBfn8BA$f~e#P)0%5j~|XBHb(N227H zN@c2fn)K3N*?l>WZi3Odo@@9AWR~n)Hq91{zaExZijpg$17*rVtMP zXwX_!aQ%%7-1b&IaY433qHcp_CkLFKKm}Ia26;%K_{bYqXikwvPd&gnWp^I-RxeEZ zds+nyWla7%VO8HKGwJ;Otsj6!9oUkK|B&9i_1)F@=H4eKVBk1~5uu=Xs`=75-CRXy z`PT`R&;3fVuZoqbae){th9YcrSeP7Gz{tl&S#(;0Ci%9*#1tjE0QbvCsy0`lI&wH{ z1>?2vM}K$Nr-$8g)Va@YX`sKRG*;k7Ji*KH;Y}3hGMitngGIDMJHp&Qpw;?6*>t@$ zPlp=zx~ayDZ*6JVIa(R+T)j>^my`b7Z7HjN^dz#(vy>w0D_w6rheDgRb%1o~seMoH zef5D}f+!$D!q(8AI5Lux+1$@-P;g61lP{jb0&20v1AX|F@r%z3H_EdalDKFq;VIyV zc(_1;>L0r_xc!ty7#{XR#o;t)y_#vyZa{lfQ|0H5F|(7wm2g^p5hw*~Q6LSDWK&8b zHe}R}S>7$kKPvGf`>R=0fbnY(%LXo*u!L zNza9;b!5;{pGm#p{cvUpZe(dVSQ2_LFVli_${3=b&3-E0*Zn1y&3--*sauFI~eRm@VS+@<{}Y>NfX6v{5to`c_b+XbJ=t)m0*pVFSWXtV*8f0znd3ko#*qSK~ zm*@~#-OPw4B$q15@^~mB=1iG0Q|b-YcKz$Gz6vkaxBRQaNsJCB*R$HOyz6o~6Sv3b z?Z$gp*>`-=>S0$<*4aPaL^zk^U@iVMo<#Dll!gIoKX6pvC{^hT97F*B439XZZJ>wp za!^Y6-Bf~PO-D-j_5e9^+dh72$baYsi7H|`>=f=1K3}-M@gHp`d6TLHTi0FzL2_I7 z_D<7z{t5|E>|;b#omHYODDqW;%=OE%g3H0U!=vs>Z$)G=#gPGSS0Rf_E0r!B(eOn{ z;-tcv8T?<-VdNDj;ivVe`6Nz%wi^=^NOcf#%18l|Tuw|jJ^tPnW8gw%s9aX)B~UL2 zH2h|`_!Fd#U(5$JI-J>fw^*#zCCB^aeG+s26NBUOnX4j_PjpPPh))tHQSUcn_+1(MY@lA7;p2==f%14kIAgiLE7ds2bB< z70{eaAEDLBcW=rGQKxkWlBI$*&ZM?<=vYqrszSqh?eS?g^;JFBPh za-~@p7_;57MIXHko?Et}WqjPgnDK=wG|TNQ7Z!aB8WzN=KN#49ED2KX(_f-kLAz7+ z3SKk`)%|Fj+bZ0YDq?b1gI|M=E1EmHgU7G@JPu+0{4Z3Y`_4P}N5{#wFZ6yM&ZTj; zTb%#v?b*eqQ7*uetA#StN$v+TVJCr-F z{uJo^bnMmMP&zV8I$}QarHJuS27nC+inq&ynYG5dg%NbB_xVT0!_m!o!_i7_tivO^ z!=P-rZn|7#QnCweHy77%(0xqB5+M<~eoTObYy42>0^{i5z{n zd&*p3Tb?c;t<)xuX6O1pfI?3$`B+Ze^AWF+%u`20w)nv}gdj?+J+x9BCJemD=AZu; zLSfP$m*r*Jf0fWjjdRpGbArUgYDSyRqbGj^5^Yuk9}%e5@mOm#Rp&&lrHKsX)W8{- z@=X;s!EE3REW~rAbwQF(@GX=|N9?Sz#?i5e#zW&dk6SY0F3HLtG*O`;$n2R@8T%>B zQ08P@N)tHXB}%czNkq%d!C3GM6IIp}_*~2Sl~oDScIb>Gy0Fh3dXSI{sR3HQc`M)@ zj{2IXlFL!^Mh);_po69SyQa4*J)pvkbVnpT5LMIu6zP|1?bKEwNL(2fb1heZjvEXh z&;X~Iw7NB|CBqG#^-{7WItqAZ@lpxP6kQWy zzTdtnBo@%HS?ZQSYyFU(BrVbN8@DU?Z|0yAR5UB`KahhPi=uR;C-0XnzLRC3l@=qE zVV5N-;HqmR!lUyboZYHOX5=6eV@4be8GEd=q4C_XqOzv1SO321zjy;oA80G1IgD{U zJ&~Vt)JdNrzYdX!$|nKXgq>2BnyZ{G30VO0eZsAD6bb)`@UrLw`$6Gs-m(l9V!;0a z96U#Y{ZThp{c`@M03(SG9fyC>J>I14uSVr(>@XkaFTIBOq8k60&?*0mHxLAeh_wU@ zkuMb7`(!q(Kk-?QuPe^b>fODN`Jz9NZ)(X7;r`i2*NsHl?$siWBg|`x`-L6_t3k^$ zSZ$u6go~#@%!K0h@W`jhg^TfDA4Sts^wQLhAiOBEE^YVH99l~F$-hUsh=JWZa`e!x zB9PYstSUf#gmAAVDsGI|%V?%`3`7%YjBRY}8@@PE1=E7278rFBZHE-URFb;c+I5|! zZ9Bct_PaJvR7Fq)@O1&4==35?kKVf5_b9pyhENFcO>f za~%f$54PZI^3T)%W(&Z1f|<`{q1|sp-od$Gy&vqJ4B8}?DEUMmK7c9BHyZWJ+4+H; zdvk(k%{Z`YczXE9G>Y^akszs;>%&R;h<{U;5&uA==_dW&%;P<-3gC)o+B7BmjqPFc zfeY>z?gW=#P+y{tUu2bxkwB?LhmFmX>RwZGW1~~5iiuX0G5<``8&X_)-26dklWZQ2 zjDN#pI98Bg+%_hj00mml6{%ll5!u_?mV1@^@MRqnxBuW_y#gbmPJ{Vj^ZvZ zN9w#IWN2awnos!tgfi}UNPWT-l4f6HQ3~9qbZsjL=t4_J29X0pr90##uI<|c>|(MV z6)Ip1;_dz)biuwHI6MC{l3RltzvGV~LQ5Fqg%@uc0v(di+u{F-7YM*@e36kEz?mGx z==j>^wNga41)$VeUXHc z)U^*P(_fMLgCMO?1e;4-E&)`4pQEUzd2)IS+?xL$fuGoJu|)Jli~B}_Z&v|icNy}+ zPW818rcw3YTzN@4DrOctKyTdhv+woktNdSl!TBt2w^PGz5;)kQ@4iVBG60S^&ZTPG zg7E@4Xm0XDaFRIqO+#^YGQQJC3Zo@Ek712cx0_8NEJjfU%*d;i{1;vz_0erQ*JNRr z!E{*yVyO~Cz60x`V1b~OUcw4_HYM9dvV*+#)X%^xp`ot;MC6Js5iSE3zt-e{(^?JL zUO3WtC{pS#sxAkc3;q&ruWX4?8%(x$*TSB@&9kO z;E0CK_>n-aG*%SZb~mF-U0|mw5W8r|fb9hnI8AFtMOI&1h|CnQqO-aSyMy@?p(qdYR(+B0p0;NxTTf*{(dj_5kXql-I?CeeaMkL zvoAGRO_P;O(vSWL7i7o_9$M5VcRh2La*6nVemxk;T9OZ}mGvc);SGTVi1(c}+$ zBGe2k#H{p z%5hDXSAjE=-#K_v?>OL9g<$;EKWt$7u?6l&mY;p^>A5%dd-iSv;Q!iOt6@FUa{ud& z4BRuyFJFWQ*VQx_v(#mN^_bz|RWT5dDiX$YpMv5mo9i113=z@s`q=(&bO8&EdIJMy zAe*9$DMTmQL^k-8)yPECC(d9&GG-Q4G{}WUpfK44S-a1IGHZ3{T4)cgQQK`rYATK448J(?wS^pT6r|> z*x$y7Yt-@PlqSIpW4fd|@;7@O)J}*~#FX^pOPP+yB(&4ZRB#v$%oYrO$$TmSQKsgD zJfnRRhwQ-7{|@Rjwt8A_D~k;6s5TmsaWazM-39_*Vq7SG7J(p|!CqcL#_x`qFnz8W!g%G2lTMAWpx>OOgbBA%54CRghO{=L3G`a9h#?Sa6!FN!Hoo#*1f5Cde1f~XyGzi*-7Pr5HMqMI+%>od3r>Q&YvJxtxVsZ% zD!+Sgzi;>S^jb6XCs3?&_CEXjeD%l!G2__vuHuHWk^Jd(OW!0StY^h~)Yo zPQm|V0bt`T%=9vBYhB66eVfhHFormlXzg$i*9rNbKI&qso(#d76^ncoNYbmFu!{Vw);zKpq_}i%+|cr zk)ZW8ZdaiG4_aVqJBtLPDRqjOpyhIY=*dmdyo)U|0CO?C3rCcD;@H>62gu7OI~{M_ zX?gXbNnQDvPpq;Jj&U*G0)Gi?QL(l4njIx4x+pVV$7x$}<*T57MCbfWJNjpd4|#?W zQP>J2IQ{gN4~eX@TNJw%t}R*%i2SDnEO9`i_V>8F?R?)5k?86tZSdYcT;f%2EX04q z0WlGQq#@U0CK@MI>=T>|2dF4Sy##zS7^-%mvg2qbzKQ*xWdT+?RkFl%6T3=?z|^CRy~`#auW&z=9+rjobNo)H2~TEtyco&gUzQrqj9qijzgbZ0Da3 z9R+KpG0BAYpUD%rc3Qx{&#}+Y@C{^e*CYqTYvrZiTF@e7ZTI7CP7U0n%{pdW&ee5Z z--sb`;Y9OwGvCVKT~>G96aEN^IFU#q@OfEz$Zg9X(W&bevF+>sN(+3#+DUh?2=_!R zSp5-;HdFI=UJi78^vCJV&^2mJ_WS-V3wA?BedU$qqW#Um4$eZ18Er@{!C1h}TO!dfbI z=kVoIZm%MI7k6Y!AVRr&U0U=S!mzxj7!X-de4_#+!&k15)?16V{bB11gjSuL&=YvnHCzRi(>YK7dTE{uPC}5IfS`KQ~cc-v+*|7nFCJF zIR_b63#bT2GC`&wM4;j;ctub%&)$`(ZInu-3 zkKA+U1Z;ZZLj@_Y=b#^9y%-@z)A!n#H{r3mK?e!C`yTCS1fw;Lgt*Oe%A$OXjz6My zqum%xT}Mr)=xTd>)_Gc!P8ZBQ+nX_=O|Mv&x8VC5Xe~iD81luP?VFl#wHS_1tzr1` z{YND@nDOb8#U;K{okJr(Ij!-ywnxzlm$i_%HgU)I2(~)I*_<2GdoAOX`3DOR?`S7m^BaZ>J2 zsv~4W2t|gvcpOHzc1U%?6Mo8nzPC3eJUDU*ah38@@hB_1Q{8l@2@ktHJs3@n3Jcx< za)?(yJH6#wGz!;TARp)dYvK1=6FQG&sgU~NcV=5sj7qN@IkdW27K{clyU zaF&Cat9saJ^}llkrwRmwLiGhhK;4PhJ-n;~?UnoER6*Jz{h}wY$=zlL`~i;(nHJON zBDMFz#>EFwswbB_Mze!Dvo7?~chg-ci}D1B@&E1$a23*Y_Z(a0Q*wIU{Ej3;Yk#rQ zRXD16w_F^9!-TpMWMkmz_xYz~JNPSwoah1A1;45>YCC8=dLW)2!UM^M+J^IeX)4s&7Ydj!kt5J0A8G|mX&@g5QKpQK|wG8Y? zNf??>trz9_LA>i8u|+m=l6dQE?<(s*EMSh_4SgI6HhAKgV*LZ6sqXy@H{N+2Xy``d zM+6&|Tux#nJ=&Nsz}rt5a|rZA0?Y%3%kr{%g741RDcTi55tSfRzOSJ7OiD{u$~7c8 zAYFk>8e5I+MhJp5h?mhK;k07TC>NE{s#xX-F8og0+k*@gUQ6@c9Z8F?T`K7>KL~6& zYM+`Th-k{DCO0jHM3ILS%w$x&%-8laF>l>mFZz7m*AOzfsi_;lmh~Y2di>ap**fjf zW%(z1xqT9CzlNj1w*l#sz5TVmN8RhTo$lL>!k@>~5~&rt9_GXp&T`jcY&P5Kw=R`BTsyfZTaDTfp{!YEAS6!0ER6G99-| zv~M*fOGfSnmyrm;cpN5nq(V2?{rx}+&wfFG^;9k$bBk&HW;C_>hYa)pm@6ToYe#~rPXy5=t=6%EH zeK=&}vRap*p+(B3sz;XB=a{>vHNOXKQp+uW0Y7vH!3W}zrhW6E8ZNh03ru+|=O}5X z3)8m|XYvi1*Ty3fMO^{cPkq6CyLKqw+5CB)_n|H}6A6G~b6Y zK4sX>#1yfQEnAbVZi?VZ?-;d@f4o^Ryz;GY1FMffWnd?Q?+b3u^a8z;>&_Cz4a$n; zvLhXgLm{@JF+1^2XvBNP%)d^CZGT2K0LKa>wr{^^Tyf8LFm`D-u*(-yUX6!AmI zM<_xnmg@6m{bm8%_*_*@xERnYqe83~AsQJhYJwxXCiNFM#aLfk#}v0Q&RMFnk&-H- zk7rQ}M~4<;-?2*iiP2<$7521tk@@ill9H~NgMg8A`A}n=f5Uw0r>tb2=3YbVq*>9WlgN@8!Fzn#a1iBV{UY%x`}-&69*`ig}z3 zbs@MNCpH=WonUG1vwEGL4nf~a8ds_*oy9STf$k=!B+H%d%>htx^;Vs>4Ai~xSax@c#BS2#ZoM(ExWGfiU%)bj>7n!Wi}M6 zd$bDlJGd{TQVBK=%Nf@fJ7x47o=1nu! zb(_@nZoW6Z5pH@Y6h8~zCw+Lx;X3}A={;^iB0SeZLqFIH`A%dX-#O(`lT}jq`%2Zt zkCCo^{SCNRh&C+va_vv& zY2ZZPn5WxMr$MbsESW|>5Nr<_wIT9lo?s!=dM$3a*$;&UMN2~k`0*Sv_9+eRidS&g zxJs{^kdQ1k%USV-@0yOS<1vWY*D_956v4F549$c|Qe5RPi0cbhj{cE2!k(@aT^F7_ zNcAj!v@ggdM+uoIEcM5NH|GSU733?LN5YzNh}lCx!P4&T7hUW#`T=&E82%9l2=4SG z6uV)21Bfk}`(KSfBo}^tl}#x>G@6$QXs$O&Hrs(3;GpSAwr^_}bDiq|zGgfJM9I1iy^ zYhw7A%v+2Jvxjz`Z56(S+^D)x&Y%{`f`776nH^t%VEbgitX|-Y#$K2KIiTMN7H8Mo(MQZ)v!vH|bZd{r4<3PhcToSB zIeZZVD@cttByXq^E+)7Uq>J{3g;lOHs}&@sLhT;B#yNAj@dNxW3tG7Ol0fKzpJ#53 zgE~^Fidl^y#VuYX=j6$%Nw5Y1P}?1@rf$(Q>h9wK8L-&>8yrk~NH))Ur08K}e#f8Z zYli{fFQ(s}Go#77WkEeAvVNwM!3JTilf4mS9B}#H!NFrQ@A6+{;11Wwr2ex!`SsFF zBR%Mp+ZS(@Z<>H&?S5PMoOz5tMF#7khhN)7tQBR~cICKA(Zn)w8n3rDJ(yxOIs{(X zyOs(`maSvzB}}Yf0U1t?zbsX*W|Z$f(|LcRWsOZ1D~Jz2>MAB0k5 zASNaXH8oe63X06WZcJpb{CAs?QvDkk#3Opv{SRP}Xtzb1N3^JntL<)FbHd2N5<=7r zVeCUkxu9wD7FPId*cEQmYHB0&Q&?&8eg5o7>s`NO;^!MYjk#Zatp>?J0BzFFIMf{} zjI*+-=enNXDDB;izHR0&y@2Iq5`jda-1o7j+#&y}w!s>J+p2Q%PIjyRoSsj;jPQb9 zxbN$HGHJ}K@xi27+m(K-#c`spOGk%IO}pS$HV%(LXZihM?7Ir>vFF{;Hmc@Z^{SQc zTdBcI4~LjU0}>MDZ;NxzLi9Er8&P=Q=4fRh3@x=kGMlt}wM5ErQ z)mNQ_EaR^pG(01jP1KhCB$ftMGP-liI>zRUCVW0-#84yLFlj+B;tWQ&)b^+F;OLy&d-CK*7Zb)0h79Da#5xs0Nm$`~VA;=uu< zZ9nnggNMUMwt`y3Xbxf?G821CBx^$W%yFzLr1A}N!1x)c5mSLzqx6Pon#ahF&=G>j z+Aya@)E)_s1wosr8Gy8c9Dx|VzpXm9c}8DYg zi^b-^uuaNGDY8KxyiGG>fFsaH%vCM;Syne8%V?P|(j2+*L9%RAhPGAKtGVe1$E(?K z!6&(VSST6vX+{3@6_dk;9-F~*I{5-bXYt=2tND=Air(3qLJKG!qOFBl?7kK0~WNvs$CM3rR@M{flGmfUY0i7ogQx=;jJB22sA!0D#_YsqY~4sY@1Q z?KT>mh#Fuy8&|$_?Jz|qs1MY^laUFt_>{d^-JAcJv-?YdbreI4#I8FwyI8C&jY-zc zAogd(-hBl#TPF)HS6SD{IkyWj@=iARreMt_WJ^l4W<-W?Yg@@DvcL|+AY6$Emknrl zda*O@)woy5^0vw(kDTmXZPy=Pw}3lk1rr~87FcIggol<*yO=-SM2oCE8Xy;vGLk>P z?mbEyD80lby8Dp)MXp-bZZdJWaf)7_Qy zbLl%V{$6dE?Oj6cH7)zUmoRBjs0vOcWE^#Bip|2sioOt?bZTl^1N}`9@dut%&&%3U zG0%fL!K@$!0xq^QV|F0i-81&OSf!lb`SzN_)zrxagJd~Z4J+fd4h=3r(#C9DO zk;ws$Ib6nRe0rz`#GaJOfLQn%!U#t;&|<0a8(kCYYz!XYEVEidnOwk4T%sbZ%OnPweJsFTXXhQ?6_+K#;&Xo4kNScQh@`X+){#CD6FA}C6%(# z4lbVfUFu*|pn||fwhO}=s#Ip5K-l0%f&$ekCfa}=?77#oHdg4`q3g-+HUir`!|5EM z>D+qxF?fVyD6*v=5<(Kr5Y^gzJ;Q{=|B>6H1JP>)V|v;kt#c8dm4XC~3Bf94&ptKS zy0p!JQ7zL8qsh4^FYb!lUF6I0Lp{6{Bs}b3(SJl3MPdfF7b_ADHN8tcRNYl@5a-@?M}bi!R2a^5{B zt^brAtP1=heJn|g@d8bO$`g!a{DUvm5Jmn-N#6%Ld}W%Kd6Of2@!I@k-+(&RJ8+F@ zeVhSjx8!@m{(B$2Pt{pK$XfGuEu)Y|JiVHjs0u>cKCyFP>7;a?pNPTxSjnG2 zR{4t`61~Kiz%gQLI+idj)l+WMIUk@t$T)3FIL#LyjhetT3weAn+vU-}Af==Hd3cJ7r%RCtBbb(}Kev0}#8i&*HL9*#AKOV1)LKz%gepe}pQ!2SNY z6o@Lmt~;qh5-|Vz^F&Y>zJ>nSwezsBxk;!tK$oSTP$T=XFU8OH}h{#X%Zud>1OelQf`bwdG>TdHEFA?Yc}myBMW8P4{EX}b1A~q1prh|_hzG(EJTPYxg{Lu^L1`3Z-941PDsbOLus%n?t0{Dtn8N`C{2V(*$O_ydpLR&ED2!2Z_0RyN9kRFn)58AdzM0YGu zE^ufKxxG)3x`?MS-{ztKxcv_0f2wKIAR5 zL0CNfeHqPM*3S1b2Jb`>%RdU9(h%#$As$sQ(|a#xzbPS$UtoU>^@`4X5!G)s9$n-~ zl=|M?YM|q!=6U+SAm{j&HuITSEt>lnYcEr^G4J>3S>Ml6pTok^bljrPZ2 z>=bP@5BuexQ8{czc9k@ms_ij*khkA{=^&B8W%Rh2rP?X+#+(F`_@+V01;sB4161WMBSRblGrr&EHDb(R(P_EWzcJ1%|v{)w_0 zvqs(iJb@5_Msj#uu6^_me2ZwYN61emYa`l7XomZViNGc-U%l|CT)t$vFA?Rw5r73lO~+O)J@v5%9dk;0l#VG0kT$5VE-iv57fb8lz_ zQRt`b4@f?s!fJKMSV3*M%9S!jRujp3G@lA$ln9sq&3tP}A&DdAlluZr z`^A5<08T1cN!)!gaXS22nwXJ@Rg^IS0X!#LUBvDF;k-{M?N~F~ywv4*Ay0bj~dv&M&#iPv8`h+JjjCD*U!&TJaL9=;IY|UYt-?WE|yqDsuMu=>Q zm$0v5PIMk2P0;{QRjH-&rQzV+4}SaZlcR&}62{vc*Ikh9#+oT2ooqJFf8l~3{}V1q z_%|--2jhZu`mb)FvE(-2l|bf&eSQOg-oWzQ`{slJ=()#?;FV!mk=HNtV5ZyUkG9&5 z_)bov&EXNZ$RE})ZD#N}!G(uKi{h2xW~#^Fam{`M$veI`T32BiKSw#xTyeo>P+%vd zGgUt*I)$nIDLQh&m_*Joqo6mrzb~r#t1dqdu@?PNyO}b9kU?4N>)tQdkv}H$pMaw* zVX1f5%;W%Czoa6qzp{WFa4P|p1?Qi_wQ>Pxc~MLj-}C!VqhT0kGWB2=;j=Z^$tgtL zUjEVoC9*s$9lvtn+zh9#sxq66)UrY*8zJKNDa2(|p?xs`ygraLq9i2>9~p3 zC{qsETZpepue zGhUqRT5HAV>Bgpz6E+EX)J%OlrHp}L)y5mtQS0zif+|~2A2yy4k!umQdC52AOI-R7 zr5BnXvZcQ%zj`F=K!WdE;@r1D#Q&Lr@tEz{n`DCkZJn+{M!rZIogRI=pCUR|tE^tnR%anH8fVQ$$g-16hdhnz<2v(apN(r3D3_m{ZFLzB zeNNb4J_14_$k}Ai!$2`%Gm(x1(iS_Vb85`>2V7TiUtq=&@K;6H9SUE0$N>z&Deq1_ zWF5{#)5fG3{8P(mp(U+69YQ>Pj_QC!s;dK`($@t;blJ76sE;kJKEmZpYf>rs2ZmW+ z5Ps6ult>YKN;>sil{lN??n*hNq-6mWalS{X^LlT1i+*`&+$P>#PL-s%w7>sKa7j%5 zvh2hzWSh8swnCm{)f^ zJBk(bdTcpFLb$sqJUnCEz+~q+pfpY|N&aBHv^C@!&xY!LY;u@a6gj4AiGDu*O9`j~ z+dP~f+2!Wk5#zU~nu?8Ne)s`H^*&~BjjREJZ=IvN0dbVF`bk~a+PRgkUq4j$lPaJC zRt^cvMmNXpK-C|4x8(hD#s5_ZXAgVvPUrCc3U=Z+OZYT}dG%s^?Wp5o9YL>q+`Ao% zf2Sf2apOVt+5fiqF3rCEe5XRw;ZG6?Dz)r$?B*W^>d6||x;zfUssKX1*J$E5wv=yv zlzR^r(=UrO44y2=FB;@y*EhT1z5QIeb1iZJDW>&x za5o6r`DVdqc0l0US_DAw4>ZHcBz0=*l0SQAcYYAWX)y?BdWfDHDPgvU|A{cjb@Ib&yEyO z$Uq>&KdlEed1jU6Vh*?Z4ylIjlUpgL=syiND8)| z`^dn;eUC(uHHW<7(cu0%3=1n&+mu zHeJT9r8?+y;E;Ft+O7+8V5f+^J#V|`rG*K-&-32!kf740Ku}W+JY}SO}Gmq?H1zSmp7r7+$Bj$ zLJpu4ESUH{gG-wpoT|nG%bwWpWP3lvyGNry zhg$yaGU%SW%mx6-vYN!rAsy=6%d|-1NPu0z zpK@sc$Zs1$N#Ki_*i*Y_6N*&B^|D5^2oa&nw>%RZzoy4;KTW3^K&O3!$5;Ju78GI7 zQKsvQKu6X&^@T~wW?=$nU~W@ZXpmIy1v7ML(AV{8Mv`A>L1TuCjDwlWpVmJlrB@WQLkF~8ROd${Pm zKkDEQX;G)@*u4(eZu~TSUCi)C&S;v@Ir_VvV`xbGXFl(wyLIMgRl65(Z7RM-=f~dX z3GTlTE3R)fpg4$HBzAoWU`DB$*T>e6#+>if#T1~tZKQaX#sl{lFwq#q)3AD1G-70P zY)P`BeBYzc@Tly&Y@x@$60|z22mMoH03JqYN5w~Ok^@{t?J>4RmK65}30c9%M-2Yx z1{H^%*})GH>6rs=v1nLE1)4aieE#76g4QURIId7>>lvWk>bi#_Qv;a-v#mV%eklIk zlJd|~>EYO06bf`WExAH>blcgWHKIB);HUgkc`w;HWFuujbX5ER5bsB6-aJn@VB)9G%b1<#Bq>>Q?ygtOZG2Wz+2W@ zQU(5ldPJi~x?BaD?}DUC*Lj{o*2bjO#5Vkt0>H4KVwF_fz862G^~ zOLCfK<9ab|qkP9naK!gx>ox`&gC?(}U~IKr%%dH!l({*SB)< zs*gKAQwhl!C=AK!2(SFBmd%DW=jSAVD`!2jt;}xQm}5nMdmn|7t!nm16P4bMwjBI9 zbb&u&kCo)4LnAwvU>gJklxtF!kTCGD+awFaN$CuMQ|nSR)lB)WwYuY#JJjN zjwS9VF8L_WHEGSWlp;hi`R((27Jagmrm7zkg0Qdv`?qhaHYQ@~I-Mr!HgEB*ncifk z*bu0AJ*Cc+_6&e+5!dgr;7@P#7WVHGbIq5~c8JI9usq(Q! z$iaN*`?ka`~ly1>!^8g z&$v5SQv&|N5wjQ2tW&oy$k0#4?)6%&0ZV!eXAU$1dIAipvN#|^&cnIKp*S_(ycHah zBwK-frX`*N#>@G*p@4@d49X@XixmBV@mz5R9h*;hp0f%(rq_6TnX}nZN&~P>pD(WH zW7}wF%J{j=^PO+_Gs~U~(-%_>PFN(Ls+<@k*c##d%ccFWNj2ScGay_4cDHOIh0t*V z&N-HoXYr1;kl00O=u{)AfAANi2jjC|@c>w$XkwJ#7}Ldh5QxLaQ|XieZc>RN=PHNTx4V_(-J&KBfvjyTNUm|_T1pRH@FGm=tbHhl=Hm2 zgHA^_bHo1(xCLE8y33a@#tl4DmODHOlhNI-360DlE)W_YGWK!1b@{ zf;je1>e>?2Ja%*lj?DojGo3(dPilcLuDtkaocK1G`*GInR8lxW0F7=)7inxsXcven zG}s{CA5mWuyZ>y`%OZIWiC^!7r`#G~=yu3O8b;AQaD1H2rb_X7_-?|H`>2FiW)qNEuyeqKkbf`30Wa%%zW-~kD!^%DK$Irxo*72je2~>&q-}V?M zG4&A8%}g{IO4ugnEHYQ*9ru`AjYqAS4kDpEg4fb9oiVA2F|zkCTLZyUE!S-$B=EdF z3Fz@`?^NUL$vhm(yx6b0kG#8IiG(S?Uc@sEp!HAqrMk|NLihx^Yi_$AvSx&_IqufE zW`B4B-@^7?=fzCC))c%#C1e#HT{>XOi~99=!^V+2J7tTiv8E7KZ?&Ss&Hx@VP{Fz& zX7yVFp&;v{Oxs-k6_{nGYL?~Cx?0C>@iBN=&4BlQm@EQ}AA5dH8CawM6tr~9=wQ9w z9An3Sm`Bx@F}coaD3{9Mv+gwi$Xerf{XXSVfw_k z(6iPLN^Suq$;*;pQtTBe4!{-x+)+YN!Hos}|7k2pv0em<{TeaTU-)d;3Y5hnHFgMH z-Jm%%5bD0boxy>pW)#{@LhQ_3ip@j2jVS^pnuCZ9d-CT8n5JAck+UFu!Hoqu|7t8q z&~Iz|o+f~{1y^_35CU3Ajk+er+-Cj;SbKU$8o-n3Ck z`ciQl#55N(XStHoO>ax>GReDvhku~@=d=0wa$`yKt`YS9H?&}w;<7OS{DI^nypR6X z9?`k3NGohlHjr|#+WtSa1w)nf1nny5J(QNYN~sUO-TL1c5lR1J3zWVD1~ZtUyW`d* z5Z)_l*e?|M0RB`E84zb9VqvX(Rs*1S3l5ex-BB7r@gTxJKo-P>xvt@+5QeN->H%bw zNl*<-uM35XkDV^}i&@nvI&p?^d=uoRP)6zz{^m@Exe_IRu zw0Rw^b7!Eo9CW$9Sf#LCIuUtKDlDe;Wr%#k(#Rw?oa4DhZqCbi_4Dz#Xr|x_3Rcabl&h!x}7X9%#3$b zZ0DZ6Fb{>r3xp;Ro}*lT%k>L2RCvBOTd!!h{D&UjqQ zw?kpgyNMHp}0qdq;Ec=Y}9;Cl-e(wN~iO8ey={Myl-V1zDtr$W*d6@n#q z-W(%f!1O3@=Y3f$^Wv*k)33a5evIkdXDT+V*e97doOP89&I#qH{n1u>#&Ir;bD!YK zBHWcK1}B5eoXXb@3rWfzaR%J6uB&aoPRYGw;xU#3P5am(n-nk8jIrHG5Y1KRYP$Vb`<9O z5ew^@qT&>~d15ZxRwtqY@UtNJuz0u_!MuZVXPCq3SefFkG%NU{*`PxBxX;1x#;+dx zg`US~F}2zIsOOvJ_L6(Y5nW1cbL4^A6{2J=QR?bX93MIGrYDGDLoSjT?TUgNv6QZf z>Pjva#2~$^)|v4AP&@dyH9_Rt?z#(|OzcPxbzM_8N({iAMfnvn_NARItUKjOQ)eR@ z?04UcN)p1}QbvIqlPI^y{>3aaL7Hx6A_Iy`XNBT5s8e~J%*V^0HOS~O^l|mwJdOrT zm$NRJ#f*C9bai2Yd^hkxg`dJMIDVuGMNM1iF_ZqjB~Vjzg#E0L6^*IqRkm00?Hb=v zV1b8yd&T;2Y5m*r3RjUDRdu2JJDf6;JJ!r+NWLX;9WO=~gFh4tLcI-L^7^EESMrGP z#bc_uKh)JhhPAfE_pIHguhIvZ@Aqp{tQaI6FF&lFR_MVq2lB(cm)^fA91VXiYna?F z5?dRA_q8f@Z?6Xm(WQ+Or$_PDRo#}o8bRmHo1q+UUq~UItz6GL{hMN8;B(dncY@IT z928-Z@LFbtDWZpP-{-2s-`6BAro7fS?YH-K&ntE3dssDtqywi{TW}Gi{*-$kcWH9& zNgFy|NhFelTV4FRUZ3;JgxR?qCe+EHn~m5Qh*hTPG9=Kgw^%?XsSIrMmFmm%G37CZSQ2BdYgSxu%T2D>oPVaU9Wd_fopL5lGV|0*s3_0h#IJM;{Fi#B2iOLG$NINia$}86vR~AH2bev$G=mJQTa;Sqy_!2Gd|DbPZGBhXtNm2-I!%;wE7s zAmn%jq~#}wi<09pfFo#*Jw@adNuc5>dXPIc_+pWEUjq~R#8M1{j55%~_bGWKg1880 z6R8A-{JvU*Sx@^mk&NH@?WtjdIogR|%zBxhs}UH|zjxYDjq@0j$DGRumGj4ds|q^z z7wpt^Vr6+JfR!4e{VB7JQ+q|?ut8yPIk@G2n+kmKZ-D!XrH-5+YUU0>gzZZDq#RlbIiBe&_Ia?ILMFsP; z<|TjD6`yy7^yD93>>BRCS;4NknmAMe@`%CC+L5mG@jpn$;G{qyD_1pYv6IX9i`J|& zT!+=(H&`?YTweDp!y(zZ z(D>*tx(38yQ!sC`m);7@!2!zG`FDhwtqyON(#|M11cy}Y1<#>lpt+#D`oM?20%ar) zAxBE2p(T%RUQ5_1e!o)V(1R13P)dkV}x>+)IQq$~$hky3|+NovisWH0Wzh zeD40C0?I>WMUiIn169PzeSUrPyjkwUSPvx~{GnmKFFfX<5 zB+}m7{+(EDN5V-D74F?~f=5sX?@MKYm0_E00FO}?aYdMKj)uSdy4}7Y8_tdU&UxG9 zvk2nD&QY}INy~*yr(_c_prdRx&c%E&+V0v)!yCQpL%&yzmA<8&b z+tXWuxH5Xe@+6t3)yX!|3)ZgQc4-7;S6@JXtdbugueWFq^JR|rKiPoZ&KC<5xpRrZ~~2E8P1uLafu<_UCds2ZIxvhxqOXt zyS=-`hAvobHM$O5hvqRHTbLoqjOGlP_<`+Xq9E0`S2jAHIg?w}({-odwPt6H7OUOv zWV0KX7Hz%ty0(XY@C{!so?m%+5R$qfsj_1<;Bi{_FP%XQC+SutvMb*eb$cLIu&ZXL zC(~)^6(ae`5|Xvnl~yoBNhhNbaHZKgWe4>bkm}H4Iq`d?RB>8I_>=Cv^56V1S*U)p zFlQgqgw{YFj?rU=-0dmIAfTyxDRlaH#)1;6V0L+Ll05aOd)iBxp>k8nZQ0=d3qIdglNFWTUa zNrL+zY`pD?RYQ=E&*K-xLz(=B>WnA>-A8UE)hd-f+7NvPNFf9LY~97g{ZWo;R53rt zI!jJBHE#Z@TG_cfv}|!iGPgw4CK7%2e0-n3a^Gc-EotjA^#Y*#pF5PFQ}QljP~T2E_Rk&_&Zj;impEl zu7;1luWiJmG@xb%tZjO?867~EtCD_K{|My2TA)ArckhIaa_a}vjP^e)dg1&M)o z&3ZxzfopxN7vV_{G&8}Hk&Z!iQa-eF{p{dVf#r=`+t$xqTo=>a#$#iRW+&{OH_^;I zqU&p4dwUoXeQoRp=wo6kS2guuhRC%YCcTh(s&8^SqqEnZ7=F)PR(oowAsEoQO@yj9 ztusUqcyTB+9m<=O=>31g0U?cHQ3+Y6a<>d+A8T%ZCl@Bl2$bF_YoVT83#owX2pk0@ zc5^40ujH=QB?QHZ;Bur@Ug$#vFG%=EE3!G0B;HZ$b-;!;o!3n(ea)%} z$~`+I(Ta>QA-VsZH3GpFoe=xXyTCFGrlznXL*S<=Rsgi?U^FXo3j$|>XjLEJl$zO^ zr>@Ep2+zR}*)*m}2Aoh?+%h;r{LzJ2<2s32vZLdFivvt20==RRa{pHx03Ko-Vmpf? zxf_Yv!!%i-)TESs+I048kv9wy1ILyBwhvO*0G~(La1Z43L!&RTs5i`|6unrY%4vj>D{n9b+r?iTrv? za7i|zs(T!Zx7J3M1sCt~6H0U1*r}66t|SJEIj)~p2*01!2sq%7u7WK3_(TC>0V!Ek zWM9@{nCG9nSB>F)3VXN`@dCK$UtL&q=iY=X!h88ATw#^aw=}fqal(=BOWT3Zzv{d!4`A0Mr z7$LbQuadWQ#bRm3Bg4idHwc!lzXGJL<3aZ{jV1IQJR<-n0SqmypLQf#OS@285>D{jmMW3%E|}jtCU= z=)o05)E2K>j*(x+KV0HR)>o^Mr|bdc0-R#BGhe__LZ)k5Ng9UU!kdD0{g!6tgbexe zks7xR94R86pUIf1-NbOE=M`bu3Bu8j$BY`dBejctWl(UuD@YUf%2Jh83opphcU}Id zi>^ZpzDfb$K7xwX*_yXDupq>};rx|60|3;H{Y zS*>;)Cu%;~!q(M-Iz^;5)#9CR)4ugUYc27NMWk0;>*bxjxmE zGD{YGXXKO{TPXOjFRYF^++hM(PX_1%o+~&eaXH9OMF>u{>m)Xo);08wRS&q{5A(q# z*&(q&pXg0ZRZ0tXnnPd1k0x!)Ziu5zfe*#ASHnfO@+`9-N=o-#jdkQ!w-{#NTLMcE zIK=QP1^Ew2^#XxwmyvWiIH$#tR-8@R7A4?toS5U44H=-+$&G{SL*pd1%dyQ7Z}2cF%$O@p{Ba*6#}P9IEm+cC%DG=Y$1t+^Zuj_ z*Q1i98?GN0b|-`G1V+|F?z^p}ynqFwBLOWe{*xphSM;B69G zgZ@_U*6!0<)@I%ud$e_?w!B#U6-VDphiNyk*BYaDF>eegu3@)uMf4d0uwF!Bwa^c7 zsiNVMnMF(f31=FL2YnxRJegXQG1>j|Ucnn_ao zF_3&RACVqWjZh9N)l+Wi;*q=k?Rs$JJp1u~^%oGxbyRxOG7f+0bK@CNn}1VF^K5^5 zP59>5+U@?l@NftiVstU^_P!|iy|d4FnDVv5v4K!vkmdp6jpCQg-T5bGq+L!u+pw#> zH^1q4ZnBau%+E%EMO2D&8Ibh=p4$=D_*>)SF#5a1uIY30p9TE+66#i8MP~ypEh%q? z6_?Y(D}Mx$*jB#ocRtavz~giR5DLvE9MVD4lv(vT1k}|ATx`c zE3GJ9>QKA;cY}fu{Mt$f2-|M*SoJcvG!wS-T!{Z2guvI6`+DD=ctm6Czk(28?&d`k zqXb70;3Fy)Ne&X~W)KK-9HljE9*7s=0QKzsgh|DzJLW5o05uQDoa&!{&o`OF?2)6Yh*!4ByMWJH+ zM2JwmVYSyUQaX=^UqURJ4Ml~E5thlTT@-95hiEziWARkD`sEd&`3uWScY`I{et&2} zl5JLmPuTM~S};NLZe4TO=Q7xRQ} z2}@qpeMEHZ+pM=jVT@RoCwTe63)~yqx&=5o>Er*F`xP|{LJ_cmd&zxPN0l2Ypcn+} zg0`_sq!e#PMzc=47mD9)N7CybI6_k{rFREI2{N+?(Hw(PgsH-nKsJy6fBpicDTtiH zJ<4^Lq}$*<`LW{aXQtexA+5&^EJbO51tB=ZwKIYZF7lPj&}WOa*Z&IOv~5`YT?EI} zVa9|{;;Ni~>Wc^Q)Otu@cqS5X*iI~dj{?39Usy7_JFN2~mx8{;gb|K)8N?_x_8UKsxcm3T z9HRAD$lh=#0*`h~FEMzpnbEiIZ>{%6?HjD?&Q}8ffvAzUk;kJ?w~)Y=r%y!QIvy9- zc5B&xIS|fylD4nS`4Tx6NocExKF(T0P&{nmUm@_Mw?VU(g2v3h*v_ZySz|GJy7Baj{E{3KdT@*8T zwW3er<2MPa=>t38+M*5R-97C*|DM6v!(sHJE~hQD$u5yx2Xe)4O~bj9hjC?y>-INa zmm7lEJhX=Vo4W~QiaUswi?5ly2Me3>6l^Qz0ShgGXxXzjAh(>V=96$aEc`OtUi9X_ z;tzcP6MrC-qCH3E9#SuPT_mhNUWhG&cUE2-iKcMTX0Y@=v8}t0q0f)uk%d@_ZIA;m zJ*w34{q^hX`9_~_>+n;8H^Ot*J&*U(oSYKK?P4{%7-WOd!YA{}b~`gWd1`S~QkJta zGy8vGz8{%JPV0{L%V1RkByIJDlr=)T$SJ+=h18S(?kATetFs#Q0cgVQ-RtI< z^{(QzQ%dfC2#T6icwH&*OiO1+m(*_UWtGG(oOl#GrFvM693M>b&(DnSALYBWNpYZUHl17HFkl;M4rKEz_}Sl*Fw%6{PMICA23Ww(g)Guat-ky zV$;wnm&5Pa`SzH4l>jTx6Su8(cw8FtS95m=P_gR*=CssE|*==#Tx33Ufx9M$%lmWAzC zRr(a1h=HMjk{HGDkrEsn91}NJpRz*DhC|nmM|)pvq1G zr*V(6#=Abxr-teuEto_^=#XW%&7mSQVdngRXUUPP-~xSY*GQ?4tDX2V>B?GfyNvHM`Wn`j?DhJMuPcn^iE>1> zVv0-3HlXl(9`Fm|X(8jcqgeOS{9Zuj2h)NiF7|xz=X1SW7` zT9ziPJv?=H8SU}sVHWN6jQ%k@dqtn&6jqvOyYhtZ0os@mv*;i09eh_DEd6+qmy(JT zs43>UFG36>)`3)F)TSSYnAFC;35U}3V96C+A& zh!V^SV1bu(j%JHYld=ZzB+uQ3`arPiERD~=tKZZj(Zptse(U<&iPuF|O0Lvox=h(? zAI))2e?YK?*;+NUGu~c$d_!!Fk$SPcIG8CWkSRhQ)vSJ~R9DAARQ1DTR!#M>fS>wDoqnp9s; zF#MGQSU3}DeaYNSp)M}?`76DfQ9T%MT1p}a7A?dRyXkBH&m(bMK+?w3#3GTtL@9e2weswQ-UlRRqe)UoMMzgS*Vzs z60v8d!$NK*ki~TSn71ZLJ4)nIP;8ErXt{x+7Dm!)3glXW@!#8)70l8Glj(%5Bp^Gz zNotgaI^in)3*?f&%}f=eusY>f)O>0fN=TE~$omL;bH3P|Kl9Y`{p%T~9%-z_&St&( zT?Es&aEet|@XRnT29x;BvxLH9;F9W_5hmN&-y=-KTP>p&KE^o8Vh)2lVviDOK`0Q*iBa1$1=JNOGH721 zi=Oz1U7@kzqHr*MsQsm#9BkIZz1*44=%Cdrn2c>MRsEUNr zT>sTTmH30$xyH#$bw287KdO6CiN48;;~7<8+3p0M!`%Qv{;mD=ts?t5Qyl9XgEAp0 z>{60YAwD3ja*ho`bRx=onw$;2Qh!j{8np#kfK)QR3RVZp#&NuE@}vzVm9sI17H9{u z6kMKe#r)+VRQatnTQIn(K|92jfA-Ci%h z~XBNP%{J#l-b|>*N?ZMO$_ehMtZgu0!;Y zJY}2^d3*(Z3%|`SAXSK*5j6!MH#N+Fv0}EQ*{bltoTFM7K{@*)V2mRiXSUXYX}~JL zIAgxbfdFjYl9!PdLn{qi#~dfzRBqHM%6(cozcLE0QY$~p9N`NOO{+Wm6zNVEsc6#; zDsaWt&posJw1HnVnme{$Bi^V;>Q zlLP$-Jn`m0;n~lkvYT!_x}x6wteO3Ea`(*cJGry{8?Sx&+HY<7ru}isckr0ZceJfa z+xg45zO&ZH$S>nVo0QE5AxT10+7IIo^9ycG*JnWMZZCnlOLF!{pi(C1=XX!%Nw1wf zcXzGFlh6W%1NNq;np1YzzO4ra+{=jd+XJ@dtCvAN&Kx4jFaZGHcs38R9d=e!%8BXA zEFrW;zt78?hqPAHt>4#?>bHfm_S3Fz%Xdh5X;?VO18o*NI54m;9AIGo=ZOla0GN%5 zv%#N9N|cg}T_zJ+$GuvGM72JiwmMxhb-D8G)F;IZi4L*B>>61OypMJg?>;@=#&t_l zyT)A}cBbo*G*x;i<9i>iOCr~y$$NWX>X{S|J#khyeBhgUTpsh7Vq&Dg62$RQb9LOo zhB$+i-6|RtIokBkwMWs8p81awek}q9aH&eg<9P}BX%DIcHJHQQdZsN+-Aj0iAHjL{ zpjhb-ilDr9SvKaKQw-_n2yJ!{B?EE;6lO`#Kh)dn0gC+ejv|2`(caE^03+4y#Q~sO ztk&a zs|?Pea7K9*YK4Zc}iqzeP-BVxq zDd*^$!I31HYiR6eArkyWisTu#3?t8rwezH)s$D~B7uhw#cxZE1jUbrg`zsk0Y}?l- zg-mgWMA6HkbM4i)ms@+M>z(U`L|a)d)JpaOzC?rWm3GpP6wLApCK}0L6p7^UvaqQ` zlI>XVS!&msHG+18c&#@^DSqI8-U|b4-L-9SFfeSW|L(m&_%nfZ_OLejYx;UhuoAt~ zc-x_8z_r*p+z4#TNXwPrH1}T{Cv6iVgw)KpaWPiNExhFFiVf{-sD5qql|;2eFKc6D z^(x~OL{A6}-SIUGM-D&tsqDxDNd#5$PG- zQ<{&lG|*MN;L@J=Mn16O3lUML0$H}}aHnH%hc-iKyIO#!v0l(JAw=*uqJSrLp&^D_ zvfR%_Tj9BjF=`~sJMKx%wCG#%4sE~l)U?I(O+*elkIi9z#HNQO1!)t(X7_`M6v{#aUN8-gSdpmJKL}U&SU(yB(uAoln+HK==2qP> zsry(9c9R43TgOiHOQTg7Oeuux67wZkP86~P?#jQY8ll&t*sBq8)$kXz ziPYEK+Uxr`zcim(d&FpsQarV^B~H^enRYz|$PK`p8kZQ@e7`_+1vn_Di7?PCfhg zHmkCLteVPO{Oe=T!nW6Wy+O9-GBxS-5ZbW688{Am6K`Q$cx={`c`72nf9hb4hr^@V z$pd~VY8oUv+tP%)cCM(^Ts2_{afeuJLq0|}apeKQMYxdQltVTmsp@FbZ@lO*MOv_- zG$vOyY*%Hvnn->?=KhpOyqLm5k9bWM*(SL9)dOK^l&LP~_ByS9az@J=^KAku91l?u zrg0mtC3d)yAn)xwn1UYWhz<}y^ij$7Bj&cfS_Q=vLZ;#`@C%2Fge(fFZ^#ISI1$^_0h4{LZ?oY8wxq(nuevx%(Es1uvSU67c;|ZX2oGKF=anUi zNhQyyj+z<*=o;Xrm)N1MK!V~X&d>oz001%Lk>>87Wp5=sm51)nM=wY4X0XgJdaQ=P|_(P$j8nj70**|TW17wxQeIMesPZLq1jpGB5eMvQ&fEp1)pVQ4xC@O&u-$N3FNd0Z5HAVsO2k5 zc=2Fz7-Z+?v$rn!p?7^Sphixj7ni%~AAJeWufEJwde^hR$VmOPS=a;njK!3 zbL?S2L6#=tW>~ZtQo`zFB9NK69jej^k>r4xTd*g?*YS+5*WoHlz?I;k_P%2kNO0H0 zYtO%u-Fd`N#95=t75W+?fytCep#2`4sZhJ}oHQpM^66@*hi@>zvJiBxUzy@oy-E`jxTWnUatUr8SYmc>NcYF`t90MsE#r^JJQ^*#SU5H z`3#BTH95pHa1c}M)|qQ%(+7ASzYZL2J{t4cV9@p{b|CHa&sNtnXt$eRWpRwUJwcD$ z<1>$?4=_D{9hd@kWDQ)9Ri~ z!FXjU6&`Ra#bgF=sV+B$8ca?i$)Np!Fk`UqTx;e}eG{qIF!}I`GCk@x%<$rCaaJd# zWY>tD)-5grU-6SwPQO42eern8OMG17gmWOX3Kx5d*#sSurtY1IcBr2J$!Hn!S2nzB z!}u=DId(!0s}W+14m?-RF71a4vNkSEL)(pQLZPI1Ol3LP;b`B3jR3&afjJLeo2yJq zag?lx{#X2T%m%Hm5F0{_|-A$(Xge*{_~54?~SqnM$sWgGLJk$1VnSg|s~ z-_cY`7t{|oV;1F1HELeDvUlujCVvv*=j=0VxWua;6zcmDj#T`T7U;AV%Z7Br?p4P9 z^xO7G^1!Om+wrDICMRKP!P*CSAe})(l~=9txPKsR^$yCbD0R9iV!xF<*ta>@$H~*H z&(qTm_FW3`)d+STwy=X-@9-sno%+%|I9646dQqjzn>7uwDKhSWLA7=kSz za;(Ebt|@SaU`XUK;k^<`_MiM7NX%hnE??F2 z^GP{7?L;Xgsh_t%+bmQ+xXO7#XlX{rlD433Z9}e%^5Nv0_V&D%tB4Xv0X>(L60(01#y6l9b4Z$OeVHmCjEQ%>kZv zj;CT-Q)P7(MRqulqmJ2cs^66@zq6~E!=-m2AVK2xx>S4NL!jO z7v~+)kE8+8k=>soEX?PN^OpWfqMA;Cxa-AvkAISyNZ6w(vs3vpA`uxO2y=Jl`jfhf zVxt^b^md;flga4=Y~~sCIWSmn#JyRBi+L?4&Bl?{Vj-_!j5Gq0W%V z!n1&A_^ZVdJ)EiamH_@vhObc@-S)-ilb+Bo9y*VD3ni{Ji%%llgkD`|AE0SW7udxL z0Jv5xn~b7|Mo`~Q%CJJ@_?Xju@J~cZI8dpNpq}QSP*_Ki9b|>(*cv#M2Y&dygseNJ z5q+wNj~0!W7W%B*_xv(d^g^$#Dr=^vJ(om5$xzcvCYkVZVl=PJKTopKG;6j`)vv*4 zarP2WdG#zXh;${^Nq|XynYcAP#<>MycUu#pkJ7n^iV@x5x+PzR*u%+k<%;g;fL0SRw6!E~B=c*!};GCp;uZ_;GlUBzS67y_LV1#<&$6Dd#3v$(cNIAO=Jy)}9 z%Gc%c@yAddbA;(DHJ1Mg^0 zJBQOS4KXt%b{@Z%l}@p}Fh)2~I^`+M%WLy%@?6OoZ7Z;`dP#!9AdAp{E7A|Nn!!E zga?7eF8qBFFW~4=<`D4Hw(jm=*jN=xxa^0`$od2(-w0`49&>ae1l9*KuQ-gnbqr_) z|8WO*Usg?AOK=#QMY1dv;)arixFK)ZS(R2QMe3*|y{~mU7rW*9i$H_$6@o5+K7Std z3dc*l;H4*m1U=`^$Jwf@?+Qgr{Y8H`HJdgJq>DY?%M`|+_$v^YZ^mfN0Bf{D_>M~!R{+;!jx9w8}F zu``HC5em3@Pmwx>zC2v_x;UhDq9APmVA zN>L{iFt8%4|8A*@2wJKd+1Waq*gF3~F-|iS>{d9?hBoeL;BXd|OqL~E2Q;@0yXEx- ze}__n-;Ko9i$r2##sAE->q;;l{pspB>uI+mZ5ih_G;i9r2eB-!|6O2m;2qRwPGjsEQX#BP>l7BuFI_3I+ukb z&NA0pZElk2StJNJVAE9-?5Tt~jad=Pnv_vFTkHa%ZL0411Nf}C?+7h-&ca{5wM?%m z;oWDL@m2v;_BCsda0(X`eCr6;Ml#qlX((Z5JG>k{xO&bpTv(bI>Q}~f!l&us8`qL7 zGtZNLaPU+Nj$HnzMMs(2f%$lBt+welP3dV43Z_)Jka$aouaoo^g4O;l0Y`R09jZie zbYyhq(RrQlI@96}(yQE2I))UVq%Jw9bP}u5dpSt+$I1EDkU0BFC2;unq*?vXL!d%7 zXg@xL#z6zCD6!649*w1d4*OQ_YELssC-%;Pqf_1L#^=>IXtji0T}U=L0=bm{Eb{>4 zf#$Elz-30e^hS$NNhck8U*5+x8W>co#dA==@|zWd_G8U)a;r2UCs2qEb~@glzf?9a zel0UyRJu|S)UtVuVE)3BJR^#w^EGX`?Yc$Im`)geRP=c7fFFCGL0b2Y-LvLbmpxzD zdl@*#{2ulsPmD@;9c1k3S|IHtZW&|G=5eKx3|y&#zMS=RB~#>WEAo;?;heqS=cp=l zK0sQGsw3_86uK;op5bQCu5`CzEH9b8D)v?Nc+~91lfXOJ2r48*AK6duplF1%j~x{a ziVtyv;_5g$44KT)#NlUqRV?yStr>ci$-#l<&n5uIh=0u_*pJY}siFZgy|iDJBBrwW zqF&sQr(z`RdP|*EB!rZ%;)KBcs3g;chH%{?3%D0$^oY%~4~9>oog}d%Z3vWPgyupk zL$IsxS3#2C&S$9#Mu%?K!45FLL)r>Mk};P=(lU!=s?@PinY?q}m z<&R$w_K4S@_9gkgDmA2H9vvlYW!h{*swO`2!?K8{aHJbt`3?}BRSnF2e-nE-)l?YF;x+E3D-AS*#&+z$7D zH_BmwMmZTVWg&V=S#d@i16vDI6Q{q%yUGM<%RVNwkbCZL(~as<(l}!M!}(Jfg|PCc zaje8q!Gt6&J|-Ac3>0W0uAY%^?-`*SZ3*miAB07}Abagvse(6Di5%hGb-b4gEZVj$ ztBaMAP;%3kUqx@btkmF3@L(}ZDPre;t#l?Vb+Ty`lzfXyDVTBLgmgE<0cn`n4nuTh zg_li^ls`@5ka2myacR?#Wz8=JX3|8;uBf<33l#*k28UiF3z`{OR{zd3*k)L$gqzAM zpIHLw=$-cK%PffA*9=U;TfPd&$4e0sd3)pAY;`NBS4(R{S5jmAo`G%%5WLpdT&-7}!+h HpVa>VjNMtv literal 0 HcmV?d00001 diff --git a/tests/dom/run.mjs b/tests/dom/run.mjs index 84e95f9..227f22d 100644 --- a/tests/dom/run.mjs +++ b/tests/dom/run.mjs @@ -31,7 +31,7 @@ try { await page.waitForSelector('.tiptap', { timeout: 15_000 }); // A multi-page corpus document, through the file input (no picker in headless). - await page.setInputFiles('input.file-input', join(ROOT, 'tests/corpus/05-breaks.odt')); + await page.setInputFiles('input.file-input[accept*=".odt"]', join(ROOT, 'tests/corpus/05-breaks.odt')); await page.waitForFunction(() => (document.querySelector('.tiptap')?.textContent ?? '').length > 200, null, { timeout: 30_000 }); const opened = await settled(); @@ -95,7 +95,7 @@ try { const marked = await dot(true); await page.keyboard.press(`${MOD}+z`); const backToSaved = await dot(false); - await page.setInputFiles('input.file-input', join(ROOT, 'tests/corpus/04-table.odt')); + await page.setInputFiles('input.file-input[accept*=".odt"]', join(ROOT, 'tests/corpus/04-table.odt')); await page.waitForFunction(() => document.querySelector('.tiptap table td')?.textContent.trim(), null, { timeout: 30_000 }); const opened2 = await dot(false); @@ -111,7 +111,7 @@ try { // And so does the name the file is saved under. The file comes back in first, so the // rename is the only thing standing between the document and its clean state. - await page.setInputFiles('input.file-input', join(ROOT, 'tests/corpus/04-table.odt')); + await page.setInputFiles('input.file-input[accept*=".odt"]', join(ROOT, 'tests/corpus/04-table.odt')); await page.waitForFunction(() => document.querySelector('.tiptap table td')?.textContent.trim(), null, { timeout: 30_000 }); const reopened = await dot(false); @@ -131,6 +131,33 @@ try { const saved = await readFile(await download.path()); const isDocx = saved[0] === 0x50 && saved[1] === 0x4b && saved.includes('word/document.xml'); check(isDocx, `Save As (.docx) without a picker downloads a DOCX (${download.suggestedFilename()}, ${saved.length} bytes)`); + + // The header/footer switches live in the ribbon's Insert tab. A ticked "different + // first page" makes page 1 a second zone, so what is typed there lands beside the + // running one — and the distance travels to the app's own storage. + await page.keyboard.press('Escape'); + await page.locator('.ribbon-tab', { hasText: 'Insert' }).first().click(); + const hfOptions = page.locator('button.rb', { hasText: 'Options' }).first(); + await hfOptions.click(); + await page.locator('.ribbon-menu .check-row input').first().check(); + const dist = page.locator('.ribbon-menu .num-row input').first(); + await dist.fill('1.8'); + await dist.dispatchEvent('change'); + await hfOptions.click(); + const zone = page.locator('.hf-zone.hf-header').first(); + await zone.dblclick({ timeout: 5000 }).catch(() => zone.dispatchEvent('dblclick')); + await page.waitForSelector('.hf-active .tiptap', { timeout: 5000 }); + await page.waitForFunction(() => document.activeElement?.closest?.('.hf-active'), null, { timeout: 5000 }); + await page.keyboard.type('Titelseite'); + await page.locator('.hf-bar-done').click(); + const hf = await page.evaluate(() => ({ + first: localStorage.getItem('edentext-hf-different-first'), + running: localStorage.getItem('edentext-header'), + firstPage: localStorage.getItem('edentext-header-first') ?? '', + dist: localStorage.getItem('edentext-hf-distances') ?? '', + })); + check(hf.first === 'true' && !hf.running && hf.firstPage.includes('Titelseite') && /"header":1.8/.test(hf.dist), + `the ribbon's header/footer switches reach the document (first page: ${hf.first}, running zone: ${hf.running}, distances: ${hf.dist})`); } catch (err) { check(false, `dom run threw: ${err.message ?? err}`); } finally { diff --git a/tests/layout/baseline.json b/tests/layout/baseline.json index 17d5e84..7a73403 100644 --- a/tests/layout/baseline.json +++ b/tests/layout/baseline.json @@ -107,105 +107,114 @@ ] }, "docs/showcase/book.odt": { - "pages": 95, - "lines": 3739, - "ms": 800, + "pages": 104, + "lines": 3757, + "ms": 1300, "starts": [ "Alice’s Adventures in Wonderland", "Contents", "Chapter I", "fortunately was just in time to see it p", - "wonder what Latitude or Longitude I’ve g", - "down it. There was not a moment to be lo", - "at any rate a book of rules for shutting", - "said Alice to herself, “in my going out ", - "ained the same size: to be sure, this ge", + "ice to say it over) “—yes, that’s about ", + "Alice was not a bit hurt, and she jumped", + "things had happened lately, that Alice h", + "And so it was indeed: she was now only t", + "it,” said Alice, “and if it makes me gro", "", "Chapter II", "Just then her head struck against the ro", "“I’m sure I’m not Ada,” she said, “for h", - "while she was talking. “How", - "“Would it be of any use, now,” thought A", - "“We indeed!” cried the Mouse, who was tr", + "As she said this she looked down at her ", + "Just then she heard something splashing ", + "pool, “and she sits purring so nicely by", + "Dodo, a", + "", "Chapter III", - "soon submitted to by the English, who wa", - "“What I was going to say,” said the Dodo", - "“Of course,” the Dodo replied very grave", - "‘I’ll be judge, I’ll be jury,’ Said cunn", - "This speech caused a remarkable sensatio", + "‘William the Conqueror, whose cause was", + "either!” And the Eaglet bent down its he", + "not got into it), and handed them round ", + "“Fury said to a mouse, That he met in th", + "“And who is Dinah, if I might venture to", "Chapter IV", - "bright brass plate with the name “W. RAB", - "her head. Still she went on growing, and", - "Presently the Rabbit came up to the door", - "voices all talking together: she made ou", - "“We must burn the house down!” said the ", - "how to set about it; and while she was p", - "The great question certainly was, what? ", + "this, she came upon a neat little house,", + "there was not even room for this, and sh", + "“Mary Ann! Mary Ann!” said the voice. “F", + "were", + "Last came a little feeble, squeaking voi", + "“The first thing I’ve got to do,” said A", + "This seemed to Alice a good opportunity ", "Chapter V", "then after that into a butterfly, I shou", - "“Repeat, ‘", - "The Caterpillar was the first to speak.", - "at last she stretched her arms round it ", - "but the tops of the trees under which sh", - "use denying it. I suppose you’ll be tell", - "why, I should frighten them out of their", + "“Well, I’ve tried to say “How doth the l", + "“Not", + "“Of the mushroom,” said the Caterpillar,", + "you?” She was moving them about as she s", + "“But I’m", + "It was so long since she had been anythi", "Chapter VI", - "Alice went timidly up to the door, and k", - "The Footman seemed to think this a good ", - "Alice did not at all like the tone of th", - "“Wow! wow! wow!”", - "not like the look of the thing at all. “", - "“Oh, you’re sure to do that,” said the C", - "“It turned into a pig,” Alice quietly sa", + "Fish-Footman was gone, and the other was", + "It was, no doubt: only Alice did not lik", + "“They all can,” said the Duchess; “and m", + "of lullaby to it as she did so, and givi", + "grunted in reply (it had left off sneezi", + "“Cheshire Puss,” she began, rather timid", + "“Call it what you like,” said the Cat. “", + "ause the chimneys were shaped like ears ", + "", "Chapter VII", "“Your hair wants cutting,” said the Hatt", - "“Two days wrong!” sighed the Hatter. “I ", - "Alice sighed wearily. “I think you might", - "‘Up above the world you fly, Like a tea-", - "“And be quick about it,” added the Hatte", - "“There’s no such thing!” Alice was begin", - "“They were learning to draw,” the Dormou", - "led into the garden. Then she went to wo", + "Alice considered a little, and then said", + "“Nor I,” said the March Hare.", + "“It goes on, you know,” the Hatter conti", + "“Tell us a story!” said the March Hare.", + "The Dormouse again took a minute or two ", + "This answer so confused poor Alice, that", + "Once more she found herself in the long ", "", "Chapter VIII", - "“Would you tell me,” said Alice, a littl", - "When the procession came opposite to Ali", - "“May it please your Majesty,” said Two, ", - "“She boxed the Queen’s ears—” the Rabbit", - "minute, “and then,” thought she, “what w", - "“It’s a friend of mine—a Cheshire Cat,” ", - "this side of the ground.” So she tucked ", + "watching them, and he checked himself su", + "upon their faces, so that they couldn’t ", + "“Leave off that!” screamed the Queen. “Y", + "“What for?” said Alice.", + "short time the Queen was in a furious pa", + "“How do you like the Queen?” said the Ca", + "The hedgehog was engaged in a fight with", + "Alice could think of nothing else to say", + "", "Chapter IX", - "“Tut, tut, child!” said the Duchess. “Ev", - "“Of course it is,” said the Duchess, who", - "Queen in front of them, with her arms fo", - "that’s", - "So they sat down, and nobody spoke for s", - "“", - "Seaography", + "“Perhaps it hasn’t one,” Alice ventured ", + "“Right, as usual,” said the Duchess: “wh", + "“Just about as much right,” said the Duc", + "“It’s the thing Mock Turtle Soup is made", + "So they went up to the Mock Turtle, who ", + "“Yes, we went to school in the sea, thou", + "“Yes,” said Alice doubtfully: “it means—", + "This was quite a new idea to Alice, and ", + "", "Chapter X", "“Of course,” the Mock Turtle said: “adva", "you, won’t you, will you join the dance?", - "a long way. So they got their tails fast", - "“I could tell you my adventures—beginnin", - "[later editions continued as follows Whe", - "“Yes, I think you’d better leave off,” s", - "", + "“The reason is,” said the", + "“Of course not,” said the Mock Turtle: “", + "of the Lobster Quadrille, that she hardl", + "[later editions continued as follows The", + "“Chorus again!” cried the", "Chapter XI", "“And that’s the jury-box,” thought Alice", - "“The Queen of Hearts, she made some tart", - "This did not seem to encourage the witne", - "“Of course twinkling begins with a T!” s", - "“I can’t go no lower,” said the Hatter: ", - "For some minutes the whole court was in ", + "On this the White Rabbit blew three blas", + "Here the Queen put on her spectacles, an", + "“I’m a poor man, your Majesty,” the Hatt", + "which tied up at the mouth with strings:", + "“Well, if I must, I must,” the King said", "", "Chapter XII", - "As soon as the jury had a little recover", - "“Well, I shan’t go, at any rate,” said A", - "“That", - "“If there’s no meaning in it,” said the ", - "“I won’t!” said Alice.", - "The long grass rustled at her feet as th" + "said to herself; “I should think it woul", + "“You are,” said the King.", + "“If you didn’t sign it,” said the King, ", + "him,) “I’ll give him sixpence.", + "“It’s a pun!” the King added in an offen", + "First, she dreamed of little Alice herse", + "her childhood: and how she would gather " ] }, "docs/showcase/newsletter.docx": { @@ -640,6 +649,26 @@ "Appendix" ] }, + "tests/corpus/16-hf-variants.docx": { + "pages": 3, + "lines": 1528, + "ms": 300, + "starts": [ + "1.", + "18.", + "35." + ] + }, + "tests/corpus/16-hf-variants.odt": { + "pages": 3, + "lines": 1688, + "ms": 100, + "starts": [ + "1.", + "18.", + "35." + ] + }, "tests/corpus/word/01-flow.docx": { "pages": 2, "lines": 1008, diff --git a/tests/layout/run.mjs b/tests/layout/run.mjs index da49bdc..c346f63 100644 --- a/tests/layout/run.mjs +++ b/tests/layout/run.mjs @@ -179,7 +179,7 @@ try { await page.reload({ waitUntil: 'load' }); await page.waitForSelector('.tiptap', { timeout: 30_000 }); await page.evaluate(() => { window.__loadAt = performance.now(); }); - await page.setInputFiles('input.file-input', file); + await page.setInputFiles('input.file-input[accept*=".odt"]', file); // The document's name says the file is in; a document may hold nothing to wait for. await page.waitForFunction((n) => document.querySelector('.doc-name-input')?.value.startsWith(n), basename(file, extname(file)), { timeout: 60_000 }); await settle(page, true); diff --git a/tests/monkey/run.mjs b/tests/monkey/run.mjs index 395494a..721fe16 100644 --- a/tests/monkey/run.mjs +++ b/tests/monkey/run.mjs @@ -16,8 +16,9 @@ const PORT = +(process.env.MONKEY_PORT ?? 4189); const SEED = +(process.env.MONKEY_SEED ?? 1); const RUNS = +(process.env.MONKEY_RUNS ?? 3); const OPS = +(process.env.MONKEY_OPS ?? 60); -const DOCS = process.env.MONKEY_DOC ? [process.env.MONKEY_DOC] : ['02-blocks.odt', '04-table.odt', '08-lists.odt', '12-notes.odt', '15-chapters.odt']; -const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; +const HF_OPS = +(process.env.MONKEY_HF_OPS ?? 8); +const DOCS = process.env.MONKEY_DOC ? [process.env.MONKEY_DOC] : ['02-blocks.odt', '16-hf-variants.odt', '04-table.odt', '08-lists.odt', '12-notes.odt', '15-chapters.odt']; +const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNwaDgAAAKEAYEml6crAAAAAElFTkSuQmCC'; const { check, failures } = checker(); if (!hasXmllint) console.log('xmllint missing: the saved files are not validated against the schemas'); @@ -86,6 +87,30 @@ const drawOp = (r, size) => { return { key: 'ArrowRight' }; }; +// The zone's own schema — one paragraph of runs, breaks, fields and inline images — so it +// draws from a table of its own. No Escape (that leaves), no undo: a pass falls into one +// history group, so one Mod+Z would empty the zone before the round trip ever sees it. +const HF_KEYS = ['Enter', 'Backspace', 'Backspace', 'Delete', 'Tab', 'Home', 'End', + 'ArrowLeft', 'ArrowRight', 'Shift+ArrowLeft', 'Shift+ArrowRight', `${MOD}+b`, `${MOD}+i`, `${MOD}+u`]; +const HF_OPS_TABLE = [ + [14, (r) => ({ type: pick(r, WORDS) })], + [12, (r) => ({ key: pick(r, HF_KEYS) })], + // The insert bar's three field buttons, in its order: page number, count, chapter. + [5, (r) => ({ field: int(r, 0, 2) })], + [9, (r) => pick(r, [ + { cmd: 'toggleBold' }, { cmd: 'toggleItalic' }, { cmd: 'toggleUnderline' }, { cmd: 'toggleStrike' }, + { cmd: 'unsetAllMarks' }, { cmd: 'setTextAlign', args: [pick(r, ['left', 'center', 'right'])] }, + { cmd: 'setFontSize', args: ['10pt'] }, { cmd: 'setColor', args: ['#c00000'] }, + { cmd: 'setImage', args: [{ src: PNG, width: 24, height: 18 }] }, + ])], +]; +const HF_TOTAL = HF_OPS_TABLE.reduce((n, [w]) => n + w, 0); +const drawHfOp = (r) => { + let x = r() * HF_TOTAL; + for (const [w, draw] of HF_OPS_TABLE) { if ((x -= w) < 0) return draw(r); } + return { key: 'ArrowRight' }; +}; + const server = await devServer(PORT); const { browser, page, pageErrors } = await openApp(PORT); const consoleErrors = []; @@ -93,20 +118,29 @@ page.on('console', (m) => { if (m.type() === 'error') consoleErrors.push(m.text( const ed = (fn, arg) => page.evaluate(fn, arg); const focused = async () => { - const err = await ed(() => { try { document.querySelector('.tiptap').editor.commands.focus(); return null; } catch (e) { return String(e); } }); - await page.waitForFunction(() => document.activeElement === document.querySelector('.tiptap'), null, { timeout: 2000 }).catch(() => {}); + const err = await ed(() => { try { document.querySelector('.tiptap-host .tiptap').editor.commands.focus(); return null; } catch (e) { return String(e); } }); + await page.waitForFunction(() => document.activeElement === document.querySelector('.tiptap-host .tiptap'), null, { timeout: 2000 }).catch(() => {}); return err; }; const runCmd = (op) => ed(({ cmd, args }) => { - const editor = document.querySelector('.tiptap').editor; + const editor = document.querySelector('.tiptap-host .tiptap').editor; + // A caret only ever sits in a text block — the view puts it nowhere else — but + // focus(pos) makes the selection wherever it is told, and Enter on one between two + // list items throws out of prosemirror. Snap to the next text position instead. + if (cmd === 'focus' && typeof args?.[0] === 'number') { + const doc = editor.state.doc; + let at = Math.max(0, Math.min(args[0], doc.content.size)); + for (let n = at; n <= doc.content.size; n++) if (doc.resolve(n).parent.isTextblock) { at = n; break; } + args = [at]; + } try { return { ok: editor.commands[cmd](...(args ?? [])) }; } catch (e) { return { threw: String(e?.stack ?? e) }; } }, op); // The schema's own check of the document after every op — a command may leave a node // with content its spec rejects, which nothing else reports. const docCheck = () => ed(() => { - try { document.querySelector('.tiptap').editor.state.doc.check(); return null; } catch (e) { return String(e); } + try { document.querySelector('.tiptap-host .tiptap').editor.state.doc.check(); return null; } catch (e) { return String(e); } }); -const docJson = () => ed(() => JSON.stringify(document.querySelector('.tiptap').editor.getJSON())); +const docJson = () => ed(() => JSON.stringify(document.querySelector('.tiptap-host .tiptap').editor.getJSON())); // The key the history is held against, and the count of the columns flow's own rewrites: // the flow changes the document outside the history (`FLOW_TX`, columnsFlow.ts), so undo // rebases over it — ponytail: such a run has its history left unchecked. @@ -123,8 +157,8 @@ const watchFlow = () => ed(async () => { return n; }; window.__docKey = () => JSON.stringify( - mergeJoinedParagraphsJson(strip(document.querySelector('.tiptap').editor.getJSON()).content ?? [])); - const view = document.querySelector('.tiptap').editor.view; + mergeJoinedParagraphsJson(strip(document.querySelector('.tiptap-host .tiptap').editor.getJSON()).content ?? [])); + const view = document.querySelector('.tiptap-host .tiptap').editor.view; window.__flowTx = 0; if (view.__flowWatched) return; view.__flowWatched = true; @@ -135,25 +169,132 @@ const watchFlow = () => ed(async () => { }; }); const docKey = () => ed(() => window.__docKey()); -const docSize = () => ed(() => document.querySelector('.tiptap').editor.state.doc.content.size); +const docSize = () => ed(() => document.querySelector('.tiptap-host .tiptap').editor.state.doc.content.size); -const saveAs = async (ext) => { +const HF_LIVE = '.hf-active .tiptap'; +const hfCmd = (op) => ed(({ cmd, args }) => { + const editor = document.querySelector('.hf-active .tiptap')?.editor; + if (!editor) return { threw: 'the zone editor is gone' }; + try { return { ok: editor.commands[cmd](...(args ?? [])) }; } catch (e) { return { threw: String(e?.stack ?? e) }; } +}, op); +const hfCheck = () => ed(() => { + const editor = document.querySelector('.hf-active .tiptap')?.editor; + if (!editor) return 'the zone editor is gone'; + try { editor.state.doc.check(); return null; } catch (e) { return String(e); } +}); + +// A floating toolbar of the body's — a selected frame's, say — can sit over the zone +// and its bar; the handler behind the element is what this run is after, not hit-testing. +const clickish = (at, how = 'click') => at[how]({ timeout: 4000 }) + .catch(() => at.dispatchEvent(how, {}, { timeout: 4000 })) + .catch(() => false); // gone already (an op closed the zone): the checks after it still speak + +// A checkbox in the ribbon's own menu: the menu re-renders under the click (the flag +// ends an open zone edit), so a real click can find the element detached. +const setBox = (at, on) => at.setChecked(on, { timeout: 4000 }) + .catch(() => at.evaluate((el, v) => { if (el.checked !== v) el.click(); }, on)); + +// The two variant flags and the zone distances, from the ribbon's Insert tab. Setting +// them here is what puts the first-page and even-page zones in play on a document that +// carries none of its own. +async function setHfOptions(r) { + await page.locator('.ribbon-tab', { hasText: 'Insert' }).first().click(); + const options = page.locator('button.rb', { hasText: 'Options' }).first(); + await clickish(options); + await page.waitForSelector('.ribbon-menu .check-row input', { timeout: 5000 }); + const boxes = page.locator('.ribbon-menu .check-row input'); + for (const i of [0, 1]) await setBox(boxes.nth(i), r() < 0.6); + if (r() < 0.4) { + const dist = page.locator('.ribbon-menu .num-row input').nth(int(r, 0, 1)); + await dist.fill(String(pick(r, [0.6, 1, 1.8]))); + await dist.dispatchEvent('change'); + } + await clickish(options); +} + +// Flipping a flag ends an open zone edit (App.svelte drops hfActive with it), which +// tears the live editor down mid-session — what was typed must be in the zones anyway. +async function flipUnderEdit(mark) { + const zone = page.locator('.hf-zone.hf-footer').first(); + if (!(await zone.count())) return null; + await clickish(zone, 'dblclick'); + await page.waitForSelector(HF_LIVE, { timeout: 5000 }); + await page.waitForFunction(() => document.activeElement?.closest?.('.hf-active'), null, { timeout: 2000 }).catch(() => {}); + await page.keyboard.type(mark); + await page.locator('.ribbon-tab', { hasText: 'Insert' }).first().click(); + const options = page.locator('button.rb', { hasText: 'Options' }).first(); + await clickish(options); + const box = page.locator('.ribbon-menu .check-row input').nth(1); + await setBox(box, !(await box.isChecked())); + await clickish(options); + const gone = await page.waitForSelector('.hf-active', { state: 'detached', timeout: 5000 }).then(() => true, () => false); + const kept = await ed((m) => ['edentext-header', 'edentext-footer', 'edentext-header-first', + 'edentext-footer-first', 'edentext-header-even', 'edentext-footer-even', 'edentext-hf-sections'] + .some((k) => (localStorage.getItem(k) ?? '').includes(m)), mark); + if (!gone) return 'the flag left the zone editor open'; + return kept ? null : 'the text typed before the flag flipped is in no zone'; +} + +// A zone edited the way a user reaches it: double-click in, edit, leave by Done. Pages +// 1 to 3 in turn — with the two variant flags on that is one session per variant, the +// title page's, an even page's and a running one's. Only the round trip reports on it. +async function editZone(r, zone) { + const zones = page.locator(`.hf-zone.hf-${zone}`); + const n = await zones.count(); + if (!n) return { ops: [], broken: `no ${zone} zone on the page` }; + const ops = []; + let broken = null; + for (let p = 0; p < Math.min(3, n) && !broken; p++) { + await clickish(zones.nth(p), 'dblclick'); + await page.waitForSelector(HF_LIVE, { timeout: 5000 }); + // The mount focuses itself a tick later; typing before that loses its first keys. + await page.waitForFunction(() => document.activeElement?.closest?.('.hf-active'), null, { timeout: 2000 }).catch(() => {}); + for (let i = 0; i < HF_OPS && !broken; i++) { + const op = { page: p + 1, ...drawHfOp(r) }; + ops.push(op); + if (op.type) await page.keyboard.type(op.type); + else if (op.key) await page.keyboard.press(op.key); + else if (op.field !== undefined) await clickish(page.locator('.hf-bar-btn').nth(op.field)); + else { + const res = await hfCmd(op); + if (res.threw) broken = `${op.cmd} threw: ${res.threw}`; + } + const invalid = broken ? null : await hfCheck(); + if (invalid) broken = `schema-invalid ${zone} on page ${p + 1}: ${invalid}`; + if (pageErrors.length) broken = `uncaught: ${pageErrors.join(' | ')}`; + } + await clickish(page.locator('.hf-bar-done')); + await page.waitForSelector('.hf-active', { state: 'detached', timeout: 5000 }).catch(() => {}); + } + return { ops, broken }; +} + +const saveAs = async (ext, retry = true) => { await page.click('.ribbon-tab-file'); - const [dl] = await Promise.all([ - page.waitForEvent('download', { timeout: 60_000 }), - page.locator('.ribbon-menu button', { hasText: `(.${ext})` }).first().click(), - ]); - await page.keyboard.press('Escape'); - return new Uint8Array(await readFile(await dl.path())); + try { + const [dl] = await Promise.all([ + page.waitForEvent('download', { timeout: 30_000 }), + page.locator('.ribbon-menu button', { hasText: `(.${ext})` }).first().click(), + ]); + await page.keyboard.press('Escape'); + return new Uint8Array(await readFile(await dl.path())); + } catch (err) { + // The File menu can miss a click while the app is still laying the document out, and + // no download follows. One more go; a second miss is a finding. + if (!retry) throw err; + await page.keyboard.press('Escape'); + return saveAs(ext, false); + } }; // The file the app saved, read back by the app's importer against the editor's document // (its paragraphs joined across a column or page merged, as the export merges them). const roundTrip = (bytes, ext) => ed(async ({ b64, ext }) => { - const [{ importOdt }, { importDocx }, { mergeJoinedParagraphsJson }, { normalize, firstDiff, stripFontHoist }] = await Promise.all([ - import('/src/lib/import/odt.ts'), import('/src/lib/import/docx.ts'), import('/src/lib/export/odt.ts'), import('/tests/normalize.ts')]); + const [{ importOdt }, { importDocx }, { mergeJoinedParagraphsJson }, { normalize, firstDiff, stripFontHoist }, hfStore, { effectiveListLevel, defaultLevelBullet }] = await Promise.all([ + import('/src/lib/import/odt.ts'), import('/src/lib/import/docx.ts'), import('/src/lib/export/odt.ts'), import('/tests/normalize.ts'), + import('/src/lib/storage/headerFooter.ts'), import('/src/lib/styles/listStyles.ts')]); const bin = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); const res = ext === 'odt' ? importOdt(bin) : importDocx(bin); - const editor = document.querySelector('.tiptap').editor; + const editor = document.querySelector('.tiptap-host .tiptap').editor; const json = editor.getJSON(); // What the importer suppresses as the style's own: a block attribute equal to what its // named style resolves to, and bold in a header cell. @@ -169,8 +310,9 @@ const roundTrip = (bytes, ext) => ed(async ({ b64, ext }) => { text = own.text ?? {}; } if (header && n.type === 'text' && n.marks) n.marks = n.marks.filter((m) => m.type !== 'bold'); - // A formula alone in its paragraph is a display formula to both files. - if (n.type === 'paragraph' && n.content?.length === 1 && n.content[0].type === 'formula') n.content[0].attrs = { ...n.content[0].attrs, display: true }; + // A formula alone in its block is a display formula to both files — a note holds its + // inline content without a paragraph, so it is a block here too. + if ((n.type === 'paragraph' || n.type === 'note') && n.content?.length === 1 && n.content[0].type === 'formula') n.content[0].attrs = { ...n.content[0].attrs, display: true }; // A box with no size of its own is saved at the size the exporter falls back to, and // Word's table grid always carries widths, so a merged cell reads back with them. if (n.type === 'textBox') n.attrs = { ...n.attrs, width: n.attrs?.width ?? 280, height: n.attrs?.height ?? 96 }; @@ -178,6 +320,10 @@ const roundTrip = (bytes, ext) => ed(async ({ b64, ext }) => { // Both formats spell a header row as the one that repeats, so it reads back repeating. if (n.type === 'table' && n.content?.[0]?.content?.[0]?.type === 'tableHeader') delete n.attrs?.repeatHeader; if (n.attrs?.styleName === style || n.attrs?.styleName === 'Standard') delete n.attrs.styleName; + // Same for bold and italic: a heading is already both where its style says so, and + // a mark repeating that is what the importers drop. + if (n.type === 'text' && n.marks) n.marks = n.marks.filter((m) => + !((m.type === 'bold' && text.bold) || (m.type === 'italic' && text.italic))); // A run repeating what its own style says is direct formatting both importers // suppress — they cannot tell it from a style that failed to resolve. for (const m of n.type === 'text' ? n.marks ?? [] : []) { @@ -194,7 +340,40 @@ const roundTrip = (bytes, ext) => ed(async ({ b64, ext }) => { for (const c of n.content ?? []) styled(c, header || n.type === 'tableHeader', inFrame, text); return n; }; - const held = styled({ ...json, content: mergeJoinedParagraphsJson(json.content ?? []) }); + // The exporter folds a columns section's page-split chain back into one node and merges + // the flow-split paragraphs inside it (replaceColumns, odt.ts); so must the held side. + const foldColumns = (blocks) => { + const out = []; + for (const b of mergeJoinedParagraphsJson(blocks)) { + const prev = out[out.length - 1]; + if (b.type !== 'columns') { out.push(b); continue; } + if (prev?.type === 'columns' && JSON.stringify(prev.attrs) === JSON.stringify(b.attrs)) { + prev.content = mergeJoinedParagraphsJson([...(prev.content ?? []), ...(b.content ?? [])]); + } else out.push({ ...b, content: mergeJoinedParagraphsJson(b.content ?? []) }); + } + return out; + }; + // A list's type is only what its marker draws: where the document's own list style + // governs the depth, that level's kind decides it (listStyles.ts) and neither file + // carries a node type beside it. Both sides compare the drawn kind. + const effListKinds = (n, style = null, depth = 0) => { + const list = n.type === 'bulletList' || n.type === 'orderedList'; + const own = list && depth === 0 ? sheet?.list?.[n.attrs?.listStyleName] ?? null : style; + if (list) { + const eff = effectiveListLevel(n.attrs ?? {}, n.type === 'orderedList', own, depth + 1); + n.type = eff.kind === 'number' ? 'orderedList' : 'bulletList'; + // The name is not always written: direct formatting on a marker makes the list keep + // its resolved automatic clone instead (odt.ts), and a nested list's own name is only + // the memory for a later lift out. corpus.test.ts holds the plain round trip. + // Its marker then reads back as the direct char the style drew, so both sides carry + // the drawn one — the same model the kind above compares by. + n.attrs = { ...(n.attrs ?? {}), listStyleName: null, + bulletChar: eff.kind === 'bullet' ? eff.bulletChar ?? defaultLevelBullet(depth + 1) : null }; + } + for (const c of n.content ?? []) effListKinds(c, own, depth + (list ? 1 : 0)); + return n; + }; + const held = effListKinds(styled({ ...json, content: foldColumns(json.content ?? []) })); // Word's list model is flat — an item's further blocks are unnumbered paragraphs at its // indent, and nesting is the numbering's — so the DOCX leg compares a list's content // flat, without its structure or the indent Word writes on a block it does not number. @@ -209,22 +388,43 @@ const roundTrip = (bytes, ext) => ed(async ({ b64, ext }) => { inList = true; continue; } - // A paragraph following the list carries the same indent: it is a continuation of - // the item, which is the one shape Word has for a block it does not number. - out.push(inList && kid.type === 'paragraph' ? bare(kid) : kid); - inList = inList && kid.type === 'paragraph'; + // A block following the list carries the same indent: it is a continuation of the + // item, which is the one shape Word has for a block it does not number. + const cont = kid.type === 'paragraph' || kid.type === 'heading'; + out.push(inList && cont ? bare(kid) : kid); + inList = inList && cont; } return { ...n, content: out }; }; const flat = (n) => stripFontHoist(normalize(ext === 'docx' ? flatLists(n) : n)); - return { diff: firstDiff(flat(held), flat(styled(res.content))), warnings: res.warnings, held: JSON.stringify(held), read: JSON.stringify(res.content) }; + // The zones the app holds (its own storage is what it exports from) against the ones + // the file gives back — a zone is no part of the document compared above. + const { loadHfDoc, loadExtraHfSections, loadDifferentFirstPage, loadDifferentOddEven, hfIsEmpty, EMPTY_HF_SET } = hfStore; + const heldSets = [{ ...EMPTY_HF_SET, + header: loadHfDoc('header'), footer: loadHfDoc('footer'), + headerFirst: loadHfDoc('header', 'first'), footerFirst: loadHfDoc('footer', 'first'), + headerEven: loadHfDoc('header', 'even'), footerEven: loadHfDoc('footer', 'even'), + differentFirstPage: loadDifferentFirstPage(), differentOddEven: loadDifferentOddEven() }, ...loadExtraHfSections()]; + const zoneOf = (d) => (hfIsEmpty(d) ? null : stripFontHoist(normalize(structuredClone(d)))); + // Only the variants a set's flags put in play: the file carries them nowhere else. + const zonesOf = (s) => { + const out = { header: zoneOf(s.header), footer: zoneOf(s.footer), + differentFirstPage: !!s.differentFirstPage, differentOddEven: !!s.differentOddEven }; + if (out.differentFirstPage) { out.headerFirst = zoneOf(s.headerFirst); out.footerFirst = zoneOf(s.footerFirst); } + if (out.differentOddEven) { out.headerEven = zoneOf(s.headerEven); out.footerEven = zoneOf(s.footerEven); } + return out; + }; + const hfDiff = firstDiff(heldSets.map(zonesOf), (res.hfSections ?? []).map(zonesOf)); + return { diff: firstDiff(flat(held), flat(effListKinds(styled(res.content)))), hfDiff, warnings: res.warnings, + held: JSON.stringify(held), read: JSON.stringify(res.content), + hfHeld: JSON.stringify(heldSets.map(zonesOf)), hfRead: JSON.stringify((res.hfSections ?? []).map(zonesOf)) }; }, { b64: Buffer.from(bytes).toString('base64'), ext }); try { await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load' }); await page.evaluate(() => { localStorage.clear(); window.showSaveFilePicker = undefined; }); await page.reload({ waitUntil: 'load' }); - await page.waitForSelector('.tiptap', { timeout: 30_000 }); + await page.waitForSelector('.tiptap-host .tiptap', { timeout: 30_000 }); await page.evaluate(() => { window.showSaveFilePicker = undefined; }); const retried = new Set(); @@ -234,13 +434,27 @@ try { const doc = DOCS[run % DOCS.length]; const r = mulberry32(seed); const before = await docJson(); - await page.setInputFiles('input.file-input', join(ROOT, 'tests/corpus', doc)); - await page.waitForFunction((b) => JSON.stringify(document.querySelector('.tiptap').editor.getJSON()) !== b, before, { timeout: 30_000 }); + await page.setInputFiles('input.file-input[accept*=".odt"]', join(ROOT, 'tests/corpus', doc)); + await page.waitForFunction((b) => JSON.stringify(document.querySelector('.tiptap-host .tiptap').editor.getJSON()) !== b, before, { timeout: 30_000 }); await page.waitForTimeout(1500); - await watchFlow(); - const initial = await docKey(); const log = []; let broken = null; + + // Both zones, before the body ops: they can cut the document to a single page, + // and pages 1 to 3 are what the two variant flags spread the six zones over. + await setHfOptions(r); + for (const zone of ['header', 'footer']) { + const pass = await editZone(r, zone); + await writeFile(join(tmpdir(), `monkey-${seed}-${zone}.json`), JSON.stringify(pass.ops)); + check(!pass.broken, `seed ${seed}: ${pass.ops.length} ops in the ${zone}${pass.broken ? ` — ${pass.broken}` : ''}`); + if (pass.broken) { broken = pass.broken; break; } + } + if (broken) break; + const flipped = await flipUnderEdit(`zap${seed}`); + check(!flipped, `seed ${seed}: a flag flipped under an open zone${flipped ? ` — ${flipped}` : ''}`); + if (flipped) { broken = flipped; break; } + await watchFlow(); + const initial = await docKey(); for (let i = 0; i < OPS && !broken; i++) { const op = drawOp(r, await docSize()); log.push(op); @@ -262,7 +476,7 @@ try { // Undo until the opened document is back, redo as often, and the edited one is back. const undone = broken ? -1 : await ed(async (initial) => { const { firstDiff } = await import('/tests/normalize.ts'); - const editor = document.querySelector('.tiptap').editor; + const editor = document.querySelector('.tiptap-host .tiptap').editor; for (let n = 0; n < 500; n++) { if (window.__docKey() === initial) return n; if (!editor.can().undo()) return `undo stops at ${firstDiff({ type: 'doc', content: JSON.parse(initial) }, { type: 'doc', content: JSON.parse(window.__docKey()) })}`; @@ -271,7 +485,7 @@ try { return -1; }, initial); const redone = typeof undone !== 'number' || undone < 0 ? null : await ed((n) => { - const editor = document.querySelector('.tiptap').editor; + const editor = document.querySelector('.tiptap-host .tiptap').editor; for (let i = 0; i < n; i++) editor.commands.redo(); return window.__docKey(); }, undone); @@ -289,25 +503,28 @@ try { const schema = hasXmllint ? (ext === 'odt' ? validateOdt : validateDocx)(unzipSync(bytes)) : []; const rt = await roundTrip(bytes, ext).catch((err) => ({ diff: null, warnings: [], threw: String(err.message ?? err).split('\n')[0] })); const bad = rt.threw ? `import threw: ${rt.threw}` : schema.length ? `schema: ${schema[0].slice(0, 300)}` - : rt.diff ? `round trip: ${rt.diff}` : rt.warnings.length ? `warnings: ${rt.warnings.join(' | ')}` : null; + : rt.diff ? `round trip: ${rt.diff}` : rt.hfDiff ? `header/footer: ${rt.hfDiff}` + : rt.warnings.length ? `warnings: ${rt.warnings.join(' | ')}` : null; check(!bad, `seed ${seed}: the saved .${ext} validates and reads back${bad ? ` — ${bad}` : ''}`); if (bad) { // The file and both documents, for the repro. const stem = join(tmpdir(), `monkey-${seed}`); await writeFile(`${stem}.${ext}`, bytes); if (rt.held) await Promise.all([writeFile(`${stem}-${ext}-held.json`, rt.held), writeFile(`${stem}-${ext}-read.json`, rt.read)]); + if (rt.hfDiff) await Promise.all([writeFile(`${stem}-${ext}-hf-held.json`, rt.hfHeld), writeFile(`${stem}-${ext}-hf-read.json`, rt.hfRead)]); console.log(` kept: ${stem}.${ext}`); } } } catch (err) { // The app reloading under the run (its own recovery prompt, a settings write) - // tears the page context down mid-op; one more go, then it is a finding. - if (!/Execution context was destroyed/.test(err.message) || retried.has(run)) throw err; + // tears the page context down mid-op — either message says so; one more go, then + // it is a finding. + if (!/Execution context was destroyed|promise was garbage collected/.test(err.message) || retried.has(run)) throw err; retried.add(run); await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load' }); await page.evaluate(() => { localStorage.clear(); window.showSaveFilePicker = undefined; }); await page.reload({ waitUntil: 'load' }); - await page.waitForSelector('.tiptap', { timeout: 30_000 }); + await page.waitForSelector('.tiptap-host .tiptap', { timeout: 30_000 }); run--; } } diff --git a/tests/normalize.ts b/tests/normalize.ts index 7bb8df4..f43933e 100644 --- a/tests/normalize.ts +++ b/tests/normalize.ts @@ -17,6 +17,9 @@ const ORDERED_DEFAULTS: Record = { maxLevel: MAX_HEADING_LEVEL, leader: '.', citationStyle: 'key', }; +// Inline atoms that carry no mark into either file (see normalize below). +const MARKLESS_ATOMS = new Set(['formula', 'image', 'hardBreak']); + // Mark-attr defaults dropped like ORDERED_DEFAULTS is for node attrs. const MARK_DEFAULTS: Record = { plain: false, // link.ts default; the DOCX importer writes it out explicitly @@ -59,14 +62,16 @@ export function normalize(node: N): N { if (node.type === 'doc') canonNoteIds(node); const out: N = { type: node.type }; if (node.text != null) out.text = node.text; - // A formula adopts the caret's marks for its font; neither file carries them. - if (node.marks?.length && node.type !== 'formula') { + // An inline atom wears the marks of the run around it — a formula's font, a picture's + // or a line break's inherited comment. None of them paints, and no file carries them. + if (node.marks?.length && !MARKLESS_ATOMS.has(node.type)) { const marks = node.marks .map((m: N) => { const mm: N = { type: m.type }; - // An empty string is the picker's "unset" (a font family, say) — no value at all. + // An empty string is the picker's "unset" (a font family, say) — no value at all, + // and an empty list (a comment with no replies) is the same nothing. const attrs = Object.fromEntries(Object.entries(m.attrs ?? {}) - .filter(([k, v]) => v != null && v !== '' && MARK_DEFAULTS[k] !== v) + .filter(([k, v]) => v != null && v !== '' && !(Array.isArray(v) && !v.length) && MARK_DEFAULTS[k] !== v) .map(([k, v]) => [k, typeof v === 'string' && /^#[0-9a-f]{6}$/i.test(v) ? v.toUpperCase() : v])); // ODF keeps the authored (naive local) comment/revision date, DOCX re-serializes // the same instant as UTC — compare the instant. diff --git a/tests/render-parity/make-fixtures.mjs b/tests/render-parity/make-fixtures.mjs index 6af9ef1..b6fdd92 100644 --- a/tests/render-parity/make-fixtures.mjs +++ b/tests/render-parity/make-fixtures.mjs @@ -339,6 +339,25 @@ await write('15-chapters.docx', [{ }, }, 'chapter-numbering'); +// 16. All six header/footer zones at once: a title page of its own and separate even +// pages, both riding on a running zone. Four pages, so each variant renders at least once. +await write('16-hf-variants.docx', [{ + properties: { page, titlePage: true }, + headers: { + default: new Header({ children: [new Paragraph({ alignment: AlignmentType.RIGHT, children: [new TextRun('Odd Header')] })] }), + first: new Header({ children: [new Paragraph({ alignment: AlignmentType.CENTER, children: [new TextRun('Title Page Header')] })] }), + even: new Header({ children: [new Paragraph({ alignment: AlignmentType.LEFT, children: [new TextRun('Even Header')] })] }), + }, + footers: { + default: new Footer({ children: [new Paragraph({ alignment: AlignmentType.RIGHT, + children: [new TextRun({ children: ['Odd page ', PageNumber.CURRENT] })] })] }), + first: new Footer({ children: [new Paragraph({ alignment: AlignmentType.CENTER, children: [new TextRun('Title Page Footer')] })] }), + even: new Footer({ children: [new Paragraph({ alignment: AlignmentType.LEFT, + children: [new TextRun({ children: ['Even page ', PageNumber.CURRENT] })] })] }), + }, + children: Array.from({ length: 40 }, (_, i) => para(`${i + 1}. ${LOREM}`)), +}], undefined, { evenAndOddHeaderAndFooters: true }); + // ODT twins, written by LibreOffice itself — the dominant ODT producer, so they carry // its own conventions (percentage font sizes, Text Body, list styles) and exercise the // foreign-document path our own exporter never produces. diff --git a/tests/render-parity/run.mjs b/tests/render-parity/run.mjs index b4c45c0..6fa553f 100644 --- a/tests/render-parity/run.mjs +++ b/tests/render-parity/run.mjs @@ -84,7 +84,7 @@ async function editorRender(browser, file) { }); await page.reload({ waitUntil: 'load' }); await page.waitForSelector('.tiptap'); - await page.setInputFiles('input.file-input', file); + await page.setInputFiles('input.file-input[accept*=".odt"]', file); await settle(page); const result = await page.evaluate(extractLayout); await page.close(); diff --git a/tests/schemaValidate.ts b/tests/schemaValidate.ts index f21725f..4ff0882 100644 --- a/tests/schemaValidate.ts +++ b/tests/schemaValidate.ts @@ -114,12 +114,19 @@ function validate(schema: string, kind: 'xsd' | 'rng', parts: Part[]): string[] // Every XML part of an unzipped .odt against the ODF schema (foreign namespaces // stripped, as extended conformance allows). Formula sub-documents root math:math, // which the schema's start pattern excludes; mimetype and Pictures/ are not XML. +// libxml2's RelaxNG cannot match an element the schema declares by name *class* inside a +// — the ODF statistic fields share one , and probed, +// text:creator (its own name=) in the same span validates. LibreOffice writes the span +// too, so the field is unwrapped for the check rather than avoided in the export. +const STAT_FIELD_SPAN = + /]*>(]*(?:\/>|>[^<]*<\/text:[a-z-]+>))<\/text:span>/g; + export function validateOdt(files: Record): string[] { const keepOdf = (ns: string | null) => ns == null || ODF_NS.has(ns); const docs: Part[] = []; const manifests: Part[] = []; for (const [name, bytes] of Object.entries(files)) { - const part = { label: `odt ${name}`, xml: stripForeign(strFromU8(bytes), keepOdf) }; + const part = { label: `odt ${name}`, xml: stripForeign(strFromU8(bytes), keepOdf).replace(STAT_FIELD_SPAN, '$1') }; if (/^(content|styles|meta|settings)\.xml$/.test(name)) docs.push(part); else if (name === 'META-INF/manifest.xml') manifests.push(part); } diff --git a/tests/smoke/run.mjs b/tests/smoke/run.mjs index 2e7b52a..7090dcb 100644 --- a/tests/smoke/run.mjs +++ b/tests/smoke/run.mjs @@ -40,7 +40,7 @@ try { check(true, 'autosave survives a reload'); // A corpus document imports, renders its table and reports a page count. - await page.setInputFiles('input.file-input', join(ROOT, 'tests/corpus/04-table.odt')); + await page.setInputFiles('input.file-input[accept*=".odt"]', join(ROOT, 'tests/corpus/04-table.odt')); await page.waitForFunction(() => document.querySelector('.tiptap table td')?.textContent.trim(), null, { timeout: 30_000 }); const pages = await page.evaluate(() => document.querySelector('.statusbar')?.textContent ?? '');