Skip to content

Commit efa2961

Browse files
feat(math): implement m:sSubSup sub-superscript converter (SD-2374) (#2668)
* feat(math): implement m:sSubSup sub-superscript converter (SD-2374) Add convertSubSuperscript converter that transforms OMML m:sSubSup elements into MathML <msubsup> with three children (base, subscript, superscript), each wrapped in <mrow> for valid arity. Closes #2597 * test: add behavior test for sSubSup and update stale comment - Add behavior test verifying m:sSubSup renders as <msubsup> with correct base/subscript/superscript children (x_i^2 from fixture) - Update stale comment that listed superscript as unimplemented * fix: correct spec section reference in sSubSup JSDoc §22.1.2.104 is sSubSupPr (properties), §22.1.2.103 is sSubSup itself. --------- Co-authored-by: Caio Pizzol <caio@harbourshare.com>
1 parent 8dfbf95 commit efa2961

5 files changed

Lines changed: 197 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
@@ -11,3 +11,4 @@ export { convertFraction } from './fraction.js';
1111
export { convertBar } from './bar.js';
1212
export { convertSubscript } from './subscript.js';
1313
export { convertSuperscript } from './superscript.js';
14+
export { convertSubSuperscript } from './sub-superscript.js';
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { MathObjectConverter } from '../types.js';
2+
3+
const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
4+
5+
/**
6+
* Convert m:sSubSup (sub-superscript) to MathML <msubsup>.
7+
*
8+
* OMML structure:
9+
* m:sSubSup → m:sSubSupPr (optional), m:e (base), m:sub (subscript), m:sup (superscript)
10+
*
11+
* MathML output:
12+
* <msubsup> <mrow>base</mrow> <mrow>sub</mrow> <mrow>sup</mrow> </msubsup>
13+
*
14+
* @spec ECMA-376 §22.1.2.103
15+
*/
16+
export const convertSubSuperscript: 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+
const sup = elements.find((e) => e.name === 'm:sup');
21+
22+
const msubsup = doc.createElementNS(MATHML_NS, 'msubsup');
23+
24+
const baseRow = doc.createElementNS(MATHML_NS, 'mrow');
25+
baseRow.appendChild(convertChildren(base?.elements ?? []));
26+
msubsup.appendChild(baseRow);
27+
28+
const subRow = doc.createElementNS(MATHML_NS, 'mrow');
29+
subRow.appendChild(convertChildren(sub?.elements ?? []));
30+
msubsup.appendChild(subRow);
31+
32+
const supRow = doc.createElementNS(MATHML_NS, 'mrow');
33+
supRow.appendChild(convertChildren(sup?.elements ?? []));
34+
msubsup.appendChild(supRow);
35+
36+
return msubsup;
37+
};

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

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,3 +551,136 @@ describe('m:sSup converter', () => {
551551
expect(msup!.children[0]!.textContent).toBe('x');
552552
});
553553
});
554+
555+
describe('m:sSubSup converter', () => {
556+
it('converts m:sSubSup to <msubsup> with base, subscript, and superscript', () => {
557+
const omml = {
558+
name: 'm:oMath',
559+
elements: [
560+
{
561+
name: 'm:sSubSup',
562+
elements: [
563+
{
564+
name: 'm:e',
565+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
566+
},
567+
{
568+
name: 'm:sub',
569+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'i' }] }] }],
570+
},
571+
{
572+
name: 'm:sup',
573+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '2' }] }] }],
574+
},
575+
],
576+
},
577+
],
578+
};
579+
const result = convertOmmlToMathml(omml, doc);
580+
expect(result).not.toBeNull();
581+
const msubsup = result!.querySelector('msubsup');
582+
expect(msubsup).not.toBeNull();
583+
expect(msubsup!.children.length).toBe(3);
584+
expect(msubsup!.children[0]!.textContent).toBe('x');
585+
expect(msubsup!.children[1]!.textContent).toBe('i');
586+
expect(msubsup!.children[2]!.textContent).toBe('2');
587+
});
588+
589+
it('ignores m:sSubSupPr properties element', () => {
590+
const omml = {
591+
name: 'm:oMath',
592+
elements: [
593+
{
594+
name: 'm:sSubSup',
595+
elements: [
596+
{ name: 'm:sSubSupPr', elements: [{ name: 'm:alnScr' }] },
597+
{
598+
name: 'm:e',
599+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }],
600+
},
601+
{
602+
name: 'm:sub',
603+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'n' }] }] }],
604+
},
605+
{
606+
name: 'm:sup',
607+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'k' }] }] }],
608+
},
609+
],
610+
},
611+
],
612+
};
613+
const result = convertOmmlToMathml(omml, doc);
614+
expect(result).not.toBeNull();
615+
const msubsup = result!.querySelector('msubsup');
616+
expect(msubsup).not.toBeNull();
617+
expect(msubsup!.children.length).toBe(3);
618+
expect(msubsup!.children[0]!.textContent).toBe('a');
619+
expect(msubsup!.children[1]!.textContent).toBe('n');
620+
expect(msubsup!.children[2]!.textContent).toBe('k');
621+
});
622+
623+
it('wraps multi-part operands in <mrow> for valid arity', () => {
624+
// x_{n+1}^{k-1} — both sub and sup have multiple runs
625+
const omml = {
626+
name: 'm:oMath',
627+
elements: [
628+
{
629+
name: 'm:sSubSup',
630+
elements: [
631+
{
632+
name: 'm:e',
633+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
634+
},
635+
{
636+
name: 'm:sub',
637+
elements: [
638+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'n' }] }] },
639+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '+' }] }] },
640+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '1' }] }] },
641+
],
642+
},
643+
{
644+
name: 'm:sup',
645+
elements: [
646+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'k' }] }] },
647+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '-' }] }] },
648+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '1' }] }] },
649+
],
650+
},
651+
],
652+
},
653+
],
654+
};
655+
const result = convertOmmlToMathml(omml, doc);
656+
expect(result).not.toBeNull();
657+
const msubsup = result!.querySelector('msubsup');
658+
expect(msubsup).not.toBeNull();
659+
expect(msubsup!.children.length).toBe(3);
660+
expect(msubsup!.children[0]!.textContent).toBe('x');
661+
expect(msubsup!.children[1]!.textContent).toBe('n+1');
662+
expect(msubsup!.children[2]!.textContent).toBe('k-1');
663+
});
664+
665+
it('handles missing m:sub and m:sup gracefully', () => {
666+
const omml = {
667+
name: 'm:oMath',
668+
elements: [
669+
{
670+
name: 'm:sSubSup',
671+
elements: [
672+
{
673+
name: 'm:e',
674+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
675+
},
676+
],
677+
},
678+
],
679+
};
680+
const result = convertOmmlToMathml(omml, doc);
681+
expect(result).not.toBeNull();
682+
const msubsup = result!.querySelector('msubsup');
683+
expect(msubsup).not.toBeNull();
684+
expect(msubsup!.children[0]!.textContent).toBe('x');
685+
});
686+
});

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
convertBar,
1717
convertSubscript,
1818
convertSuperscript,
19+
convertSubSuperscript,
1920
} from './converters/index.js';
2021

