Skip to content

Commit aa0d15d

Browse files
feat(math): implement m:sPre pre-sub-superscript converter (#2747)
* feat(math): implement m:sPre pre-sub-superscript converter (closes #2609) Made-with: Cursor * fix(math): address review findings on m:sPre converter - Correct JSDoc OMML child order to spec (sPrePr, sub, sup, e) per ECMA-376 §22.1.2.99 - Move m:sPre into the implemented section of the converter registry - Add sibling-parity unit tests: properties-element filter, multi-run mrow wrap - Assert children.length === 4 in missing-case test to lock in arity invariant - Relocate describe block next to m:sSubSup; align test name with sibling convention - Reorder happy-path fixture to spec-correct child order - Add behavior tests + math-spre-tests.docx fixture covering 9 m:sPre shapes (basic, isotope, multi-run, only-sub, only-sup, no sPrePr, fraction-in-sub, nested sPre, display-mode oMathPara) - Add m:sPre round-trip test in math importer for child-order preservation * test(math): lock in m:sPre export passthrough round-trip --------- Co-authored-by: Caio Pizzol <caiopizzol@icloud.com>
1 parent 9a3776d commit aa0d15d

File tree

8 files changed

+450
-1
lines changed

8 files changed

+450
-1
lines changed

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
@@ -14,6 +14,7 @@ export { convertDelimiter } from './delimiter.js';
1414
export { convertSubscript } from './subscript.js';
1515
export { convertSuperscript } from './superscript.js';
1616
export { convertSubSuperscript } from './sub-superscript.js';
17+
export { convertPreSubSuperscript } from './pre-sub-superscript.js';
1718
export { convertRadical } from './radical.js';
1819
export { convertLowerLimit } from './lower-limit.js';
1920
export { convertUpperLimit } from './upper-limit.js';
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { MathObjectConverter } from '../types.js';
2+
3+
const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
4+
5+
/**
6+
* Convert m:sPre (pre-sub-superscript) to MathML <mmultiscripts>.
7+
*
8+
* OMML structure:
9+
* m:sPre → m:sPrePr (optional), m:sub (subscript), m:sup (superscript), m:e (base)
10+
*
11+
* Note: element order differs from m:sSubSup — in m:sPre the base (m:e) is the
12+
* LAST child, not the first. The converter uses tag-based lookup (not position)
13+
* so any order is accepted.
14+
*
15+
* MathML output:
16+
* <mmultiscripts>
17+
* <mrow>base</mrow>
18+
* <mprescripts/>
19+
* <mrow>sub</mrow>
20+
* <mrow>sup</mrow>
21+
* </mmultiscripts>
22+
*
23+
* The <mprescripts/> separator tells MathML that the scripts that follow
24+
* are placed to the left of the base rather than to the right.
25+
*
26+
* @spec ECMA-376 §22.1.2.99
27+
*/
28+
export const convertPreSubSuperscript: MathObjectConverter = (node, doc, convertChildren) => {
29+
const elements = node.elements ?? [];
30+
const base = elements.find((e) => e.name === 'm:e');
31+
const sub = elements.find((e) => e.name === 'm:sub');
32+
const sup = elements.find((e) => e.name === 'm:sup');
33+
34+
const mmultiscripts = doc.createElementNS(MATHML_NS, 'mmultiscripts');
35+
36+
const baseRow = doc.createElementNS(MATHML_NS, 'mrow');
37+
baseRow.appendChild(convertChildren(base?.elements ?? []));
38+
mmultiscripts.appendChild(baseRow);
39+
40+
mmultiscripts.appendChild(doc.createElementNS(MATHML_NS, 'mprescripts'));
41+
42+
const subRow = doc.createElementNS(MATHML_NS, 'mrow');
43+
subRow.appendChild(convertChildren(sub?.elements ?? []));
44+
mmultiscripts.appendChild(subRow);
45+
46+
const supRow = doc.createElementNS(MATHML_NS, 'mrow');
47+
supRow.appendChild(convertChildren(sup?.elements ?? []));
48+
mmultiscripts.appendChild(supRow);
49+
50+
return mmultiscripts;
51+
};

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

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1514,6 +1514,148 @@ describe('m:sSubSup converter', () => {
15141514
});
15151515
});
15161516

