From 40c7536170e6604b66d57daf3da5818097ff486c Mon Sep 17 00:00:00 2001 From: Abdeltoto Date: Tue, 7 Apr 2026 22:43:34 -0400 Subject: [PATCH 1/4] feat(math): implement m:eqArr equation-array converter (closes #2607) Made-with: Cursor --- .../math/converters/equation-array.ts | 38 ++++++++++++++ .../dom/src/features/math/converters/index.ts | 1 + .../src/features/math/omml-to-mathml.test.ts | 49 +++++++++++++++++++ .../dom/src/features/math/omml-to-mathml.ts | 3 +- 4 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 packages/layout-engine/painters/dom/src/features/math/converters/equation-array.ts diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/equation-array.ts b/packages/layout-engine/painters/dom/src/features/math/converters/equation-array.ts new file mode 100644 index 0000000000..88e92eac02 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/features/math/converters/equation-array.ts @@ -0,0 +1,38 @@ +import type { MathObjectConverter } from '../types.js'; + +const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; + +/** + * Convert m:eqArr (equation array) to MathML . + * + * OMML structure: + * m:eqArr → m:eqArrPr (optional), m:e* (one element per row) + * + * MathML output: + * + * row-content + * ... + * + * + * Unlike m:m (matrix), equation arrays have one cell per row and are + * typically left-aligned. Used for systems of equations. + * + * @spec ECMA-376 §22.1.2.34 + */ +export const convertEquationArray: MathObjectConverter = (node, doc, convertChildren) => { + const elements = node.elements ?? []; + const rows = elements.filter((e) => e.name === 'm:e'); + + const mtable = doc.createElementNS(MATHML_NS, 'mtable'); + mtable.setAttribute('columnalign', 'left'); + + for (const row of rows) { + const mtr = doc.createElementNS(MATHML_NS, 'mtr'); + const mtd = doc.createElementNS(MATHML_NS, 'mtd'); + mtd.appendChild(convertChildren(row.elements ?? [])); + mtr.appendChild(mtd); + mtable.appendChild(mtr); + } + + return mtable.childNodes.length > 0 ? mtable : null; +}; diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/index.ts b/packages/layout-engine/painters/dom/src/features/math/converters/index.ts index 0e7eb25496..31c1431010 100644 --- a/packages/layout-engine/painters/dom/src/features/math/converters/index.ts +++ b/packages/layout-engine/painters/dom/src/features/math/converters/index.ts @@ -13,3 +13,4 @@ export { convertFunction } from './function.js'; export { convertSubscript } from './subscript.js'; export { convertSuperscript } from './superscript.js'; export { convertSubSuperscript } from './sub-superscript.js'; +export { convertEquationArray } from './equation-array.js'; diff --git a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts index e689c8f842..7a81f9b01a 100644 --- a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts +++ b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts @@ -927,3 +927,52 @@ describe('m:func converter', () => { expect(mis[1]!.textContent).toBe('cos'); }); }); + +describe('m:eqArr converter', () => { + it('converts equation array to left-aligned ', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:eqArr', + elements: [ + { + name: 'm:e', + elements: [ + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }, + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '=' }] }] }, + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '1' }] }] }, + ], + }, + { + name: 'm:e', + elements: [ + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'y' }] }] }, + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '=' }] }] }, + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '2' }] }] }, + ], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const mtable = result!.querySelector('mtable'); + expect(mtable).not.toBeNull(); + expect(mtable!.getAttribute('columnalign')).toBe('left'); + const rows = mtable!.querySelectorAll('mtr'); + expect(rows.length).toBe(2); + expect(rows[0]!.textContent).toBe('x=1'); + expect(rows[1]!.textContent).toBe('y=2'); + }); + + it('returns null for empty equation array', () => { + const omml = { + name: 'm:oMath', + elements: [{ name: 'm:eqArr', elements: [] }], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).toBeNull(); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.ts b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.ts index 1a5672d61d..00bf841b63 100644 --- a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.ts +++ b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.ts @@ -18,6 +18,7 @@ import { convertSubscript, convertSuperscript, convertSubSuperscript, + convertEquationArray, } from './converters/index.js'; export const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; @@ -50,7 +51,7 @@ const MATH_OBJECT_REGISTRY: Record = { 'm:borderBox': null, // Border box (border around math content) 'm:box': null, // Box (invisible grouping container) 'm:d': null, // Delimiter (parentheses, brackets, braces) - 'm:eqArr': null, // Equation array (vertical array of equations) + 'm:eqArr': convertEquationArray, // Equation array (vertical array of equations) 'm:groupChr': null, // Group character (overbrace, underbrace) 'm:limLow': null, // Lower limit (e.g., lim) 'm:limUpp': null, // Upper limit From 4df681aba49a8c39ff2866b94f67a4b792f1db89 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sun, 12 Apr 2026 17:28:01 -0700 Subject: [PATCH 2/4] fix(math): strip & alignment markers from m:eqArr rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per ECMA-376 §22.1.2.34, '&' characters inside m:t elements within an equation array are alignment markers, not literal text. Without mapping them to MathML / (poorly supported in browsers), the previous implementation rendered them as literal ampersands. Strip '&' from m:t text before recursing into row children so real-world documents with aligned equations render cleanly. Also adds tests covering the m:eqArrPr filter and nested-math recursion paths. --- .../math/converters/equation-array.ts | 26 +++++- .../src/features/math/omml-to-mathml.test.ts | 90 +++++++++++++++++++ 2 files changed, 113 insertions(+), 3 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/equation-array.ts b/packages/layout-engine/painters/dom/src/features/math/converters/equation-array.ts index 88e92eac02..41f613b29c 100644 --- a/packages/layout-engine/painters/dom/src/features/math/converters/equation-array.ts +++ b/packages/layout-engine/painters/dom/src/features/math/converters/equation-array.ts @@ -1,7 +1,26 @@ -import type { MathObjectConverter } from '../types.js'; +import type { MathObjectConverter, OmmlJsonNode } from '../types.js'; const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; +/** + * Deep-clone row children with `&` stripped from m:t text nodes. + * + * ECMA-376 §22.1.2.34: `&` characters inside m:t are alignment markers + * (odd = align, even = spacer), not literal text. This implementation + * doesn't yet map them to MathML /, so strip them + * to avoid rendering literal ampersands in the output. + */ +const stripAlignmentMarkers = (nodes: OmmlJsonNode[]): OmmlJsonNode[] => + nodes.map((node) => { + if (node?.type === 'text' && typeof node.text === 'string' && node.text.includes('&')) { + return { ...node, text: node.text.replace(/&/g, '') }; + } + if (node?.elements) { + return { ...node, elements: stripAlignmentMarkers(node.elements) }; + } + return node; + }); + /** * Convert m:eqArr (equation array) to MathML . * @@ -10,7 +29,7 @@ const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; * * MathML output: * - * row-content + * row-content * ... * * @@ -29,7 +48,8 @@ export const convertEquationArray: MathObjectConverter = (node, doc, convertChil for (const row of rows) { const mtr = doc.createElementNS(MATHML_NS, 'mtr'); const mtd = doc.createElementNS(MATHML_NS, 'mtd'); - mtd.appendChild(convertChildren(row.elements ?? [])); + const cleanedChildren = stripAlignmentMarkers(row.elements ?? []); + mtd.appendChild(convertChildren(cleanedChildren)); mtr.appendChild(mtd); mtable.appendChild(mtr); } diff --git a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts index 7a81f9b01a..c3707292b8 100644 --- a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts +++ b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts @@ -975,4 +975,94 @@ describe('m:eqArr converter', () => { const result = convertOmmlToMathml(omml, doc); expect(result).toBeNull(); }); + + it('strips & alignment markers from row content', () => { + // ECMA-376 §22.1.2.34: `&` inside m:t is an alignment marker, not literal text. + // The converter doesn't yet map these to MathML alignment elements, so they + // should be stripped rather than rendered. + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:eqArr', + elements: [ + { + name: 'm:e', + elements: [ + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }, + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '&=' }] }] }, + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '1' }] }] }, + ], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + const rows = result!.querySelectorAll('mtr'); + expect(rows.length).toBe(1); + expect(rows[0]!.textContent).toBe('x=1'); + expect(rows[0]!.textContent).not.toContain('&'); + }); + + it('ignores m:eqArrPr properties element', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:eqArr', + elements: [ + { name: 'm:eqArrPr', elements: [{ name: 'm:ctrlPr' }] }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'y' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + const rows = result!.querySelectorAll('mtr'); + expect(rows.length).toBe(2); + expect(rows[0]!.textContent).toBe('x'); + expect(rows[1]!.textContent).toBe('y'); + }); + + it('preserves nested math (fraction) inside rows', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:eqArr', + elements: [ + { + name: 'm:e', + elements: [ + { + name: 'm:f', + elements: [ + { + name: 'm:num', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }], + }, + { + name: 'm:den', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'b' }] }] }], + }, + ], + }, + ], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + const mfrac = result!.querySelector('mtable mtr mtd mfrac'); + expect(mfrac).not.toBeNull(); + }); }); From a6afe49dc00311b15cb6bc96a879ff26d9197f45 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sun, 12 Apr 2026 17:46:45 -0700 Subject: [PATCH 3/4] test(math): add behavior tests for m:eqArr converter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds math-eqarr-tests.docx fixture with 5 Word-native equation arrays: basic, nested fraction, nested subscript, alignment markers, and m:eqArrPr properties. New test.describe block follows the convention established by limLow/limUpp, delimiter, radical, and func suites — verifying mtable structure, nested-math recursion, alignment-marker stripping, and m:eqArrPr property filtering. Also registers the fixture in the R2 rendering corpus (sd-2754) so layout and visual regression suites will auto-discover it. --- .../importing/fixtures/math-eqarr-tests.docx | Bin 0 -> 11430 bytes .../tests/importing/math-equations.spec.ts | 107 ++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 tests/behavior/tests/importing/fixtures/math-eqarr-tests.docx diff --git a/tests/behavior/tests/importing/fixtures/math-eqarr-tests.docx b/tests/behavior/tests/importing/fixtures/math-eqarr-tests.docx new file mode 100644 index 0000000000000000000000000000000000000000..14f5b31a4bf175a32c034ce7f332edaf0d4835e4 GIT binary patch literal 11430 zcmZ{K19)9qw{~o!Nn_i#ZQE*WvvC?*joR2Y8{5f_Z99$s^qlXzJ*WS@`+4S`&&2z# zwb$5l%rVz^6{JDI(13t|pn&esqIA~trma`k3? z>D+Cs>k~&Tdl-;J?}J|hr`Q&xS}5{+hrY*{Vw*n#m$1l*m`a+HRd$|p$O%(gz{%Dy zE->G(F?Z)~y6d)DLqpqK772qJE6O5^LW0hK4P`h^MO$U-%U& zwR*{za!=$^Pioew2>kuD-RT%W#S}kP2?(|oE$_Kh{2D=TA`)o!r*g-t0lPa$W3x|3S=JBkt zvMS#(RGm_Ef=%+EJr!$fIBcChGpi)cRRP#-8NuPhy!YyeumFqfI zF9mu7q=8N{xRKQ^E?pVJ@%J(b(D&u$LIbajPs+Io!`P9Q_BTwT%g3rE&NA|l;@G)f zj8D#!Y-TwTaR}phUje76i;p`GInD5Jsi2FFUY$`LKnnjd0(wzJis8Evx}-2Pu<?aO(*3jao}H?Kv25wIdND{3UaSh{#Va6Kg?2z#?}t|0otiBnGJA`;RV+ zt`MNLvPz+nk01+m-LM}mT)t!wG`Yx8QxcUY@>3q#RJn}md1h-KL9o#RUv&t3Tu^1j z&!TnbGA-ycfb9%N^uq$~cEEnf!9-!Frvro_!R}S1LiFVlAsv$)SbZ<|H*lDPgLsRU^g3ZV8M}?LzKmWmih+ zZBenSCUTZ<_`J=Kv4uio*o(3P^=PRef*~meDd{pPO5H^f0wRSpzd;dpkyM`}?c|hm z`b;EkYX#0e)xkMQ-UC@xQj(KiSG3IEjX#nCxGUkqh*C#W$YM&@u!S8|_J-FrLC{|p zuu;cD!BB?pmCBrePFV}jo~SWT>saIH2yKZ^|H#fejMLwxFqY(UzF-jp6HOOZEqz~w zjc`(ohkxC;Dr3%ZwWq(MC=F-f$=Iw7pSnLP$wmD%{$%pnrkBHmuw9JL_fxSgdYdFX zp5fWL0eegzd}IXVP3;7nE3hA*FQ*u2|wxV#QbN$9)i<_QbQW&p5dF?Bt(@Y}=ft z@b%jYFwCwpB^{iGfUJ@dk`K$M!JGksp*%AWlNPN%UCmICm4HV`gy_72`&LSPU;V3S zshw^N^-GHdt3Bg*8RI>|Mj&0Ht@Cc`it3S@i=l%iC<+EyOh$1=SOeE&GYj!3_TcqB z%7qMUXSwy}VCkJ*{%OTh2m(j{i|4tyqQ|R`5oY9qY=k*$f78XZ*)}vQc@r}aHEa7$ z2yuiwXc)w2^loB58m#^nGoaa3+Yv(IO4T0CdYt%Mj_Zs=;2K91A}to*h5nLz4XPPU zK6t1TCa;AQu@@~iagZPw+p z=9$CA_yfC9VusztsRkn_B-Frk4SK0bKQT{9Z5QU4bcslIC zFmK^J*RlQuYQ)z--P4e2LYte>6suv~mdS;kx1Rzn)%e0UM^kFL$ZI7=x|dc5YHax& z3z8aRz6tdvP9eq?#0#9SwRhe*_>RmTvQ&kGHO1mWI2&9LH8oa{z!1L>L@n?6Io|b& z;I6!&tk4mwgY=zUA`&wpmN0}T`$vr5RL+m&z(}5)eN&xZX?yB6trv;ct!OnIuHUP} z#&BOw*uykiXv?W2W?ylv@aoYmUaevabvHz<1QTh}+AbJdzm=a>yKf?Z<41~$Oo>QJ z2p|&ylv{Egyl&dP{;|G?)d#oX-#zXsBoGkFf7iF!do-eAXk`7D+g(l2wfp+Z?UJAJ zYjoH##O6^_70=hlxzFc|sYr_B{?jo2!#WU~;;iv7>!;DQrk{GuDpT2N`Ml|*Q z5l#4KRUJi*)O0I+nkd!T2X(J7w0io1$MBD;%H(%KFmBN(Li0@n3o!~ZnlSzNaNJZC zx`Qz^DOu($D&Ir==e>jGF|}vs84xzS)M~aVscyo)q+k$`rm`AHHC~L}po1sM$4F{W zK0s)Ufs$Z+X5Fs5#{8Ov{4h!>j&{v)97h}uy+0A21{A(cBNC6`QWwMK(;d)_KF71N zp*XvzBu))8=Ve5>kDP*P)c7OMkY1TAo4sxhil=m5xUa?z(uQVLTK=jP27&<2IKY*OsLV6YgFv%eOH z8vNrIttqwnU;!NO4Jfa8W4|0INj5-}@>ViVMV)%QB4K-chq}f_5a6d7fckO=`)#7g z>bQniKN;?HP6d18azRh%Y$%;Zl4)(E;%6sI=zuF+OzY}0PKW2NXKhPoYR}mDe$`j* z(0Q{Pf}G@==eMO75;Vx0O!k9NMU}VipS{@G@k#7{yMRNQZJUkPp8k8-g;%hDZw}o< z1-k5DKtRQC|JWRyOr4!AY(F{u6~wA9+b(k;`RE(G2~J*fwP?5+eUmdBxFi#8)19Tf zOGB}UfT9PHTy(&AI_FLlk+z5=vW-hgO=os86Tus&z~|$0+B*%KRtYc0*A40r7olTB zUoL*Tb>M8e;Epc=b1b+rB;T^jRoO#{{)q0)_deoF)I#oyr6cl8F&S>_aF` z1hgX27w0ct56___ugW<{N+0*_qvh8XF=)Hg`X2Oz#1I!t1)^;18LXG;+xodZuIDUVr}_6 z*a}o>sO6iVIh??irby-Vka3;#1r&aA6_SKUJN_yw_RGy_(?XRH3(iFb3opEVh*;F^b9!m-K53XvA+`li-wd5@Tm;>v`@H!8KyR$CpJ#+;M<2B zvw4GQ@{Q#)#;@ZJgWNp=ylx(avy+zLc{(Wp*i*rzu57oGwL)93yJBF?+0zZ+xvLVNFPPrC z+JYu%fkClYWuMBi(zLWmU7b$8#N?u$>)_ScqcPFq32c(5Xa^j`ElXD`n) z{BM`r*Pl*I$sZ? zAHz#O2jl-7lJ;|VWjozXErJ)NN$D6_hho>gW|+Er34@HM=yu<=+X}c_3Av*ovO|SV z8K~wtbcwy3A4QZNHGmVaT8|Q;NghJHGPxq2rm*1SnDHwQq%{Ztg#$J?x~k;p=lOJ# zZ<@62#W7yD^HVR_*o^`GbINz59z_S7BfsIc?(p_r?M?e&F(1j> zJRQ>+AvTvq1l~Q##>3$8fOd-z}52ufW*}Q(UJc=_0`wS=8pd` zcZC4}fb2_#PSLbs;8hk!`bqT#;nVe2Poav%(6q=-l=YQZ$pW8(3>kOY<5?{n&HdG` z*P+!)tNc#%NS3X%?ep^)-IXXw?TP96GtavF1mLy&+=Hjgn^ETh@6D&LXM*v*VcD;B zDmuf?UKDG%Q@pnL?mNNVny<@)*sg9fyNT3H%rJq%y4;J+_R_N-xOMD^+NaJf8C~y; zAADOlQJ7Q_O^yw>qt53mkOv`R1D59AWg%qmm1kLq*?Z+%7Bb%HBJ6uGbUc{T@lk-c zmr;Z`-583EWdKRqJep8t5=WRMhZ90v+zHM%au0I_?T2KL)m3+RZ4BJn+9Ym-wPm7zrA*+iXrF zb5wAAKD8rOR?ER<*bWIagQnRCeQ%}(-Ap#3*I?fAEstVwE^+esw4Vy8{66UZCI(&@ zyj$wNBpxBdRt*L7O<0t?xc3k-UQ5Pw^mycdb_?tv0#9XW(@m*nE|u^s1zr|8jH}e3QHOe% zDlEB2-!sH!l{uq|dK|G7J566D=j`4a>%SVib!RVk8|1+2_%3iu6RWzAL(Et~izVqeV{`jlL%3n$;sl*G-mHJSDQETSlBY!)+1Hg{g=a zN7EyXq3Dv*yGME=2pzKg>?*B1u~Hb3$7a5)4t`G~Q44HyZ*DB$>Al(-J!8t474zh9ha8coae{i`C(?cp;H&DxWFIP6XA7D#u}h>eTj1MAALf>4%|CGw_06Ov8jxE+QW zM4x!8qP>@H{SN`6c<`rmTZ`8e(BwpGwYOwB;k0QO*`}|AnD7>BewsU_9)u-1o&^4? z1@PO>mym9)g)K4NglQiiS!HjYbMcN`JC|DnkNIR4ez@QhnX~Th3O>;P7&_Z%$v#1d zJ~F8U{V>pR`Jse0=ExZq@Ako!{?#!HN~BH(v;~3Yvk9*T&`^s4*dbRW<%GsHl)T1u z(L~CmDNBrjl-%_z>H}UHCYD%!P>AACj-d*cIc71?YW8GtR||iEvcHR;8hIcxJTMgl z6+dzNGwSMYaaYl^OwaFPebc`=l8*y6qiaMZL6u!fa7f2u*bTr&u09BcdSKg#i8Kgc z$z2sSD)xmY?mi*34d1kGk)VKF{unK_v;GipGKm&()dXcGqKO10$kN!*zF|JH`U!xL zcBmiV%oo)9x!Ic(Bt|^M$+9fvyf)kP1mE}RE9v>mfa=ytJ4E~nSzaIlpEyoNVL05_*S zjPcp5qu8mP&Z)^qC;kRGb~BOVHe<0mIRP_~QW+B#@q7~Id>30LVl8q?W{}MC4Rqj? z@*G||Z9%WE*5H(EizPi2U6JOzI1I#-{xHY~0;dd048#ilFr(!l=t_Tg1w%RKbOU43 zDSYF7FYU59LgDWPhD(7E1h)vuA3-Hd0Fp{12XxNS+K#0WMnyvKuO>Rtq(9dgX3-=b zM$zONnF3+odjbC!;@1=y}%TkcNShV{;mm6 ztn3Bx=W%lGBKf&&$Mex|4dUqHre^hTwB1&OSs6?Cc+p~~gs(&w!CBwgR@5pTKOJCa zcj~dfbr<$o@hoLYy=p`GOrUiQ*wFsS7;YNcEltiD#Af&KoI{D^8jxLVw1lP$+%Cu~ z-L7$`5Y>zR?4X=pjEM-hC%f2UgwWbVDXtD@h{$fb7fyJxQQ)n^n888Cd9E$5bvIMz z-GPRbzEUleng%Uv3ti7Ne%kn9Ovb!4D{WFuj2Sq;5IGQIK2nIJ+T+W%(f#+{K1Qy;AV+-KPn)X$GXMT?^9iit|dsiJ7w(N+C&DC{;?r zAZka^7$(I1dX`}|$ynb{@70`@-R zAABkavz{ONIGCg;&6vjl!hI(S;){i;ZkFX;q<;=TK&cE3`_&&D3dC=W0un zOPH2ar3K_$EsNDOetj9XS`N{$zRyTl;!`*+$>%C|n2T4m)Ty7Pc<;4L#8RhC%kNi} zc2U~?HTeE}bn8ui^|DGul_q2T8Zvu_C}qwzSr*H^<5b9924HJ+{}23sW)A%i`Cl}n zf0O@{=8tRO=Y5gCfGK)njX()K^KodT(IYK;8iD2yMUD}@2=+oaE$$G{G=1~<^g!J= zFO58WPF^!Hi#(8oK-S7I?qWs`f+urhkEyyKkjC z(8Y3Jxic&**~Af`s^U_i9kbf3)iKP-Yijg1Ty$}*y!695=Q@ANeguUYY@ccC#JDX) zVB@|=*=|G%uS3TJ#qv1@ejXpse0IZeI_)fXp9x}QP4L*2>G&R}q=}u4=Nl=7_Hh2l zvbl~)^vhJ$V8+q|+j_y>c^Pw~akK6xIh(Z#=KT^1`j}%gp0Aecllt=?H);l)pY7O7 zLzQNoAlN?|w$kW2)Hb)!tTu4~jDB!n?|;O~{3LP6)AM1x7JlT!i(Bv^YSn6Tx*cM@ z&?_orvc1Lzch$}sro*qYijMB#F$?iDZiq+rz-yu zo7eQlhNo}h)eAS_r>uBPDkiUy&>?cdc@J@aVH!8rW7q5fsInA(YE~0sWGSD4v;WXk zLidi&%d#6({n7M1E}nON`uSX97q*$0U8S zx92nBrz3srmvx0mb5RW=<|NJX>K~Il-AAN?h zZj(ueNjdRr2Xafc#?91QYrAbQgV$-dgxvbOIum3Qr4jzM;NhvPq?APQBOlZDtq1@Q z6kpZJvXSwtWmk%1gPT2Dvi-s|{>ucLlA#u;{Vm3V=x zneFjq#`29fhU`(MfCG!|m+j>==~e#WWuv9}#l7tIAT?~r0v0xo*zQruL$5Tz5tB`L zqVod9VXVY7;aSPe3-yZW9!l{=k>Yz4(Vc6Tf3fa!g;YG>q*%@vr?suDe7u0_E}=>m zRU#9nl9rK9MPArbrx;2ZhcKf&vsuKfDIsYV6+aqAl9riHQb^j}rWnK-2X(EC`aZ@Z zAvums%1Iu-^E^xPAYHIngJE)V@6wQD?^s>5k>|4LNe zi@L(hHF~DZGNw|~2QJ0R0yT}5|I&Y(J-LjJsg@P|E@`MisW*>|6?|uMk8$;}aPfxy)gD$_we_k< z-3{0VQ#*QMwv(+yG3Cc{MM+YwRiX+DYpFehNc57^EnVqZx^>F#!g*=CDmTId!%6tm zLN}q0PpI^2wo>H+H#bkR)gj%k%)LlCivv{z!TG z%!mq&vSLX`xd3}{7WpecZ?~i9RgZ?3Qa|}g{x&Q#VOO{UGFNWxGk&4NSwSQ09hvhNE_U;SG!(0ALPFD<;ZsYN;0l5T%`^iwBk8$)$NBYdH|FWI) zSbSeOpGvZhN%>wo`bZv%PA$Cby1(E~UWa%@=T`RXABS|ZwMPlF_v{_*`|H48dG5dR zL~f==%Kvy9m^q_p*G+%~v~f@Rgc#(O&&4Wj%&PezrJY8);RPtDloSL2RMDc(uT@@p z*?0D3`mSRIcfh-uJ>5I-8pcLUye9~y3M9YgYoxDF1w>5Z&#v+vELxKrpe#C!d$((< za)TFO!d`yfFX6Yv$RBBiD3tr#D7x(Vdr_>PQ*)Vlddrs?dRAT45x~;pA?ww&ff`|=6F8ExuOD5+zvM7s_qlLi;&f^XjVW{O#7k(oJNM^Fqp z6z`fLvM+>qIT zk16_!NgS#qKohoy=3zMzU@ew~mMkS^oLj=~F-CGCAN|o=$U~DL2aW(bR%L7JdRrm7 zI#+%4|9*Kgsp=a4009I9PXq*n{$IN|o15C0(*O0%NcWz@JJr&TS?)*orJsCb*k~P% z5YdGj@vm?=(e0uO#omf%V!HxX7uSm;I85NlV8CnFvUs#^Zn|pyl_M0V-&*pzd4)PaoJctM^Xy&Mu zF{82RG(YfnY@&8|fs+VJSD;RhI60Hj2oPXgbx%6Z#+a_R8>^ELVKuR?>AWPi#UoYl z^chcmC*oqRTz8;;(ldNRVn5#ROyULqEJ$!Z%iGoB3pn&5ZGfQC!?H&7wACH}oHQby zBDNS;=58)?d+%T1?FTN-0xmLf*Z8|)aO`-@t=#=W&+#_6YZWKHTDXIUL(8d$9@ti{ zpC=!{)a31CA@d!$X0Fu*oh!J~w!-*Qr90C4%Ts=p{tQEh#sO}W@QoCX#5NIn_y^i` zWLFe8Q>qeOA?}2W>6Qyl<)bQIXd`l2bwu%Je3@NOAVkdZ-pLmFaxK=uh-_ca*OP($ ziPy{L>xpdsCv1*xMH;v^FNUb2w(PgZUBAv3(4Q0QZ&%mqeoWgQxNlca{2kBxoqDEv z<~6U+JtO#j?w7Y1Z?9+6px(V3-x2r+ptm*%Tfhog;@HhKfM^$%8OM#ojw2PZ98$Y8 zj0Gk{%D&;-68J-`U>^E6K~UheNDj7JJVw~EXP^|v$&4ohCJ6@;WEaChJOPRGamp>} zy$nN<=?++c>gxO>#d-sRKNff~3pRFcjbvjSTGq7Hm^ z6X>|1pJAk0UvL?%N24#frj?$c2tnu#!;svczd*|~TYm82PgdQz#8G+i~3Vc zP2Q*|UzKlf&wI7%#^yeChvx+x=Vl&9b76gfeLt4KsXId$E?Q<+O=;dtsv{H4#%721 zR<;#3upMwi!r>zed2{T-SJUZ!Io5byzTEuES6G~Q;>Ir9^pa&Jq#4HloNNXIIE0Qb z9F!Gznwfg$cV&@%dMJo#J~Xy{Ow})sS*)jXePN8LiD%)v)-)0t?+wWzu1LqDyZ7Zt zNwCb3m?JKrNv0p!Wzp%)@r1`j@9ZJsi!dZhahQWark54RF-aWhAF`|~U#f6%lrxk6*AcN{)D>AK_zv)8My_zurGkJ! z_qkW4I?+YII#x)m;@YgLwYfwl2%&~jx*jxTWvg0_~{&8{KMwWBL3#7 zi}1Vk-C>tU+yU3Xy3$jh9NeKln2gDNb52}$x;_X=ji8z(G^;;Z^KhutOY%~Dy7<&6 zPB>(wfk%03sn<1kEG2i!mpJ{>S|dxRBXWJr;i@mcRGJP?E>T$>+1{z#QBY!|wu>FH znseUvnCgCy`i-pIE_t3!+KP*(qMEpf7GQJn6@WSB#)EKIJ72+?Z0UT_%%;nel*XTQ zdX$dFOp-)Q%E@gg^%M~D^@?<}kB;)pLK{NDGdvk6RK3O|Jww^oUI)E;#>oHqq9jbL z@~9W3lbQ#8(TBI$$lB{Jyllxa0oXycTxTQVv#M(DO`^@!cklCo4U?y8TSd9KlLire z=qfXJ^Ov+81+4Wqf(KAO+xvo|_Pd09Qt>exoK4iOJI2>MxK0Bov)KGRl zXwi0mvZNO zkLB9q%63*C4wzweD5|)#p)TecvM)-}Npy|J^I~9ZjvB{-t=K`_)q(KO*;+JWt{|kkxA|k0+>v zYc)q|Rp=)WAdM%;SUZa{wsU|AL`hLq*Mt+0?ld8xSI!Sfy~e0?o*6(iBACP6ai&|k zQNOgp6F@LuF+b0(!a($+rL9AYH+L>0x#$@5i-<#BjxmBX<&ZOn`LMS@iXIj|lBaBY%VxcyU;bhwqO^?p-zj`(FS3g#PnXe!Pno;RMbDUjnDv6_C3lGUuc*&dpm&XnR4H@QQYX;I@o^^o23wuTEj6jg9GEtI_RMIZ9|^in64< zrNOdhpeN0UGb)3@x@em^^33fl?E4Z-1N{y93;mO~nGTB9>wfs&M}D?*Ld`n;O*`xi zUBT;j5d|;^+Fyb0|1Ri!e=Ps_{4es(KMDR6BmPDQzem-7N)!J?|EZn(jYfO_FaHnv zze>74;eU#}e#1-N!}wSZxPe!MT_3Cdrs{|A9GENuV) literal 0 HcmV?d00001 diff --git a/tests/behavior/tests/importing/math-equations.spec.ts b/tests/behavior/tests/importing/math-equations.spec.ts index 8a009063cd..79fff954d6 100644 --- a/tests/behavior/tests/importing/math-equations.spec.ts +++ b/tests/behavior/tests/importing/math-equations.spec.ts @@ -5,6 +5,7 @@ import { test, expect } from '../../fixtures/superdoc.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ALL_OBJECTS_DOC = path.resolve(__dirname, 'fixtures/math-all-objects.docx'); const FUNC_DOC = path.resolve(__dirname, 'fixtures/math-func-tests.docx'); +const EQARR_DOC = path.resolve(__dirname, 'fixtures/math-eqarr-tests.docx'); // Single-object test docs are used for focused verification by community contributors. // The all-objects doc is used for behavior tests since it exercises the full pipeline. @@ -220,3 +221,109 @@ test.describe('m:func (function apply) rendering', () => { expect(fractionData!.denominatorText).toBe('x'); }); }); + +test.describe('m:eqArr (equation array) rendering', () => { + // Fixture (math-eqarr-tests.docx) contains 5 Word-native equation arrays: + // 1. Basic 2-row — x=1 / y=2 + // 2. Row with nested fraction — a/b=c / x=y + // 3. Row with subscript — x_1=a / y=b + // 4. Alignment markers (&) — x&=1 / yy&=22 (ampersands must be stripped) + // 5. With m:eqArrPr properties — x=1 / y=2 (Pr element must be filtered) + + test('renders all 5 equation arrays as ', async ({ superdoc }) => { + await superdoc.loadDocument(EQARR_DOC); + await superdoc.waitForStable(); + + const data = await superdoc.page.evaluate(() => { + const mtables = Array.from(document.querySelectorAll('mtable')); + return mtables.map((t) => ({ + columnalign: t.getAttribute('columnalign'), + mtrCount: t.querySelectorAll(':scope > mtr').length, + })); + }); + + expect(data.length).toBe(5); + for (const t of data) { + expect(t.columnalign).toBe('left'); + expect(t.mtrCount).toBe(2); + } + }); + + test('preserves nested inside an equation array row (case 2)', async ({ superdoc }) => { + await superdoc.loadDocument(EQARR_DOC); + await superdoc.waitForStable(); + + const hasFracInRow = await superdoc.page.evaluate(() => { + const mtables = Array.from(document.querySelectorAll('mtable')); + for (const t of mtables) { + const frac = t.querySelector(':scope > mtr > mtd mfrac'); + if ( + frac && + frac.children.length === 2 && + frac.children[0]?.textContent === 'a' && + frac.children[1]?.textContent === 'b' + ) { + return true; + } + } + return false; + }); + + expect(hasFracInRow).toBe(true); + }); + + test('preserves nested inside an equation array row (case 3)', async ({ superdoc }) => { + await superdoc.loadDocument(EQARR_DOC); + await superdoc.waitForStable(); + + const hasSubInRow = await superdoc.page.evaluate(() => { + const mtables = Array.from(document.querySelectorAll('mtable')); + return mtables.some((t) => t.querySelector(':scope > mtr > mtd msub') !== null); + }); + + expect(hasSubInRow).toBe(true); + }); + + test('strips & alignment markers from row content (case 4)', async ({ superdoc }) => { + await superdoc.loadDocument(EQARR_DOC); + await superdoc.waitForStable(); + + // ECMA-376 §22.1.2.34: `&` inside m:t is an alignment marker, not literal text. + // The converter does not yet map these to MathML alignment groups, so they + // should be stripped rather than rendered as literal ampersands. + const alignmentData = await superdoc.page.evaluate(() => { + const mtables = Array.from(document.querySelectorAll('mtable')); + const texts = mtables.flatMap((t) => + Array.from(t.querySelectorAll(':scope > mtr > mtd')).map((td) => td.textContent ?? ''), + ); + return { + anyContainsAmpersand: texts.some((s) => s.includes('&')), + hasStrippedRow: texts.some((s) => s === 'yy=22'), + }; + }); + + expect(alignmentData.anyContainsAmpersand).toBe(false); + expect(alignmentData.hasStrippedRow).toBe(true); + }); + + test('m:eqArrPr property element is filtered out (case 5)', async ({ superdoc }) => { + await superdoc.loadDocument(EQARR_DOC); + await superdoc.waitForStable(); + + // Word emits m:eqArrPr wrapping m:baseJc / m:maxDist / m:rSp / m:ctrlPr etc. + // These must be stripped by the converter — they should never appear as DOM + // elements named "eqarrpr" / "basejc" / "maxdist" / "ctrlpr". + const leaked = await superdoc.page.evaluate(() => { + const leaks: string[] = []; + for (const el of document.querySelectorAll('math *')) { + const name = el.localName.toLowerCase(); + if (['eqarrpr', 'basejc', 'maxdist', 'objdist', 'rsp', 'rsprule', 'ctrlpr'].includes(name)) { + leaks.push(name); + } + } + return leaks; + }); + + expect(leaked).toEqual([]); + }); +}); From 10a05e0d1c4b7da7904bc845128f06cda37d8c36 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 13 Apr 2026 14:36:29 -0700 Subject: [PATCH 4/4] style(math): wrap m:eqArr cell content in explicit Align with sibling math converters (fraction, subscript, radical, bar, function, limits) which all create an explicit inside their container elements. MathML's implicit mrow semantics mean the rendered output is unchanged, but this matches the directory convention and makes the JSDoc shape match the code. --- .../dom/src/features/math/converters/equation-array.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/equation-array.ts b/packages/layout-engine/painters/dom/src/features/math/converters/equation-array.ts index 41f613b29c..2028257385 100644 --- a/packages/layout-engine/painters/dom/src/features/math/converters/equation-array.ts +++ b/packages/layout-engine/painters/dom/src/features/math/converters/equation-array.ts @@ -29,7 +29,7 @@ const stripAlignmentMarkers = (nodes: OmmlJsonNode[]): OmmlJsonNode[] => * * MathML output: * - * row-content + * row-content * ... * * @@ -48,8 +48,10 @@ export const convertEquationArray: MathObjectConverter = (node, doc, convertChil for (const row of rows) { const mtr = doc.createElementNS(MATHML_NS, 'mtr'); const mtd = doc.createElementNS(MATHML_NS, 'mtd'); + const mrow = doc.createElementNS(MATHML_NS, 'mrow'); const cleanedChildren = stripAlignmentMarkers(row.elements ?? []); - mtd.appendChild(convertChildren(cleanedChildren)); + mrow.appendChild(convertChildren(cleanedChildren)); + mtd.appendChild(mrow); mtr.appendChild(mtd); mtable.appendChild(mtr); }