Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { MathObjectConverter, OmmlJsonNode } from '../types.js';

const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';

const DEFAULT_BEGIN_DELIMITER = '(';
const DEFAULT_END_DELIMITER = ')';
const DEFAULT_SEPARATOR_DELIMITER = '|';
Comment thread
andrewsrigom marked this conversation as resolved.
Outdated

function getDelimiterValue(properties: OmmlJsonNode | undefined, name: string, fallback: string): string {
const property = properties?.elements?.find((element) => element.name === name);
return property?.attributes?.['m:val'] ?? fallback;
Comment thread
andrewsrigom marked this conversation as resolved.
Outdated
}

function createExpressionGroup(
expression: OmmlJsonNode | undefined,
doc: Document,
convertChildren: (children: OmmlJsonNode[]) => DocumentFragment,
): Element | null {
const fragment = convertChildren(expression?.elements ?? []);
if (fragment.childNodes.length === 0) return null;

const group = doc.createElementNS(MATHML_NS, 'mrow');
group.appendChild(fragment);
return group;
}

/**
* Convert m:d (delimiter) to MathML.
*
* OMML structure:
* m:d → m:dPr (optional: begChr, endChr, sepChr) + one or more m:e expressions
*
* MathML output:
* <mrow><mo>(</mo> ...content... <mo>)</mo></mrow>
*
* @spec ECMA-376 §22.1.2.24
*/
export const convertDelimiter: MathObjectConverter = (node, doc, convertChildren) => {
const elements = node.elements ?? [];
const delimiterProps = elements.find((element) => element.name === 'm:dPr');
const expressions = elements.filter((element) => element.name === 'm:e');

const beginDelimiter = getDelimiterValue(delimiterProps, 'm:begChr', DEFAULT_BEGIN_DELIMITER);
const endDelimiter = getDelimiterValue(delimiterProps, 'm:endChr', DEFAULT_END_DELIMITER);
const separatorDelimiter = getDelimiterValue(delimiterProps, 'm:sepChr', DEFAULT_SEPARATOR_DELIMITER);

const wrapper = doc.createElementNS(MATHML_NS, 'mrow');

const begin = doc.createElementNS(MATHML_NS, 'mo');
begin.textContent = beginDelimiter;
wrapper.appendChild(begin);

const expressionGroups = expressions
.map((expression) => createExpressionGroup(expression, doc, convertChildren))
.filter((expressionGroup): expressionGroup is Element => expressionGroup !== null);

expressionGroups.forEach((expressionGroup, index) => {
if (index > 0) {
const separator = doc.createElementNS(MATHML_NS, 'mo');
separator.textContent = separatorDelimiter;
wrapper.appendChild(separator);
}

wrapper.appendChild(expressionGroup);
});

const end = doc.createElementNS(MATHML_NS, 'mo');
end.textContent = endDelimiter;
wrapper.appendChild(end);

return wrapper;
};
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
export { convertMathRun } from './math-run.js';
export { convertFraction } from './fraction.js';
export { convertBar } from './bar.js';
export { convertDelimiter } from './delimiter.js';
export { convertSubscript } from './subscript.js';
export { convertSuperscript } from './superscript.js';
export { convertSubSuperscript } from './sub-superscript.js';
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,173 @@ describe('m:bar converter', () => {
});
});

describe('m:d converter', () => {
it('converts m:d to delimiters around the expression', () => {
const omml = {
name: 'm:oMath',
elements: [
{
name: 'm:d',
elements: [
{
name: 'm:dPr',
elements: [
{ name: 'm:begChr', attributes: { 'm:val': '(' } },
{ name: 'm:endChr', attributes: { 'm:val': ')' } },
],
},
{
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: 'y' }] }] },
],
},
],
},
],
};

const result = convertOmmlToMathml(omml, doc);
expect(result).not.toBeNull();
expect(result!.textContent).toBe('(x+y)');

const outerRow = result!.querySelector('mrow');
expect(outerRow).not.toBeNull();
expect(outerRow!.children[0]!.textContent).toBe('(');
expect(outerRow!.children[1]!.textContent).toBe('x+y');
expect(outerRow!.children[2]!.textContent).toBe(')');
});

