Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
@@ -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 <munder>.
*
* OMML structure:
* m:limLow → m:limLowPr (optional), m:e (base, e.g. "lim"), m:lim (limit expression)
*
* MathML output:
* <munder> <mrow>base</mrow> <mrow>lim</mrow> </munder>
*
* @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;
};
Original file line number Diff line number Diff line change
@@ -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 <mover>.
*
* OMML structure:
* m:limUpp → m:limUppPr (optional), m:e (base), m:lim (limit expression placed above)
*
* MathML output:
* <mover> <mrow>base</mrow> <mrow>lim</mrow> </mover>
*
* @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;
};
Original file line number Diff line number Diff line change
Expand Up @@ -1642,3 +1642,290 @@ describe('m:rad converter', () => {
expect(msqrt!.textContent).toBe('');
});
});

describe('m:limLow converter', () => {
it('converts m:limLow to <munder> 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 <mrow> 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();
// <munder> must have exactly 2 children (base + limit), each wrapped in <mrow>
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 <mover> 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 <mrow> 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();
// <mover> must have exactly 2 children (base + limit), each wrapped in <mrow>
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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -44,6 +46,9 @@ const MATH_OBJECT_REGISTRY: Record<string, MathObjectConverter | null> = {
'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)
Expand All @@ -54,12 +59,9 @@ const MATH_OBJECT_REGISTRY: Record<string, MathObjectConverter | null> = {
'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)
};

Expand Down
Loading