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..dc2117a7af --- /dev/null +++ b/packages/layout-engine/painters/dom/src/features/math/converters/delimiter.ts @@ -0,0 +1,63 @@ +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 = '\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); + if (!property) return fallback; + return property.attributes?.['m:val'] ?? ''; +} + +/** + * 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); + + let renderedCount = 0; + for (const expression of expressions) { + const fragment = convertChildren(expression?.elements ?? []); + if (fragment.childNodes.length === 0) continue; + + if (renderedCount > 0) { + const separator = doc.createElementNS(MATHML_NS, 'mo'); + separator.textContent = separatorDelimiter; + wrapper.appendChild(separator); + } + + const group = doc.createElementNS(MATHML_NS, 'mrow'); + group.appendChild(fragment); + wrapper.appendChild(group); + renderedCount++; + } + + 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 4b8bc226e4..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 @@ -10,6 +10,7 @@ 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'; 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 dd24e9d0b6..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 @@ -322,6 +322,584 @@ 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 U+2502 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\u2502y)'); + }); + + 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(''); + }); + + 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', () => { + 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 f4d9dea4b7..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 @@ -15,6 +15,7 @@ import { convertFraction, convertBar, convertFunction, + convertDelimiter, convertSubscript, convertSuperscript, convertSubSuperscript, @@ -40,6 +41,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:func': convertFunction, // Function apply (sin, cos, log, etc.) 'm:sSub': convertSubscript, // Subscript @@ -50,7 +52,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:groupChr': null, // Group character (overbrace, underbrace) 'm:limLow': null, // Lower limit (e.g., lim) 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 0000000000..dafb1df17d Binary files /dev/null and b/tests/behavior/tests/importing/fixtures/math-delimiter-tests.docx differ diff --git a/tests/behavior/tests/importing/math-equations.spec.ts b/tests/behavior/tests/importing/math-equations.spec.ts index 97b47e9402..0f166dd3da 100644 --- a/tests/behavior/tests/importing/math-equations.spec.ts +++ b/tests/behavior/tests/importing/math-equations.spec.ts @@ -5,6 +5,7 @@ import { test, expect } from '../../fixtures/superdoc.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ALL_OBJECTS_DOC = path.resolve(__dirname, 'fixtures/math-all-objects.docx'); const FUNC_DOC = path.resolve(__dirname, 'fixtures/math-func-tests.docx'); +const DELIMITER_DOC = path.resolve(__dirname, 'fixtures/math-delimiter-tests.docx'); // Single-object test docs are used for focused verification by community contributors. // The all-objects doc is used for behavior tests since it exercises the full pipeline. @@ -128,8 +129,8 @@ test.describe('math equation import and rendering', () => { 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)'); + }); +});