it('defaults to parentheses and pipe separators when dPr is missing', () => {
const omml = {
name: 'm:oMath',
elements: [
{
name: 'm:d',
elements: [
{
name: 'm:e',
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
},
{
name: 'm:e',
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'y' }] }] }],
},
],
},
],
};

const result = convertOmmlToMathml(omml, doc);
expect(result).not.toBeNull();
expect(result!.textContent).toBe('(x|y)');
});

it('uses custom delimiter and separator characters for multiple expressions', () => {
const omml = {
name: 'm:oMath',
elements: [
{
name: 'm:d',
elements: [
{
name: 'm:dPr',
elements: [
{ name: 'm:begChr', attributes: { 'm:val': '[' } },
{ name: 'm:endChr', attributes: { 'm:val': ']' } },
{ name: 'm:sepChr', attributes: { 'm:val': ';' } },
],
},
{
name: 'm:e',
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }],
},
{
name: 'm:e',
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'b' }] }] }],
},
],
},
],
};

const result = convertOmmlToMathml(omml, doc);
expect(result).not.toBeNull();
expect(result!.textContent).toBe('[a;b]');

const outerRow = result!.querySelector('mrow');
expect(outerRow).not.toBeNull();
expect(outerRow!.children.length).toBe(5);
expect(outerRow!.children[0]!.textContent).toBe('[');
expect(outerRow!.children[2]!.textContent).toBe(';');
expect(outerRow!.children[4]!.textContent).toBe(']');
});

it('does not render stray separators for empty expressions', () => {
const omml = {
name: 'm:oMath',
elements: [
{
name: 'm:d',
elements: [
{ name: 'm:e', 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();
expect(result!.textContent).toBe('(x)');
});

it('preserves explicit empty delimiter characters', () => {
const omml = {
name: 'm:oMath',
elements: [
{
name: 'm:d',
elements: [
{
name: 'm:dPr',
elements: [
{ name: 'm:begChr', attributes: { 'm:val': '' } },
{ name: 'm:endChr', attributes: { 'm:val': '' } },
{ name: 'm:sepChr', attributes: { 'm:val': '' } },
],
},
{
name: 'm:e',
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
},
{
name: 'm:e',
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'y' }] }] }],
},
],
},
],
};

const result = convertOmmlToMathml(omml, doc);
expect(result).not.toBeNull();
expect(result!.textContent).toBe('xy');

const outerRow = result!.querySelector('mrow');
expect(outerRow).not.toBeNull();
expect(outerRow!.children.length).toBe(5);
expect(outerRow!.children[0]!.textContent).toBe('');
expect(outerRow!.children[2]!.textContent).toBe('');
expect(outerRow!.children[4]!.textContent).toBe('');
});
});

describe('m:sSub converter', () => {
it('converts m:sSub to <msub> with base and subscript', () => {
const omml = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
convertMathRun,
convertFraction,
convertBar,
convertDelimiter,
convertSubscript,
convertSuperscript,
convertSubSuperscript,
Expand All @@ -38,6 +39,7 @@ const MATH_OBJECT_REGISTRY: Record<string, MathObjectConverter | null> = {
// ── Implemented ──────────────────────────────────────────────────────────
'm:r': convertMathRun,
'm:bar': convertBar, // Bar (overbar/underbar)
'm:d': convertDelimiter, // Delimiter (parentheses, brackets, braces)
'm:f': convertFraction, // Fraction (numerator/denominator)
'm:sSub': convertSubscript, // Subscript
'm:sSup': convertSuperscript, // Superscript
Expand All @@ -47,7 +49,6 @@ const MATH_OBJECT_REGISTRY: Record<string, MathObjectConverter | null> = {
'm:acc': null, // Accent (diacritical mark above base)
'm:borderBox': null, // Border box (border around math content)
'm:box': null, // Box (invisible grouping container)
'm:d': null, // Delimiter (parentheses, brackets, braces)
'm:eqArr': null, // Equation array (vertical array of equations)
'm:func': null, // Function apply (sin, cos, log, etc.)
'm:groupChr': null, // Group character (overbrace, underbrace)
Expand Down