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 fa48eb6aae..afa0d857bf 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 @@ -10,3 +10,4 @@ export { convertMathRun } from './math-run.js'; export { convertFraction } from './fraction.js'; export { convertBar } from './bar.js'; export { convertSubscript } from './subscript.js'; +export { convertSuperscript } from './superscript.js'; diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/superscript.ts b/packages/layout-engine/painters/dom/src/features/math/converters/superscript.ts new file mode 100644 index 0000000000..49a5933a02 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/features/math/converters/superscript.ts @@ -0,0 +1,32 @@ +import type { MathObjectConverter } from '../types.js'; + +const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; + +/** + * Convert m:sSup (superscript) to MathML . + * + * OMML structure: + * m:sSup → m:sSupPr (optional), m:e (base), m:sup (superscript) + * + * MathML output: + * base sup + * + * @spec ECMA-376 §22.1.2.105 + */ +export const convertSuperscript: MathObjectConverter = (node, doc, convertChildren) => { + const elements = node.elements ?? []; + const base = elements.find((e) => e.name === 'm:e'); + const sup = elements.find((e) => e.name === 'm:sup'); + + const msup = doc.createElementNS(MATHML_NS, 'msup'); + + const baseRow = doc.createElementNS(MATHML_NS, 'mrow'); + baseRow.appendChild(convertChildren(base?.elements ?? [])); + msup.appendChild(baseRow); + + const supRow = doc.createElementNS(MATHML_NS, 'mrow'); + supRow.appendChild(convertChildren(sup?.elements ?? [])); + msup.appendChild(supRow); + + return msup; +}; 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 f6273b3948..606176d72f 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 @@ -436,3 +436,118 @@ describe('m:sSub converter', () => { expect(msub!.children[0]!.textContent).toBe('a'); }); }); + +describe('m:sSup converter', () => { + it('converts m:sSup to with base and superscript', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:sSup', + elements: [ + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }], + }, + { + 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 msup = result!.querySelector('msup'); + expect(msup).not.toBeNull(); + expect(msup!.children.length).toBe(2); + expect(msup!.children[0]!.textContent).toBe('x'); + expect(msup!.children[1]!.textContent).toBe('2'); + }); + + it('ignores m:sSupPr properties element', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:sSup', + elements: [ + { name: 'm:sSupPr', elements: [{ name: 'm:ctrlPr' }] }, + { + name: 'm:e', + 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 msup = result!.querySelector('msup'); + expect(msup).not.toBeNull(); + expect(msup!.children.length).toBe(2); + expect(msup!.children[0]!.textContent).toBe('a'); + expect(msup!.children[1]!.textContent).toBe('b'); + }); + + it('wraps multi-part base and superscript in for valid arity', () => { + // (x+1)^2 — base has 3 runs that must be grouped + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:sSup', + elements: [ + { + name: 'm:e', + elements: [ + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }, + { 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: '2' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const msup = result!.querySelector('msup'); + expect(msup).not.toBeNull(); + // must have exactly 2 children (base + superscript), each wrapped in + expect(msup!.children.length).toBe(2); + expect(msup!.children[0]!.textContent).toBe('x+1'); + expect(msup!.children[1]!.textContent).toBe('2'); + }); + + it('handles missing m:sup gracefully', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:sSup', + 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 msup = result!.querySelector('msup'); + expect(msup).not.toBeNull(); + expect(msup!.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 8332438f8e..b83bb84511 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 @@ -10,7 +10,13 @@ */ import type { OmmlJsonNode, MathObjectConverter } from './types.js'; -import { convertMathRun, convertFraction, convertBar, convertSubscript } from './converters/index.js'; +import { + convertMathRun, + convertFraction, + convertBar, + convertSubscript, + convertSuperscript, +} from './converters/index.js'; export const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; @@ -33,6 +39,7 @@ const MATH_OBJECT_REGISTRY: Record = { 'm:bar': convertBar, // Bar (overbar/underbar) 'm:f': convertFraction, // Fraction (numerator/denominator) 'm:sSub': convertSubscript, // Subscript + 'm:sSup': convertSuperscript, // Superscript // ── Not yet implemented (community contributions welcome) ──────────────── 'm:acc': null, // Accent (diacritical mark above base) @@ -50,7 +57,6 @@ const MATH_OBJECT_REGISTRY: Record = { 'm:rad': null, // Radical (square root, nth root) 'm:sPre': null, // Pre-sub-superscript (left of base) 'm:sSubSup': null, // Sub-superscript (both) - 'm:sSup': null, // Superscript }; /** OMML argument/container elements that wrap children in . */