Skip to content

Commit 68de253

Browse files
feat(math): implement m:sSup superscript converter (SD-2372) (#2634)
* feat(math): implement m:sSup superscript converter (SD-2372) Add OMML m:sSup β†’ MathML <msup> converter following the fraction.ts pattern. Also move m:f from the "not yet implemented" registry section to "implemented" where it belongs. Closes #2595 * fix(math): wrap operands in <mrow> for valid MathML arity Converters for msup, mfrac were appending raw DocumentFragments directly, causing multi-token expressions (e.g. x+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 2cc3abb commit 68de253

4 files changed

Lines changed: 156 additions & 2 deletions

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

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

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,3 +436,118 @@ describe('m:sSub converter', () => {
436436
expect(msub!.children[0]!.textContent).toBe('a');
437437
});
438438
});
439+
440+
describe('m:sSup converter', () => {
441+
it('converts m:sSup to <msup> with base and superscript', () => {
442+
const omml = {
443+
name: 'm:oMath',
444+
elements: [
445+
{
446+
name: 'm:sSup',
447+
elements: [
448+
{
449+
name: 'm:e',
450+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
451+
},
452+
{
453+
name: 'm:sup',
454+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '2' }] }] }],
455+
},
456+
],
457+
},
458+
],
459+
};
460+
const result = convertOmmlToMathml(omml, doc);
461+
expect(result).not.toBeNull();
462+
const msup = result!.querySelector('msup');
463+
expect(msup).not.toBeNull();
464+
expect(msup!.children.length).toBe(2);
465+
expect(msup!.children[0]!.textContent).toBe('x');
466+
expect(msup!.children[1]!.textContent).toBe('2');
467+
});
468+
469+
it('ignores m:sSupPr properties element', () => {
470+
const omml = {
471+
name: 'm:oMath',
472+
elements: [
473+
{
474+
name: 'm:sSup',
475+
elements: [
476+
{ name: 'm:sSupPr', elements: [{ name: 'm:ctrlPr' }] },
477+
{
478+
name: 'm:e',
479+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }],
480+
},
481+
{
482+
name: 'm:sup',
483+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'b' }] }] }],
484+
},
485+
],
486+
},
487+
],
488+
};
489+
const result = convertOmmlToMathml(omml, doc);
490+
expect(result).not.toBeNull();
491+
const msup = result!.querySelector('msup');
492+
expect(msup).not.toBeNull();
493+
expect(msup!.children.length).toBe(2);
494+
expect(msup!.children[0]!.textContent).toBe('a');
495+
expect(msup!.children[1]!.textContent).toBe('b');
496+
});
497+
498+
it('wraps multi-part base and superscript in <mrow> for valid arity', () => {
499+
// (x+1)^2 β€” base has 3 runs that must be grouped
500+
const omml = {
501+
name: 'm:oMath',
502+
elements: [
503+
{
504+
name: 'm:sSup',
505+
elements: [
506+
{
507+
name: 'm:e',
508+
elements: [
509+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] },
510+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '+' }] }] },
511+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '1' }] }] },
512+
],
513+
},
514+
{
515+
name: 'm:sup',
516+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '2' }] }] }],
517+
},
518+
],
519+
},
520+
],
521+
};
522+
const result = convertOmmlToMathml(omml, doc);
523+
expect(result).not.toBeNull();
524+
const msup = result!.querySelector('msup');
525+
expect(msup).not.toBeNull();
526+
// <msup> must have exactly 2 children (base + superscript), each wrapped in <mrow>
527+
expect(msup!.children.length).toBe(2);
528+
expect(msup!.children[0]!.textContent).toBe('x+1');
529+
expect(msup!.children[1]!.textContent).toBe('2');
530+
});
531+
532+
it('handles missing m:sup gracefully', () => {
533+
const omml = {
534+
name: 'm:oMath',
535+
elements: [
536+
{
537+
name: 'm:sSup',
538+
elements: [
539+
{
540+
name: 'm:e',
541+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
542+
},
543+
],
544+
},
545+
],
546+
};
547+
const result = convertOmmlToMathml(omml, doc);
548+
expect(result).not.toBeNull();
549+
const msup = result!.querySelector('msup');
550+
expect(msup).not.toBeNull();
551+
expect(msup!.children[0]!.textContent).toBe('x');
552+
});
553+
});

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

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

1212
import type { OmmlJsonNode, MathObjectConverter } from './types.js';
13-
import { convertMathRun, convertFraction, convertBar, convertSubscript } from './converters/index.js';
13+
import {
14+
convertMathRun,
15+
convertFraction,
16+
convertBar,
17+
convertSubscript,
18+
convertSuperscript,
19+
} from './converters/index.js';
1420

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

@@ -33,6 +39,7 @@ const MATH_OBJECT_REGISTRY: Record<string, MathObjectConverter | null> = {
3339
'm:bar': convertBar, // Bar (overbar/underbar)
3440
'm:f': convertFraction, // Fraction (numerator/denominator)
3541
'm:sSub': convertSubscript, // Subscript
42+
'm:sSup': convertSuperscript, // Superscript
3643

3744
// ── Not yet implemented (community contributions welcome) ────────────────
3845
'm:acc': null, // Accent (diacritical mark above base)
@@ -50,7 +57,6 @@ const MATH_OBJECT_REGISTRY: Record<string, MathObjectConverter | null> = {
5057
'm:rad': null, // Radical (square root, nth root)
5158
'm:sPre': null, // Pre-sub-superscript (left of base)
5259
'm:sSubSup': null, // Sub-superscript (both)
53-
'm:sSup': null, // Superscript
5460
};
5561

5662
/** OMML argument/container elements that wrap children in <mrow>. */

0 commit comments

Comments
Β (0)