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 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..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 @@ -3,10 +3,38 @@ import type { MathObjectConverter } from '../types.js'; const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; const FUNCTION_APPLY_OPERATOR = '\u2061'; +// 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', + 'msub', + 'msup', + 'msubsup', + 'mmultiscripts', + 'mfrac', + 'msqrt', + 'mroot', + 'mtable', + 'mtr', + 'mtd', +]); + function forceNormalMathVariant(root: ParentNode): void { - root.querySelectorAll('mi').forEach((identifier) => { - identifier.setAttribute('mathvariant', 'normal'); - }); + // 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'); + } + 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 898ab4da70..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,8 +2333,18 @@ 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'); + + // 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). + // 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(); }); }); @@ -2565,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 cee7ebbc04..647c9d8342 100644 --- a/tests/behavior/tests/importing/math-equations.spec.ts +++ b/tests/behavior/tests/importing/math-equations.spec.ts @@ -724,6 +724,48 @@ test.describe('m:limLow / m:limUpp (limit object) rendering', () => { expect(counts.sup).toBe(1); }); + 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. Word's own + // OMML2MML.xsl emits n (no mathvariant) for limit variables. + // + // 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(); + } + } + }); + test('preserves nested inside (case 8: lim of x_i → 0)', async ({ superdoc }) => { await superdoc.loadDocument(LIMIT_DOC); await superdoc.waitForStable();