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 2857cfa29b..f94a3d6132 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 @@ -15,3 +15,5 @@ export { convertSubscript } from './subscript.js'; export { convertSuperscript } from './superscript.js'; export { convertSubSuperscript } from './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/lower-limit.ts b/packages/layout-engine/painters/dom/src/features/math/converters/lower-limit.ts new file mode 100644 index 0000000000..bda0ffabd7 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/features/math/converters/lower-limit.ts @@ -0,0 +1,32 @@ +import type { MathObjectConverter } from '../types.js'; + +const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; + +/** + * Convert m:limLow (lower limit) to MathML . + * + * OMML structure: + * m:limLow → m:limLowPr (optional), m:e (base, e.g. "lim"), m:lim (limit expression) + * + * MathML output: + * base lim + * + * @spec ECMA-376 §22.1.2.54 + */ +export const convertLowerLimit: MathObjectConverter = (node, doc, convertChildren) => { + const elements = node.elements ?? []; + const base = elements.find((e) => e.name === 'm:e'); + const lim = elements.find((e) => e.name === 'm:lim'); + + const munder = doc.createElementNS(MATHML_NS, 'munder'); + + const baseRow = doc.createElementNS(MATHML_NS, 'mrow'); + baseRow.appendChild(convertChildren(base?.elements ?? [])); + munder.appendChild(baseRow); + + const limRow = doc.createElementNS(MATHML_NS, 'mrow'); + limRow.appendChild(convertChildren(lim?.elements ?? [])); + munder.appendChild(limRow); + + return munder; +}; diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/upper-limit.ts b/packages/layout-engine/painters/dom/src/features/math/converters/upper-limit.ts new file mode 100644 index 0000000000..7a6f1e487a --- /dev/null +++ b/packages/layout-engine/painters/dom/src/features/math/converters/upper-limit.ts @@ -0,0 +1,32 @@ +import type { MathObjectConverter } from '../types.js'; + +const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; + +/** + * Convert m:limUpp (upper limit) to MathML . + * + * OMML structure: + * m:limUpp → m:limUppPr (optional), m:e (base), m:lim (limit expression placed above) + * + * MathML output: + * base lim + * + * @spec ECMA-376 §22.1.2.56 + */ +export const convertUpperLimit: MathObjectConverter = (node, doc, convertChildren) => { + const elements = node.elements ?? []; + const base = elements.find((e) => e.name === 'm:e'); + const lim = elements.find((e) => e.name === 'm:lim'); + + const mover = doc.createElementNS(MATHML_NS, 'mover'); + + const baseRow = doc.createElementNS(MATHML_NS, 'mrow'); + baseRow.appendChild(convertChildren(base?.elements ?? [])); + mover.appendChild(baseRow); + + const limRow = doc.createElementNS(MATHML_NS, 'mrow'); + limRow.appendChild(convertChildren(lim?.elements ?? [])); + mover.appendChild(limRow); + + return mover; +}; 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 ba65368aa2..4dc7d99ef2 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 @@ -1642,3 +1642,290 @@ describe('m:rad converter', () => { expect(msqrt!.textContent).toBe(''); }); }); + +describe('m:limLow converter', () => { + it('converts m:limLow to with base and lower limit', () => { + // lim_{n→∞} + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:limLow', + elements: [ + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'lim' }] }] }], + }, + { + name: 'm:lim', + elements: [ + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'n' }] }] }, + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '\u2192' }] }] }, + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '\u221E' }] }] }, + ], + }, + ], + }, + ], + }; + + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const munder = result!.querySelector('munder'); + expect(munder).not.toBeNull(); + expect(munder!.children.length).toBe(2); + expect(munder!.children[0]!.textContent).toBe('lim'); + expect(munder!.children[1]!.textContent).toBe('n\u2192\u221E'); + }); + + it('ignores m:limLowPr properties element', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:limLow', + elements: [ + { name: 'm:limLowPr', elements: [{ name: 'm:ctrlPr' }] }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'inf' }] }] }], + }, + { + name: 'm:lim', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }], + }, + ], + }, + ], + }; + + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const munder = result!.querySelector('munder'); + expect(munder).not.toBeNull(); + expect(munder!.children.length).toBe(2); + expect(munder!.children[0]!.textContent).toBe('inf'); + expect(munder!.children[1]!.textContent).toBe('x'); + }); + + it('wraps multi-part base and limit in for valid arity', () => { + // lim_{n→∞} — limit has 3 runs that must be grouped + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:limLow', + elements: [ + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'lim' }] }] }], + }, + { + name: 'm:lim', + elements: [ + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'n' }] }] }, + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '\u2192' }] }] }, + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '\u221E' }] }] }, + ], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const munder = result!.querySelector('munder'); + expect(munder).not.toBeNull(); + // must have exactly 2 children (base + limit), each wrapped in + expect(munder!.children.length).toBe(2); + expect(munder!.children[0]!.textContent).toBe('lim'); + expect(munder!.children[1]!.textContent).toBe('n\u2192\u221E'); + }); + + it('handles missing m:lim gracefully', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:limLow', + elements: [ + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'lim' }] }] }], + }, + ], + }, + ], + }; + + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const munder = result!.querySelector('munder'); + expect(munder).not.toBeNull(); + expect(munder!.children[0]!.textContent).toBe('lim'); + }); + + it('handles missing m:e gracefully', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:limLow', + elements: [ + { + name: 'm:lim', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'k' }] }] }], + }, + ], + }, + ], + }; + + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const munder = result!.querySelector('munder'); + expect(munder).not.toBeNull(); + expect(munder!.children[1]!.textContent).toBe('k'); + }); +}); + +describe('m:limUpp converter', () => { + it('converts m:limUpp to with base and upper limit', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:limUpp', + elements: [ + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'max' }] }] }], + }, + { + name: 'm:lim', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }], + }, + ], + }, + ], + }; + + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const mover = result!.querySelector('mover'); + expect(mover).not.toBeNull(); + expect(mover!.children.length).toBe(2); + expect(mover!.children[0]!.textContent).toBe('max'); + expect(mover!.children[1]!.textContent).toBe('x'); + }); + + it('ignores m:limUppPr properties element', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:limUpp', + elements: [ + { name: 'm:limUppPr', elements: [{ name: 'm:ctrlPr' }] }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '=' }] }] }], + }, + { + name: 'm:lim', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'def' }] }] }], + }, + ], + }, + ], + }; + + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const mover = result!.querySelector('mover'); + expect(mover).not.toBeNull(); + expect(mover!.children.length).toBe(2); + expect(mover!.children[0]!.textContent).toBe('='); + expect(mover!.children[1]!.textContent).toBe('def'); + }); + + it('wraps multi-part base and limit in for valid arity', () => { + // A^{i+1} — limit has 3 runs that must be grouped + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:limUpp', + elements: [ + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'A' }] }] }], + }, + { + name: 'm:lim', + elements: [ + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'i' }] }] }, + { 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 mover = result!.querySelector('mover'); + expect(mover).not.toBeNull(); + // must have exactly 2 children (base + limit), each wrapped in + expect(mover!.children.length).toBe(2); + expect(mover!.children[0]!.textContent).toBe('A'); + expect(mover!.children[1]!.textContent).toBe('i+1'); + }); + + it('handles missing m:lim gracefully', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:limUpp', + elements: [ + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'sup' }] }] }], + }, + ], + }, + ], + }; + + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const mover = result!.querySelector('mover'); + expect(mover).not.toBeNull(); + expect(mover!.children[0]!.textContent).toBe('sup'); + }); + + it('handles missing m:e gracefully', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:limUpp', + elements: [ + { + name: 'm:lim', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'n' }] }] }], + }, + ], + }, + ], + }; + + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const mover = result!.querySelector('mover'); + expect(mover).not.toBeNull(); + expect(mover!.children[1]!.textContent).toBe('n'); + }); +}); 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 8c3d9509a8..149869d0b7 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 @@ -20,6 +20,8 @@ import { convertSuperscript, convertSubSuperscript, convertRadical, + convertLowerLimit, + convertUpperLimit, } from './converters/index.js'; export const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; @@ -44,6 +46,9 @@ const MATH_OBJECT_REGISTRY: Record = { 'm:d': convertDelimiter, // Delimiter (parentheses, brackets, braces) 'm:f': convertFraction, // Fraction (numerator/denominator) 'm:func': convertFunction, // Function apply (sin, cos, log, etc.) + 'm:limLow': convertLowerLimit, // Lower limit (e.g., lim) + 'm:limUpp': convertUpperLimit, // Upper limit + 'm:rad': convertRadical, // Radical (square root, nth root) 'm:sSub': convertSubscript, // Subscript 'm:sSup': convertSuperscript, // Superscript 'm:sSubSup': convertSubSuperscript, // Sub-superscript (both) @@ -54,12 +59,9 @@ const MATH_OBJECT_REGISTRY: Record = { 'm:box': null, // Box (invisible grouping container) 'm:eqArr': null, // Equation array (vertical array of equations) 'm:groupChr': null, // Group character (overbrace, underbrace) - 'm:limLow': null, // Lower limit (e.g., lim) - 'm:limUpp': null, // Upper limit 'm:m': null, // Matrix (grid of elements) 'm:nary': null, // N-ary operator (integral, summation, product) 'm:phant': null, // Phantom (invisible spacing placeholder) - 'm:rad': convertRadical, // Radical (square root, nth root) 'm:sPre': null, // Pre-sub-superscript (left of base) };