diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/equation-array.ts b/packages/layout-engine/painters/dom/src/features/math/converters/equation-array.ts new file mode 100644 index 0000000000..2028257385 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/features/math/converters/equation-array.ts @@ -0,0 +1,60 @@ +import type { MathObjectConverter, OmmlJsonNode } from '../types.js'; + +const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; + +/** + * Deep-clone row children with `&` stripped from m:t text nodes. + * + * ECMA-376 §22.1.2.34: `&` characters inside m:t are alignment markers + * (odd = align, even = spacer), not literal text. This implementation + * doesn't yet map them to MathML /, so strip them + * to avoid rendering literal ampersands in the output. + */ +const stripAlignmentMarkers = (nodes: OmmlJsonNode[]): OmmlJsonNode[] => + nodes.map((node) => { + if (node?.type === 'text' && typeof node.text === 'string' && node.text.includes('&')) { + return { ...node, text: node.text.replace(/&/g, '') }; + } + if (node?.elements) { + return { ...node, elements: stripAlignmentMarkers(node.elements) }; + } + return node; + }); + +/** + * Convert m:eqArr (equation array) to MathML . + * + * OMML structure: + * m:eqArr → m:eqArrPr (optional), m:e* (one element per row) + * + * MathML output: + * + * row-content + * ... + * + * + * Unlike m:m (matrix), equation arrays have one cell per row and are + * typically left-aligned. Used for systems of equations. + * + * @spec ECMA-376 §22.1.2.34 + */ +export const convertEquationArray: MathObjectConverter = (node, doc, convertChildren) => { + const elements = node.elements ?? []; + const rows = elements.filter((e) => e.name === 'm:e'); + + const mtable = doc.createElementNS(MATHML_NS, 'mtable'); + mtable.setAttribute('columnalign', 'left'); + + for (const row of rows) { + const mtr = doc.createElementNS(MATHML_NS, 'mtr'); + const mtd = doc.createElementNS(MATHML_NS, 'mtd'); + const mrow = doc.createElementNS(MATHML_NS, 'mrow'); + const cleanedChildren = stripAlignmentMarkers(row.elements ?? []); + mrow.appendChild(convertChildren(cleanedChildren)); + mtd.appendChild(mrow); + mtr.appendChild(mtd); + mtable.appendChild(mtr); + } + + return mtable.childNodes.length > 0 ? mtable : 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 db3c53cce1..7aad78a235 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 @@ -16,6 +16,7 @@ export { convertSuperscript } from './superscript.js'; export { convertSubSuperscript } from './sub-superscript.js'; export { convertAccent } from './accent.js'; export { convertPreSubSuperscript } from './pre-sub-superscript.js'; +export { convertEquationArray } from './equation-array.js'; export { convertRadical } from './radical.js'; export { convertLowerLimit } from './lower-limit.js'; export { convertUpperLimit } from './upper-limit.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 b6d994f442..d31e2755c9 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 @@ -2569,3 +2569,142 @@ describe('m:limUpp converter', () => { expect(mover!.children[1]!.textContent).toBe('x'); }); }); + +describe('m:eqArr converter', () => { + it('converts equation array to left-aligned ', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:eqArr', + elements: [ + { + 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' }] }] }, + ], + }, + { + name: 'm:e', + elements: [ + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'y' }] }] }, + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '=' }] }] }, + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '2' }] }] }, + ], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const mtable = result!.querySelector('mtable'); + expect(mtable).not.toBeNull(); + expect(mtable!.getAttribute('columnalign')).toBe('left'); + const rows = mtable!.querySelectorAll('mtr'); + expect(rows.length).toBe(2); + expect(rows[0]!.textContent).toBe('x=1'); + expect(rows[1]!.textContent).toBe('y=2'); + }); + + it('returns null for empty equation array', () => { + const omml = { + name: 'm:oMath', + elements: [{ name: 'm:eqArr', elements: [] }], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).toBeNull(); + }); + + it('strips & alignment markers from row content', () => { + // ECMA-376 §22.1.2.34: `&` inside m:t is an alignment marker, not literal text. + // The converter doesn't yet map these to MathML alignment elements, so they + // should be stripped rather than rendered. + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:eqArr', + elements: [ + { + 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); + const rows = result!.querySelectorAll('mtr'); + expect(rows.length).toBe(1); + expect(rows[0]!.textContent).toBe('x=1'); + expect(rows[0]!.textContent).not.toContain('&'); + }); + + it('ignores m:eqArrPr properties element', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:eqArr', + elements: [ + { name: 'm:eqArrPr', elements: [{ name: 'm:ctrlPr' }] }, + { + 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); + const rows = result!.querySelectorAll('mtr'); + expect(rows.length).toBe(2); + expect(rows[0]!.textContent).toBe('x'); + expect(rows[1]!.textContent).toBe('y'); + }); + + it('preserves nested math (fraction) inside rows', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:eqArr', + elements: [ + { + name: 'm:e', + elements: [ + { + name: 'm:f', + elements: [ + { + name: 'm:num', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }], + }, + { + name: 'm:den', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'b' }] }] }], + }, + ], + }, + ], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + const mfrac = result!.querySelector('mtable mtr mtd mfrac'); + expect(mfrac).not.toBeNull(); + }); +}); 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 ce57a759fe..fd3585fabe 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 @@ -21,6 +21,7 @@ import { convertSubSuperscript, convertAccent, convertPreSubSuperscript, + convertEquationArray, convertRadical, convertLowerLimit, convertUpperLimit, @@ -47,6 +48,7 @@ const MATH_OBJECT_REGISTRY: Record = { 'm:acc': convertAccent, // Accent (diacritical mark above base) 'm:bar': convertBar, // Bar (overbar/underbar) 'm:d': convertDelimiter, // Delimiter (parentheses, brackets, braces) + 'm:eqArr': convertEquationArray, // Equation array (vertical array of equations) 'm:f': convertFraction, // Fraction (numerator/denominator) 'm:func': convertFunction, // Function apply (sin, cos, log, etc.) 'm:limLow': convertLowerLimit, // Lower limit (e.g., lim) @@ -60,7 +62,6 @@ const MATH_OBJECT_REGISTRY: Record = { // ── Not yet implemented (community contributions welcome) ──────────────── '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:groupChr': null, // Group character (overbrace, underbrace) 'm:m': null, // Matrix (grid of elements) 'm:nary': null, // N-ary operator (integral, summation, product) diff --git a/tests/behavior/tests/importing/fixtures/math-eqarr-tests.docx b/tests/behavior/tests/importing/fixtures/math-eqarr-tests.docx new file mode 100644 index 0000000000..14f5b31a4b Binary files /dev/null and b/tests/behavior/tests/importing/fixtures/math-eqarr-tests.docx differ diff --git a/tests/behavior/tests/importing/math-equations.spec.ts b/tests/behavior/tests/importing/math-equations.spec.ts index a48811cf04..facc9bbfa1 100644 --- a/tests/behavior/tests/importing/math-equations.spec.ts +++ b/tests/behavior/tests/importing/math-equations.spec.ts @@ -9,6 +9,7 @@ const SPRE_DOC = path.resolve(__dirname, 'fixtures/math-spre-tests.docx'); const DELIMITER_DOC = path.resolve(__dirname, 'fixtures/math-delimiter-tests.docx'); const RADICAL_DOC = path.resolve(__dirname, 'fixtures/math-radical-tests.docx'); const LIMIT_DOC = path.resolve(__dirname, 'fixtures/math-limit-tests.docx'); +const EQARR_DOC = path.resolve(__dirname, 'fixtures/math-eqarr-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. @@ -775,3 +776,109 @@ test.describe('m:limLow / m:limUpp (limit object) rendering', () => { expect(leaked).toEqual([]); }); }); + +test.describe('m:eqArr (equation array) rendering', () => { + // Fixture (math-eqarr-tests.docx) contains 5 Word-native equation arrays: + // 1. Basic 2-row — x=1 / y=2 + // 2. Row with nested fraction — a/b=c / x=y + // 3. Row with subscript — x_1=a / y=b + // 4. Alignment markers (&) — x&=1 / yy&=22 (ampersands must be stripped) + // 5. With m:eqArrPr properties — x=1 / y=2 (Pr element must be filtered) + + test('renders all 5 equation arrays as ', async ({ superdoc }) => { + await superdoc.loadDocument(EQARR_DOC); + await superdoc.waitForStable(); + + const data = await superdoc.page.evaluate(() => { + const mtables = Array.from(document.querySelectorAll('mtable')); + return mtables.map((t) => ({ + columnalign: t.getAttribute('columnalign'), + mtrCount: t.querySelectorAll(':scope > mtr').length, + })); + }); + + expect(data.length).toBe(5); + for (const t of data) { + expect(t.columnalign).toBe('left'); + expect(t.mtrCount).toBe(2); + } + }); + + test('preserves nested inside an equation array row (case 2)', async ({ superdoc }) => { + await superdoc.loadDocument(EQARR_DOC); + await superdoc.waitForStable(); + + const hasFracInRow = await superdoc.page.evaluate(() => { + const mtables = Array.from(document.querySelectorAll('mtable')); + for (const t of mtables) { + const frac = t.querySelector(':scope > mtr > mtd mfrac'); + if ( + frac && + frac.children.length === 2 && + frac.children[0]?.textContent === 'a' && + frac.children[1]?.textContent === 'b' + ) { + return true; + } + } + return false; + }); + + expect(hasFracInRow).toBe(true); + }); + + test('preserves nested inside an equation array row (case 3)', async ({ superdoc }) => { + await superdoc.loadDocument(EQARR_DOC); + await superdoc.waitForStable(); + + const hasSubInRow = await superdoc.page.evaluate(() => { + const mtables = Array.from(document.querySelectorAll('mtable')); + return mtables.some((t) => t.querySelector(':scope > mtr > mtd msub') !== null); + }); + + expect(hasSubInRow).toBe(true); + }); + + test('strips & alignment markers from row content (case 4)', async ({ superdoc }) => { + await superdoc.loadDocument(EQARR_DOC); + await superdoc.waitForStable(); + + // ECMA-376 §22.1.2.34: `&` inside m:t is an alignment marker, not literal text. + // The converter does not yet map these to MathML alignment groups, so they + // should be stripped rather than rendered as literal ampersands. + const alignmentData = await superdoc.page.evaluate(() => { + const mtables = Array.from(document.querySelectorAll('mtable')); + const texts = mtables.flatMap((t) => + Array.from(t.querySelectorAll(':scope > mtr > mtd')).map((td) => td.textContent ?? ''), + ); + return { + anyContainsAmpersand: texts.some((s) => s.includes('&')), + hasStrippedRow: texts.some((s) => s === 'yy=22'), + }; + }); + + expect(alignmentData.anyContainsAmpersand).toBe(false); + expect(alignmentData.hasStrippedRow).toBe(true); + }); + + test('m:eqArrPr property element is filtered out (case 5)', async ({ superdoc }) => { + await superdoc.loadDocument(EQARR_DOC); + await superdoc.waitForStable(); + + // Word emits m:eqArrPr wrapping m:baseJc / m:maxDist / m:rSp / m:ctrlPr etc. + // These must be stripped by the converter — they should never appear as DOM + // elements named "eqarrpr" / "basejc" / "maxdist" / "ctrlpr". + const leaked = await superdoc.page.evaluate(() => { + const leaks: string[] = []; + for (const el of document.querySelectorAll('math *')) { + const name = el.localName.toLowerCase(); + if (['eqarrpr', 'basejc', 'maxdist', 'objdist', 'rsp', 'rsprule', 'ctrlpr'].includes(name)) { + leaks.push(name); + } + } + return leaks; + }); + + expect(leaked).toEqual([]); + }); +});