Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
@@ -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 <mtable>.
*
* 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:
* <mtable>
* <mtr>
* <mtd> <mrow>cell-content</mrow> </mtd>
* ...
* </mtr>
* ...
* </mtable>
*
* 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;
};
Original file line number Diff line number Diff line change
Expand Up @@ -3620,3 +3620,312 @@ describe('m:groupChr converter', () => {
});
});
});

describe('m:m converter', () => {
it('converts 2x2 matrix to <mtable> with <mtr> and <mtd>', () => {
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 <mrow> inside <mtd>', () => {
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 <mrow>, not as direct <mtd> 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 <m:e> 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');
});
});
Loading
Loading