Skip to content

Commit 02543d6

Browse files
Abdeltotocaiopizzolcaio-pizzol
authored
feat(math): implement m:nary n-ary operator converter (#2752)
* feat(math): implement m:nary n-ary operator converter (closes #2602) Made-with: Cursor * fix(math): parse full ST_OnOff values in nary converter Made-with: Cursor * fix(math): spec-compliant m:nary defaults + fill missing naryPr props (SD-2381) - m:limLoc (§22.1.2.53): absent element now uses operator-character heuristic — integrals default to subSup, non-integrals to undOvr. <m:limLoc/> 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 <mo> instead of <msubsup> with empty <mrow/> slots. - m:chr (§22.1.2.20): <m:chr/> 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 <mo> 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. * fix(math): subHide/supHide only hide empty placeholders, not content (SD-2381) 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. * fix(math): promote hidden limit content into opposite slot (matches Word) 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 <msubsup><mo>∫</mo><mrow>0</mrow><mrow>1</mrow></msubsup> (∫ with 0 below and 1 above — stacked). After: renders as <msup><mo>∫</mo><mrow>01</mrow></msup>, 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 <mo>. - 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". --------- Co-authored-by: Caio Pizzol <caiopizzol@icloud.com> Co-authored-by: Caio Pizzol <caio@harbourshare.com>
1 parent 4227ebe commit 02543d6

6 files changed

Lines changed: 939 additions & 1 deletion

File tree

packages/layout-engine/painters/dom/src/features/math/converters/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@ export { convertEquationArray } from './equation-array.js';
2020
export { convertRadical } from './radical.js';
2121
export { convertLowerLimit } from './lower-limit.js';
2222
export { convertUpperLimit } from './upper-limit.js';
23+
export { convertNary } from './nary.js';
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import type { MathObjectConverter, OmmlJsonNode } from '../types.js';
2+
3+
const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
4+
5+
/** Default n-ary operator character when m:chr is absent: integral sign (∫, U+222B). */
6+
const DEFAULT_NARY_CHAR = '\u222B';
7+
8+
/**
9+
* Integral-like operators (∫∬∭∮∯∰∱∲∳), which default to side-limits (subSup).
10+
* Non-integrals (∑, ∏, ⋃, ...) default to under/over limits (undOvr) in display mode.
11+
*/
12+
const INTEGRAL_CHARS = /^[\u222B-\u2233]$/;
13+
14+
/**
15+
* Convert m:nary (n-ary operator) to MathML.
16+
*
17+
* OMML structure:
18+
* m:nary → m:naryPr (optional: m:chr@m:val, m:limLoc@m:val, m:subHide, m:supHide),
19+
* m:sub (lower limit, optional), m:sup (upper limit, optional), m:e (body)
20+
*
21+
* MathML shape depends on which limits are shown and the limit location:
22+
*
23+
* Both limits, subSup (default for integrals):
24+
* <mrow><msubsup><mo>∫</mo><mrow>sub</mrow><mrow>sup</mrow></msubsup><mrow>body</mrow></mrow>
25+
* Both limits, undOvr (default for ∑, ∏, ⋃, ...):
26+
* <mrow><munderover><mo>∑</mo><mrow>sub</mrow><mrow>sup</mrow></munderover><mrow>body</mrow></mrow>
27+
*
28+
* Only sub: <msub> / <munder> + <mo> + <mrow>sub</mrow>
29+
* Only sup: <msup> / <mover> + <mo> + <mrow>sup</mrow>
30+
* Neither: bare <mo> inside the outer <mrow>
31+
*
32+
* @spec ECMA-376 §22.1.2.70 (m:nary), §22.1.2.72 (m:naryPr),
33+
* §22.1.2.53 (m:limLoc), §22.1.2.20 (m:chr), §22.9.2.7 (ST_OnOff)
34+
*/
35+
export const convertNary: MathObjectConverter = (node, doc, convertChildren) => {
36+
const elements = node.elements ?? [];
37+
const naryPr = elements.find((e) => e.name === 'm:naryPr');
38+
const sub = elements.find((e) => e.name === 'm:sub');
39+
const sup = elements.find((e) => e.name === 'm:sup');
40+
const body = elements.find((e) => e.name === 'm:e');
41+
42+
const chr = naryPr?.elements?.find((e) => e.name === 'm:chr');
43+
const limLoc = naryPr?.elements?.find((e) => e.name === 'm:limLoc');
44+
const subHide = naryPr?.elements?.find((e) => e.name === 'm:subHide');
45+
const supHide = naryPr?.elements?.find((e) => e.name === 'm:supHide');
46+
const grow = naryPr?.elements?.find((e) => e.name === 'm:grow');
47+
48+
// §22.1.2.20 m:chr defaults:
49+
// element absent → U+222B (integral)
50+
// element present → m:val (empty string if val attribute absent)
51+
const opChar = chr === undefined ? DEFAULT_NARY_CHAR : (chr.attributes?.['m:val'] ?? '');
52+
53+
// §22.1.2.53 m:limLoc defaults:
54+
// element absent → operator-character heuristic (integrals → subSup, others → undOvr)
55+
// element present, m:val absent → undOvr
56+
// element present with m:val → use m:val
57+
const limLocVal = limLoc?.attributes?.['m:val'];
58+
const isUndOvr =
59+
limLocVal === 'undOvr' ||
60+
(limLoc !== undefined && limLocVal === undefined) ||
61+
(limLoc === undefined && opChar !== '' && !INTEGRAL_CHARS.test(opChar));
62+
63+
/** ST_OnOff true values per §22.9.2.7: '1', 'true', or bare-element (no attributes). */
64+
const isStOnOffTrue = (el?: OmmlJsonNode) =>
65+
el !== undefined &&
66+
(el.attributes?.['m:val'] === '1' ||
67+
el.attributes?.['m:val'] === 'on' ||
68+
el.attributes?.['m:val'] === 'true' ||
69+
!el.attributes);
70+
71+
const subHidden = isStOnOffTrue(subHide);
72+
const supHidden = isStOnOffTrue(supHide);
73+
74+
// Strip m:ctrlPr (formatting hint only) to get each limit's meaningful children.
75+
const stripCtrl = (el?: OmmlJsonNode) => (el?.elements ?? []).filter((e) => e.name !== 'm:ctrlPr');
76+
const subChildren = stripCtrl(sub);
77+
const supChildren = stripCtrl(sup);
78+
79+
// Word's behavior for subHide/supHide (§22.1.2.72):
80+
// - Empty limit + hide flag ON → suppress the placeholder slot.
81+
// - Non-empty limit + hide flag ON → promote the content into the opposite
82+
// slot (sub → prepended to sup, sup → appended to sub). Word does this so
83+
// author-entered content is never silently dropped.
84+
const promotedToSup = subHidden && !supHidden ? subChildren : [];
85+
const promotedToSub = supHidden && !subHidden ? supChildren : [];
86+
const renderSubChildren = subHidden ? [] : [...subChildren, ...promotedToSub];
87+
const renderSupChildren = supHidden ? [] : [...promotedToSup, ...supChildren];
88+
89+
// A slot is rendered if it has content OR if the element is present for an
90+
// empty placeholder (§22.1.2.70 says sub/sup are optional — absent means no slot).
91+
const hasSub = renderSubChildren.length > 0 || (sub !== undefined && !subHidden);
92+
const hasSup = renderSupChildren.length > 0 || (sup !== undefined && !supHidden);
93+
94+
// §22.1.2.72 m:grow: default is ON (operator grows with operand). When explicitly OFF,
95+
// suppress enlargement by setting largeop="false" — MathML's operator dictionary otherwise
96+
// applies largeop/stretchy automatically for standard n-ary glyphs.
97+
const growOff = grow !== undefined && !isStOnOffTrue(grow);
98+
99+
const mo = doc.createElementNS(MATHML_NS, 'mo');
100+
mo.textContent = opChar;
101+
if (growOff) {
102+
mo.setAttribute('largeop', 'false');
103+
mo.setAttribute('stretchy', 'false');
104+
}
105+
106+
let operatorEl: Element;
107+
108+
if (hasSub && hasSup) {
109+
const tag = isUndOvr ? 'munderover' : 'msubsup';
110+
operatorEl = doc.createElementNS(MATHML_NS, tag);
111+
operatorEl.appendChild(mo);
112+
113+
const subRow = doc.createElementNS(MATHML_NS, 'mrow');
114+
subRow.appendChild(convertChildren(renderSubChildren));
115+
operatorEl.appendChild(subRow);
116+
117+
const supRow = doc.createElementNS(MATHML_NS, 'mrow');
118+
supRow.appendChild(convertChildren(renderSupChildren));
119+
operatorEl.appendChild(supRow);
120+
} else if (hasSub) {
121+
const tag = isUndOvr ? 'munder' : 'msub';
122+
operatorEl = doc.createElementNS(MATHML_NS, tag);
123+
operatorEl.appendChild(mo);
124+
125+
const subRow = doc.createElementNS(MATHML_NS, 'mrow');
126+
subRow.appendChild(convertChildren(renderSubChildren));
127+
operatorEl.appendChild(subRow);
128+
} else if (hasSup) {
129+
const tag = isUndOvr ? 'mover' : 'msup';
130+
operatorEl = doc.createElementNS(MATHML_NS, tag);
131+
operatorEl.appendChild(mo);
132+
133+
const supRow = doc.createElementNS(MATHML_NS, 'mrow');
134+
supRow.appendChild(convertChildren(renderSupChildren));
135+
operatorEl.appendChild(supRow);
136+
} else {
137+
operatorEl = mo;
138+
}
139+
140+
const wrapper = doc.createElementNS(MATHML_NS, 'mrow');
141+
wrapper.appendChild(operatorEl);
142+
143+
const bodyRow = doc.createElementNS(MATHML_NS, 'mrow');
144+
bodyRow.appendChild(convertChildren(body?.elements ?? []));
145+
if (bodyRow.childNodes.length > 0) {
146+
wrapper.appendChild(bodyRow);
147+
}
148+
149+
return wrapper;
150+
};

0 commit comments

Comments
 (0)