Skip to content

Commit f2079a7

Browse files
committed
feat(math): implement m:d delimiter converter (SD-2380)
1 parent 8dfbf95 commit f2079a7

4 files changed

Lines changed: 202 additions & 1 deletion

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type { MathObjectConverter, OmmlJsonNode } from '../types.js';
2+
3+
const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
4+
5+
const DEFAULT_BEGIN_DELIMITER = '(';
6+
const DEFAULT_END_DELIMITER = ')';
7+
const DEFAULT_SEPARATOR_DELIMITER = '|';
8+
9+
function getDelimiterValue(properties: OmmlJsonNode | undefined, name: string, fallback: string): string {
10+
const property = properties?.elements?.find((element) => element.name === name);
11+
return property?.attributes?.['m:val'] || fallback;
12+
}
13+
14+
function createExpressionGroup(
15+
expression: OmmlJsonNode | undefined,
16+
doc: Document,
17+
convertChildren: (children: OmmlJsonNode[]) => DocumentFragment,
18+
): Element | null {
19+
const fragment = convertChildren(expression?.elements ?? []);
20+
if (fragment.childNodes.length === 0) return null;
21+
22+
const group = doc.createElementNS(MATHML_NS, 'mrow');
23+
group.appendChild(fragment);
24+
return group;
25+
}
26+
27+
/**
28+
* Convert m:d (delimiter) to MathML.
29+
*
30+
* OMML structure:
31+
* m:d β†’ m:dPr (optional: begChr, endChr, sepChr) + one or more m:e expressions
32+
*
33+
* MathML output:
34+
* <mrow><mo>(</mo> ...content... <mo>)</mo></mrow>
35+
*
36+
* @spec ECMA-376 Β§22.1.2.24
37+
*/
38+
export const convertDelimiter: MathObjectConverter = (node, doc, convertChildren) => {
39+
const elements = node.elements ?? [];
40+
const delimiterProps = elements.find((element) => element.name === 'm:dPr');
41+
const expressions = elements.filter((element) => element.name === 'm:e');
42+
43+
const beginDelimiter = getDelimiterValue(delimiterProps, 'm:begChr', DEFAULT_BEGIN_DELIMITER);
44+
const endDelimiter = getDelimiterValue(delimiterProps, 'm:endChr', DEFAULT_END_DELIMITER);
45+
const separatorDelimiter = getDelimiterValue(delimiterProps, 'm:sepChr', DEFAULT_SEPARATOR_DELIMITER);
46+
47+
const wrapper = doc.createElementNS(MATHML_NS, 'mrow');
48+
49+
const begin = doc.createElementNS(MATHML_NS, 'mo');
50+
begin.textContent = beginDelimiter;
51+
wrapper.appendChild(begin);
52+
53+
const expressionGroups = expressions
54+
.map((expression) => createExpressionGroup(expression, doc, convertChildren))
55+
.filter((expressionGroup): expressionGroup is Element => expressionGroup !== null);
56+
57+
expressionGroups.forEach((expressionGroup, index) => {
58+
if (index > 0) {
59+
const separator = doc.createElementNS(MATHML_NS, 'mo');
60+
separator.textContent = separatorDelimiter;
61+
wrapper.appendChild(separator);
62+
}
63+
64+
wrapper.appendChild(expressionGroup);
65+
});
66+
67+
const end = doc.createElementNS(MATHML_NS, 'mo');
68+
end.textContent = endDelimiter;
69+
wrapper.appendChild(end);
70+
71+
return wrapper;
72+
};

β€Žpackages/layout-engine/painters/dom/src/features/math/converters/index.tsβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@
99
export { convertMathRun } from './math-run.js';
1010
export { convertFraction } from './fraction.js';
1111
export { convertBar } from './bar.js';
12+
export { convertDelimiter } from './delimiter.js';
1213
export { convertSubscript } from './subscript.js';
1314
export { convertSuperscript } from './superscript.js';

β€Žpackages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.tsβ€Ž

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,133 @@ describe('m:bar converter', () => {
322322
});
323323
});
324324

