diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/fraction.ts b/packages/layout-engine/painters/dom/src/features/math/converters/fraction.ts index 82276f2a71..836406694a 100644 --- a/packages/layout-engine/painters/dom/src/features/math/converters/fraction.ts +++ b/packages/layout-engine/painters/dom/src/features/math/converters/fraction.ts @@ -19,8 +19,14 @@ export const convertFraction: MathObjectConverter = (node, doc, convertChildren) const den = elements.find((e) => e.name === 'm:den'); const frac = doc.createElementNS(MATHML_NS, 'mfrac'); - frac.appendChild(convertChildren(num?.elements ?? [])); - frac.appendChild(convertChildren(den?.elements ?? [])); + + const numRow = doc.createElementNS(MATHML_NS, 'mrow'); + numRow.appendChild(convertChildren(num?.elements ?? [])); + frac.appendChild(numRow); + + const denRow = doc.createElementNS(MATHML_NS, 'mrow'); + denRow.appendChild(convertChildren(den?.elements ?? [])); + frac.appendChild(denRow); return frac; }; 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 b1ec9c3e1c..fa48eb6aae 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 @@ -9,3 +9,4 @@ export { convertMathRun } from './math-run.js'; export { convertFraction } from './fraction.js'; export { convertBar } from './bar.js'; +export { convertSubscript } from './subscript.js'; diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/subscript.ts b/packages/layout-engine/painters/dom/src/features/math/converters/subscript.ts new file mode 100644 index 0000000000..6eabcbc1b4 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/features/math/converters/subscript.ts @@ -0,0 +1,32 @@ +import type { MathObjectConverter } from '../types.js'; + +const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; + +/** + * Convert m:sSub (subscript) to MathML . + * + * OMML structure: + * m:sSub → m:sSubPr (optional), m:e (base), m:sub (subscript) + * + * MathML output: + * base sub + * + * @spec ECMA-376 §22.1.2.101 + */ +export const convertSubscript: 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 msub = doc.createElementNS(MATHML_NS, 'msub'); + + const baseRow = doc.createElementNS(MATHML_NS, 'mrow'); + baseRow.appendChild(convertChildren(base?.elements ?? [])); + msub.appendChild(baseRow); + + const subRow = doc.createElementNS(MATHML_NS, 'mrow'); + subRow.appendChild(convertChildren(sub?.elements ?? [])); + msub.appendChild(subRow); + + return msub; +}; 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 567dcfc85b..f6273b3948 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 @@ -119,8 +119,7 @@ describe('convertOmmlToMathml', () => { expect(result!.textContent).toBe('z'); }); - it('handles unimplemented math objects by extracting child content', () => { - // m:f (fraction) is not yet implemented — should fall back to rendering children + it('converts m:f (fraction) to with numerator and denominator', () => { const omml = { name: 'm:oMath', elements: [ @@ -151,6 +150,44 @@ describe('convertOmmlToMathml', () => { expect(mfrac!.children[1]!.textContent).toBe('b'); }); + it('wraps multi-part fraction operands in for valid arity', () => { + // (a+b)/(c+d) — both numerator and denominator have multiple runs + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:f', + elements: [ + { + name: 'm:num', + elements: [ + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }, + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '+' }] }] }, + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'b' }] }] }, + ], + }, + { + name: 'm:den', + elements: [ + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'c' }] }] }, + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '+' }] }] }, + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'd' }] }] }, + ], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const mfrac = result!.querySelector('mfrac'); + expect(mfrac).not.toBeNull(); + // must have exactly 2 children (num + den), each wrapped in + expect(mfrac!.children.length).toBe(2); + expect(mfrac!.children[0]!.textContent).toBe('a+b'); + expect(mfrac!.children[1]!.textContent).toBe('c+d'); + }); + it('sets mathvariant=normal for m:nor (normal text) flag', () => { const omml = { name: 'm:oMath', @@ -284,3 +321,118 @@ describe('m:bar converter', () => { expect(mo?.textContent).toBe('\u203E'); }); }); + +describe('m:sSub converter', () => { + it('converts m:sSub to with base and subscript', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:sSub', + elements: [ + { + 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: '1' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const msub = result!.querySelector('msub'); + expect(msub).not.toBeNull(); + expect(msub!.children.length).toBe(2); + expect(msub!.children[0]!.textContent).toBe('a'); + expect(msub!.children[1]!.textContent).toBe('1'); + }); + + it('ignores m:sSubPr properties element', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:sSub', + elements: [ + { name: 'm:sSubPr', elements: [{ name: 'm:ctrlPr' }] }, + { + 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' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const msub = result!.querySelector('msub'); + expect(msub).not.toBeNull(); + expect(msub!.children.length).toBe(2); + expect(msub!.children[0]!.textContent).toBe('x'); + expect(msub!.children[1]!.textContent).toBe('n'); + }); + + it('wraps multi-part base and subscript in for valid arity', () => { + // x_{n+1} — subscript has 3 runs that must be grouped + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:sSub', + 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' }] }] }, + ], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const msub = result!.querySelector('msub'); + expect(msub).not.toBeNull(); + // must have exactly 2 children (base + subscript), each wrapped in + expect(msub!.children.length).toBe(2); + expect(msub!.children[0]!.textContent).toBe('x'); + expect(msub!.children[1]!.textContent).toBe('n+1'); + }); + + it('handles missing m:sub gracefully', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:sSub', + elements: [ + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const msub = result!.querySelector('msub'); + expect(msub).not.toBeNull(); + expect(msub!.children[0]!.textContent).toBe('a'); + }); +}); 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 3dd25c3014..8332438f8e 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,7 @@ */ import type { OmmlJsonNode, MathObjectConverter } from './types.js'; -import { convertMathRun, convertFraction, convertBar } from './converters/index.js'; +import { convertMathRun, convertFraction, convertBar, convertSubscript } from './converters/index.js'; export const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; @@ -32,6 +32,7 @@ const MATH_OBJECT_REGISTRY: Record = { 'm:r': convertMathRun, 'm:bar': convertBar, // Bar (overbar/underbar) 'm:f': convertFraction, // Fraction (numerator/denominator) + 'm:sSub': convertSubscript, // Subscript // ── Not yet implemented (community contributions welcome) ──────────────── 'm:acc': null, // Accent (diacritical mark above base) @@ -48,7 +49,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:sSub': null, // Subscript 'm:sSubSup': null, // Sub-superscript (both) 'm:sSup': null, // Superscript };