From 1ea025872fca291eeefa290e44ddd5ef7fd0ce51 Mon Sep 17 00:00:00 2001 From: Abdeltoto Date: Tue, 7 Apr 2026 22:41:57 -0400 Subject: [PATCH 1/5] feat(math): implement m:nary n-ary operator converter (closes #2602) Made-with: Cursor --- .../dom/src/features/math/converters/index.ts | 1 + .../dom/src/features/math/converters/nary.ts | 101 +++++++++++++ .../src/features/math/omml-to-mathml.test.ts | 138 ++++++++++++++++++ .../dom/src/features/math/omml-to-mathml.ts | 3 +- 4 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 packages/layout-engine/painters/dom/src/features/math/converters/nary.ts 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..07a444d268 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/features/math/converters/nary.ts @@ -0,0 +1,101 @@ +import type { MathObjectConverter } from '../types.js'; + +const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; + +/** Default n-ary operator character: integral sign (∫, U+222B). */ +const DEFAULT_NARY_CHAR = '\u222B'; + +/** + * 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), m:sup (upper limit), m:e (integrand/summand) + * + * MathML output depends on limit location: + * + * limLoc="subSup" (default for integrals): + * + * sub sup + * body + * + * + * limLoc="undOvr" (typical for ∑, ∏): + * + * sub sup + * body + * + * + * When sub/sup are hidden, falls back to , , or bare . + * + * @spec ECMA-376 §22.1.2.70 + */ +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 opChar = chr?.attributes?.['m:val'] ?? DEFAULT_NARY_CHAR; + const isUndOvr = limLoc?.attributes?.['m:val'] === 'undOvr'; + + const isHidden = (el?: typeof subHide) => + el && (el.attributes?.['m:val'] === '1' || el.attributes?.['m:val'] === 'on' || !el.attributes); + + const hasSub = !isHidden(subHide); + const hasSup = !isHidden(supHide); + + const mo = doc.createElementNS(MATHML_NS, 'mo'); + mo.textContent = opChar; + + 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(sub?.elements ?? [])); + operatorEl.appendChild(subRow); + + const supRow = doc.createElementNS(MATHML_NS, 'mrow'); + supRow.appendChild(convertChildren(sup?.elements ?? [])); + 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(sub?.elements ?? [])); + 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(sup?.elements ?? [])); + 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..96d1e7f850 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,141 @@ 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(); + }); +}); 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) }; From a5c579fd9df2a0c9c72cf5641d1635b67bf43938 Mon Sep 17 00:00:00 2001 From: Abdeltoto Date: Tue, 7 Apr 2026 22:51:55 -0400 Subject: [PATCH 2/5] fix(math): parse full ST_OnOff values in nary converter Made-with: Cursor --- .../painters/dom/src/features/math/converters/nary.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 index 07a444d268..1faadef262 100644 --- a/packages/layout-engine/painters/dom/src/features/math/converters/nary.ts +++ b/packages/layout-engine/painters/dom/src/features/math/converters/nary.ts @@ -45,8 +45,13 @@ export const convertNary: MathObjectConverter = (node, doc, convertChildren) => const opChar = chr?.attributes?.['m:val'] ?? DEFAULT_NARY_CHAR; const isUndOvr = limLoc?.attributes?.['m:val'] === 'undOvr'; + /** OOXML ST_OnOff true values: "1", "on", "true", or boolean-flag presence. */ const isHidden = (el?: typeof subHide) => - el && (el.attributes?.['m:val'] === '1' || el.attributes?.['m:val'] === 'on' || !el.attributes); + el && + (el.attributes?.['m:val'] === '1' || + el.attributes?.['m:val'] === 'on' || + el.attributes?.['m:val'] === 'true' || + !el.attributes); const hasSub = !isHidden(subHide); const hasSup = !isHidden(supHide); From 2ed0bb409ec1ec1da69cf734d26527e9595944f2 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sun, 12 Apr 2026 17:56:42 -0700 Subject: [PATCH 3/5] fix(math): spec-compliant m:nary defaults + fill missing naryPr props (SD-2381) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - m:limLoc (§22.1.2.53): absent element now uses operator-character heuristic — integrals default to subSup, non-integrals to undOvr. with no val attribute defaults to undOvr per spec. - m:sub/m:sup (§22.1.2.70): treat as actually absent when the element is missing, not just when a hide flag is set — indefinite integrals now render as bare instead of with empty slots. - m:chr (§22.1.2.20): with no val renders an empty operator instead of silently defaulting to the integral glyph. - m:grow (§22.1.2.72): when explicitly OFF, emit largeop="false" stretchy="false" on the so the renderer doesn't enlarge. - Helper type is OmmlJsonNode (no more typeof subHide). JSDoc documents all 6 output shapes. Tests: - 10 new unit tests in omml-to-mathml.test.ts covering every ECMA-376 spec path for m:nary (subHide true/bare/OFF, limLoc no-val, chr no-val, operator heuristic, m:grow suppression, etc). - New behavior fixture math-nary-tests.docx (13 scenarios) + 9 Playwright tests in math-equations.spec.ts. Fixture uploaded to the shared R2 corpus as rendering/sd-2381-nary-scenarios.docx for layout/visual. --- .../dom/src/features/math/converters/nary.ts | 86 +++-- .../src/features/math/omml-to-mathml.test.ts | 342 ++++++++++++++++++ .../importing/fixtures/math-nary-tests.docx | Bin 0 -> 12529 bytes .../tests/importing/math-equations.spec.ts | 175 +++++++++ 4 files changed, 573 insertions(+), 30 deletions(-) create mode 100644 tests/behavior/tests/importing/fixtures/math-nary-tests.docx 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 index 1faadef262..37d30f2861 100644 --- a/packages/layout-engine/painters/dom/src/features/math/converters/nary.ts +++ b/packages/layout-engine/painters/dom/src/features/math/converters/nary.ts @@ -1,34 +1,36 @@ -import type { MathObjectConverter } from '../types.js'; +import type { MathObjectConverter, OmmlJsonNode } from '../types.js'; const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; -/** Default n-ary operator character: integral sign (∫, U+222B). */ +/** 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), m:sup (upper limit), m:e (integrand/summand) - * - * MathML output depends on limit location: + * m:sub (lower limit, optional), m:sup (upper limit, optional), m:e (body) * - * limLoc="subSup" (default for integrals): - * - * sub sup - * body - * + * MathML shape depends on which limits are shown and the limit location: * - * limLoc="undOvr" (typical for ∑, ∏): - * - * sub sup - * body - * + * Both limits, subSup (default for integrals): + * subsupbody + * Both limits, undOvr (default for ∑, ∏, ⋃, ...): + * subsupbody * - * When sub/sup are hidden, falls back to , , or bare . + * Only sub: / + + sub + * Only sup: / + + sup + * Neither: bare inside the outer * - * @spec ECMA-376 §22.1.2.70 + * @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 ?? []; @@ -41,23 +43,47 @@ export const convertNary: MathObjectConverter = (node, doc, convertChildren) => 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 opChar = chr?.attributes?.['m:val'] ?? DEFAULT_NARY_CHAR; - const isUndOvr = limLoc?.attributes?.['m:val'] === 'undOvr'; - - /** OOXML ST_OnOff true values: "1", "on", "true", or boolean-flag presence. */ - const isHidden = (el?: typeof subHide) => - el && + 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 hasSub = !isHidden(subHide); - const hasSup = !isHidden(supHide); + // A limit is rendered only when its element is actually present AND not hidden. + // Mirrors radical.ts's handling of m:deg/m:degHide (§22.1.2.70 says sub/sup are optional). + const hasSub = sub !== undefined && !isStOnOffTrue(subHide); + const hasSup = sup !== undefined && !isStOnOffTrue(supHide); + + // §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; @@ -67,11 +93,11 @@ export const convertNary: MathObjectConverter = (node, doc, convertChildren) => operatorEl.appendChild(mo); const subRow = doc.createElementNS(MATHML_NS, 'mrow'); - subRow.appendChild(convertChildren(sub?.elements ?? [])); + subRow.appendChild(convertChildren(sub.elements ?? [])); operatorEl.appendChild(subRow); const supRow = doc.createElementNS(MATHML_NS, 'mrow'); - supRow.appendChild(convertChildren(sup?.elements ?? [])); + supRow.appendChild(convertChildren(sup.elements ?? [])); operatorEl.appendChild(supRow); } else if (hasSub) { const tag = isUndOvr ? 'munder' : 'msub'; @@ -79,7 +105,7 @@ export const convertNary: MathObjectConverter = (node, doc, convertChildren) => operatorEl.appendChild(mo); const subRow = doc.createElementNS(MATHML_NS, 'mrow'); - subRow.appendChild(convertChildren(sub?.elements ?? [])); + subRow.appendChild(convertChildren(sub.elements ?? [])); operatorEl.appendChild(subRow); } else if (hasSup) { const tag = isUndOvr ? 'mover' : 'msup'; @@ -87,7 +113,7 @@ export const convertNary: MathObjectConverter = (node, doc, convertChildren) => operatorEl.appendChild(mo); const supRow = doc.createElementNS(MATHML_NS, 'mrow'); - supRow.appendChild(convertChildren(sup?.elements ?? [])); + supRow.appendChild(convertChildren(sup.elements ?? [])); operatorEl.appendChild(supRow); } else { operatorEl = mo; 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 96d1e7f850..6e6c7820bf 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 @@ -2845,4 +2845,346 @@ describe('m:nary converter', () => { const msub = result!.querySelector('msub'); expect(msub).not.toBeNull(); }); + + it('renders only superscript when subHide is set (→ )', () => { + 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(); + const msup = result!.querySelector('msup'); + expect(msup).not.toBeNull(); + expect(result!.querySelector('msub')).toBeNull(); + expect(result!.querySelector('msubsup')).toBeNull(); + expect(msup!.children[0]!.textContent).toBe('\u222B'); + expect(msup!.children[1]!.textContent).toBe('n'); + }); + + it('treats m:subHide m:val="true" as hidden (ST_OnOff §22.9.2.7)', () => { + 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: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')).toBeNull(); + expect(result!.querySelector('msup')).not.toBeNull(); + }); + + it('treats bare (no attributes) as hidden (ST_OnOff §22.9.2.7)', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:nary', + elements: [ + { + name: 'm:naryPr', + elements: [{ name: 'm:subHide' }], + }, + { + 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')).toBeNull(); + expect(result!.querySelector('msup')).not.toBeNull(); + }); + + 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/tests/behavior/tests/importing/fixtures/math-nary-tests.docx b/tests/behavior/tests/importing/fixtures/math-nary-tests.docx new file mode 100644 index 0000000000000000000000000000000000000000..fdaa8af5b1a1363611b6f132477d87a73bf21354 GIT binary patch literal 12529 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 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 facc9bbfa1..2a26b5bb5a 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,177 @@ 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 m:val="true" is treated as ON (§22.9.2.7)', async ({ superdoc }) => { + await superdoc.loadDocument(NARY_DOC); + await superdoc.waitForStable(); + + // Scenario 8 (index 7): m:subHide m:val="true" — sub should be hidden → msup. + const data = await superdoc.page.evaluate(() => { + const math = document.querySelectorAll('math')[7]; + const msup = math?.querySelector('msup'); + return { + hasMsubsup: math?.querySelector('msubsup') !== null, + hasMsup: msup !== null, + sup: msup?.children[1]?.textContent ?? null, + }; + }); + expect(data!.hasMsubsup).toBe(false); + expect(data!.hasMsup).toBe(true); + expect(data!.sup).toBe('1'); + }); + + 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([]); + }); +}); From 1a09c00011d397e04ebe8b56528ff70474130071 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 13 Apr 2026 14:36:45 -0700 Subject: [PATCH 4/5] fix(math): subHide/supHide only hide empty placeholders, not content (SD-2381) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per ECMA-376 §22.1.2.72, the subHide/supHide flags control whether EMPTY m:sub/m:sup limits are rendered as a placeholder character or hidden. When the limit has content, it must always be rendered regardless of the flag — matching Word's actual behavior. Previous code hid the entire slot whenever the hide flag was ON, which silently dropped content. For example, an integral with sub="0", sup="1", and subHide=true was rendering as ∫¹ instead of ∫₀¹. - hasSub/hasSup now check meaningful content (ignoring m:ctrlPr, the formatting-hint child Word emits inside empty limit elements). - The hide flag only suppresses the slot when the limit is empty/absent. - Updated 3 unit tests that encoded the incorrect semantics; added a new regression test for "content overrides hide" and a test confirming m:ctrlPr-only limits are treated as empty. - Updated the behavior test for the same scenarios. --- .../dom/src/features/math/converters/nary.ts | 13 ++- .../src/features/math/omml-to-mathml.test.ts | 90 ++++++++++++++++--- .../tests/importing/math-equations.spec.ts | 46 +++++++--- 3 files changed, 122 insertions(+), 27 deletions(-) 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 index 37d30f2861..fda6d86522 100644 --- a/packages/layout-engine/painters/dom/src/features/math/converters/nary.ts +++ b/packages/layout-engine/painters/dom/src/features/math/converters/nary.ts @@ -68,10 +68,15 @@ export const convertNary: MathObjectConverter = (node, doc, convertChildren) => el.attributes?.['m:val'] === 'true' || !el.attributes); - // A limit is rendered only when its element is actually present AND not hidden. - // Mirrors radical.ts's handling of m:deg/m:degHide (§22.1.2.70 says sub/sup are optional). - const hasSub = sub !== undefined && !isStOnOffTrue(subHide); - const hasSup = sup !== undefined && !isStOnOffTrue(supHide); + // Meaningful content = any non-property child (m:ctrlPr is a formatting hint, not content). + const hasMeaningfulContent = (el?: OmmlJsonNode) => + el !== undefined && (el.elements ?? []).some((e) => e.name !== 'm:ctrlPr'); + + // §22.1.2.72: subHide/supHide only control rendering of EMPTY limit placeholders. + // When m:sub/m:sup has content, it is always rendered regardless of the hide flag. + // When m:sub/m:sup is empty/absent, the hide flag suppresses the placeholder slot. + const hasSub = hasMeaningfulContent(sub) || (sub !== undefined && !isStOnOffTrue(subHide)); + const hasSup = hasMeaningfulContent(sup) || (sup !== undefined && !isStOnOffTrue(supHide)); // §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 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 6e6c7820bf..9b553617f8 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 @@ -2846,7 +2846,9 @@ describe('m:nary converter', () => { expect(msub).not.toBeNull(); }); - it('renders only superscript when subHide is set (→ )', () => { + it('subHide/supHide do NOT hide limits that have content (§22.1.2.72)', () => { + // Per spec: subHide/supHide only control empty-limit placeholder rendering. + // When m:sub/m:sup has content, it is always shown regardless of the flag. const omml = { name: 'm:oMath', elements: [ @@ -2875,15 +2877,48 @@ describe('m:nary converter', () => { }; const result = convertOmmlToMathml(omml, doc); expect(result).not.toBeNull(); + const msubsup = result!.querySelector('msubsup'); + expect(msubsup).not.toBeNull(); + expect(msubsup!.children[1]!.textContent).toBe('0'); + expect(msubsup!.children[2]!.textContent).toBe('n'); + }); + + 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('msub')).toBeNull(); expect(result!.querySelector('msubsup')).toBeNull(); - expect(msup!.children[0]!.textContent).toBe('\u222B'); expect(msup!.children[1]!.textContent).toBe('n'); }); - it('treats m:subHide m:val="true" as hidden (ST_OnOff §22.9.2.7)', () => { + 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: [ @@ -2894,10 +2929,7 @@ describe('m:nary converter', () => { name: 'm:naryPr', elements: [{ name: 'm:subHide', attributes: { 'm:val': 'true' } }], }, - { - name: 'm:sub', - elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '0' }] }] }], - }, + { name: 'm:sub', elements: [] }, { name: 'm:sup', elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '1' }] }] }], @@ -2916,7 +2948,8 @@ describe('m:nary converter', () => { expect(result!.querySelector('msup')).not.toBeNull(); }); - it('treats bare (no attributes) as hidden (ST_OnOff §22.9.2.7)', () => { + 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: [ @@ -2927,10 +2960,7 @@ describe('m:nary converter', () => { name: 'm:naryPr', elements: [{ name: 'm:subHide' }], }, - { - name: 'm:sub', - elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '0' }] }] }], - }, + { name: 'm:sub', elements: [] }, { name: 'm:sup', elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '1' }] }] }], @@ -2949,6 +2979,40 @@ describe('m:nary converter', () => { 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 = { diff --git a/tests/behavior/tests/importing/math-equations.spec.ts b/tests/behavior/tests/importing/math-equations.spec.ts index 2a26b5bb5a..431b15d5a4 100644 --- a/tests/behavior/tests/importing/math-equations.spec.ts +++ b/tests/behavior/tests/importing/math-equations.spec.ts @@ -987,23 +987,49 @@ test.describe('m:nary (n-ary operator) rendering', () => { expect(data!.bodyText).toContain('f(x)dx'); }); - test('subHide m:val="true" is treated as ON (§22.9.2.7)', async ({ superdoc }) => { + test('subHide/supHide do not hide limits that have content (§22.1.2.72)', async ({ superdoc }) => { await superdoc.loadDocument(NARY_DOC); await superdoc.waitForStable(); - // Scenario 8 (index 7): m:subHide m:val="true" — sub should be hidden → msup. + // Scenarios 7 and 8 in the fixture both set m:subHide (as "true" and as bare element) + // but the m:sub elements contain "0" — per spec these are only placeholder-hide flags, + // so both limits should still render (msubsup), matching Word's behavior. const data = await superdoc.page.evaluate(() => { - const math = document.querySelectorAll('math')[7]; - const msup = math?.querySelector('msup'); + const maths = document.querySelectorAll('math'); + const [seven, eight] = [maths[7], maths[8]]; + const fromMath = (m?: Element | null) => { + const mss = m?.querySelector('msubsup'); + return { + hasMsubsup: mss !== null, + sub: mss?.children[1]?.textContent ?? null, + sup: mss?.children[2]?.textContent ?? null, + }; + }; + return { seven: fromMath(seven), eight: fromMath(eight) }; + }); + expect(data.seven.hasMsubsup).toBe(true); + expect(data.seven.sub).toBe('0'); + expect(data.seven.sup).toBe('1'); + expect(data.eight.hasMsubsup).toBe(true); + expect(data.eight.sub).toBe('0'); + expect(data.eight.sup).toBe('1'); + }); + + 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 { - hasMsubsup: math?.querySelector('msubsup') !== null, - hasMsup: msup !== null, - sup: msup?.children[1]?.textContent ?? null, + hasScriptWrapper: math?.querySelector('msubsup, msub, msup, munderover, munder, mover') !== null, + opChar: math?.querySelector('mo')?.textContent ?? null, }; }); - expect(data!.hasMsubsup).toBe(false); - expect(data!.hasMsup).toBe(true); - expect(data!.sup).toBe('1'); + expect(data!.hasScriptWrapper).toBe(false); + expect(data!.opChar).toBe('\u222B'); }); test(' with no val renders an empty operator (§22.1.2.20)', async ({ superdoc }) => { From 7e02ecac20378c081a7c1bc955c648e009411784 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 13 Apr 2026 14:44:16 -0700 Subject: [PATCH 5/5] fix(math): promote hidden limit content into opposite slot (matches Word) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the previous subHide/supHide fix. When m:subHide is ON and m:sub has content, Word doesn't simply suppress the slot — it promotes the sub content into the sup slot, prepended to any existing sup content (symmetric for supHide: sup content appended to sub). This preserves any author-entered content even when a file is hand-crafted with conflicting hide+content OMML. Before: an integral with m:sub="0", m:sup="1", m:subHide="true" rendered as 01 (∫ with 0 below and 1 above — stacked). After: renders as 01, matching Word's ∫^{01} where 0 appears to the left of 1 in the superscript. - Compute renderSubChildren / renderSupChildren with promotion, then pass those arrays to convertChildren. - Strip m:ctrlPr before the check so Word's "empty with formatting hint" pattern still resolves to bare . - Replaced the "content always wins over hide" unit test with two tests anchoring the promotion (sub→sup, sup→sub). - Updated the behavior test for scenarios 8/9 to assert msup with "01". --- .../dom/src/features/math/converters/nary.ts | 39 +++++++++----- .../src/features/math/omml-to-mathml.test.ts | 54 ++++++++++++++++--- .../tests/importing/math-equations.spec.ts | 29 +++++----- 3 files changed, 88 insertions(+), 34 deletions(-) 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 index fda6d86522..92e74527cf 100644 --- a/packages/layout-engine/painters/dom/src/features/math/converters/nary.ts +++ b/packages/layout-engine/painters/dom/src/features/math/converters/nary.ts @@ -68,15 +68,28 @@ export const convertNary: MathObjectConverter = (node, doc, convertChildren) => el.attributes?.['m:val'] === 'true' || !el.attributes); - // Meaningful content = any non-property child (m:ctrlPr is a formatting hint, not content). - const hasMeaningfulContent = (el?: OmmlJsonNode) => - el !== undefined && (el.elements ?? []).some((e) => e.name !== 'm:ctrlPr'); - - // §22.1.2.72: subHide/supHide only control rendering of EMPTY limit placeholders. - // When m:sub/m:sup has content, it is always rendered regardless of the hide flag. - // When m:sub/m:sup is empty/absent, the hide flag suppresses the placeholder slot. - const hasSub = hasMeaningfulContent(sub) || (sub !== undefined && !isStOnOffTrue(subHide)); - const hasSup = hasMeaningfulContent(sup) || (sup !== undefined && !isStOnOffTrue(supHide)); + 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 @@ -98,11 +111,11 @@ export const convertNary: MathObjectConverter = (node, doc, convertChildren) => operatorEl.appendChild(mo); const subRow = doc.createElementNS(MATHML_NS, 'mrow'); - subRow.appendChild(convertChildren(sub.elements ?? [])); + subRow.appendChild(convertChildren(renderSubChildren)); operatorEl.appendChild(subRow); const supRow = doc.createElementNS(MATHML_NS, 'mrow'); - supRow.appendChild(convertChildren(sup.elements ?? [])); + supRow.appendChild(convertChildren(renderSupChildren)); operatorEl.appendChild(supRow); } else if (hasSub) { const tag = isUndOvr ? 'munder' : 'msub'; @@ -110,7 +123,7 @@ export const convertNary: MathObjectConverter = (node, doc, convertChildren) => operatorEl.appendChild(mo); const subRow = doc.createElementNS(MATHML_NS, 'mrow'); - subRow.appendChild(convertChildren(sub.elements ?? [])); + subRow.appendChild(convertChildren(renderSubChildren)); operatorEl.appendChild(subRow); } else if (hasSup) { const tag = isUndOvr ? 'mover' : 'msup'; @@ -118,7 +131,7 @@ export const convertNary: MathObjectConverter = (node, doc, convertChildren) => operatorEl.appendChild(mo); const supRow = doc.createElementNS(MATHML_NS, 'mrow'); - supRow.appendChild(convertChildren(sup.elements ?? [])); + supRow.appendChild(convertChildren(renderSupChildren)); operatorEl.appendChild(supRow); } else { operatorEl = mo; 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 9b553617f8..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 @@ -2846,9 +2846,9 @@ describe('m:nary converter', () => { expect(msub).not.toBeNull(); }); - it('subHide/supHide do NOT hide limits that have content (§22.1.2.72)', () => { - // Per spec: subHide/supHide only control empty-limit placeholder rendering. - // When m:sub/m:sup has content, it is always shown regardless of the flag. + 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: [ @@ -2877,10 +2877,50 @@ describe('m:nary converter', () => { }; const result = convertOmmlToMathml(omml, doc); expect(result).not.toBeNull(); - const msubsup = result!.querySelector('msubsup'); - expect(msubsup).not.toBeNull(); - expect(msubsup!.children[1]!.textContent).toBe('0'); - expect(msubsup!.children[2]!.textContent).toBe('n'); + 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) → ', () => { diff --git a/tests/behavior/tests/importing/math-equations.spec.ts b/tests/behavior/tests/importing/math-equations.spec.ts index 431b15d5a4..6e033616ac 100644 --- a/tests/behavior/tests/importing/math-equations.spec.ts +++ b/tests/behavior/tests/importing/math-equations.spec.ts @@ -987,32 +987,33 @@ test.describe('m:nary (n-ary operator) rendering', () => { expect(data!.bodyText).toContain('f(x)dx'); }); - test('subHide/supHide do not hide limits that have content (§22.1.2.72)', async ({ superdoc }) => { + test('subHide with content promotes sub into sup slot (matches Word)', async ({ superdoc }) => { await superdoc.loadDocument(NARY_DOC); await superdoc.waitForStable(); - // Scenarios 7 and 8 in the fixture both set m:subHide (as "true" and as bare element) - // but the m:sub elements contain "0" — per spec these are only placeholder-hide flags, - // so both limits should still render (msubsup), matching Word's behavior. + // 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 mss = m?.querySelector('msubsup'); + const msup = m?.querySelector('msup'); return { - hasMsubsup: mss !== null, - sub: mss?.children[1]?.textContent ?? null, - sup: mss?.children[2]?.textContent ?? null, + 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(true); - expect(data.seven.sub).toBe('0'); - expect(data.seven.sup).toBe('1'); - expect(data.eight.hasMsubsup).toBe(true); - expect(data.eight.sub).toBe('0'); - expect(data.eight.sup).toBe('1'); + 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 }) => {