From bb2f86e8a982386b716acd8b0efe927e28897545 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 13 Apr 2026 16:50:06 -0700 Subject: [PATCH] feat(math): implement m:m matrix converter (closes #2601) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert the OMML matrix element (m:m) to MathML . Each m:mr row becomes an and each m:e cell becomes an wrapping an that holds the converted cell content, matching the pattern used by fraction/equation-array/radical/nary converters. - Empty m:e cells are preserved as positional gaps per §22.1.2.32 and render a U+25A1 placeholder by default so the layout matches Word's rendering. m:plcHide in m:mPr suppresses the placeholder (§22.1.2.83). - Remaining m:mPr properties (mcs/mcJc/baseJc) are ignored for now; follow-up work will map per-column justification onto columnalign. Spec: ECMA-376 §22.1.2.60 --- .../dom/src/features/math/converters/index.ts | 1 + .../src/features/math/converters/matrix.ts | 73 +++++ .../src/features/math/omml-to-mathml.test.ts | 309 ++++++++++++++++++ .../dom/src/features/math/omml-to-mathml.ts | 3 +- .../importing/fixtures/math-matrix-tests.docx | Bin 0 -> 11873 bytes .../tests/importing/math-equations.spec.ts | 100 ++++++ 6 files changed, 485 insertions(+), 1 deletion(-) create mode 100644 packages/layout-engine/painters/dom/src/features/math/converters/matrix.ts create mode 100644 tests/behavior/tests/importing/fixtures/math-matrix-tests.docx 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 3d9960dda6..fcc7916fcb 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 @@ -23,3 +23,4 @@ export { convertUpperLimit } from './upper-limit.js'; export { convertNary } from './nary.js'; export { convertPhantom } from './phantom.js'; export { convertGroupCharacter } from './group-character.js'; +export { convertMatrix } from './matrix.js'; diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/matrix.ts b/packages/layout-engine/painters/dom/src/features/math/converters/matrix.ts new file mode 100644 index 0000000000..b126b84a14 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/features/math/converters/matrix.ts @@ -0,0 +1,73 @@ +import type { MathObjectConverter, OmmlJsonNode } from '../types.js'; + +const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; + +/** Visual placeholder for empty matrix cells when m:plcHide is off (§22.1.2.83). */ +const EMPTY_CELL_PLACEHOLDER = '\u25A1'; // WHITE SQUARE + +/** True when the given m:plcHide element expresses "hide placeholders". */ +function isPlaceholderHidden(plcHide: OmmlJsonNode | undefined): boolean { + if (!plcHide) return false; + const val = plcHide.attributes?.['m:val']; + // Per §22.1.2.83: presence without @m:val means placeholders are hidden. + if (val === undefined) return true; + return val === '1' || val === 'true'; +} + +/** + * Convert m:m (matrix) to MathML . + * + * OMML structure: + * m:m → m:mPr (optional: mcs/mcJc/baseJc/plcHide — only plcHide applied), m:mr* (rows) + * m:mr → m:e* (cells; empty m:e creates a positional gap per §22.1.2.32) + * + * MathML output: + * + * + * cell-content + * ... + * + * ... + * + * + * Empty cells render a U+25A1 placeholder by default (§22.1.2.83 plcHide="0"). + * When m:plcHide is present with val "1"/"true" or no val, the placeholder is suppressed. + * + * @spec ECMA-376 §22.1.2.60 + */ +export const convertMatrix: MathObjectConverter = (node, doc, convertChildren) => { + const elements = node.elements ?? []; + const rows = elements.filter((e) => e.name === 'm:mr'); + + const matrixProps = elements.find((e) => e.name === 'm:mPr'); + const plcHide = matrixProps?.elements?.find((e) => e.name === 'm:plcHide'); + const hidePlaceholders = isPlaceholderHidden(plcHide); + + const mtable = doc.createElementNS(MATHML_NS, 'mtable'); + + for (const row of rows) { + const mtr = doc.createElementNS(MATHML_NS, 'mtr'); + const cells = row.elements?.filter((e) => e.name === 'm:e') ?? []; + + for (const cell of cells) { + const mtd = doc.createElementNS(MATHML_NS, 'mtd'); + const mrow = doc.createElementNS(MATHML_NS, 'mrow'); + const fragment = convertChildren(cell.elements ?? []); + + if (fragment.childNodes.length === 0 && !hidePlaceholders) { + const placeholder = doc.createElementNS(MATHML_NS, 'mi'); + placeholder.textContent = EMPTY_CELL_PLACEHOLDER; + mrow.appendChild(placeholder); + } else { + mrow.appendChild(fragment); + } + + 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/omml-to-mathml.test.ts b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts index ea75cd9e55..c5a1345725 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 @@ -3620,3 +3620,312 @@ describe('m:groupChr converter', () => { }); }); }); + +describe('m:m converter', () => { + it('converts 2x2 matrix to with and ', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:m', + elements: [ + { + name: 'm:mr', + elements: [ + { + 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' }] }] }], + }, + ], + }, + { + name: 'm:mr', + elements: [ + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'c' }] }] }], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'd' }] }] }], + }, + ], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const mtable = result!.querySelector('mtable'); + expect(mtable).not.toBeNull(); + const rows = mtable!.querySelectorAll('mtr'); + expect(rows.length).toBe(2); + const cells = mtable!.querySelectorAll('mtd'); + expect(cells.length).toBe(4); + expect(cells[0]!.textContent).toBe('a'); + expect(cells[1]!.textContent).toBe('b'); + expect(cells[2]!.textContent).toBe('c'); + expect(cells[3]!.textContent).toBe('d'); + }); + + it('returns null for empty matrix', () => { + const omml = { + name: 'm:oMath', + elements: [{ name: 'm:m', elements: [] }], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).toBeNull(); + }); + + it('converts 1x3 row vector', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:m', + elements: [ + { + name: 'm:mr', + elements: [ + { + name: 'm:e', + elements: [{ 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: '2' }] }] }], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '3' }] }] }], + }, + ], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + const mtable = result!.querySelector('mtable'); + expect(mtable).not.toBeNull(); + const rows = mtable!.querySelectorAll('mtr'); + expect(rows.length).toBe(1); + const cells = mtable!.querySelectorAll('mtd'); + expect(cells.length).toBe(3); + }); + + it('wraps each cell content in inside ', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:m', + elements: [ + { + name: 'm:mr', + 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: 'y' }] }] }, + ], + }, + ], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + const mtd = result!.querySelector('mtd'); + expect(mtd).not.toBeNull(); + // Cell content sits under an , not as direct siblings. + expect(mtd!.children.length).toBe(1); + expect(mtd!.firstElementChild!.localName).toBe('mrow'); + expect(mtd!.textContent).toBe('x+y'); + }); + + it('preserves nested math objects in cells (fraction, superscript)', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:m', + elements: [ + { + name: 'm:mr', + 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' }] }] }, + ], + }, + ], + }, + ], + }, + { + name: 'm:e', + elements: [ + { + name: 'm:sSup', + elements: [ + { + name: 'm:e', + elements: [ + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'z' }] }] }, + ], + }, + { + name: 'm:sup', + elements: [ + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '2' }] }] }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + const mtable = result!.querySelector('mtable'); + expect(mtable!.querySelector('mtd mfrac')).not.toBeNull(); + expect(mtable!.querySelector('mtd msup')).not.toBeNull(); + }); + + it('renders a placeholder in empty cells by default (§22.1.2.83 plcHide="0")', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:m', + elements: [ + { + name: 'm:mr', + elements: [ + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }], + }, + { name: 'm:e' }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'c' }] }] }], + }, + ], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + const cells = result!.querySelectorAll('mtd'); + expect(cells.length).toBe(3); + expect(cells[0]!.textContent).toBe('a'); + expect(cells[1]!.textContent).toBe('\u25A1'); + expect(cells[2]!.textContent).toBe('c'); + }); + + it('hides empty-cell placeholders when m:plcHide is set (§22.1.2.83)', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:m', + elements: [ + { name: 'm:mPr', elements: [{ name: 'm:plcHide', attributes: { 'm:val': '1' } }] }, + { + name: 'm:mr', + elements: [ + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }], + }, + { name: 'm:e' }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'c' }] }] }], + }, + ], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + const cells = result!.querySelectorAll('mtd'); + expect(cells.length).toBe(3); + expect(cells[1]!.textContent).toBe(''); + }); + + it('ignores m:mPr properties element', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:m', + elements: [ + { + name: 'm:mPr', + elements: [ + { + name: 'm:mcs', + elements: [ + { + name: 'm:mc', + elements: [{ name: 'm:mcPr', elements: [{ name: 'm:count', attributes: { 'm:val': '2' } }] }], + }, + ], + }, + ], + }, + { + name: 'm:mr', + elements: [ + { + 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); + const mtable = result!.querySelector('mtable'); + expect(mtable).not.toBeNull(); + const cells = mtable!.querySelectorAll('mtd'); + expect(cells.length).toBe(2); + expect(mtable!.textContent).toBe('ab'); + }); +}); 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 f959e9054b..e2f8f016f9 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 @@ -28,6 +28,7 @@ import { convertNary, convertPhantom, convertGroupCharacter, + convertMatrix, } from './converters/index.js'; export const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; @@ -56,6 +57,7 @@ const MATH_OBJECT_REGISTRY: Record = { 'm:func': convertFunction, // Function apply (sin, cos, log, etc.) 'm:limLow': convertLowerLimit, // Lower limit (e.g., lim) 'm:limUpp': convertUpperLimit, // Upper limit + 'm:m': convertMatrix, // Matrix (grid of elements) 'm:nary': convertNary, // N-ary operator (integral, summation, product) 'm:phant': convertPhantom, // Phantom (invisible spacing placeholder) 'm:rad': convertRadical, // Radical (square root, nth root) @@ -68,7 +70,6 @@ const MATH_OBJECT_REGISTRY: Record = { 'm:borderBox': null, // Border box (border around math content) 'm:box': null, // Box (invisible grouping container) 'm:groupChr': convertGroupCharacter, // Group character (overbrace, underbrace) - 'm:m': null, // Matrix (grid of elements) }; /** OMML argument/container elements that wrap children in . */ diff --git a/tests/behavior/tests/importing/fixtures/math-matrix-tests.docx b/tests/behavior/tests/importing/fixtures/math-matrix-tests.docx new file mode 100644 index 0000000000000000000000000000000000000000..7070652dbe18e6554da2176400f274ee69cd3fd6 GIT binary patch literal 11873 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 { expect(v11.style).toBeNull(); }); }); + +test.describe('m:m (matrix) rendering', () => { + // Fixture contains 10 matrix shapes (§22.1.2.60): + // 0: plain 2x2, no delimiter + // 1: 2x2 wrapped in [ ] delimiter + // 2: 2x2 in ( ) with nested m:f and m:sSup in cells + // 3: 2x2 in [ ] with multi-run cells (a+b, c-d, …) + // 4: 2x3 in [ ] with empty gaps + // 5: 2x3 with per-column m:mcJc=left/center/right (no delimiter) + // 6: 1x3 row vector in [ ] + // 7: 3x1 column vector in [ ] + // 8: 1x2 containing literal '&' in text (non-spec edge case) + // 9: inline matrix with m:baseJc=top + + test('imports all 10 matrix equations from docx', async ({ superdoc }) => { + await superdoc.loadDocument(MATRIX_DOC); + await superdoc.waitForStable(); + const mathCount = await superdoc.page.evaluate(() => document.querySelectorAll('math').length); + expect(mathCount).toBe(10); + const tableCount = await superdoc.page.evaluate(() => document.querySelectorAll('mtable').length); + expect(tableCount).toBe(10); + }); + + test('wraps every in for cell content grouping', async ({ superdoc }) => { + await superdoc.loadDocument(MATRIX_DOC); + await superdoc.waitForStable(); + const allWrapped = await superdoc.page.evaluate(() => { + const tds = Array.from(document.querySelectorAll('mtd')); + return tds.every((td) => td.children.length === 1 && td.firstElementChild?.localName === 'mrow'); + }); + expect(allWrapped).toBe(true); + }); + + test('renders matrix wrapped in m:d with delimiter operators around the mtable', async ({ superdoc }) => { + await superdoc.loadDocument(MATRIX_DOC); + await superdoc.waitForStable(); + // Case 1: [ ] brackets around 2x2. + const result = await superdoc.page.evaluate(() => { + const maths = document.querySelectorAll('math'); + const mtable = maths[1]?.querySelector('mtable'); + const operators = Array.from(maths[1]?.querySelectorAll('mrow > mo') ?? []).map((el) => el.textContent); + return { hasMtable: mtable != null, operators }; + }); + expect(result.hasMtable).toBe(true); + expect(result.operators).toEqual(['[', ']']); + }); + + test('preserves nested math (mfrac, msup) inside matrix cells', async ({ superdoc }) => { + await superdoc.loadDocument(MATRIX_DOC); + await superdoc.waitForStable(); + const result = await superdoc.page.evaluate(() => { + const maths = document.querySelectorAll('math'); + const m = maths[2]; + return { + hasMfrac: m?.querySelector('mtd mfrac') != null, + hasMsup: m?.querySelector('mtd msup') != null, + }; + }); + expect(result.hasMfrac).toBe(true); + expect(result.hasMsup).toBe(true); + }); + + test('renders empty cells with a U+25A1 placeholder by default (§22.1.2.83)', async ({ superdoc }) => { + await superdoc.loadDocument(MATRIX_DOC); + await superdoc.waitForStable(); + // Case 4: 2x3 with empty cells at (0,1) and (1,0). Expect three columns preserved + // with placeholder glyphs at the gaps. + const result = await superdoc.page.evaluate(() => { + const maths = document.querySelectorAll('math'); + const mtable = maths[4]?.querySelector('mtable'); + const rows = Array.from(mtable?.querySelectorAll('mtr') ?? []); + return rows.map((row) => Array.from(row.querySelectorAll('mtd')).map((td) => td.textContent)); + }); + expect(result).toEqual([ + ['a', '\u25A1', 'c'], + ['\u25A1', 'e', 'f'], + ]); + }); + + test('renders multi-run cell content as siblings inside the cell ', async ({ superdoc }) => { + await superdoc.loadDocument(MATRIX_DOC); + await superdoc.waitForStable(); + // Case 3 bottom row cell 0: x, +, y as three separate runs → mi, mo, mi under mrow. + const result = await superdoc.page.evaluate(() => { + const maths = document.querySelectorAll('math'); + const mtable = maths[3]?.querySelector('mtable'); + const cell = mtable?.querySelectorAll('mtr')[1]?.querySelectorAll('mtd')[0]; + const mrow = cell?.firstElementChild; + return { + mrowTag: mrow?.localName, + childTags: Array.from(mrow?.children ?? []).map((c) => c.localName), + text: mrow?.textContent, + }; + }); + expect(result.mrowTag).toBe('mrow'); + expect(result.childTags).toEqual(['mi', 'mo', 'mi']); + expect(result.text).toBe('x+y'); + }); +});