1517+
describe('m:sPre converter', () => {
1518+
// Per ECMA-376 §22.1.2.99, m:sPre children appear in the order
1519+
// (m:sPrePr?, m:sub, m:sup, m:e) — base is last, not first.
1520+
it('converts pre-sub-superscript to <mmultiscripts> with <mprescripts/>', () => {
1521+
const omml = {
1522+
name: 'm:oMath',
1523+
elements: [
1524+
{
1525+
name: 'm:sPre',
1526+
elements: [
1527+
{
1528+
name: 'm:sub',
1529+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }],
1530+
},
1531+
{
1532+
name: 'm:sup',
1533+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'b' }] }] }],
1534+
},
1535+
{
1536+
name: 'm:e',
1537+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'X' }] }] }],
1538+
},
1539+
],
1540+
},
1541+
],
1542+
};
1543+
const result = convertOmmlToMathml(omml, doc);
1544+
expect(result).not.toBeNull();
1545+
const mmulti = result!.querySelector('mmultiscripts');
1546+
expect(mmulti).not.toBeNull();
1547+
// mmultiscripts children order: base, <mprescripts/>, sub, sup
1548+
expect(mmulti!.children.length).toBe(4);
1549+
expect(mmulti!.children[0]!.textContent).toBe('X');
1550+
expect(mmulti!.children[1]!.localName).toBe('mprescripts');
1551+
expect(mmulti!.children[2]!.textContent).toBe('a');
1552+
expect(mmulti!.children[3]!.textContent).toBe('b');
1553+
});
1554+
1555+
it('ignores m:sPrePr properties element', () => {
1556+
const omml = {
1557+
name: 'm:oMath',
1558+
elements: [
1559+
{
1560+
name: 'm:sPre',
1561+
elements: [
1562+
{ name: 'm:sPrePr', elements: [{ name: 'm:ctrlPr' }] },
1563+
{
1564+
name: 'm:sub',
1565+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }],
1566+
},
1567+
{
1568+
name: 'm:sup',
1569+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'b' }] }] }],
1570+
},
1571+
{
1572+
name: 'm:e',
1573+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'X' }] }] }],
1574+
},
1575+
],
1576+
},
1577+
],
1578+
};
1579+
const result = convertOmmlToMathml(omml, doc);
1580+
expect(result).not.toBeNull();
1581+
const mmulti = result!.querySelector('mmultiscripts');
1582+
expect(mmulti).not.toBeNull();
1583+
expect(mmulti!.children.length).toBe(4);
1584+
expect(mmulti!.children[0]!.textContent).toBe('X');
1585+
expect(mmulti!.children[1]!.localName).toBe('mprescripts');
1586+
expect(mmulti!.children[2]!.textContent).toBe('a');
1587+
expect(mmulti!.children[3]!.textContent).toBe('b');
1588+
});
1589+
1590+
it('wraps multi-run sub and sup in <mrow> for valid arity', () => {
1591+
// {}_{n+1}^{k-1}X — both pre-scripts have multiple runs
1592+
const omml = {
1593+
name: 'm:oMath',
1594+
elements: [
1595+
{
1596+
name: 'm:sPre',
1597+
elements: [
1598+
{
1599+
name: 'm:sub',
1600+
elements: [
1601+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'n' }] }] },
1602+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '+' }] }] },
1603+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '1' }] }] },
1604+
],
1605+
},
1606+
{
1607+
name: 'm:sup',
1608+
elements: [
1609+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'k' }] }] },
1610+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '-' }] }] },
1611+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '1' }] }] },
1612+
],
1613+
},
1614+
{
1615+
name: 'm:e',
1616+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'X' }] }] }],
1617+
},
1618+
],
1619+
},
1620+
],
1621+
};
1622+
const result = convertOmmlToMathml(omml, doc);
1623+
expect(result).not.toBeNull();
1624+
const mmulti = result!.querySelector('mmultiscripts');
1625+
expect(mmulti).not.toBeNull();
1626+
// <mmultiscripts> must keep exactly 4 children — the mrow wrapping preserves arity
1627+
expect(mmulti!.children.length).toBe(4);
1628+
expect(mmulti!.children[0]!.textContent).toBe('X');
1629+
expect(mmulti!.children[1]!.localName).toBe('mprescripts');
1630+
expect(mmulti!.children[2]!.textContent).toBe('n+1');
1631+
expect(mmulti!.children[3]!.textContent).toBe('k-1');
1632+
});
1633+
1634+
it('handles missing m:sub and m:sup gracefully', () => {
1635+
const omml = {
1636+
name: 'm:oMath',
1637+
elements: [
1638+
{
1639+
name: 'm:sPre',
1640+
elements: [
1641+
{
1642+
name: 'm:e',
1643+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'Y' }] }] }],
1644+
},
1645+
],
1646+
},
1647+
],
1648+
};
1649+
const result = convertOmmlToMathml(omml, doc);
1650+
const mmulti = result!.querySelector('mmultiscripts');
1651+
expect(mmulti).not.toBeNull();
1652+
// Empty sub/sup mrows preserved to keep valid <mmultiscripts> arity of 4.
1653+
expect(mmulti!.children.length).toBe(4);
1654+
expect(mmulti!.children[0]!.textContent).toBe('Y');
1655+
expect(mmulti!.children[1]!.localName).toBe('mprescripts');
1656+
});
1657+
});
1658+
15171659
describe('m:func converter', () => {
15181660
it('converts m:func to function name + apply operator + argument', () => {
15191661
const omml = {

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
@@ -19,6 +19,7 @@ import {
1919
convertSubscript,
2020
convertSuperscript,
2121
convertSubSuperscript,
22+
convertPreSubSuperscript,
2223
convertRadical,
2324
convertLowerLimit,
2425
convertUpperLimit,
@@ -52,6 +53,7 @@ const MATH_OBJECT_REGISTRY: Record<string, MathObjectConverter | null> = {
5253
'm:sSub': convertSubscript, // Subscript
5354
'm:sSup': convertSuperscript, // Superscript
5455
'm:sSubSup': convertSubSuperscript, // Sub-superscript (both)
56+
'm:sPre': convertPreSubSuperscript, // Pre-sub-superscript (left of base)
5557

5658
// ── Not yet implemented (community contributions welcome) ────────────────
5759
'm:acc': null, // Accent (diacritical mark above base)
@@ -62,7 +64,6 @@ const MATH_OBJECT_REGISTRY: Record<string, MathObjectConverter | null> = {
6264
'm:m': null, // Matrix (grid of elements)
6365
'm:nary': null, // N-ary operator (integral, summation, product)
6466
'm:phant': null, // Phantom (invisible spacing placeholder)
65-
'm:sPre': null, // Pre-sub-superscript (left of base)
6667
};
6768

6869
/** OMML argument/container elements that wrap children in <mrow>. */
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { translatePassthroughNode } from '../../exporter.js';
3+
4+
// Math nodes (mathInline / mathBlock) serialize back to OOXML via a generic
5+
// passthrough that deep-copies node.attrs.originalXml. These tests lock in
6+
// that behavior so m:sPre (and other math objects) round-trip on export.
7+
8+
describe('math export passthrough', () => {
9+
it('deep-copies m:sPre originalXml with child order preserved', () => {
10+
// Spec-correct child order per ECMA-376 §22.1.2.99: (m:sPrePr, m:sub, m:sup, m:e)
11+
const originalXml = {
12+
name: 'm:oMath',
13+
elements: [
14+
{
15+
name: 'm:sPre',
16+
elements: [
17+
{ name: 'm:sPrePr', elements: [{ name: 'm:ctrlPr' }] },
18+
{
19+
name: 'm:sub',
20+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '1' }] }] }],
21+
},
22+
{
23+
name: 'm:sup',
24+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '2' }] }] }],
25+
},
26+
{
27+
name: 'm:e',
28+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'A' }] }] }],
29+
},
30+
],
31+
},
32+
],
33+
};
34+
35+
const node = { attrs: { originalXml } };
36+
const result = translatePassthroughNode({ node });
37+
38+
expect(result).not.toBe(originalXml);
39+
expect(result.name).toBe('m:oMath');
40+
expect(result.elements[0].name).toBe('m:sPre');
41+
expect(result.elements[0].elements.map((e) => e.name)).toEqual(['m:sPrePr', 'm:sub', 'm:sup', 'm:e']);
42+
43+
// Verify deep copy: mutating the result must not affect the source
44+
result.elements[0].elements[1].elements[0].elements[0].elements[0].text = 'MUTATED';
45+
expect(originalXml.elements[0].elements[1].elements[0].elements[0].elements[0].text).toBe('1');
46+
});
47+
48+
it('passes through m:oMathPara wrapping m:sPre for display-mode export', () => {
49+
const originalXml = {
50+
name: 'm:oMathPara',
51+
elements: [
52+
{
53+
name: 'm:oMath',
54+
elements: [
55+
{
56+
name: 'm:sPre',
57+
elements: [
58+
{ name: 'm:sPrePr', elements: [{ name: 'm:ctrlPr' }] },
59+
{
60+
name: 'm:sub',
61+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '1' }] }] }],
62+
},
63+
{
64+
name: 'm:sup',
65+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '2' }] }] }],
66+
},
67+
{
68+
name: 'm:e',
69+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'Z' }] }] }],
70+
},
71+
],
72+
},
73+
],
74+
},
75+
],
76+
};
77+
78+
const result = translatePassthroughNode({ node: { attrs: { originalXml } } });
79+
80+
expect(result.name).toBe('m:oMathPara');
81+
expect(result.elements[0].name).toBe('m:oMath');
82+
expect(result.elements[0].elements[0].name).toBe('m:sPre');
83+
});
84+
85+
it('returns null when originalXml is missing', () => {
86+
expect(translatePassthroughNode({ node: { attrs: {} } })).toBeNull();
87+
expect(translatePassthroughNode({ node: {} })).toBeNull();
88+
expect(translatePassthroughNode({})).toBeNull();
89+
});
90+
});

