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 f94a3d6132..24aa1f87fd 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 @@ -14,6 +14,7 @@ export { convertDelimiter } from './delimiter.js'; export { convertSubscript } from './subscript.js'; export { convertSuperscript } from './superscript.js'; export { convertSubSuperscript } from './sub-superscript.js'; +export { convertPreSubSuperscript } from './pre-sub-superscript.js'; export { convertRadical } from './radical.js'; export { convertLowerLimit } from './lower-limit.js'; export { convertUpperLimit } from './upper-limit.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..ed8707bd5d --- /dev/null +++ b/packages/layout-engine/painters/dom/src/features/math/converters/pre-sub-superscript.ts @@ -0,0 +1,51 @@ +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: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: + * + * 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 8682a48cf1..2e7da8c13e 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 @@ -1514,6 +1514,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 = { 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 149869d0b7..bbb47c5979 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 @@ -19,6 +19,7 @@ import { convertSubscript, convertSuperscript, convertSubSuperscript, + convertPreSubSuperscript, convertRadical, convertLowerLimit, convertUpperLimit, @@ -52,6 +53,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) @@ -62,7 +64,6 @@ const MATH_OBJECT_REGISTRY: Record = { 'm:m': null, // Matrix (grid of elements) 'm:nary': null, // N-ary operator (integral, summation, product) 'm:phant': null, // Phantom (invisible spacing placeholder) - 'm:sPre': null, // 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/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(); + }); +}); 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 0000000000..e5759651ba Binary files /dev/null and b/tests/behavior/tests/importing/fixtures/math-spre-tests.docx differ diff --git a/tests/behavior/tests/importing/math-equations.spec.ts b/tests/behavior/tests/importing/math-equations.spec.ts index e6ce46d6a8..f6c58495a9 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'); const DELIMITER_DOC = path.resolve(__dirname, 'fixtures/math-delimiter-tests.docx'); const RADICAL_DOC = path.resolve(__dirname, 'fixtures/math-radical-tests.docx'); const LIMIT_DOC = path.resolve(__dirname, 'fixtures/math-limit-tests.docx'); @@ -241,6 +242,133 @@ test.describe('m:func (function apply) rendering', () => { }); }); +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'); + }); +}); + test.describe('m:d (delimiter) rendering', () => { test('renders all 21 delimiter test cases as elements', async ({ superdoc }) => { await superdoc.loadDocument(DELIMITER_DOC);