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 @@ -11,3 +11,4 @@ export { convertFraction } from './fraction.js';
export { convertBar } from './bar.js';
export { convertSubscript } from './subscript.js';
export { convertSuperscript } from './superscript.js';
export { convertSubSuperscript } from './sub-superscript.js';
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { MathObjectConverter } from '../types.js';

const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';

/**
* Convert m:sSubSup (sub-superscript) to MathML <msubsup>.
*
* OMML structure:
* m:sSubSup → m:sSubSupPr (optional), m:e (base), m:sub (subscript), m:sup (superscript)
*
* MathML output:
* <msubsup> <mrow>base</mrow> <mrow>sub</mrow> <mrow>sup</mrow> </msubsup>
*
* @spec ECMA-376 §22.1.2.103
*/
export const convertSubSuperscript: MathObjectConverter = (node, doc, convertChildren) => {
const elements = node.elements ?? [];
const base = elements.find((e) => e.name === 'm:e');
const sub = elements.find((e) => e.name === 'm:sub');
const sup = elements.find((e) => e.name === 'm:sup');

const msubsup = doc.createElementNS(MATHML_NS, 'msubsup');

const baseRow = doc.createElementNS(MATHML_NS, 'mrow');
baseRow.appendChild(convertChildren(base?.elements ?? []));
msubsup.appendChild(baseRow);

const subRow = doc.createElementNS(MATHML_NS, 'mrow');
subRow.appendChild(convertChildren(sub?.elements ?? []));
msubsup.appendChild(subRow);

const supRow = doc.createElementNS(MATHML_NS, 'mrow');
supRow.appendChild(convertChildren(sup?.elements ?? []));
msubsup.appendChild(supRow);

return msubsup;
};
Original file line number Diff line number Diff line change
Expand Up @@ -551,3 +551,136 @@ describe('m:sSup converter', () => {
expect(msup!.children[0]!.textContent).toBe('x');
});
});

describe('m:sSubSup converter', () => {
it('converts m:sSubSup to <msubsup> with base, subscript, and superscript', () => {
const omml = {
name: 'm:oMath',
elements: [
{
name: 'm:sSubSup',
elements: [
{
name: 'm:e',
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
},
{
name: 'm:sub',
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'i' }] }] }],
},
{
name: 'm:sup',
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '2' }] }] }],
},
],
},
],
};
const result = convertOmmlToMathml(omml, doc);
expect(result).not.toBeNull();
const msubsup = result!.querySelector('msubsup');
expect(msubsup).not.toBeNull();
expect(msubsup!.children.length).toBe(3);
expect(msubsup!.children[0]!.textContent).toBe('x');
expect(msubsup!.children[1]!.textContent).toBe('i');
expect(msubsup!.children[2]!.textContent).toBe('2');
});

it('ignores m:sSubSupPr properties element', () => {
const omml = {
name: 'm:oMath',
elements: [
{
name: 'm:sSubSup',
elements: [
{ name: 'm:sSubSupPr', elements: [{ name: 'm:alnScr' }] },
{
name: 'm:e',
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }],
},
{
name: 'm:sub',
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'n' }] }] }],
},
{
name: 'm:sup',
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'k' }] }] }],
},
],
},
],
};
const result = convertOmmlToMathml(omml, doc);
expect(result).not.toBeNull();
const msubsup = result!.querySelector('msubsup');
expect(msubsup).not.toBeNull();
expect(msubsup!.children.length).toBe(3);
expect(msubsup!.children[0]!.textContent).toBe('a');
expect(msubsup!.children[1]!.textContent).toBe('n');
expect(msubsup!.children[2]!.textContent).toBe('k');
});

it('wraps multi-part operands in <mrow> for valid arity', () => {
// x_{n+1}^{k-1} — both sub and sup have multiple runs
const omml = {
name: 'm:oMath',
elements: [
{
name: 'm:sSubSup',
elements: [
{
name: 'm:e',
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
},
{
name: 'm:sub',
elements: [
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'n' }] }] },
{ 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:sup',
elements: [
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'k' }] }] },
{ 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);
expect(result).not.toBeNull();
const msubsup = result!.querySelector('msubsup');
expect(msubsup).not.toBeNull();
expect(msubsup!.children.length).toBe(3);
expect(msubsup!.children[0]!.textContent).toBe('x');
expect(msubsup!.children[1]!.textContent).toBe('n+1');
expect(msubsup!.children[2]!.textContent).toBe('k-1');
});

it('handles missing m:sub and m:sup gracefully', () => {
const omml = {
name: 'm:oMath',
elements: [
{
name: 'm:sSubSup',
elements: [
{
name: 'm:e',
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
},
],
},
],
};
const result = convertOmmlToMathml(omml, doc);
expect(result).not.toBeNull();
const msubsup = result!.querySelector('msubsup');
expect(msubsup).not.toBeNull();
expect(msubsup!.children[0]!.textContent).toBe('x');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
convertBar,
convertSubscript,
convertSuperscript,
convertSubSuperscript,
} from './converters/index.js';

export const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
Expand All @@ -40,6 +41,7 @@ const MATH_OBJECT_REGISTRY: Record<string, MathObjectConverter | null> = {
'm:f': convertFraction, // Fraction (numerator/denominator)
'm:sSub': convertSubscript, // Subscript
'm:sSup': convertSuperscript, // Superscript
'm:sSubSup': convertSubSuperscript, // Sub-superscript (both)

// ── Not yet implemented (community contributions welcome) ────────────────
'm:acc': null, // Accent (diacritical mark above base)
Expand All @@ -56,7 +58,6 @@ const MATH_OBJECT_REGISTRY: Record<string, MathObjectConverter | null> = {
'm:phant': null, // Phantom (invisible spacing placeholder)
'm:rad': null, // Radical (square root, nth root)
'm:sPre': null, // Pre-sub-superscript (left of base)
'm:sSubSup': null, // Sub-superscript (both)
};

/** OMML argument/container elements that wrap children in <mrow>. */
Expand Down
25 changes: 24 additions & 1 deletion tests/behavior/tests/importing/math-equations.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,34 @@ test.describe('math equation import and rendering', () => {
}
});

test('renders sub-superscript as <msubsup> with base, subscript, and superscript', async ({ superdoc }) => {
await superdoc.loadDocument(ALL_OBJECTS_DOC);
await superdoc.waitForStable();

// The test doc has x_i^2 — should render as <msubsup> with 3 children
const subSupData = await superdoc.page.evaluate(() => {
const msubsup = document.querySelector('msubsup');
if (!msubsup) return null;
return {
childCount: msubsup.children.length,
base: msubsup.children[0]?.textContent,
subscript: msubsup.children[1]?.textContent,
superscript: msubsup.children[2]?.textContent,
};
});

expect(subSupData).not.toBeNull();
expect(subSupData!.childCount).toBe(3);
expect(subSupData!.base).toBe('x');
expect(subSupData!.subscript).toBe('i');
expect(subSupData!.superscript).toBe('2');
});

test('math text content is preserved for unimplemented objects', async ({ superdoc }) => {
await superdoc.loadDocument(ALL_OBJECTS_DOC);
await superdoc.waitForStable();

// Unimplemented math objects (e.g., superscript, radical) should still
// Unimplemented math objects (e.g., radical, delimiter) should still
// have their text content accessible in the PM document
const mathTexts = await superdoc.page.evaluate(() => {
const view = (window as any).editor?.view;
Expand Down
Loading