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 0000000000..7070652dbe Binary files /dev/null and b/tests/behavior/tests/importing/fixtures/math-matrix-tests.docx differ diff --git a/tests/behavior/tests/importing/math-equations.spec.ts b/tests/behavior/tests/importing/math-equations.spec.ts index 9cef64f904..59384714bf 100644 --- a/tests/behavior/tests/importing/math-equations.spec.ts +++ b/tests/behavior/tests/importing/math-equations.spec.ts @@ -13,6 +13,7 @@ const EQARR_DOC = path.resolve(__dirname, 'fixtures/math-eqarr-tests.docx'); const NARY_DOC = path.resolve(__dirname, 'fixtures/math-nary-tests.docx'); const PHANTOM_DOC = path.resolve(__dirname, 'fixtures/math-phantom-tests.docx'); const GROUPCHR_DOC = path.resolve(__dirname, 'fixtures/math-groupchr-tests.docx'); +const MATRIX_DOC = path.resolve(__dirname, 'fixtures/math-matrix-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. @@ -1404,3 +1405,102 @@ test.describe('m:groupChr (group character) rendering', () => { 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'); + }); +});