325+
describe('m:d converter', () => {
326+
it('converts m:d to delimiters around the expression', () => {
327+
const omml = {
328+
name: 'm:oMath',
329+
elements: [
330+
{
331+
name: 'm:d',
332+
elements: [
333+
{
334+
name: 'm:dPr',
335+
elements: [
336+
{ name: 'm:begChr', attributes: { 'm:val': '(' } },
337+
{ name: 'm:endChr', attributes: { 'm:val': ')' } },
338+
],
339+
},
340+
{
341+
name: 'm:e',
342+
elements: [
343+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] },
344+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '+' }] }] },
345+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'y' }] }] },
346+
],
347+
},
348+
],
349+
},
350+
],
351+
};
352+
353+
const result = convertOmmlToMathml(omml, doc);
354+
expect(result).not.toBeNull();
355+
expect(result!.textContent).toBe('(x+y)');
356+
357+
const outerRow = result!.querySelector('mrow');
358+
expect(outerRow).not.toBeNull();
359+
expect(outerRow!.children[0]!.textContent).toBe('(');
360+
expect(outerRow!.children[1]!.textContent).toBe('x+y');
361+
expect(outerRow!.children[2]!.textContent).toBe(')');
362+
});
363+
364+
it('defaults to parentheses and pipe separators when dPr is missing', () => {
365+
const omml = {
366+
name: 'm:oMath',
367+
elements: [
368+
{
369+
name: 'm:d',
370+
elements: [
371+
{
372+
name: 'm:e',
373+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
374+
},
375+
{
376+
name: 'm:e',
377+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'y' }] }] }],
378+
},
379+
],
380+
},
381+
],
382+
};
383+
384+
const result = convertOmmlToMathml(omml, doc);
385+
expect(result).not.toBeNull();
386+
expect(result!.textContent).toBe('(x|y)');
387+
});
388+
389+
it('uses custom delimiter and separator characters for multiple expressions', () => {
390+
const omml = {
391+
name: 'm:oMath',
392+
elements: [
393+
{
394+
name: 'm:d',
395+
elements: [
396+
{
397+
name: 'm:dPr',
398+
elements: [
399+
{ name: 'm:begChr', attributes: { 'm:val': '[' } },
400+
{ name: 'm:endChr', attributes: { 'm:val': ']' } },
401+
{ name: 'm:sepChr', attributes: { 'm:val': ';' } },
402+
],
403+
},
404+
{
405+
name: 'm:e',
406+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }],
407+
},
408+
{
409+
name: 'm:e',
410+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'b' }] }] }],
411+
},
412+
],
413+
},
414+
],
415+
};
416+
417+
const result = convertOmmlToMathml(omml, doc);
418+
expect(result).not.toBeNull();
419+
expect(result!.textContent).toBe('[a;b]');
420+
421+
const outerRow = result!.querySelector('mrow');
422+
expect(outerRow).not.toBeNull();
423+
expect(outerRow!.children.length).toBe(5);
424+
expect(outerRow!.children[0]!.textContent).toBe('[');
425+
expect(outerRow!.children[2]!.textContent).toBe(';');
426+
expect(outerRow!.children[4]!.textContent).toBe(']');
427+
});
428+
429+
it('does not render stray separators for empty expressions', () => {
430+
const omml = {
431+
name: 'm:oMath',
432+
elements: [
433+
{
434+
name: 'm:d',
435+
elements: [
436+
{ name: 'm:e', elements: [] },
437+
{
438+
name: 'm:e',
439+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
440+
},
441+
],
442+
},
443+
],
444+
};
445+
446+
const result = convertOmmlToMathml(omml, doc);
447+
expect(result).not.toBeNull();
448+
expect(result!.textContent).toBe('(x)');
449+
});
450+
});
451+
325452
describe('m:sSub converter', () => {
326453
it('converts m:sSub to <msub> with base and subscript', () => {
327454
const omml = {

β€Žpackages/layout-engine/painters/dom/src/features/math/omml-to-mathml.tsβ€Ž

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
convertMathRun,
1515
convertFraction,
1616
convertBar,
17+
convertDelimiter,
1718
convertSubscript,
1819
convertSuperscript,
1920
} from './converters/index.js';
@@ -37,6 +38,7 @@ const MATH_OBJECT_REGISTRY: Record<string, MathObjectConverter | null> = {
3738
// ── Implemented ──────────────────────────────────────────────────────────
3839
'm:r': convertMathRun,
3940
'm:bar': convertBar, // Bar (overbar/underbar)
41+
'm:d': convertDelimiter, // Delimiter (parentheses, brackets, braces)
4042
'm:f': convertFraction, // Fraction (numerator/denominator)
4143
'm:sSub': convertSubscript, // Subscript
4244
'm:sSup': convertSuperscript, // Superscript
@@ -45,7 +47,6 @@ const MATH_OBJECT_REGISTRY: Record<string, MathObjectConverter | null> = {
4547
'm:acc': null, // Accent (diacritical mark above base)
4648
'm:borderBox': null, // Border box (border around math content)
4749
'm:box': null, // Box (invisible grouping container)
48-
'm:d': null, // Delimiter (parentheses, brackets, braces)
4950
'm:eqArr': null, // Equation array (vertical array of equations)
5051
'm:func': null, // Function apply (sin, cos, log, etc.)
5152
'm:groupChr': null, // Group character (overbrace, underbrace)

0 commit comments

Comments
Β (0)