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 @@ -19,8 +19,14 @@ export const convertFraction: MathObjectConverter = (node, doc, convertChildren)
const den = elements.find((e) => e.name === 'm:den');

const frac = doc.createElementNS(MATHML_NS, 'mfrac');
frac.appendChild(convertChildren(num?.elements ?? []));
frac.appendChild(convertChildren(den?.elements ?? []));

const numRow = doc.createElementNS(MATHML_NS, 'mrow');
numRow.appendChild(convertChildren(num?.elements ?? []));
frac.appendChild(numRow);

const denRow = doc.createElementNS(MATHML_NS, 'mrow');
denRow.appendChild(convertChildren(den?.elements ?? []));
frac.appendChild(denRow);

return frac;
};
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@
export { convertMathRun } from './math-run.js';
export { convertFraction } from './fraction.js';
export { convertBar } from './bar.js';
export { convertSubscript } from './subscript.js';
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { MathObjectConverter } from '../types.js';

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

/**
* Convert m:sSub (subscript) to MathML <msub>.
*
* OMML structure:
* m:sSub → m:sSubPr (optional), m:e (base), m:sub (subscript)
*
* MathML output:
* <msub> <mrow>base</mrow> <mrow>sub</mrow> </msub>
*
* @spec ECMA-376 §22.1.2.101
*/
export const convertSubscript: 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 msub = doc.createElementNS(MATHML_NS, 'msub');

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

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

return msub;
};
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,7 @@ describe('convertOmmlToMathml', () => {
expect(result!.textContent).toBe('z');
});

it('handles unimplemented math objects by extracting child content', () => {
// m:f (fraction) is not yet implemented — should fall back to rendering children
it('converts m:f (fraction) to <mfrac> with numerator and denominator', () => {
const omml = {
name: 'm:oMath',
elements: [
Expand Down Expand Up @@ -151,6 +150,44 @@ describe('convertOmmlToMathml', () => {
expect(mfrac!.children[1]!.textContent).toBe('b');
});

it('wraps multi-part fraction operands in <mrow> for valid arity', () => {
// (a+b)/(c+d) — both numerator and denominator have multiple runs
const omml = {
name: 'm:oMath',
elements: [
{
name: 'm:f',
elements: [
{
name: 'm:num',
elements: [
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] },
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '+' }] }] },
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'b' }] }] },
],
},
{
name: 'm:den',
elements: [
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'c' }] }] },
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '+' }] }] },
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'd' }] }] },
],
},
],
},
],
};
const result = convertOmmlToMathml(omml, doc);
expect(result).not.toBeNull();
const mfrac = result!.querySelector('mfrac');
expect(mfrac).not.toBeNull();
// <mfrac> must have exactly 2 children (num + den), each wrapped in <mrow>
expect(mfrac!.children.length).toBe(2);
expect(mfrac!.children[0]!.textContent).toBe('a+b');
expect(mfrac!.children[1]!.textContent).toBe('c+d');
});

it('sets mathvariant=normal for m:nor (normal text) flag', () => {
const omml = {
name: 'm:oMath',
Expand Down Expand Up @@ -284,3 +321,118 @@ describe('m:bar converter', () => {
expect(mo?.textContent).toBe('\u203E');
});
});

describe('m:sSub converter', () => {
it('converts m:sSub to <msub> with base and subscript', () => {
const omml = {
name: 'm:oMath',
elements: [
{
name: 'm:sSub',
elements: [
{
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: '1' }] }] }],
},
],
},
],
};
const result = convertOmmlToMathml(omml, doc);
expect(result).not.toBeNull();
const msub = result!.querySelector('msub');
expect(msub).not.toBeNull();
expect(msub!.children.length).toBe(2);
expect(msub!.children[0]!.textContent).toBe('a');
expect(msub!.children[1]!.textContent).toBe('1');
});

it('ignores m:sSubPr properties element', () => {
const omml = {
name: 'm:oMath',
elements: [
{
name: 'm:sSub',
elements: [
{ name: 'm:sSubPr', elements: [{ name: 'm:ctrlPr' }] },
{
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' }] }] }],
},
],
},
],
};
const result = convertOmmlToMathml(omml, doc);
expect(result).not.toBeNull();
const msub = result!.querySelector('msub');
expect(msub).not.toBeNull();
expect(msub!.children.length).toBe(2);
expect(msub!.children[0]!.textContent).toBe('x');
expect(msub!.children[1]!.textContent).toBe('n');
});

it('wraps multi-part base and subscript in <mrow> for valid arity', () => {
// x_{n+1} — subscript has 3 runs that must be grouped
const omml = {
name: 'm:oMath',
elements: [
{
name: 'm:sSub',
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' }] }] },
],
},
],
},
],
};
const result = convertOmmlToMathml(omml, doc);
expect(result).not.toBeNull();
const msub = result!.querySelector('msub');
expect(msub).not.toBeNull();
// <msub> must have exactly 2 children (base + subscript), each wrapped in <mrow>
expect(msub!.children.length).toBe(2);
expect(msub!.children[0]!.textContent).toBe('x');
expect(msub!.children[1]!.textContent).toBe('n+1');
});

it('handles missing m:sub gracefully', () => {
const omml = {
name: 'm:oMath',
elements: [
{
name: 'm:sSub',
elements: [
{
name: 'm:e',
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }],
},
],
},
],
};
const result = convertOmmlToMathml(omml, doc);
expect(result).not.toBeNull();
const msub = result!.querySelector('msub');
expect(msub).not.toBeNull();
expect(msub!.children[0]!.textContent).toBe('a');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
*/

import type { OmmlJsonNode, MathObjectConverter } from './types.js';
import { convertMathRun, convertFraction, convertBar } from './converters/index.js';
import { convertMathRun, convertFraction, convertBar, convertSubscript } from './converters/index.js';

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

Expand All @@ -32,6 +32,7 @@ const MATH_OBJECT_REGISTRY: Record<string, MathObjectConverter | null> = {
'm:r': convertMathRun,
'm:bar': convertBar, // Bar (overbar/underbar)
'm:f': convertFraction, // Fraction (numerator/denominator)
'm:sSub': convertSubscript, // Subscript

// ── Not yet implemented (community contributions welcome) ────────────────
'm:acc': null, // Accent (diacritical mark above base)
Expand All @@ -48,7 +49,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:sSub': null, // Subscript
'm:sSubSup': null, // Sub-superscript (both)
'm:sSup': null, // Superscript
};
Expand Down
Loading