Skip to content

Commit 9a3776d

Browse files
gpardhivvarmaclaudecaiopizzol
authored
feat(math): implement m:limLow and m:limUpp limit converters (#2771)
* feat(math): implement m:limLow and m:limUpp limit converters (SD-2377, SD-2378) Add lower limit (m:limLow → <munder>) and upper limit (m:limUpp → <mover>) OMML-to-MathML converters per ECMA-376 §22.1.2.54 and §22.1.2.56. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(math): handle m:sty and m:scr in convertMathRun (SD-2377/2378) ECMA-376 §22.1.2 defines m:sty (style: p/b/i/bi) and m:scr (script: roman, script, fraktur, double-struck, sans-serif, monospace) on math run properties. Word emits m:sty="p" on function names like "lim", "max", "sup" — without this handling those names rendered italic instead of upright when they appear outside an m:func wrapper. Precedence: m:sty > m:scr > m:nor. m:nor stays as the legacy normal flag, equivalent to m:sty="p" when no explicit style or script is set. * test(math): expand limLow/limUpp unit coverage (SD-2377/2378) - Tighten "handles missing m:e/m:lim gracefully" tests: assert arity-2 <munder>/<mover> with an empty <mrow> on the missing side (was only checking the present side). - Add multi-run base tests for both converters — existing tests only exercised multi-run m:lim, leaving the m:e base-wrapping path untested. - Add nested-math tests (fraction inside m:lim) for both converters. - Add m:func-wrapped tests (m:func > m:fName > m:limLow/m:limUpp) — the shape Word actually emits when the base is a function keyword. - Add full coverage for m:sty (p/b/i/bi), m:scr (6 variants), and m:sty > m:scr precedence per ECMA-376 §22.1.2. * test(math): add behavior fixture and tests for limLow/limUpp (SD-2377/2378) New fixture math-limit-tests.docx contains 8 Word-native limit shapes: m:limLow in m:func, bare m:limUpp, m:limLow with fraction in m:lim, bare m:limLow, m:limUpp in m:func, multi-char non-lim function bases (max, sup), and m:limLow with a nested m:sSub in the limit expression. Seven new it blocks assert: - arity of <munder> (6) and <mover> (2) - nested <mfrac> inside <munder> (case 3) - nested <msub> inside <munder> (case 8) - mathvariant=normal on function keywords via m:sty val=p - bare identifiers keep italic default (no stray mathvariant) - property elements (m:limLowPr/m:limUppPr/m:ctrlPr) don't leak to DOM The same fixture is uploaded to the shared R2 corpus as sd-2377-limlow-limupp-showcase-all-cases.docx so test:layout and test:visual cover these shapes automatically. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Caio Pizzol <caiopizzol@icloud.com>
1 parent 81ee173 commit 9a3776d

File tree

8 files changed

+937
-10
lines changed

8 files changed

+937
-10
lines changed

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,5 @@ export { convertSubscript } from './subscript.js';
1515
export { convertSuperscript } from './superscript.js';
1616
export { convertSubSuperscript } from './sub-superscript.js';
1717
export { convertRadical } from './radical.js';
18+
export { convertLowerLimit } from './lower-limit.js';
19+
export { convertUpperLimit } from './upper-limit.js';
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { MathObjectConverter } from '../types.js';
2+
3+
const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
4+
5+
/**
6+
* Convert m:limLow (lower limit) to MathML <munder>.
7+
*
8+
* OMML structure:
9+
* m:limLow → m:limLowPr (optional), m:e (base, e.g. "lim"), m:lim (limit expression)
10+
*
11+
* MathML output:
12+
* <munder> <mrow>base</mrow> <mrow>lim</mrow> </munder>
13+
*
14+
* @spec ECMA-376 §22.1.2.54
15+
*/
16+
export const convertLowerLimit: MathObjectConverter = (node, doc, convertChildren) => {
17+
const elements = node.elements ?? [];
18+
const base = elements.find((e) => e.name === 'm:e');
19+
const lim = elements.find((e) => e.name === 'm:lim');
20+
21+
const munder = doc.createElementNS(MATHML_NS, 'munder');
22+
23+
const baseRow = doc.createElementNS(MATHML_NS, 'mrow');
24+
baseRow.appendChild(convertChildren(base?.elements ?? []));
25+
munder.appendChild(baseRow);
26+
27+
const limRow = doc.createElementNS(MATHML_NS, 'mrow');
28+
limRow.appendChild(convertChildren(lim?.elements ?? []));
29+
munder.appendChild(limRow);
30+
31+
return munder;
32+
};

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

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,44 @@ function classifyMathText(text: string): 'mn' | 'mo' | 'mi' {
7777
return 'mi';
7878
}
7979

80+
/** ECMA-376 m:sty → MathML mathvariant (§22.1.2 math run properties). */
81+
const STY_TO_VARIANT: Record<string, string> = {
82+
p: 'normal',
83+
b: 'bold',
84+
i: 'italic',
85+
bi: 'bold-italic',
86+
};
87+
88+
/** ECMA-376 m:scr → MathML mathvariant (§22.1.2 math run properties). */
89+
const SCR_TO_VARIANT: Record<string, string> = {
90+
roman: 'normal',
91+
script: 'script',
92+
fraktur: 'fraktur',
93+
'double-struck': 'double-struck',
94+
'sans-serif': 'sans-serif',
95+
monospace: 'monospace',
96+
};
97+
98+
/**
99+
* Resolve the effective MathML mathvariant from OMML m:rPr.
100+
*
101+
* Precedence (highest first): m:sty > m:scr > m:nor.
102+
* m:nor is the legacy "normal text" flag (ECMA-376 §22.1.2); it is treated as
103+
* equivalent to m:sty="p" when neither m:sty nor m:scr is present.
104+
*/
105+
function resolveMathVariant(rPr: OmmlJsonNode | undefined): string | null {
106+
const elements = rPr?.elements ?? [];
107+
const sty = elements.find((el) => el.name === 'm:sty')?.attributes?.['m:val'];
108+
if (sty && STY_TO_VARIANT[sty]) return STY_TO_VARIANT[sty]!;
109+
110+
const scr = elements.find((el) => el.name === 'm:scr')?.attributes?.['m:val'];
111+
if (scr && SCR_TO_VARIANT[scr]) return SCR_TO_VARIANT[scr]!;
112+
113+
if (elements.some((el) => el.name === 'm:nor')) return 'normal';
114+
115+
return null;
116+
}
117+
80118
/**
81119
* Convert an m:r (math run) element to MathML.
82120
*
@@ -105,18 +143,18 @@ export const convertMathRun: MathObjectConverter = (node, doc) => {
105143

106144
if (!text) return null;
107145

108-
// Check m:rPr for normal text flag (m:nor) which disables math italics
109146
const rPr = elements.find((el) => el.name === 'm:rPr');
110-
const isNormalText = rPr?.elements?.some((el) => el.name === 'm:nor') ?? false;
111-
147+
const variant = resolveMathVariant(rPr);
112148
const tag = classifyMathText(text);
149+
113150
const el = doc.createElementNS(MATHML_NS, tag);
114151
el.textContent = text;
115152

116-
// MathML <mi> with single-char content is italic by default (spec).
117-
// Multi-char <mi> is normal by default. The m:nor flag forces normal.
118-
if (tag === 'mi' && isNormalText) {
119-
el.setAttribute('mathvariant', 'normal');
153+
// Apply mathvariant when the spec properties resolve to one. The default
154+
// for single-char <mi> is italic and for multi-char <mi>/<mo>/<mn> is
155+
// normal — we only set an attribute when m:rPr explicitly specifies it.
156+
if (variant) {
157+
el.setAttribute('mathvariant', variant);
120158
}
121159

122160
return el;
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { MathObjectConverter } from '../types.js';
2+
3+
const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
4+
5+
/**
6+
* Convert m:limUpp (upper limit) to MathML <mover>.
7+
*
8+
* OMML structure:
9+
* m:limUpp → m:limUppPr (optional), m:e (base), m:lim (limit expression placed above)
10+
*
11+
* MathML output:
12+
* <mover> <mrow>base</mrow> <mrow>lim</mrow> </mover>
13+
*
14+
* @spec ECMA-376 §22.1.2.56
15+
*/
16+
export const convertUpperLimit: MathObjectConverter = (node, doc, convertChildren) => {
17+
const elements = node.elements ?? [];
18+
const base = elements.find((e) => e.name === 'm:e');
19+
const lim = elements.find((e) => e.name === 'm:lim');
20+
21+
const mover = doc.createElementNS(MATHML_NS, 'mover');
22+
23+
const baseRow = doc.createElementNS(MATHML_NS, 'mrow');
24+
baseRow.appendChild(convertChildren(base?.elements ?? []));
25+
mover.appendChild(baseRow);
26+
27+
const limRow = doc.createElementNS(MATHML_NS, 'mrow');
28+
limRow.appendChild(convertChildren(lim?.elements ?? []));
29+
mover.appendChild(limRow);
30+
31+
return mover;
32+
};

0 commit comments

Comments
 (0)