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
@@ -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 <maligngroup>/<malignmark>, 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 <mtable>.
*
* OMML structure:
* m:eqArr → m:eqArrPr (optional), m:e* (one element per row)
*
* MathML output:
* <mtable columnalign="left">
* <mtr> <mtd> <mrow>row-content</mrow> </mtd> </mtr>
* ...
* </mtable>
*
* 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;
};
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -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 <mtable>', () => {
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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
convertSubSuperscript,
convertAccent,
convertPreSubSuperscript,
convertEquationArray,
convertRadical,
convertLowerLimit,
convertUpperLimit,
Expand All @@ -47,6 +48,7 @@ const MATH_OBJECT_REGISTRY: Record<string, MathObjectConverter | null> = {
'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)
Expand All @@ -60,7 +62,6 @@ const MATH_OBJECT_REGISTRY: Record<string, MathObjectConverter | null> = {
// ── 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)
Expand Down
Binary file not shown.
107 changes: 107 additions & 0 deletions tests/behavior/tests/importing/math-equations.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 <mtable columnalign="left">', 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 <mfrac> 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 <msub> 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([]);
});
});
Loading