Skip to content

Commit 2cc3abb

Browse files
feat(math): implement m:sSub subscript converter (SD-2373) (#2635)
* feat(math): implement m:sSub subscript converter (SD-2373) Add OMML m:sSub β†’ MathML <msub> converter following the fraction.ts pattern. Also move m:f from the "not yet implemented" registry section to "implemented" where it belongs. Closes #2596 * fix(math): wrap operands in <mrow> for valid MathML arity Converters for msub, mfrac were appending raw DocumentFragments directly, causing multi-token expressions (e.g. n+1) to produce too many direct children. MathML script/fraction elements require exactly 2 children. Wrap each operand in <mrow> to group them, matching the pattern already used by bar.ts.
1 parent 179190e commit 2cc3abb

5 files changed

Lines changed: 197 additions & 6 deletions

File tree

β€Žpackages/layout-engine/painters/dom/src/features/math/converters/fraction.tsβ€Ž

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,14 @@ export const convertFraction: MathObjectConverter = (node, doc, convertChildren)
1919
const den = elements.find((e) => e.name === 'm:den');
2020

2121
const frac = doc.createElementNS(MATHML_NS, 'mfrac');
22-
frac.appendChild(convertChildren(num?.elements ?? []));
23-
frac.appendChild(convertChildren(den?.elements ?? []));
22+
23+
const numRow = doc.createElementNS(MATHML_NS, 'mrow');
24+
numRow.appendChild(convertChildren(num?.elements ?? []));
25+
frac.appendChild(numRow);
26+
27+
const denRow = doc.createElementNS(MATHML_NS, 'mrow');
28+
denRow.appendChild(convertChildren(den?.elements ?? []));
29+
frac.appendChild(denRow);
2430

2531
return frac;
2632
};

β€Ž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
@@ -9,3 +9,4 @@
99
export { convertMathRun } from './math-run.js';
1010
export { convertFraction } from './fraction.js';
1111
export { convertBar } from './bar.js';
12+
export { convertSubscript } from './subscript.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:sSub (subscript) to MathML <msub>.
7+
*
8+
* OMML structure:
9+
* m:sSub β†’ m:sSubPr (optional), m:e (base), m:sub (subscript)
10+
*
11+
* MathML output:
12+
* <msub> <mrow>base</mrow> <mrow>sub</mrow> </msub>
13+
*
14+
* @spec ECMA-376 Β§22.1.2.101
15+
*/
16+
export const convertSubscript: MathObjectConverter = (node, doc, convertChildren) => {
17+
const elements = node.elements ?? [];
18+
const base = elements.find((e) => e.name === 'm:e');
19+
const sub = elements.find((e) => e.name === 'm:sub');
20+
21+
const msub = doc.createElementNS(MATHML_NS, 'msub');
22+
23+
const baseRow = doc.createElementNS(MATHML_NS, 'mrow');
24+
baseRow.appendChild(convertChildren(base?.elements ?? []));
25+
msub.appendChild(baseRow);
26+
27+
const subRow = doc.createElementNS(MATHML_NS, 'mrow');
28+
subRow.appendChild(convertChildren(sub?.elements ?? []));
29+
msub.appendChild(subRow);
30+
31+
return msub;
32+
};

β€Žpackages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.tsβ€Ž

Lines changed: 154 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,7 @@ describe('convertOmmlToMathml', () => {
119119
expect(result!.textContent).toBe('z');
120120
});
121121

