From f87eec8f7ab657d22882722dc21dff6b49f54fed Mon Sep 17 00:00:00 2001 From: Abdeltoto Date: Tue, 7 Apr 2026 22:37:07 -0400 Subject: [PATCH 1/3] feat(math): implement m:sPre pre-sub-superscript converter (closes #2609) Made-with: Cursor --- .../dom/src/features/math/converters/index.ts | 1 + .../math/converters/pre-sub-superscript.ts | 47 +++++++++++++++ .../src/features/math/omml-to-mathml.test.ts | 58 +++++++++++++++++++ .../dom/src/features/math/omml-to-mathml.ts | 3 +- 4 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 packages/layout-engine/painters/dom/src/features/math/converters/pre-sub-superscript.ts diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/index.ts b/packages/layout-engine/painters/dom/src/features/math/converters/index.ts index 0e7eb25496..b6cc4a7377 100644 --- a/packages/layout-engine/painters/dom/src/features/math/converters/index.ts +++ b/packages/layout-engine/painters/dom/src/features/math/converters/index.ts @@ -13,3 +13,4 @@ export { convertFunction } from './function.js'; export { convertSubscript } from './subscript.js'; export { convertSuperscript } from './superscript.js'; export { convertSubSuperscript } from './sub-superscript.js'; +export { convertPreSubSuperscript } from './pre-sub-superscript.js'; diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/pre-sub-superscript.ts b/packages/layout-engine/painters/dom/src/features/math/converters/pre-sub-superscript.ts new file mode 100644 index 0000000000..dbf6893b36 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/features/math/converters/pre-sub-superscript.ts @@ -0,0 +1,47 @@ +import type { MathObjectConverter } from '../types.js'; + +const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; + +/** + * Convert m:sPre (pre-sub-superscript) to MathML . + * + * OMML structure: + * m:sPre → m:sPrePr (optional), m:e (base), m:sub (subscript), m:sup (superscript) + * + * MathML output: + * + * base + * + * sub + * sup + * + * + * The separator tells MathML that the scripts that follow + * are placed to the left of the base rather than to the right. + * + * @spec ECMA-376 §22.1.2.99 + */ +export const convertPreSubSuperscript: MathObjectConverter = (node, doc, convertChildren) => { + const elements = node.elements ?? []; + const base = elements.find((e) => e.name === 'm:e'); + const sub = elements.find((e) => e.name === 'm:sub'); + const sup = elements.find((e) => e.name === 'm:sup'); + + const mmultiscripts = doc.createElementNS(MATHML_NS, 'mmultiscripts'); + + const baseRow = doc.createElementNS(MATHML_NS, 'mrow'); + baseRow.appendChild(convertChildren(base?.elements ?? [])); + mmultiscripts.appendChild(baseRow); + + mmultiscripts.appendChild(doc.createElementNS(MATHML_NS, 'mprescripts')); + + const subRow = doc.createElementNS(MATHML_NS, 'mrow'); + subRow.appendChild(convertChildren(sub?.elements ?? [])); + mmultiscripts.appendChild(subRow); + + const supRow = doc.createElementNS(MATHML_NS, 'mrow'); + supRow.appendChild(convertChildren(sup?.elements ?? [])); + mmultiscripts.appendChild(supRow); + + return mmultiscripts; +}; diff --git a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts index e689c8f842..d867ddbba5 100644 --- a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts +++ b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts @@ -927,3 +927,61 @@ describe('m:func converter', () => { expect(mis[1]!.textContent).toBe('cos'); }); }); + +describe('m:sPre converter', () => { + it('converts pre-sub-superscript to with ', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:sPre', + elements: [ + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'X' }] }] }], + }, + { + name: 'm:sub', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }], + }, + { + name: 'm:sup', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'b' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const mmulti = result!.querySelector('mmultiscripts'); + expect(mmulti).not.toBeNull(); + expect(mmulti!.children.length).toBe(4); + expect(mmulti!.children[0]!.textContent).toBe('X'); + expect(mmulti!.children[1]!.localName).toBe('mprescripts'); + expect(mmulti!.children[2]!.textContent).toBe('a'); + expect(mmulti!.children[3]!.textContent).toBe('b'); + }); + + it('handles missing sub/sup gracefully', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:sPre', + elements: [ + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'Y' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + const mmulti = result!.querySelector('mmultiscripts'); + expect(mmulti).not.toBeNull(); + expect(mmulti!.children[0]!.textContent).toBe('Y'); + expect(mmulti!.children[1]!.localName).toBe('mprescripts'); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.ts b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.ts index 1a5672d61d..24b8d19d16 100644 --- a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.ts +++ b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.ts @@ -18,6 +18,7 @@ import { convertSubscript, convertSuperscript, convertSubSuperscript, + convertPreSubSuperscript, } from './converters/index.js'; export const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; @@ -58,7 +59,7 @@ const MATH_OBJECT_REGISTRY: Record = { 'm:nary': null, // N-ary operator (integral, summation, product) 'm:phant': null, // Phantom (invisible spacing placeholder) 'm:rad': null, // Radical (square root, nth root) - 'm:sPre': null, // Pre-sub-superscript (left of base) + 'm:sPre': convertPreSubSuperscript, // Pre-sub-superscript (left of base) }; /** OMML argument/container elements that wrap children in . */ From 5c8043da3ba50923651e7be42dad9bf6639f897d Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sun, 12 Apr 2026 13:46:43 -0700 Subject: [PATCH 2/3] fix(math): address review findings on m:sPre converter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../math/converters/pre-sub-superscript.ts | 6 +- .../src/features/math/omml-to-mathml.test.ts | 200 +++++++++++++----- .../dom/src/features/math/omml-to-mathml.ts | 2 +- .../v2/importer/math/math-importer.test.js | 36 ++++ .../importing/fixtures/math-spre-tests.docx | Bin 0 -> 14175 bytes .../tests/importing/math-equations.spec.ts | 128 +++++++++++ 6 files changed, 312 insertions(+), 60 deletions(-) create mode 100644 tests/behavior/tests/importing/fixtures/math-spre-tests.docx diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/pre-sub-superscript.ts b/packages/layout-engine/painters/dom/src/features/math/converters/pre-sub-superscript.ts index dbf6893b36..ed8707bd5d 100644 --- a/packages/layout-engine/painters/dom/src/features/math/converters/pre-sub-superscript.ts +++ b/packages/layout-engine/painters/dom/src/features/math/converters/pre-sub-superscript.ts @@ -6,7 +6,11 @@ const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; * Convert m:sPre (pre-sub-superscript) to MathML . * * OMML structure: - * m:sPre → m:sPrePr (optional), m:e (base), m:sub (subscript), m:sup (superscript) + * m:sPre → m:sPrePr (optional), m:sub (subscript), m:sup (superscript), m:e (base) + * + * Note: element order differs from m:sSubSup — in m:sPre the base (m:e) is the + * LAST child, not the first. The converter uses tag-based lookup (not position) + * so any order is accepted. * * MathML output: * diff --git a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts index d867ddbba5..e18b9aee36 100644 --- a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts +++ b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts @@ -685,6 +685,148 @@ describe('m:sSubSup converter', () => { }); }); +describe('m:sPre converter', () => { + // Per ECMA-376 §22.1.2.99, m:sPre children appear in the order + // (m:sPrePr?, m:sub, m:sup, m:e) — base is last, not first. + it('converts pre-sub-superscript to with ', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:sPre', + elements: [ + { + name: 'm:sub', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }], + }, + { + name: 'm:sup', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'b' }] }] }], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'X' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const mmulti = result!.querySelector('mmultiscripts'); + expect(mmulti).not.toBeNull(); + // mmultiscripts children order: base, , sub, sup + expect(mmulti!.children.length).toBe(4); + expect(mmulti!.children[0]!.textContent).toBe('X'); + expect(mmulti!.children[1]!.localName).toBe('mprescripts'); + expect(mmulti!.children[2]!.textContent).toBe('a'); + expect(mmulti!.children[3]!.textContent).toBe('b'); + }); + + it('ignores m:sPrePr properties element', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:sPre', + elements: [ + { name: 'm:sPrePr', elements: [{ name: 'm:ctrlPr' }] }, + { + name: 'm:sub', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }], + }, + { + name: 'm:sup', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'b' }] }] }], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'X' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const mmulti = result!.querySelector('mmultiscripts'); + expect(mmulti).not.toBeNull(); + expect(mmulti!.children.length).toBe(4); + expect(mmulti!.children[0]!.textContent).toBe('X'); + expect(mmulti!.children[1]!.localName).toBe('mprescripts'); + expect(mmulti!.children[2]!.textContent).toBe('a'); + expect(mmulti!.children[3]!.textContent).toBe('b'); + }); + + it('wraps multi-run sub and sup in for valid arity', () => { + // {}_{n+1}^{k-1}X — both pre-scripts have multiple runs + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:sPre', + elements: [ + { + name: 'm:sub', + elements: [ + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'n' }] }] }, + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '+' }] }] }, + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '1' }] }] }, + ], + }, + { + name: 'm:sup', + elements: [ + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'k' }] }] }, + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '-' }] }] }, + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '1' }] }] }, + ], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'X' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const mmulti = result!.querySelector('mmultiscripts'); + expect(mmulti).not.toBeNull(); + // must keep exactly 4 children — the mrow wrapping preserves arity + expect(mmulti!.children.length).toBe(4); + expect(mmulti!.children[0]!.textContent).toBe('X'); + expect(mmulti!.children[1]!.localName).toBe('mprescripts'); + expect(mmulti!.children[2]!.textContent).toBe('n+1'); + expect(mmulti!.children[3]!.textContent).toBe('k-1'); + }); + + it('handles missing m:sub and m:sup gracefully', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:sPre', + elements: [ + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'Y' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + const mmulti = result!.querySelector('mmultiscripts'); + expect(mmulti).not.toBeNull(); + // Empty sub/sup mrows preserved to keep valid arity of 4. + expect(mmulti!.children.length).toBe(4); + expect(mmulti!.children[0]!.textContent).toBe('Y'); + expect(mmulti!.children[1]!.localName).toBe('mprescripts'); + }); +}); + describe('m:func converter', () => { it('converts m:func to function name + apply operator + argument', () => { const omml = { @@ -927,61 +1069,3 @@ describe('m:func converter', () => { expect(mis[1]!.textContent).toBe('cos'); }); }); - -describe('m:sPre converter', () => { - it('converts pre-sub-superscript to with ', () => { - const omml = { - name: 'm:oMath', - elements: [ - { - name: 'm:sPre', - elements: [ - { - name: 'm:e', - elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'X' }] }] }], - }, - { - name: 'm:sub', - elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }], - }, - { - name: 'm:sup', - elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'b' }] }] }], - }, - ], - }, - ], - }; - const result = convertOmmlToMathml(omml, doc); - expect(result).not.toBeNull(); - const mmulti = result!.querySelector('mmultiscripts'); - expect(mmulti).not.toBeNull(); - expect(mmulti!.children.length).toBe(4); - expect(mmulti!.children[0]!.textContent).toBe('X'); - expect(mmulti!.children[1]!.localName).toBe('mprescripts'); - expect(mmulti!.children[2]!.textContent).toBe('a'); - expect(mmulti!.children[3]!.textContent).toBe('b'); - }); - - it('handles missing sub/sup gracefully', () => { - const omml = { - name: 'm:oMath', - elements: [ - { - name: 'm:sPre', - elements: [ - { - name: 'm:e', - elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'Y' }] }] }], - }, - ], - }, - ], - }; - const result = convertOmmlToMathml(omml, doc); - const mmulti = result!.querySelector('mmultiscripts'); - expect(mmulti).not.toBeNull(); - expect(mmulti!.children[0]!.textContent).toBe('Y'); - expect(mmulti!.children[1]!.localName).toBe('mprescripts'); - }); -}); diff --git a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.ts b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.ts index 24b8d19d16..fdd9c80986 100644 --- a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.ts +++ b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.ts @@ -45,6 +45,7 @@ const MATH_OBJECT_REGISTRY: Record = { 'm:sSub': convertSubscript, // Subscript 'm:sSup': convertSuperscript, // Superscript 'm:sSubSup': convertSubSuperscript, // Sub-superscript (both) + 'm:sPre': convertPreSubSuperscript, // Pre-sub-superscript (left of base) // ── Not yet implemented (community contributions welcome) ──────────────── 'm:acc': null, // Accent (diacritical mark above base) @@ -59,7 +60,6 @@ const MATH_OBJECT_REGISTRY: Record = { 'm:nary': null, // N-ary operator (integral, summation, product) 'm:phant': null, // Phantom (invisible spacing placeholder) 'm:rad': null, // Radical (square root, nth root) - 'm:sPre': convertPreSubSuperscript, // Pre-sub-superscript (left of base) }; /** OMML argument/container elements that wrap children in . */ diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/math/math-importer.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/math/math-importer.test.js index 0d9e573556..49ddd4db10 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/math/math-importer.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/math/math-importer.test.js @@ -53,6 +53,42 @@ describe('mathNodeHandler', () => { expect(original).not.toBe(oMathNode); expect(original.elements[0].name).toBe('m:sSup'); }); + + it('preserves m:sPre subtree verbatim in originalXml', () => { + // Spec-correct child order per ECMA-376 §22.1.2.99: (m:sPrePr?, m:sub, m:sup, m:e) + const oMathNode = { + name: 'm:oMath', + elements: [ + { + name: 'm:sPre', + elements: [ + { name: 'm:sPrePr', elements: [{ name: 'm:ctrlPr' }] }, + { + name: 'm:sub', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '1' }] }] }], + }, + { + name: 'm:sup', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '2' }] }] }], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'A' }] }] }], + }, + ], + }, + ], + }; + + const result = handler({ nodes: [oMathNode] }); + const original = result.nodes[0].attrs.originalXml; + + expect(original).not.toBe(oMathNode); + expect(original.elements[0].name).toBe('m:sPre'); + // Child order is preserved — the layout-engine converter relies on tag-based + // lookup, but the importer must not rearrange the tree. + expect(original.elements[0].elements.map((e) => e.name)).toEqual(['m:sPrePr', 'm:sub', 'm:sup', 'm:e']); + }); }); describe('m:oMathPara (display math)', () => { diff --git a/tests/behavior/tests/importing/fixtures/math-spre-tests.docx b/tests/behavior/tests/importing/fixtures/math-spre-tests.docx new file mode 100644 index 0000000000000000000000000000000000000000..e5759651bab8c44a69df96ede1a05e16ae7ee3ef GIT binary patch literal 14175 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 a*8z``ECkRze%qr12j~Hg-ow~`JNrL?W`ZaH literal 0 HcmV?d00001 diff --git a/tests/behavior/tests/importing/math-equations.spec.ts b/tests/behavior/tests/importing/math-equations.spec.ts index 8a009063cd..6a98288c98 100644 --- a/tests/behavior/tests/importing/math-equations.spec.ts +++ b/tests/behavior/tests/importing/math-equations.spec.ts @@ -5,6 +5,7 @@ import { test, expect } from '../../fixtures/superdoc.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ALL_OBJECTS_DOC = path.resolve(__dirname, 'fixtures/math-all-objects.docx'); const FUNC_DOC = path.resolve(__dirname, 'fixtures/math-func-tests.docx'); +const SPRE_DOC = path.resolve(__dirname, 'fixtures/math-spre-tests.docx'); // Single-object test docs are used for focused verification by community contributors. // The all-objects doc is used for behavior tests since it exercises the full pipeline. @@ -220,3 +221,130 @@ test.describe('m:func (function apply) rendering', () => { expect(fractionData!.denominatorText).toBe('x'); }); }); + +test.describe('m:sPre (pre-sub-superscript) rendering', () => { + // Fixture covers 9 m:sPre shapes: basic, isotope, multi-run, only-sub, only-sup, + // no sPrePr, fraction-in-sub, nested sPre, display-mode m:oMathPara. + test('imports all m:sPre equations from docx', async ({ superdoc }) => { + await superdoc.loadDocument(SPRE_DOC); + await superdoc.waitForStable(); + + const mathCount = await superdoc.page.evaluate(() => document.querySelectorAll('math').length); + expect(mathCount).toBe(9); + }); + + test('renders each m:sPre as with ', async ({ superdoc }) => { + await superdoc.loadDocument(SPRE_DOC); + await superdoc.waitForStable(); + + const structure = await superdoc.page.evaluate(() => { + const multis = Array.from(document.querySelectorAll('mmultiscripts')); + return { + count: multis.length, + allHaveFourChildren: multis.every((m) => m.children.length === 4), + allHavePrescripts: multis.every((m) => m.children[1]?.localName === 'mprescripts'), + allHaveBaseFirst: multis.every((m) => m.children[0]?.localName === 'mrow'), + }; + }); + + // 8 outer sPre + 1 inner nested + 1 inside m:oMathPara = 10 + expect(structure.count).toBe(10); + expect(structure.allHaveFourChildren).toBe(true); + expect(structure.allHavePrescripts).toBe(true); + expect(structure.allHaveBaseFirst).toBe(true); + }); + + test('preserves multi-run operands inside ', async ({ superdoc }) => { + await superdoc.loadDocument(SPRE_DOC); + await superdoc.waitForStable(); + + // Test 3 in the fixture: sub=n+1, sup=k-1, base=X + const multiRun = await superdoc.page.evaluate(() => { + const multis = Array.from(document.querySelectorAll('mmultiscripts')); + const target = multis.find((m) => m.children[0]?.textContent === 'X'); + if (!target) return null; + return { + subText: target.children[2]?.textContent, + supText: target.children[3]?.textContent, + subChildCount: target.children[2]?.children.length ?? 0, + }; + }); + + expect(multiRun).not.toBeNull(); + expect(multiRun!.subText).toBe('n+1'); + expect(multiRun!.supText).toBe('k-1'); + // sub mrow should contain 3 tokens (mi/mo/mn), preserving arity of outer mmultiscripts + expect(multiRun!.subChildCount).toBe(3); + }); + + test('missing m:sub/m:sup renders empty to preserve arity', async ({ superdoc }) => { + await superdoc.loadDocument(SPRE_DOC); + await superdoc.waitForStable(); + + // Test 4 (base=P, only sub=5) and Test 5 (base=Q, only sup=3) + const emptySlots = await superdoc.page.evaluate(() => { + const multis = Array.from(document.querySelectorAll('mmultiscripts')); + const onlySub = multis.find((m) => m.children[0]?.textContent === 'P'); + const onlySup = multis.find((m) => m.children[0]?.textContent === 'Q'); + return { + onlySubEmptySup: onlySub?.children[3]?.textContent === '', + onlySupEmptySub: onlySup?.children[2]?.textContent === '', + // Both still have exactly 4 children + arityPreserved: onlySub?.children.length === 4 && onlySup?.children.length === 4, + }; + }); + + expect(emptySlots.onlySubEmptySup).toBe(true); + expect(emptySlots.onlySupEmptySub).toBe(true); + expect(emptySlots.arityPreserved).toBe(true); + }); + + test('nested m:sPre renders nested inside outer base', async ({ superdoc }) => { + await superdoc.loadDocument(SPRE_DOC); + await superdoc.waitForStable(); + + // Test 8: outer sPre(a, b, ) + const nested = await superdoc.page.evaluate(() => { + const multis = Array.from(document.querySelectorAll('mmultiscripts')); + // The outer one has a nested mmultiscripts inside its first child (base mrow) + const outer = multis.find((m) => m.children[0]?.querySelector('mmultiscripts')); + if (!outer) return null; + const inner = outer.children[0]!.querySelector('mmultiscripts')!; + return { + outerSubText: outer.children[2]?.textContent, + outerSupText: outer.children[3]?.textContent, + innerBaseText: inner.children[0]?.textContent, + innerSubText: inner.children[2]?.textContent, + innerSupText: inner.children[3]?.textContent, + }; + }); + + expect(nested).not.toBeNull(); + expect(nested!.outerSubText).toBe('a'); + expect(nested!.outerSupText).toBe('b'); + expect(nested!.innerBaseText).toBe('Y'); + expect(nested!.innerSubText).toBe('c'); + expect(nested!.innerSupText).toBe('d'); + }); + + test('m:oMathPara wrapping m:sPre renders in display mode', async ({ superdoc }) => { + await superdoc.loadDocument(SPRE_DOC); + await superdoc.waitForStable(); + + // Test 9: ...base=Z + const displayMode = await superdoc.page.evaluate(() => { + const multis = Array.from(document.querySelectorAll('mmultiscripts')); + const target = multis.find((m) => m.children[0]?.textContent === 'Z'); + if (!target) return null; + const math = target.closest('math'); + return { + display: math?.getAttribute('display'), + displaystyle: math?.getAttribute('displaystyle'), + }; + }); + + expect(displayMode).not.toBeNull(); + expect(displayMode!.display).toBe('block'); + expect(displayMode!.displaystyle).toBe('true'); + }); +}); From 66692ae528a3a94617a03c1a7582cb5719a70c68 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sun, 12 Apr 2026 14:11:29 -0700 Subject: [PATCH 3/3] test(math): lock in m:sPre export passthrough round-trip --- .../v2/exporter/math-passthrough.test.js | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/math-passthrough.test.js diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/math-passthrough.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/math-passthrough.test.js new file mode 100644 index 0000000000..8f37474f63 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/math-passthrough.test.js @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest'; +import { translatePassthroughNode } from '../../exporter.js'; + +// Math nodes (mathInline / mathBlock) serialize back to OOXML via a generic +// passthrough that deep-copies node.attrs.originalXml. These tests lock in +// that behavior so m:sPre (and other math objects) round-trip on export. + +describe('math export passthrough', () => { + it('deep-copies m:sPre originalXml with child order preserved', () => { + // Spec-correct child order per ECMA-376 §22.1.2.99: (m:sPrePr, m:sub, m:sup, m:e) + const originalXml = { + name: 'm:oMath', + elements: [ + { + name: 'm:sPre', + elements: [ + { name: 'm:sPrePr', elements: [{ name: 'm:ctrlPr' }] }, + { + name: 'm:sub', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '1' }] }] }], + }, + { + name: 'm:sup', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '2' }] }] }], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'A' }] }] }], + }, + ], + }, + ], + }; + + const node = { attrs: { originalXml } }; + const result = translatePassthroughNode({ node }); + + expect(result).not.toBe(originalXml); + expect(result.name).toBe('m:oMath'); + expect(result.elements[0].name).toBe('m:sPre'); + expect(result.elements[0].elements.map((e) => e.name)).toEqual(['m:sPrePr', 'm:sub', 'm:sup', 'm:e']); + + // Verify deep copy: mutating the result must not affect the source + result.elements[0].elements[1].elements[0].elements[0].elements[0].text = 'MUTATED'; + expect(originalXml.elements[0].elements[1].elements[0].elements[0].elements[0].text).toBe('1'); + }); + + it('passes through m:oMathPara wrapping m:sPre for display-mode export', () => { + const originalXml = { + name: 'm:oMathPara', + elements: [ + { + name: 'm:oMath', + elements: [ + { + name: 'm:sPre', + elements: [ + { name: 'm:sPrePr', elements: [{ name: 'm:ctrlPr' }] }, + { + name: 'm:sub', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '1' }] }] }], + }, + { + name: 'm:sup', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '2' }] }] }], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'Z' }] }] }], + }, + ], + }, + ], + }, + ], + }; + + const result = translatePassthroughNode({ node: { attrs: { originalXml } } }); + + expect(result.name).toBe('m:oMathPara'); + expect(result.elements[0].name).toBe('m:oMath'); + expect(result.elements[0].elements[0].name).toBe('m:sPre'); + }); + + it('returns null when originalXml is missing', () => { + expect(translatePassthroughNode({ node: { attrs: {} } })).toBeNull(); + expect(translatePassthroughNode({ node: {} })).toBeNull(); + expect(translatePassthroughNode({})).toBeNull(); + }); +});