2122
export const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
@@ -40,6 +41,7 @@ const MATH_OBJECT_REGISTRY: Record<string, MathObjectConverter | null> = {
4041
'm:f': convertFraction, // Fraction (numerator/denominator)
4142
'm:sSub': convertSubscript, // Subscript
4243
'm:sSup': convertSuperscript, // Superscript
44+
'm:sSubSup': convertSubSuperscript, // Sub-superscript (both)
4345

4446
// ── Not yet implemented (community contributions welcome) ────────────────
4547
'm:acc': null, // Accent (diacritical mark above base)
@@ -56,7 +58,6 @@ const MATH_OBJECT_REGISTRY: Record<string, MathObjectConverter | null> = {
5658
'm:phant': null, // Phantom (invisible spacing placeholder)
5759
'm:rad': null, // Radical (square root, nth root)
5860
'm:sPre': null, // Pre-sub-superscript (left of base)
59-
'm:sSubSup': null, // Sub-superscript (both)
6061
};
6162

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

tests/behavior/tests/importing/math-equations.spec.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,34 @@ test.describe('math equation import and rendering', () => {
8383
}
8484
});
8585

86+
test('renders sub-superscript as <msubsup> with base, subscript, and superscript', async ({ superdoc }) => {
87+
await superdoc.loadDocument(ALL_OBJECTS_DOC);
88+
await superdoc.waitForStable();
89+
90+
// The test doc has x_i^2 — should render as <msubsup> with 3 children
91+
const subSupData = await superdoc.page.evaluate(() => {
92+
const msubsup = document.querySelector('msubsup');
93+
if (!msubsup) return null;
94+
return {
95+
childCount: msubsup.children.length,
96+
base: msubsup.children[0]?.textContent,
97+
subscript: msubsup.children[1]?.textContent,
98+
superscript: msubsup.children[2]?.textContent,
99+
};
100+
});
101+
102+
expect(subSupData).not.toBeNull();
103+
expect(subSupData!.childCount).toBe(3);
104+
expect(subSupData!.base).toBe('x');
105+
expect(subSupData!.subscript).toBe('i');
106+
expect(subSupData!.superscript).toBe('2');
107+
});
108+
86109
test('math text content is preserved for unimplemented objects', async ({ superdoc }) => {
87110
await superdoc.loadDocument(ALL_OBJECTS_DOC);
88111
await superdoc.waitForStable();
89112

90-
// Unimplemented math objects (e.g., superscript, radical) should still
113+
// Unimplemented math objects (e.g., radical, delimiter) should still
91114
// have their text content accessible in the PM document
92115
const mathTexts = await superdoc.page.evaluate(() => {
93116
const view = (window as any).editor?.view;

0 commit comments

Comments
 (0)