122-
it('handles unimplemented math objects by extracting child content', () => {
123-
// m:f (fraction) is not yet implemented β€” should fall back to rendering children
122+
it('converts m:f (fraction) to <mfrac> with numerator and denominator', () => {
124123
const omml = {
125124
name: 'm:oMath',
126125
elements: [
@@ -151,6 +150,44 @@ describe('convertOmmlToMathml', () => {
151150
expect(mfrac!.children[1]!.textContent).toBe('b');
152151
});
153152

153+
it('wraps multi-part fraction operands in <mrow> for valid arity', () => {
154+
// (a+b)/(c+d) β€” both numerator and denominator have multiple runs
155+
const omml = {
156+
name: 'm:oMath',
157+
elements: [
158+
{
159+
name: 'm:f',
160+
elements: [
161+
{
162+
name: 'm:num',
163+
elements: [
164+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] },
165+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '+' }] }] },
166+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'b' }] }] },
167+
],
168+
},
169+
{
170+
name: 'm:den',
171+
elements: [
172+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'c' }] }] },
173+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '+' }] }] },
174+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'd' }] }] },
175+
],
176+
},
177+
],
178+
},
179+
],
180+
};
181+
const result = convertOmmlToMathml(omml, doc);
182+
expect(result).not.toBeNull();
183+
const mfrac = result!.querySelector('mfrac');
184+
expect(mfrac).not.toBeNull();
185+
// <mfrac> must have exactly 2 children (num + den), each wrapped in <mrow>
186+
expect(mfrac!.children.length).toBe(2);
187+
expect(mfrac!.children[0]!.textContent).toBe('a+b');
188+
expect(mfrac!.children[1]!.textContent).toBe('c+d');
189+
});
190+
154191
it('sets mathvariant=normal for m:nor (normal text) flag', () => {
155192
const omml = {
156193
name: 'm:oMath',
@@ -284,3 +321,118 @@ describe('m:bar converter', () => {
284321
expect(mo?.textContent).toBe('\u203E');
285322
});
286323
});
324+
325+
describe('m:sSub converter', () => {
326+
it('converts m:sSub to <msub> with base and subscript', () => {
327+
const omml = {
328+
name: 'm:oMath',
329+
elements: [
330+
{
331+
name: 'm:sSub',
332+
elements: [
333+
{
334+
name: 'm:e',
335+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }],
336+
},
337+
{
338+
name: 'm:sub',
339+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '1' }] }] }],
340+
},
341+
],
342+
},
343+
],
344+
};
345+
const result = convertOmmlToMathml(omml, doc);
346+
expect(result).not.toBeNull();
347+
const msub = result!.querySelector('msub');
348+
expect(msub).not.toBeNull();
349+
expect(msub!.children.length).toBe(2);
350+
expect(msub!.children[0]!.textContent).toBe('a');
351+
expect(msub!.children[1]!.textContent).toBe('1');
352+
});
353+
354+
it('ignores m:sSubPr properties element', () => {
355+
const omml = {
356+
name: 'm:oMath',
357+
elements: [
358+
{
359+
name: 'm:sSub',
360+
elements: [
361+
{ name: 'm:sSubPr', elements: [{ name: 'm:ctrlPr' }] },
362+
{
363+
name: 'm:e',
364+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
365+
},
366+
{
367+
name: 'm:sub',
368+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'n' }] }] }],
369+
},
370+
],
371+
},
372+
],
373+
};
374+
const result = convertOmmlToMathml(omml, doc);
375+
expect(result).not.toBeNull();
376+
const msub = result!.querySelector('msub');
377+
expect(msub).not.toBeNull();
378+
expect(msub!.children.length).toBe(2);
379+
expect(msub!.children[0]!.textContent).toBe('x');
380+
expect(msub!.children[1]!.textContent).toBe('n');
381+
});
382+
383+
it('wraps multi-part base and subscript in <mrow> for valid arity', () => {
384+
// x_{n+1} β€” subscript has 3 runs that must be grouped
385+
const omml = {
386+
name: 'm:oMath',
387+
elements: [
388+
{
389+
name: 'm:sSub',
390+
elements: [
391+
{
392+
name: 'm:e',
393+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
394+
},
395+
{
396+
name: 'm:sub',
397+
elements: [
398+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'n' }] }] },
399+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '+' }] }] },
400+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '1' }] }] },
401+
],
402+
},
403+
],
404+
},
405+
],
406+
};
407+
const result = convertOmmlToMathml(omml, doc);
408+
expect(result).not.toBeNull();
409+
const msub = result!.querySelector('msub');
410+
expect(msub).not.toBeNull();
411+
// <msub> must have exactly 2 children (base + subscript), each wrapped in <mrow>
412+
expect(msub!.children.length).toBe(2);
413+
expect(msub!.children[0]!.textContent).toBe('x');
414+
expect(msub!.children[1]!.textContent).toBe('n+1');
415+
});
416+
417+
it('handles missing m:sub gracefully', () => {
418+
const omml = {
419+
name: 'm:oMath',
420+
elements: [
421+
{
422+
name: 'm:sSub',
423+
elements: [
424+
{
425+
name: 'm:e',
426+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }],
427+
},
428+
],
429+
},
430+
],
431+
};
432+
const result = convertOmmlToMathml(omml, doc);
433+
expect(result).not.toBeNull();
434+
const msub = result!.querySelector('msub');
435+
expect(msub).not.toBeNull();
436+
expect(msub!.children[0]!.textContent).toBe('a');
437+
});
438+
});

β€Žpackages/layout-engine/painters/dom/src/features/math/omml-to-mathml.tsβ€Ž

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
*/
1111

1212
import type { OmmlJsonNode, MathObjectConverter } from './types.js';
13-
import { convertMathRun, convertFraction, convertBar } from './converters/index.js';
13+
import { convertMathRun, convertFraction, convertBar, convertSubscript } from './converters/index.js';
1414

1515
export const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
1616

@@ -32,6 +32,7 @@ const MATH_OBJECT_REGISTRY: Record<string, MathObjectConverter | null> = {
3232
'm:r': convertMathRun,
3333
'm:bar': convertBar, // Bar (overbar/underbar)
3434
'm:f': convertFraction, // Fraction (numerator/denominator)
35+
'm:sSub': convertSubscript, // Subscript
3536

3637
// ── Not yet implemented (community contributions welcome) ────────────────
3738
'm:acc': null, // Accent (diacritical mark above base)
@@ -48,7 +49,6 @@ const MATH_OBJECT_REGISTRY: Record<string, MathObjectConverter | null> = {
4849
'm:phant': null, // Phantom (invisible spacing placeholder)
4950
'm:rad': null, // Radical (square root, nth root)
5051
'm:sPre': null, // Pre-sub-superscript (left of base)
51-
'm:sSub': null, // Subscript
5252
'm:sSubSup': null, // Sub-superscript (both)
5353
'm:sSup': null, // Superscript
5454
};

0 commit comments

Comments
Β (0)