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
1 change: 1 addition & 0 deletions apps/docs/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ SuperDoc renders, edits, and automates .docx files — in the browser, headless
- Reads and writes OOXML natively — documents stay real .docx files at every step
- 180+ MCP tools covering reading, editing, formatting, comments, tracked changes, and more
- Tracked changes and comments as first-class operations
- Math equations render natively as MathML — no external library needed
- Same document model across browser editor, headless Node, and APIs
- Stateless API for convert, annotate, sign, and verify
- Real-time collaboration with Yjs CRDT
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,38 @@ import type { MathObjectConverter } from '../types.js';
const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
const FUNCTION_APPLY_OPERATOR = '\u2061';

// Boundary elements for the function-name mathvariant walk: every MathML
// element whose children occupy their own semantic slot (base, subscript,
// limit, matrix cell, etc.). When m:fName wraps one of these, the slot
// content carries authored styling per ECMA-376 §22.1.2.111 and must not be
// overwritten. Anything inside these is skipped.
const MATH_VARIANT_BOUNDARY_ELEMENTS = new Set([
'munder',
'mover',
'munderover',
'msub',
'msup',
'msubsup',
'mmultiscripts',
'mfrac',
'msqrt',
'mroot',
'mtable',
'mtr',
'mtd',
]);

function forceNormalMathVariant(root: ParentNode): void {
root.querySelectorAll('mi').forEach((identifier) => {
identifier.setAttribute('mathvariant', 'normal');
});
// Array.from is required here: HTMLCollection is not iterable under the
// default DOM lib (needs `dom.iterable`), so `for…of root.children` fails
// type-check.
for (const child of Array.from(root.children)) {
if (MATH_VARIANT_BOUNDARY_ELEMENTS.has(child.localName)) continue;
if (child.localName === 'mi' && !child.hasAttribute('mathvariant')) {
child.setAttribute('mathvariant', 'normal');
}
forceNormalMathVariant(child);
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -861,6 +861,43 @@ describe('m:func converter', () => {
expect(mis[0]!.textContent).toBe('sin');
expect(mis[1]!.textContent).toBe('cos');
});

it('preserves explicit m:sty=i on function-name runs', () => {
// SD-2538 preserve branch: forceNormalMathVariant must NOT overwrite
// an existing mathvariant. When Word marks a function-name run with
// m:sty="i", convertMathRun already set mathvariant="italic" — the
// function-apply pass must leave it alone.
const omml = {
name: 'm:oMath',
elements: [
{
name: 'm:func',
elements: [
{
name: 'm:fName',
elements: [
{
name: 'm:r',
elements: [
{ name: 'm:rPr', elements: [{ name: 'm:sty', attributes: { 'm:val': 'i' } }] },
{ name: 'm:t', elements: [{ type: 'text', text: 'L' }] },
],
},
],
},
{
name: 'm:e',
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
},
],
},
],
};
const result = convertOmmlToMathml(omml, doc);
const nameMi = result!.querySelectorAll('mi')[0];
expect(nameMi!.textContent).toBe('L');
expect(nameMi!.getAttribute('mathvariant')).toBe('italic');
});
});

describe('m:rad converter', () => {
Expand Down Expand Up @@ -2296,8 +2333,18 @@ describe('m:limLow converter', () => {
const munder = result!.querySelector('munder');
expect(munder).not.toBeNull();
expect(munder!.children.length).toBe(2);
expect(munder!.children[0]!.textContent).toBe('lim');
expect(munder!.children[1]!.textContent).toBe('n');

// Base: "lim" — upright via m:sty=p on the run.
const limMi = munder!.children[0]!.querySelector('mi');
expect(limMi!.textContent).toBe('lim');
expect(limMi!.getAttribute('mathvariant')).toBe('normal');

// Limit expression: "n" — must stay italic (no mathvariant attribute set).
// SD-2538 regression: convertFunction used to recurse into the <munder>
// and force mathvariant="normal" on every <mi>, including this one.
const nMi = munder!.children[1]!.querySelector('mi');
expect(nMi!.textContent).toBe('n');
expect(nMi!.getAttribute('mathvariant')).toBeNull();
});
});

Expand Down Expand Up @@ -2565,8 +2612,17 @@ describe('m:limUpp converter', () => {
const mover = result!.querySelector('mover');
expect(mover).not.toBeNull();
expect(mover!.children.length).toBe(2);
expect(mover!.children[0]!.textContent).toBe('lim');
expect(mover!.children[1]!.textContent).toBe('x');

// Base: "lim" — upright via m:sty=p. Limit variable "x" — italic default.
// Symmetric to the m:limLow-in-m:func case; pins the 'mover' entry of
// MATH_VARIANT_BOUNDARY_ELEMENTS.
const limMi = mover!.children[0]!.querySelector('mi');
expect(limMi!.textContent).toBe('lim');
expect(limMi!.getAttribute('mathvariant')).toBe('normal');

const xMi = mover!.children[1]!.querySelector('mi');
expect(xMi!.textContent).toBe('x');
expect(xMi!.getAttribute('mathvariant')).toBeNull();
});
});

Expand Down
42 changes: 42 additions & 0 deletions tests/behavior/tests/importing/math-equations.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,48 @@ test.describe('m:limLow / m:limUpp (limit object) rendering', () => {
expect(counts.sup).toBe(1);
});

test('keeps limit variables italic when m:limLow/m:limUpp is wrapped in m:func (SD-2538)', async ({ superdoc }) => {
await superdoc.loadDocument(LIMIT_DOC);
await superdoc.waitForStable();

// ECMA-376 §22.1.2.111: m:r without m:sty defaults to italic. Word's own
// OMML2MML.xsl emits <mi>n</mi> (no mathvariant) for limit variables.
//
// Fixture math-limit-tests.docx has 6 m:func>m:fName wrappers: 5 around
// m:limLow (→ <munder>) and 1 around m:limUpp (→ <mover>). The function
// bases are lim×4, max×1, sup×1 — all carry m:sty=p and must render
// upright. The limit expression runs have no m:sty and must stay italic.
const FUNCTION_BASES = ['lim', 'max', 'sup'];
const variantCheck = await superdoc.page.evaluate((bases) => {
const collect = (tag: string) =>
Array.from(document.querySelectorAll(tag))
.map((el) => {
const baseMi = el.children[0]?.querySelector('mi');
const limitEl = el.children[1];
return {
base: baseMi?.textContent ?? '',
baseVariant: baseMi?.getAttribute('mathvariant') ?? null,
limitVariants: Array.from(limitEl?.querySelectorAll('mi') ?? []).map((mi) =>
mi.getAttribute('mathvariant'),
),
};
})
.filter((entry) => bases.includes(entry.base));
return { munder: collect('munder'), mover: collect('mover') };
}, FUNCTION_BASES);

// Exact counts pin against a regression that drops a case silently.
expect(variantCheck.munder).toHaveLength(5); // 3×lim + 1×max + 1×sup
expect(variantCheck.mover).toHaveLength(1); // 1×lim (case: m:limUpp in func)

for (const entry of [...variantCheck.munder, ...variantCheck.mover]) {
expect(entry.baseVariant).toBe('normal');
for (const limVariant of entry.limitVariants) {
expect(limVariant).toBeNull();
}
}
});

test('preserves nested <msub> inside <munder> (case 8: lim of x_i → 0)', async ({ superdoc }) => {
await superdoc.loadDocument(LIMIT_DOC);
await superdoc.waitForStable();
Expand Down
Loading