Skip to content

Commit 47777f5

Browse files
feat(math): implement m:m matrix converter (closes #2601) (#2753)
Convert the OMML matrix element (m:m) to MathML <mtable>. Each m:mr row becomes an <mtr> and each m:e cell becomes an <mtd> wrapping an <mrow> 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 <mtable> columnalign. Spec: ECMA-376 §22.1.2.60 Co-authored-by: Caio Pizzol <caio@harbourshare.com>
1 parent 5485d54 commit 47777f5

6 files changed

Lines changed: 485 additions & 1 deletion

File tree

packages/layout-engine/painters/dom/src/features/math/converters/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,4 @@ export { convertUpperLimit } from './upper-limit.js';
2323
export { convertNary } from './nary.js';
2424
export { convertPhantom } from './phantom.js';
2525
export { convertGroupCharacter } from './group-character.js';
26+
export { convertMatrix } from './matrix.js';
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import type { MathObjectConverter, OmmlJsonNode } from '../types.js';
2+
3+
const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
4+
5+
/** Visual placeholder for empty matrix cells when m:plcHide is off (§22.1.2.83). */
6+
const EMPTY_CELL_PLACEHOLDER = '\u25A1'; // WHITE SQUARE
7+
8+
/** True when the given m:plcHide element expresses "hide placeholders". */
9+
function isPlaceholderHidden(plcHide: OmmlJsonNode | undefined): boolean {
10+
if (!plcHide) return false;
11+
const val = plcHide.attributes?.['m:val'];
12+
// Per §22.1.2.83: presence without @m:val means placeholders are hidden.
13+
if (val === undefined) return true;
14+
return val === '1' || val === 'true';
15+
}
16+
17+
/**
18+
* Convert m:m (matrix) to MathML <mtable>.
19+
*
20+
* OMML structure:
21+
* m:m → m:mPr (optional: mcs/mcJc/baseJc/plcHide — only plcHide applied), m:mr* (rows)
22+
* m:mr → m:e* (cells; empty m:e creates a positional gap per §22.1.2.32)
23+
*
24+
* MathML output:
25+
* <mtable>
26+
* <mtr>
27+
* <mtd> <mrow>cell-content</mrow> </mtd>
28+
* ...
29+
* </mtr>
30+
* ...
31+
* </mtable>
32+
*
33+
* Empty cells render a U+25A1 placeholder by default (§22.1.2.83 plcHide="0").
34+
* When m:plcHide is present with val "1"/"true" or no val, the placeholder is suppressed.
35+
*
36+
* @spec ECMA-376 §22.1.2.60
37+
*/
38+
export const convertMatrix: MathObjectConverter = (node, doc, convertChildren) => {
39+
const elements = node.elements ?? [];
40+
const rows = elements.filter((e) => e.name === 'm:mr');
41+
42+
const matrixProps = elements.find((e) => e.name === 'm:mPr');
43+
const plcHide = matrixProps?.elements?.find((e) => e.name === 'm:plcHide');
44+
const hidePlaceholders = isPlaceholderHidden(plcHide);
45+
46+
const mtable = doc.createElementNS(MATHML_NS, 'mtable');
47+
48+
for (const row of rows) {
49+
const mtr = doc.createElementNS(MATHML_NS, 'mtr');
50+
const cells = row.elements?.filter((e) => e.name === 'm:e') ?? [];
51+
52+
for (const cell of cells) {
53+
const mtd = doc.createElementNS(MATHML_NS, 'mtd');
54+
const mrow = doc.createElementNS(MATHML_NS, 'mrow');
55+
const fragment = convertChildren(cell.elements ?? []);
56+
57+
if (fragment.childNodes.length === 0 && !hidePlaceholders) {
58+
const placeholder = doc.createElementNS(MATHML_NS, 'mi');
59+
placeholder.textContent = EMPTY_CELL_PLACEHOLDER;
60+
mrow.appendChild(placeholder);
61+
} else {
62+
mrow.appendChild(fragment);
63+
}
64+
65+
mtd.appendChild(mrow);
66+
mtr.appendChild(mtd);
67+
}
68+
69+
mtable.appendChild(mtr);
70+
}
71+
72+
return mtable.childNodes.length > 0 ? mtable : null;
73+
};

packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3620,3 +3620,312 @@ describe('m:groupChr converter', () => {
36203620
});
36213621
});
36223622
});
3623+
3624+
describe('m:m converter', () => {
3625+
it('converts 2x2 matrix to <mtable> with <mtr> and <mtd>', () => {
3626+
const omml = {
3627+
name: 'm:oMath',
3628+
elements: [
3629+
{
3630+
name: 'm:m',
3631+
elements: [
3632+
{
3633+
name: 'm:mr',
3634+
elements: [
3635+
{
3636+
name: 'm:e',
3637+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }],
3638+
},
3639+
{
3640+
name: 'm:e',
3641+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'b' }] }] }],
3642+
},
3643+
],
3644+
},
3645+
{
3646+
name: 'm:mr',
3647+
elements: [
3648+
{
3649+
name: 'm:e',
3650+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'c' }] }] }],
3651+
},
3652+
{
3653+
name: 'm:e',
3654+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'd' }] }] }],
3655+
},
3656+
],
3657+
},
3658+
],
3659+
},
3660+
],
3661+
};
3662+
const result = convertOmmlToMathml(omml, doc);
3663+
expect(result).not.toBeNull();
3664+
const mtable = result!.querySelector('mtable');
3665+
expect(mtable).not.toBeNull();
3666+
const rows = mtable!.querySelectorAll('mtr');
3667+
expect(rows.length).toBe(2);
3668+
const cells = mtable!.querySelectorAll('mtd');
3669+
expect(cells.length).toBe(4);
3670+
expect(cells[0]!.textContent).toBe('a');
3671+
expect(cells[1]!.textContent).toBe('b');
3672+
expect(cells[2]!.textContent).toBe('c');
3673+
expect(cells[3]!.textContent).toBe('d');
3674+
});
3675+
3676+
it('returns null for empty matrix', () => {
3677+
const omml = {
3678+
name: 'm:oMath',
3679+
elements: [{ name: 'm:m', elements: [] }],
3680+
};
3681+
const result = convertOmmlToMathml(omml, doc);
3682+
expect(result).toBeNull();
3683+
});
3684+
3685+
it('converts 1x3 row vector', () => {
3686+
const omml = {
3687+
name: 'm:oMath',
3688+
elements: [
3689+
{
3690+
name: 'm:m',
3691+
elements: [
3692+
{
3693+
name: 'm:mr',
3694+
elements: [
3695+
{
3696+
name: 'm:e',
3697+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '1' }] }] }],
3698+
},
3699+
{
3700+
name: 'm:e',
3701+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '2' }] }] }],
3702+
},
3703+
{
3704+
name: 'm:e',
3705+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '3' }] }] }],
3706+
},
3707+
],
3708+
},
3709+
],
3710+
},
3711+
],
3712+
};
3713+
const result = convertOmmlToMathml(omml, doc);
3714+
const mtable = result!.querySelector('mtable');
3715+
expect(mtable).not.toBeNull();
3716+
const rows = mtable!.querySelectorAll('mtr');
3717+
expect(rows.length).toBe(1);
3718+
const cells = mtable!.querySelectorAll('mtd');
3719+
expect(cells.length).toBe(3);
3720+
});
3721+
3722+
it('wraps each cell content in <mrow> inside <mtd>', () => {
3723+
const omml = {
3724+
name: 'm:oMath',
3725+
elements: [
3726+
{
3727+
name: 'm:m',
3728+
elements: [
3729+
{
3730+
name: 'm:mr',
3731+
elements: [
3732+
{
3733+
name: 'm:e',
3734+
elements: [
3735+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] },
3736+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '+' }] }] },
3737+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'y' }] }] },
3738+
],
3739+
},
3740+
],
3741+
},
3742+
],
3743+
},
3744+
],
3745+
};
3746+
const result = convertOmmlToMathml(omml, doc);
3747+
const mtd = result!.querySelector('mtd');
3748+
expect(mtd).not.toBeNull();
3749+
// Cell content sits under an <mrow>, not as direct <mtd> siblings.
3750+
expect(mtd!.children.length).toBe(1);
3751+
expect(mtd!.firstElementChild!.localName).toBe('mrow');
3752+
expect(mtd!.textContent).toBe('x+y');
3753+
});
3754+
3755+
it('preserves nested math objects in cells (fraction, superscript)', () => {
3756+
const omml = {
3757+
name: 'm:oMath',
3758+
elements: [
3759+
{
3760+
name: 'm:m',
3761+
elements: [
3762+
{
3763+
name: 'm:mr',
3764+
elements: [
3765+
{
3766+
name: 'm:e',
3767+
elements: [
3768+
{
3769+
name: 'm:f',
3770+
elements: [
3771+
{
3772+
name: 'm:num',
3773+
elements: [
3774+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] },
3775+
],
3776+
},
3777+
{
3778+
name: 'm:den',
3779+
elements: [
3780+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'b' }] }] },
3781+
],
3782+
},
3783+
],
3784+
},
3785+
],
3786+
},
3787+
{
3788+
name: 'm:e',
3789+
elements: [
3790+
{
3791+
name: 'm:sSup',
3792+
elements: [
3793+
{
3794+
name: 'm:e',
3795+
elements: [
3796+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'z' }] }] },
3797+
],
3798+
},
3799+
{
3800+
name: 'm:sup',
3801+
elements: [
3802+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '2' }] }] },
3803+
],
3804+
},
3805+
],
3806+
},
3807+
],
3808+
},
3809+
],
3810+
},
3811+
],
3812+
},
3813+
],
3814+
};
3815+
const result = convertOmmlToMathml(omml, doc);
3816+
const mtable = result!.querySelector('mtable');
3817+
expect(mtable!.querySelector('mtd mfrac')).not.toBeNull();
3818+
expect(mtable!.querySelector('mtd msup')).not.toBeNull();
3819+
});
3820+
3821+
it('renders a placeholder in empty <m:e> cells by default (§22.1.2.83 plcHide="0")', () => {
3822+
const omml = {
3823+
name: 'm:oMath',
3824+
elements: [
3825+
{
3826+
name: 'm:m',
3827+
elements: [
3828+
{
3829+
name: 'm:mr',
3830+
elements: [
3831+
{
3832+
name: 'm:e',
3833+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }],
3834+
},
3835+
{ name: 'm:e' },
3836+
{
3837+
name: 'm:e',
3838+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'c' }] }] }],
3839+
},
3840+
],
3841+
},
3842+
],
3843+
},
3844+
],
3845+
};
3846+
const result = convertOmmlToMathml(omml, doc);
3847+
const cells = result!.querySelectorAll('mtd');
3848+
expect(cells.length).toBe(3);
3849+
expect(cells[0]!.textContent).toBe('a');
3850+
expect(cells[1]!.textContent).toBe('\u25A1');
3851+
expect(cells[2]!.textContent).toBe('c');
3852+
});
3853+
3854+
it('hides empty-cell placeholders when m:plcHide is set (§22.1.2.83)', () => {
3855+
const omml = {
3856+
name: 'm:oMath',
3857+
elements: [
3858+
{
3859+
name: 'm:m',
3860+
elements: [
3861+
{ name: 'm:mPr', elements: [{ name: 'm:plcHide', attributes: { 'm:val': '1' } }] },
3862+
{
3863+
name: 'm:mr',
3864+
elements: [
3865+
{
3866+
name: 'm:e',
3867+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }],
3868+
},
3869+
{ name: 'm:e' },
3870+
{
3871+
name: 'm:e',
3872+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'c' }] }] }],
3873+
},
3874+
],
3875+
},
3876+
],
3877+
},
3878+
],
3879+
};
3880+
const result = convertOmmlToMathml(omml, doc);
3881+
const cells = result!.querySelectorAll('mtd');
3882+
expect(cells.length).toBe(3);
3883+
expect(cells[1]!.textContent).toBe('');
3884+
});
3885+
3886+
it('ignores m:mPr properties element', () => {
3887+
const omml = {
3888+
name: 'm:oMath',
3889+
elements: [
3890+
{
3891+
name: 'm:m',
3892+
elements: [
3893+
{
3894+
name: 'm:mPr',
3895+
elements: [
3896+
{
3897+
name: 'm:mcs',
3898+
elements: [
3899+
{
3900+
name: 'm:mc',
3901+
elements: [{ name: 'm:mcPr', elements: [{ name: 'm:count', attributes: { 'm:val': '2' } }] }],
3902+
},
3903+
],
3904+
},
3905+
],
3906+
},
3907+
{
3908+
name: 'm:mr',
3909+
elements: [
3910+
{
3911+
name: 'm:e',
3912+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }],
3913+
},
3914+
{
3915+
name: 'm:e',
3916+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'b' }] }] }],
3917+
},
3918+
],
3919+
},
3920+
],
3921+
},
3922+
],
3923+
};
3924+
const result = convertOmmlToMathml(omml, doc);
3925+
const mtable = result!.querySelector('mtable');
3926+
expect(mtable).not.toBeNull();
3927+
const cells = mtable!.querySelectorAll('mtd');
3928+
expect(cells.length).toBe(2);
3929+
expect(mtable!.textContent).toBe('ab');
3930+
});
3931+
});

0 commit comments

Comments
 (0)