From 699d36f38d2d9280a2e6a76771fce981e16fa66f Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 20 Apr 2026 15:41:52 -0300 Subject: [PATCH 1/5] fix(math): preserve italic on variables inside function-wrapped limits (SD-2538) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Word wraps lim_(n→∞) as m:func > m:fName > m:limLow. convertFunction was calling forceNormalMathVariant with querySelectorAll('mi'), which recursed into the produced by m:limLow and stripped italic from every variable inside m:lim. Scope forceNormalMathVariant so it walks only non-structural children and never overwrites an existing mathvariant — preserves m:sty/m:scr authored styling on the function name and leaves nested limit, subscript, superscript, matrix cells, etc. untouched. --- .../src/features/math/converters/function.ts | 32 +++++++++- .../src/features/math/omml-to-mathml.test.ts | 58 +++++++++++++++++++ 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/function.ts b/packages/layout-engine/painters/dom/src/features/math/converters/function.ts index 65d1461f88..28e28718c6 100644 --- a/packages/layout-engine/painters/dom/src/features/math/converters/function.ts +++ b/packages/layout-engine/painters/dom/src/features/math/converters/function.ts @@ -3,10 +3,36 @@ import type { MathObjectConverter } from '../types.js'; const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; const FUNCTION_APPLY_OPERATOR = '\u2061'; +// MathML elements whose contents have their own semantic slot (base, subscript, +// limit expression, matrix cell, etc.) and must keep authored styling when +// m:fName wraps a nested construct like m:limLow. +const STRUCTURAL_MATHML = new Set([ + 'munder', + 'mover', + 'munderover', + 'msub', + 'msup', + 'msubsup', + 'mmultiscripts', + 'mfrac', + 'msqrt', + 'mroot', + 'mtable', + 'mtr', + 'mtd', +]); + function forceNormalMathVariant(root: ParentNode): void { - root.querySelectorAll('mi').forEach((identifier) => { - identifier.setAttribute('mathvariant', 'normal'); - }); + const walk = (node: ParentNode): void => { + for (const child of Array.from(node.children)) { + if (STRUCTURAL_MATHML.has(child.localName)) continue; + if (child.localName === 'mi' && !child.hasAttribute('mathvariant')) { + child.setAttribute('mathvariant', 'normal'); + } + walk(child); + } + }; + walk(root); } /** 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 898ab4da70..170327c656 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 @@ -2299,6 +2299,64 @@ describe('m:limLow converter', () => { expect(munder!.children[0]!.textContent).toBe('lim'); expect(munder!.children[1]!.textContent).toBe('n'); }); + + it('keeps variables inside m:lim italic when wrapped in m:func (SD-2538)', () => { + // Regression: convertFunction used to force mathvariant="normal" on every + // in m:fName, including variables inside a nested m:limLow's m:lim. + // Only the function-name text ("lim") should be upright. + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:func', + elements: [ + { + name: 'm:fName', + elements: [ + { + name: 'm:limLow', + elements: [ + { + name: 'm:e', + elements: [ + { + name: 'm:r', + elements: [ + { name: 'm:rPr', elements: [{ name: 'm:sty', attributes: { 'm:val': 'p' } }] }, + { name: 'm:t', elements: [{ type: 'text', text: 'lim' }] }, + ], + }, + ], + }, + { + name: 'm:lim', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'n' }] }] }], + }, + ], + }, + ], + }, + { name: 'm:e', elements: [] }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + + const munder = result!.querySelector('munder'); + expect(munder).not.toBeNull(); + + // Base: "lim" — upright via m:sty=p + const limMi = munder!.children[0]!.querySelector('mi'); + expect(limMi!.textContent).toBe('lim'); + expect(limMi!.getAttribute('mathvariant')).toBe('normal'); + + // Limit expression: "n" — must stay italic (no mathvariant attribute set) + const nMi = munder!.children[1]!.querySelector('mi'); + expect(nMi!.textContent).toBe('n'); + expect(nMi!.getAttribute('mathvariant')).toBeNull(); + }); }); describe('m:limUpp converter', () => { From 60b2da90452cbe7b0a91b72ee499d3d412fe89f6 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 20 Apr 2026 15:42:00 -0300 Subject: [PATCH 2/5] docs(llms): mention native math equation rendering --- apps/docs/llms.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/docs/llms.txt b/apps/docs/llms.txt index 0f0668d445..1e76a62b5b 100644 --- a/apps/docs/llms.txt +++ b/apps/docs/llms.txt @@ -9,6 +9,7 @@ SuperDoc renders, edits, and automates .docx files — in the browser, headless - Reads and writes OOXML natively — documents stay real .docx files at every step - 180+ MCP tools covering reading, editing, formatting, comments, tracked changes, and more - Tracked changes and comments as first-class operations +- Math equations render natively as MathML — no external library needed - Same document model across browser editor, headless Node, and APIs - Stateless API for convert, annotate, sign, and verify - Real-time collaboration with Yjs CRDT From 4a1000089f7ad9e33ad3c91f6bfd52fc7751c78d Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 20 Apr 2026 16:25:10 -0300 Subject: [PATCH 3/5] test(math): add behavior-level regression for SD-2538 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins the end-to-end shape of the fix by loading math-limit-tests.docx and asserting that every inside a lim-based 's limit expression has no mathvariant attribute — i.e. variables in m:lim stay italic when m:limLow is wrapped in m:func > m:fName. Matches the canonical shape produced by Word's own OMML2MML.XSL. --- .../tests/importing/math-equations.spec.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/behavior/tests/importing/math-equations.spec.ts b/tests/behavior/tests/importing/math-equations.spec.ts index cee7ebbc04..8d9c43fd2c 100644 --- a/tests/behavior/tests/importing/math-equations.spec.ts +++ b/tests/behavior/tests/importing/math-equations.spec.ts @@ -724,6 +724,50 @@ test.describe('m:limLow / m:limUpp (limit object) rendering', () => { expect(counts.sup).toBe(1); }); + test('keeps limit variables italic when m:limLow is wrapped in m:func (SD-2538)', async ({ superdoc }) => { + await superdoc.loadDocument(LIMIT_DOC); + await superdoc.waitForStable(); + + // ECMA-376 §22.1.2.111: m:r without m:sty defaults to italic. + // Case 1 of math-limit-tests.docx is lim_(n→∞), encoded as + // m:func > m:fName > m:limLow. The function-name run ("lim") carries + // m:sty="p" so it renders upright; the limit expression runs ("n", "→∞") + // have no m:sty and must stay italic — i.e. with no mathvariant. + // + // Pre-SD-2538 convertFunction forced mathvariant="normal" on every + // inside m:fName, including nested limit variables. This asserts Word's + // own OMML2MML.xsl output shape: n (no attribute). + const limitVariantCheck = await superdoc.page.evaluate(() => { + const munders = Array.from(document.querySelectorAll('munder')); + const results: Array<{ hasLimUpright: boolean; limitMis: Array<{ text: string; mathvariant: string | null }> }> = + []; + for (const munder of munders) { + const base = munder.children[0]; + const limit = munder.children[1]; + const baseMi = base?.querySelector('mi'); + if (baseMi?.textContent !== 'lim') continue; + results.push({ + hasLimUpright: baseMi.getAttribute('mathvariant') === 'normal', + limitMis: Array.from(limit?.querySelectorAll('mi') ?? []).map((mi) => ({ + text: mi.textContent ?? '', + mathvariant: mi.getAttribute('mathvariant'), + })), + }); + } + return results; + }); + + // Every lim-based should have an upright base and NO mathvariant + // on any inside the limit expression. + expect(limitVariantCheck.length).toBeGreaterThan(0); + for (const entry of limitVariantCheck) { + expect(entry.hasLimUpright).toBe(true); + for (const limMi of entry.limitMis) { + expect(limMi.mathvariant).toBeNull(); + } + } + }); + test('preserves nested inside (case 8: lim of x_i → 0)', async ({ superdoc }) => { await superdoc.loadDocument(LIMIT_DOC); await superdoc.waitForStable(); From 90bc454157867a4ea74d7081028048cf5234d022 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 20 Apr 2026 16:44:28 -0300 Subject: [PATCH 4/5] refactor(math): apply review feedback on SD-2538 fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename STRUCTURAL_MATHML → MATH_VARIANT_BOUNDARY_ELEMENTS; the intent is "where the walk stops", not "which elements are structural". - Drop the inner walk closure and the Array.from snapshot — the outer function already recurses cleanly; neither earned its keep. Tests: - Fold the SD-2538 unit test into the adjacent "m:limLow in m:func" test; the fixture shape was identical, only the assertions differed. - Extend "m:limUpp in m:func" with the symmetric mathvariant assertions so the 'mover' entry of the boundary set is pinned, not just 'munder'. - Add a preserve-branch test (m:sty=i on function name) to pin the !hasAttribute guard — previously no test would have failed if we'd removed it. - Behavior test: assert exact count (5 munder + 1 mover) instead of >0, and cover both limLow and limUpp in the same sweep. Review context: Word's own OMML2MML.XSL confirmed as parity target. The subtree-opaque concern from Codex was checked against the XSL output for m:func > m:fName > m:sSub (f_i(x)) and matches our post-fix behavior. --- .../src/features/math/converters/function.ts | 25 ++--- .../src/features/math/omml-to-mathml.test.ts | 106 +++++++++--------- .../tests/importing/math-equations.spec.ts | 70 ++++++------ 3 files changed, 98 insertions(+), 103 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/function.ts b/packages/layout-engine/painters/dom/src/features/math/converters/function.ts index 28e28718c6..8ae9c38842 100644 --- a/packages/layout-engine/painters/dom/src/features/math/converters/function.ts +++ b/packages/layout-engine/painters/dom/src/features/math/converters/function.ts @@ -3,10 +3,12 @@ import type { MathObjectConverter } from '../types.js'; const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; const FUNCTION_APPLY_OPERATOR = '\u2061'; -// MathML elements whose contents have their own semantic slot (base, subscript, -// limit expression, matrix cell, etc.) and must keep authored styling when -// m:fName wraps a nested construct like m:limLow. -const STRUCTURAL_MATHML = new Set([ +// Boundary elements for the function-name mathvariant walk: every MathML +// element whose children occupy their own semantic slot (base, subscript, +// limit, matrix cell, etc.). When m:fName wraps one of these, the slot +// content carries authored styling per ECMA-376 §22.1.2.111 and must not be +// overwritten. Anything inside these is skipped. +const MATH_VARIANT_BOUNDARY_ELEMENTS = new Set([ 'munder', 'mover', 'munderover', @@ -23,16 +25,13 @@ const STRUCTURAL_MATHML = new Set([ ]); function forceNormalMathVariant(root: ParentNode): void { - const walk = (node: ParentNode): void => { - for (const child of Array.from(node.children)) { - if (STRUCTURAL_MATHML.has(child.localName)) continue; - if (child.localName === 'mi' && !child.hasAttribute('mathvariant')) { - child.setAttribute('mathvariant', 'normal'); - } - walk(child); + for (const child of root.children) { + if (MATH_VARIANT_BOUNDARY_ELEMENTS.has(child.localName)) continue; + if (child.localName === 'mi' && !child.hasAttribute('mathvariant')) { + child.setAttribute('mathvariant', 'normal'); } - }; - walk(root); + forceNormalMathVariant(child); + } } /** 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 170327c656..010d6b9218 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 @@ -861,6 +861,43 @@ describe('m:func converter', () => { expect(mis[0]!.textContent).toBe('sin'); expect(mis[1]!.textContent).toBe('cos'); }); + + it('preserves explicit m:sty=i on function-name runs', () => { + // SD-2538 preserve branch: forceNormalMathVariant must NOT overwrite + // an existing mathvariant. When Word marks a function-name run with + // m:sty="i", convertMathRun already set mathvariant="italic" — the + // function-apply pass must leave it alone. + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:func', + elements: [ + { + name: 'm:fName', + elements: [ + { + name: 'm:r', + elements: [ + { name: 'm:rPr', elements: [{ name: 'm:sty', attributes: { 'm:val': 'i' } }] }, + { name: 'm:t', elements: [{ type: 'text', text: 'L' }] }, + ], + }, + ], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + const nameMi = result!.querySelectorAll('mi')[0]; + expect(nameMi!.textContent).toBe('L'); + expect(nameMi!.getAttribute('mathvariant')).toBe('italic'); + }); }); describe('m:rad converter', () => { @@ -2296,63 +2333,15 @@ describe('m:limLow converter', () => { const munder = result!.querySelector('munder'); expect(munder).not.toBeNull(); expect(munder!.children.length).toBe(2); - expect(munder!.children[0]!.textContent).toBe('lim'); - expect(munder!.children[1]!.textContent).toBe('n'); - }); - - it('keeps variables inside m:lim italic when wrapped in m:func (SD-2538)', () => { - // Regression: convertFunction used to force mathvariant="normal" on every - // in m:fName, including variables inside a nested m:limLow's m:lim. - // Only the function-name text ("lim") should be upright. - const omml = { - name: 'm:oMath', - elements: [ - { - name: 'm:func', - elements: [ - { - name: 'm:fName', - elements: [ - { - name: 'm:limLow', - elements: [ - { - name: 'm:e', - elements: [ - { - name: 'm:r', - elements: [ - { name: 'm:rPr', elements: [{ name: 'm:sty', attributes: { 'm:val': 'p' } }] }, - { name: 'm:t', elements: [{ type: 'text', text: 'lim' }] }, - ], - }, - ], - }, - { - name: 'm:lim', - elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'n' }] }] }], - }, - ], - }, - ], - }, - { name: 'm:e', elements: [] }, - ], - }, - ], - }; - const result = convertOmmlToMathml(omml, doc); - expect(result).not.toBeNull(); - const munder = result!.querySelector('munder'); - expect(munder).not.toBeNull(); - - // Base: "lim" — upright via m:sty=p + // Base: "lim" — upright via m:sty=p on the run. const limMi = munder!.children[0]!.querySelector('mi'); expect(limMi!.textContent).toBe('lim'); expect(limMi!.getAttribute('mathvariant')).toBe('normal'); - // Limit expression: "n" — must stay italic (no mathvariant attribute set) + // Limit expression: "n" — must stay italic (no mathvariant attribute set). + // SD-2538 regression: convertFunction used to recurse into the + // and force mathvariant="normal" on every , including this one. const nMi = munder!.children[1]!.querySelector('mi'); expect(nMi!.textContent).toBe('n'); expect(nMi!.getAttribute('mathvariant')).toBeNull(); @@ -2623,8 +2612,17 @@ describe('m:limUpp converter', () => { const mover = result!.querySelector('mover'); expect(mover).not.toBeNull(); expect(mover!.children.length).toBe(2); - expect(mover!.children[0]!.textContent).toBe('lim'); - expect(mover!.children[1]!.textContent).toBe('x'); + + // Base: "lim" — upright via m:sty=p. Limit variable "x" — italic default. + // Symmetric to the m:limLow-in-m:func case; pins the 'mover' entry of + // MATH_VARIANT_BOUNDARY_ELEMENTS. + const limMi = mover!.children[0]!.querySelector('mi'); + expect(limMi!.textContent).toBe('lim'); + expect(limMi!.getAttribute('mathvariant')).toBe('normal'); + + const xMi = mover!.children[1]!.querySelector('mi'); + expect(xMi!.textContent).toBe('x'); + expect(xMi!.getAttribute('mathvariant')).toBeNull(); }); }); diff --git a/tests/behavior/tests/importing/math-equations.spec.ts b/tests/behavior/tests/importing/math-equations.spec.ts index 8d9c43fd2c..647c9d8342 100644 --- a/tests/behavior/tests/importing/math-equations.spec.ts +++ b/tests/behavior/tests/importing/math-equations.spec.ts @@ -724,46 +724,44 @@ test.describe('m:limLow / m:limUpp (limit object) rendering', () => { expect(counts.sup).toBe(1); }); - test('keeps limit variables italic when m:limLow is wrapped in m:func (SD-2538)', async ({ superdoc }) => { + test('keeps limit variables italic when m:limLow/m:limUpp is wrapped in m:func (SD-2538)', async ({ superdoc }) => { await superdoc.loadDocument(LIMIT_DOC); await superdoc.waitForStable(); - // ECMA-376 §22.1.2.111: m:r without m:sty defaults to italic. - // Case 1 of math-limit-tests.docx is lim_(n→∞), encoded as - // m:func > m:fName > m:limLow. The function-name run ("lim") carries - // m:sty="p" so it renders upright; the limit expression runs ("n", "→∞") - // have no m:sty and must stay italic — i.e. with no mathvariant. + // ECMA-376 §22.1.2.111: m:r without m:sty defaults to italic. Word's own + // OMML2MML.xsl emits n (no mathvariant) for limit variables. // - // Pre-SD-2538 convertFunction forced mathvariant="normal" on every - // inside m:fName, including nested limit variables. This asserts Word's - // own OMML2MML.xsl output shape: n (no attribute). - const limitVariantCheck = await superdoc.page.evaluate(() => { - const munders = Array.from(document.querySelectorAll('munder')); - const results: Array<{ hasLimUpright: boolean; limitMis: Array<{ text: string; mathvariant: string | null }> }> = - []; - for (const munder of munders) { - const base = munder.children[0]; - const limit = munder.children[1]; - const baseMi = base?.querySelector('mi'); - if (baseMi?.textContent !== 'lim') continue; - results.push({ - hasLimUpright: baseMi.getAttribute('mathvariant') === 'normal', - limitMis: Array.from(limit?.querySelectorAll('mi') ?? []).map((mi) => ({ - text: mi.textContent ?? '', - mathvariant: mi.getAttribute('mathvariant'), - })), - }); - } - return results; - }); - - // Every lim-based should have an upright base and NO mathvariant - // on any inside the limit expression. - expect(limitVariantCheck.length).toBeGreaterThan(0); - for (const entry of limitVariantCheck) { - expect(entry.hasLimUpright).toBe(true); - for (const limMi of entry.limitMis) { - expect(limMi.mathvariant).toBeNull(); + // Fixture math-limit-tests.docx has 6 m:func>m:fName wrappers: 5 around + // m:limLow (→ ) and 1 around m:limUpp (→ ). The function + // bases are lim×4, max×1, sup×1 — all carry m:sty=p and must render + // upright. The limit expression runs have no m:sty and must stay italic. + const FUNCTION_BASES = ['lim', 'max', 'sup']; + const variantCheck = await superdoc.page.evaluate((bases) => { + const collect = (tag: string) => + Array.from(document.querySelectorAll(tag)) + .map((el) => { + const baseMi = el.children[0]?.querySelector('mi'); + const limitEl = el.children[1]; + return { + base: baseMi?.textContent ?? '', + baseVariant: baseMi?.getAttribute('mathvariant') ?? null, + limitVariants: Array.from(limitEl?.querySelectorAll('mi') ?? []).map((mi) => + mi.getAttribute('mathvariant'), + ), + }; + }) + .filter((entry) => bases.includes(entry.base)); + return { munder: collect('munder'), mover: collect('mover') }; + }, FUNCTION_BASES); + + // Exact counts pin against a regression that drops a case silently. + expect(variantCheck.munder).toHaveLength(5); // 3×lim + 1×max + 1×sup + expect(variantCheck.mover).toHaveLength(1); // 1×lim (case: m:limUpp in func) + + for (const entry of [...variantCheck.munder, ...variantCheck.mover]) { + expect(entry.baseVariant).toBe('normal'); + for (const limVariant of entry.limitVariants) { + expect(limVariant).toBeNull(); } } }); From 65b054aebe00ef1a3d5c324f4166b92c45e144f9 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 20 Apr 2026 17:04:05 -0300 Subject: [PATCH 5/5] fix(math): restore Array.from on HTMLCollection iteration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review-driven removal of Array.from broke type-check in CI: HTMLCollection is not iterable under the default lib.dom.d.ts (needs `dom.iterable`), so `for…of root.children` fails TS2488. Bun's looser runtime type-check let it slide locally. Restoring Array.from with a comment explaining why. --- .../painters/dom/src/features/math/converters/function.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/function.ts b/packages/layout-engine/painters/dom/src/features/math/converters/function.ts index 8ae9c38842..145c183af9 100644 --- a/packages/layout-engine/painters/dom/src/features/math/converters/function.ts +++ b/packages/layout-engine/painters/dom/src/features/math/converters/function.ts @@ -25,7 +25,10 @@ const MATH_VARIANT_BOUNDARY_ELEMENTS = new Set([ ]); function forceNormalMathVariant(root: ParentNode): void { - for (const child of root.children) { + // Array.from is required here: HTMLCollection is not iterable under the + // default DOM lib (needs `dom.iterable`), so `for…of root.children` fails + // type-check. + for (const child of Array.from(root.children)) { if (MATH_VARIANT_BOUNDARY_ELEMENTS.has(child.localName)) continue; if (child.localName === 'mi' && !child.hasAttribute('mathvariant')) { child.setAttribute('mathvariant', 'normal');