From 804c4d38a11f55d723073ef299b463eaf65cf996 Mon Sep 17 00:00:00 2001 From: frontandrews Date: Thu, 2 Apr 2026 18:59:42 -0300 Subject: [PATCH 1/6] feat(math): implement m:d delimiter converter (SD-2380) --- .../src/features/math/converters/delimiter.ts | 72 ++++++++ .../dom/src/features/math/converters/index.ts | 1 + .../src/features/math/omml-to-mathml.test.ts | 167 ++++++++++++++++++ .../dom/src/features/math/omml-to-mathml.ts | 3 +- 4 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 packages/layout-engine/painters/dom/src/features/math/converters/delimiter.ts diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/delimiter.ts b/packages/layout-engine/painters/dom/src/features/math/converters/delimiter.ts new file mode 100644 index 0000000000..abc151e0af --- /dev/null +++ b/packages/layout-engine/painters/dom/src/features/math/converters/delimiter.ts @@ -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 = '|'; + +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; +} + +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: + * ( ...content... ) + * + * @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; +}; 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 afa0d857bf..264470df1a 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,5 +9,6 @@ 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'; 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 606176d72f..20af5249c8 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 @@ -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 with base and subscript', () => { const omml = { 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 b83bb84511..75b6d98864 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 @@ -14,6 +14,7 @@ import { convertMathRun, convertFraction, convertBar, + convertDelimiter, convertSubscript, convertSuperscript, } from './converters/index.js'; @@ -37,6 +38,7 @@ const MATH_OBJECT_REGISTRY: Record = { // ── 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 @@ -45,7 +47,6 @@ const MATH_OBJECT_REGISTRY: Record = { '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) From a43afb517027056f28cc199d3d28e412decc1246 Mon Sep 17 00:00:00 2001 From: andrewsrigom Date: Wed, 8 Apr 2026 21:50:12 -0300 Subject: [PATCH 2/6] fix(math): address delimiter review and sync converters --- .../src/features/math/converters/function.ts | 55 +++ .../dom/src/features/math/converters/index.ts | 2 + .../src/features/math/converters/radical.ts | 48 +++ .../src/features/math/omml-to-mathml.test.ts | 375 ++++++++++++++++++ .../dom/src/features/math/omml-to-mathml.ts | 6 +- 5 files changed, 484 insertions(+), 2 deletions(-) create mode 100644 packages/layout-engine/painters/dom/src/features/math/converters/function.ts create mode 100644 packages/layout-engine/painters/dom/src/features/math/converters/radical.ts diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/function.ts b/packages/layout-engine/painters/dom/src/features/math/converters/function.ts new file mode 100644 index 0000000000..65d1461f88 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/features/math/converters/function.ts @@ -0,0 +1,55 @@ +import type { MathObjectConverter } from '../types.js'; + +const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; +const FUNCTION_APPLY_OPERATOR = '\u2061'; + +function forceNormalMathVariant(root: ParentNode): void { + root.querySelectorAll('mi').forEach((identifier) => { + identifier.setAttribute('mathvariant', 'normal'); + }); +} + +/** + * Convert m:func (function apply) to MathML. + * + * OMML structure: + * m:func → m:funcPr (optional), m:fName (function name), m:e (argument) + * + * MathML output: + * name argument + * + * Function names are rendered upright (mathvariant="normal") instead of the + * default italic identifier style used by MathML. + * + * @spec ECMA-376 §22.1.2.39 + */ +export const convertFunction: MathObjectConverter = (node, doc, convertChildren) => { + const elements = node.elements ?? []; + const functionName = elements.find((element) => element.name === 'm:fName'); + const argument = elements.find((element) => element.name === 'm:e'); + + const wrapper = doc.createElementNS(MATHML_NS, 'mrow'); + + const functionNameRow = doc.createElementNS(MATHML_NS, 'mrow'); + functionNameRow.appendChild(convertChildren(functionName?.elements ?? [])); + forceNormalMathVariant(functionNameRow); + + if (functionNameRow.childNodes.length > 0) { + wrapper.appendChild(functionNameRow); + } + + const argumentRow = doc.createElementNS(MATHML_NS, 'mrow'); + argumentRow.appendChild(convertChildren(argument?.elements ?? [])); + + if (functionNameRow.childNodes.length > 0 && argumentRow.childNodes.length > 0) { + const applyOperator = doc.createElementNS(MATHML_NS, 'mo'); + applyOperator.textContent = FUNCTION_APPLY_OPERATOR; + wrapper.appendChild(applyOperator); + } + + if (argumentRow.childNodes.length > 0) { + wrapper.appendChild(argumentRow); + } + + return wrapper.childNodes.length > 0 ? wrapper : null; +}; 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 c8b6862dcf..2857cfa29b 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,7 +9,9 @@ export { convertMathRun } from './math-run.js'; export { convertFraction } from './fraction.js'; export { convertBar } from './bar.js'; +export { convertFunction } from './function.js'; export { convertDelimiter } from './delimiter.js'; export { convertSubscript } from './subscript.js'; export { convertSuperscript } from './superscript.js'; export { convertSubSuperscript } from './sub-superscript.js'; +export { convertRadical } from './radical.js'; diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/radical.ts b/packages/layout-engine/painters/dom/src/features/math/converters/radical.ts new file mode 100644 index 0000000000..4b0d13de40 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/features/math/converters/radical.ts @@ -0,0 +1,48 @@ +import type { MathObjectConverter } from '../types.js'; + +const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; + +/** + * Convert m:rad (radical) to MathML or . + * + * OMML structure: + * m:rad → m:radPr (optional: m:degHide), m:deg (degree), m:e (radicand) + * + * MathML output: + * - degree hidden → radicand + * - degree shown → radicanddegree + * + * @spec ECMA-376 §22.1.2.88 + */ +export const convertRadical: MathObjectConverter = (node, doc, convertChildren) => { + const elements = node.elements ?? []; + + const radPr = elements.find((e) => e.name === 'm:radPr'); + const deg = elements.find((e) => e.name === 'm:deg'); + const radicand = elements.find((e) => e.name === 'm:e'); + + // m:degHide val defaults to false; presence with val="1" or "true" means hidden + const degHideEl = radPr?.elements?.find((e) => e.name === 'm:degHide'); + const degHideVal = degHideEl?.attributes?.['m:val']; + const degreeHidden = degHideEl !== undefined && degHideVal !== '0' && degHideVal !== 'false'; + + if (degreeHidden || !deg) { + const msqrt = doc.createElementNS(MATHML_NS, 'msqrt'); + const radicandRow = doc.createElementNS(MATHML_NS, 'mrow'); + radicandRow.appendChild(convertChildren(radicand?.elements ?? [])); + msqrt.appendChild(radicandRow); + return msqrt; + } + + const mroot = doc.createElementNS(MATHML_NS, 'mroot'); + + const radicandRow = doc.createElementNS(MATHML_NS, 'mrow'); + radicandRow.appendChild(convertChildren(radicand?.elements ?? [])); + mroot.appendChild(radicandRow); + + const degRow = doc.createElementNS(MATHML_NS, 'mrow'); + degRow.appendChild(convertChildren(deg?.elements ?? [])); + mroot.appendChild(degRow); + + return mroot; +}; 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 50ae15baee..b678f2123c 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 @@ -489,6 +489,381 @@ describe('m:d converter', () => { }); }); +describe('m:func converter', () => { + it('converts m:func to function name + apply operator + argument', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:func', + elements: [ + { + name: 'm:fName', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'sin' }] }] }], + }, + { + 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(`sin${'\u2061'}x`); + + const mrow = result!.querySelector('mrow'); + expect(mrow).not.toBeNull(); + + const functionIdentifier = mrow!.querySelector('mi'); + expect(functionIdentifier).not.toBeNull(); + expect(functionIdentifier!.textContent).toBe('sin'); + expect(functionIdentifier!.getAttribute('mathvariant')).toBe('normal'); + + const applyOperator = mrow!.querySelector('mo'); + expect(applyOperator).not.toBeNull(); + expect(applyOperator!.textContent).toBe('\u2061'); + }); + + it('ignores m:funcPr properties element', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:func', + elements: [ + { name: 'm:funcPr', elements: [{ name: 'm:ctrlPr' }] }, + { + name: 'm:fName', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'log' }] }] }], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '10' }] }] }], + }, + ], + }, + ], + }; + + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + expect(result!.textContent).toBe(`log${'\u2061'}10`); + }); + + it('renders single-character function names upright', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:func', + elements: [ + { + name: 'm:fName', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'f' }] }] }], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }], + }, + ], + }, + ], + }; + + const result = convertOmmlToMathml(omml, doc); + const firstMi = result!.querySelector('mi'); + expect(firstMi).not.toBeNull(); + expect(firstMi!.textContent).toBe('f'); + expect(firstMi!.getAttribute('mathvariant')).toBe('normal'); + }); + + it('wraps multi-part arguments in ', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:func', + elements: [ + { + name: 'm:fName', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'sin' }] }] }], + }, + { + 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: '1' }] }] }, + ], + }, + ], + }, + ], + }; + + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + + const outerRow = result!.querySelector('math > mrow'); + expect(outerRow).not.toBeNull(); + expect(outerRow!.children.length).toBe(3); + expect(outerRow!.children[0]!.textContent).toBe('sin'); + expect(outerRow!.children[1]!.textContent).toBe('\u2061'); + expect(outerRow!.children[2]!.textContent).toBe('x+1'); + }); + + it('renders only the argument when m:fName is missing', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:func', + 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'); + + const mo = result!.querySelector('mo'); + expect(mo).toBeNull(); + }); + + it('renders only the function name when m:e is missing', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:func', + elements: [ + { + name: 'm:fName', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'sin' }] }] }], + }, + ], + }, + ], + }; + + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + expect(result!.textContent).toBe('sin'); + + const mo = result!.querySelector('mo'); + expect(mo).toBeNull(); + + const mi = result!.querySelector('mi'); + expect(mi!.getAttribute('mathvariant')).toBe('normal'); + }); + + it('returns null for empty m:func', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:func', + elements: [], + }, + ], + }; + + const result = convertOmmlToMathml(omml, doc); + expect(result).toBeNull(); + }); + + it('handles nested m:func (sin of cos x)', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:func', + elements: [ + { + name: 'm:fName', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'sin' }] }] }], + }, + { + name: 'm:e', + elements: [ + { + name: 'm:func', + elements: [ + { + name: 'm:fName', + elements: [ + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'cos' }] }] }, + ], + }, + { + 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(`sin${'\u2061'}cos${'\u2061'}x`); + + const mis = result!.querySelectorAll('mi[mathvariant="normal"]'); + expect(mis.length).toBe(2); + expect(mis[0]!.textContent).toBe('sin'); + expect(mis[1]!.textContent).toBe('cos'); + }); +}); + +describe('m:rad converter', () => { + it('converts m:rad with degHide to ', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:rad', + elements: [ + { + name: 'm:radPr', + elements: [{ name: 'm:degHide' }], + }, + { name: 'm:deg', 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(); + const msqrt = result!.querySelector('msqrt'); + expect(msqrt).not.toBeNull(); + expect(msqrt!.textContent).toBe('x'); + expect(result!.querySelector('mroot')).toBeNull(); + }); + + it('converts m:rad without degHide to with radicand first, degree second', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:rad', + elements: [ + { + name: 'm:deg', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '3' }] }] }], + }, + { + 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(); + const mroot = result!.querySelector('mroot'); + expect(mroot).not.toBeNull(); + expect(mroot!.children[0]!.textContent).toBe('x'); + expect(mroot!.children[1]!.textContent).toBe('3'); + expect(result!.querySelector('msqrt')).toBeNull(); + }); + + it('converts m:rad with degHide m:val="0" to (degree explicitly visible)', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:rad', + elements: [ + { + name: 'm:radPr', + elements: [{ name: 'm:degHide', attributes: { 'm:val': '0' } }], + }, + { + name: 'm:deg', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '3' }] }] }], + }, + { + 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!.querySelector('mroot')).not.toBeNull(); + expect(result!.querySelector('msqrt')).toBeNull(); + }); + + it('produces when m:deg is missing entirely', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:rad', + 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!.querySelector('msqrt')).not.toBeNull(); + expect(result!.querySelector('mroot')).toBeNull(); + }); + + it('handles missing m:e gracefully', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:rad', + elements: [ + { + name: 'm:radPr', + elements: [{ name: 'm:degHide' }], + }, + { name: 'm:deg', elements: [] }, + ], + }, + ], + }; + + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const msqrt = result!.querySelector('msqrt'); + expect(msqrt).not.toBeNull(); + expect(msqrt!.textContent).toBe(''); + }); +}); + describe('m:sSub converter', () => { it('converts m:sSub to with base and subscript', () => { const omml = { 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 bfb9f19d13..8c3d9509a8 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 @@ -14,10 +14,12 @@ import { convertMathRun, convertFraction, convertBar, + convertFunction, convertDelimiter, convertSubscript, convertSuperscript, convertSubSuperscript, + convertRadical, } from './converters/index.js'; export const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; @@ -41,6 +43,7 @@ const MATH_OBJECT_REGISTRY: Record = { 'm:bar': convertBar, // Bar (overbar/underbar) 'm:d': convertDelimiter, // Delimiter (parentheses, brackets, braces) 'm:f': convertFraction, // Fraction (numerator/denominator) + 'm:func': convertFunction, // Function apply (sin, cos, log, etc.) 'm:sSub': convertSubscript, // Subscript 'm:sSup': convertSuperscript, // Superscript 'm:sSubSup': convertSubSuperscript, // Sub-superscript (both) @@ -50,14 +53,13 @@ const MATH_OBJECT_REGISTRY: Record = { 'm:borderBox': null, // Border box (border around math content) 'm:box': null, // Box (invisible grouping container) '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) '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': null, // Radical (square root, nth root) + 'm:rad': convertRadical, // Radical (square root, nth root) 'm:sPre': null, // Pre-sub-superscript (left of base) }; From a0a0d00d3306ba6ebba7e2f5532a3b17dcdc1d98 Mon Sep 17 00:00:00 2001 From: andrewsrigom Date: Wed, 8 Apr 2026 22:26:10 -0300 Subject: [PATCH 3/6] fix(math): address delimiter review feedback --- .../src/features/math/converters/delimiter.ts | 5 ++- .../src/features/math/omml-to-mathml.test.ts | 40 ++++++++++++++++++- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/delimiter.ts b/packages/layout-engine/painters/dom/src/features/math/converters/delimiter.ts index abc151e0af..07d65661ab 100644 --- a/packages/layout-engine/painters/dom/src/features/math/converters/delimiter.ts +++ b/packages/layout-engine/painters/dom/src/features/math/converters/delimiter.ts @@ -4,11 +4,12 @@ const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; const DEFAULT_BEGIN_DELIMITER = '('; const DEFAULT_END_DELIMITER = ')'; -const DEFAULT_SEPARATOR_DELIMITER = '|'; +const DEFAULT_SEPARATOR_DELIMITER = '\u2502'; 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; + if (!property) return fallback; + return property.attributes?.['m:val'] ?? ''; } function createExpressionGroup( 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 7bc1a3bf13..ba65368aa2 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 @@ -361,7 +361,7 @@ describe('m:d converter', () => { expect(outerRow!.children[2]!.textContent).toBe(')'); }); - it('defaults to parentheses and pipe separators when dPr is missing', () => { + it('defaults to parentheses and U+2502 separators when dPr is missing', () => { const omml = { name: 'm:oMath', elements: [ @@ -383,7 +383,7 @@ describe('m:d converter', () => { const result = convertOmmlToMathml(omml, doc); expect(result).not.toBeNull(); - expect(result!.textContent).toBe('(x|y)'); + expect(result!.textContent).toBe('(x\u2502y)'); }); it('uses custom delimiter and separator characters for multiple expressions', () => { @@ -487,6 +487,42 @@ describe('m:d converter', () => { expect(outerRow!.children[2]!.textContent).toBe(''); expect(outerRow!.children[4]!.textContent).toBe(''); }); + + it('suppresses delimiter characters when chr elements are present without m:val', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:d', + elements: [ + { + name: 'm:dPr', + elements: [{ name: 'm:begChr' }, { name: 'm:endChr' }, { name: 'm:sepChr' }], + }, + { + 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:func converter', () => { From e3f4d6efcd72eba74ac205bdefc0523529a4c931 Mon Sep 17 00:00:00 2001 From: andrewsrigom Date: Wed, 8 Apr 2026 22:33:15 -0300 Subject: [PATCH 4/6] docs(math): clarify delimiter separator spec --- .../painters/dom/src/features/math/converters/delimiter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/delimiter.ts b/packages/layout-engine/painters/dom/src/features/math/converters/delimiter.ts index 07d65661ab..fd9537863b 100644 --- a/packages/layout-engine/painters/dom/src/features/math/converters/delimiter.ts +++ b/packages/layout-engine/painters/dom/src/features/math/converters/delimiter.ts @@ -4,7 +4,7 @@ const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; const DEFAULT_BEGIN_DELIMITER = '('; const DEFAULT_END_DELIMITER = ')'; -const DEFAULT_SEPARATOR_DELIMITER = '\u2502'; +const DEFAULT_SEPARATOR_DELIMITER = '\u2502'; // ECMA-376 22.1.2.95: BOX DRAWINGS LIGHT VERTICAL function getDelimiterValue(properties: OmmlJsonNode | undefined, name: string, fallback: string): string { const property = properties?.elements?.find((element) => element.name === name); From 6784fed1cead934b452f1b96813c9860bee8e20b Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 8 Apr 2026 22:04:51 -0700 Subject: [PATCH 5/6] test(math): add behavior tests for m:d delimiter rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 5 behavior tests exercising the delimiter converter with a dedicated test document covering all 21 ECMA-376 §22.1.2.24 compliance cases: default parentheses, U+2502 separator, suppressed delimiters (chr without m:val), custom Unicode delimiters (floor, ceiling, abs), and nesting. Fix stale comment that still referred to delimiter as unimplemented. --- .../fixtures/math-delimiter-tests.docx | Bin 0 -> 14521 bytes .../tests/importing/math-equations.spec.ts | 127 +++++++++++++++++- 2 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 tests/behavior/tests/importing/fixtures/math-delimiter-tests.docx diff --git a/tests/behavior/tests/importing/fixtures/math-delimiter-tests.docx b/tests/behavior/tests/importing/fixtures/math-delimiter-tests.docx new file mode 100644 index 0000000000000000000000000000000000000000..dafb1df17de76764c841085e007096d0012ba8d9 GIT binary patch literal 14521 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 { await superdoc.loadDocument(ALL_OBJECTS_DOC); await superdoc.waitForStable(); - // Unimplemented math objects (e.g., delimiter) should still - // have their text content accessible in the PM document + // Unimplemented math objects should still have their text + // content accessible in the PM document const mathTexts = await superdoc.page.evaluate(() => { const view = (window as any).editor?.view; if (!view) return []; @@ -237,3 +238,125 @@ test.describe('m:func (function apply) rendering', () => { expect(fractionData!.denominatorText).toBe('x'); }); }); + +test.describe('m:d (delimiter) rendering', () => { + test('renders all 21 delimiter test cases as elements', async ({ superdoc }) => { + await superdoc.loadDocument(DELIMITER_DOC); + await superdoc.waitForStable(); + + const mathCount = await superdoc.page.evaluate(() => { + return document.querySelectorAll('math').length; + }); + expect(mathCount).toBe(21); + }); + + test('default parentheses wrap expression in delimiters', async ({ superdoc }) => { + await superdoc.loadDocument(DELIMITER_DOC); + await superdoc.waitForStable(); + + // Case 1: default (x+y) + const data = await superdoc.page.evaluate(() => { + const math = document.querySelectorAll('math')[0]; + if (!math) return null; + const mrow = math.querySelector('mrow'); + if (!mrow) return null; + const mos = mrow.querySelectorAll(':scope > mo'); + return { + text: math.textContent, + openDelim: mos[0]?.textContent, + closeDelim: mos[mos.length - 1]?.textContent, + }; + }); + + expect(data).not.toBeNull(); + expect(data!.text).toBe('(x+y)'); + expect(data!.openDelim).toBe('('); + expect(data!.closeDelim).toBe(')'); + }); + + test('uses U+2502 as default separator between expressions', async ({ superdoc }) => { + await superdoc.loadDocument(DELIMITER_DOC); + await superdoc.waitForStable(); + + // Case 2: two expressions with default separator + const data = await superdoc.page.evaluate(() => { + const math = document.querySelectorAll('math')[1]; + if (!math) return null; + return { text: math.textContent }; + }); + + expect(data).not.toBeNull(); + expect(data!.text).toBe('(x\u2502y)'); + }); + + test('suppresses delimiter when chr element present without m:val', async ({ superdoc }) => { + await superdoc.loadDocument(DELIMITER_DOC); + await superdoc.waitForStable(); + + // Case 5: begChr present, no val → suppress opening delimiter + const case5 = await superdoc.page.evaluate(() => { + const math = document.querySelectorAll('math')[4]; + return math?.textContent ?? null; + }); + expect(case5).toBe('x+y)'); + + // Case 8: endChr present, no val → suppress closing delimiter + const case8 = await superdoc.page.evaluate(() => { + const math = document.querySelectorAll('math')[7]; + return math?.textContent ?? null; + }); + expect(case8).toBe('(x+y'); + + // Case 9: both present, no val → suppress both + const case9 = await superdoc.page.evaluate(() => { + const math = document.querySelectorAll('math')[8]; + return math?.textContent ?? null; + }); + expect(case9).toBe('x+y'); + }); + + test('renders custom delimiter characters', async ({ superdoc }) => { + await superdoc.loadDocument(DELIMITER_DOC); + await superdoc.waitForStable(); + + // Case 13: absolute value |x| + const absVal = await superdoc.page.evaluate(() => { + const math = document.querySelectorAll('math')[12]; + return math?.textContent ?? null; + }); + expect(absVal).toBe('|x|'); + + // Case 15: floor ⌊x⌋ + const floor = await superdoc.page.evaluate(() => { + const math = document.querySelectorAll('math')[14]; + return math?.textContent ?? null; + }); + expect(floor).toBe('⌊x⌋'); + + // Case 16: ceiling ⌈x⌉ + const ceiling = await superdoc.page.evaluate(() => { + const math = document.querySelectorAll('math')[15]; + return math?.textContent ?? null; + }); + expect(ceiling).toBe('⌈x⌉'); + }); + + test('renders nested delimiters', async ({ superdoc }) => { + await superdoc.loadDocument(DELIMITER_DOC); + await superdoc.waitForStable(); + + // Case 17: ((x+y)+z) + const nested = await superdoc.page.evaluate(() => { + const math = document.querySelectorAll('math')[16]; + if (!math) return null; + const innerMrows = math.querySelectorAll('mrow mrow mo'); + return { + text: math.textContent, + nestedMoCount: innerMrows.length, + }; + }); + + expect(nested).not.toBeNull(); + expect(nested!.text).toBe('((x+y)+z)'); + }); +}); From 408dad97211a4fc883a8b60890c13d06d44ecf18 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 8 Apr 2026 22:17:52 -0700 Subject: [PATCH 6/6] refactor(math): inline createExpressionGroup into single loop Replace map + filter + forEach chain with a single for-loop that builds expression groups directly, removing the single-use helper. --- .../src/features/math/converters/delimiter.ts | 30 +++++++------------ 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/delimiter.ts b/packages/layout-engine/painters/dom/src/features/math/converters/delimiter.ts index fd9537863b..dc2117a7af 100644 --- a/packages/layout-engine/painters/dom/src/features/math/converters/delimiter.ts +++ b/packages/layout-engine/painters/dom/src/features/math/converters/delimiter.ts @@ -12,19 +12,6 @@ function getDelimiterValue(properties: OmmlJsonNode | undefined, name: string, f return property.attributes?.['m:val'] ?? ''; } -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. * @@ -51,19 +38,22 @@ export const convertDelimiter: MathObjectConverter = (node, doc, convertChildren begin.textContent = beginDelimiter; wrapper.appendChild(begin); - const expressionGroups = expressions - .map((expression) => createExpressionGroup(expression, doc, convertChildren)) - .filter((expressionGroup): expressionGroup is Element => expressionGroup !== null); + let renderedCount = 0; + for (const expression of expressions) { + const fragment = convertChildren(expression?.elements ?? []); + if (fragment.childNodes.length === 0) continue; - expressionGroups.forEach((expressionGroup, index) => { - if (index > 0) { + if (renderedCount > 0) { const separator = doc.createElementNS(MATHML_NS, 'mo'); separator.textContent = separatorDelimiter; wrapper.appendChild(separator); } - wrapper.appendChild(expressionGroup); - }); + const group = doc.createElementNS(MATHML_NS, 'mrow'); + group.appendChild(fragment); + wrapper.appendChild(group); + renderedCount++; + } const end = doc.createElementNS(MATHML_NS, 'mo'); end.textContent = endDelimiter;