TZz5Zv|Te@Eb?4Jz6OCZoHUQ`x#qMLbO-y>L1#e+Gv2!!X`rY*?SyP&^RgcktJY|*S>vW^zCZsr
zcN^m6ZaGv7UV;PxLHX0%=p_Lv=;~Sg#|gF+qiLP;V}em0@~X62
zGezc7(-h6rMmbi87CpZD#Qe3GH!=b>*^5`7M+{w(Hikmd7k0=EfVh)7+uGu$h>}(h
zp=P+^DndE>xdV}u*4``by&m^dNscRbqs`6{1iclPK)
zJ}PR+sw5_x6Vin%PTeZI1)JYV6%S{1~bH7z8cgeWOo%jAiAokvB044al
zV6}0j8UJ^9?yFF4(fVGQP@?R!_44cSc;z+9(XzxX(XGlVtA4XLMzgf1o4C)Th35NJ
zZ?)s$4yP4xS5N1(_>TIM=|tZw50>Awu?F@!!bLPKE#S4fZn;)Bwk38BAMaG8a0kv9
zT@Yo)Upzd2dn7}Hyh!8t9w@8u+;P)|n-Lwy;k7lpOSfUU`qbHT4LkP){&(R1k+gFr
zkgOwL2Gu+E-vjq=qPaIe6?uExEi2y79A?nBl7<8v&P9sbO?!^iGw7J
z91I3$>;MdyM!&DOc
ziA@uT@l{eiu~C+V-!Sl#+>2}*iny)C9zg;|RbNvYYvk7WX)-6s)1+hE!h`J8gb{9#
zF(`iwzCZ}*nV*Ef_*%%uC)QDo49B@eEG{yab+RzdXRzV;Fzo7Xcvp|TVl_jm^2S0f
zaU^zaArZm^OzznC?zMH247CRb-66rtM*kXWFq~TBJL!KP}sRF0sU_3E&EFWqEjoNrH~uSWt%O
zVj+Yn+Tf5clo8@;r(KT1WcMIJ{6z4yOGDbgr8Uu;z2rzK2#gqjVbf(busMfzX-ToO
z$q{5#2#@997i95GJSnAHh*Nq^0o$6P;T9l;i3wpMfVJ`kiBJ
zgB3JAQsZnkR~(m}UsV7RsoYzNn*_{ZhHE|fV)Bq@u)r~N;6s@vL2DG!z97G7Go6S>D
zrRAO2tf^Xc3;XUL%|o>n!3IGeJC=LFK{
z1s)#UXC};E7zb!dgk-wQ^m0YiHLGa0H*z3ADQM}po^|Ll+RYkY8
zRcsb55AmA4Tb`t!t-eTK*Id`>o+`g=xqBb-{1wJ#Tk&M|bVc!GQyz*y8mY=%dmbHF
zho7JV(+Hz=PxQ432pcVEOTTYk@LZm(%_ImJCN|ISKw7>5&P&=f_g2uPbl#
z~8My}m7G<9*^RXWxC8n#{{GeT~MY`H3q&Y>>gB|<0?_L{aaBYD*gd9n;
zT3n{ST7Flq+f42tk8C%FgDE``1{vz(k8%<(OMD}jEr9A71CNfN>87n^}2xRz*0*^
za4gMA2$EWFgh-i*d&UUY=%83067aS2j+*tXRk++}q~jN01J8hZx?ss39mzr_2a=ZTNtcs)<$uN}5R_fGOQ1mLc5E>8lm^
zSQ-MP%H%<+t(TX!|VS37&xZ8t<m&rTVUY-5qQ_EN-
z!@{c5`Z+~)g6=%eC7x$=E(pET+tH1*weudEhW(Sno*hQyNF%hO4elJuVHk+}g31
zR@|EHF~yeCj(o-Dd!59Hts#3&E6l*gYEzBZI^^R`!YxKim!sR}+Ail_ZAVYAf2Z~z
z3ns>BeIpz%Yac~$Kc#j%LwkEuD`UIAQv1JJE>T{vS^ywelTme(!@No0%p&;`Y9>B~KhxlT%p;wDJRo;W_vXJ85y`f>6|Z25CREoQyTgRGD|7jrLP?LSh)tq5Dw2EonvQ=Hm71B6KsE*Xxk~J
zFY!98tUZzLC@^)iMluBt8nKy||FNdjSYe6p3#+mSen9lLplda{TvqCPP9jUhsa>{d
zmonf%#4OE-o!fo`{b_Q{Ib&v?M_jbqeGN(RM>qIK8;m<|2&VbW0T}dv!csm6jRsoku;`&%-P;IO<*_rg#Xg2?(~pld<|)(yP+e>E1Z;h^U1Nt=
zre)s;zbrV-=kzmeT?dn~dI>T&R5v9gfsxWu@#JXt=d}}>SyC~vTb^;y+^D`1m>VKo
zYd%ImS4B23o6A=gn@Q~o88?%Ri{2B`0qiJ?3vu9P6TK1)D1FbP(C<+_It#9w*zh^_
z%giP$pwj_hdOJa{{aXGom82+oK!h{P(w51DLIN6^^0Oa>+U7iGJ0)Rj3()Ze+lS
z5zJFN5xh@1)3Z%QH&EfTYKxH%+bM8T~
z{iu?A2y}!|XjZZ}0##g2t0q&R$)r6>6{iSL>fHH_YSEI_M&`M7Z>@K-(rfvSZ*4ZK
zJc0|`;z~;}mS`1f-#9YJM%zazB&rXjY0kQAXM@*_y;6XEuq{aT*!yCfffFe>iC`s5
z`ne||Xnbb~hNJ+C%x{XS>0qi)!6;5(rm$G;{q};%5lbVzqyax1Q1=GZg5b<@QP=e3;!M)IKePFS%91l>RSfChT-?fxCPEk{brWawRIa;%-Sd2)BhlS
z;t;{hT$_?9t`KF&&G62$ip~C<->oSY*sepIh;vg>PPGli8a-}(?&N|7^fj~5ZWNrR)=6LXyd
zP?H$hn5R*r824IAb9iQEc!K;j0#PjAi5R`C3emqrA+$f=TAd8_0h(8p66Wbq{yHM|roC(vhu+`BAWNdmq
zD*2vfs&*URE)c$iIXEz94!a^N
z*cibcZTyZeu64Rty#^+Y4j<-(XGZ^GoJC`vg(X?zy%2OD!8ub{cj_CnLM$G!WXhc9
zkEXmj?hr%Z2lvBuGe
zE~^mN-Fb=op6j`!1dtLrbB>bVXKQw`fr)o;;Ud}`82H-zRNX~nwiO~^F&q6eu$&06
zrVD)E%tR*b8-uPf2Q$O&ywMve1LGij_hvQBOP5!))&n#(&bt3$CC|sjSb7@qWtqJg
z1O)LUf;;~Zv2pOO5Ncg6TY)^02IUkQwwSXN{`rrV_Pi9~%
z5v8WJ?%eA6>V$^vedX6r+5Pb)D8UMZ8d4b)I7Go2UXP8ZWsJa(7N&Z;86^L@M2bKpm7Qh>?9{ODBzNA#{z&
zamKxKJ)?sjv8!JZ%Tk4;RS2;YK9pec5SDV$LUo<~?TUDM;6`=zJW*h9W2Rq8+|G$|
zR*aQ%B-Oocmxnjj>(g%wJrLjIF>4B#K75h=egePAE
zI-A)g94(fKFs(=$w+%FL9i#_DXZz$cAWZNRGg`a@-e~vQkQm-**;=Av{_E9zs-V-{
z!oUaV64%=Bky-!Y7jUlvpwR1~o{h4i$BRsPm6R$nNej?ti=_JaD3n}L!0MnY#
zhe=Odn+W=Iji0_M%-ld?+_P#@wTj>u6+v^l>0MuKKl`OImu_Xc%NP~UaI2gL^C!v2
zYYMpNIoC#=$_@W-W5FPO(Tx?@NmU`)*&J=
zKlt_5#q;Eobu;d$gNw|)ZpD?x(|s~zhW?RKua{d69w+arpD7WK^ft?upyxsB)80v0
zSkt;iJ6Qo?t~aAsnycd4z(X6v992PCzvpmL^NmW5N01$Bmx@|f?F_qqzMCas7Hl$J
z3DLa@gk*%`*ntYf@XMcd6^JyieV^ub^w}s`J7$neq)$TOCV1g&t*u!xpy=YFCRh7I2_i&rL_uN%>mK+P
zaffyH?sXB9uA0&*-`Li4#MPb=7z|7{tpjg59&HBcw;MqtkeJp%*v4wc*|QSqWXjDV+dgoF1y%1
zw?-4q3{G8hBF{kLV9KjJ+JQk#^0LV=J+k~bU@o*3nik~5k(J&u5x|enLB3Sq&QN
z?ckHplr^W?vuD#aO2%;nm(bGAQTZcNK9sZ5GJmF#p&RLzUH%Mx=p27now@jk86lo&
z)}RY^OR1by8
zC-$}?!E0k-%UN}Awp|8EqvGFQ3Av))dERtsa&g
zB;h%?Xf;m6$JaP}MhjUIk-EY>VwEI@*q;G88q3JlTlfRKqVx0i=wPHE1N=A3uIhVP
z6e&OFUUu~iF?A9Kp5PmPCLB5EXWb~u9sfcB2We2H6!x&mEyBfJ!aW{8jQ*jqURK%j
z2(!WkuUaR_@@FR9mkQ~9B{c)$gcmeU6WDB9CAW=2c*~V4p4&Ha1$|)gHiq-m;bF$Z
zmU9-NP{~xmemiJnDJ1N>Dh9Ymd(^I2dL?H+c%_$X$nCNHg_+0;PM*+R7_hNn!a|@S
zgamy-bJpK^N1=>8Bc$P|JTKUr={z1!_qPWHfUq&q@RGJXpq8CYwHH1}c+1`(9-+wl=AJd+kS+OBh};ySwfX(hU0V)+m$0cPA~CJa60*jCf=z_CAd_dMD=h$%n__Ix2(`g
zL|SJ!LAKrEIKMr@JJ%qoC=v5dFru!pBbB0vKUN34&e$${T~m?;Y&)5w(Iz1S(Ap2QrNS^GkUTi_Vwn
z4@3O3u~=dIuT~D<2bsNfd(gUW(6eV82?%%6KW*PVeRS_DTz6vffG+
zjx7rmB6Yu%ziIq!`41h-9_BqK7hAHtB7G<=xccPLWyhnM$1_d1wVcF%h@dzn7BT&(
z>X)EIL0IwpA*0@I;|Y=>byM_cu<
za_kNQtI#1f{<*J~r}ajFS_7NqFX@g41*$Gy)bC8}dT}~5BF47kBWVv8J&h+Wpfu+v
zN$vAwBR;On!}?MxA^IDsAQ}p>#bU>s*=6k^S19m3v?e>DX_2`PYK*%cGV
zGQWU^nMIQ46a_&24jQB*!1j^30EZ9z1@&8@QZ?1~yxcGz2UH36Izt~DzlKdYHjaTf
zn|Y^2QGiY&{rmEvsyr@Jqj|Zsx78L!QaP%1;5V#;@i@V3FBLZXVEl!`MpYg~@vy&H
zJUyFi_+Wg^j}uD8w)1kd)Abgj(rgUsX4yCiFO8N2sl!#|^e<*FCgfjC
zk_&qb#U+0Sa?^?&{ulOtUa9sc_-`<|1;2s++4uwIAAZA?Q3B|r{BFroC051$IJ8Ok
zluP$fTk3{0$QkcWyWV`9{Sv=8uCpNR@H1K6w-A{J9%K)Sm5*9(8vVn|ASqzQgsl#P
zmc_Z1rNME>1o4DQ=pzZ{)hKam-q~U&?q$y4t0lu_3ee&dYM0C=G{8tkAqxNlel|c0
z&922r*0Jux^g74Ry`@WIkY0nVhU4AA5Kqde*4ALz?Y3b=sx@!WSztHjSHy_vFJ{`+
zdF};?-@QfaDpgyD=Sh?;6I*I`moVGts0$bva~xtguY?CF?9Mj#9}Sh!z#FQ(g7Zouaq$_LF-`ujNuNyvV^^$o`PLk%UMI_3(c3t$I
zK;5(I3riCwntJF6?SfUKUcYKO0#n0GBzQ|DF}k(V%HL-hPvca7y?NE5%mQkiXx^eL
zwF`ev*nw*#3*nG69pUJfHs+JIY#8!r?L&tAnb#BMmX|Gf
z_aw_v(1?-L6NTe&muhQ{+^rZuyJU>`Je?ObeTsk
z27V1l=h6V&pwRbw?gh;^UN}PBUyGO7UN>>OL8^|XAARqX!U-*xJ@2}|Tu1^gecqB}
zmNr*BHD=6Y+k1!EBLvBp$V2r=b*|>IEgBX3Nu3%WRhH|it)OO9fjoLblr|5QM2xcD
zTgWNkd9T`Er0zhe&Vr=wKwf`;j>S<#DZi{#B3LCU9fy{(x3e@Z5E~h>T7F}#P+nO?
zY`ZLWD3~fK1Bbjoup>Lp4+0rFRG##ujY~vqM4g(GGJEr3it3i(+-#nf>FS<~TZ}m?
zKk}Q=q>xKMKr$T|i{3m+QYt*Erg4HcPu@hoO0u6ICS({Pxc0P3trUZleH&T9Mj2
z!EyQsAkBR%j(Ebj-D}}l`E`IQ^W1lGk7*S{3v;bY11Ct3xTah5A!&;aWda
z9~V_uIrmNLObs+G*{C&Zym{;)hcAi8cm1FzCver+gTbPRTkDhJK??n};(&v8BU`e6
z)q-~rwE3|&J@rUk-{A60QWH|C?K>`cXdaJ3tcFqEb_;rF=DKz@z2u7b`^_3@vX*CO
z_>5^WzJgvmwGYJ`s7|*7u=VBgml3n8JDNhq+=wdJ7a;DdB3B=8JB3z?ARWQJ&mGOt
z56hLLe_cFI*^|(=OiQunW2jv6-nD2|8M-p8SuQhabQ(8TkUXV++Vc9>qLCk>kKeY7
z?M)0V4gUu*!Y1pls1=#%*hC-c4boj%;0$t26X((4>jUTb_(B
zcNnXn`NU+bg8b&b^_SyID^J@9&GdS)L`1jLZSa9gbOMeGbuL5BG8su>GmW&D@0b@fNM#3cQTUUzv;qr=D$D<1w~58mS!Etv{Yk
z!o~7=#g_J7OZORxV}GYD?k&W}_e952Z`&I^XLsGm>t54n;aDKLTB-NW9(+MOL~PXm
zoU^vT?Y?tDu;V*FHG7hVzs%bnfoBagF?aR~JSJG>u9h85F?EIrftFGZ{BHGmgOvS{YjOkx*jG6$BA$q-(5^u}qDvFEqo`_35B@XY}dx
z;e0fM_Z}C}Axj6>?8X$n*PQWux8>FL2zE2N@_cr#?Db~D1^@Z%p11X3r%lUH%cSb*
zp>vSX%lY&Y^ZDtB7RSXY_z3?!0H(+M`sr&}tdNYB?T
zG<(zdoORXQsxY_axnf)*Bq=DFCk)b&us>jBp+QG#=g|7W)krhXJh+B|32|z|oKU~k
z4+>}C?1j~(Sn3Q88|)#I=}|u)K1F59?=tWVVX)6##EndpAjxhx)vw23Vx|dzE+UP{
z43Z=nWv3g%8+nle`awZdBiUN51NCqvkHDz*F{wBxIqa3YkJO?-2TX-ykqq!d;_=buAno_Ey0qp^~*#8RgS^7q${|7(hq$
z-vp7X1>-YY41GG~nvlDPA_isD4MK8$2!@tsF~jiSjaS?vcyqhWGiAp{SP(R*MQv>b
z=iRL#KF@+S{`zfiN=rwzw<)M<%vK=nTSFsQ3XU9VZPAJeF>0gRe6~>MF4P+#C276F
zJVl=EZTF>$3(M=oP2eL0-o*@_>fFj4$4(@XT}LWE+$V`GCAk?RvDP#+OUq6AOUWi!
z-F}51vW12xo`Ry-11Ap2`p0x5KGh)Jhj=5H*i1}Iu$4BOfs%Tc8b5%XQk*F4N)Xl91|$X*BuO&YvN9d9fmKvch01emD#`s)vq?uF~kKq$gwfE(|CNrQm5N#Z>G
zR&7Vn=^nSw`72HFabsI&Xbgj4sVw{06}$8A0g0hBQ^ZEK2QSM)pS9xLWbaRmzX%ie
z>!}b>Uz%yPPw$IK9rDCZJT_HHGH3{#?*kmQrN5OV!&8cUt_*8wlW)y0wp7}}4PDAS
zZoW%&zDCWWD6@{AVHY>&0+v^j7ShjJo}|oT4Lbo5uBvCs+2YOYPa4=YfpJN^aff@!
zXe?xLq~x63x?=Y}0V!wXYuyagN2cnpMO;JTK?0Sl43bmjJ#945D<}25A5Mycgg)`w;WWRa)JjGz*Wsthz55(T^UZrpr|qT@`vVV(tzdxdBgsd*M4c+?w)@O&
z#Mb9XiMK_9A^||{Zof^T+1~Jd)MD`X1eyJ
zTB1rfmu|naz-mxcaHmV{*sYj5P@>G*3VE8dT1l%dJy{$KFR2>uiP&HV(N;dT4a?^3
zA3gsY!S!Fs_u5(c8H+{XxgD;!gdM?nf
zm}@ChY>Dp%WHt%tr>~w)9og1P11cx0sA<4Cn`}2KqE*HVNxRG}cbw)!GWb4|rS(X&
zWVQC&BG89urhI0GTY-t>Yh!b(+S{Dz)cC@EuwVh(+)RB0aq511fXRUSy96y9Ldap$
zurj!B&AS*|B%_1|sc$lYdLfo&L{|IiNYhv+D0SA!%d0UQ_pk6X3L^>Y-QI88z!JId2Mb~y@%}Oe;hr9e@
zL<;DcLkR71F+TXRW%3G?0PJtw`mf(6VErb7f7$l}$@Ttm_)ou7fcX7Oh5sXm{{j53
zH1)p&|B%KXzR!BW|4Cv00{2hV^}q7`RbT%z&&{9V{!-ijJCc9*{#Wt$&!}lHgX%Am
ze@nst9qd2v`)iP&`!4hH3CN%0{J-n?uVUq&DMtQ<^~VtZ-^oGzLaz1Y$@UL*?|;Yb
zS8eal+#dgg`Uj=&zoYoOTmG-K^JiQo=szg_Ji`?g{yVzA%1VBQWk&u3_E(X~e`fnb
zt@7($@z1c;=zqZepPl3X4*Ay|&7YBtG5;j|)Bfha1O4ZN6ZRMX`&0e_`sZuWe|KAd
zeJT2x<-o6iKffaV|8hlP{DbTNHk*FU89#GX{uS`&@%1037sSh0{}I`cKxAKLWHQr_
G|NbBE&;mvP
literal 0
HcmV?d00001
diff --git a/tests/behavior/tests/navigation/fixtures/pageref-standalone-uppercase-h.docx b/tests/behavior/tests/navigation/fixtures/pageref-standalone-uppercase-h.docx
new file mode 100644
index 0000000000000000000000000000000000000000..06829ef754943d472aa3c1a8547d88dbf0474e74
GIT binary patch
literal 13828
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 element.
+// The wrapped case (Word TOCs) is covered by toc-anchor-scroll.spec.ts.
+//
+// Both fixtures are a 7-entry TOC where the first entry's outer
+// has been removed, leaving only the inner PAGEREF \h field for that row.
+// The other entries retain their wrappers and serve as a control.
+//
+// The "Introduction" entry uses bookmark id _Toc227765979. Its page number
+// anchor only exists if the PAGEREF \h switch produces a link on its own.
+
+test.skip(!fs.existsSync(LOWERCASE_DOC), 'Standalone PAGEREF fixture missing');
+test.skip(!fs.existsSync(UPPERCASE_DOC), 'Uppercase PAGEREF fixture missing');
+
+test('@behavior SD-2537: standalone PAGEREF with \\h renders a clickable anchor', async ({ superdoc }) => {
+ await superdoc.loadDocument(LOWERCASE_DOC);
+ await superdoc.waitForStable(2000);
+
+ // The first TOC entry has its outer stripped. The page
+ // number should still be an anchor because the PAGEREF \h synthesizes one.
+ const pageNumberLink = superdoc.page.locator('a.superdoc-link[href="#_Toc227765979"]');
+ await expect(pageNumberLink).toBeVisible({ timeout: 10_000 });
+});
+
+test('@behavior SD-2537: clicking standalone PAGEREF navigates to the bookmark', async ({ superdoc }) => {
+ await superdoc.loadDocument(LOWERCASE_DOC);
+ await superdoc.waitForStable(2000);
+
+ const selBefore = await superdoc.getSelection();
+ const pageNumberLink = superdoc.page.locator('a.superdoc-link[href="#_Toc227765979"]').first();
+ await expect(pageNumberLink).toBeVisible({ timeout: 10_000 });
+ await pageNumberLink.click();
+ await superdoc.waitForStable(2000);
+
+ // goToAnchor moves the caret to the bookmark target.
+ const selAfter = await superdoc.getSelection();
+ expect(selAfter.from).not.toBe(selBefore.from);
+});
+
+test('@behavior SD-2537: standalone PAGEREF with uppercase \\H also renders a clickable anchor', async ({
+ superdoc,
+}) => {
+ // ECMA-376 §17.16.1 says field switches are case-insensitive. \H should
+ // behave identically to \h.
+ await superdoc.loadDocument(UPPERCASE_DOC);
+ await superdoc.waitForStable(2000);
+
+ const pageNumberLink = superdoc.page.locator('a.superdoc-link[href="#_Toc227765979"]');
+ await expect(pageNumberLink).toBeVisible({ timeout: 10_000 });
+});
From 2c38373ce6629d95ff5c3a2f344ad075237ebaa7 Mon Sep 17 00:00:00 2001
From: Kendall Ernst <84405229+kendaller@users.noreply.github.com>
Date: Wed, 22 Apr 2026 14:57:54 -0700
Subject: [PATCH 14/43] fix(paragraph): guard listRendering destructure against
null (#2896)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(paragraph): guard listRendering destructure against null
ParagraphNodeView destructured `{ suffix, justification }` from
`this.node.attrs.listRendering` without a null-check in #updateListStyles,
and accessed `listRendering.markerText`/`.suffix` directly in #initList.
When a paragraph node carried `listRendering: null` — which can happen
after certain editor mutations (e.g. dispatching a transaction that
combines `setDocAttribute('bodySectPr', …)` with a paragraph delete) —
the post-transaction list-styles re-pass threw:
TypeError: Cannot destructure property 'suffix' of
'this.node.attrs.listRendering' as it is null
Use `?? {}` and optional chaining so a null value falls back through
the existing defaults (`suffix ?? 'tab'` and the `suffix == null` branch
in #createSeparator).
Adds a regression test.
* fix(paragraph): no-op list style updates when listRendering is null
Previously the null-guarded path fell back to `suffix = 'tab'` and still
invoked `#calculateMarkerStyle`/`#calculateTabSeparatorStyle`. Reviewer
(codex-connector) flagged that in mixed-suffix updates — where a queued
RAF callback runs after a node transitions from `suffix: 'space'` to
`listRendering: null` — the separator may still be a Text node. Writing
`this.separator.style.cssText` on a Text node throws.
Change #updateListStyles and #initList to return early when
`listRendering` is null, leaving the existing marker/separator untouched.
Future updates (when the node gets a real listRendering or isList()
returns false) will clean up as before.
Adds a regression test covering the space→null transition.
* test(paragraph): cover constructor mount and null-to-tab recovery
Addresses review feedback on #2896:
- Update #initList JSDoc `@param` to include `| null`, matching the
no-op-on-null behavior added in the previous commit.
- Add a test that mounts a ParagraphNodeView with `listRendering: null`
(the constructor path, not just update()), confirming the null guards
in #initList and #updateListStyles cover first-render too.
- Add a test for the space→null→tab transition that verifies the
separator swaps from a text node back to a span when listRendering
returns with a different suffix.
---------
Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
---
.../extensions/paragraph/ParagraphNodeView.js | 19 ++++-
.../paragraph/ParagraphNodeView.test.js | 76 +++++++++++++++++++
2 files changed, 93 insertions(+), 2 deletions(-)
diff --git a/packages/super-editor/src/editors/v1/extensions/paragraph/ParagraphNodeView.js b/packages/super-editor/src/editors/v1/extensions/paragraph/ParagraphNodeView.js
index 94b3717102..b5156a4586 100644
--- a/packages/super-editor/src/editors/v1/extensions/paragraph/ParagraphNodeView.js
+++ b/packages/super-editor/src/editors/v1/extensions/paragraph/ParagraphNodeView.js
@@ -232,7 +232,16 @@ export class ParagraphNodeView {
* @returns {boolean}
*/
#updateListStyles() {
- let { suffix, justification } = this.node.attrs.listRendering;
+ const listRendering = this.node.attrs.listRendering;
+ // When listRendering is null (can happen transiently during certain
+ // transactions, e.g. after a setDocAttribute + paragraph delete), leave
+ // the existing marker/separator untouched. Forcing a default `suffix` here
+ // would risk writing tab-style CSS onto a text-node separator created by
+ // a prior 'space'/'nothing' suffix and scheduled RAF pass.
+ if (!listRendering) {
+ return true;
+ }
+ let { suffix, justification } = listRendering;
suffix = suffix ?? 'tab';
this.#calculateMarkerStyle(justification);
if (suffix === 'tab') {
@@ -280,9 +289,15 @@ export class ParagraphNodeView {
}
/**
- * @param {{ markerText: string, suffix?: string }} listRendering
+ * @param {{ markerText: string, suffix?: string } | null} listRendering
*/
#initList(listRendering) {
+ // See #updateListStyles: when listRendering is null the previous marker/
+ // separator are left in place; avoid invoking the create helpers with
+ // undefined values.
+ if (!listRendering) {
+ return;
+ }
this.#createMarker(listRendering.markerText);
this.#createSeparator(listRendering.suffix);
}
diff --git a/packages/super-editor/src/editors/v1/extensions/paragraph/ParagraphNodeView.test.js b/packages/super-editor/src/editors/v1/extensions/paragraph/ParagraphNodeView.test.js
index 4575db07f8..08376ef5d7 100644
--- a/packages/super-editor/src/editors/v1/extensions/paragraph/ParagraphNodeView.test.js
+++ b/packages/super-editor/src/editors/v1/extensions/paragraph/ParagraphNodeView.test.js
@@ -199,6 +199,82 @@ describe('ParagraphNodeView', () => {
expect(nodeView.separator.textContent).toBe('\u00A0');
});
+ it('does not throw when listRendering is null', () => {
+ // Regression: #updateListStyles destructured `{ suffix, justification }`
+ // from `this.node.attrs.listRendering` without a null-check, throwing
+ // `TypeError: Cannot destructure property 'suffix' of ... as it is null`
+ // whenever a paragraph node carried `listRendering: null`.
+ isList.mockReturnValue(true);
+ const baseAttrs = createNode().attrs;
+ const { nodeView } = mountNodeView({ attrs: { ...baseAttrs } });
+
+ const nextNode = createNode({
+ attrs: {
+ ...baseAttrs,
+ listRendering: null,
+ },
+ });
+
+ expect(() => nodeView.update(nextNode, [])).not.toThrow();
+ });
+
+ it('does not try to style a text-node separator when switching to null listRendering', () => {
+ // Regression: when transitioning from a 'space'/'nothing' suffix (which
+ // creates a text-node separator) to `listRendering: null`, the null-guarded
+ // path must not fall back to the 'tab' branch, since writing
+ // `this.separator.style.cssText` on a Text node throws.
+ isList.mockReturnValue(true);
+ const spaceAttrs = {
+ ...createNode().attrs,
+ listRendering: { suffix: 'space', justification: 'left', markerText: '1.' },
+ };
+ const { nodeView } = mountNodeView({ attrs: spaceAttrs });
+ // The separator should be a Text node under the 'space' suffix.
+ expect(nodeView.separator?.nodeType).toBe(Node.TEXT_NODE);
+ const textSeparator = nodeView.separator;
+
+ const nullNode = createNode({
+ attrs: { ...spaceAttrs, listRendering: null },
+ });
+
+ expect(() => nodeView.update(nullNode, [])).not.toThrow();
+ // The text-node separator must be left alone (not replaced, not styled).
+ expect(nodeView.separator).toBe(textSeparator);
+ });
+
+ it('does not throw when mounted with listRendering null', () => {
+ // Regression: the null guards in #initList and #updateListStyles must also
+ // cover the constructor path — mounting a paragraph whose listRendering is
+ // already null previously threw before update() ever ran.
+ isList.mockReturnValue(true);
+ const nullAttrs = { ...createNode().attrs, listRendering: null };
+ expect(() => mountNodeView({ attrs: nullAttrs })).not.toThrow();
+ });
+
+ it('recovers marker/separator when listRendering returns from null to tab', () => {
+ // Regression: the null-guarded path leaves the existing marker/separator in
+ // place. When listRendering clears and later returns with a different suffix
+ // (here: space → null → tab), the separator has to swap from a text node
+ // back to a span element — #createSeparator handles this only if the
+ // recovery path actually runs, so exercise it end-to-end.
+ isList.mockReturnValue(true);
+ const spaceAttrs = {
+ ...createNode().attrs,
+ listRendering: { suffix: 'space', justification: 'left', markerText: '1.' },
+ };
+ const { nodeView } = mountNodeView({ attrs: spaceAttrs });
+
+ nodeView.update(createNode({ attrs: { ...spaceAttrs, listRendering: null } }), []);
+
+ const tabNode = createNode({
+ attrs: { ...spaceAttrs, listRendering: { suffix: 'tab', justification: 'left', markerText: '2.' } },
+ });
+ nodeView.update(tabNode, []);
+
+ expect(nodeView.marker?.textContent).toBe('2.');
+ expect(nodeView.separator?.tagName?.toLowerCase()).toBe('span');
+ });
+
it('uses hanging indent width for right-justified tabs and skips tab helper', () => {
isList.mockReturnValue(true);
const attrs = {
From db9add05ad329626f51fe889141b1a23ca98cce1 Mon Sep 17 00:00:00 2001
From: "superdoc-bot[bot]"
<235763992+superdoc-bot[bot]@users.noreply.github.com>
Date: Wed, 22 Apr 2026 18:58:12 -0300
Subject: [PATCH 15/43] chore: merge stable into main (release conflicts
auto-resolved) (#2915)
Co-authored-by: github-actions[bot]
---
packages/esign/src/index.tsx | 1 +
1 file changed, 1 insertion(+)
diff --git a/packages/esign/src/index.tsx b/packages/esign/src/index.tsx
index ab3a6e9813..d1746887dc 100644
--- a/packages/esign/src/index.tsx
+++ b/packages/esign/src/index.tsx
@@ -527,4 +527,5 @@ const SuperDocESign = forwardRef
Date: Wed, 22 Apr 2026 18:58:22 -0300
Subject: [PATCH 16/43] docs: add kendaller to community contributors (#2916)
Co-authored-by: github-actions[bot]
---
README.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/README.md b/README.md
index 8a23252a87..9b34c8b2d9 100644
--- a/README.md
+++ b/README.md
@@ -169,6 +169,7 @@ Special thanks to these community members who have contributed code to SuperDoc:
+
Want to see your avatar here? Check the [Contributing Guide](CONTRIBUTING.md) to get started.
From 84b5fbcc8364a151c6029c440f8205b493cbb84f Mon Sep 17 00:00:00 2001
From: Artem Nistuley <101666502+artem-harbour@users.noreply.github.com>
Date: Thu, 23 Apr 2026 01:26:46 +0300
Subject: [PATCH 17/43] fix: rebuild fragment on geometry change (#2842)
---
.../layout-engine/painters/dom/src/index.test.ts | 12 +++++++++---
packages/layout-engine/painters/dom/src/renderer.ts | 12 ++++++++++++
2 files changed, 21 insertions(+), 3 deletions(-)
diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts
index 3a1e8f57bf..35f24eb71d 100644
--- a/packages/layout-engine/painters/dom/src/index.test.ts
+++ b/packages/layout-engine/painters/dom/src/index.test.ts
@@ -3029,7 +3029,7 @@ describe('DomPainter', () => {
expect(appliedWordSpacing).toBeCloseTo(expectedWordSpacing, 5);
});
- it('reuses fragment DOM nodes when layout geometry changes', () => {
+ it('rebuilds fragment DOM nodes when layout geometry changes to keep line epochs in sync', () => {
const painter = createTestPainter({ blocks: [block], measures: [measure] });
painter.paint(layout, mount);
@@ -3051,9 +3051,12 @@ describe('DomPainter', () => {
painter.paint(movedLayout, mount);
const fragmentAfter = mount.querySelector('.superdoc-fragment') as HTMLElement;
+ const lineAfter = fragmentAfter.querySelector('.superdoc-line') as HTMLElement;
- expect(fragmentAfter).toBe(fragmentBefore);
+ expect(fragmentAfter).not.toBe(fragmentBefore);
expect(fragmentAfter.style.left).toBe('60px');
+ expect(fragmentAfter.dataset.layoutEpoch).toBeTruthy();
+ expect(lineAfter.dataset.layoutEpoch).toBe(fragmentAfter.dataset.layoutEpoch);
});
it('rebuilds fragment DOM when block content changes via setData', () => {
@@ -5016,10 +5019,13 @@ describe('DomPainter', () => {
painter.paint(updatedLayout, mount);
const updatedWrapper = mount.querySelector('.superdoc-fragment-list-item') as HTMLElement;
- expect(updatedWrapper).toBe(initialWrapper);
+ const updatedLine = updatedWrapper.querySelector('.superdoc-line') as HTMLElement;
+ expect(updatedWrapper).not.toBe(initialWrapper);
expect(updatedWrapper.style.left).toBe('90px');
expect(updatedWrapper.style.top).toBe('55px');
expect(updatedWrapper.style.width).toBe('310px');
+ expect(updatedWrapper.dataset.layoutEpoch).toBeTruthy();
+ expect(updatedLine.dataset.layoutEpoch).toBe(updatedWrapper.dataset.layoutEpoch);
});
it('applies resolved zIndex only to anchored media fragments', () => {
diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts
index e6e030be30..6c9eb68ee4 100644
--- a/packages/layout-engine/painters/dom/src/renderer.ts
+++ b/packages/layout-engine/painters/dom/src/renderer.ts
@@ -2748,6 +2748,7 @@ export class DomPainter {
if (current) {
existing.delete(key);
+ const geometryChanged = hasFragmentGeometryChanged(current.fragment, fragment);
const sdtBoundaryMismatch = shouldRebuildForSdtBoundary(current.element, sdtBoundary);
// Detect mismatch in any between-border property
const betweenBorderMismatch =
@@ -2764,6 +2765,7 @@ export class DomPainter {
current.element.dataset.pmStart != null &&
this.currentMapping.map(Number(current.element.dataset.pmStart)) !== newPmStart;
const needsRebuild =
+ geometryChanged ||
this.changedBlocks.has(fragment.blockId) ||
current.signature !== fragmentSignature(fragment, this.blockLookup) ||
sdtBoundaryMismatch ||
@@ -7330,6 +7332,16 @@ const fragmentSignature = (fragment: Fragment, lookup: BlockLookup): string => {
return base;
};
+const hasFragmentGeometryChanged = (previous: Fragment, next: Fragment): boolean =>
+ previous.x !== next.x ||
+ previous.y !== next.y ||
+ previous.width !== next.width ||
+ ('height' in previous &&
+ 'height' in next &&
+ typeof previous.height === 'number' &&
+ typeof next.height === 'number' &&
+ previous.height !== next.height);
+
const getSdtMetadataId = (metadata: SdtMetadata | null | undefined): string => {
if (!metadata) return '';
if ('id' in metadata && metadata.id != null) {
From 58c3fca8ae997cc3a6f0d8fd13e48669675ef879 Mon Sep 17 00:00:00 2001
From: Nick Bernal <117235294+harbournick@users.noreply.github.com>
Date: Wed, 22 Apr 2026 16:17:26 -0700
Subject: [PATCH 18/43] ci: automate stable promotion pr preparation (#2917)
---
.github/workflows/ci-behavior.yml | 2 +-
.github/workflows/ci-superdoc.yml | 2 +-
.github/workflows/promote-stable.yml | 184 +++++++++++++++++++++++++++
.github/workflows/visual-test.yml | 2 +-
cicd.md | 20 +--
5 files changed, 199 insertions(+), 11 deletions(-)
create mode 100644 .github/workflows/promote-stable.yml
diff --git a/.github/workflows/ci-behavior.yml b/.github/workflows/ci-behavior.yml
index bd5f37792d..a631113816 100644
--- a/.github/workflows/ci-behavior.yml
+++ b/.github/workflows/ci-behavior.yml
@@ -5,7 +5,7 @@ permissions:
on:
pull_request:
- branches: [main]
+ branches: [main, stable]
paths:
- 'packages/superdoc/**'
- 'packages/layout-engine/**'
diff --git a/.github/workflows/ci-superdoc.yml b/.github/workflows/ci-superdoc.yml
index 62a0ab065b..208916f66e 100644
--- a/.github/workflows/ci-superdoc.yml
+++ b/.github/workflows/ci-superdoc.yml
@@ -5,7 +5,7 @@ permissions:
on:
pull_request:
- branches: [main, 'release/**']
+ branches: [main, stable, 'release/**']
paths-ignore:
- 'apps/docs/**'
- 'apps/mcp/**'
diff --git a/.github/workflows/promote-stable.yml b/.github/workflows/promote-stable.yml
new file mode 100644
index 0000000000..7a54dbc296
--- /dev/null
+++ b/.github/workflows/promote-stable.yml
@@ -0,0 +1,184 @@
+name: 🚀 Promote to stable
+
+on:
+ workflow_dispatch:
+ inputs:
+ branch_name:
+ description: Optional candidate branch name (defaults to merge/main-into-stable-YYYY-MM-DD)
+ required: false
+ type: string
+ schedule:
+ - cron: '0 7 * * *'
+
+permissions:
+ contents: write
+ pull-requests: write
+
+concurrency:
+ group: promote-stable
+ cancel-in-progress: false
+
+jobs:
+ prepare-stable-pr:
+ runs-on: ubuntu-latest
+ env:
+ BASE_BRANCH: stable
+ SOURCE_BRANCH: main
+ steps:
+ - name: Generate token
+ id: generate_token
+ uses: actions/create-github-app-token@v2
+ with:
+ app-id: ${{ secrets.APP_ID }}
+ private-key: ${{ secrets.APP_PRIVATE_KEY }}
+
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ token: ${{ steps.generate_token.outputs.token }}
+
+ - name: Prepare candidate branch
+ id: prepare
+ env:
+ REQUESTED_BRANCH_NAME: ${{ inputs.branch_name }}
+ run: |
+ set -euo pipefail
+
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+
+ git fetch origin "${BASE_BRANCH}" "${SOURCE_BRANCH}" --prune
+
+ DEFAULT_BRANCH_NAME="merge/main-into-stable-$(date -u +%Y-%m-%d)"
+ BRANCH_NAME="${REQUESTED_BRANCH_NAME}"
+ if [[ -z "${BRANCH_NAME}" ]]; then
+ BRANCH_NAME="${DEFAULT_BRANCH_NAME}"
+ fi
+
+ if git ls-remote --exit-code --heads origin "${BRANCH_NAME}" >/dev/null 2>&1; then
+ echo "Remote branch already exists: ${BRANCH_NAME}"
+ echo "Preserving the existing frozen candidate branch."
+ echo "branch_name=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}"
+ echo "merge_status=existing" >> "${GITHUB_OUTPUT}"
+ exit 0
+ fi
+
+ git checkout -B "${BRANCH_NAME}" "origin/${BASE_BRANCH}"
+
+ if git merge --no-ff --no-edit "origin/${SOURCE_BRANCH}"; then
+ MERGE_STATUS="clean"
+ else
+ MERGE_STATUS="conflicts"
+ git add -A
+ git commit -m "chore: merge main into stable (conflicts need resolution)"
+ fi
+
+ if git diff --quiet "origin/${BASE_BRANCH}"...HEAD; then
+ echo "No changes to promote from ${SOURCE_BRANCH} into ${BASE_BRANCH}."
+ echo "branch_name=" >> "${GITHUB_OUTPUT}"
+ echo "merge_status=noop" >> "${GITHUB_OUTPUT}"
+ exit 0
+ fi
+
+ git push origin "${BRANCH_NAME}"
+
+ echo "branch_name=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}"
+ echo "merge_status=${MERGE_STATUS}" >> "${GITHUB_OUTPUT}"
+
+ - name: Open pull request
+ if: steps.prepare.outputs.branch_name != ''
+ id: pr
+ env:
+ GH_TOKEN: ${{ steps.generate_token.outputs.token }}
+ BRANCH_NAME: ${{ steps.prepare.outputs.branch_name }}
+ MERGE_STATUS: ${{ steps.prepare.outputs.merge_status }}
+ run: |
+ set -euo pipefail
+
+ if [[ "${MERGE_STATUS}" == "conflicts" ]]; then
+ PR_TITLE="Merge main into stable (conflicts need resolution)"
+ PR_BODY="$(cat </dev/null || true)"
+
+ if [[ -n "${EXISTING_PR_URL}" ]]; then
+ gh pr edit "${EXISTING_PR_URL}" \
+ --repo "${GITHUB_REPOSITORY}" \
+ --title "${PR_TITLE}" \
+ --body "${PR_BODY}"
+ echo "url=${EXISTING_PR_URL}" >> "${GITHUB_OUTPUT}"
+ exit 0
+ fi
+
+ PR_URL="$(gh pr create \
+ --repo "${GITHUB_REPOSITORY}" \
+ --base "${BASE_BRANCH}" \
+ --head "${BRANCH_NAME}" \
+ --title "${PR_TITLE}" \
+ --body "${PR_BODY}")"
+
+ echo "url=${PR_URL}" >> "${GITHUB_OUTPUT}"
+
+ - name: Write workflow summary
+ run: |
+ {
+ echo "### Promote to stable"
+ echo
+ echo "| Field | Value |"
+ echo "| --- | --- |"
+ echo "| Event | \`${{ github.event_name }}\` |"
+ echo "| Source branch | \`${SOURCE_BRANCH}\` |"
+ echo "| Base branch | \`${BASE_BRANCH}\` |"
+ echo "| Candidate branch | \`${{ steps.prepare.outputs.branch_name || 'n/a' }}\` |"
+ echo "| Merge status | \`${{ steps.prepare.outputs.merge_status || 'n/a' }}\` |"
+ echo "| PR | ${{ steps.pr.outputs.url || 'n/a' }} |"
+ } >> "${GITHUB_STEP_SUMMARY}"
diff --git a/.github/workflows/visual-test.yml b/.github/workflows/visual-test.yml
index bf9e54ef8f..09c5378af6 100644
--- a/.github/workflows/visual-test.yml
+++ b/.github/workflows/visual-test.yml
@@ -6,7 +6,7 @@ permissions:
on:
pull_request:
- branches: [main]
+ branches: [main, stable]
paths:
- 'packages/superdoc/**'
- 'packages/layout-engine/**'
diff --git a/cicd.md b/cicd.md
index 0171f724f9..1e13844416 100644
--- a/cicd.md
+++ b/cicd.md
@@ -69,15 +69,17 @@ main (next) → stable (latest) → X.x (maintenance)
#### 3. Promote to Stable (`promote-stable.yml`)
-**Trigger**: Manual workflow dispatch
+**Trigger**: Manual workflow dispatch or daily schedule at `07:00 UTC`
-**Input**: Optional tag to promote (defaults to latest from main)
+**Input**: Optional candidate branch name (defaults to `merge/main-into-stable-YYYY-MM-DD`)
**Actions**:
-- Merges specified version to stable branch
-- Triggers automatic stable release
-- Updates npm @latest tag
+- Creates a fresh candidate branch from `stable`
+- Merges `main` into that branch
+- Opens a PR targeting `stable`
+- If the merge conflicts, commits the conflicted merge to the branch so a human can resolve it there
+- Merging that PR triggers the automatic stable release workflow
#### 4. Create Patch Branch (`create-patch.yml`)
@@ -208,9 +210,11 @@ These skip semantic-release entirely — useful for re-publishing a failed platf
### Scenario 2: Creating Stable Release
1. Run "Promote to Stable" workflow
-2. Merges main to stable
-3. Automatically publishes `1.1.0` as @latest
-4. Syncs back to main with version bump
+2. Review the generated PR from the candidate branch into `stable`
+3. If needed, resolve merge conflicts on the candidate branch
+4. Merge the PR into `stable`
+5. Automatically publishes `1.1.0` as @latest
+6. Syncs back to main with version bump
### Scenario 3: Hotfix to Current Stable
From c3b2cef6c64b7dec4ee20fa658eb8415166d9b9d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tadeu=20Tupinamb=C3=A1?=
Date: Wed, 22 Apr 2026 20:35:33 -0300
Subject: [PATCH 19/43] refactor(layout): lift page metadata into ResolvedPage
(#2810)
---
.../contracts/src/resolved-layout.ts | 33 +++-
.../layout-resolved/src/resolveLayout.test.ts | 182 ++++++++++++++++++
.../layout-resolved/src/resolveLayout.ts | 16 +-
.../painters/dom/src/renderer.ts | 86 ++++++---
4 files changed, 286 insertions(+), 31 deletions(-)
diff --git a/packages/layout-engine/contracts/src/resolved-layout.ts b/packages/layout-engine/contracts/src/resolved-layout.ts
index 9170e1e202..d0eae8f951 100644
--- a/packages/layout-engine/contracts/src/resolved-layout.ts
+++ b/packages/layout-engine/contracts/src/resolved-layout.ts
@@ -1,4 +1,14 @@
-import type { DrawingBlock, FlowMode, Fragment, ImageBlock, Line, TableBlock, TableMeasure } from './index.js';
+import type {
+ DrawingBlock,
+ FlowMode,
+ Fragment,
+ ImageBlock,
+ Line,
+ PageMargins,
+ SectionVerticalAlign,
+ TableBlock,
+ TableMeasure,
+} from './index.js';
/** A fully resolved layout ready for the next-generation paint pipeline. */
export type ResolvedLayout = {
@@ -10,6 +20,8 @@ export type ResolvedLayout = {
pageGap: number;
/** Resolved pages with normalized dimensions. */
pages: ResolvedPage[];
+ /** Document epoch identifier from the source layout. Used for change tracking in the painter. */
+ layoutEpoch?: number;
};
/** A single resolved page with stable identity and normalized dimensions. */
@@ -26,6 +38,25 @@ export type ResolvedPage = {
height: number;
/** Resolved paint items for this page. */
items: ResolvedPaintItem[];
+ /** Page margins from the source page. Used for ruler rendering and header/footer positioning. */
+ margins?: PageMargins;
+ /** Extra bottom space reserved for footnotes (px). Used for footer space calculation. */
+ footnoteReserved?: number;
+ /** Formatted page number text (e.g. "i", "ii" for Roman numeral sections). */
+ numberText?: string;
+ /** Vertical alignment of content within this page. */
+ vAlign?: SectionVerticalAlign;
+ /** Base section margins before header/footer inflation. Used for vAlign centering calculations. */
+ baseMargins?: { top: number; bottom: number };
+ /** 0-based index of the section this page belongs to. */
+ sectionIndex?: number;
+ /** Header/footer reference IDs for this page's section. */
+ sectionRefs?: {
+ headerRefs?: { default?: string; first?: string; even?: string; odd?: string };
+ footerRefs?: { default?: string; first?: string; even?: string; odd?: string };
+ };
+ /** Page orientation. */
+ orientation?: 'portrait' | 'landscape';
};
/** Union of all resolved paint item kinds. */
diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts
index a9df355da8..04a9fc805b 100644
--- a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts
+++ b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts
@@ -1487,4 +1487,186 @@ describe('resolveLayout', () => {
expect(content.lines[0].availableWidth).toBe(360);
});
});
+
+ describe('page metadata fields', () => {
+ it('carries margins through to resolved page', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [],
+ margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36, gutter: 0 },
+ },
+ ],
+ };
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ expect(result.pages[0].margins).toEqual({
+ top: 72,
+ right: 72,
+ bottom: 72,
+ left: 72,
+ header: 36,
+ footer: 36,
+ gutter: 0,
+ });
+ });
+
+ it('leaves margins undefined when page has no margins', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [] }],
+ };
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ expect(result.pages[0].margins).toBeUndefined();
+ });
+
+ it('carries footnoteReserved through to resolved page', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [], footnoteReserved: 48 }],
+ };
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ expect(result.pages[0].footnoteReserved).toBe(48);
+ });
+
+ it('carries numberText through to resolved page', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [], numberText: 'i' }],
+ };
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ expect(result.pages[0].numberText).toBe('i');
+ });
+
+ it('carries vAlign and baseMargins through to resolved page', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [],
+ vAlign: 'center',
+ baseMargins: { top: 72, bottom: 72 },
+ },
+ ],
+ };
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ expect(result.pages[0].vAlign).toBe('center');
+ expect(result.pages[0].baseMargins).toEqual({ top: 72, bottom: 72 });
+ });
+
+ it('carries sectionIndex through to resolved page', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [], sectionIndex: 2 }],
+ };
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ expect(result.pages[0].sectionIndex).toBe(2);
+ });
+
+ it('carries sectionRefs through to resolved page', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [],
+ sectionRefs: {
+ headerRefs: { default: 'hdr1', first: 'hdr-first' },
+ footerRefs: { default: 'ftr1' },
+ },
+ },
+ ],
+ };
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ expect(result.pages[0].sectionRefs).toEqual({
+ headerRefs: { default: 'hdr1', first: 'hdr-first' },
+ footerRefs: { default: 'ftr1' },
+ });
+ });
+
+ it('carries orientation through to resolved page', () => {
+ const layout: Layout = {
+ pageSize: { w: 792, h: 612 },
+ pages: [{ number: 1, fragments: [], orientation: 'landscape' }],
+ };
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ expect(result.pages[0].orientation).toBe('landscape');
+ });
+
+ it('leaves optional metadata undefined when not set on source page', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [] }],
+ };
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ const page = result.pages[0];
+ expect(page.margins).toBeUndefined();
+ expect(page.footnoteReserved).toBeUndefined();
+ expect(page.numberText).toBeUndefined();
+ expect(page.vAlign).toBeUndefined();
+ expect(page.baseMargins).toBeUndefined();
+ expect(page.sectionIndex).toBeUndefined();
+ expect(page.sectionRefs).toBeUndefined();
+ expect(page.orientation).toBeUndefined();
+ });
+
+ it('carries all metadata fields together on a fully-populated page', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 3,
+ fragments: [],
+ margins: { top: 72, right: 72, bottom: 72, left: 72 },
+ footnoteReserved: 24,
+ numberText: 'iii',
+ vAlign: 'bottom',
+ baseMargins: { top: 96, bottom: 96 },
+ sectionIndex: 1,
+ sectionRefs: {
+ headerRefs: { default: 'h1' },
+ footerRefs: { default: 'f1', even: 'f-even' },
+ },
+ orientation: 'portrait',
+ },
+ ],
+ };
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ const page = result.pages[0];
+ expect(page.margins).toEqual({ top: 72, right: 72, bottom: 72, left: 72 });
+ expect(page.footnoteReserved).toBe(24);
+ expect(page.numberText).toBe('iii');
+ expect(page.vAlign).toBe('bottom');
+ expect(page.baseMargins).toEqual({ top: 96, bottom: 96 });
+ expect(page.sectionIndex).toBe(1);
+ expect(page.sectionRefs).toEqual({
+ headerRefs: { default: 'h1' },
+ footerRefs: { default: 'f1', even: 'f-even' },
+ });
+ expect(page.orientation).toBe('portrait');
+ });
+ });
+
+ describe('layoutEpoch', () => {
+ it('carries layoutEpoch from source layout to resolved layout', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [],
+ layoutEpoch: 42,
+ };
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ expect(result.layoutEpoch).toBe(42);
+ });
+
+ it('defaults layoutEpoch to undefined when not set', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [],
+ };
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ expect(result.layoutEpoch).toBeUndefined();
+ });
+ });
});
diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts
index fd16d0b15d..1c7e513981 100644
--- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts
+++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts
@@ -168,12 +168,26 @@ export function resolveLayout(input: ResolveLayoutInput): ResolvedLayout {
items: page.fragments.map((fragment, fragmentIndex) =>
resolveFragmentItem(fragment, fragmentIndex, pageIndex, blockMap),
),
+ margins: page.margins,
+ footnoteReserved: page.footnoteReserved,
+ numberText: page.numberText,
+ vAlign: page.vAlign,
+ baseMargins: page.baseMargins,
+ sectionIndex: page.sectionIndex,
+ sectionRefs: page.sectionRefs,
+ orientation: page.orientation,
}));
- return {
+ const resolved: ResolvedLayout = {
version: 1,
flowMode,
pageGap: layout.pageGap ?? 0,
pages,
};
+
+ if (layout.layoutEpoch != null) {
+ resolved.layoutEpoch = layout.layoutEpoch;
+ }
+
+ return resolved;
}
diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts
index 6c9eb68ee4..c4b3976298 100644
--- a/packages/layout-engine/painters/dom/src/renderer.ts
+++ b/packages/layout-engine/painters/dom/src/renderer.ts
@@ -1689,7 +1689,7 @@ export class DomPainter {
}
this.layoutVersion += 1;
- this.layoutEpoch = layout.layoutEpoch ?? 0;
+ this.layoutEpoch = this.resolvedLayout?.layoutEpoch ?? layout.layoutEpoch ?? 0;
this.mount = mount;
this.beginPaintSnapshot(layout);
@@ -2200,6 +2200,7 @@ export class DomPainter {
if (!this.doc) {
throw new Error('DomPainter: document is not available');
}
+ const resolvedPage = this.getResolvedPage(pageIndex);
const el = this.doc.createElement('div');
el.classList.add(CLASS_NAMES.page);
applyStyles(el, pageStyles(width, height, this.getEffectivePageStyles()));
@@ -2210,7 +2211,7 @@ export class DomPainter {
// Render per-page ruler if enabled (suppressed in semantic flow mode)
if (!this.isSemanticFlow && this.options.ruler?.enabled) {
- const rulerEl = this.renderPageRuler(width, page);
+ const rulerEl = this.renderPageRuler(width, page, resolvedPage);
if (rulerEl) {
el.appendChild(rulerEl);
}
@@ -2220,7 +2221,7 @@ export class DomPainter {
pageNumber: page.number,
totalPages: this.totalPages,
section: 'body',
- pageNumberText: page.numberText,
+ pageNumberText: resolvedPage?.numberText ?? page.numberText,
pageIndex,
};
@@ -2234,9 +2235,8 @@ export class DomPainter {
this.renderFragment(fragment, contextBase, sdtBoundary, betweenBorderFlags.get(index), resolvedItem),
);
});
- this.renderDecorationsForPage(el, page, pageIndex);
- this.renderColumnSeparators(el, page, width, height);
-
+ this.renderDecorationsForPage(el, page, pageIndex, resolvedPage);
+ this.renderColumnSeparators(el, page, width, height, resolvedPage);
return el;
}
@@ -2258,18 +2258,18 @@ export class DomPainter {
* - Uses DEFAULT_PAGE_HEIGHT_PX (1056px = 11 inches) if page.size.h is not available
* - Defaults margins to 0 if not explicitly provided
*/
- private renderPageRuler(pageWidthPx: number, page: Page): HTMLElement | null {
+ private renderPageRuler(pageWidthPx: number, page: Page, resolvedPage?: ResolvedPage | null): HTMLElement | null {
if (!this.doc) {
console.warn('[renderPageRuler] Cannot render ruler: document is not available.');
return null;
}
- if (!page.margins) {
+ const margins = resolvedPage?.margins ?? page.margins;
+ if (!margins) {
console.warn(`[renderPageRuler] Cannot render ruler for page ${page.number}: margins not available.`);
return null;
}
- const margins = page.margins;
const leftMargin = margins.left ?? 0;
const rightMargin = margins.right ?? 0;
@@ -2317,14 +2317,23 @@ export class DomPainter {
}
}
- private renderColumnSeparators(pageEl: HTMLElement, page: Page, pageWidth: number, pageHeight: number): void {
+ private renderColumnSeparators(
+ pageEl: HTMLElement,
+ page: Page,
+ pageWidth: number,
+ pageHeight: number,
+ resolvedPage?: ResolvedPage | null,
+ ): void {
if (!this.doc) return;
- if (!page.margins) return;
+ pageEl.querySelectorAll('[data-superdoc-column-separator="true"]').forEach((separator) => separator.remove());
- const leftMargin = page.margins.left ?? 0;
- const rightMargin = page.margins.right ?? 0;
- const topMargin = page.margins.top ?? 0;
- const bottomMargin = page.margins.bottom ?? 0;
+ const pageMargins = resolvedPage?.margins ?? page.margins;
+ if (!pageMargins) return;
+
+ const leftMargin = pageMargins.left ?? 0;
+ const rightMargin = pageMargins.right ?? 0;
+ const topMargin = pageMargins.top ?? 0;
+ const bottomMargin = pageMargins.bottom ?? 0;
const contentWidth = pageWidth - leftMargin - rightMargin;
// Prefer columnRegions (per-region configs for pages with continuous
@@ -2356,6 +2365,7 @@ export class DomPainter {
for (const separatorX of separatorPositions) {
const separatorEl = this.doc.createElement('div');
+ separatorEl.dataset.superdocColumnSeparator = 'true';
separatorEl.style.position = 'absolute';
separatorEl.style.left = `${separatorX}px`;
@@ -2403,10 +2413,15 @@ export class DomPainter {
return separatorPositions;
}
- private renderDecorationsForPage(pageEl: HTMLElement, page: Page, pageIndex: number): void {
+ private renderDecorationsForPage(
+ pageEl: HTMLElement,
+ page: Page,
+ pageIndex: number,
+ resolvedPage?: ResolvedPage | null,
+ ): void {
if (this.isSemanticFlow) return;
- this.renderDecorationSection(pageEl, page, pageIndex, 'header');
- this.renderDecorationSection(pageEl, page, pageIndex, 'footer');
+ this.renderDecorationSection(pageEl, page, pageIndex, 'header', resolvedPage);
+ this.renderDecorationSection(pageEl, page, pageIndex, 'footer', resolvedPage);
}
/**
@@ -2444,17 +2459,19 @@ export class DomPainter {
page: Page,
kind: 'header' | 'footer',
effectiveOffset: number,
+ resolvedPage?: ResolvedPage | null,
): number {
if (kind === 'header') {
return effectiveOffset;
}
- const bottomMargin = page.margins?.bottom;
+ const pageMargins = resolvedPage?.margins ?? page.margins;
+ const bottomMargin = pageMargins?.bottom;
if (bottomMargin == null) {
return effectiveOffset;
}
- const footnoteReserve = page.footnoteReserved ?? 0;
+ const footnoteReserve = resolvedPage?.footnoteReserved ?? page.footnoteReserved ?? 0;
const adjustedBottomMargin = Math.max(0, bottomMargin - footnoteReserve);
const styledPageHeight = Number.parseFloat(pageEl.style.height || '');
const pageHeight =
@@ -2465,11 +2482,18 @@ export class DomPainter {
return Math.max(0, pageHeight - adjustedBottomMargin);
}
- private renderDecorationSection(pageEl: HTMLElement, page: Page, pageIndex: number, kind: 'header' | 'footer'): void {
+ private renderDecorationSection(
+ pageEl: HTMLElement,
+ page: Page,
+ pageIndex: number,
+ kind: 'header' | 'footer',
+ resolvedPage?: ResolvedPage | null,
+ ): void {
if (!this.doc) return;
const provider = kind === 'header' ? this.headerProvider : this.footerProvider;
const className = kind === 'header' ? CLASS_NAMES.pageHeader : CLASS_NAMES.pageFooter;
const existing = pageEl.querySelector(`.${className}`);
+ // Provider still receives legacy page — its signature is not changed in this PR
const data = provider ? provider(page.number, page.margins, page) : null;
if (!data || data.fragments.length === 0) {
@@ -2482,7 +2506,8 @@ export class DomPainter {
container.innerHTML = '';
const baseOffset = data.offset ?? (kind === 'footer' ? pageEl.clientHeight - data.height : 0);
const marginLeft = data.marginLeft ?? 0;
- const marginRight = page.margins?.right ?? 0;
+ const pageMargins = resolvedPage?.margins ?? page.margins;
+ const marginRight = pageMargins?.right ?? 0;
// For footers, if content is taller than reserved space, expand container upward
// The container bottom stays anchored at footerMargin from page bottom
@@ -2522,7 +2547,7 @@ export class DomPainter {
// Header page-relative anchors use raw inner-layout Y and are handled with
// the simpler effectiveOffset subtraction (unchanged from the baseline).
const footerAnchorPageOriginY =
- kind === 'footer' ? this.getDecorationAnchorPageOriginY(pageEl, page, kind, effectiveOffset) : 0;
+ kind === 'footer' ? this.getDecorationAnchorPageOriginY(pageEl, page, kind, effectiveOffset, resolvedPage) : 0;
const footerAnchorContainerOffsetY = kind === 'footer' ? footerAnchorPageOriginY - effectiveOffset : 0;
// For footers, calculate offset to push content to bottom of container
@@ -2547,7 +2572,7 @@ export class DomPainter {
pageNumber: page.number,
totalPages: this.totalPages,
section: kind,
- pageNumberText: page.numberText,
+ pageNumberText: resolvedPage?.numberText ?? page.numberText,
pageIndex,
};
@@ -2719,6 +2744,7 @@ export class DomPainter {
}
private patchPage(state: PageDomState, page: Page, pageSize: { w: number; h: number }, pageIndex: number): void {
+ const resolvedPage = this.getResolvedPage(pageIndex);
const pageEl = state.element;
applyStyles(pageEl, pageStyles(pageSize.w, pageSize.h, this.getEffectivePageStyles()));
this.applySemanticPageOverrides(pageEl);
@@ -2735,7 +2761,7 @@ export class DomPainter {
pageNumber: page.number,
totalPages: this.totalPages,
section: 'body',
- pageNumberText: page.numberText,
+ pageNumberText: resolvedPage?.numberText ?? page.numberText,
pageIndex,
};
@@ -2815,7 +2841,8 @@ export class DomPainter {
});
state.fragments = nextFragments;
- this.renderDecorationsForPage(pageEl, page, pageIndex);
+ this.renderDecorationsForPage(pageEl, page, pageIndex, resolvedPage);
+ this.renderColumnSeparators(pageEl, page, pageSize.w, pageSize.h, resolvedPage);
}
/**
@@ -2875,6 +2902,7 @@ export class DomPainter {
if (!this.doc) {
throw new Error('DomPainter.createPageState requires a document');
}
+ const resolvedPage = this.getResolvedPage(pageIndex);
const el = this.doc.createElement('div');
el.classList.add(CLASS_NAMES.page);
applyStyles(el, pageStyles(pageSize.w, pageSize.h, this.getEffectivePageStyles()));
@@ -2885,6 +2913,7 @@ export class DomPainter {
pageNumber: page.number,
totalPages: this.totalPages,
section: 'body',
+ pageNumberText: resolvedPage?.numberText ?? page.numberText,
pageIndex,
};
@@ -2910,9 +2939,8 @@ export class DomPainter {
};
});
- this.renderDecorationsForPage(el, page, pageIndex);
- this.renderColumnSeparators(el, page, pageSize.w, pageSize.h);
-
+ this.renderDecorationsForPage(el, page, pageIndex, resolvedPage);
+ this.renderColumnSeparators(el, page, pageSize.w, pageSize.h, resolvedPage);
return { element: el, fragments: fragmentStates };
}
From 032d4c10cf07341ba92e9d2ca756d81dca9e6260 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tadeu=20Tupinamb=C3=A1?=
Date: Wed, 22 Apr 2026 21:41:02 -0300
Subject: [PATCH 20/43] [2/16] refactor(layout): lift fragment metadata into
resolved paint items (#2811)
* refactor(layout): lift page metadata into ResolvedPage
* refactor(layout): lift fragment metadata into resolved paint items
Add pmStart, pmEnd, continuesFromPrev, continuesOnNext, markerWidth,
and metadata fields to resolved paint item types. Populate them in
the resolvers and update the painter to prefer resolved item data
over legacy Fragment reads with fallbacks.
* fix(layout): use resolved continuation state for paragraph first-line width
---
.../contracts/src/resolved-layout.ts | 29 ++
.../layout-resolved/src/resolveDrawing.ts | 5 +-
.../layout-resolved/src/resolveImage.ts | 6 +-
.../layout-resolved/src/resolveLayout.test.ts | 363 ++++++++++++++++++
.../layout-resolved/src/resolveLayout.ts | 22 +-
.../layout-resolved/src/resolveTable.ts | 7 +-
.../painters/dom/src/index.test.ts | 70 ++++
.../painters/dom/src/renderer.ts | 92 +++--
8 files changed, 555 insertions(+), 39 deletions(-)
diff --git a/packages/layout-engine/contracts/src/resolved-layout.ts b/packages/layout-engine/contracts/src/resolved-layout.ts
index d0eae8f951..7d28407900 100644
--- a/packages/layout-engine/contracts/src/resolved-layout.ts
+++ b/packages/layout-engine/contracts/src/resolved-layout.ts
@@ -3,6 +3,7 @@ import type {
FlowMode,
Fragment,
ImageBlock,
+ ImageFragmentMetadata,
Line,
PageMargins,
SectionVerticalAlign,
@@ -105,6 +106,16 @@ export type ResolvedFragmentItem = {
blockId: string;
/** Index within page.fragments — bridge to legacy content rendering. */
fragmentIndex: number;
+ /** ProseMirror start position for click-to-position mapping. */
+ pmStart?: number;
+ /** ProseMirror end position for click-to-position mapping. */
+ pmEnd?: number;
+ /** Whether this fragment continues from a previous page. */
+ continuesFromPrev?: boolean;
+ /** Whether this fragment continues on the next page. */
+ continuesOnNext?: boolean;
+ /** List marker box width in pixels (para/list-item only). */
+ markerWidth?: number;
/** Pre-resolved paragraph content for non-table paragraph fragments. */
content?: ResolvedParagraphContent;
};
@@ -205,6 +216,14 @@ export type ResolvedTableItem = {
blockId: string;
/** Index within page.fragments — bridge to legacy rendering. */
fragmentIndex: number;
+ /** ProseMirror start position for click-to-position mapping. */
+ pmStart?: number;
+ /** ProseMirror end position for click-to-position mapping. */
+ pmEnd?: number;
+ /** Whether this table fragment continues from a previous page. */
+ continuesFromPrev?: boolean;
+ /** Whether this table fragment continues on the next page. */
+ continuesOnNext?: boolean;
/** Pre-extracted TableBlock (replaces blockLookup.get()). */
block: TableBlock;
/** Pre-extracted TableMeasure (replaces blockLookup.get()). */
@@ -241,8 +260,14 @@ export type ResolvedImageItem = {
blockId: string;
/** Index within page.fragments — bridge to legacy rendering. */
fragmentIndex: number;
+ /** ProseMirror start position for click-to-position mapping. */
+ pmStart?: number;
+ /** ProseMirror end position for click-to-position mapping. */
+ pmEnd?: number;
/** Pre-extracted ImageBlock (replaces blockLookup.get()). */
block: ImageBlock;
+ /** Image metadata for interactive resizing (original dimensions, aspect ratio). */
+ metadata?: ImageFragmentMetadata;
};
/**
@@ -271,6 +296,10 @@ export type ResolvedDrawingItem = {
blockId: string;
/** Index within page.fragments — bridge to legacy rendering. */
fragmentIndex: number;
+ /** ProseMirror start position for click-to-position mapping. */
+ pmStart?: number;
+ /** ProseMirror end position for click-to-position mapping. */
+ pmEnd?: number;
/** Pre-extracted DrawingBlock (replaces blockLookup.get()). */
block: DrawingBlock;
};
diff --git a/packages/layout-engine/layout-resolved/src/resolveDrawing.ts b/packages/layout-engine/layout-resolved/src/resolveDrawing.ts
index de0db6741d..9d3d39ff13 100644
--- a/packages/layout-engine/layout-resolved/src/resolveDrawing.ts
+++ b/packages/layout-engine/layout-resolved/src/resolveDrawing.ts
@@ -17,7 +17,7 @@ export function resolveDrawingItem(
): ResolvedDrawingItem {
const { block } = requireResolvedBlockAndMeasure(blockMap, fragment.blockId, 'drawing', 'drawing', 'drawing');
- return {
+ const item: ResolvedDrawingItem = {
kind: 'fragment',
fragmentKind: 'drawing',
id: resolveDrawingFragmentId(fragment),
@@ -31,4 +31,7 @@ export function resolveDrawingItem(
fragmentIndex,
block,
};
+ if (fragment.pmStart != null) item.pmStart = fragment.pmStart;
+ if (fragment.pmEnd != null) item.pmEnd = fragment.pmEnd;
+ return item;
}
diff --git a/packages/layout-engine/layout-resolved/src/resolveImage.ts b/packages/layout-engine/layout-resolved/src/resolveImage.ts
index d1747585f9..e09632c7aa 100644
--- a/packages/layout-engine/layout-resolved/src/resolveImage.ts
+++ b/packages/layout-engine/layout-resolved/src/resolveImage.ts
@@ -17,7 +17,7 @@ export function resolveImageItem(
): ResolvedImageItem {
const { block } = requireResolvedBlockAndMeasure(blockMap, fragment.blockId, 'image', 'image', 'image');
- return {
+ const item: ResolvedImageItem = {
kind: 'fragment',
fragmentKind: 'image',
id: resolveImageFragmentId(fragment),
@@ -31,4 +31,8 @@ export function resolveImageItem(
fragmentIndex,
block,
};
+ if (fragment.pmStart != null) item.pmStart = fragment.pmStart;
+ if (fragment.pmEnd != null) item.pmEnd = fragment.pmEnd;
+ if (fragment.metadata != null) item.metadata = fragment.metadata;
+ return item;
}
diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts
index 04a9fc805b..2e935e82a3 100644
--- a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts
+++ b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts
@@ -638,6 +638,369 @@ describe('resolveLayout', () => {
});
});
+ describe('fragment metadata lifting', () => {
+ it('lifts pmStart and pmEnd from a paragraph fragment', () => {
+ const paraFragment: ParaFragment = {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 0,
+ toLine: 1,
+ x: 72,
+ y: 100,
+ width: 468,
+ pmStart: 5,
+ pmEnd: 42,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [paraFragment] }],
+ };
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.pmStart).toBe(5);
+ expect(item.pmEnd).toBe(42);
+ });
+
+ it('omits pmStart and pmEnd when not present on paragraph fragment', () => {
+ const paraFragment: ParaFragment = {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 0,
+ toLine: 1,
+ x: 72,
+ y: 100,
+ width: 468,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [paraFragment] }],
+ };
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.pmStart).toBeUndefined();
+ expect(item.pmEnd).toBeUndefined();
+ });
+
+ it('lifts continuesFromPrev and continuesOnNext from a paragraph fragment', () => {
+ const paraFragment: ParaFragment = {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 1,
+ toLine: 3,
+ x: 72,
+ y: 72,
+ width: 468,
+ continuesFromPrev: true,
+ continuesOnNext: true,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [paraFragment] }],
+ };
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.continuesFromPrev).toBe(true);
+ expect(item.continuesOnNext).toBe(true);
+ });
+
+ it('omits continuesFromPrev and continuesOnNext when not set', () => {
+ const paraFragment: ParaFragment = {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 0,
+ toLine: 1,
+ x: 72,
+ y: 100,
+ width: 468,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [paraFragment] }],
+ };
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.continuesFromPrev).toBeUndefined();
+ expect(item.continuesOnNext).toBeUndefined();
+ });
+
+ it('lifts markerWidth from a paragraph fragment', () => {
+ const paraFragment: ParaFragment = {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 0,
+ toLine: 1,
+ x: 72,
+ y: 100,
+ width: 468,
+ markerWidth: 36,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [paraFragment] }],
+ };
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.markerWidth).toBe(36);
+ });
+
+ it('lifts continuesFromPrev, continuesOnNext, and markerWidth from a list-item fragment', () => {
+ const listItemFragment: ListItemFragment = {
+ kind: 'list-item',
+ blockId: 'list1',
+ itemId: 'item-a',
+ fromLine: 1,
+ toLine: 2,
+ x: 108,
+ y: 200,
+ width: 432,
+ markerWidth: 36,
+ continuesFromPrev: true,
+ continuesOnNext: false,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [listItemFragment] }],
+ };
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'list',
+ id: 'list1',
+ listType: 'bullet',
+ items: [
+ {
+ id: 'item-a',
+ marker: { text: '•', style: {} },
+ paragraph: { kind: 'paragraph', id: 'item-a-p', runs: [] },
+ },
+ ],
+ },
+ ];
+ const measures: Measure[] = [
+ {
+ kind: 'list',
+ items: [
+ {
+ itemId: 'item-a',
+ markerWidth: 36,
+ markerTextWidth: 10,
+ indentLeft: 36,
+ paragraph: {
+ kind: 'paragraph',
+ lines: [
+ { fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 200, ascent: 12, descent: 4, lineHeight: 20 },
+ { fromRun: 0, fromChar: 5, toRun: 0, toChar: 10, width: 180, ascent: 12, descent: 4, lineHeight: 20 },
+ ],
+ totalHeight: 40,
+ },
+ },
+ ],
+ totalHeight: 40,
+ },
+ ];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.continuesFromPrev).toBe(true);
+ expect(item.continuesOnNext).toBe(false);
+ expect(item.markerWidth).toBe(36);
+ });
+
+ it('lifts pmStart, pmEnd, continuesFromPrev, and continuesOnNext from a table fragment', () => {
+ const tableFragment: TableFragment = {
+ kind: 'table',
+ blockId: 'tbl1',
+ fromRow: 0,
+ toRow: 3,
+ x: 72,
+ y: 100,
+ width: 468,
+ height: 300,
+ pmStart: 10,
+ pmEnd: 200,
+ continuesFromPrev: true,
+ continuesOnNext: false,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [tableFragment] }],
+ };
+ const tableBlock = { kind: 'table' as const, id: 'tbl1', rows: [] };
+ const tableMeasure = { kind: 'table' as const, rows: [], columnWidths: [], totalWidth: 0, totalHeight: 0 };
+
+ const result = resolveLayout({
+ layout,
+ flowMode: 'paginated',
+ blocks: [tableBlock as any],
+ measures: [tableMeasure as any],
+ });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedTableItem;
+ expect(item.pmStart).toBe(10);
+ expect(item.pmEnd).toBe(200);
+ expect(item.continuesFromPrev).toBe(true);
+ expect(item.continuesOnNext).toBe(false);
+ });
+
+ it('omits pmStart and pmEnd from table fragment when not set', () => {
+ const tableFragment: TableFragment = {
+ kind: 'table',
+ blockId: 'tbl1',
+ fromRow: 0,
+ toRow: 1,
+ x: 72,
+ y: 100,
+ width: 468,
+ height: 30,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [tableFragment] }],
+ };
+ const tableBlock = { kind: 'table' as const, id: 'tbl1', rows: [] };
+ const tableMeasure = { kind: 'table' as const, rows: [], columnWidths: [], totalWidth: 0, totalHeight: 0 };
+
+ const result = resolveLayout({
+ layout,
+ flowMode: 'paginated',
+ blocks: [tableBlock as any],
+ measures: [tableMeasure as any],
+ });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedTableItem;
+ expect(item.pmStart).toBeUndefined();
+ expect(item.pmEnd).toBeUndefined();
+ });
+
+ it('lifts pmStart, pmEnd, and metadata from an image fragment', () => {
+ const imageFragment: ImageFragment = {
+ kind: 'image',
+ blockId: 'img1',
+ x: 100,
+ y: 200,
+ width: 300,
+ height: 250,
+ pmStart: 15,
+ pmEnd: 16,
+ metadata: {
+ originalWidth: 600,
+ originalHeight: 500,
+ maxWidth: 468,
+ maxHeight: 700,
+ aspectRatio: 1.2,
+ minWidth: 50,
+ minHeight: 42,
+ },
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [imageFragment] }],
+ };
+ const imageBlock = { kind: 'image' as const, id: 'img1', src: 'test.png', width: 300, height: 250 };
+ const blocks: FlowBlock[] = [imageBlock];
+ const measures: Measure[] = [{ kind: 'image', width: 300, height: 250 }];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedImageItem;
+ expect(item.pmStart).toBe(15);
+ expect(item.pmEnd).toBe(16);
+ expect(item.metadata).toEqual({
+ originalWidth: 600,
+ originalHeight: 500,
+ maxWidth: 468,
+ maxHeight: 700,
+ aspectRatio: 1.2,
+ minWidth: 50,
+ minHeight: 42,
+ });
+ });
+
+ it('omits metadata from image fragment when not set', () => {
+ const imageFragment: ImageFragment = {
+ kind: 'image',
+ blockId: 'img1',
+ x: 100,
+ y: 200,
+ width: 300,
+ height: 250,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [imageFragment] }],
+ };
+ const imageBlock = { kind: 'image' as const, id: 'img1', src: 'test.png', width: 300, height: 250 };
+ const blocks: FlowBlock[] = [imageBlock];
+ const measures: Measure[] = [{ kind: 'image', width: 300, height: 250 }];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedImageItem;
+ expect(item.metadata).toBeUndefined();
+ });
+
+ it('lifts pmStart and pmEnd from a drawing fragment', () => {
+ const drawingFragment: DrawingFragment = {
+ kind: 'drawing',
+ drawingKind: 'vectorShape',
+ blockId: 'dr1',
+ x: 50,
+ y: 60,
+ width: 200,
+ height: 150,
+ isAnchored: true,
+ zIndex: 3,
+ geometry: { width: 200, height: 150 },
+ scale: 1,
+ pmStart: 30,
+ pmEnd: 31,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [drawingFragment] }],
+ };
+ const drawingBlock = {
+ kind: 'drawing' as const,
+ id: 'dr1',
+ drawingKind: 'vectorShape' as const,
+ geometry: { width: 200, height: 150 },
+ };
+ const blocks: FlowBlock[] = [drawingBlock as any];
+ const measures: Measure[] = [{ kind: 'drawing', width: 200, height: 150 }];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedDrawingItem;
+ expect(item.pmStart).toBe(30);
+ expect(item.pmEnd).toBe(31);
+ });
+
+ it('omits pmStart and pmEnd from drawing fragment when not set', () => {
+ const drawingFragment: DrawingFragment = {
+ kind: 'drawing',
+ drawingKind: 'vectorShape',
+ blockId: 'dr1',
+ x: 50,
+ y: 60,
+ width: 200,
+ height: 150,
+ geometry: { width: 200, height: 150 },
+ scale: 1,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [drawingFragment] }],
+ };
+ const drawingBlock = {
+ kind: 'drawing' as const,
+ id: 'dr1',
+ drawingKind: 'vectorShape' as const,
+ geometry: { width: 200, height: 150 },
+ };
+ const blocks: FlowBlock[] = [drawingBlock as any];
+ const measures: Measure[] = [{ kind: 'drawing', width: 200, height: 150 }];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedDrawingItem;
+ expect(item.pmStart).toBeUndefined();
+ expect(item.pmEnd).toBeUndefined();
+ });
+ });
+
describe('paragraph content resolution', () => {
const makeLine = (
overrides: Partial = {},
diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts
index 1c7e513981..3f1d19d4de 100644
--- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts
+++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts
@@ -6,11 +6,14 @@ import type {
Fragment,
DrawingFragment,
ImageFragment,
+ ListItemFragment,
+ ParaFragment,
TableFragment,
Line,
ResolvedLayout,
ResolvedPage,
ResolvedPaintItem,
+ ResolvedFragmentItem,
ResolvedParagraphContent,
ListMeasure,
ParagraphBlock,
@@ -136,9 +139,9 @@ function resolveFragmentItem(
return resolveImageItem(fragment as ImageFragment, fragmentIndex, pageIndex, blockMap);
case 'drawing':
return resolveDrawingItem(fragment as DrawingFragment, fragmentIndex, pageIndex, blockMap);
- default:
+ default: {
// para, list-item — existing generic resolution
- return {
+ const item: ResolvedFragmentItem = {
kind: 'fragment',
id: resolveFragmentId(fragment),
pageIndex,
@@ -152,6 +155,21 @@ function resolveFragmentItem(
fragmentIndex,
content: resolveParagraphContentIfApplicable(fragment, blockMap),
};
+ if (fragment.kind === 'para') {
+ const para = fragment as ParaFragment;
+ if (para.pmStart != null) item.pmStart = para.pmStart;
+ if (para.pmEnd != null) item.pmEnd = para.pmEnd;
+ if (para.continuesFromPrev != null) item.continuesFromPrev = para.continuesFromPrev;
+ if (para.continuesOnNext != null) item.continuesOnNext = para.continuesOnNext;
+ if (para.markerWidth != null) item.markerWidth = para.markerWidth;
+ } else if (fragment.kind === 'list-item') {
+ const listItem = fragment as ListItemFragment;
+ if (listItem.continuesFromPrev != null) item.continuesFromPrev = listItem.continuesFromPrev;
+ if (listItem.continuesOnNext != null) item.continuesOnNext = listItem.continuesOnNext;
+ if (listItem.markerWidth != null) item.markerWidth = listItem.markerWidth;
+ }
+ return item;
+ }
}
}
diff --git a/packages/layout-engine/layout-resolved/src/resolveTable.ts b/packages/layout-engine/layout-resolved/src/resolveTable.ts
index f88a692109..588634987e 100644
--- a/packages/layout-engine/layout-resolved/src/resolveTable.ts
+++ b/packages/layout-engine/layout-resolved/src/resolveTable.ts
@@ -25,7 +25,7 @@ export function resolveTableItem(
): ResolvedTableItem {
const { block, measure } = requireResolvedBlockAndMeasure(blockMap, fragment.blockId, 'table', 'table', 'table');
- return {
+ const item: ResolvedTableItem = {
kind: 'fragment',
fragmentKind: 'table',
id: resolveTableFragmentId(fragment),
@@ -42,4 +42,9 @@ export function resolveTableItem(
cellSpacingPx: measure.cellSpacingPx ?? getCellSpacingPx(block.attrs?.cellSpacing),
effectiveColumnWidths: fragment.columnWidths ?? measure.columnWidths,
};
+ if (fragment.pmStart != null) item.pmStart = fragment.pmStart;
+ if (fragment.pmEnd != null) item.pmEnd = fragment.pmEnd;
+ if (fragment.continuesFromPrev != null) item.continuesFromPrev = fragment.continuesFromPrev;
+ if (fragment.continuesOnNext != null) item.continuesOnNext = fragment.continuesOnNext;
+ return item;
}
diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts
index 35f24eb71d..ecf05ecb8c 100644
--- a/packages/layout-engine/painters/dom/src/index.test.ts
+++ b/packages/layout-engine/painters/dom/src/index.test.ts
@@ -6365,6 +6365,76 @@ describe('DomPainter', () => {
expect(page2Line.style.textIndent).toBe('0px');
});
+ it('uses resolved continuesFromPrev for first-line width calculations', () => {
+ const continuedBlock: FlowBlock = {
+ kind: 'paragraph',
+ id: 'resolved-continued-block',
+ runs: [{ text: 'alpha beta gamma', fontFamily: 'Arial', fontSize: 16 }],
+ attrs: { alignment: 'justify', indent: { left: 20, hanging: 40 } },
+ };
+
+ const continuedMeasure: Measure = {
+ kind: 'paragraph',
+ lines: [
+ {
+ fromRun: 0,
+ fromChar: 0,
+ toRun: 0,
+ toChar: 16,
+ width: 120,
+ ascent: 12,
+ descent: 4,
+ lineHeight: 20,
+ },
+ ],
+ totalHeight: 20,
+ };
+
+ const continuedLayout: Layout = {
+ pageSize: layout.pageSize,
+ pages: [
+ {
+ number: 1,
+ fragments: [
+ {
+ kind: 'para',
+ blockId: 'resolved-continued-block',
+ fromLine: 0,
+ toLine: 1,
+ x: 0,
+ y: 0,
+ width: 200,
+ continuesOnNext: true,
+ },
+ ],
+ },
+ ],
+ };
+
+ const resolvedLayout = createSinglePageResolvedLayout({
+ kind: 'fragment',
+ id: 'resolved-continued-item',
+ pageIndex: 0,
+ x: 0,
+ y: 0,
+ width: 200,
+ height: 20,
+ fragmentKind: 'para',
+ blockId: 'resolved-continued-block',
+ fragmentIndex: 0,
+ continuesFromPrev: true,
+ continuesOnNext: true,
+ });
+
+ const painter = createTestPainter({ blocks: [continuedBlock], measures: [continuedMeasure] });
+ painter.setResolvedLayout(resolvedLayout);
+ painter.paint(continuedLayout, mount);
+
+ const lineEl = mount.querySelector('.superdoc-line') as HTMLElement;
+ expect(lineEl.style.textIndent).toBe('0px');
+ expect(lineEl.style.wordSpacing).toBe('30px');
+ });
+
it('removes fragment-level indent styles to prevent double-application', () => {
const doubleIndentBlock: FlowBlock = {
kind: 'paragraph',
diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts
index c4b3976298..356d29ef88 100644
--- a/packages/layout-engine/painters/dom/src/renderer.ts
+++ b/packages/layout-engine/painters/dom/src/renderer.ts
@@ -2412,7 +2412,6 @@ export class DomPainter {
return separatorPositions;
}
-
private renderDecorationsForPage(
pageEl: HTMLElement,
page: Page,
@@ -3039,13 +3038,18 @@ export class DomPainter {
const wordLayout = isMinimalWordLayout(block.attrs?.wordLayout) ? block.attrs.wordLayout : undefined;
const content = resolvedItem?.content;
+ // Prefer resolved item metadata over legacy fragment reads
+ const paraContinuesFromPrev = resolvedItem?.continuesFromPrev ?? fragment.continuesFromPrev;
+ const paraContinuesOnNext = resolvedItem?.continuesOnNext ?? fragment.continuesOnNext;
+ const paraMarkerWidth = resolvedItem?.markerWidth ?? fragment.markerWidth;
+
const fragmentEl = this.doc.createElement('div');
fragmentEl.classList.add(CLASS_NAMES.fragment);
// For TOC entries, override white-space to prevent wrapping
const isTocEntry = block.attrs?.isTocEntry;
// For fragments with markers, allow overflow to show markers positioned at negative left
- const hasMarker = !fragment.continuesFromPrev && fragment.markerWidth && wordLayout?.marker;
+ const hasMarker = !paraContinuesFromPrev && paraMarkerWidth && wordLayout?.marker;
// SDT containers need overflow visible for tooltips/labels positioned above
const hasSdtContainer =
block.attrs?.sdt?.type === 'documentSection' ||
@@ -3072,10 +3076,10 @@ export class DomPainter {
fragmentEl.classList.add('superdoc-toc-entry');
}
- if (fragment.continuesFromPrev) {
+ if (paraContinuesFromPrev) {
fragmentEl.dataset.continuesFromPrev = 'true';
}
- if (fragment.continuesOnNext) {
+ if (paraContinuesOnNext) {
fragmentEl.dataset.continuesOnNext = 'true';
}
@@ -3131,7 +3135,7 @@ export class DomPainter {
} else {
const dropCapDescriptor = block.attrs?.dropCapDescriptor;
const dropCapMeasure = measure.dropCap;
- if (dropCapDescriptor && dropCapMeasure && !fragment.continuesFromPrev) {
+ if (dropCapDescriptor && dropCapMeasure && !paraContinuesFromPrev) {
const dropCapEl = this.renderDropCap(dropCapDescriptor, dropCapMeasure);
fragmentEl.appendChild(dropCapEl);
}
@@ -3257,7 +3261,7 @@ export class DomPainter {
const paragraphEndsWithLineBreak = lastRun?.kind === 'lineBreak';
const listFirstLineTextStartPx =
- !fragment.continuesFromPrev && fragment.markerWidth && wordLayout?.marker
+ !paraContinuesFromPrev && paraMarkerWidth && wordLayout?.marker
? resolvePainterListTextStartPx({
wordLayout,
indentLeftPx: paraIndentLeft,
@@ -3268,8 +3272,8 @@ export class DomPainter {
: undefined;
const shouldUseSharedInlinePrefixGeometry =
- !fragment.continuesFromPrev &&
- fragment.markerWidth &&
+ !paraContinuesFromPrev &&
+ paraMarkerWidth &&
wordLayout?.marker?.justification === 'left' &&
wordLayout.firstLineIndentMode !== true &&
typeof fragment.markerTextWidth === 'number' &&
@@ -3287,7 +3291,7 @@ export class DomPainter {
let listTabWidth = 0;
let markerStartPos = 0;
- if (!fragment.continuesFromPrev && fragment.markerWidth && wordLayout?.marker) {
+ if (!paraContinuesFromPrev && paraMarkerWidth && wordLayout?.marker) {
const markerTextWidth = fragment.markerTextWidth!;
const anchorPoint = paraIndentLeft - (paraIndent?.hanging ?? 0) + (paraIndent?.firstLine ?? 0);
const markerJustification = wordLayout.marker.justification ?? 'left';
@@ -3322,8 +3326,7 @@ export class DomPainter {
lines.forEach((line, index) => {
const hasExplicitSegmentPositioning = line.segments?.some((segment) => segment.x !== undefined) === true;
- const hasListFirstLineMarker =
- index === 0 && !fragment.continuesFromPrev && fragment.markerWidth && wordLayout?.marker;
+ const hasListFirstLineMarker = index === 0 && !paraContinuesFromPrev && paraMarkerWidth && wordLayout?.marker;
const shouldUseResolvedListTextStart =
hasListFirstLineMarker && hasExplicitSegmentPositioning && listFirstLineTextStartPx != null;
@@ -3337,7 +3340,7 @@ export class DomPainter {
}
// Adjust availableWidth for first-line text indent (hanging indent).
- const isFirstLine = index === 0 && !fragment.continuesFromPrev;
+ const isFirstLine = index === 0 && !paraContinuesFromPrev;
const isListFirstLine = Boolean(hasListFirstLineMarker && fragment.markerTextWidth);
if (isFirstLine && !isListFirstLine && !hasExplicitSegmentPositioning) {
availableWidthOverride = adjustAvailableWidthForTextIndent(
@@ -3348,7 +3351,7 @@ export class DomPainter {
}
const isLastLineOfFragment = index === lines.length - 1;
- const isLastLineOfParagraph = isLastLineOfFragment && !fragment.continuesOnNext;
+ const isLastLineOfParagraph = isLastLineOfFragment && !paraContinuesOnNext;
const shouldSkipJustifyForLastLine = isLastLineOfParagraph && !paragraphEndsWithLineBreak;
const lineEl = this.renderLine(
@@ -3384,7 +3387,7 @@ export class DomPainter {
if (paraIndentRight && paraIndentRight > 0) {
lineEl.style.paddingRight = `${paraIndentRight}px`;
}
- if (!fragment.continuesFromPrev && index === 0 && firstLineOffset && !isListFirstLine) {
+ if (!paraContinuesFromPrev && index === 0 && firstLineOffset && !isListFirstLine) {
if (!hasExplicitSegmentPositioning) {
lineEl.style.textIndent = `${firstLineOffset}px`;
}
@@ -3580,6 +3583,11 @@ export class DomPainter {
throw new Error(`DomPainter: missing list item ${fragment.itemId}`);
}
+ // Prefer resolved item metadata over legacy fragment reads
+ const listContinuesFromPrev = resolvedItem?.continuesFromPrev ?? fragment.continuesFromPrev;
+ const listContinuesOnNext = resolvedItem?.continuesOnNext ?? fragment.continuesOnNext;
+ const listMarkerWidth = resolvedItem?.markerWidth ?? fragment.markerWidth;
+
const fragmentEl = this.doc.createElement('div');
fragmentEl.classList.add(CLASS_NAMES.fragment, `${CLASS_NAMES.fragment}-list-item`);
applyStyles(fragmentEl, fragmentStyles);
@@ -3605,10 +3613,10 @@ export class DomPainter {
sdtBoundary,
);
- if (fragment.continuesFromPrev) {
+ if (listContinuesFromPrev) {
fragmentEl.dataset.continuesFromPrev = 'true';
}
- if (fragment.continuesOnNext) {
+ if (listContinuesOnNext) {
fragmentEl.dataset.continuesOnNext = 'true';
}
@@ -3623,7 +3631,7 @@ export class DomPainter {
if (marker) {
markerEl.textContent = marker.markerText ?? null;
markerEl.style.display = 'inline-block';
- markerEl.style.width = `${Math.max(0, fragment.markerWidth - LIST_MARKER_GAP)}px`;
+ markerEl.style.width = `${Math.max(0, listMarkerWidth - LIST_MARKER_GAP)}px`;
markerEl.style.paddingRight = `${LIST_MARKER_GAP}px`;
markerEl.style.textAlign = marker.justification ?? 'left';
@@ -3638,7 +3646,7 @@ export class DomPainter {
// Fallback: legacy behavior
markerEl.textContent = item.marker.text;
markerEl.style.display = 'inline-block';
- markerEl.style.width = `${Math.max(0, fragment.markerWidth - LIST_MARKER_GAP)}px`;
+ markerEl.style.width = `${Math.max(0, listMarkerWidth - LIST_MARKER_GAP)}px`;
markerEl.style.paddingRight = `${LIST_MARKER_GAP}px`;
if (item.marker.align) {
markerEl.style.textAlign = item.marker.align;
@@ -3738,16 +3746,19 @@ export class DomPainter {
}
// Add PM position markers for transaction targeting
- if (fragment.pmStart != null) {
- fragmentEl.dataset.pmStart = String(fragment.pmStart);
+ const imgPmStart = resolvedItem?.pmStart ?? fragment.pmStart;
+ if (imgPmStart != null) {
+ fragmentEl.dataset.pmStart = String(imgPmStart);
}
- if (fragment.pmEnd != null) {
- fragmentEl.dataset.pmEnd = String(fragment.pmEnd);
+ const imgPmEnd = resolvedItem?.pmEnd ?? fragment.pmEnd;
+ if (imgPmEnd != null) {
+ fragmentEl.dataset.pmEnd = String(imgPmEnd);
}
// Add metadata for interactive image resizing (skip watermarks - they should not be interactive)
- if (fragment.metadata && !block.attrs?.vmlWatermark) {
- fragmentEl.setAttribute('data-image-metadata', JSON.stringify(fragment.metadata));
+ const imgMetadata = resolvedItem?.metadata ?? fragment.metadata;
+ if (imgMetadata && !block.attrs?.vmlWatermark) {
+ fragmentEl.setAttribute('data-image-metadata', JSON.stringify(imgMetadata));
}
// behindDoc images are supported via z-index; suppress noisy debug logs
@@ -6839,8 +6850,14 @@ export class DomPainter {
/**
* Applies PM position data attributes from a legacy Fragment.
* Extracted from applyFragmentFrame for use in the resolved wrapper path.
+ * When a resolvedItem is provided, its fields take precedence over fragment fields.
*/
- private applyFragmentPmAttributes(el: HTMLElement, fragment: Fragment, section?: 'body' | 'header' | 'footer'): void {
+ private applyFragmentPmAttributes(
+ el: HTMLElement,
+ fragment: Fragment,
+ section?: 'body' | 'header' | 'footer',
+ resolvedItem?: ResolvedFragmentItem | ResolvedTableItem | ResolvedImageItem | ResolvedDrawingItem,
+ ): void {
// Footnote content is read-only: prevent cursor placement and typing
if (typeof fragment.blockId === 'string' && fragment.blockId.startsWith('footnote-')) {
el.setAttribute('contenteditable', 'false');
@@ -6850,22 +6867,28 @@ export class DomPainter {
if (section === 'body' || section === undefined) {
assertFragmentPmPositions(fragment, 'paragraph fragment');
}
- if (fragment.pmStart != null) {
- el.dataset.pmStart = String(fragment.pmStart);
+ // Narrow to ResolvedFragmentItem to access para-specific resolved fields
+ const resolvedFrag = resolvedItem as ResolvedFragmentItem | undefined;
+ const pmStart = resolvedFrag?.pmStart ?? (fragment as ParaFragment).pmStart;
+ if (pmStart != null) {
+ el.dataset.pmStart = String(pmStart);
} else {
delete el.dataset.pmStart;
}
- if (fragment.pmEnd != null) {
- el.dataset.pmEnd = String(fragment.pmEnd);
+ const pmEnd = resolvedFrag?.pmEnd ?? (fragment as ParaFragment).pmEnd;
+ if (pmEnd != null) {
+ el.dataset.pmEnd = String(pmEnd);
} else {
delete el.dataset.pmEnd;
}
- if (fragment.continuesFromPrev) {
+ const continuesFromPrev = resolvedFrag?.continuesFromPrev ?? (fragment as ParaFragment).continuesFromPrev;
+ if (continuesFromPrev) {
el.dataset.continuesFromPrev = 'true';
} else {
delete el.dataset.continuesFromPrev;
}
- if (fragment.continuesOnNext) {
+ const continuesOnNext = resolvedFrag?.continuesOnNext ?? (fragment as ParaFragment).continuesOnNext;
+ if (continuesOnNext) {
el.dataset.continuesOnNext = 'true';
} else {
delete el.dataset.continuesOnNext;
@@ -6949,7 +6972,7 @@ export class DomPainter {
el.style.height = `${item.height}px`;
}
- this.applyFragmentPmAttributes(el, fragment, section);
+ this.applyFragmentPmAttributes(el, fragment, section, item);
}
/**
@@ -6966,8 +6989,9 @@ export class DomPainter {
section?: 'body' | 'header' | 'footer',
): void {
this.applyResolvedFragmentFrame(el, item, fragment, section);
- el.style.left = `${item.x - fragment.markerWidth}px`;
- el.style.width = `${item.width + fragment.markerWidth}px`;
+ const mw = item.markerWidth ?? fragment.markerWidth;
+ el.style.left = `${item.x - mw}px`;
+ el.style.width = `${item.width + mw}px`;
}
/**
From c1f616e45c482cc0c283f9d24c9165bcca1608ec Mon Sep 17 00:00:00 2001
From: Nick Bernal <117235294+harbournick@users.noreply.github.com>
Date: Wed, 22 Apr 2026 17:47:45 -0700
Subject: [PATCH 21/43] chore: add initial scaffold for labs release check
orchestration (#2919)
* chore: add initial scaffold for labs release check orchestration
* chore: tweak gha
---
.../release-qualification-dispatch.yml | 124 ++++++++++++++++++
cicd.md | 36 +++--
2 files changed, 152 insertions(+), 8 deletions(-)
create mode 100644 .github/workflows/release-qualification-dispatch.yml
diff --git a/.github/workflows/release-qualification-dispatch.yml b/.github/workflows/release-qualification-dispatch.yml
new file mode 100644
index 0000000000..309cd81b2f
--- /dev/null
+++ b/.github/workflows/release-qualification-dispatch.yml
@@ -0,0 +1,124 @@
+name: 🧪 Dispatch Release Qualification
+
+on:
+ pull_request:
+ branches:
+ - stable
+ types:
+ - opened
+ - ready_for_review
+ - reopened
+ - synchronize
+
+permissions:
+ contents: read
+ pull-requests: read
+
+concurrency:
+ group: release-qualification-dispatch-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+jobs:
+ dispatch-release-qualification:
+ if: github.event.pull_request.head.repo.full_name == github.repository
+ runs-on: ubuntu-latest
+ steps:
+ - name: Build dispatch payload
+ env:
+ PR_TITLE: ${{ github.event.pull_request.title }}
+ run: |
+ set -euo pipefail
+
+ MERGE_PREPARATION_STATUS="unknown"
+ if [[ "${PR_TITLE}" == *"conflicts need resolution"* ]]; then
+ MERGE_PREPARATION_STATUS="conflicts"
+ fi
+
+ cat < release-qualification-payload.json
+ {
+ "repositoryOwner": "${{ github.repository_owner }}",
+ "repositoryName": "${GITHUB_REPOSITORY#*/}",
+ "repositoryFullName": "${{ github.repository }}",
+ "pullRequestNumber": ${{ github.event.pull_request.number }},
+ "pullRequestUrl": "${{ github.event.pull_request.html_url }}",
+ "baseRef": "${{ github.event.pull_request.base.ref }}",
+ "headRef": "${{ github.event.pull_request.head.ref }}",
+ "headSha": "${{ github.event.pull_request.head.sha }}",
+ "mergePreparationStatus": "${MERGE_PREPARATION_STATUS}",
+ "triggerEvent": "${{ github.event.action }}"
+ }
+ EOF
+
+ - name: Dispatch to Labs release orchestrator
+ id: dispatch
+ env:
+ LABS_RELEASE_QUALIFICATION_TOKEN: ${{ secrets.LABS_RELEASE_QUALIFICATION_TOKEN }}
+ LABS_RELEASE_QUALIFICATION_URL: ${{ vars.LABS_RELEASE_QUALIFICATION_URL }}
+ run: |
+ set -euo pipefail
+
+ if [[ -z "${LABS_RELEASE_QUALIFICATION_URL}" ]]; then
+ echo "LABS_RELEASE_QUALIFICATION_URL is required." >&2
+ exit 1
+ fi
+
+ if [[ -z "${LABS_RELEASE_QUALIFICATION_TOKEN}" ]]; then
+ echo "LABS_RELEASE_QUALIFICATION_TOKEN is required." >&2
+ exit 1
+ fi
+
+ RESPONSE_FILE="$(mktemp)"
+ set +e
+ HTTP_STATUS="$(curl \
+ --fail-with-body \
+ --silent \
+ --show-error \
+ --output "${RESPONSE_FILE}" \
+ --write-out '%{http_code}' \
+ -X POST \
+ -H 'content-type: application/json' \
+ -H "x-labs-internal-token: ${LABS_RELEASE_QUALIFICATION_TOKEN}" \
+ --data @release-qualification-payload.json \
+ "${LABS_RELEASE_QUALIFICATION_URL}")"
+ CURL_EXIT=$?
+ set -e
+
+ if [[ "${CURL_EXIT}" -ne 0 ]]; then
+ cat "${RESPONSE_FILE}"
+ exit "${CURL_EXIT}"
+ fi
+
+ if [[ "${HTTP_STATUS}" -lt 200 || "${HTTP_STATUS}" -ge 300 ]]; then
+ cat "${RESPONSE_FILE}"
+ exit 1
+ fi
+
+ RUN_ID="$(jq -r '.run.runId // empty' "${RESPONSE_FILE}")"
+ RUN_STATUS="$(jq -r '.run.status // empty' "${RESPONSE_FILE}")"
+ CREATED="$(jq -r '.created // false' "${RESPONSE_FILE}")"
+
+ if [[ -z "${RUN_ID}" || -z "${RUN_STATUS}" ]]; then
+ cat "${RESPONSE_FILE}"
+ echo "Labs response did not include the expected run metadata." >&2
+ exit 1
+ fi
+
+ echo "run_id=${RUN_ID}" >> "${GITHUB_OUTPUT}"
+ echo "run_status=${RUN_STATUS}" >> "${GITHUB_OUTPUT}"
+ echo "created=${CREATED}" >> "${GITHUB_OUTPUT}"
+
+ - name: Write workflow summary
+ run: |
+ {
+ echo "### Release Qualification Dispatch"
+ echo
+ echo "| Field | Value |"
+ echo "| --- | --- |"
+ echo "| PR | #${{ github.event.pull_request.number }} |"
+ echo "| Base branch | \`${{ github.event.pull_request.base.ref }}\` |"
+ echo "| Head branch | \`${{ github.event.pull_request.head.ref }}\` |"
+ echo "| Head SHA | \`${{ github.event.pull_request.head.sha }}\` |"
+ echo "| Labs run | \`${{ steps.dispatch.outputs.run_id }}\` |"
+ echo "| Labs status | \`${{ steps.dispatch.outputs.run_status }}\` |"
+ echo "| New run created | \`${{ steps.dispatch.outputs.created }}\` |"
+ } >> "${GITHUB_STEP_SUMMARY}"
diff --git a/cicd.md b/cicd.md
index 1e13844416..fdec60dfbd 100644
--- a/cicd.md
+++ b/cicd.md
@@ -81,7 +81,25 @@ main (next) → stable (latest) → X.x (maintenance)
- If the merge conflicts, commits the conflicted merge to the branch so a human can resolve it there
- Merging that PR triggers the automatic stable release workflow
-#### 4. Create Patch Branch (`create-patch.yml`)
+#### 4. Release Qualification Dispatch (`release-qualification-dispatch.yml`)
+
+**Trigger**: Pull requests targeting `stable` (`opened`, `reopened`, `synchronize`, `ready_for_review`)
+
+**Actions**:
+
+- Sends the PR head SHA and branch metadata to the Labs release-orchestrator service
+- Lets Labs create a generic GitHub check run on that SHA
+- Does not wait on or expose any private Labs details in the repository
+- Re-triggers automatically when new commits are pushed to the PR branch
+
+Only same-repository PRs dispatch to Labs. Forked PRs are intentionally skipped so private Labs credentials are never exposed to untrusted branches.
+
+**Required configuration**:
+
+- variable: `LABS_RELEASE_QUALIFICATION_URL`
+- secret: `LABS_RELEASE_QUALIFICATION_TOKEN`
+
+#### 5. Create Patch Branch (`create-patch.yml`)
**Trigger**: Manual workflow dispatch
@@ -92,7 +110,7 @@ main (next) → stable (latest) → X.x (maintenance)
- Creates `X.x` branch from last stable tag
- Enables patching of old versions
-#### 5. Forward Port (`forward-port.yml`)
+#### 6. Forward Port (`forward-port.yml`)
**Triggers**:
@@ -107,7 +125,7 @@ main (next) → stable (latest) → X.x (maintenance)
### Support Workflows
-#### 6. Test Suite (`test-suite.yml`)
+#### 7. Test Suite (`test-suite.yml`)
**Type**: Reusable workflow
@@ -118,7 +136,7 @@ main (next) → stable (latest) → X.x (maintenance)
- Visual regression tests (Playwright)
- E2E tests (external service)
-#### 7. Visual Tests (`test-example-apps.yml`)
+#### 8. Visual Tests (`test-example-apps.yml`)
**Triggers**:
@@ -211,10 +229,12 @@ These skip semantic-release entirely — useful for re-publishing a failed platf
1. Run "Promote to Stable" workflow
2. Review the generated PR from the candidate branch into `stable`
-3. If needed, resolve merge conflicts on the candidate branch
-4. Merge the PR into `stable`
-5. Automatically publishes `1.1.0` as @latest
-6. Syncs back to main with version bump
+3. Labs receives the PR head SHA and creates the `Release Qualification` GitHub check run
+4. If needed, resolve merge conflicts on the candidate branch and push fixes
+5. Re-run or wait for qualification on the new PR head SHA
+6. Merge the PR into `stable`
+7. Automatically publishes `1.1.0` as @latest
+8. Syncs back to main with version bump
### Scenario 3: Hotfix to Current Stable
From 5730b6cb74d10e9606c35a66ddb1f6df4d4e5389 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tadeu=20Tupinamb=C3=A1?=
Date: Wed, 22 Apr 2026 22:38:59 -0300
Subject: [PATCH 22/43] [3/16] refactor(layout): pre-compute SDT container keys
in resolved layout (#2812)
* refactor(layout): lift page metadata into ResolvedPage
* refactor(layout): lift fragment metadata into resolved paint items
Add pmStart, pmEnd, continuesFromPrev, continuesOnNext, markerWidth,
and metadata fields to resolved paint item types. Populate them in
the resolvers and update the painter to prefer resolved item data
over legacy Fragment reads with fallbacks.
* refactor(layout): pre-compute SDT container keys in resolved layout
* refactor(layout): pre-compute paragraph border data in resolved layout (#2813)
---
.../contracts/src/resolved-layout.ts | 13 +
.../src/paragraphBorderHash.ts | 33 +
.../layout-resolved/src/resolveLayout.test.ts | 578 ++++++++++++++++++
.../layout-resolved/src/resolveLayout.ts | 64 +-
.../layout-resolved/src/sdtContainerKey.ts | 40 ++
.../paragraph-borders/group-analysis.ts | 46 +-
.../painters/dom/src/renderer.ts | 40 +-
packages/react/package.json | 1 +
8 files changed, 801 insertions(+), 14 deletions(-)
create mode 100644 packages/layout-engine/layout-resolved/src/paragraphBorderHash.ts
create mode 100644 packages/layout-engine/layout-resolved/src/sdtContainerKey.ts
diff --git a/packages/layout-engine/contracts/src/resolved-layout.ts b/packages/layout-engine/contracts/src/resolved-layout.ts
index 7d28407900..ca615e563b 100644
--- a/packages/layout-engine/contracts/src/resolved-layout.ts
+++ b/packages/layout-engine/contracts/src/resolved-layout.ts
@@ -6,6 +6,7 @@ import type {
ImageFragmentMetadata,
Line,
PageMargins,
+ ParagraphBorders,
SectionVerticalAlign,
TableBlock,
TableMeasure,
@@ -118,6 +119,12 @@ export type ResolvedFragmentItem = {
markerWidth?: number;
/** Pre-resolved paragraph content for non-table paragraph fragments. */
content?: ResolvedParagraphContent;
+ /** Pre-computed SDT container key for boundary grouping (`structuredContent:` or `documentSection:`). */
+ sdtContainerKey?: string | null;
+ /** Pre-computed hash of paragraph borders for between-border grouping. */
+ paragraphBorderHash?: string;
+ /** Pre-extracted paragraph borders for between-border rendering. */
+ paragraphBorders?: ParagraphBorders;
};
/** Resolved paragraph content for non-table paragraph/list-item fragments. */
@@ -232,6 +239,8 @@ export type ResolvedTableItem = {
cellSpacingPx: number;
/** Pre-computed effective column widths: fragment.columnWidths ?? measure.columnWidths. */
effectiveColumnWidths: number[];
+ /** Pre-computed SDT container key for boundary grouping (`structuredContent:` or `documentSection:`). */
+ sdtContainerKey?: string | null;
};
/**
@@ -268,6 +277,8 @@ export type ResolvedImageItem = {
block: ImageBlock;
/** Image metadata for interactive resizing (original dimensions, aspect ratio). */
metadata?: ImageFragmentMetadata;
+ /** Pre-computed SDT container key for boundary grouping (typically null for images). */
+ sdtContainerKey?: string | null;
};
/**
@@ -302,6 +313,8 @@ export type ResolvedDrawingItem = {
pmEnd?: number;
/** Pre-extracted DrawingBlock (replaces blockLookup.get()). */
block: DrawingBlock;
+ /** Pre-computed SDT container key for boundary grouping (typically null for drawings). */
+ sdtContainerKey?: string | null;
};
/** Type guard: checks whether a resolved paint item is a ResolvedTableItem. */
diff --git a/packages/layout-engine/layout-resolved/src/paragraphBorderHash.ts b/packages/layout-engine/layout-resolved/src/paragraphBorderHash.ts
new file mode 100644
index 0000000000..b49022cc69
--- /dev/null
+++ b/packages/layout-engine/layout-resolved/src/paragraphBorderHash.ts
@@ -0,0 +1,33 @@
+import type { ParagraphBorder, ParagraphBorders } from '@superdoc/contracts';
+
+/**
+ * Hashes a single paragraph border for equality comparison.
+ *
+ * Duplicated from painters/dom/src/paragraph-hash-utils.ts to avoid a
+ * circular dependency (painter-dom → layout-resolved is not allowed).
+ * Keep the two copies in sync.
+ */
+const hashParagraphBorder = (border: ParagraphBorder): string => {
+ const parts: string[] = [];
+ if (border.style !== undefined) parts.push(`s:${border.style}`);
+ if (border.width !== undefined) parts.push(`w:${border.width}`);
+ if (border.color !== undefined) parts.push(`c:${border.color}`);
+ if (border.space !== undefined) parts.push(`sp:${border.space}`);
+ return parts.join(',');
+};
+
+/**
+ * Hashes a full paragraph borders object for grouping comparison.
+ *
+ * Two paragraph fragments with the same hash belong to the same border group
+ * per ECMA-376 §17.3.1.24.
+ */
+export const hashParagraphBorders = (borders: ParagraphBorders): string => {
+ const parts: string[] = [];
+ if (borders.top) parts.push(`t:[${hashParagraphBorder(borders.top)}]`);
+ if (borders.right) parts.push(`r:[${hashParagraphBorder(borders.right)}]`);
+ if (borders.bottom) parts.push(`b:[${hashParagraphBorder(borders.bottom)}]`);
+ if (borders.left) parts.push(`l:[${hashParagraphBorder(borders.left)}]`);
+ if (borders.between) parts.push(`bw:[${hashParagraphBorder(borders.between)}]`);
+ return parts.join(';');
+};
diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts
index 2e935e82a3..5921c55e7e 100644
--- a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts
+++ b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts
@@ -2032,4 +2032,582 @@ describe('resolveLayout', () => {
expect(result.layoutEpoch).toBeUndefined();
});
});
+ describe('sdtContainerKey resolution', () => {
+ it('sets sdtContainerKey for a paragraph with block structuredContent sdt', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'p1',
+ runs: [],
+ attrs: { sdt: { type: 'structuredContent', scope: 'block', id: 'sdt-1' } },
+ },
+ ];
+ const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.sdtContainerKey).toBe('structuredContent:sdt-1');
+ });
+
+ it('sets sdtContainerKey for a paragraph with documentSection sdt', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'p1',
+ runs: [],
+ attrs: { sdt: { type: 'documentSection', id: 'sec-1' } },
+ },
+ ];
+ const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.sdtContainerKey).toBe('documentSection:sec-1');
+ });
+
+ it('uses sdBlockId for documentSection when id is absent', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'p1',
+ runs: [],
+ attrs: { sdt: { type: 'documentSection', sdBlockId: 'blk-99' } },
+ },
+ ];
+ const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.sdtContainerKey).toBe('documentSection:blk-99');
+ });
+
+ it('falls back to containerSdt when primary sdt has no container config', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'p1',
+ runs: [],
+ attrs: {
+ sdt: { type: 'structuredContent', scope: 'inline', id: 'inline-1' },
+ containerSdt: { type: 'documentSection', id: 'sec-2' },
+ },
+ },
+ ];
+ const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.sdtContainerKey).toBe('documentSection:sec-2');
+ });
+
+ it('returns null (omits sdtContainerKey) for inline structuredContent scope', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'p1',
+ runs: [],
+ attrs: { sdt: { type: 'structuredContent', scope: 'inline', id: 'inline-1' } },
+ },
+ ];
+ const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.sdtContainerKey).toBeUndefined();
+ });
+
+ it('omits sdtContainerKey when paragraph has no sdt', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [{ kind: 'paragraph', id: 'p1', runs: [] }];
+ const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.sdtContainerKey).toBeUndefined();
+ });
+
+ it('sets sdtContainerKey for a list-item fragment from its item paragraph sdt', () => {
+ const listItemFragment: ListItemFragment = {
+ kind: 'list-item',
+ blockId: 'list1',
+ itemId: 'item-a',
+ fromLine: 0,
+ toLine: 1,
+ x: 108,
+ y: 200,
+ width: 432,
+ markerWidth: 36,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [listItemFragment] }],
+ };
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'list',
+ id: 'list1',
+ listType: 'bullet',
+ items: [
+ {
+ id: 'item-a',
+ marker: { text: '•', style: {} },
+ paragraph: {
+ kind: 'paragraph',
+ id: 'item-a-p',
+ runs: [],
+ attrs: { sdt: { type: 'structuredContent', scope: 'block', id: 'list-sdt-1' } },
+ },
+ },
+ ],
+ },
+ ];
+ const measures: Measure[] = [
+ {
+ kind: 'list',
+ items: [
+ {
+ itemId: 'item-a',
+ markerWidth: 36,
+ markerTextWidth: 10,
+ indentLeft: 36,
+ paragraph: {
+ kind: 'paragraph',
+ lines: [
+ { fromRun: 0, fromChar: 0, toRun: 0, toChar: 10, width: 400, ascent: 12, descent: 4, lineHeight: 24 },
+ ],
+ totalHeight: 24,
+ },
+ },
+ ],
+ totalHeight: 24,
+ },
+ ];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.sdtContainerKey).toBe('structuredContent:list-sdt-1');
+ });
+
+ it('sets sdtContainerKey for a table fragment with sdt', () => {
+ const tableFragment: TableFragment = {
+ kind: 'table',
+ blockId: 'tbl1',
+ fromRow: 0,
+ toRow: 1,
+ x: 72,
+ y: 100,
+ width: 468,
+ height: 30,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [tableFragment] }],
+ };
+ const tableBlock = {
+ kind: 'table' as const,
+ id: 'tbl1',
+ rows: [],
+ attrs: { sdt: { type: 'documentSection' as const, id: 'tbl-sec-1' } },
+ };
+ const tableMeasure = {
+ kind: 'table' as const,
+ rows: [],
+ columnWidths: [],
+ totalWidth: 0,
+ totalHeight: 0,
+ };
+
+ const result = resolveLayout({
+ layout,
+ flowMode: 'paginated',
+ blocks: [tableBlock as any],
+ measures: [tableMeasure as any],
+ });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedTableItem;
+ expect(item.sdtContainerKey).toBe('documentSection:tbl-sec-1');
+ });
+
+ it('omits sdtContainerKey for image and drawing fragments', () => {
+ const imageFragment: ImageFragment = {
+ kind: 'image',
+ blockId: 'img1',
+ x: 100,
+ y: 200,
+ width: 300,
+ height: 250,
+ };
+ const drawingFragment: DrawingFragment = {
+ kind: 'drawing',
+ drawingKind: 'vectorShape',
+ blockId: 'dr1',
+ x: 50,
+ y: 60,
+ width: 200,
+ height: 150,
+ geometry: { width: 200, height: 150 },
+ scale: 1,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [imageFragment, drawingFragment] }],
+ };
+ const imageBlock = { kind: 'image' as const, id: 'img1', src: 'test.png', width: 300, height: 250 };
+ const drawingBlock = {
+ kind: 'drawing' as const,
+ id: 'dr1',
+ drawingKind: 'vectorShape' as const,
+ geometry: { width: 200, height: 150 },
+ };
+
+ const result = resolveLayout({
+ layout,
+ flowMode: 'paginated',
+ blocks: [imageBlock, drawingBlock as any],
+ measures: [
+ { kind: 'image', width: 300, height: 250 },
+ { kind: 'drawing', width: 200, height: 150 },
+ ],
+ });
+ const imgItem = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedImageItem;
+ const drItem = result.pages[0].items[1] as import('@superdoc/contracts').ResolvedDrawingItem;
+ expect(imgItem.sdtContainerKey).toBeUndefined();
+ expect(drItem.sdtContainerKey).toBeUndefined();
+ });
+
+ it('returns null (omits key) for structuredContent block scope with no id', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'p1',
+ runs: [],
+ attrs: { sdt: { type: 'structuredContent', scope: 'block' } },
+ },
+ ];
+ const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.sdtContainerKey).toBeUndefined();
+ });
+
+ it('returns null (omits key) for documentSection with no id or sdBlockId', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'p1',
+ runs: [],
+ attrs: { sdt: { type: 'documentSection' } },
+ },
+ ];
+ const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.sdtContainerKey).toBeUndefined();
+ });
+ });
+
+ describe('paragraphBorders pre-computation', () => {
+ it('populates paragraphBorders and paragraphBorderHash for a paragraph with borders', () => {
+ const borders = {
+ top: { style: 'solid' as const, width: 4, color: '#000000' },
+ bottom: { style: 'solid' as const, width: 4, color: '#000000' },
+ left: { style: 'solid' as const, width: 4, color: '#000000' },
+ right: { style: 'solid' as const, width: 4, color: '#000000' },
+ between: { style: 'solid' as const, width: 4, color: '#000000' },
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [{ kind: 'paragraph', id: 'p1', runs: [], attrs: { borders } }];
+ const measures: Measure[] = [
+ {
+ kind: 'paragraph',
+ lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 200, ascent: 12, descent: 4, lineHeight: 20 }],
+ totalHeight: 20,
+ },
+ ];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.paragraphBorders).toEqual(borders);
+ expect(item.paragraphBorderHash).toBeDefined();
+ expect(typeof item.paragraphBorderHash).toBe('string');
+ expect(item.paragraphBorderHash!.length).toBeGreaterThan(0);
+ });
+
+ it('omits paragraphBorders and paragraphBorderHash when paragraph has no borders', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [{ kind: 'paragraph', id: 'p1', runs: [] }];
+ const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.paragraphBorders).toBeUndefined();
+ expect(item.paragraphBorderHash).toBeUndefined();
+ });
+
+ it('produces matching hashes for identical border definitions', () => {
+ const borders = {
+ top: { style: 'solid' as const, width: 4, color: '#000000' },
+ bottom: { style: 'solid' as const, width: 4, color: '#000000' },
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [
+ { kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 },
+ { kind: 'para', blockId: 'p2', fromLine: 0, toLine: 1, x: 72, y: 130, width: 468 },
+ ],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [
+ { kind: 'paragraph', id: 'p1', runs: [], attrs: { borders } },
+ { kind: 'paragraph', id: 'p2', runs: [], attrs: { borders: { ...borders } } },
+ ];
+ const measures: Measure[] = [
+ {
+ kind: 'paragraph',
+ lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 200, ascent: 12, descent: 4, lineHeight: 20 }],
+ totalHeight: 20,
+ },
+ {
+ kind: 'paragraph',
+ lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 200, ascent: 12, descent: 4, lineHeight: 20 }],
+ totalHeight: 20,
+ },
+ ];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item0 = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ const item1 = result.pages[0].items[1] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item0.paragraphBorderHash).toBe(item1.paragraphBorderHash);
+ });
+
+ it('produces different hashes for different border definitions', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [
+ { kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 },
+ { kind: 'para', blockId: 'p2', fromLine: 0, toLine: 1, x: 72, y: 130, width: 468 },
+ ],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'p1',
+ runs: [],
+ attrs: { borders: { top: { style: 'solid' as const, width: 4, color: '#000000' } } },
+ },
+ {
+ kind: 'paragraph',
+ id: 'p2',
+ runs: [],
+ attrs: { borders: { top: { style: 'dashed' as const, width: 2, color: '#FF0000' } } },
+ },
+ ];
+ const measures: Measure[] = [
+ {
+ kind: 'paragraph',
+ lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 200, ascent: 12, descent: 4, lineHeight: 20 }],
+ totalHeight: 20,
+ },
+ {
+ kind: 'paragraph',
+ lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 200, ascent: 12, descent: 4, lineHeight: 20 }],
+ totalHeight: 20,
+ },
+ ];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item0 = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ const item1 = result.pages[0].items[1] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item0.paragraphBorderHash).not.toBe(item1.paragraphBorderHash);
+ });
+
+ it('populates paragraphBorders for list-item fragments', () => {
+ const borders = {
+ top: { style: 'solid' as const, width: 2, color: '#0000FF' },
+ between: { style: 'solid' as const, width: 1, color: '#0000FF' },
+ };
+ const listItemFragment: ListItemFragment = {
+ kind: 'list-item',
+ blockId: 'list1',
+ itemId: 'item-a',
+ fromLine: 0,
+ toLine: 1,
+ x: 72,
+ y: 100,
+ width: 468,
+ markerWidth: 36,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [listItemFragment] }],
+ };
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'list',
+ id: 'list1',
+ listType: 'bullet',
+ items: [
+ {
+ id: 'item-a',
+ marker: { text: '•', style: {} },
+ paragraph: { kind: 'paragraph', id: 'item-a-p', runs: [], attrs: { borders } },
+ },
+ ],
+ },
+ ];
+ const measures: Measure[] = [
+ {
+ kind: 'list',
+ items: [
+ {
+ itemId: 'item-a',
+ markerWidth: 36,
+ markerTextWidth: 10,
+ indentLeft: 36,
+ paragraph: {
+ kind: 'paragraph',
+ lines: [
+ { fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 200, ascent: 12, descent: 4, lineHeight: 20 },
+ ],
+ totalHeight: 20,
+ },
+ },
+ ],
+ totalHeight: 20,
+ },
+ ];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.paragraphBorders).toEqual(borders);
+ expect(item.paragraphBorderHash).toBeDefined();
+ });
+
+ it('does not add paragraphBorders to table items', () => {
+ const tableFragment: TableFragment = {
+ kind: 'table',
+ blockId: 'tbl1',
+ fromRow: 0,
+ toRow: 1,
+ x: 72,
+ y: 100,
+ width: 468,
+ height: 100,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [tableFragment] }],
+ };
+ const blocks: FlowBlock[] = [{ kind: 'table', id: 'tbl1', rows: [{ cells: [] }] } as any];
+ const measures: Measure[] = [
+ {
+ kind: 'table',
+ columnWidths: [468],
+ rows: [{ cells: [{ width: 468, height: 100 }] }],
+ } as any,
+ ];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as any;
+ expect(item.paragraphBorders).toBeUndefined();
+ expect(item.paragraphBorderHash).toBeUndefined();
+ });
+ });
});
diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts
index 3f1d19d4de..de5666c2f9 100644
--- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts
+++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts
@@ -10,12 +10,14 @@ import type {
ParaFragment,
TableFragment,
Line,
+ ParagraphBorders,
ResolvedLayout,
ResolvedPage,
ResolvedPaintItem,
ResolvedFragmentItem,
ResolvedParagraphContent,
ListMeasure,
+ ListBlock,
ParagraphBlock,
ParagraphMeasure,
} from '@superdoc/contracts';
@@ -24,6 +26,8 @@ import { resolveTableItem } from './resolveTable.js';
import { resolveImageItem } from './resolveImage.js';
import { resolveDrawingItem } from './resolveDrawing.js';
import type { BlockMapEntry } from './resolvedBlockLookup.js';
+import { computeSdtContainerKey } from './sdtContainerKey.js';
+import { hashParagraphBorders } from './paragraphBorderHash.js';
export type ResolveLayoutInput = {
layout: Layout;
@@ -125,16 +129,64 @@ function resolveParagraphContentIfApplicable(
return resolveParagraphContent(fragment, entry.block as ParagraphBlock, entry.measure as ParagraphMeasure);
}
+function resolveFragmentParagraphBorders(
+ fragment: Fragment,
+ blockMap: Map,
+): ParagraphBorders | undefined {
+ const entry = blockMap.get(fragment.blockId);
+ if (!entry) return undefined;
+
+ if (fragment.kind === 'para' && entry.block.kind === 'paragraph') {
+ return (entry.block as ParagraphBlock).attrs?.borders;
+ }
+
+ if (fragment.kind === 'list-item' && entry.block.kind === 'list') {
+ const block = entry.block as ListBlock;
+ const item = block.items.find((listItem) => listItem.id === fragment.itemId);
+ return item?.paragraph.attrs?.borders;
+ }
+
+ return undefined;
+}
+
+function resolveFragmentSdtContainerKey(fragment: Fragment, blockMap: Map): string | null {
+ const entry = blockMap.get(fragment.blockId);
+ if (!entry) return null;
+ const block = entry.block;
+
+ if (fragment.kind === 'para' && block.kind === 'paragraph') {
+ return computeSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt);
+ }
+
+ if (fragment.kind === 'list-item' && block.kind === 'list') {
+ const listBlock = block as ListBlock;
+ const item = listBlock.items.find((listItem) => listItem.id === fragment.itemId);
+ return computeSdtContainerKey(item?.paragraph.attrs?.sdt, item?.paragraph.attrs?.containerSdt);
+ }
+
+ if (fragment.kind === 'table' && block.kind === 'table') {
+ return computeSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt);
+ }
+
+ // image, drawing — no SDT container keys
+ return null;
+}
+
function resolveFragmentItem(
fragment: Fragment,
fragmentIndex: number,
pageIndex: number,
blockMap: Map,
): ResolvedPaintItem {
+ const sdtContainerKey = resolveFragmentSdtContainerKey(fragment, blockMap);
+
// Route to kind-specific resolvers for types that carry extracted block/measure data.
switch (fragment.kind) {
- case 'table':
- return resolveTableItem(fragment as TableFragment, fragmentIndex, pageIndex, blockMap);
+ case 'table': {
+ const item = resolveTableItem(fragment as TableFragment, fragmentIndex, pageIndex, blockMap);
+ if (sdtContainerKey != null) item.sdtContainerKey = sdtContainerKey;
+ return item;
+ }
case 'image':
return resolveImageItem(fragment as ImageFragment, fragmentIndex, pageIndex, blockMap);
case 'drawing':
@@ -155,6 +207,14 @@ function resolveFragmentItem(
fragmentIndex,
content: resolveParagraphContentIfApplicable(fragment, blockMap),
};
+ if (sdtContainerKey != null) item.sdtContainerKey = sdtContainerKey;
+
+ // Pre-compute paragraph border data for between-border grouping
+ const borders = resolveFragmentParagraphBorders(fragment, blockMap);
+ if (borders) {
+ item.paragraphBorders = borders;
+ item.paragraphBorderHash = hashParagraphBorders(borders);
+ }
if (fragment.kind === 'para') {
const para = fragment as ParaFragment;
if (para.pmStart != null) item.pmStart = para.pmStart;
diff --git a/packages/layout-engine/layout-resolved/src/sdtContainerKey.ts b/packages/layout-engine/layout-resolved/src/sdtContainerKey.ts
new file mode 100644
index 0000000000..4cee08673f
--- /dev/null
+++ b/packages/layout-engine/layout-resolved/src/sdtContainerKey.ts
@@ -0,0 +1,40 @@
+import type { SdtMetadata } from '@superdoc/contracts';
+
+/**
+ * Returns a stable key for grouping consecutive fragments in the same SDT container.
+ *
+ * This is a minimal duplicate of the logic in `painters/dom/src/utils/sdt-helpers.ts`
+ * (`getSdtContainerKey`), kept here to avoid a dependency on the painter package.
+ * Only the key derivation is needed; DOM styling helpers are not.
+ */
+export function computeSdtContainerKey(sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null): string | null {
+ const metadata = getSdtContainerMetadata(sdt, containerSdt);
+ if (!metadata) return null;
+
+ if (metadata.type === 'structuredContent') {
+ if (metadata.scope !== 'block') return null;
+ if (!metadata.id) return null;
+ return `structuredContent:${metadata.id}`;
+ }
+
+ if (metadata.type === 'documentSection') {
+ const sectionId = metadata.id ?? metadata.sdBlockId;
+ if (!sectionId) return null;
+ return `documentSection:${sectionId}`;
+ }
+
+ return null;
+}
+
+function isSdtContainer(sdt?: SdtMetadata | null): boolean {
+ if (!sdt) return false;
+ if (sdt.type === 'documentSection') return true;
+ if (sdt.type === 'structuredContent' && sdt.scope === 'block') return true;
+ return false;
+}
+
+function getSdtContainerMetadata(sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null): SdtMetadata | null {
+ if (isSdtContainer(sdt)) return sdt ?? null;
+ if (isSdtContainer(containerSdt)) return containerSdt ?? null;
+ return null;
+}
diff --git a/packages/layout-engine/painters/dom/src/features/paragraph-borders/group-analysis.ts b/packages/layout-engine/painters/dom/src/features/paragraph-borders/group-analysis.ts
index d2996105ef..c225a92810 100644
--- a/packages/layout-engine/painters/dom/src/features/paragraph-borders/group-analysis.ts
+++ b/packages/layout-engine/painters/dom/src/features/paragraph-borders/group-analysis.ts
@@ -15,6 +15,8 @@ import type {
ListMeasure,
ParagraphBlock,
ParagraphAttrs,
+ ResolvedPaintItem,
+ ResolvedFragmentItem,
} from '@superdoc/contracts';
import type { BlockLookup } from './types.js';
import { hashParagraphBorders } from '../../paragraph-hash-utils.js';
@@ -124,9 +126,23 @@ const isBetweenBorderNone = (borders: ParagraphAttrs['borders']): boolean => {
*
* Middle fragments in a chain of 3+ get both flags.
*/
+
+/**
+ * Helper: check whether a resolved item is a ResolvedFragmentItem (para/list-item)
+ * with pre-computed paragraph border data.
+ */
+function isResolvedFragmentWithBorders(
+ item: ResolvedPaintItem | undefined,
+): item is ResolvedFragmentItem & { paragraphBorders: NonNullable } {
+ return (
+ item !== undefined && item.kind === 'fragment' && 'paragraphBorders' in item && item.paragraphBorders !== undefined
+ );
+}
+
export const computeBetweenBorderFlags = (
fragments: readonly Fragment[],
blockLookup: BlockLookup,
+ resolvedItems?: readonly ResolvedPaintItem[],
): Map => {
// Phase 1: determine which consecutive pairs form between-border groups
const pairFlags = new Set();
@@ -137,7 +153,10 @@ export const computeBetweenBorderFlags = (
if (frag.kind !== 'para' && frag.kind !== 'list-item') continue;
if (frag.continuesOnNext) continue;
- const borders = getFragmentParagraphBorders(frag, blockLookup);
+ const resolvedCur = resolvedItems?.[i];
+ const borders = isResolvedFragmentWithBorders(resolvedCur)
+ ? resolvedCur.paragraphBorders
+ : getFragmentParagraphBorders(frag, blockLookup);
if (!borders) continue;
const next = fragments[i + 1];
@@ -152,9 +171,24 @@ export const computeBetweenBorderFlags = (
)
continue;
- const nextBorders = getFragmentParagraphBorders(next, blockLookup);
+ const resolvedNext = resolvedItems?.[i + 1];
+ const nextBorders = isResolvedFragmentWithBorders(resolvedNext)
+ ? resolvedNext.paragraphBorders
+ : getFragmentParagraphBorders(next, blockLookup);
if (!nextBorders) continue;
- if (hashParagraphBorders(borders) !== hashParagraphBorders(nextBorders)) continue;
+
+ // Compare using pre-computed hashes when available, falling back to computing on-the-fly.
+ const curHash =
+ resolvedCur && 'paragraphBorderHash' in resolvedCur && (resolvedCur as ResolvedFragmentItem).paragraphBorderHash
+ ? (resolvedCur as ResolvedFragmentItem).paragraphBorderHash!
+ : hashParagraphBorders(borders);
+ const nextHash =
+ resolvedNext &&
+ 'paragraphBorderHash' in resolvedNext &&
+ (resolvedNext as ResolvedFragmentItem).paragraphBorderHash
+ ? (resolvedNext as ResolvedFragmentItem).paragraphBorderHash!
+ : hashParagraphBorders(nextBorders);
+ if (curHash !== nextHash) continue;
// Skip fragments in different columns (different x positions)
if (frag.x !== next.x) continue;
@@ -175,7 +209,11 @@ export const computeBetweenBorderFlags = (
for (const i of pairFlags) {
const frag = fragments[i];
const next = fragments[i + 1];
- const fragHeight = getFragmentHeight(frag, blockLookup);
+ const resolvedCur = resolvedItems?.[i];
+ const fragHeight =
+ resolvedCur && 'height' in resolvedCur && resolvedCur.height != null
+ ? resolvedCur.height
+ : getFragmentHeight(frag, blockLookup);
const gapBelow = Math.max(0, next.y - (frag.y + fragHeight));
const isNoBetween = noBetweenPairs.has(i);
diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts
index 356d29ef88..6254d066b2 100644
--- a/packages/layout-engine/painters/dom/src/renderer.ts
+++ b/packages/layout-engine/painters/dom/src/renderer.ts
@@ -2225,8 +2225,13 @@ export class DomPainter {
pageIndex,
};
- const sdtBoundaries = computeSdtBoundaries(page.fragments, this.blockLookup, this.sdtLabelsRendered);
- const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, this.blockLookup);
+ const sdtBoundaries = computeSdtBoundaries(
+ page.fragments,
+ this.blockLookup,
+ this.sdtLabelsRendered,
+ resolvedPage?.items,
+ );
+ const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, this.blockLookup, resolvedPage?.items);
page.fragments.forEach((fragment, index) => {
const sdtBoundary = sdtBoundaries.get(index);
@@ -2753,8 +2758,13 @@ export class DomPainter {
const existing = new Map(state.fragments.map((frag) => [frag.key, frag]));
const nextFragments: FragmentDomState[] = [];
- const sdtBoundaries = computeSdtBoundaries(page.fragments, this.blockLookup, this.sdtLabelsRendered);
- const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, this.blockLookup);
+ const sdtBoundaries = computeSdtBoundaries(
+ page.fragments,
+ this.blockLookup,
+ this.sdtLabelsRendered,
+ resolvedPage?.items,
+ );
+ const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, this.blockLookup, resolvedPage?.items);
const contextBase: FragmentRenderContext = {
pageNumber: page.number,
@@ -2916,8 +2926,13 @@ export class DomPainter {
pageIndex,
};
- const sdtBoundaries = computeSdtBoundaries(page.fragments, this.blockLookup, this.sdtLabelsRendered);
- const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, this.blockLookup);
+ const sdtBoundaries = computeSdtBoundaries(
+ page.fragments,
+ this.blockLookup,
+ this.sdtLabelsRendered,
+ resolvedPage?.items,
+ );
+ const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, this.blockLookup, resolvedPage?.items);
const fragmentStates: FragmentDomState[] = page.fragments.map((fragment, index) => {
const sdtBoundary = sdtBoundaries.get(index);
const resolvedItem = this.getResolvedFragmentItem(pageIndex, index);
@@ -7237,9 +7252,18 @@ const computeSdtBoundaries = (
fragments: readonly Fragment[],
blockLookup: BlockLookup,
sdtLabelsRendered: Set,
+ resolvedItems?: readonly ResolvedPaintItem[],
): Map => {
const boundaries = new Map();
- const containerKeys = fragments.map((fragment) => getFragmentSdtContainerKey(fragment, blockLookup));
+ const containerKeys: (string | null)[] = resolvedItems
+ ? resolvedItems.map((item) => {
+ if ('sdtContainerKey' in item) {
+ const key = (item as { sdtContainerKey?: string | null }).sdtContainerKey;
+ return key ?? null;
+ }
+ return null;
+ })
+ : fragments.map((fragment) => getFragmentSdtContainerKey(fragment, blockLookup));
let i = 0;
while (i < fragments.length) {
@@ -7268,7 +7292,7 @@ const computeSdtBoundaries = (
let paddingBottomOverride: number | undefined;
if (!isEnd) {
const nextFragment = fragments[k + 1];
- const currentHeight = getFragmentHeight(fragment, blockLookup);
+ const currentHeight = resolvedItems?.[k]?.height ?? getFragmentHeight(fragment, blockLookup);
const currentBottom = fragment.y + currentHeight;
const gapToNext = nextFragment.y - currentBottom;
if (gapToNext > 0) {
diff --git a/packages/react/package.json b/packages/react/package.json
index 5acbc31285..b1263315ef 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -21,6 +21,7 @@
"build": "vite build",
"dev": "vite build --watch",
"test": "vitest run",
+ "pretype-check": "node ../../apps/cli/scripts/ensure-superdoc-build.js --types",
"type-check": "tsc --noEmit",
"lint": "eslint src --ext .ts,.tsx",
"prepublishOnly": "pnpm run build"
From ffb8b4581e4191a31ea81b7ff95bbf5b4ab625f3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tadeu=20Tupinamb=C3=A1?=
Date: Wed, 22 Apr 2026 23:28:02 -0300
Subject: [PATCH 23/43] [5/16] refactor(layout): move change detection into
resolved layout stage (#2814)
* refactor(layout): lift page metadata into ResolvedPage
* refactor(layout): lift fragment metadata into resolved paint items
Add pmStart, pmEnd, continuesFromPrev, continuesOnNext, markerWidth,
and metadata fields to resolved paint item types. Populate them in
the resolvers and update the painter to prefer resolved item data
over legacy Fragment reads with fallbacks.
* refactor(layout): pre-compute SDT container keys in resolved layout
* refactor(layout): pre-compute paragraph border data in resolved layout
* refactor(layout): move change detection into resolved layout stage
* fix: avoid duplicate block version hashing in DomPainter
---
.../contracts/src/resolved-layout.ts | 10 +
.../layout-resolved/src/hashUtils.ts | 116 ++++
.../layout-resolved/src/resolveLayout.test.ts | 347 ++++++++++++
.../layout-resolved/src/resolveLayout.ts | 47 +-
.../layout-resolved/src/versionSignature.ts | 535 ++++++++++++++++++
.../painters/dom/src/index.test.ts | 69 +++
.../painters/dom/src/renderer.ts | 37 +-
7 files changed, 1147 insertions(+), 14 deletions(-)
create mode 100644 packages/layout-engine/layout-resolved/src/hashUtils.ts
create mode 100644 packages/layout-engine/layout-resolved/src/versionSignature.ts
diff --git a/packages/layout-engine/contracts/src/resolved-layout.ts b/packages/layout-engine/contracts/src/resolved-layout.ts
index ca615e563b..4701ec17a2 100644
--- a/packages/layout-engine/contracts/src/resolved-layout.ts
+++ b/packages/layout-engine/contracts/src/resolved-layout.ts
@@ -20,6 +20,8 @@ export type ResolvedLayout = {
flowMode: FlowMode;
/** Gap between pages in pixels (0 when unset). */
pageGap: number;
+ /** Pre-computed block versions for painter-side cache invalidation. */
+ blockVersions?: Record;
/** Resolved pages with normalized dimensions. */
pages: ResolvedPage[];
/** Document epoch identifier from the source layout. Used for change tracking in the painter. */
@@ -125,6 +127,8 @@ export type ResolvedFragmentItem = {
paragraphBorderHash?: string;
/** Pre-extracted paragraph borders for between-border rendering. */
paragraphBorders?: ParagraphBorders;
+ /** Pre-computed change-detection signature (blockVersion + fragment-specific data). */
+ version?: string;
};
/** Resolved paragraph content for non-table paragraph/list-item fragments. */
@@ -241,6 +245,8 @@ export type ResolvedTableItem = {
effectiveColumnWidths: number[];
/** Pre-computed SDT container key for boundary grouping (`structuredContent:` or `documentSection:`). */
sdtContainerKey?: string | null;
+ /** Pre-computed change-detection signature (blockVersion + fragment-specific data). */
+ version?: string;
};
/**
@@ -279,6 +285,8 @@ export type ResolvedImageItem = {
metadata?: ImageFragmentMetadata;
/** Pre-computed SDT container key for boundary grouping (typically null for images). */
sdtContainerKey?: string | null;
+ /** Pre-computed change-detection signature (blockVersion + fragment-specific data). */
+ version?: string;
};
/**
@@ -315,6 +323,8 @@ export type ResolvedDrawingItem = {
block: DrawingBlock;
/** Pre-computed SDT container key for boundary grouping (typically null for drawings). */
sdtContainerKey?: string | null;
+ /** Pre-computed change-detection signature (blockVersion + fragment-specific data). */
+ version?: string;
};
/** Type guard: checks whether a resolved paint item is a ResolvedTableItem. */
diff --git a/packages/layout-engine/layout-resolved/src/hashUtils.ts b/packages/layout-engine/layout-resolved/src/hashUtils.ts
new file mode 100644
index 0000000000..ff2b4c38ad
--- /dev/null
+++ b/packages/layout-engine/layout-resolved/src/hashUtils.ts
@@ -0,0 +1,116 @@
+import type { BorderSpec, CellBorders, Run, TableBorders, TableBorderValue } from '@superdoc/contracts';
+
+/**
+ * Hash helpers for block version computation.
+ *
+ * Duplicated from painters/dom/src/paragraph-hash-utils.ts to avoid a circular
+ * dependency (painter-dom -> layout-resolved is not allowed). Keep the two
+ * copies in sync.
+ */
+
+// ---------------------------------------------------------------------------
+// Table/Cell border hashing
+// ---------------------------------------------------------------------------
+
+const isNoneBorder = (value: TableBorderValue): value is { none: true } => {
+ return typeof value === 'object' && value !== null && 'none' in value && (value as { none: true }).none === true;
+};
+
+const isBorderSpec = (value: unknown): value is BorderSpec => {
+ return typeof value === 'object' && value !== null && !('none' in value);
+};
+
+export const hashBorderSpec = (border: BorderSpec): string => {
+ const parts: string[] = [];
+ if (border.style !== undefined) parts.push(`s:${border.style}`);
+ if (border.width !== undefined) parts.push(`w:${border.width}`);
+ if (border.color !== undefined) parts.push(`c:${border.color}`);
+ if (border.space !== undefined) parts.push(`sp:${border.space}`);
+ return parts.join(',');
+};
+
+const hashTableBorderValue = (borderValue: TableBorderValue | undefined): string => {
+ if (borderValue === undefined) return '';
+ if (borderValue === null) return 'null';
+ if (isNoneBorder(borderValue)) return 'none';
+ if (isBorderSpec(borderValue)) {
+ return hashBorderSpec(borderValue);
+ }
+ return '';
+};
+
+export const hashTableBorders = (borders: TableBorders | undefined): string => {
+ if (!borders) return '';
+ const parts: string[] = [];
+ if (borders.top !== undefined) parts.push(`t:[${hashTableBorderValue(borders.top)}]`);
+ if (borders.right !== undefined) parts.push(`r:[${hashTableBorderValue(borders.right)}]`);
+ if (borders.bottom !== undefined) parts.push(`b:[${hashTableBorderValue(borders.bottom)}]`);
+ if (borders.left !== undefined) parts.push(`l:[${hashTableBorderValue(borders.left)}]`);
+ if (borders.insideH !== undefined) parts.push(`ih:[${hashTableBorderValue(borders.insideH)}]`);
+ if (borders.insideV !== undefined) parts.push(`iv:[${hashTableBorderValue(borders.insideV)}]`);
+ return parts.join(';');
+};
+
+export const hashCellBorders = (borders: CellBorders | undefined): string => {
+ if (!borders) return '';
+ const parts: string[] = [];
+ if (borders.top) parts.push(`t:[${hashBorderSpec(borders.top)}]`);
+ if (borders.right) parts.push(`r:[${hashBorderSpec(borders.right)}]`);
+ if (borders.bottom) parts.push(`b:[${hashBorderSpec(borders.bottom)}]`);
+ if (borders.left) parts.push(`l:[${hashBorderSpec(borders.left)}]`);
+ return parts.join(';');
+};
+
+// ---------------------------------------------------------------------------
+// Run property accessors
+// ---------------------------------------------------------------------------
+
+const hasStringProp = (run: Run, prop: string): run is Run & Record => {
+ return prop in run && typeof (run as Record)[prop] === 'string';
+};
+
+const hasNumberProp = (run: Run, prop: string): run is Run & Record => {
+ return prop in run && typeof (run as Record)[prop] === 'number';
+};
+
+const hasBooleanProp = (run: Run, prop: string): run is Run & Record => {
+ return prop in run && typeof (run as Record)[prop] === 'boolean';
+};
+
+export const getRunStringProp = (run: Run, prop: string): string => {
+ if (hasStringProp(run, prop)) {
+ return run[prop];
+ }
+ return '';
+};
+
+export const getRunNumberProp = (run: Run, prop: string): number => {
+ if (hasNumberProp(run, prop)) {
+ return run[prop];
+ }
+ return 0;
+};
+
+export const getRunBooleanProp = (run: Run, prop: string): boolean => {
+ if (hasBooleanProp(run, prop)) {
+ return run[prop];
+ }
+ return false;
+};
+
+export const getRunUnderlineStyle = (run: Run): string => {
+ if ('underline' in run && typeof run.underline === 'boolean') {
+ return run.underline ? 'single' : '';
+ }
+ if ('underline' in run && run.underline && typeof run.underline === 'object') {
+ return (run.underline as { style?: string }).style ?? '';
+ }
+ return '';
+};
+
+export const getRunUnderlineColor = (run: Run): string => {
+ if ('underline' in run && run.underline && typeof run.underline === 'object') {
+ return (run.underline as { color?: string }).color ?? '';
+ }
+ return '';
+};
diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts
index 5921c55e7e..2f1b21d7d4 100644
--- a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts
+++ b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts
@@ -90,6 +90,33 @@ describe('resolveLayout', () => {
expect(a).toEqual(b);
});
+ it('includes precomputed block versions for every supplied block', () => {
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 0, width: 468 }],
+ },
+ ],
+ };
+ const blocks: FlowBlock[] = [
+ { kind: 'paragraph', id: 'p1', runs: [{ text: 'visible', fontFamily: 'Arial', fontSize: 12 }] } as any,
+ { kind: 'paragraph', id: 'p2', runs: [{ text: 'lookup-only', fontFamily: 'Arial', fontSize: 12 }] } as any,
+ ];
+ const measures: Measure[] = [
+ { kind: 'paragraph', lines: [{ lineHeight: 20 }] } as any,
+ { kind: 'paragraph', lines: [{ lineHeight: 20 }] } as any,
+ ];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+
+ expect(result.blockVersions).toBeDefined();
+ expect(result.blockVersions).toHaveProperty('p1');
+ expect(result.blockVersions).toHaveProperty('p2');
+ expect(result.blockVersions?.p1).not.toBe(result.blockVersions?.p2);
+ });
+
it('defaults pageGap to 0 when layout.pageGap is undefined', () => {
const result = resolveLayout({ layout: baseLayout, flowMode: 'paginated', blocks: [], measures: [] });
expect(result.pageGap).toBe(0);
@@ -2610,4 +2637,324 @@ describe('resolveLayout', () => {
expect(item.paragraphBorderHash).toBeUndefined();
});
});
+
+ describe('version signature', () => {
+ it('sets version on paragraph fragment items', () => {
+ const paraFragment: ParaFragment = {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 0,
+ toLine: 1,
+ x: 72,
+ y: 0,
+ width: 468,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [paraFragment] }],
+ };
+ const blocks: FlowBlock[] = [
+ { kind: 'paragraph', id: 'p1', runs: [{ text: 'hello', fontFamily: 'Arial', fontSize: 12 }] } as any,
+ ];
+ const measures: Measure[] = [{ kind: 'paragraph', lines: [{ lineHeight: 20 }] } as any];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as any;
+ expect(item.version).toBeDefined();
+ expect(typeof item.version).toBe('string');
+ expect(item.version.length).toBeGreaterThan(0);
+ });
+
+ it('sets version on table fragment items', () => {
+ const tableFragment: TableFragment = {
+ kind: 'table',
+ blockId: 'tbl1',
+ fromRow: 0,
+ toRow: 1,
+ x: 72,
+ y: 0,
+ width: 468,
+ height: 100,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [tableFragment] }],
+ };
+ const blocks: FlowBlock[] = [{ kind: 'table', id: 'tbl1', rows: [{ cells: [] }] } as any];
+ const measures: Measure[] = [
+ {
+ kind: 'table',
+ columnWidths: [468],
+ rows: [{ cells: [{ width: 468, height: 100 }] }],
+ } as any,
+ ];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as any;
+ expect(item.version).toBeDefined();
+ expect(typeof item.version).toBe('string');
+ });
+
+ it('sets version on image fragment items', () => {
+ const imageFragment: ImageFragment = {
+ kind: 'image',
+ blockId: 'img1',
+ x: 72,
+ y: 0,
+ width: 200,
+ height: 150,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [imageFragment] }],
+ };
+ const blocks: FlowBlock[] = [{ kind: 'image', id: 'img1', src: 'test.png', width: 200, height: 150 } as any];
+ const measures: Measure[] = [{ kind: 'image' } as any];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as any;
+ expect(item.version).toBeDefined();
+ expect(typeof item.version).toBe('string');
+ });
+
+ it('sets version on drawing fragment items', () => {
+ const drawingFragment: DrawingFragment = {
+ kind: 'drawing',
+ blockId: 'dr1',
+ drawingKind: 'image',
+ x: 72,
+ y: 0,
+ width: 200,
+ height: 150,
+ geometry: { width: 200, height: 150 },
+ scale: 1,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [drawingFragment] }],
+ };
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'drawing',
+ drawingKind: 'image',
+ id: 'dr1',
+ src: 'test.png',
+ width: 200,
+ height: 150,
+ geometry: { width: 200, height: 150 },
+ } as any,
+ ];
+ const measures: Measure[] = [{ kind: 'drawing' } as any];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as any;
+ expect(item.version).toBeDefined();
+ expect(typeof item.version).toBe('string');
+ });
+
+ it('sets version on list-item fragment items', () => {
+ const listFragment: ListItemFragment = {
+ kind: 'list-item',
+ blockId: 'list1',
+ itemId: 'item1',
+ fromLine: 0,
+ toLine: 1,
+ x: 72,
+ y: 0,
+ width: 468,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [listFragment] }],
+ };
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'list',
+ id: 'list1',
+ items: [
+ {
+ id: 'item1',
+ marker: { text: '1.' },
+ paragraph: {
+ kind: 'paragraph',
+ id: 'p-item1',
+ runs: [{ text: 'item', fontFamily: 'Arial', fontSize: 12 }],
+ },
+ },
+ ],
+ } as any,
+ ];
+ const measures: Measure[] = [
+ {
+ kind: 'list',
+ items: [{ itemId: 'item1', paragraph: { kind: 'paragraph', lines: [{ lineHeight: 20 }] } }],
+ } as any,
+ ];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const item = result.pages[0].items[0] as any;
+ expect(item.version).toBeDefined();
+ expect(typeof item.version).toBe('string');
+ });
+
+ it('produces different versions when block content changes', () => {
+ const paraFragment: ParaFragment = {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 0,
+ toLine: 1,
+ x: 72,
+ y: 0,
+ width: 468,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [paraFragment] }],
+ };
+ const measures: Measure[] = [{ kind: 'paragraph', lines: [{ lineHeight: 20 }] } as any];
+
+ const blocks1: FlowBlock[] = [
+ { kind: 'paragraph', id: 'p1', runs: [{ text: 'hello', fontFamily: 'Arial', fontSize: 12 }] } as any,
+ ];
+ const blocks2: FlowBlock[] = [
+ { kind: 'paragraph', id: 'p1', runs: [{ text: 'world', fontFamily: 'Arial', fontSize: 12 }] } as any,
+ ];
+
+ const result1 = resolveLayout({ layout, flowMode: 'paginated', blocks: blocks1, measures });
+ const result2 = resolveLayout({ layout, flowMode: 'paginated', blocks: blocks2, measures });
+ const ver1 = (result1.pages[0].items[0] as any).version;
+ const ver2 = (result2.pages[0].items[0] as any).version;
+ expect(ver1).not.toBe(ver2);
+ });
+
+ it('produces same version for identical inputs', () => {
+ const paraFragment: ParaFragment = {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 0,
+ toLine: 1,
+ x: 72,
+ y: 0,
+ width: 468,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [paraFragment] }],
+ };
+ const blocks: FlowBlock[] = [
+ { kind: 'paragraph', id: 'p1', runs: [{ text: 'hello', fontFamily: 'Arial', fontSize: 12 }] } as any,
+ ];
+ const measures: Measure[] = [{ kind: 'paragraph', lines: [{ lineHeight: 20 }] } as any];
+
+ const result1 = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const result2 = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const ver1 = (result1.pages[0].items[0] as any).version;
+ const ver2 = (result2.pages[0].items[0] as any).version;
+ expect(ver1).toBe(ver2);
+ });
+
+ it('produces different versions when fragment line range changes', () => {
+ const fragment1: ParaFragment = {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 0,
+ toLine: 1,
+ x: 72,
+ y: 0,
+ width: 468,
+ };
+ const fragment2: ParaFragment = {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 0,
+ toLine: 2,
+ x: 72,
+ y: 0,
+ width: 468,
+ };
+ const blocks: FlowBlock[] = [
+ { kind: 'paragraph', id: 'p1', runs: [{ text: 'hello', fontFamily: 'Arial', fontSize: 12 }] } as any,
+ ];
+ const measures: Measure[] = [{ kind: 'paragraph', lines: [{ lineHeight: 20 }, { lineHeight: 20 }] } as any];
+
+ const layout1: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [fragment1] }],
+ };
+ const layout2: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [fragment2] }],
+ };
+
+ const result1 = resolveLayout({ layout: layout1, flowMode: 'paginated', blocks, measures });
+ const result2 = resolveLayout({ layout: layout2, flowMode: 'paginated', blocks, measures });
+ const ver1 = (result1.pages[0].items[0] as any).version;
+ const ver2 = (result2.pages[0].items[0] as any).version;
+ expect(ver1).not.toBe(ver2);
+ });
+
+ it('caches block version across fragments sharing the same block', () => {
+ const frag1: ParaFragment = {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 0,
+ toLine: 1,
+ x: 72,
+ y: 0,
+ width: 468,
+ };
+ const frag2: ParaFragment = {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 1,
+ toLine: 2,
+ x: 72,
+ y: 20,
+ width: 468,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [frag1, frag2] }],
+ };
+ const blocks: FlowBlock[] = [
+ { kind: 'paragraph', id: 'p1', runs: [{ text: 'hello world', fontFamily: 'Arial', fontSize: 12 }] } as any,
+ ];
+ const measures: Measure[] = [{ kind: 'paragraph', lines: [{ lineHeight: 20 }, { lineHeight: 20 }] } as any];
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
+ const ver1 = (result.pages[0].items[0] as any).version;
+ const ver2 = (result.pages[0].items[1] as any).version;
+
+ // Both versions should be defined
+ expect(ver1).toBeDefined();
+ expect(ver2).toBeDefined();
+ // They should differ (different line ranges)
+ expect(ver1).not.toBe(ver2);
+ // But both share the same block version prefix
+ const prefix1 = ver1.split('|')[0];
+ const prefix2 = ver2.split('|')[0];
+ expect(prefix1).toBe(prefix2);
+ });
+
+ it('uses "missing" for fragments with no matching block', () => {
+ const paraFragment: ParaFragment = {
+ kind: 'para',
+ blockId: 'nonexistent',
+ fromLine: 0,
+ toLine: 1,
+ x: 72,
+ y: 0,
+ width: 468,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [paraFragment] }],
+ };
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ const item = result.pages[0].items[0] as any;
+ expect(item.version).toBeDefined();
+ expect(item.version).toContain('missing');
+ });
+ });
});
diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts
index de5666c2f9..7e6b56abac 100644
--- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts
+++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts
@@ -28,6 +28,7 @@ import { resolveDrawingItem } from './resolveDrawing.js';
import type { BlockMapEntry } from './resolvedBlockLookup.js';
import { computeSdtContainerKey } from './sdtContainerKey.js';
import { hashParagraphBorders } from './paragraphBorderHash.js';
+import { deriveBlockVersion, fragmentSignature } from './versionSignature.js';
export type ResolveLayoutInput = {
layout: Layout;
@@ -172,25 +173,53 @@ function resolveFragmentSdtContainerKey(fragment: Fragment, blockMap: Map,
+ cache: Map,
+): string {
+ const cached = cache.get(blockId);
+ if (cached !== undefined) return cached;
+ const entry = blockMap.get(blockId);
+ if (!entry) {
+ cache.set(blockId, 'missing');
+ return 'missing';
+ }
+ const version = deriveBlockVersion(entry.block);
+ cache.set(blockId, version);
+ return version;
+}
function resolveFragmentItem(
fragment: Fragment,
fragmentIndex: number,
pageIndex: number,
blockMap: Map,
+ blockVersionCache: Map,
): ResolvedPaintItem {
const sdtContainerKey = resolveFragmentSdtContainerKey(fragment, blockMap);
+ const blockVer = computeBlockVersion(fragment.blockId, blockMap, blockVersionCache);
+ const version = fragmentSignature(fragment, blockVer);
// Route to kind-specific resolvers for types that carry extracted block/measure data.
switch (fragment.kind) {
case 'table': {
const item = resolveTableItem(fragment as TableFragment, fragmentIndex, pageIndex, blockMap);
if (sdtContainerKey != null) item.sdtContainerKey = sdtContainerKey;
+ item.version = version;
+ return item;
+ }
+ case 'image': {
+ const item = resolveImageItem(fragment as ImageFragment, fragmentIndex, pageIndex, blockMap);
+ if (sdtContainerKey != null) item.sdtContainerKey = sdtContainerKey;
+ item.version = version;
+ return item;
+ }
+ case 'drawing': {
+ const item = resolveDrawingItem(fragment as DrawingFragment, fragmentIndex, pageIndex, blockMap);
+ if (sdtContainerKey != null) item.sdtContainerKey = sdtContainerKey;
+ item.version = version;
return item;
}
- case 'image':
- return resolveImageItem(fragment as ImageFragment, fragmentIndex, pageIndex, blockMap);
- case 'drawing':
- return resolveDrawingItem(fragment as DrawingFragment, fragmentIndex, pageIndex, blockMap);
default: {
// para, list-item — existing generic resolution
const item: ResolvedFragmentItem = {
@@ -228,6 +257,7 @@ function resolveFragmentItem(
if (listItem.continuesOnNext != null) item.continuesOnNext = listItem.continuesOnNext;
if (listItem.markerWidth != null) item.markerWidth = listItem.markerWidth;
}
+ item.version = version;
return item;
}
}
@@ -236,6 +266,7 @@ function resolveFragmentItem(
export function resolveLayout(input: ResolveLayoutInput): ResolvedLayout {
const { layout, flowMode, blocks, measures } = input;
const blockMap = buildBlockMap(blocks, measures);
+ const blockVersionCache = new Map();
const pages: ResolvedPage[] = layout.pages.map((page, pageIndex) => ({
id: `page-${pageIndex}`,
@@ -244,7 +275,7 @@ export function resolveLayout(input: ResolveLayoutInput): ResolvedLayout {
width: page.size?.w ?? layout.pageSize.w,
height: page.size?.h ?? layout.pageSize.h,
items: page.fragments.map((fragment, fragmentIndex) =>
- resolveFragmentItem(fragment, fragmentIndex, pageIndex, blockMap),
+ resolveFragmentItem(fragment, fragmentIndex, pageIndex, blockMap, blockVersionCache),
),
margins: page.margins,
footnoteReserved: page.footnoteReserved,
@@ -263,6 +294,12 @@ export function resolveLayout(input: ResolveLayoutInput): ResolvedLayout {
pages,
};
+ if (blocks.length > 0) {
+ resolved.blockVersions = Object.fromEntries(
+ blocks.map((block) => [block.id, computeBlockVersion(block.id, blockMap, blockVersionCache)]),
+ );
+ }
+
if (layout.layoutEpoch != null) {
resolved.layoutEpoch = layout.layoutEpoch;
}
diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.ts b/packages/layout-engine/layout-resolved/src/versionSignature.ts
new file mode 100644
index 0000000000..8b2b15bb15
--- /dev/null
+++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts
@@ -0,0 +1,535 @@
+import type {
+ DrawingBlock,
+ FieldAnnotationRun,
+ FlowBlock,
+ Fragment,
+ ImageBlock,
+ ImageDrawing,
+ ImageRun,
+ ParagraphAttrs,
+ ParagraphBlock,
+ SdtMetadata,
+ ShapeGroupDrawing,
+ TableAttrs,
+ TableBlock,
+ TableCellAttrs,
+ TextRun,
+ VectorShapeDrawing,
+} from '@superdoc/contracts';
+import { hashParagraphBorders } from './paragraphBorderHash.js';
+import {
+ hashCellBorders,
+ hashTableBorders,
+ getRunBooleanProp,
+ getRunNumberProp,
+ getRunStringProp,
+ getRunUnderlineColor,
+ getRunUnderlineStyle,
+} from './hashUtils.js';
+
+// ---------------------------------------------------------------------------
+// SDT metadata helpers
+// ---------------------------------------------------------------------------
+
+const getSdtMetadataId = (metadata: SdtMetadata | null | undefined): string => {
+ if (!metadata) return '';
+ if ('id' in metadata && metadata.id != null) {
+ return String(metadata.id);
+ }
+ return '';
+};
+
+const getSdtMetadataLockMode = (metadata: SdtMetadata | null | undefined): string => {
+ if (!metadata) return '';
+ return metadata.type === 'structuredContent' ? (metadata.lockMode ?? '') : '';
+};
+
+const getSdtMetadataVersion = (metadata: SdtMetadata | null | undefined): string => {
+ if (!metadata) return '';
+ return [metadata.type, getSdtMetadataLockMode(metadata), getSdtMetadataId(metadata)].join(':');
+};
+
+// ---------------------------------------------------------------------------
+// Clip path helpers
+// ---------------------------------------------------------------------------
+
+const CLIP_PATH_PREFIXES = ['inset(', 'polygon(', 'circle(', 'ellipse(', 'path(', 'rect('];
+
+const readClipPathValue = (value: unknown): string => {
+ if (typeof value !== 'string') return '';
+ const normalized = value.trim();
+ if (normalized.length === 0) return '';
+ const lower = normalized.toLowerCase();
+ if (!CLIP_PATH_PREFIXES.some((prefix) => lower.startsWith(prefix))) return '';
+ return normalized;
+};
+
+const resolveClipPathFromAttrs = (attrs: unknown): string => {
+ if (!attrs || typeof attrs !== 'object') return '';
+ const record = attrs as Record;
+ return readClipPathValue(record.clipPath);
+};
+
+const resolveBlockClipPath = (block: unknown): string => {
+ if (!block || typeof block !== 'object') return '';
+ const record = block as Record;
+ return readClipPathValue(record.clipPath) || resolveClipPathFromAttrs(record.attrs);
+};
+
+// ---------------------------------------------------------------------------
+// List marker validation
+// ---------------------------------------------------------------------------
+
+const hasListMarkerProperties = (
+ attrs: unknown,
+): attrs is {
+ numberingProperties: { numId?: number | string; ilvl?: number };
+ wordLayout?: { marker?: { markerText?: string } };
+} => {
+ if (!attrs || typeof attrs !== 'object') return false;
+ const obj = attrs as Record;
+
+ if (!obj.numberingProperties || typeof obj.numberingProperties !== 'object') return false;
+ const numProps = obj.numberingProperties as Record;
+
+ if ('numId' in numProps) {
+ const numId = numProps.numId;
+ if (typeof numId !== 'number' && typeof numId !== 'string') return false;
+ }
+
+ if ('ilvl' in numProps) {
+ const ilvl = numProps.ilvl;
+ if (typeof ilvl !== 'number') return false;
+ }
+
+ if ('wordLayout' in obj && obj.wordLayout !== undefined) {
+ if (typeof obj.wordLayout !== 'object' || obj.wordLayout === null) return false;
+ const wordLayout = obj.wordLayout as Record;
+
+ if ('marker' in wordLayout && wordLayout.marker !== undefined) {
+ if (typeof wordLayout.marker !== 'object' || wordLayout.marker === null) return false;
+ const marker = wordLayout.marker as Record;
+
+ if ('markerText' in marker && marker.markerText !== undefined) {
+ if (typeof marker.markerText !== 'string') return false;
+ }
+ }
+ }
+
+ return true;
+};
+
+// ---------------------------------------------------------------------------
+// FNV-1a hash helpers (for table block hashing)
+// ---------------------------------------------------------------------------
+
+const hashString = (seed: number, value: string): number => {
+ let hash = seed >>> 0;
+ for (let i = 0; i < value.length; i++) {
+ hash ^= value.charCodeAt(i);
+ hash = Math.imul(hash, 16777619);
+ }
+ return hash >>> 0;
+};
+
+const hashNumber = (seed: number, value: number | undefined | null): number => {
+ const n = Number.isFinite(value) ? (value as number) : 0;
+ let hash = seed ^ n;
+ hash = Math.imul(hash, 16777619);
+ hash ^= hash >>> 13;
+ return hash >>> 0;
+};
+
+// ---------------------------------------------------------------------------
+// deriveBlockVersion
+// ---------------------------------------------------------------------------
+
+/**
+ * Derives a version string for a flow block based on its content and styling properties.
+ *
+ * This version string is used for cache invalidation. When any visual property of the block
+ * changes, the version string changes, triggering a DOM rebuild instead of reusing cached elements.
+ *
+ * Duplicated from painters/dom/src/renderer.ts to allow the resolved layout stage to
+ * pre-compute block versions without depending on painter-dom. Keep the two copies in sync
+ * until the painter fully migrates to resolved versions.
+ */
+export const deriveBlockVersion = (block: FlowBlock): string => {
+ if (block.kind === 'paragraph') {
+ const markerVersion = hasListMarkerProperties(block.attrs)
+ ? `marker:${block.attrs.numberingProperties.numId ?? ''}:${block.attrs.numberingProperties.ilvl ?? 0}:${block.attrs.wordLayout?.marker?.markerText ?? ''}`
+ : '';
+
+ const runsVersion = block.runs
+ .map((run) => {
+ if (run.kind === 'image') {
+ const imgRun = run as ImageRun;
+ return [
+ 'img',
+ imgRun.src,
+ imgRun.width,
+ imgRun.height,
+ imgRun.alt ?? '',
+ imgRun.title ?? '',
+ imgRun.clipPath ?? '',
+ imgRun.distTop ?? '',
+ imgRun.distBottom ?? '',
+ imgRun.distLeft ?? '',
+ imgRun.distRight ?? '',
+ readClipPathValue((imgRun as { clipPath?: unknown }).clipPath),
+ ].join(',');
+ }
+
+ if (run.kind === 'lineBreak') {
+ return 'linebreak';
+ }
+
+ if (run.kind === 'tab') {
+ return [run.text ?? '', 'tab'].join(',');
+ }
+
+ if (run.kind === 'fieldAnnotation') {
+ const fieldRun = run as FieldAnnotationRun;
+ const size = fieldRun.size ? `${fieldRun.size.width ?? ''}x${fieldRun.size.height ?? ''}` : '';
+ const highlighted = fieldRun.highlighted !== false ? 1 : 0;
+ return [
+ 'field',
+ fieldRun.variant ?? '',
+ fieldRun.displayLabel ?? '',
+ fieldRun.fieldColor ?? '',
+ fieldRun.borderColor ?? '',
+ highlighted,
+ fieldRun.hidden ? 1 : 0,
+ fieldRun.visibility ?? '',
+ fieldRun.imageSrc ?? '',
+ fieldRun.linkUrl ?? '',
+ fieldRun.rawHtml ?? '',
+ size,
+ fieldRun.fontFamily ?? '',
+ fieldRun.fontSize ?? '',
+ fieldRun.textColor ?? '',
+ fieldRun.textHighlight ?? '',
+ fieldRun.bold ? 1 : 0,
+ fieldRun.italic ? 1 : 0,
+ fieldRun.underline ? 1 : 0,
+ fieldRun.fieldId ?? '',
+ fieldRun.fieldType ?? '',
+ ].join(',');
+ }
+
+ const textRun = run as TextRun;
+ return [
+ textRun.text ?? '',
+ textRun.fontFamily,
+ textRun.fontSize,
+ textRun.bold ? 1 : 0,
+ textRun.italic ? 1 : 0,
+ textRun.color ?? '',
+ textRun.underline?.style ?? '',
+ textRun.underline?.color ?? '',
+ textRun.strike ? 1 : 0,
+ textRun.highlight ?? '',
+ textRun.letterSpacing != null ? textRun.letterSpacing : '',
+ textRun.vertAlign ?? '',
+ textRun.baselineShift != null ? textRun.baselineShift : '',
+ textRun.token ?? '',
+ textRun.trackedChange ? 1 : 0,
+ textRun.comments?.length ?? 0,
+ ].join(',');
+ })
+ .join('|');
+
+ const attrs = block.attrs as ParagraphAttrs | undefined;
+
+ const paragraphAttrsVersion = attrs
+ ? [
+ attrs.alignment ?? '',
+ attrs.spacing?.before ?? '',
+ attrs.spacing?.after ?? '',
+ attrs.spacing?.line ?? '',
+ attrs.spacing?.lineRule ?? '',
+ attrs.indent?.left ?? '',
+ attrs.indent?.right ?? '',
+ attrs.indent?.firstLine ?? '',
+ attrs.indent?.hanging ?? '',
+ attrs.borders ? hashParagraphBorders(attrs.borders) : '',
+ attrs.shading?.fill ?? '',
+ attrs.shading?.color ?? '',
+ attrs.direction ?? '',
+ attrs.rtl ? '1' : '',
+ attrs.tabs?.length ? JSON.stringify(attrs.tabs) : '',
+ ].join(':')
+ : '';
+
+ const sdtAttrs = (block.attrs as ParagraphAttrs | undefined)?.sdt;
+ const sdtVersion = getSdtMetadataVersion(sdtAttrs);
+
+ const parts = [markerVersion, runsVersion, paragraphAttrsVersion, sdtVersion].filter(Boolean);
+ return parts.join('|');
+ }
+
+ if (block.kind === 'list') {
+ return block.items.map((item) => `${item.id}:${item.marker.text}:${deriveBlockVersion(item.paragraph)}`).join('|');
+ }
+
+ if (block.kind === 'image') {
+ const imgSdt = (block as ImageBlock).attrs?.sdt;
+ const imgSdtVersion = getSdtMetadataVersion(imgSdt);
+ return [
+ block.src ?? '',
+ block.width ?? '',
+ block.height ?? '',
+ block.alt ?? '',
+ block.title ?? '',
+ resolveBlockClipPath(block),
+ imgSdtVersion,
+ ].join('|');
+ }
+
+ if (block.kind === 'drawing') {
+ if (block.drawingKind === 'image') {
+ const imageLike = block as ImageDrawing;
+ return [
+ 'drawing:image',
+ imageLike.src ?? '',
+ imageLike.width ?? '',
+ imageLike.height ?? '',
+ imageLike.alt ?? '',
+ resolveBlockClipPath(imageLike),
+ ].join('|');
+ }
+ if (block.drawingKind === 'vectorShape') {
+ const vector = block as VectorShapeDrawing;
+ return [
+ 'drawing:vector',
+ vector.shapeKind ?? '',
+ vector.fillColor ?? '',
+ vector.strokeColor ?? '',
+ vector.strokeWidth ?? '',
+ vector.geometry.width,
+ vector.geometry.height,
+ vector.geometry.rotation ?? 0,
+ vector.geometry.flipH ? 1 : 0,
+ vector.geometry.flipV ? 1 : 0,
+ ].join('|');
+ }
+ if (block.drawingKind === 'shapeGroup') {
+ const group = block as ShapeGroupDrawing;
+ const childSignature = group.shapes
+ .map((child) => `${child.shapeType}:${JSON.stringify(child.attrs ?? {})}`)
+ .join(';');
+ return [
+ 'drawing:group',
+ group.geometry.width,
+ group.geometry.height,
+ group.groupTransform ? JSON.stringify(group.groupTransform) : '',
+ childSignature,
+ ].join('|');
+ }
+ if (block.drawingKind === 'chart') {
+ return [
+ 'drawing:chart',
+ block.chartData?.chartType ?? '',
+ block.chartData?.series?.length ?? 0,
+ block.geometry.width,
+ block.geometry.height,
+ block.chartRelId ?? '',
+ ].join('|');
+ }
+ const _exhaustive: never = block;
+ return `drawing:unknown:${(block as DrawingBlock).id}`;
+ }
+
+ if (block.kind === 'table') {
+ const tableBlock = block as TableBlock;
+
+ let hash = 2166136261;
+ hash = hashString(hash, block.id);
+ hash = hashNumber(hash, tableBlock.rows.length);
+ hash = (tableBlock.columnWidths ?? []).reduce((acc, width) => hashNumber(acc, Math.round(width * 1000)), hash);
+
+ const rows = tableBlock.rows ?? [];
+ for (const row of rows) {
+ if (!row || !Array.isArray(row.cells)) continue;
+ hash = hashNumber(hash, row.cells.length);
+ for (const cell of row.cells) {
+ if (!cell) continue;
+ const cellBlocks = cell.blocks ?? (cell.paragraph ? [cell.paragraph] : []);
+ hash = hashNumber(hash, cellBlocks.length);
+ hash = hashNumber(hash, cell.rowSpan ?? 1);
+ hash = hashNumber(hash, cell.colSpan ?? 1);
+
+ if (cell.attrs) {
+ const cellAttrs = cell.attrs as TableCellAttrs;
+ if (cellAttrs.borders) {
+ hash = hashString(hash, hashCellBorders(cellAttrs.borders));
+ }
+ if (cellAttrs.padding) {
+ const p = cellAttrs.padding;
+ hash = hashNumber(hash, p.top ?? 0);
+ hash = hashNumber(hash, p.right ?? 0);
+ hash = hashNumber(hash, p.bottom ?? 0);
+ hash = hashNumber(hash, p.left ?? 0);
+ }
+ if (cellAttrs.verticalAlign) {
+ hash = hashString(hash, cellAttrs.verticalAlign);
+ }
+ if (cellAttrs.background) {
+ hash = hashString(hash, cellAttrs.background);
+ }
+ }
+
+ for (const cellBlock of cellBlocks) {
+ hash = hashString(hash, cellBlock?.kind ?? 'unknown');
+ if (cellBlock?.kind === 'paragraph') {
+ const paragraphBlock = cellBlock as ParagraphBlock;
+ const runs = paragraphBlock.runs ?? [];
+ hash = hashNumber(hash, runs.length);
+
+ const attrs = paragraphBlock.attrs as ParagraphAttrs | undefined;
+
+ if (attrs) {
+ hash = hashString(hash, attrs.alignment ?? '');
+ hash = hashNumber(hash, attrs.spacing?.before ?? 0);
+ hash = hashNumber(hash, attrs.spacing?.after ?? 0);
+ hash = hashNumber(hash, attrs.spacing?.line ?? 0);
+ hash = hashString(hash, attrs.spacing?.lineRule ?? '');
+ hash = hashNumber(hash, attrs.indent?.left ?? 0);
+ hash = hashNumber(hash, attrs.indent?.right ?? 0);
+ hash = hashNumber(hash, attrs.indent?.firstLine ?? 0);
+ hash = hashNumber(hash, attrs.indent?.hanging ?? 0);
+ hash = hashString(hash, attrs.shading?.fill ?? '');
+ hash = hashString(hash, attrs.shading?.color ?? '');
+ hash = hashString(hash, attrs.direction ?? '');
+ hash = hashString(hash, attrs.rtl ? '1' : '');
+ if (attrs.borders) {
+ hash = hashString(hash, hashParagraphBorders(attrs.borders));
+ }
+ }
+
+ for (const run of runs) {
+ if ('text' in run && typeof run.text === 'string') {
+ hash = hashString(hash, run.text);
+ }
+ hash = hashNumber(hash, run.pmStart ?? -1);
+ hash = hashNumber(hash, run.pmEnd ?? -1);
+
+ hash = hashString(hash, getRunStringProp(run, 'color'));
+ hash = hashString(hash, getRunStringProp(run, 'highlight'));
+ hash = hashString(hash, getRunBooleanProp(run, 'bold') ? '1' : '');
+ hash = hashString(hash, getRunBooleanProp(run, 'italic') ? '1' : '');
+ hash = hashNumber(hash, getRunNumberProp(run, 'fontSize'));
+ hash = hashString(hash, getRunStringProp(run, 'fontFamily'));
+ hash = hashString(hash, getRunUnderlineStyle(run));
+ hash = hashString(hash, getRunUnderlineColor(run));
+ hash = hashString(hash, getRunBooleanProp(run, 'strike') ? '1' : '');
+ hash = hashString(hash, getRunStringProp(run, 'vertAlign'));
+ hash = hashNumber(hash, getRunNumberProp(run, 'baselineShift'));
+ }
+ }
+ }
+ }
+ }
+
+ if (tableBlock.attrs) {
+ const tblAttrs = tableBlock.attrs as TableAttrs;
+ if (tblAttrs.borders) {
+ hash = hashString(hash, hashTableBorders(tblAttrs.borders));
+ }
+ if (tblAttrs.borderCollapse) {
+ hash = hashString(hash, tblAttrs.borderCollapse);
+ }
+ if (tblAttrs.cellSpacing !== undefined) {
+ const cs = tblAttrs.cellSpacing;
+ if (typeof cs === 'number') {
+ hash = hashNumber(hash, cs);
+ } else {
+ const v = (cs as { value?: number; type?: string }).value ?? 0;
+ const t = (cs as { value?: number; type?: string }).type ?? 'px';
+ hash = hashString(hash, `cs:${v}:${t}`);
+ }
+ }
+ if (tblAttrs.sdt) {
+ hash = hashString(hash, tblAttrs.sdt.type);
+ hash = hashString(hash, getSdtMetadataLockMode(tblAttrs.sdt));
+ hash = hashString(hash, getSdtMetadataId(tblAttrs.sdt));
+ }
+ }
+
+ return [block.id, tableBlock.rows.length, hash.toString(16)].join('|');
+ }
+
+ return block.id;
+};
+
+// ---------------------------------------------------------------------------
+// fragmentSignature
+// ---------------------------------------------------------------------------
+
+/**
+ * Computes a change-detection signature for a layout fragment.
+ *
+ * Combines the block-level version with fragment-specific data (line range,
+ * continuation flags, marker width, drawing geometry, table row range, etc.)
+ * so that each fragment has a unique identity for incremental re-rendering.
+ *
+ * Adapted from painters/dom/src/renderer.ts fragmentSignature(). The painter
+ * version accepts a BlockLookup map; this version takes a pre-computed
+ * blockVersion string directly.
+ */
+export const fragmentSignature = (fragment: Fragment, blockVersion: string): string => {
+ if (fragment.kind === 'para') {
+ return [
+ blockVersion,
+ fragment.fromLine,
+ fragment.toLine,
+ fragment.continuesFromPrev ? 1 : 0,
+ fragment.continuesOnNext ? 1 : 0,
+ fragment.markerWidth ?? '',
+ ].join('|');
+ }
+ if (fragment.kind === 'list-item') {
+ return [
+ blockVersion,
+ fragment.itemId,
+ fragment.fromLine,
+ fragment.toLine,
+ fragment.continuesFromPrev ? 1 : 0,
+ fragment.continuesOnNext ? 1 : 0,
+ ].join('|');
+ }
+ if (fragment.kind === 'image') {
+ return [blockVersion, fragment.width, fragment.height].join('|');
+ }
+ if (fragment.kind === 'drawing') {
+ return [
+ blockVersion,
+ fragment.drawingKind,
+ fragment.drawingContentId ?? '',
+ fragment.width,
+ fragment.height,
+ fragment.geometry.width,
+ fragment.geometry.height,
+ fragment.geometry.rotation ?? 0,
+ fragment.scale ?? 1,
+ fragment.zIndex ?? '',
+ ].join('|');
+ }
+ if (fragment.kind === 'table') {
+ const partialSig = fragment.partialRow
+ ? `${fragment.partialRow.fromLineByCell.join(',')}-${fragment.partialRow.toLineByCell.join(',')}-${fragment.partialRow.partialHeight}`
+ : '';
+ return [
+ blockVersion,
+ fragment.fromRow,
+ fragment.toRow,
+ fragment.width,
+ fragment.height,
+ fragment.continuesFromPrev ? 1 : 0,
+ fragment.continuesOnNext ? 1 : 0,
+ fragment.repeatHeaderCount ?? 0,
+ partialSig,
+ ].join('|');
+ }
+ return blockVersion;
+};
diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts
index ecf05ecb8c..fa8d1aa682 100644
--- a/packages/layout-engine/painters/dom/src/index.test.ts
+++ b/packages/layout-engine/painters/dom/src/index.test.ts
@@ -5028,6 +5028,75 @@ describe('DomPainter', () => {
expect(updatedLine.dataset.layoutEpoch).toBe(updatedWrapper.dataset.layoutEpoch);
});
+ it('uses resolved block versions for block change tracking', () => {
+ const blockId = 'resolved-version-block';
+ const paragraphBlock: FlowBlock = {
+ kind: 'paragraph',
+ id: blockId,
+ runs: [{ text: 'Stable content', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 14 }],
+ };
+ const paragraphMeasure: Measure = {
+ kind: 'paragraph',
+ lines: [
+ {
+ fromRun: 0,
+ fromChar: 0,
+ toRun: 0,
+ toChar: 14,
+ width: 100,
+ ascent: 12,
+ descent: 4,
+ lineHeight: 20,
+ },
+ ],
+ totalHeight: 20,
+ };
+ const paragraphLayout: Layout = {
+ pageSize: { w: 400, h: 500 },
+ pages: [
+ {
+ number: 1,
+ fragments: [
+ { kind: 'para', blockId, fromLine: 0, toLine: 1, x: 24, y: 24, width: 300, pmStart: 0, pmEnd: 14 },
+ ],
+ },
+ ],
+ };
+
+ const item = {
+ kind: 'fragment' as const,
+ id: `para:${blockId}:0:1`,
+ pageIndex: 0,
+ x: 24,
+ y: 24,
+ width: 300,
+ height: 20,
+ fragmentKind: 'para' as const,
+ blockId,
+ fragmentIndex: 0,
+ version: 'stable-fragment-version',
+ };
+
+ const painter = createTestPainter({ blocks: [paragraphBlock], measures: [paragraphMeasure] });
+
+ painter.setResolvedLayout({
+ ...createSinglePageResolvedLayout(item),
+ blockVersions: { [blockId]: 'resolved-block-version-1' },
+ });
+ painter.paint(paragraphLayout, mount);
+
+ const initialWrapper = mount.querySelector('.superdoc-fragment') as HTMLElement;
+
+ painter.setResolvedLayout({
+ ...createSinglePageResolvedLayout(item),
+ blockVersions: { [blockId]: 'resolved-block-version-2' },
+ });
+ painter.paint(paragraphLayout, mount);
+
+ const updatedWrapper = mount.querySelector('.superdoc-fragment') as HTMLElement;
+ expect(updatedWrapper).not.toBe(initialWrapper);
+ });
+
it('applies resolved zIndex only to anchored media fragments', () => {
const anchoredDrawingBlock: FlowBlock = {
kind: 'drawing',
diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts
index 6254d066b2..5156b74927 100644
--- a/packages/layout-engine/painters/dom/src/renderer.ts
+++ b/packages/layout-engine/painters/dom/src/renderer.ts
@@ -1611,13 +1611,18 @@ export class DomPainter {
private updateBlockLookup(input: DomPainterInput): void {
const { blocks, measures, headerBlocks, headerMeasures, footerBlocks, footerMeasures } = input;
+ const resolvedBlockVersions = this.resolvedLayout?.blockVersions;
// Build lookup for main document blocks
- const nextLookup = this.buildBlockLookup(blocks, measures);
+ const nextLookup = this.buildBlockLookup(blocks, measures, resolvedBlockVersions);
const normalizedHeader = this.normalizeOptionalBlockMeasurePair('header', headerBlocks, headerMeasures);
if (normalizedHeader) {
- const headerLookup = this.buildBlockLookup(normalizedHeader.blocks, normalizedHeader.measures);
+ const headerLookup = this.buildBlockLookup(
+ normalizedHeader.blocks,
+ normalizedHeader.measures,
+ resolvedBlockVersions,
+ );
headerLookup.forEach((entry, id) => {
nextLookup.set(id, entry);
});
@@ -1625,7 +1630,11 @@ export class DomPainter {
const normalizedFooter = this.normalizeOptionalBlockMeasurePair('footer', footerBlocks, footerMeasures);
if (normalizedFooter) {
- const footerLookup = this.buildBlockLookup(normalizedFooter.blocks, normalizedFooter.measures);
+ const footerLookup = this.buildBlockLookup(
+ normalizedFooter.blocks,
+ normalizedFooter.measures,
+ resolvedBlockVersions,
+ );
footerLookup.forEach((entry, id) => {
nextLookup.set(id, entry);
});
@@ -2799,10 +2808,12 @@ export class DomPainter {
newPmStart != null &&
current.element.dataset.pmStart != null &&
this.currentMapping.map(Number(current.element.dataset.pmStart)) !== newPmStart;
+ const resolvedSig =
+ resolvedItem && 'version' in resolvedItem ? (resolvedItem as { version?: string }).version : undefined;
const needsRebuild =
geometryChanged ||
this.changedBlocks.has(fragment.blockId) ||
- current.signature !== fragmentSignature(fragment, this.blockLookup) ||
+ current.signature !== (resolvedSig ?? fragmentSignature(fragment, this.blockLookup)) ||
sdtBoundaryMismatch ||
betweenBorderMismatch ||
mappingUnreliable;
@@ -2811,7 +2822,7 @@ export class DomPainter {
const replacement = this.renderFragment(fragment, contextBase, sdtBoundary, betweenInfo, resolvedItem);
pageEl.replaceChild(replacement, current.element);
current.element = replacement;
- current.signature = fragmentSignature(fragment, this.blockLookup);
+ current.signature = resolvedSig ?? fragmentSignature(fragment, this.blockLookup);
} else if (this.currentMapping) {
// Fragment NOT rebuilt - update position attributes to reflect document changes
this.updatePositionAttributes(current.element, this.currentMapping);
@@ -2831,11 +2842,13 @@ export class DomPainter {
const fresh = this.renderFragment(fragment, contextBase, sdtBoundary, betweenInfo, resolvedItem);
pageEl.insertBefore(fresh, pageEl.children[index] ?? null);
+ const freshSig =
+ resolvedItem && 'version' in resolvedItem ? (resolvedItem as { version?: string }).version : undefined;
nextFragments.push({
key,
fragment,
element: fresh,
- signature: fragmentSignature(fragment, this.blockLookup),
+ signature: freshSig ?? fragmentSignature(fragment, this.blockLookup),
context: contextBase,
});
});
@@ -2944,9 +2957,11 @@ export class DomPainter {
resolvedItem,
);
el.appendChild(fragmentEl);
+ const initSig =
+ resolvedItem && 'version' in resolvedItem ? (resolvedItem as { version?: string }).version : undefined;
return {
key: fragmentKey(fragment),
- signature: fragmentSignature(fragment, this.blockLookup),
+ signature: initSig ?? fragmentSignature(fragment, this.blockLookup),
fragment,
element: fragmentEl,
context: contextBase,
@@ -7042,7 +7057,11 @@ export class DomPainter {
return 0;
}
- private buildBlockLookup(blocks: FlowBlock[], measures: Measure[]): BlockLookup {
+ private buildBlockLookup(
+ blocks: FlowBlock[],
+ measures: Measure[],
+ precomputedVersions?: Record,
+ ): BlockLookup {
if (blocks.length !== measures.length) {
throw new Error('DomPainter requires the same number of blocks and measures');
}
@@ -7052,7 +7071,7 @@ export class DomPainter {
lookup.set(block.id, {
block,
measure: measures[index],
- version: deriveBlockVersion(block),
+ version: precomputedVersions?.[block.id] ?? deriveBlockVersion(block),
});
});
return lookup;
From e26689eadaadc3250d60371dc549280d6b1e93a1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tadeu=20Tupinamb=C3=A1?=
Date: Wed, 22 Apr 2026 23:55:11 -0300
Subject: [PATCH 24/43] [6/16] refactor(layout): lift paragraph/list block and
measure into resolved items (#2818)
* refactor(layout): lift page metadata into ResolvedPage
* refactor(layout): lift fragment metadata into resolved paint items
Add pmStart, pmEnd, continuesFromPrev, continuesOnNext, markerWidth,
and metadata fields to resolved paint item types. Populate them in
the resolvers and update the painter to prefer resolved item data
over legacy Fragment reads with fallbacks.
* refactor(layout): pre-compute SDT container keys in resolved layout
* refactor(layout): pre-compute paragraph border data in resolved layout
* refactor(layout): move change detection into resolved layout stage
* refactor(layout): lift paragraph and list-item block/measure into resolved items
---
.../contracts/src/resolved-layout.ts | 8 +
.../layout-resolved/src/resolveLayout.test.ts | 156 ++++++++++++++++++
.../layout-resolved/src/resolveLayout.ts | 13 +-
.../painters/dom/src/renderer.ts | 46 ++++--
4 files changed, 208 insertions(+), 15 deletions(-)
diff --git a/packages/layout-engine/contracts/src/resolved-layout.ts b/packages/layout-engine/contracts/src/resolved-layout.ts
index 4701ec17a2..c26cd784a9 100644
--- a/packages/layout-engine/contracts/src/resolved-layout.ts
+++ b/packages/layout-engine/contracts/src/resolved-layout.ts
@@ -5,8 +5,12 @@ import type {
ImageBlock,
ImageFragmentMetadata,
Line,
+ ListBlock,
+ ListMeasure,
PageMargins,
+ ParagraphBlock,
ParagraphBorders,
+ ParagraphMeasure,
SectionVerticalAlign,
TableBlock,
TableMeasure,
@@ -129,6 +133,10 @@ export type ResolvedFragmentItem = {
paragraphBorders?: ParagraphBorders;
/** Pre-computed change-detection signature (blockVersion + fragment-specific data). */
version?: string;
+ /** Pre-extracted block for paragraph (ParagraphBlock) or list-item (ListBlock) fragments. */
+ block?: ParagraphBlock | ListBlock;
+ /** Pre-extracted measure for paragraph (ParagraphMeasure) or list-item (ListMeasure) fragments. */
+ measure?: ParagraphMeasure | ListMeasure;
};
/** Resolved paragraph content for non-table paragraph/list-item fragments. */
diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts
index 2f1b21d7d4..e6245f491a 100644
--- a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts
+++ b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts
@@ -665,6 +665,162 @@ describe('resolveLayout', () => {
});
});
+ describe('paragraph/list-item block and measure lifting', () => {
+ it('lifts block and measure from a paragraph fragment', () => {
+ const paraFragment: ParaFragment = {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 0,
+ toLine: 1,
+ x: 72,
+ y: 100,
+ width: 468,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [paraFragment] }],
+ };
+ const paragraphBlock: FlowBlock = { kind: 'paragraph', id: 'p1', runs: [] };
+ const paragraphMeasure: Measure = {
+ kind: 'paragraph',
+ lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 10, width: 400, ascent: 12, descent: 4, lineHeight: 20 }],
+ totalHeight: 20,
+ };
+
+ const result = resolveLayout({
+ layout,
+ flowMode: 'paginated',
+ blocks: [paragraphBlock],
+ measures: [paragraphMeasure],
+ });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.block).toBe(paragraphBlock);
+ expect(item.measure).toBe(paragraphMeasure);
+ });
+
+ it('lifts block and measure from a list-item fragment', () => {
+ const listItemFragment: ListItemFragment = {
+ kind: 'list-item',
+ blockId: 'list1',
+ itemId: 'item-a',
+ fromLine: 0,
+ toLine: 1,
+ x: 108,
+ y: 200,
+ width: 432,
+ markerWidth: 36,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [listItemFragment] }],
+ };
+ const listBlock: FlowBlock = {
+ kind: 'list',
+ id: 'list1',
+ listType: 'bullet',
+ items: [
+ {
+ id: 'item-a',
+ marker: { text: '•', style: {} },
+ paragraph: { kind: 'paragraph', id: 'item-a-p', runs: [] },
+ },
+ ],
+ };
+ const listMeasure: Measure = {
+ kind: 'list',
+ items: [
+ {
+ itemId: 'item-a',
+ markerWidth: 36,
+ markerTextWidth: 10,
+ indentLeft: 36,
+ paragraph: {
+ kind: 'paragraph',
+ lines: [
+ { fromRun: 0, fromChar: 0, toRun: 0, toChar: 10, width: 400, ascent: 12, descent: 4, lineHeight: 24 },
+ ],
+ totalHeight: 24,
+ },
+ },
+ ],
+ totalHeight: 24,
+ };
+
+ const result = resolveLayout({
+ layout,
+ flowMode: 'paginated',
+ blocks: [listBlock],
+ measures: [listMeasure],
+ });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.block).toBe(listBlock);
+ expect(item.measure).toBe(listMeasure);
+ });
+
+ it('leaves block and measure undefined when the block entry is missing', () => {
+ const paraFragment: ParaFragment = {
+ kind: 'para',
+ blockId: 'missing',
+ fromLine: 0,
+ toLine: 1,
+ x: 72,
+ y: 100,
+ width: 468,
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [paraFragment] }],
+ };
+
+ const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
+ expect(item.block).toBeUndefined();
+ expect(item.measure).toBeUndefined();
+ });
+
+ it('does not set ResolvedFragmentItem.block on table fragments (they use ResolvedTableItem.block)', () => {
+ const tableFragment: TableFragment = {
+ kind: 'table',
+ blockId: 't1',
+ fromRow: 0,
+ toRow: 1,
+ x: 10,
+ y: 20,
+ width: 400,
+ height: 80,
+ columnWidths: [200, 200],
+ };
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [{ number: 1, fragments: [tableFragment] }],
+ };
+ const tableBlock = {
+ kind: 'table' as const,
+ id: 't1',
+ rows: [],
+ columnWidths: [200, 200],
+ };
+ const tableMeasure = {
+ kind: 'table' as const,
+ columnWidths: [200, 200],
+ rows: [],
+ totalHeight: 80,
+ };
+
+ const result = resolveLayout({
+ layout,
+ flowMode: 'paginated',
+ blocks: [tableBlock as any],
+ measures: [tableMeasure as any],
+ });
+ // Table items carry block/measure as ResolvedTableItem typed fields.
+ // They should NOT use the optional ResolvedFragmentItem.block path (no fall-through to the default branch).
+ const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedTableItem;
+ expect(item.fragmentKind).toBe('table');
+ expect(item.block).toBe(tableBlock);
+ expect(item.measure).toBe(tableMeasure);
+ });
+ });
describe('fragment metadata lifting', () => {
it('lifts pmStart and pmEnd from a paragraph fragment', () => {
const paraFragment: ParaFragment = {
diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts
index 7e6b56abac..1c2d870741 100644
--- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts
+++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts
@@ -238,6 +238,18 @@ function resolveFragmentItem(
};
if (sdtContainerKey != null) item.sdtContainerKey = sdtContainerKey;
+ // Pre-extract block/measure for para and list-item fragments so the painter
+ // can prefer resolved data over a blockLookup read.
+ const entry = blockMap.get(fragment.blockId);
+ if (entry) {
+ if (fragment.kind === 'para' && entry.block.kind === 'paragraph' && entry.measure.kind === 'paragraph') {
+ item.block = entry.block as ParagraphBlock;
+ item.measure = entry.measure as ParagraphMeasure;
+ } else if (fragment.kind === 'list-item' && entry.block.kind === 'list' && entry.measure.kind === 'list') {
+ item.block = entry.block as ListBlock;
+ item.measure = entry.measure as ListMeasure;
+ }
+ }
// Pre-compute paragraph border data for between-border grouping
const borders = resolveFragmentParagraphBorders(fragment, blockMap);
if (borders) {
@@ -299,7 +311,6 @@ export function resolveLayout(input: ResolveLayoutInput): ResolvedLayout {
blocks.map((block) => [block.id, computeBlockVersion(block.id, blockMap, blockVersionCache)]),
);
}
-
if (layout.layoutEpoch != null) {
resolved.layoutEpoch = layout.layoutEpoch;
}
diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts
index 5156b74927..2c538cfc34 100644
--- a/packages/layout-engine/painters/dom/src/renderer.ts
+++ b/packages/layout-engine/painters/dom/src/renderer.ts
@@ -3054,17 +3054,26 @@ export class DomPainter {
resolvedItem?: ResolvedFragmentItem,
): HTMLElement {
try {
- const lookup = this.blockLookup.get(fragment.blockId);
- if (!lookup || lookup.block.kind !== 'paragraph' || lookup.measure.kind !== 'paragraph') {
- throw new Error(`DomPainter: missing block/measure for fragment ${fragment.blockId}`);
- }
-
if (!this.doc) {
throw new Error('DomPainter: document is not available');
}
- const block = lookup.block as ParagraphBlock;
- const measure = lookup.measure as ParagraphMeasure;
+ // Prefer pre-extracted block/measure from the resolved item; fall back to blockLookup.
+ let block: ParagraphBlock;
+ let measure: ParagraphMeasure;
+ const resolvedBlock = resolvedItem?.block;
+ const resolvedMeasure = resolvedItem?.measure;
+ if (resolvedBlock?.kind === 'paragraph' && resolvedMeasure?.kind === 'paragraph') {
+ block = resolvedBlock as ParagraphBlock;
+ measure = resolvedMeasure as ParagraphMeasure;
+ } else {
+ const lookup = this.blockLookup.get(fragment.blockId);
+ if (!lookup || lookup.block.kind !== 'paragraph' || lookup.measure.kind !== 'paragraph') {
+ throw new Error(`DomPainter: missing block/measure for fragment ${fragment.blockId}`);
+ }
+ block = lookup.block as ParagraphBlock;
+ measure = lookup.measure as ParagraphMeasure;
+ }
const wordLayout = isMinimalWordLayout(block.attrs?.wordLayout) ? block.attrs.wordLayout : undefined;
const content = resolvedItem?.content;
@@ -3596,17 +3605,26 @@ export class DomPainter {
resolvedItem?: ResolvedFragmentItem,
): HTMLElement {
try {
- const lookup = this.blockLookup.get(fragment.blockId);
- if (!lookup || lookup.block.kind !== 'list' || lookup.measure.kind !== 'list') {
- throw new Error(`DomPainter: missing list data for fragment ${fragment.blockId}`);
- }
-
if (!this.doc) {
throw new Error('DomPainter: document is not available');
}
- const block = lookup.block as ListBlock;
- const measure = lookup.measure as ListMeasure;
+ // Prefer pre-extracted block/measure from the resolved item; fall back to blockLookup.
+ let block: ListBlock;
+ let measure: ListMeasure;
+ const resolvedBlock = resolvedItem?.block;
+ const resolvedMeasure = resolvedItem?.measure;
+ if (resolvedBlock?.kind === 'list' && resolvedMeasure?.kind === 'list') {
+ block = resolvedBlock as ListBlock;
+ measure = resolvedMeasure as ListMeasure;
+ } else {
+ const lookup = this.blockLookup.get(fragment.blockId);
+ if (!lookup || lookup.block.kind !== 'list' || lookup.measure.kind !== 'list') {
+ throw new Error(`DomPainter: missing list data for fragment ${fragment.blockId}`);
+ }
+ block = lookup.block as ListBlock;
+ measure = lookup.measure as ListMeasure;
+ }
const item = block.items.find((entry) => entry.id === fragment.itemId);
const itemMeasure = measure.items.find((entry) => entry.itemId === fragment.itemId);
if (!item || !itemMeasure) {
From 94d47f17cbc5a95750cb01739ec781d09c641845 Mon Sep 17 00:00:00 2001
From: Codex Test
Date: Wed, 22 Apr 2026 20:15:27 -0700
Subject: [PATCH 25/43] ci: poll Labs for release qualification status in
Actions
---
.../release-qualification-dispatch.yml | 92 ++++++++++++++++++-
cicd.md | 6 +-
2 files changed, 93 insertions(+), 5 deletions(-)
diff --git a/.github/workflows/release-qualification-dispatch.yml b/.github/workflows/release-qualification-dispatch.yml
index 309cd81b2f..79566f1b13 100644
--- a/.github/workflows/release-qualification-dispatch.yml
+++ b/.github/workflows/release-qualification-dispatch.yml
@@ -20,8 +20,10 @@ concurrency:
jobs:
dispatch-release-qualification:
+ name: Release Qualification
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
+ timeout-minutes: 10
steps:
- name: Build dispatch payload
env:
@@ -95,6 +97,7 @@ jobs:
RUN_ID="$(jq -r '.run.runId // empty' "${RESPONSE_FILE}")"
RUN_STATUS="$(jq -r '.run.status // empty' "${RESPONSE_FILE}")"
+ RUN_STATUS_MESSAGE="$(jq -r '.run.statusMessage // empty' "${RESPONSE_FILE}")"
CREATED="$(jq -r '.created // false' "${RESPONSE_FILE}")"
if [[ -z "${RUN_ID}" || -z "${RUN_STATUS}" ]]; then
@@ -103,14 +106,98 @@ jobs:
exit 1
fi
+ RUN_STATUS_URL="${LABS_RELEASE_QUALIFICATION_URL%/}/${RUN_ID}"
+
echo "run_id=${RUN_ID}" >> "${GITHUB_OUTPUT}"
echo "run_status=${RUN_STATUS}" >> "${GITHUB_OUTPUT}"
+ echo "run_status_message=${RUN_STATUS_MESSAGE}" >> "${GITHUB_OUTPUT}"
+ echo "run_status_url=${RUN_STATUS_URL}" >> "${GITHUB_OUTPUT}"
echo "created=${CREATED}" >> "${GITHUB_OUTPUT}"
+ - name: Wait for Labs release qualification result
+ id: await
+ env:
+ INITIAL_RUN_STATUS: ${{ steps.dispatch.outputs.run_status }}
+ INITIAL_RUN_STATUS_MESSAGE: ${{ steps.dispatch.outputs.run_status_message }}
+ LABS_RELEASE_QUALIFICATION_TOKEN: ${{ secrets.LABS_RELEASE_QUALIFICATION_TOKEN }}
+ RUN_STATUS_URL: ${{ steps.dispatch.outputs.run_status_url }}
+ run: |
+ set -euo pipefail
+
+ RUN_STATUS="${INITIAL_RUN_STATUS}"
+ RUN_STATUS_MESSAGE="${INITIAL_RUN_STATUS_MESSAGE}"
+
+ while [[ "${RUN_STATUS}" == "queued" || "${RUN_STATUS}" == "in_progress" ]]; do
+ RESPONSE_FILE="$(mktemp)"
+ set +e
+ HTTP_STATUS="$(curl \
+ --fail-with-body \
+ --silent \
+ --show-error \
+ --output "${RESPONSE_FILE}" \
+ --write-out '%{http_code}' \
+ -H "x-labs-internal-token: ${LABS_RELEASE_QUALIFICATION_TOKEN}" \
+ "${RUN_STATUS_URL}")"
+ CURL_EXIT=$?
+ set -e
+
+ if [[ "${CURL_EXIT}" -ne 0 ]]; then
+ cat "${RESPONSE_FILE}"
+ exit "${CURL_EXIT}"
+ fi
+
+ if [[ "${HTTP_STATUS}" -lt 200 || "${HTTP_STATUS}" -ge 300 ]]; then
+ cat "${RESPONSE_FILE}"
+ exit 1
+ fi
+
+ RUN_STATUS="$(jq -r '.run.status // empty' "${RESPONSE_FILE}")"
+ RUN_STATUS_MESSAGE="$(jq -r '.run.statusMessage // empty' "${RESPONSE_FILE}")"
+
+ if [[ -z "${RUN_STATUS}" ]]; then
+ cat "${RESPONSE_FILE}"
+ echo "Labs run lookup did not include a terminal status." >&2
+ exit 1
+ fi
+
+ if [[ "${RUN_STATUS}" == "queued" || "${RUN_STATUS}" == "in_progress" ]]; then
+ sleep 10
+ fi
+ done
+
+ echo "run_status=${RUN_STATUS}" >> "${GITHUB_OUTPUT}"
+ echo "run_status_message=${RUN_STATUS_MESSAGE}" >> "${GITHUB_OUTPUT}"
+
+ - name: Enforce Labs release qualification result
+ env:
+ FINAL_RUN_STATUS: ${{ steps.await.outputs.run_status }}
+ FINAL_RUN_STATUS_MESSAGE: ${{ steps.await.outputs.run_status_message }}
+ run: |
+ set -euo pipefail
+
+ case "${FINAL_RUN_STATUS}" in
+ succeeded)
+ exit 0
+ ;;
+ superseded)
+ echo "${FINAL_RUN_STATUS_MESSAGE:-Release qualification was superseded by a newer run.}"
+ exit 0
+ ;;
+ failed|action_required)
+ echo "${FINAL_RUN_STATUS_MESSAGE:-Release qualification failed.}" >&2
+ exit 1
+ ;;
+ *)
+ echo "Unexpected Labs release qualification status: ${FINAL_RUN_STATUS}" >&2
+ exit 1
+ ;;
+ esac
+
- name: Write workflow summary
+ if: always()
run: |
{
- echo "### Release Qualification Dispatch"
+ echo "### Release Qualification"
echo
echo "| Field | Value |"
echo "| --- | --- |"
@@ -119,6 +206,7 @@ jobs:
echo "| Head branch | \`${{ github.event.pull_request.head.ref }}\` |"
echo "| Head SHA | \`${{ github.event.pull_request.head.sha }}\` |"
echo "| Labs run | \`${{ steps.dispatch.outputs.run_id }}\` |"
- echo "| Labs status | \`${{ steps.dispatch.outputs.run_status }}\` |"
+ echo "| Labs status | \`${{ steps.await.outputs.run_status }}\` |"
+ echo "| Labs status message | ${{ steps.await.outputs.run_status_message || 'n/a' }} |"
echo "| New run created | \`${{ steps.dispatch.outputs.created }}\` |"
} >> "${GITHUB_STEP_SUMMARY}"
diff --git a/cicd.md b/cicd.md
index fdec60dfbd..c2fb25d1d2 100644
--- a/cicd.md
+++ b/cicd.md
@@ -88,8 +88,8 @@ main (next) → stable (latest) → X.x (maintenance)
**Actions**:
- Sends the PR head SHA and branch metadata to the Labs release-orchestrator service
-- Lets Labs create a generic GitHub check run on that SHA
-- Does not wait on or expose any private Labs details in the repository
+- Polls Labs for the terminal release-qualification state
+- Uses the GitHub Actions job itself as the required public status check
- Re-triggers automatically when new commits are pushed to the PR branch
Only same-repository PRs dispatch to Labs. Forked PRs are intentionally skipped so private Labs credentials are never exposed to untrusted branches.
@@ -229,7 +229,7 @@ These skip semantic-release entirely — useful for re-publishing a failed platf
1. Run "Promote to Stable" workflow
2. Review the generated PR from the candidate branch into `stable`
-3. Labs receives the PR head SHA and creates the `Release Qualification` GitHub check run
+3. Labs receives the PR head SHA, records the qualification run, and the workflow job polls Labs for the terminal result
4. If needed, resolve merge conflicts on the candidate branch and push fixes
5. Re-run or wait for qualification on the new PR head SHA
6. Merge the PR into `stable`
From 04aae44595f170f03fa02070cb6780f55c1ce1a8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tadeu=20Tupinamb=C3=A1?=
Date: Thu, 23 Apr 2026 00:17:58 -0300
Subject: [PATCH 26/43] [7/16] refactor(painter): extract block/measure
resolution helper (#2819)
* refactor(layout): lift page metadata into ResolvedPage
* refactor(layout): lift fragment metadata into resolved paint items
Add pmStart, pmEnd, continuesFromPrev, continuesOnNext, markerWidth,
and metadata fields to resolved paint item types. Populate them in
the resolvers and update the painter to prefer resolved item data
over legacy Fragment reads with fallbacks.
* refactor(layout): pre-compute SDT container keys in resolved layout
* refactor(layout): pre-compute paragraph border data in resolved layout
* refactor(layout): move change detection into resolved layout stage
* refactor(layout): lift paragraph and list-item block/measure into resolved items
* refactor(painter): extract block/measure resolution helper
---
.../painters/dom/src/renderer.ts | 123 ++++++++++--------
1 file changed, 69 insertions(+), 54 deletions(-)
diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts
index 2c538cfc34..11d73d3008 100644
--- a/packages/layout-engine/painters/dom/src/renderer.ts
+++ b/packages/layout-engine/painters/dom/src/renderer.ts
@@ -3058,22 +3058,16 @@ export class DomPainter {
throw new Error('DomPainter: document is not available');
}
- // Prefer pre-extracted block/measure from the resolved item; fall back to blockLookup.
- let block: ParagraphBlock;
- let measure: ParagraphMeasure;
- const resolvedBlock = resolvedItem?.block;
- const resolvedMeasure = resolvedItem?.measure;
- if (resolvedBlock?.kind === 'paragraph' && resolvedMeasure?.kind === 'paragraph') {
- block = resolvedBlock as ParagraphBlock;
- measure = resolvedMeasure as ParagraphMeasure;
- } else {
- const lookup = this.blockLookup.get(fragment.blockId);
- if (!lookup || lookup.block.kind !== 'paragraph' || lookup.measure.kind !== 'paragraph') {
- throw new Error(`DomPainter: missing block/measure for fragment ${fragment.blockId}`);
- }
- block = lookup.block as ParagraphBlock;
- measure = lookup.measure as ParagraphMeasure;
- }
+ // Prefer pre-extracted block/measure from the resolved item; fall back to blockLookup
+ // for header/footer fragments that don't have a resolved item.
+ const { block, measure } = this.resolveBlockAndMeasure(
+ fragment,
+ resolvedItem?.block,
+ resolvedItem?.measure,
+ 'paragraph',
+ 'paragraph',
+ 'paragraph block/measure',
+ );
const wordLayout = isMinimalWordLayout(block.attrs?.wordLayout) ? block.attrs.wordLayout : undefined;
const content = resolvedItem?.content;
@@ -3609,22 +3603,16 @@ export class DomPainter {
throw new Error('DomPainter: document is not available');
}
- // Prefer pre-extracted block/measure from the resolved item; fall back to blockLookup.
- let block: ListBlock;
- let measure: ListMeasure;
- const resolvedBlock = resolvedItem?.block;
- const resolvedMeasure = resolvedItem?.measure;
- if (resolvedBlock?.kind === 'list' && resolvedMeasure?.kind === 'list') {
- block = resolvedBlock as ListBlock;
- measure = resolvedMeasure as ListMeasure;
- } else {
- const lookup = this.blockLookup.get(fragment.blockId);
- if (!lookup || lookup.block.kind !== 'list' || lookup.measure.kind !== 'list') {
- throw new Error(`DomPainter: missing list data for fragment ${fragment.blockId}`);
- }
- block = lookup.block as ListBlock;
- measure = lookup.measure as ListMeasure;
- }
+ // Prefer pre-extracted block/measure from the resolved item; fall back to blockLookup
+ // for header/footer fragments that don't have a resolved item.
+ const { block, measure } = this.resolveBlockAndMeasure(
+ fragment,
+ resolvedItem?.block,
+ resolvedItem?.measure,
+ 'list',
+ 'list',
+ 'list block/measure',
+ );
const item = block.items.find((entry) => entry.id === fragment.itemId);
const itemMeasure = measure.items.find((entry) => entry.itemId === fragment.itemId);
if (!item || !itemMeasure) {
@@ -3759,17 +3747,9 @@ export class DomPainter {
resolvedItem?: ResolvedImageItem,
): HTMLElement {
try {
- // Use pre-extracted block from resolved item; fall back to blockLookup when resolved item
- // is a legacy ResolvedFragmentItem without the block field.
- const block: ImageBlock =
- resolvedItem?.block ??
- (() => {
- const lookup = this.blockLookup.get(fragment.blockId);
- if (!lookup || lookup.block.kind !== 'image' || lookup.measure.kind !== 'image') {
- throw new Error(`DomPainter: missing image block for fragment ${fragment.blockId}`);
- }
- return lookup.block as ImageBlock;
- })();
+ // Prefer pre-extracted block from the resolved item; fall back to blockLookup
+ // for header/footer fragments that don't have a resolved item.
+ const block = this.resolveBlock(fragment, resolvedItem?.block, 'image', 'image block');
if (!this.doc) {
throw new Error('DomPainter: document is not available');
@@ -3967,17 +3947,9 @@ export class DomPainter {
resolvedItem?: ResolvedDrawingItem,
): HTMLElement {
try {
- // Use pre-extracted block from resolved item; fall back to blockLookup when resolved item
- // is a legacy ResolvedFragmentItem without the block field.
- const block: DrawingBlock =
- resolvedItem?.block ??
- (() => {
- const lookup = this.blockLookup.get(fragment.blockId);
- if (!lookup || lookup.block.kind !== 'drawing' || lookup.measure.kind !== 'drawing') {
- throw new Error(`DomPainter: missing drawing block for fragment ${fragment.blockId}`);
- }
- return lookup.block as DrawingBlock;
- })();
+ // Prefer pre-extracted block from the resolved item; fall back to blockLookup
+ // for header/footer fragments that don't have a resolved item.
+ const block = this.resolveBlock(fragment, resolvedItem?.block, 'drawing', 'drawing block');
if (!this.doc) {
throw new Error('DomPainter: document is not available');
}
@@ -7075,6 +7047,49 @@ export class DomPainter {
return 0;
}
+ /**
+ * Resolves the block + measure pair for a fragment. Body fragments get these from the
+ * ResolvedFragmentItem; header/footer fragments fall back to the blockLookup map.
+ */
+ private resolveBlockAndMeasure(
+ fragment: { blockId: string },
+ resolvedBlock: FlowBlock | undefined,
+ resolvedMeasure: Measure | undefined,
+ blockKind: B['kind'],
+ measureKind: M['kind'],
+ errorLabel: string,
+ ): { block: B; measure: M } {
+ if (resolvedBlock?.kind === blockKind && resolvedMeasure?.kind === measureKind) {
+ return { block: resolvedBlock as B, measure: resolvedMeasure as M };
+ }
+ const lookup = this.blockLookup.get(fragment.blockId);
+ if (!lookup || lookup.block.kind !== blockKind || lookup.measure.kind !== measureKind) {
+ throw new Error(`DomPainter: missing ${errorLabel} for fragment ${fragment.blockId}`);
+ }
+ return { block: lookup.block as B, measure: lookup.measure as M };
+ }
+
+ /**
+ * Resolves only the block for a fragment (image/drawing rendering doesn't consume the measure).
+ * Body fragments get this from the ResolvedImageItem/ResolvedDrawingItem; header/footer
+ * fragments fall back to the blockLookup map.
+ */
+ private resolveBlock(
+ fragment: { blockId: string },
+ resolvedBlock: B | undefined,
+ blockKind: B['kind'],
+ errorLabel: string,
+ ): B {
+ if (resolvedBlock?.kind === blockKind) {
+ return resolvedBlock;
+ }
+ const lookup = this.blockLookup.get(fragment.blockId);
+ if (!lookup || lookup.block.kind !== blockKind) {
+ throw new Error(`DomPainter: missing ${errorLabel} for fragment ${fragment.blockId}`);
+ }
+ return lookup.block as B;
+ }
+
private buildBlockLookup(
blocks: FlowBlock[],
measures: Measure[],
From 55bf52ef1ca9d6c5c116535e71c8c0d496deeb1d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tadeu=20Tupinamb=C3=A1?=
Date: Thu, 23 Apr 2026 01:15:29 -0300
Subject: [PATCH 27/43] [8/16] refactor(painter): remove body blocks/measures
from DomPainterInput (#2820)
* refactor(layout): lift page metadata into ResolvedPage
* refactor(layout): lift fragment metadata into resolved paint items
Add pmStart, pmEnd, continuesFromPrev, continuesOnNext, markerWidth,
and metadata fields to resolved paint item types. Populate them in
the resolvers and update the painter to prefer resolved item data
over legacy Fragment reads with fallbacks.
* refactor(layout): pre-compute SDT container keys in resolved layout
* refactor(layout): pre-compute paragraph border data in resolved layout
* refactor(layout): move change detection into resolved layout stage
* refactor(layout): lift paragraph and list-item block/measure into resolved items
* refactor(painter): extract block/measure resolution helper
* refactor(painter): remove body blocks/measures from DomPainterInput
Body block and measure data now flows exclusively through the resolved
layout. The painter only builds a blockLookup from header/footer data,
which is the last remaining fallback surface for fragments that do not
yet have a resolved path. Complex-transaction rebuild detection now
walks the resolved layout items directly instead of iterating the body
blockLookup.
The legacy createDomPainter wrapper derives a resolved layout from
its legacyState blocks/measures on the fly so the benchmark path and
direct createDomPainter(options).paint(Layout) callers keep working
without setResolvedLayout.
* fix: dompainter body input contract on first paint
---
.../layout-resolved/src/resolveLayout.ts | 3 +
.../layout-engine/painters/dom/package.json | 1 +
.../painters/dom/src/index.test.ts | 68 ++++++++++++-----
.../layout-engine/painters/dom/src/index.ts | 73 +++++++++++++++++--
.../painters/dom/src/renderer.ts | 25 +++++--
.../painters/dom/src/virtualization.test.ts | 16 +++-
.../layout-engine/painters/dom/tsconfig.json | 1 +
.../presentation-editor/PresentationEditor.ts | 20 ++---
.../tests/PresentationEditor.test.ts | 31 ++++++++
pnpm-lock.yaml | 3 +
10 files changed, 198 insertions(+), 43 deletions(-)
diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts
index 1c2d870741..771ce6c067 100644
--- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts
+++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts
@@ -189,6 +189,7 @@ function computeBlockVersion(
cache.set(blockId, version);
return version;
}
+
function resolveFragmentItem(
fragment: Fragment,
fragmentIndex: number,
@@ -250,12 +251,14 @@ function resolveFragmentItem(
item.measure = entry.measure as ListMeasure;
}
}
+
// Pre-compute paragraph border data for between-border grouping
const borders = resolveFragmentParagraphBorders(fragment, blockMap);
if (borders) {
item.paragraphBorders = borders;
item.paragraphBorderHash = hashParagraphBorders(borders);
}
+
if (fragment.kind === 'para') {
const para = fragment as ParaFragment;
if (para.pmStart != null) item.pmStart = para.pmStart;
diff --git a/packages/layout-engine/painters/dom/package.json b/packages/layout-engine/painters/dom/package.json
index b5757c3844..940b673d32 100644
--- a/packages/layout-engine/painters/dom/package.json
+++ b/packages/layout-engine/painters/dom/package.json
@@ -21,6 +21,7 @@
"@superdoc/contracts": "workspace:*",
"@superdoc/dom-contract": "workspace:*",
"@superdoc/font-utils": "workspace:*",
+ "@superdoc/layout-resolved": "workspace:*",
"@superdoc/preset-geometry": "workspace:*",
"@superdoc/url-validation": "workspace:*"
},
diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts
index fa8d1aa682..b52913da70 100644
--- a/packages/layout-engine/painters/dom/src/index.test.ts
+++ b/packages/layout-engine/painters/dom/src/index.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
import { createDomPainter, sanitizeUrl, linkMetrics, applyRunDataAttributes } from './index.js';
import { DomPainter } from './renderer.js';
+import { resolveLayout } from '@superdoc/layout-resolved';
import type { DomPainterOptions, DomPainterInput, PaintSnapshot } from './index.js';
import { resolveListMarkerGeometry } from '../../../../../shared/common/list-marker-utils.js';
import type {
@@ -42,17 +43,38 @@ function createTestPainter(opts: { blocks?: FlowBlock[]; measures?: Measure[] }
let footerBlocks: FlowBlock[] | undefined;
let footerMeasures: Measure[] | undefined;
+ let resolvedLayoutOverridden = false;
+
return {
paint(layout: Layout, mount: HTMLElement, mapping?: unknown) {
+ const effectiveResolved = resolvedLayoutOverridden
+ ? currentResolved
+ : resolveLayout({
+ layout,
+ flowMode: opts.flowMode ?? 'paginated',
+ blocks: currentBlocks,
+ measures: currentMeasures,
+ });
+ // Tests historically pass header/footer blocks via the main `blocks` array and
+ // rely on the blockLookup containing them. Merge body blocks into headerBlocks
+ // so header/footer fragments from providers can resolve their block data.
+ const mergedHeaderBlocks =
+ headerBlocks || currentBlocks.length > 0 ? [...currentBlocks, ...(headerBlocks ?? [])] : undefined;
+ const mergedHeaderMeasures =
+ headerMeasures || currentMeasures.length > 0 ? [...currentMeasures, ...(headerMeasures ?? [])] : undefined;
+ const mergedFooterBlocks =
+ footerBlocks || currentBlocks.length > 0 ? [...currentBlocks, ...(footerBlocks ?? [])] : undefined;
+ const mergedFooterMeasures =
+ footerMeasures || currentMeasures.length > 0 ? [...currentMeasures, ...(footerMeasures ?? [])] : undefined;
const input: DomPainterInput = {
- resolvedLayout: currentResolved,
+ resolvedLayout: effectiveResolved,
sourceLayout: layout,
blocks: currentBlocks,
measures: currentMeasures,
- headerBlocks,
- headerMeasures,
- footerBlocks,
- footerMeasures,
+ headerBlocks: mergedHeaderBlocks,
+ headerMeasures: mergedHeaderMeasures,
+ footerBlocks: mergedFooterBlocks,
+ footerMeasures: mergedFooterMeasures,
};
painter.paint(input, mount, mapping as any);
},
@@ -73,6 +95,7 @@ function createTestPainter(opts: { blocks?: FlowBlock[]; measures?: Measure[] }
},
setResolvedLayout(rl: ResolvedLayout | null) {
currentResolved = rl ?? emptyResolved;
+ resolvedLayoutOverridden = true;
},
setProviders: painter.setProviders,
setVirtualizationPins: painter.setVirtualizationPins,
@@ -1357,7 +1380,10 @@ describe('DomPainter', () => {
expect(lines[1].style.wordSpacing).toBe('');
});
- it('renders an error placeholder when a legacy table fragment is missing its lookup entry', () => {
+ it('surfaces a missing-block error from resolveLayout when a table fragment references an unknown block', () => {
+ // Previous behavior: painter rendered a placeholder for missing lookup entries.
+ // New behavior: resolveLayout validates block/measure integrity upstream and throws
+ // before the painter runs. Missing-block bugs are now caught at the resolved stage.
const missingTableLayout: Layout = {
pageSize: { w: 300, h: 300 },
pages: [
@@ -1379,19 +1405,8 @@ describe('DomPainter', () => {
],
};
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {
- // Intentionally empty - suppress expected error logging during this regression test.
- });
-
const painter = createTestPainter({ blocks: [], measures: [] });
- expect(() => painter.paint(missingTableLayout, mount)).not.toThrow();
-
- const placeholder = mount.querySelector('.render-error-placeholder') as HTMLElement | null;
- expect(placeholder).toBeTruthy();
- expect(placeholder?.textContent).toContain('[Render Error: missing-table]');
- expect(consoleErrorSpy).toHaveBeenCalled();
-
- consoleErrorSpy.mockRestore();
+ expect(() => painter.paint(missingTableLayout, mount)).toThrow(/Missing block\/measure/);
});
it('renders an error placeholder when table-cell line rendering throws', () => {
@@ -1680,8 +1695,23 @@ describe('DomPainter', () => {
});
it('throws if blocks and measures length mismatch', () => {
+ // Block/measure integrity is now validated at the resolve-layout stage.
const painter = createTestPainter({ blocks: [block], measures: [] });
- expect(() => painter.paint(layout, mount)).toThrow(/same number of blocks/);
+ expect(() => painter.paint(layout, mount)).toThrow();
+ });
+
+ it('rejects resolved-layout-only paint input until body lookups are removed', () => {
+ const painter = createDomPainter({});
+
+ expect(() =>
+ painter.paint(
+ {
+ resolvedLayout: emptyResolved,
+ sourceLayout: layout,
+ } as DomPainterInput,
+ mount,
+ ),
+ ).toThrow('DomPainterInput requires body blocks and measures');
});
it('renders placeholder content for empty lines', () => {
diff --git a/packages/layout-engine/painters/dom/src/index.ts b/packages/layout-engine/painters/dom/src/index.ts
index fcbe74c7f4..8acc396001 100644
--- a/packages/layout-engine/painters/dom/src/index.ts
+++ b/packages/layout-engine/painters/dom/src/index.ts
@@ -1,5 +1,6 @@
import type { FlowBlock, Fragment, Layout, Measure, Page, PageMargins, ResolvedLayout } from '@superdoc/contracts';
import { DomPainter } from './renderer.js';
+import { resolveLayout } from '@superdoc/layout-resolved';
import type { PageStyles } from './styles.js';
import type { DomPainterInput, PaintSnapshot, PositionMapping, RulerOptions, FlowMode } from './renderer.js';
@@ -144,6 +145,11 @@ type BlockMeasurePair = {
measures: Measure[];
};
+type DomPainterInputCandidate = Partial & {
+ resolvedLayout?: ResolvedLayout;
+ sourceLayout?: Layout;
+};
+
export type DomPainterHandle = {
paint(input: DomPainterInput | Layout, mount: HTMLElement, mapping?: PositionMapping): void;
/**
@@ -177,6 +183,19 @@ function assertRequiredBlockMeasurePair(label: string, blocks: FlowBlock[], meas
}
}
+function normalizeRequiredBlockMeasurePair(
+ label: 'body',
+ blocks: FlowBlock[] | undefined,
+ measures: Measure[] | undefined,
+): BlockMeasurePair {
+ if (!Array.isArray(blocks) || !Array.isArray(measures)) {
+ throw new Error('DomPainterInput requires body blocks and measures; resolved-layout-only input is not supported.');
+ }
+
+ assertRequiredBlockMeasurePair(label, blocks, measures);
+ return { blocks, measures };
+}
+
function normalizeOptionalBlockMeasurePair(
label: 'header' | 'footer',
blocks: FlowBlock[] | undefined,
@@ -193,6 +212,10 @@ function normalizeOptionalBlockMeasurePair(
return undefined;
}
+ if (!Array.isArray(blocks) || !Array.isArray(measures)) {
+ throw new Error(`${label}Blocks and ${label}Measures must be arrays when provided.`);
+ }
+
assertRequiredBlockMeasurePair(label, blocks, measures);
return { blocks, measures };
}
@@ -206,8 +229,29 @@ function createEmptyResolvedLayout(flowMode: FlowMode | undefined, pageGap: numb
};
}
-function isDomPainterInput(value: DomPainterInput | Layout): value is DomPainterInput {
- return 'resolvedLayout' in value && 'sourceLayout' in value && 'blocks' in value && 'measures' in value;
+function isLegacyLayoutInput(value: DomPainterInput | Layout): value is Layout {
+ return 'pages' in value;
+}
+
+function normalizeDomPainterInput(input: DomPainterInputCandidate): DomPainterInput {
+ if (!input.resolvedLayout || !input.sourceLayout) {
+ throw new Error('DomPainterInput requires resolvedLayout and sourceLayout.');
+ }
+
+ const body = normalizeRequiredBlockMeasurePair('body', input.blocks, input.measures);
+ const header = normalizeOptionalBlockMeasurePair('header', input.headerBlocks, input.headerMeasures);
+ const footer = normalizeOptionalBlockMeasurePair('footer', input.footerBlocks, input.footerMeasures);
+
+ return {
+ resolvedLayout: input.resolvedLayout,
+ sourceLayout: input.sourceLayout,
+ blocks: body.blocks,
+ measures: body.measures,
+ headerBlocks: header?.blocks,
+ headerMeasures: header?.measures,
+ footerBlocks: footer?.blocks,
+ footerMeasures: footer?.measures,
+ };
}
function buildLegacyPaintInput(
@@ -216,8 +260,25 @@ function buildLegacyPaintInput(
flowMode: FlowMode | undefined,
pageGap: number | undefined,
): DomPainterInput {
+ // Derive a resolved layout from the legacy block/measure state when the caller
+ // has not supplied one via `setResolvedLayout`. The painter now reads all body
+ // fragment data from the resolved layout, so an empty resolved layout would
+ // produce a blank render.
+ let resolvedLayout: ResolvedLayout;
+ if (legacyState.resolvedLayout) {
+ resolvedLayout = legacyState.resolvedLayout;
+ } else if (legacyState.blocks.length === 0 && legacyState.measures.length === 0) {
+ resolvedLayout = createEmptyResolvedLayout(flowMode, pageGap);
+ } else {
+ resolvedLayout = resolveLayout({
+ layout,
+ flowMode: flowMode ?? 'paginated',
+ blocks: legacyState.blocks,
+ measures: legacyState.measures,
+ });
+ }
return {
- resolvedLayout: legacyState.resolvedLayout ?? createEmptyResolvedLayout(flowMode, pageGap),
+ resolvedLayout,
sourceLayout: layout,
blocks: legacyState.blocks,
measures: legacyState.measures,
@@ -253,9 +314,9 @@ export const createDomPainter = (options: DomPainterOptions): DomPainterHandle =
return {
paint(input: DomPainterInput | Layout, mount: HTMLElement, mapping?: PositionMapping) {
- const normalizedInput = isDomPainterInput(input)
- ? input
- : buildLegacyPaintInput(input, legacyState, options.flowMode, options.pageGap);
+ const normalizedInput = isLegacyLayoutInput(input)
+ ? buildLegacyPaintInput(input, legacyState, options.flowMode, options.pageGap)
+ : normalizeDomPainterInput(input);
painter.paint(normalizedInput, mount, mapping);
},
setData(
diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts
index 11d73d3008..29f5d5f2b1 100644
--- a/packages/layout-engine/painters/dom/src/renderer.ts
+++ b/packages/layout-engine/painters/dom/src/renderer.ts
@@ -250,17 +250,20 @@ export type RenderedLineInfo = {
* Input to `DomPainter.paint()`.
*
* `resolvedLayout` is the canonical resolved data. The remaining fields are
- * bridge data carried for internal rendering of non-paragraph fragments
- * (tables, images, drawings) that have not yet been migrated to resolved items.
+ * still required bridge data until the painter can render solely from resolved
+ * items for lookups, change tracking, and non-paragraph fragment rendering.
*/
export type DomPainterInput = {
resolvedLayout: ResolvedLayout;
- /** Raw Layout for internal fragment access (bridge — will be removed once all fragment types are resolved). */
+ /** Raw Layout for internal fragment access (bridge, will be removed once render loops iterate resolved items). */
sourceLayout: Layout;
+ /** Main document blocks/measures used for lookups and version tracking. */
blocks: FlowBlock[];
measures: Measure[];
+ /** Header block data (still needed for decoration rendering, no resolved path yet). */
headerBlocks?: FlowBlock[];
headerMeasures?: Measure[];
+ /** Footer block data (still needed for decoration rendering, no resolved path yet). */
footerBlocks?: FlowBlock[];
footerMeasures?: Measure[];
};
@@ -1640,7 +1643,7 @@ export class DomPainter {
});
}
- // Track changed blocks
+ // Track changed blocks (decoration only now, body change detection uses resolved version)
const changed = new Set();
nextLookup.forEach((entry, id) => {
const previous = this.blockLookup.get(id);
@@ -1674,7 +1677,13 @@ export class DomPainter {
// Complex transactions (paste, multi-step replace, etc.) fall back to full rebuild.
const isSimpleTransaction = mapping && mapping.maps.length === 1;
if (mapping && !isSimpleTransaction) {
- // Complex transaction - force all fragments to rebuild (safe fallback)
+ // Complex transaction, force all body fragments to rebuild (safe fallback).
+ for (const page of input.resolvedLayout.pages) {
+ for (const item of page.items) {
+ if ('blockId' in item) this.changedBlocks.add(item.blockId);
+ }
+ }
+ // Also mark all header/footer blocks as changed.
this.blockLookup.forEach((_, id) => this.changedBlocks.add(id));
this.currentMapping = null;
} else {
@@ -2426,6 +2435,7 @@ export class DomPainter {
return separatorPositions;
}
+
private renderDecorationsForPage(
pageEl: HTMLElement,
page: Page,
@@ -5059,6 +5069,11 @@ export class DomPainter {
// Inner cell fragments still use legacy applyFragmentFrame via deps closure.
if (resolvedItem) {
this.applyResolvedFragmentFrame(el, resolvedItem, fragment, context.section);
+ // Re-apply the SDT group width override after the resolved frame, so block-SDT
+ // containers can stretch table fragments to match sibling paragraph widths.
+ if (sdtBoundary?.widthOverride != null) {
+ el.style.width = `${sdtBoundary.widthOverride}px`;
+ }
}
return el;
diff --git a/packages/layout-engine/painters/dom/src/virtualization.test.ts b/packages/layout-engine/painters/dom/src/virtualization.test.ts
index 97f27781e7..68c96d6542 100644
--- a/packages/layout-engine/painters/dom/src/virtualization.test.ts
+++ b/packages/layout-engine/painters/dom/src/virtualization.test.ts
@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createDomPainter } from './index.js';
+import { resolveLayout } from '@superdoc/layout-resolved';
import type { DomPainterOptions, DomPainterInput, PaintSnapshot } from './index.js';
import type { FlowBlock, Measure, Layout, Fragment, PageMargins, ResolvedLayout } from '@superdoc/contracts';
@@ -21,11 +22,24 @@ function createTestPainter(opts: { blocks?: FlowBlock[]; measures?: Measure[] }
return {
paint(layout: Layout, mount: HTMLElement, mapping?: unknown) {
+ const effectiveResolved =
+ currentBlocks.length === 0 && currentMeasures.length === 0
+ ? currentResolved
+ : resolveLayout({
+ layout,
+ flowMode: opts.flowMode ?? 'paginated',
+ blocks: currentBlocks,
+ measures: currentMeasures,
+ });
const input: DomPainterInput = {
- resolvedLayout: currentResolved,
+ resolvedLayout: effectiveResolved,
sourceLayout: layout,
blocks: currentBlocks,
measures: currentMeasures,
+ headerBlocks: undefined,
+ headerMeasures: undefined,
+ footerBlocks: undefined,
+ footerMeasures: undefined,
};
painter.paint(input, mount, mapping as any);
},
diff --git a/packages/layout-engine/painters/dom/tsconfig.json b/packages/layout-engine/painters/dom/tsconfig.json
index e1df276edc..bf7c501521 100644
--- a/packages/layout-engine/painters/dom/tsconfig.json
+++ b/packages/layout-engine/painters/dom/tsconfig.json
@@ -12,6 +12,7 @@
"references": [
{ "path": "../../contracts/tsconfig.json" },
{ "path": "../../dom-contract/tsconfig.json" },
+ { "path": "../../layout-resolved/tsconfig.json" },
{ "path": "../../measuring/dom/tsconfig.json" },
{ "path": "../../../../shared/common/tsconfig.json" }
]
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts
index 46b51c0c6e..9eb6c38752 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts
@@ -4278,6 +4278,8 @@ export class PresentationEditor extends EventEmitter {
let layout: Layout;
let measures: Measure[];
let resolvedLayout: ReturnType;
+ let bodyBlocksForPaint: FlowBlock[] = blocksForLayout;
+ let bodyMeasuresForPaint: Measure[] = [];
let headerLayouts: HeaderFooterLayoutResult[] | undefined;
let footerLayouts: HeaderFooterLayoutResult[] | undefined;
let extraBlocks: FlowBlock[] | undefined;
@@ -4321,14 +4323,14 @@ export class PresentationEditor extends EventEmitter {
// Include footnote-injected blocks (separators, footnote paragraphs) so
// resolveLayout can find them when resolving page fragments.
- const resolveBlocks = extraBlocks ? [...blocksForLayout, ...extraBlocks] : blocksForLayout;
- const resolveMeasures = extraMeasures ? [...measures, ...extraMeasures] : measures;
+ bodyBlocksForPaint = extraBlocks ? [...blocksForLayout, ...extraBlocks] : blocksForLayout;
+ bodyMeasuresForPaint = extraMeasures ? [...measures, ...extraMeasures] : measures;
resolvedLayout = resolveLayout({
layout,
flowMode: this.#layoutOptions.flowMode ?? 'paginated',
- blocks: resolveBlocks,
- measures: resolveMeasures,
+ blocks: bodyBlocksForPaint,
+ measures: bodyMeasuresForPaint,
});
headerLayouts = result.headers;
@@ -4440,12 +4442,6 @@ export class PresentationEditor extends EventEmitter {
}
}
- // Merge any extra lookup blocks (e.g., footnotes injected into page fragments)
- if (extraBlocks && extraMeasures && extraBlocks.length === extraMeasures.length && extraBlocks.length > 0) {
- footerBlocks.push(...extraBlocks);
- footerMeasures.push(...extraMeasures);
- }
-
// Avoid MutationObserver overhead while repainting large DOM trees.
this.#domIndexObserverManager?.pause();
// Pass the transaction mapping for efficient position attribute updates.
@@ -4456,8 +4452,8 @@ export class PresentationEditor extends EventEmitter {
const paintInput: DomPainterInput = {
resolvedLayout,
sourceLayout: layout,
- blocks: blocksForLayout,
- measures,
+ blocks: bodyBlocksForPaint,
+ measures: bodyMeasuresForPaint,
headerBlocks: headerBlocks.length > 0 ? headerBlocks : undefined,
headerMeasures: headerMeasures.length > 0 ? headerMeasures : undefined,
footerBlocks: footerBlocks.length > 0 ? footerBlocks : undefined,
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts
index 60e212d368..70546b08bf 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts
@@ -423,6 +423,37 @@ describe('PresentationEditor', () => {
});
describe('semantic flow mode configuration', () => {
+ it('passes body blocks and measures to the painter on blank-document render', async () => {
+ editor = new PresentationEditor({
+ element: container,
+ documentId: 'blank-render-contract-doc',
+ });
+
+ await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled());
+
+ const painterInstance = (mockCreateDomPainter as unknown as Mock).mock.results[
+ (mockCreateDomPainter as unknown as Mock).mock.results.length - 1
+ ].value as {
+ paint: Mock;
+ };
+
+ await vi.waitFor(() => expect(painterInstance.paint).toHaveBeenCalled());
+
+ const [paintInput] = painterInstance.paint.mock.calls[painterInstance.paint.mock.calls.length - 1] as [
+ {
+ blocks: unknown[];
+ measures: unknown[];
+ resolvedLayout: unknown;
+ sourceLayout: unknown;
+ },
+ ];
+
+ expect(paintInput.blocks).toEqual([]);
+ expect(paintInput.measures).toEqual([]);
+ expect(paintInput.resolvedLayout).toBeTruthy();
+ expect(paintInput.sourceLayout).toBeTruthy();
+ });
+
it('forces vertical layout and disables virtualization when flowMode is semantic', async () => {
editor = new PresentationEditor({
element: container,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 69c691e95d..1b11108180 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2567,6 +2567,9 @@ importers:
'@superdoc/font-utils':
specifier: workspace:*
version: link:../../../../shared/font-utils
+ '@superdoc/layout-resolved':
+ specifier: workspace:*
+ version: link:../../layout-resolved
'@superdoc/preset-geometry':
specifier: workspace:*
version: link:../../../preset-geometry
From 1c76b4f1e925d11c70a3e9bc64c6a25c69255371 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tadeu=20Tupinamb=C3=A1?=
Date: Thu, 23 Apr 2026 02:21:13 -0300
Subject: [PATCH 28/43] [9/16] refactor(layout): add resolveHeaderFooterLayout
helper for decoration layouts (#2826)
* refactor(layout): lift page metadata into ResolvedPage
* refactor(layout): lift fragment metadata into resolved paint items
Add pmStart, pmEnd, continuesFromPrev, continuesOnNext, markerWidth,
and metadata fields to resolved paint item types. Populate them in
the resolvers and update the painter to prefer resolved item data
over legacy Fragment reads with fallbacks.
* refactor(layout): pre-compute SDT container keys in resolved layout
* refactor(layout): pre-compute paragraph border data in resolved layout
* refactor(layout): move change detection into resolved layout stage
* refactor(layout): lift paragraph and list-item block/measure into resolved items
* refactor(painter): extract block/measure resolution helper
* refactor(painter): remove body blocks/measures from DomPainterInput
Body block and measure data now flows exclusively through the resolved
layout. The painter only builds a blockLookup from header/footer data,
which is the last remaining fallback surface for fragments that do not
yet have a resolved path. Complex-transaction rebuild detection now
walks the resolved layout items directly instead of iterating the body
blockLookup.
The legacy createDomPainter wrapper derives a resolved layout from
its legacyState blocks/measures on the fly so the benchmark path and
direct createDomPainter(options).paint(Layout) callers keep working
without setResolvedLayout.
* refactor(layout): add resolveHeaderFooterLayout helper for decoration layouts
* chore: fix lock file
* fix: preserve per-page header/footer resolution data
---
packages/layout-engine/contracts/src/index.ts | 12 ++
.../contracts/src/resolved-layout.ts | 16 ++
.../layout-bridge/src/layoutHeaderFooter.ts | 2 +
.../test/headerFooterLayout.test.ts | 32 +++
.../layout-resolved/src/index.ts | 1 +
.../src/resolveHeaderFooter.test.ts | 188 ++++++++++++++++++
.../src/resolveHeaderFooter.ts | 44 ++++
.../layout-resolved/src/resolveLayout.ts | 4 +-
.../layout-engine/painters/dom/package.json | 1 +
.../painters/dom/src/renderer.ts | 1 -
pnpm-lock.yaml | 1 -
11 files changed, 298 insertions(+), 4 deletions(-)
create mode 100644 packages/layout-engine/layout-resolved/src/resolveHeaderFooter.test.ts
create mode 100644 packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts
diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts
index 4cc339e09c..7f29f5f9d0 100644
--- a/packages/layout-engine/contracts/src/index.ts
+++ b/packages/layout-engine/contracts/src/index.ts
@@ -1901,6 +1901,16 @@ export type HeaderFooterPage = {
number: number;
fragments: Fragment[];
numberText?: string;
+ /**
+ * Optional page-local block clones backing this page's resolved fragments.
+ * Present when header/footer tokens were laid out per page or per bucket.
+ */
+ blocks?: FlowBlock[];
+ /**
+ * Optional page-local measures aligned with `blocks`.
+ * Present when header/footer tokens were laid out per page or per bucket.
+ */
+ measures?: Measure[];
};
export type HeaderFooterLayout = {
@@ -1980,6 +1990,8 @@ export type {
ResolvedTableItem,
ResolvedImageItem,
ResolvedDrawingItem,
+ ResolvedHeaderFooterPage,
+ ResolvedHeaderFooterLayout,
} from './resolved-layout.js';
export { isResolvedTableItem, isResolvedImageItem, isResolvedDrawingItem } from './resolved-layout.js';
diff --git a/packages/layout-engine/contracts/src/resolved-layout.ts b/packages/layout-engine/contracts/src/resolved-layout.ts
index c26cd784a9..8e4355c432 100644
--- a/packages/layout-engine/contracts/src/resolved-layout.ts
+++ b/packages/layout-engine/contracts/src/resolved-layout.ts
@@ -350,6 +350,22 @@ export function isResolvedDrawingItem(item: ResolvedPaintItem): item is Resolved
return item.kind === 'fragment' && 'fragmentKind' in item && item.fragmentKind === 'drawing' && 'block' in item;
}
+/** A resolved header/footer page — mirrors HeaderFooterPage but with resolved items. */
+export type ResolvedHeaderFooterPage = {
+ number: number;
+ numberText?: string;
+ items: ResolvedPaintItem[];
+};
+
+/** A resolved header/footer layout — mirrors HeaderFooterLayout but with resolved pages. */
+export type ResolvedHeaderFooterLayout = {
+ height: number;
+ minY?: number;
+ maxY?: number;
+ renderHeight?: number;
+ pages: ResolvedHeaderFooterPage[];
+};
+
/** Resolved list marker rendering data with pre-computed positioning. */
export type ResolvedListMarkerItem = {
/** Marker text content (e.g., "1.", "a)", bullet). */
diff --git a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts
index de6310ada4..60afb042ed 100644
--- a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts
+++ b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts
@@ -322,6 +322,8 @@ export async function layoutHeaderFooterWithCache(
pages: pages.map((p) => ({
number: p.number,
fragments: p.fragments,
+ blocks: p.blocks,
+ measures: p.measures,
})),
};
diff --git a/packages/layout-engine/layout-bridge/test/headerFooterLayout.test.ts b/packages/layout-engine/layout-bridge/test/headerFooterLayout.test.ts
index 04a8903946..1ed4aa8cff 100644
--- a/packages/layout-engine/layout-bridge/test/headerFooterLayout.test.ts
+++ b/packages/layout-engine/layout-bridge/test/headerFooterLayout.test.ts
@@ -81,6 +81,38 @@ describe('layoutHeaderFooterWithCache', () => {
expect(measureBlock).not.toHaveBeenCalled();
});
+ it('stores page-local block clones for tokenized header/footer pages', async () => {
+ const sections = {
+ default: [
+ {
+ kind: 'paragraph',
+ id: 'page-token-header',
+ runs: [
+ { text: 'Page ', fontFamily: 'Arial', fontSize: 16 },
+ { text: '0', token: 'pageNumber', fontFamily: 'Arial', fontSize: 16 },
+ ],
+ } satisfies FlowBlock,
+ ],
+ };
+ const measureBlock = vi.fn(async () => makeMeasure(12));
+
+ const result = await layoutHeaderFooterWithCache(
+ sections,
+ { width: 300, height: 40 },
+ measureBlock,
+ undefined,
+ undefined,
+ (pageNumber) => ({ displayText: String(pageNumber), totalPages: 2 }),
+ 'header',
+ );
+
+ expect(result.default?.layout.pages).toHaveLength(2);
+ expect(result.default?.layout.pages[0].blocks?.[0].runs[1]?.text).toBe('1');
+ expect(result.default?.layout.pages[1].blocks?.[0].runs[1]?.text).toBe('2');
+ expect(result.default?.layout.pages[0].measures).toHaveLength(1);
+ expect(result.default?.layout.pages[1].measures).toHaveLength(1);
+ });
+
describe('integration test', () => {
it('full pipeline: PM JSON with page tokens → FlowBlocks → Measures → Layout', async () => {
// 1. Create PM JSON with page number tokens (simulates header/footer from SuperConverter)
diff --git a/packages/layout-engine/layout-resolved/src/index.ts b/packages/layout-engine/layout-resolved/src/index.ts
index af3f0a23c7..c504917f6f 100644
--- a/packages/layout-engine/layout-resolved/src/index.ts
+++ b/packages/layout-engine/layout-resolved/src/index.ts
@@ -1,2 +1,3 @@
export { resolveLayout } from './resolveLayout.js';
export type { ResolveLayoutInput } from './resolveLayout.js';
+export { resolveHeaderFooterLayout } from './resolveHeaderFooter.js';
diff --git a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.test.ts b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.test.ts
new file mode 100644
index 0000000000..7862da9026
--- /dev/null
+++ b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.test.ts
@@ -0,0 +1,188 @@
+import { describe, it, expect } from 'vitest';
+import { resolveHeaderFooterLayout } from './resolveHeaderFooter.js';
+import type { FlowBlock, HeaderFooterLayout, Measure, ParaFragment, ResolvedFragmentItem } from '@superdoc/contracts';
+
+describe('resolveHeaderFooterLayout', () => {
+ it('resolves a header/footer with one paragraph fragment', () => {
+ const paraFragment: ParaFragment = {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 0,
+ toLine: 1,
+ x: 72,
+ y: 10,
+ width: 468,
+ };
+ const layout: HeaderFooterLayout = {
+ height: 50,
+ pages: [{ number: 1, fragments: [paraFragment] }],
+ };
+ const blocks: FlowBlock[] = [{ kind: 'paragraph', id: 'p1', runs: [] }];
+ const measures: Measure[] = [
+ {
+ kind: 'paragraph',
+ lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 100, ascent: 10, descent: 3, lineHeight: 18 }],
+ totalHeight: 18,
+ },
+ ];
+
+ const result = resolveHeaderFooterLayout(layout, blocks, measures);
+ expect(result.pages).toHaveLength(1);
+ const item = result.pages[0].items[0] as ResolvedFragmentItem;
+ expect(item.version).toBeDefined();
+ expect(item.block?.kind).toBe('paragraph');
+ expect(item.measure?.kind).toBe('paragraph');
+ });
+
+ it('preserves height, minY, maxY, renderHeight from input', () => {
+ const layout: HeaderFooterLayout = {
+ height: 100,
+ minY: 5,
+ maxY: 120,
+ renderHeight: 115,
+ pages: [],
+ };
+
+ const result = resolveHeaderFooterLayout(layout, [], []);
+ expect(result.height).toBe(100);
+ expect(result.minY).toBe(5);
+ expect(result.maxY).toBe(120);
+ expect(result.renderHeight).toBe(115);
+ });
+
+ it('preserves numberText on pages', () => {
+ const layout: HeaderFooterLayout = {
+ height: 50,
+ pages: [
+ { number: 1, fragments: [], numberText: 'i' },
+ { number: 2, fragments: [], numberText: 'ii' },
+ ],
+ };
+
+ const result = resolveHeaderFooterLayout(layout, [], []);
+ expect(result.pages[0].numberText).toBe('i');
+ expect(result.pages[1].numberText).toBe('ii');
+ });
+
+ it('returns empty items array for empty fragments array', () => {
+ const layout: HeaderFooterLayout = {
+ height: 50,
+ pages: [{ number: 1, fragments: [] }],
+ };
+
+ const result = resolveHeaderFooterLayout(layout, [], []);
+ expect(result.pages).toHaveLength(1);
+ expect(result.pages[0].items).toEqual([]);
+ });
+
+ it('leaves block/measure undefined when block entry is missing', () => {
+ const paraFragment: ParaFragment = {
+ kind: 'para',
+ blockId: 'missing-id',
+ fromLine: 0,
+ toLine: 1,
+ x: 0,
+ y: 0,
+ width: 100,
+ };
+ const layout: HeaderFooterLayout = {
+ height: 50,
+ pages: [{ number: 1, fragments: [paraFragment] }],
+ };
+
+ const result = resolveHeaderFooterLayout(layout, [], []);
+ const item = result.pages[0].items[0] as ResolvedFragmentItem;
+ expect(item.block).toBeUndefined();
+ expect(item.measure).toBeUndefined();
+ });
+
+ it('resolves each page against its own cloned block data', () => {
+ const paraFragment: ParaFragment = {
+ kind: 'para',
+ blockId: 'page-token',
+ fromLine: 0,
+ toLine: 1,
+ x: 0,
+ y: 0,
+ width: 120,
+ };
+ const pageOneBlocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'page-token',
+ runs: [
+ { text: 'Page ', fontFamily: 'Arial', fontSize: 16 },
+ { text: '1', token: 'pageNumber', fontFamily: 'Arial', fontSize: 16 },
+ ],
+ },
+ ];
+ const pageTwoBlocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'page-token',
+ runs: [
+ { text: 'Page ', fontFamily: 'Arial', fontSize: 16 },
+ { text: '2', token: 'pageNumber', fontFamily: 'Arial', fontSize: 16 },
+ ],
+ },
+ ];
+ const makeMeasure = (text: string): Measure => ({
+ kind: 'paragraph',
+ lines: [
+ { fromRun: 0, fromChar: 0, toRun: 1, toChar: text.length, width: 120, ascent: 10, descent: 3, lineHeight: 18 },
+ ],
+ totalHeight: 18,
+ });
+ const layout: HeaderFooterLayout = {
+ height: 50,
+ pages: [
+ { number: 1, fragments: [paraFragment], blocks: pageOneBlocks, measures: [makeMeasure('Page 1')] },
+ { number: 2, fragments: [paraFragment], blocks: pageTwoBlocks, measures: [makeMeasure('Page 2')] },
+ ],
+ };
+
+ const result = resolveHeaderFooterLayout(layout, pageOneBlocks, [makeMeasure('Page 1')]);
+ const firstItem = result.pages[0].items[0] as ResolvedFragmentItem;
+ const secondItem = result.pages[1].items[0] as ResolvedFragmentItem;
+
+ expect(firstItem.block?.kind).toBe('paragraph');
+ expect(secondItem.block?.kind).toBe('paragraph');
+ expect(firstItem.block?.runs[1]?.text).toBe('1');
+ expect(secondItem.block?.runs[1]?.text).toBe('2');
+ expect(firstItem.version).not.toBe(secondItem.version);
+ });
+
+ it('uses document page indices for sparse header/footer pages', () => {
+ const paraFragment: ParaFragment = {
+ kind: 'para',
+ blockId: 'p1',
+ fromLine: 0,
+ toLine: 1,
+ x: 0,
+ y: 0,
+ width: 100,
+ };
+ const layout: HeaderFooterLayout = {
+ height: 50,
+ pages: [
+ { number: 5, fragments: [paraFragment], numberText: '5' },
+ { number: 50, fragments: [paraFragment], numberText: '50' },
+ { number: 500, fragments: [paraFragment], numberText: '500' },
+ ],
+ };
+ const blocks: FlowBlock[] = [{ kind: 'paragraph', id: 'p1', runs: [] }];
+ const measures: Measure[] = [
+ {
+ kind: 'paragraph',
+ lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 0, width: 100, ascent: 10, descent: 3, lineHeight: 18 }],
+ totalHeight: 18,
+ },
+ ];
+
+ const result = resolveHeaderFooterLayout(layout, blocks, measures);
+
+ expect((result.pages[0].items[0] as ResolvedFragmentItem).pageIndex).toBe(4);
+ expect((result.pages[1].items[0] as ResolvedFragmentItem).pageIndex).toBe(49);
+ expect((result.pages[2].items[0] as ResolvedFragmentItem).pageIndex).toBe(499);
+ });
+});
diff --git a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts
new file mode 100644
index 0000000000..9988a337c3
--- /dev/null
+++ b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts
@@ -0,0 +1,44 @@
+import type {
+ FlowBlock,
+ HeaderFooterLayout,
+ Measure,
+ ResolvedHeaderFooterLayout,
+ ResolvedHeaderFooterPage,
+} from '@superdoc/contracts';
+import { buildBlockMap, resolveFragmentItem } from './resolveLayout.js';
+
+/**
+ * Resolves a header/footer layout into a `ResolvedHeaderFooterLayout`.
+ *
+ * Standalone helper invoked per `HeaderFooterLayoutResult` from `incrementalLayout`.
+ * The caller stores results indexed by the same key (type or rId) as the originals;
+ * alignment between fragments and resolved items is guaranteed by construction.
+ */
+export function resolveHeaderFooterLayout(
+ layout: HeaderFooterLayout,
+ blocks: FlowBlock[],
+ measures: Measure[],
+): ResolvedHeaderFooterLayout {
+ const pages: ResolvedHeaderFooterPage[] = layout.pages.map((page) => {
+ const pageBlocks = page.blocks ?? blocks;
+ const pageMeasures = page.measures ?? measures;
+ const blockMap = buildBlockMap(pageBlocks, pageMeasures);
+ const blockVersionCache = new Map();
+
+ return {
+ number: page.number,
+ numberText: page.numberText,
+ items: page.fragments.map((fragment, fragmentIndex) =>
+ resolveFragmentItem(fragment, fragmentIndex, page.number - 1, blockMap, blockVersionCache),
+ ),
+ };
+ });
+
+ return {
+ height: layout.height,
+ minY: layout.minY,
+ maxY: layout.maxY,
+ renderHeight: layout.renderHeight,
+ pages,
+ };
+}
diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts
index 771ce6c067..78b5be1f13 100644
--- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts
+++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts
@@ -37,7 +37,7 @@ export type ResolveLayoutInput = {
measures: Measure[];
};
-function buildBlockMap(blocks: FlowBlock[], measures: Measure[]): Map {
+export function buildBlockMap(blocks: FlowBlock[], measures: Measure[]): Map {
const map = new Map();
for (let i = 0; i < blocks.length; i++) {
map.set(blocks[i].id, { block: blocks[i], measure: measures[i] });
@@ -190,7 +190,7 @@ function computeBlockVersion(
return version;
}
-function resolveFragmentItem(
+export function resolveFragmentItem(
fragment: Fragment,
fragmentIndex: number,
pageIndex: number,
diff --git a/packages/layout-engine/painters/dom/package.json b/packages/layout-engine/painters/dom/package.json
index 940b673d32..cf39d4348f 100644
--- a/packages/layout-engine/painters/dom/package.json
+++ b/packages/layout-engine/painters/dom/package.json
@@ -27,6 +27,7 @@
},
"devDependencies": {
"@superdoc/layout-engine": "workspace:*",
+ "@superdoc/layout-resolved": "workspace:*",
"vitest": "catalog:"
}
}
diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts
index 29f5d5f2b1..32260fc06f 100644
--- a/packages/layout-engine/painters/dom/src/renderer.ts
+++ b/packages/layout-engine/painters/dom/src/renderer.ts
@@ -2435,7 +2435,6 @@ export class DomPainter {
return separatorPositions;
}
-
private renderDecorationsForPage(
pageEl: HTMLElement,
page: Page,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1b11108180..9d59efac41 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -6405,7 +6405,6 @@ packages:
'@microsoft/teamsapp-cli@3.0.2':
resolution: {integrity: sha512-AowuJwrrUxeF9Bq/frxuy9YZjK/ECk3pi0UBXl3CQLZ4XNWfgWatiFi/UWpyHDLccFs+0Za3nNYATFvgsxEFwQ==}
engines: {node: '>=12'}
- deprecated: This package is deprecated and supported Node.js version is 18-22. Please use @microsoft/m365agentstoolkit-cli instead.
hasBin: true
'@microsoft/teamsfx-api@0.23.1':
From 0db8e2fb6e01bb5b96c8d0d62e857c23595e10ac Mon Sep 17 00:00:00 2001
From: Andrii Orlov <120495135+andrii-harbour@users.noreply.github.com>
Date: Thu, 23 Apr 2026 17:30:41 +0200
Subject: [PATCH 29/43] docs: enhance OpenAPI specification for sign API
(#2920)
* docs: enhance OpenAPI specification for sign API
- Updated the `document` object description to clarify the requirement of providing either `base64` or `url`.
- Expanded the `signer` object description to specify required fields and optional fields, including examples.
- Improved the `auditTrail` description to emphasize compliance requirements.
- Added a new `certificate` object to configure the audit trail certificate page.
- Updated the API documentation in `backend.mdx` to reflect the changes in the `signer` fields and their requirements.
* docs: update OpenAPI specification for event payload structure
- Replaced the `field` and `value` properties with a new `data` object in the event schema.
- The `data` object now supports an event-specific payload with detailed descriptions for different event types, enhancing clarity and usability.
* docs: update documentation for event payload and signer fields
- Enhanced the OpenAPI specification to clarify the structure of the event payload, including updates to the `field_change` type to support additional data types.
- Simplified the `signer` object in the backend documentation by removing unnecessary properties and added a warning about trusting browser-submitted `ip` and `userAgent` values.
---------
Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
---
apps/docs/openapi.json | 101 ++++++++++++++++++++++++--
apps/docs/solutions/esign/backend.mdx | 17 +++++
2 files changed, 113 insertions(+), 5 deletions(-)
diff --git a/apps/docs/openapi.json b/apps/docs/openapi.json
index d6b1e41a79..f2559eb087 100644
--- a/apps/docs/openapi.json
+++ b/apps/docs/openapi.json
@@ -197,19 +197,110 @@
},
"document": {
"type": "object",
- "description": "PDF or DOCX input provided as either base64 or URL"
+ "description": "PDF or DOCX input. Provide exactly one of `base64` or `url`.",
+ "oneOf": [
+ {
+ "type": "object",
+ "title": "base64",
+ "required": ["base64"],
+ "properties": {
+ "base64": {
+ "type": "string",
+ "format": "byte",
+ "minLength": 100,
+ "description": "Base64-encoded PDF or DOCX file"
+ }
+ }
+ },
+ {
+ "type": "object",
+ "title": "url",
+ "required": ["url"],
+ "properties": {
+ "url": {
+ "type": "string",
+ "format": "uri",
+ "description": "URL to fetch the document from"
+ }
+ }
+ }
+ ]
},
"signer": {
"type": "object",
- "description": "Signer details (name, email, etc.)"
+ "description": "Details of the person applying the signature. `email` and `name` are required; `ip` and `userAgent` are optional and recorded in the audit trail / certificate page when provided. No other fields are accepted — use `metadata` for application-specific context.",
+ "required": ["email", "name"],
+ "additionalProperties": false,
+ "properties": {
+ "email": {
+ "type": "string",
+ "format": "email",
+ "maxLength": 255,
+ "description": "Signer's email address",
+ "example": "jane@example.com"
+ },
+ "name": {
+ "type": "string",
+ "minLength": 2,
+ "maxLength": 255,
+ "description": "Signer's full name as it should appear on the signature",
+ "example": "Jane Smith"
+ },
+ "ip": {
+ "type": "string",
+ "format": "ipv4",
+ "description": "IPv4 address the signer submitted from. Included in the audit trail certificate for compliance.",
+ "example": "203.0.113.42"
+ },
+ "userAgent": {
+ "type": "string",
+ "description": "Browser user agent string the signer submitted from. Included in the audit trail certificate for compliance.",
+ "example": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
+ }
+ }
},
"auditTrail": {
"type": "array",
- "description": "Array of signing events and user interactions"
+ "description": "Complete event trail of user interactions. Must include at least one `submit` event for e-signature compliance.",
+ "minItems": 1,
+ "items": {
+ "type": "object",
+ "required": ["type", "timestamp"],
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["ready", "scroll", "field_change", "submit"],
+ "description": "Event kind"
+ },
+ "timestamp": {
+ "type": "string",
+ "format": "date-time",
+ "description": "ISO-8601 timestamp for the event"
+ },
+ "data": {
+ "type": "object",
+ "additionalProperties": true,
+ "description": "Event-specific payload emitted by the e-sign SDK. Shape depends on `type`:\n- `scroll` - `{ percent: number }`\n- `field_change` - `{ fieldId: string, value: string | boolean | number | null, previousValue?: string | boolean | number | null }`\n- `ready`, `submit` - typically omitted"
+ }
+ }
+ }
},
"metadata": {
"type": "object",
- "description": "Optional metadata (IP, user agent, custom fields)"
+ "additionalProperties": true,
+ "description": "Optional application-specific metadata. Free-form object for any context you want to attach to the signing event (e.g. tenantId, contractId, custom audit fields)."
+ },
+ "certificate": {
+ "type": "object",
+ "description": "Configuration for the audit trail certificate page that is appended to the signed PDF.",
+ "additionalProperties": false,
+ "properties": {
+ "enabled": {
+ "type": "boolean",
+ "default": true,
+ "description": "Whether to append an audit trail certificate page to the signed document"
+ }
+ }
}
}
},
@@ -792,7 +883,7 @@
"post": {
"summary": "Sign",
"tags": ["Signature"],
- "description": "Sign a PDF or DOCX document with a cryptographic signature.\n\nSend a JSON request body with:\n- `document`: object containing either `base64` or `url`\n- `signer`: object with signer details (name, email, etc.)\n- `auditTrail`: array of signing events\n- `eventId`: optional unique identifier\n- `metadata`: optional metadata (IP, user agent, custom fields)\n\nThe response returns the signed PDF as base64.",
+ "description": "Sign a PDF or DOCX document with a cryptographic signature.\n\nSend a JSON request body with:\n- `document` (required): object containing either `base64` or `url`\n- `signer` (required): signer details — `email` and `name` are required; `ip` and `userAgent` are optional and, when provided, are recorded in the audit trail certificate. No other signer fields are accepted; use `metadata` for anything application-specific.\n- `auditTrail` (required): array of signing events. Must include at least one `submit` event for e-signature compliance.\n- `eventId` (optional): unique identifier for the signing event\n- `metadata` (optional): free-form object for application-specific context (tenantId, contractId, etc.)\n- `certificate` (optional): `{ enabled: boolean }` — controls whether an audit trail certificate page is appended (default: `true`)\n\nThe response returns the signed PDF as base64.",
"requestBody": {
"content": {
"application/json": {
diff --git a/apps/docs/solutions/esign/backend.mdx b/apps/docs/solutions/esign/backend.mdx
index 4fbb52ec02..560b7590ae 100644
--- a/apps/docs/solutions/esign/backend.mdx
+++ b/apps/docs/solutions/esign/backend.mdx
@@ -251,6 +251,23 @@ The frontend sends the `onSubmit` payload plus a document reference and signer d
}
```
+### Signer fields
+
+When forwarding this payload to `POST /v1/sign`, only the following `signer` fields are accepted:
+
+| Field | Required | Description |
+| ----------- | -------- | --------------------------------------------------------------------------- |
+| `name` | yes | Signer's full name (2–255 chars). Rendered on the signature. |
+| `email` | yes | Signer's email address (valid email, max 255 chars). |
+| `ip` | no | IPv4 address of the signer. Included in the audit trail certificate. |
+| `userAgent` | no | Browser user agent string. Included in the audit trail certificate. |
+
+No other properties are accepted on `signer` — the request is rejected with a validation error if extra keys are sent. For application-specific context (tenantId, contractId, etc.), pass a top-level `metadata` object instead.
+
+
+ Do **not** trust `ip` or `userAgent` values from the browser-submitted payload. Derive them server-side from the incoming request (e.g. `req.ip`, `req.headers['user-agent']`) before forwarding to `/v1/sign`, so the audit trail certificate reflects what your server actually observed.
+
+
## API reference
- [Authentication](/api-reference/authentication) - Get your API key
From 5cb9691e129c42092787d270d146bd40b34ac269 Mon Sep 17 00:00:00 2001
From: Nick Bernal <117235294+harbournick@users.noreply.github.com>
Date: Thu, 23 Apr 2026 08:33:34 -0700
Subject: [PATCH 30/43] feat: tracked changes in headers and footers (#2922)
* feat(layout): make tracked changes story-aware in resolved layout
* feat(editor): add story editing sessions and runtime plumbing
* feat(doc-api): expose story-aware tracked change operations
* feat(superdoc): surface story tracked changes in comments ui
* fix(notes): only strip separators after note reference runs
* fix(superdoc): restore tracked-change comment rendering and CI tests
* fix: sidebar activation state for tcs
* fix: footnote caret selection
---
.../document-api/available-operations.mdx | 3 +-
.../reference/_generated-manifest.json | 4 +-
.../reference/capabilities/get.mdx | 46 +
.../reference/content-controls/create.mdx | 7 +
.../document-api/reference/core/index.mdx | 1 +
.../document-api/reference/create/heading.mdx | 6 +-
.../reference/create/paragraph.mdx | 6 +-
apps/docs/document-api/reference/extract.mdx | 222 ++
apps/docs/document-api/reference/index.mdx | 3 +-
.../document-api/reference/lists/insert.mdx | 6 +-
.../reference/track-changes/decide.mdx | 9 +-
.../reference/track-changes/get.mdx | 17 +-
.../reference/track-changes/list.mdx | 18 +-
packages/document-api/src/README.md | 14 +-
packages/document-api/src/contract/schemas.ts | 10 +-
packages/document-api/src/index.test.ts | 8 +
.../src/track-changes/track-changes.ts | 17 +-
packages/document-api/src/types/address.ts | 2 +
.../src/types/track-changes.types.ts | 16 +
packages/layout-engine/contracts/src/index.ts | 9 +
.../layout-bridge/src/incrementalLayout.ts | 134 +-
.../layout-engine/layout-bridge/src/index.ts | 110 +-
.../src/sectionAwareHeaderFooter.ts | 231 ++
.../layout-bridge/src/text-measurement.ts | 18 +-
.../test/selectionToRects.test.ts | 98 +
.../test/text-measurement.test.ts | 19 +
.../layout-engine/src/index.d.ts | 2 +
.../layout-engine/src/index.test.ts | 19 +
.../layout-engine/layout-engine/src/index.ts | 65 +-
.../dom/src/renderer-position-mapping.test.ts | 49 +
.../painters/dom/src/renderer.ts | 29 +-
.../pm-adapter/src/converters/image.ts | 4 +-
.../converters/inline-converters/common.ts | 2 +
.../inline-converters/generic-token.test.ts | 24 +
.../inline-converters/generic-token.ts | 3 +-
.../converters/inline-converters/tab.test.ts | 37 +-
.../src/converters/inline-converters/tab.ts | 3 +-
.../inline-converters/text-run.test.ts | 7 +
.../converters/inline-converters/text-run.ts | 5 +-
.../src/converters/paragraph.test.ts | 1 +
.../pm-adapter/src/converters/paragraph.ts | 16 +-
.../pm-adapter/src/converters/table.ts | 12 +-
.../pm-adapter/src/index.test.ts | 27 +
.../layout-engine/pm-adapter/src/internal.ts | 1 +
.../pm-adapter/src/marks/application.ts | 12 +-
.../pm-adapter/src/tracked-changes.test.ts | 10 +-
.../pm-adapter/src/tracked-changes.ts | 14 +-
.../layout-engine/pm-adapter/src/types.ts | 10 +
.../v1/assets/styles/layout/global.css | 4 +-
.../editors/v1/core/Editor.setOptions.test.ts | 32 +
.../src/editors/v1/core/Editor.ts | 21 +-
.../v1/core/commands/core-command-map.d.ts | 12 +-
.../header-footer/EditorOverlayManager.ts | 9 +
.../HeaderFooterPerRidLayout.test.ts | 82 +-
.../header-footer/HeaderFooterPerRidLayout.ts | 290 +--
.../HeaderFooterRegistry.test.ts | 80 +
.../header-footer/HeaderFooterRegistry.ts | 42 +-
.../header-footer/HeaderFooterRegistryInit.ts | 19 -
.../editors/v1/core/header-footer/types.ts | 2 +-
.../adapters/header-footer-part-descriptor.ts | 37 +-
.../presentation-editor/PresentationEditor.ts | 1939 +++++++++++++++--
.../dom/EditorStyleInjector.test.ts | 3 -
.../dom/EditorStyleInjector.ts | 16 -
.../HeaderFooterSessionManager.ts | 833 +++++--
.../input/PresentationInputBridge.ts | 267 ++-
.../layout/EndnotesBuilder.ts | 167 ++
.../layout/FootnotesBuilder.ts | 80 +-
.../pointer-events/EditorInputManager.ts | 685 ++++--
.../LocalSelectionOverlayRendering.ts | 5 +-
.../selection/VisibleTextOffsetGeometry.ts | 470 ++++
.../StoryPresentationSessionManager.test.ts | 354 +++
.../StoryPresentationSessionManager.ts | 337 +++
.../createStoryHiddenHost.test.ts | 50 +
.../story-session/createStoryHiddenHost.ts | 55 +
.../story-session/index.ts | 22 +
.../story-session/types.ts | 137 ++
.../tests/DomPositionIndex.test.ts | 20 +
.../EditorInputManager.footnoteClick.test.ts | 639 +++++-
.../tests/FootnotesBuilder.test.ts | 121 +
.../tests/HeaderFooterSessionManager.test.ts | 298 ++-
.../LocalSelectionOverlayRendering.test.ts | 1 +
.../PresentationEditor.collaboration.test.ts | 1 +
...sentationEditor.footnotesPmMarkers.test.ts | 7 +-
.../tests/PresentationEditor.media.test.ts | 1 +
.../tests/PresentationEditor.test.ts | 357 ++-
.../tests/PresentationInputBridge.test.ts | 127 ++
.../tests/VisibleTextOffsetGeometry.test.ts | 223 ++
.../v1/core/presentation-editor/types.ts | 6 +-
.../utils/CommentPositionCollection.ts | 73 +-
.../v1/core/story-editor-factory.test.ts | 64 +
.../editors/v1/core/story-editor-factory.ts | 29 +-
.../v2/importer/documentFootnotesImporter.js | 15 +-
.../v2/importer/docxImporter.js | 10 +-
.../v2/importer/endnoteReferenceImporter.js | 7 +
.../v2/importer/trackedChangeIdMapper.js | 61 +-
.../v2/importer/trackedChangeIdMapper.test.js | 92 +-
.../v3/handlers/w/del/del-translator.js | 9 +-
.../v3/handlers/w/del/del-translator.test.js | 16 +-
.../v3/handlers/w/ins/ins-translator.js | 9 +-
.../v3/handlers/w/ins/ins-translator.test.js | 16 +-
.../src/editors/v1/core/types/EditorEvents.ts | 19 +-
.../v1/dev/components/DeveloperPlayground.vue | 4 +-
.../helpers/note-pm-json.test.ts | 292 +++
.../helpers/note-pm-json.ts | 167 ++
.../helpers/tracked-change-resolver.ts | 107 +-
.../tracked-change-runtime-ref.test.ts | 46 +
.../helpers/tracked-change-runtime-ref.ts | 82 +
.../track-changes-wrappers.test.ts | 151 ++
.../plan-engine/track-changes-wrappers.ts | 309 ++-
.../header-footer-story-runtime.ts | 22 +-
.../story-runtime/index.ts | 5 +
.../live-story-session-runtime-registry.ts | 101 +
.../story-runtime/note-story-runtime.test.ts | 134 ++
.../story-runtime/note-story-runtime.ts | 115 +-
.../resolve-story-runtime.test.ts | 79 +
.../story-runtime/resolve-story-runtime.ts | 5 +
.../story-runtime/story-types.ts | 14 +
.../__tests__/tracked-change-index.test.ts | 296 +++
.../tracked-changes/enumerate-stories.test.ts | 68 +
.../tracked-changes/enumerate-stories.ts | 88 +
.../tracked-changes/story-labels.test.ts | 77 +
.../tracked-changes/story-labels.ts | 76 +
.../tracked-changes/tracked-change-index.ts | 362 +++
.../tracked-change-snapshot.ts | 44 +
.../v1/dom-observer/DomPointerMapping.test.ts | 93 +
.../v1/dom-observer/DomPointerMapping.ts | 87 +-
.../v1/dom-observer/DomPositionIndex.ts | 16 +-
.../src/editors/v1/dom-observer/index.ts | 7 +-
.../pagination/pagination-helpers.js | 36 +-
.../pagination/pagination-helpers.test.js | 56 +-
packages/super-editor/src/editors/v1/index.js | 31 +
.../v1/tests/import/footnotesImporter.test.js | 39 +
packages/super-editor/src/index.ts | 1 +
packages/superdoc/src/SuperDoc.test.js | 134 +-
packages/superdoc/src/SuperDoc.vue | 62 +-
.../CommentsLayer/CommentDialog.test.js | 188 +-
.../CommentsLayer/CommentDialog.vue | 140 +-
.../CommentsLayer/FloatingComments.vue | 12 +-
.../commentsList/commentsList.vue | 6 +-
.../commentsList/super-comments-list.js | 10 +
.../commentsList/super-comments-list.test.js | 24 +
.../components/CommentsLayer/use-comment.js | 12 +
.../superdoc/src/stores/comments-store.js | 328 ++-
.../src/stores/comments-store.test.js | 824 ++++++-
scripts/validate-command-types.mjs | 137 +-
tests/behavior/fixtures/superdoc.ts | 2 +-
tests/behavior/harness/index.html | 43 +-
tests/behavior/harness/main.ts | 101 +
tests/behavior/harness/vite.config.ts | 14 +
tests/behavior/helpers/comments.ts | 20 +-
tests/behavior/helpers/document-api.ts | 25 +-
tests/behavior/helpers/story-fixtures.ts | 368 ++++
tests/behavior/helpers/story-replacements.ts | 55 +
tests/behavior/helpers/story-surfaces.ts | 273 +++
.../behavior/helpers/story-tracked-changes.ts | 244 +++
...stays-in-body-during-footnote-edit.spec.ts | 137 ++
...-footer-live-tracked-change-bounds.spec.ts | 229 ++
...eader-footer-tracked-change-bubble.spec.ts | 70 +
...-surface-tracked-change-activation.spec.ts | 161 ++
...reject-format-suggestion-selection.spec.ts | 3 +-
...-1960-word-replacement-no-comments.spec.ts | 3 +-
.../story-surface-import-bootstrap.spec.ts | 46 +
.../story-surface-replacement-bubble.spec.ts | 173 ++
...tory-surface-tracked-change-decide.spec.ts | 150 ++
...ory-surface-tracked-change-sidebar.spec.ts | 34 +
...ked-change-independent-replacement.spec.ts | 124 ++
...ked-change-independent-replacement.spec.ts | 14 +-
.../tracked-change-partial-resolution.spec.ts | 10 +-
.../undo-redo-tracked-change-sidebar.spec.ts | 8 +-
...ndo-tracked-insert-removes-sidebar.spec.ts | 31 +-
.../double-click-edit-endnote.spec.ts | 50 +
.../double-click-edit-footnote.spec.ts | 868 ++++++++
.../headers/double-click-edit-header.spec.ts | 298 ++-
.../headers/header-footer-line-height.spec.ts | 60 +-
.../header-footer-selection-overlay.spec.ts | 136 +-
.../tests/search/search-and-navigate.spec.ts | 11 +-
.../part-surface-multiclick-selection.spec.ts | 330 +++
177 files changed, 17015 insertions(+), 1620 deletions(-)
create mode 100644 apps/docs/document-api/reference/extract.mdx
create mode 100644 packages/layout-engine/layout-bridge/src/sectionAwareHeaderFooter.ts
create mode 100644 packages/layout-engine/painters/dom/src/renderer-position-mapping.test.ts
create mode 100644 packages/super-editor/src/editors/v1/core/Editor.setOptions.test.ts
create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/layout/EndnotesBuilder.ts
create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/selection/VisibleTextOffsetGeometry.ts
create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/story-session/StoryPresentationSessionManager.test.ts
create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/story-session/StoryPresentationSessionManager.ts
create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/story-session/createStoryHiddenHost.test.ts
create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/story-session/createStoryHiddenHost.ts
create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/story-session/index.ts
create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/story-session/types.ts
create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/tests/VisibleTextOffsetGeometry.test.ts
create mode 100644 packages/super-editor/src/editors/v1/core/story-editor-factory.test.ts
create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v2/importer/endnoteReferenceImporter.js
create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/helpers/note-pm-json.test.ts
create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/helpers/note-pm-json.ts
create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-runtime-ref.test.ts
create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-runtime-ref.ts
create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts
create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/live-story-session-runtime-registry.ts
create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/__tests__/tracked-change-index.test.ts
create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/enumerate-stories.test.ts
create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/enumerate-stories.ts
create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/story-labels.test.ts
create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/story-labels.ts
create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-index.ts
create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-snapshot.ts
create mode 100644 tests/behavior/helpers/story-fixtures.ts
create mode 100644 tests/behavior/helpers/story-replacements.ts
create mode 100644 tests/behavior/helpers/story-surfaces.ts
create mode 100644 tests/behavior/helpers/story-tracked-changes.ts
create mode 100644 tests/behavior/tests/comments/body-tracked-change-anchor-stays-in-body-during-footnote-edit.spec.ts
create mode 100644 tests/behavior/tests/comments/header-footer-live-tracked-change-bounds.spec.ts
create mode 100644 tests/behavior/tests/comments/header-footer-tracked-change-bubble.spec.ts
create mode 100644 tests/behavior/tests/comments/part-surface-tracked-change-activation.spec.ts
create mode 100644 tests/behavior/tests/comments/story-surface-import-bootstrap.spec.ts
create mode 100644 tests/behavior/tests/comments/story-surface-replacement-bubble.spec.ts
create mode 100644 tests/behavior/tests/comments/story-surface-tracked-change-decide.spec.ts
create mode 100644 tests/behavior/tests/comments/story-surface-tracked-change-sidebar.spec.ts
create mode 100644 tests/behavior/tests/comments/story-tracked-change-independent-replacement.spec.ts
create mode 100644 tests/behavior/tests/endnotes/double-click-edit-endnote.spec.ts
create mode 100644 tests/behavior/tests/footnotes/double-click-edit-footnote.spec.ts
create mode 100644 tests/behavior/tests/selection/part-surface-multiclick-selection.spec.ts
diff --git a/apps/docs/document-api/available-operations.mdx b/apps/docs/document-api/available-operations.mdx
index a19aa9b2fa..666c871990 100644
--- a/apps/docs/document-api/available-operations.mdx
+++ b/apps/docs/document-api/available-operations.mdx
@@ -20,7 +20,7 @@ Use the tables below to see what operations are available and where each one is
| Citations | 15 | 0 | 15 | [Reference](/document-api/reference/citations/index) |
| Comments | 5 | 0 | 5 | [Reference](/document-api/reference/comments/index) |
| Content Controls | 55 | 0 | 55 | [Reference](/document-api/reference/content-controls/index) |
-| Core | 13 | 0 | 13 | [Reference](/document-api/reference/core/index) |
+| Core | 14 | 0 | 14 | [Reference](/document-api/reference/core/index) |
| Create | 6 | 0 | 6 | [Reference](/document-api/reference/create/index) |
| Cross-References | 5 | 0 | 5 | [Reference](/document-api/reference/cross-refs/index) |
| Diff | 3 | 0 | 3 | [Reference](/document-api/reference/diff/index) |
@@ -148,6 +148,7 @@ Use the tables below to see what operations are available and where each one is
| editor.doc.getHtml(...) | [`getHtml`](/document-api/reference/get-html) |
| editor.doc.markdownToFragment(...) | [`markdownToFragment`](/document-api/reference/markdown-to-fragment) |
| editor.doc.info(...) | [`info`](/document-api/reference/info) |
+| editor.doc.extract(...) | [`extract`](/document-api/reference/extract) |
| editor.doc.clearContent(...) | [`clearContent`](/document-api/reference/clear-content) |
| editor.doc.insert(...) | [`insert`](/document-api/reference/insert) |
| editor.doc.replace(...) | [`replace`](/document-api/reference/replace) |
diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json
index 8887fe4a9e..b886c7b66d 100644
--- a/apps/docs/document-api/reference/_generated-manifest.json
+++ b/apps/docs/document-api/reference/_generated-manifest.json
@@ -130,6 +130,7 @@
"apps/docs/document-api/reference/diff/capture.mdx",
"apps/docs/document-api/reference/diff/compare.mdx",
"apps/docs/document-api/reference/diff/index.mdx",
+ "apps/docs/document-api/reference/extract.mdx",
"apps/docs/document-api/reference/fields/get.mdx",
"apps/docs/document-api/reference/fields/index.mdx",
"apps/docs/document-api/reference/fields/insert.mdx",
@@ -436,6 +437,7 @@
"getHtml",
"markdownToFragment",
"info",
+ "extract",
"clearContent",
"insert",
"replace",
@@ -1016,5 +1018,5 @@
}
],
"marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}",
- "sourceHash": "b61fad6a3a330af8a57b78ded260c8d8918486c9829b50804227fbeb15e8bf53"
+ "sourceHash": "e74a36833ec8587b67447a79517de348cfc9b4bba1c564729c184f6d5464a018"
}
diff --git a/apps/docs/document-api/reference/capabilities/get.mdx b/apps/docs/document-api/reference/capabilities/get.mdx
index f64034b1be..d928604dd0 100644
--- a/apps/docs/document-api/reference/capabilities/get.mdx
+++ b/apps/docs/document-api/reference/capabilities/get.mdx
@@ -855,6 +855,11 @@ _No fields._
| `operations.diff.compare.dryRun` | boolean | yes | |
| `operations.diff.compare.reasons` | enum[] | no | |
| `operations.diff.compare.tracked` | boolean | yes | |
+| `operations.extract` | object | yes | |
+| `operations.extract.available` | boolean | yes | |
+| `operations.extract.dryRun` | boolean | yes | |
+| `operations.extract.reasons` | enum[] | no | |
+| `operations.extract.tracked` | boolean | yes | |
| `operations.fields.get` | object | yes | |
| `operations.fields.get.available` | boolean | yes | |
| `operations.fields.get.dryRun` | boolean | yes | |
@@ -3071,6 +3076,11 @@ _No fields._
"dryRun": false,
"tracked": false
},
+ "extract": {
+ "available": true,
+ "dryRun": false,
+ "tracked": false
+ },
"fields.get": {
"available": true,
"dryRun": false,
@@ -10179,6 +10189,41 @@ _No fields._
],
"type": "object"
},
+ "extract": {
+ "additionalProperties": false,
+ "properties": {
+ "available": {
+ "type": "boolean"
+ },
+ "dryRun": {
+ "type": "boolean"
+ },
+ "reasons": {
+ "items": {
+ "enum": [
+ "COMMAND_UNAVAILABLE",
+ "HELPER_UNAVAILABLE",
+ "OPERATION_UNAVAILABLE",
+ "TRACKED_MODE_UNAVAILABLE",
+ "DRY_RUN_UNAVAILABLE",
+ "NAMESPACE_UNAVAILABLE",
+ "STYLES_PART_MISSING",
+ "COLLABORATION_ACTIVE"
+ ]
+ },
+ "type": "array"
+ },
+ "tracked": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "available",
+ "tracked",
+ "dryRun"
+ ],
+ "type": "object"
+ },
"fields.get": {
"additionalProperties": false,
"properties": {
@@ -19570,6 +19615,7 @@ _No fields._
"getHtml",
"markdownToFragment",
"info",
+ "extract",
"clearContent",
"insert",
"replace",
diff --git a/apps/docs/document-api/reference/content-controls/create.mdx b/apps/docs/document-api/reference/content-controls/create.mdx
index 177cb4c016..620c897a9e 100644
--- a/apps/docs/document-api/reference/content-controls/create.mdx
+++ b/apps/docs/document-api/reference/content-controls/create.mdx
@@ -27,6 +27,10 @@ Returns a ContentControlMutationResult with the created content control target.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `alias` | string | no | |
+| `at` | SelectionTarget | no | SelectionTarget |
+| `at.end` | SelectionPoint | no | SelectionPoint |
+| `at.kind` | `"selection"` | no | Constant: `"selection"` |
+| `at.start` | SelectionPoint | no | SelectionPoint |
| `content` | string | no | |
| `controlType` | string | no | |
| `kind` | enum | yes | `"block"`, `"inline"` |
@@ -120,6 +124,9 @@ Returns a ContentControlMutationResult with the created content control target.
"alias": {
"type": "string"
},
+ "at": {
+ "$ref": "#/$defs/SelectionTarget"
+ },
"content": {
"type": "string"
},
diff --git a/apps/docs/document-api/reference/core/index.mdx b/apps/docs/document-api/reference/core/index.mdx
index 19c242887f..6f4931ff98 100644
--- a/apps/docs/document-api/reference/core/index.mdx
+++ b/apps/docs/document-api/reference/core/index.mdx
@@ -21,6 +21,7 @@ Primary read and write operations.
|