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 7aad78a235..fc563c56c9 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 @@ -20,3 +20,4 @@ export { convertEquationArray } from './equation-array.js'; export { convertRadical } from './radical.js'; export { convertLowerLimit } from './lower-limit.js'; export { convertUpperLimit } from './upper-limit.js'; +export { convertNary } from './nary.js'; diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/nary.ts b/packages/layout-engine/painters/dom/src/features/math/converters/nary.ts new file mode 100644 index 0000000000..92e74527cf --- /dev/null +++ b/packages/layout-engine/painters/dom/src/features/math/converters/nary.ts @@ -0,0 +1,150 @@ +import type { MathObjectConverter, OmmlJsonNode } from '../types.js'; + +const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; + +/** Default n-ary operator character when m:chr is absent: integral sign (∫, U+222B). */ +const DEFAULT_NARY_CHAR = '\u222B'; + +/** + * Integral-like operators (∫∬∭∮∯∰∱∲∳), which default to side-limits (subSup). + * Non-integrals (∑, ∏, ⋃, ...) default to under/over limits (undOvr) in display mode. + */ +const INTEGRAL_CHARS = /^[\u222B-\u2233]$/; + +/** + * Convert m:nary (n-ary operator) to MathML. + * + * OMML structure: + * m:nary → m:naryPr (optional: m:chr@m:val, m:limLoc@m:val, m:subHide, m:supHide), + * m:sub (lower limit, optional), m:sup (upper limit, optional), m:e (body) + * + * MathML shape depends on which limits are shown and the limit location: + * + * Both limits, subSup (default for integrals): + * subsupbody + * Both limits, undOvr (default for ∑, ∏, ⋃, ...): + * subsupbody + * + * Only sub: / + + sub + * Only sup: / + + sup + * Neither: bare inside the outer + * + * @spec ECMA-376 §22.1.2.70 (m:nary), §22.1.2.72 (m:naryPr), + * §22.1.2.53 (m:limLoc), §22.1.2.20 (m:chr), §22.9.2.7 (ST_OnOff) + */ +export const convertNary: MathObjectConverter = (node, doc, convertChildren) => { + const elements = node.elements ?? []; + const naryPr = elements.find((e) => e.name === 'm:naryPr'); + const sub = elements.find((e) => e.name === 'm:sub'); + const sup = elements.find((e) => e.name === 'm:sup'); + const body = elements.find((e) => e.name === 'm:e'); + + const chr = naryPr?.elements?.find((e) => e.name === 'm:chr'); + const limLoc = naryPr?.elements?.find((e) => e.name === 'm:limLoc'); + const subHide = naryPr?.elements?.find((e) => e.name === 'm:subHide'); + const supHide = naryPr?.elements?.find((e) => e.name === 'm:supHide'); + const grow = naryPr?.elements?.find((e) => e.name === 'm:grow'); + + // §22.1.2.20 m:chr defaults: + // element absent → U+222B (integral) + // element present → m:val (empty string if val attribute absent) + const opChar = chr === undefined ? DEFAULT_NARY_CHAR : (chr.attributes?.['m:val'] ?? ''); + + // §22.1.2.53 m:limLoc defaults: + // element absent → operator-character heuristic (integrals → subSup, others → undOvr) + // element present, m:val absent → undOvr + // element present with m:val → use m:val + const limLocVal = limLoc?.attributes?.['m:val']; + const isUndOvr = + limLocVal === 'undOvr' || + (limLoc !== undefined && limLocVal === undefined) || + (limLoc === undefined && opChar !== '' && !INTEGRAL_CHARS.test(opChar)); + + /** ST_OnOff true values per §22.9.2.7: '1', 'true', or bare-element (no attributes). */ + const isStOnOffTrue = (el?: OmmlJsonNode) => + el !== undefined && + (el.attributes?.['m:val'] === '1' || + el.attributes?.['m:val'] === 'on' || + el.attributes?.['m:val'] === 'true' || + !el.attributes); + + const subHidden = isStOnOffTrue(subHide); + const supHidden = isStOnOffTrue(supHide); + + // Strip m:ctrlPr (formatting hint only) to get each limit's meaningful children. + const stripCtrl = (el?: OmmlJsonNode) => (el?.elements ?? []).filter((e) => e.name !== 'm:ctrlPr'); + const subChildren = stripCtrl(sub); + const supChildren = stripCtrl(sup); + + // Word's behavior for subHide/supHide (§22.1.2.72): + // - Empty limit + hide flag ON → suppress the placeholder slot. + // - Non-empty limit + hide flag ON → promote the content into the opposite + // slot (sub → prepended to sup, sup → appended to sub). Word does this so + // author-entered content is never silently dropped. + const promotedToSup = subHidden && !supHidden ? subChildren : []; + const promotedToSub = supHidden && !subHidden ? supChildren : []; + const renderSubChildren = subHidden ? [] : [...subChildren, ...promotedToSub]; + const renderSupChildren = supHidden ? [] : [...promotedToSup, ...supChildren]; + + // A slot is rendered if it has content OR if the element is present for an + // empty placeholder (§22.1.2.70 says sub/sup are optional — absent means no slot). + const hasSub = renderSubChildren.length > 0 || (sub !== undefined && !subHidden); + const hasSup = renderSupChildren.length > 0 || (sup !== undefined && !supHidden); + + // §22.1.2.72 m:grow: default is ON (operator grows with operand). When explicitly OFF, + // suppress enlargement by setting largeop="false" — MathML's operator dictionary otherwise + // applies largeop/stretchy automatically for standard n-ary glyphs. + const growOff = grow !== undefined && !isStOnOffTrue(grow); + + const mo = doc.createElementNS(MATHML_NS, 'mo'); + mo.textContent = opChar; + if (growOff) { + mo.setAttribute('largeop', 'false'); + mo.setAttribute('stretchy', 'false'); + } + + let operatorEl: Element; + + if (hasSub && hasSup) { + const tag = isUndOvr ? 'munderover' : 'msubsup'; + operatorEl = doc.createElementNS(MATHML_NS, tag); + operatorEl.appendChild(mo); + + const subRow = doc.createElementNS(MATHML_NS, 'mrow'); + subRow.appendChild(convertChildren(renderSubChildren)); + operatorEl.appendChild(subRow); + + const supRow = doc.createElementNS(MATHML_NS, 'mrow'); + supRow.appendChild(convertChildren(renderSupChildren)); + operatorEl.appendChild(supRow); + } else if (hasSub) { + const tag = isUndOvr ? 'munder' : 'msub'; + operatorEl = doc.createElementNS(MATHML_NS, tag); + operatorEl.appendChild(mo); + + const subRow = doc.createElementNS(MATHML_NS, 'mrow'); + subRow.appendChild(convertChildren(renderSubChildren)); + operatorEl.appendChild(subRow); + } else if (hasSup) { + const tag = isUndOvr ? 'mover' : 'msup'; + operatorEl = doc.createElementNS(MATHML_NS, tag); + operatorEl.appendChild(mo); + + const supRow = doc.createElementNS(MATHML_NS, 'mrow'); + supRow.appendChild(convertChildren(renderSupChildren)); + operatorEl.appendChild(supRow); + } else { + operatorEl = mo; + } + + const wrapper = doc.createElementNS(MATHML_NS, 'mrow'); + wrapper.appendChild(operatorEl); + + const bodyRow = doc.createElementNS(MATHML_NS, 'mrow'); + bodyRow.appendChild(convertChildren(body?.elements ?? [])); + if (bodyRow.childNodes.length > 0) { + wrapper.appendChild(bodyRow); + } + + return wrapper; +}; 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 d31e2755c9..1cd8a75240 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 @@ -2708,3 +2708,587 @@ describe('m:eqArr converter', () => { expect(mfrac).not.toBeNull(); }); }); + +describe('m:nary converter', () => { + it('converts integral with sub/sup limits (subSup) to ', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:nary', + elements: [ + { + name: 'm:sub', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '0' }] }] }], + }, + { + name: 'm:sup', + elements: [{ 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: 'f(x)' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const msubsup = result!.querySelector('msubsup'); + expect(msubsup).not.toBeNull(); + const mo = msubsup!.querySelector('mo'); + expect(mo!.textContent).toBe('\u222B'); + expect(msubsup!.children[1]!.textContent).toBe('0'); + expect(msubsup!.children[2]!.textContent).toBe('1'); + }); + + it('converts summation (undOvr) to ', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:nary', + elements: [ + { + name: 'm:naryPr', + elements: [ + { name: 'm:chr', attributes: { 'm:val': '\u2211' } }, + { name: 'm:limLoc', attributes: { 'm:val': 'undOvr' } }, + ], + }, + { + name: 'm:sub', + elements: [ + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'i' }] }] }, + { 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:sup', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'n' }] }] }], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'i' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const munderover = result!.querySelector('munderover'); + expect(munderover).not.toBeNull(); + const mo = munderover!.querySelector('mo'); + expect(mo!.textContent).toBe('\u2211'); + expect(munderover!.children[1]!.textContent).toBe('i=1'); + expect(munderover!.children[2]!.textContent).toBe('n'); + }); + + it('hides sub/sup when flagged', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:nary', + elements: [ + { + name: 'm:naryPr', + elements: [ + { name: 'm:chr', attributes: { 'm:val': '\u222B' } }, + { name: 'm:subHide', attributes: { 'm:val': '1' } }, + { name: 'm:supHide', attributes: { 'm:val': '1' } }, + ], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'f' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + expect(result!.querySelector('msubsup')).toBeNull(); + expect(result!.querySelector('munderover')).toBeNull(); + const mo = result!.querySelector('mo'); + expect(mo!.textContent).toBe('\u222B'); + }); + + it('renders only subscript when supHide is set', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:nary', + elements: [ + { + name: 'm:naryPr', + elements: [{ name: 'm:supHide', attributes: { 'm:val': '1' } }], + }, + { + name: 'm:sub', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'C' }] }] }], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'ds' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + const msub = result!.querySelector('msub'); + expect(msub).not.toBeNull(); + }); + + it('subHide with non-empty m:sub promotes sub content into the sup slot (matches Word)', () => { + // Word's observed behavior: when subHide is ON but m:sub has content, the + // content is prepended to the sup slot so nothing is silently dropped. + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:nary', + elements: [ + { + name: 'm:naryPr', + elements: [{ name: 'm:subHide', attributes: { 'm:val': '1' } }], + }, + { + name: 'm:sub', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '0' }] }] }], + }, + { + name: 'm:sup', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'n' }] }] }], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + expect(result!.querySelector('msubsup')).toBeNull(); + const msup = result!.querySelector('msup'); + expect(msup).not.toBeNull(); + // Sup slot contains sub content ("0") followed by sup content ("n") + expect(msup!.children[1]!.textContent).toBe('0n'); + }); + + it('supHide with non-empty m:sup promotes sup content into the sub slot (symmetric)', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:nary', + elements: [ + { + name: 'm:naryPr', + elements: [ + { name: 'm:chr', attributes: { 'm:val': '\u222B' } }, + { name: 'm:supHide', attributes: { 'm:val': '1' } }, + ], + }, + { + name: 'm:sub', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }], + }, + { + name: 'm:sup', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'b' }] }] }], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + expect(result!.querySelector('msubsup')).toBeNull(); + const msub = result!.querySelector('msub'); + expect(msub).not.toBeNull(); + // Sub slot contains sub content ("a") followed by promoted sup content ("b") + expect(msub!.children[1]!.textContent).toBe('ab'); + }); + + it('subHide hides empty m:sub (suppresses placeholder) → ', () => { + // Empty m:sub + subHide=ON → no sub slot (spec-correct usage of the hide flag). + // This mirrors how Word emits indefinite integrals: empty m:sub/m:sup with hide flags. + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:nary', + elements: [ + { + name: 'm:naryPr', + elements: [{ name: 'm:subHide', attributes: { 'm:val': '1' } }], + }, + { name: 'm:sub', elements: [] }, + { + name: 'm:sup', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'n' }] }] }], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const msup = result!.querySelector('msup'); + expect(msup).not.toBeNull(); + expect(result!.querySelector('msubsup')).toBeNull(); + expect(msup!.children[1]!.textContent).toBe('n'); + }); + + it('treats m:subHide m:val="true" as ON for empty-limit suppression (§22.9.2.7)', () => { + // Empty m:sub + subHide m:val="true" → hidden (regression anchor for commit 2bd58d3). + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:nary', + elements: [ + { + name: 'm:naryPr', + elements: [{ name: 'm:subHide', attributes: { 'm:val': 'true' } }], + }, + { name: 'm:sub', elements: [] }, + { + name: 'm:sup', + elements: [{ 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: 'f' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + expect(result!.querySelector('msubsup')).toBeNull(); + expect(result!.querySelector('msup')).not.toBeNull(); + }); + + it('treats bare as ON for empty-limit suppression (§22.9.2.7)', () => { + // Empty m:sub + bare (no attrs) → hidden. + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:nary', + elements: [ + { + name: 'm:naryPr', + elements: [{ name: 'm:subHide' }], + }, + { name: 'm:sub', elements: [] }, + { + name: 'm:sup', + elements: [{ 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: 'f' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + expect(result!.querySelector('msubsup')).toBeNull(); + expect(result!.querySelector('msup')).not.toBeNull(); + }); + + it('ignores m:ctrlPr when checking for meaningful sub/sup content (Word emits empty-with-ctrlPr)', () => { + // Word emits ... for empty limits — treat as empty. + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:nary', + elements: [ + { + name: 'm:naryPr', + elements: [ + { name: 'm:subHide', attributes: { 'm:val': '1' } }, + { name: 'm:supHide', attributes: { 'm:val': '1' } }, + ], + }, + { name: 'm:sub', elements: [{ name: 'm:ctrlPr', elements: [] }] }, + { name: 'm:sup', elements: [{ name: 'm:ctrlPr', elements: [] }] }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'f' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + expect(result!.querySelector('msubsup')).toBeNull(); + expect(result!.querySelector('msup')).toBeNull(); + expect(result!.querySelector('msub')).toBeNull(); + // Bare only + expect(result!.querySelector('mo')!.textContent).toBe('\u222B'); + }); + + it('indefinite integral (no m:sub/m:sup, no hide flags) → bare ', () => { + // §22.1.2.70: m:sub/m:sup are optional. When absent, no subscript/superscript should be rendered. + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:nary', + elements: [ + { + name: 'm:naryPr', + elements: [{ name: 'm:chr', attributes: { 'm:val': '\u222B' } }], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'f(x)dx' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + expect(result!.querySelector('msubsup')).toBeNull(); + expect(result!.querySelector('msub')).toBeNull(); + expect(result!.querySelector('msup')).toBeNull(); + const mo = result!.querySelector('mo'); + expect(mo).not.toBeNull(); + expect(mo!.textContent).toBe('\u222B'); + expect(result!.textContent).toContain('f(x)dx'); + }); + + it('summation without m:limLoc defaults to (§22.1.2.53 + operator heuristic)', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:nary', + elements: [ + { + name: 'm:naryPr', + elements: [{ name: 'm:chr', attributes: { 'm:val': '\u2211' } }], + }, + { + name: 'm:sub', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'i=1' }] }] }], + }, + { + name: 'm:sup', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'n' }] }] }], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'i' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + expect(result!.querySelector('munderover')).not.toBeNull(); + expect(result!.querySelector('msubsup')).toBeNull(); + }); + + it(' with no val attribute defaults to undOvr (§22.1.2.53)', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:nary', + elements: [ + { + name: 'm:naryPr', + elements: [{ name: 'm:chr', attributes: { 'm:val': '\u2211' } }, { name: 'm:limLoc' }], + }, + { + name: 'm:sub', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'i=1' }] }] }], + }, + { + name: 'm:sup', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'n' }] }] }], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'i' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + expect(result!.querySelector('munderover')).not.toBeNull(); + expect(result!.querySelector('msubsup')).toBeNull(); + }); + + it('integral without m:limLoc keeps subSup (operator heuristic)', () => { + // Integrals default to side-limits; only non-integrals default to under/over when limLoc is absent. + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:nary', + elements: [ + { + name: 'm:naryPr', + elements: [{ name: 'm:chr', attributes: { 'm:val': '\u222B' } }], + }, + { + name: 'm:sub', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '0' }] }] }], + }, + { + name: 'm:sup', + elements: [{ 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: 'f' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + expect(result!.querySelector('msubsup')).not.toBeNull(); + expect(result!.querySelector('munderover')).toBeNull(); + }); + + it('suppresses operator growth when m:grow m:val="0" (§22.1.2.72)', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:nary', + elements: [ + { + name: 'm:naryPr', + elements: [ + { name: 'm:chr', attributes: { 'm:val': '\u2211' } }, + { name: 'm:grow', attributes: { 'm:val': '0' } }, + ], + }, + { + name: 'm:sub', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'i=1' }] }] }], + }, + { + name: 'm:sup', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'n' }] }] }], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'i' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const mo = result!.querySelector('mo'); + expect(mo).not.toBeNull(); + expect(mo!.getAttribute('largeop')).toBe('false'); + expect(mo!.getAttribute('stretchy')).toBe('false'); + }); + + it('leaves operator growth to MathML defaults when m:grow is absent or ON', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:nary', + elements: [ + { + name: 'm:naryPr', + elements: [{ name: 'm:chr', attributes: { 'm:val': '\u2211' } }], + }, + { + name: 'm:sub', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'i=1' }] }] }], + }, + { + name: 'm:sup', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'n' }] }] }], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'i' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const mo = result!.querySelector('mo'); + expect(mo).not.toBeNull(); + // No explicit largeop/stretchy — rely on operator dictionary defaults + expect(mo!.hasAttribute('largeop')).toBe(false); + expect(mo!.hasAttribute('stretchy')).toBe(false); + }); + + it(' with no val means "no character" (§22.1.2.20)', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:nary', + elements: [ + { + name: 'm:naryPr', + elements: [{ name: 'm:chr' }, { name: 'm:limLoc', attributes: { 'm:val': 'undOvr' } }], + }, + { + name: 'm:sub', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }], + }, + { + name: 'm:sup', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'b' }] }] }], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const mo = result!.querySelector('mo'); + expect(mo).not.toBeNull(); + expect(mo!.textContent).toBe(''); + }); +}); 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 fd3585fabe..efedb9cacb 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 @@ -25,6 +25,7 @@ import { convertRadical, convertLowerLimit, convertUpperLimit, + convertNary, } from './converters/index.js'; export const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; @@ -53,6 +54,7 @@ const MATH_OBJECT_REGISTRY: Record = { 'm:func': convertFunction, // Function apply (sin, cos, log, etc.) 'm:limLow': convertLowerLimit, // Lower limit (e.g., lim) 'm:limUpp': convertUpperLimit, // Upper limit + 'm:nary': convertNary, // N-ary operator (integral, summation, product) 'm:rad': convertRadical, // Radical (square root, nth root) 'm:sSub': convertSubscript, // Subscript 'm:sSup': convertSuperscript, // Superscript @@ -64,7 +66,6 @@ const MATH_OBJECT_REGISTRY: Record = { 'm:box': null, // Box (invisible grouping container) 'm:groupChr': null, // Group character (overbrace, underbrace) 'm:m': null, // Matrix (grid of elements) - 'm:nary': null, // N-ary operator (integral, summation, product) 'm:phant': null, // Phantom (invisible spacing placeholder) }; diff --git a/tests/behavior/tests/importing/fixtures/math-nary-tests.docx b/tests/behavior/tests/importing/fixtures/math-nary-tests.docx new file mode 100644 index 0000000000..fdaa8af5b1 Binary files /dev/null and b/tests/behavior/tests/importing/fixtures/math-nary-tests.docx differ diff --git a/tests/behavior/tests/importing/math-equations.spec.ts b/tests/behavior/tests/importing/math-equations.spec.ts index facc9bbfa1..6e033616ac 100644 --- a/tests/behavior/tests/importing/math-equations.spec.ts +++ b/tests/behavior/tests/importing/math-equations.spec.ts @@ -10,6 +10,7 @@ const DELIMITER_DOC = path.resolve(__dirname, 'fixtures/math-delimiter-tests.doc const RADICAL_DOC = path.resolve(__dirname, 'fixtures/math-radical-tests.docx'); const LIMIT_DOC = path.resolve(__dirname, 'fixtures/math-limit-tests.docx'); const EQARR_DOC = path.resolve(__dirname, 'fixtures/math-eqarr-tests.docx'); +const NARY_DOC = path.resolve(__dirname, 'fixtures/math-nary-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. @@ -882,3 +883,204 @@ test.describe('m:eqArr (equation array) rendering', () => { expect(leaked).toEqual([]); }); }); + +test.describe('m:nary (n-ary operator) rendering', () => { + // Fixture covers 13 m:nary scenarios across every ECMA-376 spec path: + // §22.1.2.20 (m:chr), §22.1.2.53 (m:limLoc), §22.1.2.70 (m:nary), + // §22.1.2.72 (m:naryPr), §22.9.2.7 (ST_OnOff). + + test('renders all 13 scenarios as elements', async ({ superdoc }) => { + await superdoc.loadDocument(NARY_DOC); + await superdoc.waitForStable(); + + const mathCount = await superdoc.page.evaluate(() => { + return document.querySelectorAll('math').length; + }); + expect(mathCount).toBe(13); + }); + + test('definite integral renders as with both limits', async ({ superdoc }) => { + await superdoc.loadDocument(NARY_DOC); + await superdoc.waitForStable(); + + // Scenario 1: ∫₀¹ f(x)dx + const data = await superdoc.page.evaluate(() => { + const math = document.querySelectorAll('math')[0]; + const msubsup = math?.querySelector('msubsup'); + if (!msubsup) return null; + return { + childCount: msubsup.children.length, + opChar: msubsup.children[0]?.textContent, + sub: msubsup.children[1]?.textContent, + sup: msubsup.children[2]?.textContent, + }; + }); + expect(data).not.toBeNull(); + expect(data!.childCount).toBe(3); + expect(data!.opChar).toBe('\u222B'); + expect(data!.sub).toBe('0'); + expect(data!.sup).toBe('1'); + }); + + test('summation without m:limLoc renders as (§22.1.2.53 + operator heuristic)', async ({ superdoc }) => { + await superdoc.loadDocument(NARY_DOC); + await superdoc.waitForStable(); + + // Scenario 3: ∑_{i=1}^n i with no m:limLoc — spec says default to undOvr in display mode. + const data = await superdoc.page.evaluate(() => { + const math = document.querySelectorAll('math')[2]; + const munderover = math?.querySelector('munderover'); + if (!munderover) return null; + return { + hasMsubsup: math?.querySelector('msubsup') !== null, + opChar: munderover.children[0]?.textContent, + under: munderover.children[1]?.textContent, + over: munderover.children[2]?.textContent, + }; + }); + expect(data).not.toBeNull(); + expect(data!.hasMsubsup).toBe(false); + expect(data!.opChar).toBe('\u2211'); + expect(data!.under).toBe('i=1'); + expect(data!.over).toBe('n'); + }); + + test('union with supHide renders as (one-sided undOvr branch)', async ({ superdoc }) => { + await superdoc.loadDocument(NARY_DOC); + await superdoc.waitForStable(); + + // Scenario 6: ⋃ᵢ Aᵢ — m:supHide=1 + no m:limLoc on a non-integral → munder. + const data = await superdoc.page.evaluate(() => { + const math = document.querySelectorAll('math')[5]; + const munder = math?.querySelector('munder'); + if (!munder) return null; + return { + hasMsub: math?.querySelector('msub') !== null, + opChar: munder.children[0]?.textContent, + under: munder.children[1]?.textContent, + }; + }); + expect(data).not.toBeNull(); + expect(data!.hasMsub).toBe(false); + expect(data!.opChar).toBe('\u22C3'); + expect(data!.under).toBe('i'); + }); + + test('indefinite integral (no m:sub/m:sup elements) renders as bare ', async ({ superdoc }) => { + await superdoc.loadDocument(NARY_DOC); + await superdoc.waitForStable(); + + // Scenario 7 (label "2b" in fixture): no sub/sup and no hide flags — expect bare . + const data = await superdoc.page.evaluate(() => { + const math = document.querySelectorAll('math')[6]; + const hasScriptWrapper = math?.querySelector('msubsup, msub, msup, munderover, munder, mover') !== null; + const mo = math?.querySelector('mo'); + return { + hasScriptWrapper, + opChar: mo?.textContent ?? null, + bodyText: math?.textContent ?? null, + }; + }); + expect(data).not.toBeNull(); + expect(data!.hasScriptWrapper).toBe(false); + expect(data!.opChar).toBe('\u222B'); + expect(data!.bodyText).toContain('f(x)dx'); + }); + + test('subHide with content promotes sub into sup slot (matches Word)', async ({ superdoc }) => { + await superdoc.loadDocument(NARY_DOC); + await superdoc.waitForStable(); + + // Scenarios 8 and 9 in the document set m:subHide ("true" / bare) on a nary + // that has non-empty m:sub ("0") and m:sup ("1"). Word renders these as + // ∫^{01} — the sub content is promoted into the sup slot so nothing is + // dropped. Expect whose sup mrow starts with "0" then "1". + const data = await superdoc.page.evaluate(() => { + const maths = document.querySelectorAll('math'); + const [seven, eight] = [maths[7], maths[8]]; + const fromMath = (m?: Element | null) => { + const msup = m?.querySelector('msup'); + return { + hasMsubsup: m?.querySelector('msubsup') !== null, + hasMsup: msup !== null, + supText: msup?.children[1]?.textContent ?? null, + }; + }; + return { seven: fromMath(seven), eight: fromMath(eight) }; + }); + expect(data.seven.hasMsubsup).toBe(false); + expect(data.seven.hasMsup).toBe(true); + expect(data.seven.supText).toBe('01'); + expect(data.eight.hasMsubsup).toBe(false); + expect(data.eight.hasMsup).toBe(true); + expect(data.eight.supText).toBe('01'); + }); + + test('Word indefinite integral (empty sub/sup + hide flags) renders as bare ', async ({ superdoc }) => { + await superdoc.loadDocument(NARY_DOC); + await superdoc.waitForStable(); + + // Scenario 2 (index 1): Word authored ∫ f(x)dx — emits empty m:sub/m:sup with + // subHide=supHide=1. This is the real "hide flag suppresses empty placeholder" case. + const data = await superdoc.page.evaluate(() => { + const math = document.querySelectorAll('math')[1]; + return { + hasScriptWrapper: math?.querySelector('msubsup, msub, msup, munderover, munder, mover') !== null, + opChar: math?.querySelector('mo')?.textContent ?? null, + }; + }); + expect(data!.hasScriptWrapper).toBe(false); + expect(data!.opChar).toBe('\u222B'); + }); + + test(' with no val renders an empty operator (§22.1.2.20)', async ({ superdoc }) => { + await superdoc.loadDocument(NARY_DOC); + await superdoc.waitForStable(); + + // Scenario 11 (index 10): + limLoc=undOvr — expect munderover with empty . + const data = await superdoc.page.evaluate(() => { + const math = document.querySelectorAll('math')[10]; + const munderover = math?.querySelector('munderover'); + const mo = munderover?.querySelector('mo'); + return { + hasMunderover: munderover !== null, + opChar: mo?.textContent ?? null, + }; + }); + expect(data!.hasMunderover).toBe(true); + expect(data!.opChar).toBe(''); + }); + + test('m:grow m:val="0" suppresses operator growth (§22.1.2.72)', async ({ superdoc }) => { + await superdoc.loadDocument(NARY_DOC); + await superdoc.waitForStable(); + + // Scenario 13 (index 12): m:grow=0 on ∑ — expect largeop="false" stretchy="false". + const data = await superdoc.page.evaluate(() => { + const math = document.querySelectorAll('math')[12]; + const mo = math?.querySelector('mo'); + return { + opChar: mo?.textContent ?? null, + largeop: mo?.getAttribute('largeop') ?? null, + stretchy: mo?.getAttribute('stretchy') ?? null, + }; + }); + expect(data!.opChar).toBe('\u2211'); + expect(data!.largeop).toBe('false'); + expect(data!.stretchy).toBe('false'); + }); + + test('OMML property elements do not leak into the MathML DOM', async ({ superdoc }) => { + await superdoc.loadDocument(NARY_DOC); + await superdoc.waitForStable(); + + // naryPr/subHide/supHide/limLoc/chr/grow are OMML property elements — they + // must not appear in the rendered MathML output. + const leaked = await superdoc.page.evaluate(() => { + return Array.from(document.querySelectorAll('math *')) + .map((el) => el.localName.toLowerCase()) + .filter((n) => ['narypr', 'subhide', 'suphide', 'limloc', 'chr', 'grow', 'ctrlpr'].includes(n)); + }); + expect(leaked).toEqual([]); + }); +});