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 afa0d857bf..9927e484fb 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 @@ -11,3 +11,4 @@ export { convertFraction } from './fraction.js'; export { convertBar } from './bar.js'; export { convertSubscript } from './subscript.js'; export { convertSuperscript } from './superscript.js'; +export { convertSubSuperscript } from './sub-superscript.js'; diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/sub-superscript.ts b/packages/layout-engine/painters/dom/src/features/math/converters/sub-superscript.ts new file mode 100644 index 0000000000..7952da47f8 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/features/math/converters/sub-superscript.ts @@ -0,0 +1,37 @@ +import type { MathObjectConverter } from '../types.js'; + +const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; + +/** + * Convert m:sSubSup (sub-superscript) to MathML . + * + * OMML structure: + * m:sSubSup → m:sSubSupPr (optional), m:e (base), m:sub (subscript), m:sup (superscript) + * + * MathML output: + * base sub sup + * + * @spec ECMA-376 §22.1.2.103 + */ +export const convertSubSuperscript: 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 msubsup = doc.createElementNS(MATHML_NS, 'msubsup'); + + const baseRow = doc.createElementNS(MATHML_NS, 'mrow'); + baseRow.appendChild(convertChildren(base?.elements ?? [])); + msubsup.appendChild(baseRow); + + const subRow = doc.createElementNS(MATHML_NS, 'mrow'); + subRow.appendChild(convertChildren(sub?.elements ?? [])); + msubsup.appendChild(subRow); + + const supRow = doc.createElementNS(MATHML_NS, 'mrow'); + supRow.appendChild(convertChildren(sup?.elements ?? [])); + msubsup.appendChild(supRow); + + return msubsup; +}; 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 606176d72f..49a4cdde3e 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 @@ -551,3 +551,136 @@ describe('m:sSup converter', () => { expect(msup!.children[0]!.textContent).toBe('x'); }); }); + +describe('m:sSubSup converter', () => { + it('converts m:sSubSup to with base, subscript, and superscript', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:sSubSup', + 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: 'i' }] }] }], + }, + { + name: 'm:sup', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '2' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const msubsup = result!.querySelector('msubsup'); + expect(msubsup).not.toBeNull(); + expect(msubsup!.children.length).toBe(3); + expect(msubsup!.children[0]!.textContent).toBe('x'); + expect(msubsup!.children[1]!.textContent).toBe('i'); + expect(msubsup!.children[2]!.textContent).toBe('2'); + }); + + it('ignores m:sSubSupPr properties element', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:sSubSup', + elements: [ + { name: 'm:sSubSupPr', elements: [{ name: 'm:alnScr' }] }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }], + }, + { + name: 'm:sub', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'n' }] }] }], + }, + { + name: 'm:sup', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'k' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const msubsup = result!.querySelector('msubsup'); + expect(msubsup).not.toBeNull(); + expect(msubsup!.children.length).toBe(3); + expect(msubsup!.children[0]!.textContent).toBe('a'); + expect(msubsup!.children[1]!.textContent).toBe('n'); + expect(msubsup!.children[2]!.textContent).toBe('k'); + }); + + it('wraps multi-part operands in for valid arity', () => { + // x_{n+1}^{k-1} — both sub and sup have multiple runs + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:sSubSup', + 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: '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' }] }] }, + ], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const msubsup = result!.querySelector('msubsup'); + expect(msubsup).not.toBeNull(); + expect(msubsup!.children.length).toBe(3); + expect(msubsup!.children[0]!.textContent).toBe('x'); + expect(msubsup!.children[1]!.textContent).toBe('n+1'); + expect(msubsup!.children[2]!.textContent).toBe('k-1'); + }); + + it('handles missing m:sub and m:sup gracefully', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:sSubSup', + elements: [ + { + 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 msubsup = result!.querySelector('msubsup'); + expect(msubsup).not.toBeNull(); + expect(msubsup!.children[0]!.textContent).toBe('x'); + }); +}); 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 b83bb84511..6c224afdb7 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 @@ -16,6 +16,7 @@ import { convertBar, convertSubscript, convertSuperscript, + convertSubSuperscript, } from './converters/index.js'; export const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; @@ -40,6 +41,7 @@ const MATH_OBJECT_REGISTRY: Record = { 'm:f': convertFraction, // Fraction (numerator/denominator) 'm:sSub': convertSubscript, // Subscript 'm:sSup': convertSuperscript, // Superscript + 'm:sSubSup': convertSubSuperscript, // Sub-superscript (both) // ── Not yet implemented (community contributions welcome) ──────────────── 'm:acc': null, // Accent (diacritical mark above base) @@ -56,7 +58,6 @@ const MATH_OBJECT_REGISTRY: Record = { '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:sSubSup': null, // Sub-superscript (both) }; /** OMML argument/container elements that wrap children in . */ diff --git a/tests/behavior/tests/importing/math-equations.spec.ts b/tests/behavior/tests/importing/math-equations.spec.ts index c3a38cc440..b8e0429335 100644 --- a/tests/behavior/tests/importing/math-equations.spec.ts +++ b/tests/behavior/tests/importing/math-equations.spec.ts @@ -83,11 +83,34 @@ test.describe('math equation import and rendering', () => { } }); + test('renders sub-superscript as with base, subscript, and superscript', async ({ superdoc }) => { + await superdoc.loadDocument(ALL_OBJECTS_DOC); + await superdoc.waitForStable(); + + // The test doc has x_i^2 — should render as with 3 children + const subSupData = await superdoc.page.evaluate(() => { + const msubsup = document.querySelector('msubsup'); + if (!msubsup) return null; + return { + childCount: msubsup.children.length, + base: msubsup.children[0]?.textContent, + subscript: msubsup.children[1]?.textContent, + superscript: msubsup.children[2]?.textContent, + }; + }); + + expect(subSupData).not.toBeNull(); + expect(subSupData!.childCount).toBe(3); + expect(subSupData!.base).toBe('x'); + expect(subSupData!.subscript).toBe('i'); + expect(subSupData!.superscript).toBe('2'); + }); + test('math text content is preserved for unimplemented objects', async ({ superdoc }) => { await superdoc.loadDocument(ALL_OBJECTS_DOC); await superdoc.waitForStable(); - // Unimplemented math objects (e.g., superscript, radical) should still + // Unimplemented math objects (e.g., radical, delimiter) should still // have their text content accessible in the PM document const mathTexts = await superdoc.page.evaluate(() => { const view = (window as any).editor?.view;