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