packages/super-editor/src/editors/v1/core/super-converter/v2/importer/math/math-importer.test.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,42 @@ describe('mathNodeHandler', () => {
5353
expect(original).not.toBe(oMathNode);
5454
expect(original.elements[0].name).toBe('m:sSup');
5555
});
56+
57+
it('preserves m:sPre subtree verbatim in originalXml', () => {
58+
// Spec-correct child order per ECMA-376 §22.1.2.99: (m:sPrePr?, m:sub, m:sup, m:e)
59+
const oMathNode = {
60+
name: 'm:oMath',
61+
elements: [
62+
{
63+
name: 'm:sPre',
64+
elements: [
65+
{ name: 'm:sPrePr', elements: [{ name: 'm:ctrlPr' }] },
66+
{
67+
name: 'm:sub',
68+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '1' }] }] }],
69+
},
70+
{
71+
name: 'm:sup',
72+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '2' }] }] }],
73+
},
74+
{
75+
name: 'm:e',
76+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'A' }] }] }],
77+
},
78+
],
79+
},
80+
],
81+
};
82+
83+
const result = handler({ nodes: [oMathNode] });
84+
const original = result.nodes[0].attrs.originalXml;
85+
86+
expect(original).not.toBe(oMathNode);
87+
expect(original.elements[0].name).toBe('m:sPre');
88+
// Child order is preserved — the layout-engine converter relies on tag-based
89+
// lookup, but the importer must not rearrange the tree.
90+
expect(original.elements[0].elements.map((e) => e.name)).toEqual(['m:sPrePr', 'm:sub', 'm:sup', 'm:e']);
91+
});
5692
});
5793

5894
describe('m:oMathPara (display math)', () => {
13.8 KB
Binary file not shown.

0 commit comments

Comments
 (0)