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 @@ -14,6 +14,7 @@ export { convertDelimiter } from './delimiter.js';
export { convertSubscript } from './subscript.js';
export { convertSuperscript } from './superscript.js';
export { convertSubSuperscript } from './sub-superscript.js';
export { convertPreSubSuperscript } from './pre-sub-superscript.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
@@ -0,0 +1,51 @@
import type { MathObjectConverter } from '../types.js';

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

/**
* Convert m:sPre (pre-sub-superscript) to MathML <mmultiscripts>.
*
* OMML structure:
* m:sPre → m:sPrePr (optional), m:sub (subscript), m:sup (superscript), m:e (base)
*
* Note: element order differs from m:sSubSup — in m:sPre the base (m:e) is the
* LAST child, not the first. The converter uses tag-based lookup (not position)
* so any order is accepted.
*
* MathML output:
* <mmultiscripts>
* <mrow>base</mrow>
* <mprescripts/>
* <mrow>sub</mrow>
* <mrow>sup</mrow>
* </mmultiscripts>
*
* The <mprescripts/> separator tells MathML that the scripts that follow
* are placed to the left of the base rather than to the right.
*
* @spec ECMA-376 §22.1.2.99
*/
export const convertPreSubSuperscript: 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 mmultiscripts = doc.createElementNS(MATHML_NS, 'mmultiscripts');

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

mmultiscripts.appendChild(doc.createElementNS(MATHML_NS, 'mprescripts'));

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

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

return mmultiscripts;
};
Original file line number Diff line number Diff line change
Expand Up @@ -1514,6 +1514,148 @@ describe('m:sSubSup converter', () => {
});
});

describe('m:sPre converter', () => {
// Per ECMA-376 §22.1.2.99, m:sPre children appear in the order
// (m:sPrePr?, m:sub, m:sup, m:e) — base is last, not first.
it('converts pre-sub-superscript to <mmultiscripts> with <mprescripts/>', () => {
const omml = {
name: 'm:oMath',
elements: [
{
name: 'm:sPre',
elements: [
{
name: 'm:sub',
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }],
},
{
name: 'm:sup',
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'b' }] }] }],
},
{
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 mmulti = result!.querySelector('mmultiscripts');
expect(mmulti).not.toBeNull();
// mmultiscripts children order: base, <mprescripts/>, sub, sup
expect(mmulti!.children.length).toBe(4);
expect(mmulti!.children[0]!.textContent).toBe('X');
expect(mmulti!.children[1]!.localName).toBe('mprescripts');
expect(mmulti!.children[2]!.textContent).toBe('a');
expect(mmulti!.children[3]!.textContent).toBe('b');
});

it('ignores m:sPrePr properties element', () => {
const omml = {
name: 'm:oMath',
elements: [
{
name: 'm:sPre',
elements: [
{ name: 'm:sPrePr', elements: [{ name: 'm:ctrlPr' }] },
{
name: 'm:sub',
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }],
},
{
name: 'm:sup',
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'b' }] }] }],
},
{
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 mmulti = result!.querySelector('mmultiscripts');
expect(mmulti).not.toBeNull();
expect(mmulti!.children.length).toBe(4);
expect(mmulti!.children[0]!.textContent).toBe('X');
expect(mmulti!.children[1]!.localName).toBe('mprescripts');
expect(mmulti!.children[2]!.textContent).toBe('a');
expect(mmulti!.children[3]!.textContent).toBe('b');
});

it('wraps multi-run sub and sup in <mrow> for valid arity', () => {
// {}_{n+1}^{k-1}X — both pre-scripts have multiple runs
const omml = {
name: 'm:oMath',
elements: [
{
name: 'm:sPre',
elements: [
{
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' }] }] },
],
},
{
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 mmulti = result!.querySelector('mmultiscripts');
expect(mmulti).not.toBeNull();
// <mmultiscripts> must keep exactly 4 children — the mrow wrapping preserves arity
expect(mmulti!.children.length).toBe(4);
expect(mmulti!.children[0]!.textContent).toBe('X');
expect(mmulti!.children[1]!.localName).toBe('mprescripts');
expect(mmulti!.children[2]!.textContent).toBe('n+1');
expect(mmulti!.children[3]!.textContent).toBe('k-1');
});

it('handles missing m:sub and m:sup gracefully', () => {
const omml = {
name: 'm:oMath',
elements: [
{
name: 'm:sPre',
elements: [
{
name: 'm:e',
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'Y' }] }] }],
},
],
},
],
};
const result = convertOmmlToMathml(omml, doc);
const mmulti = result!.querySelector('mmultiscripts');
expect(mmulti).not.toBeNull();
// Empty sub/sup mrows preserved to keep valid <mmultiscripts> arity of 4.
expect(mmulti!.children.length).toBe(4);
expect(mmulti!.children[0]!.textContent).toBe('Y');
expect(mmulti!.children[1]!.localName).toBe('mprescripts');
});
});

describe('m:func converter', () => {
it('converts m:func to function name + apply operator + argument', () => {
const omml = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
convertSubscript,
convertSuperscript,
convertSubSuperscript,
convertPreSubSuperscript,
convertRadical,
convertLowerLimit,
convertUpperLimit,
Expand Down Expand Up @@ -52,6 +53,7 @@ const MATH_OBJECT_REGISTRY: Record<string, MathObjectConverter | null> = {
'm:sSub': convertSubscript, // Subscript
'm:sSup': convertSuperscript, // Superscript
'm:sSubSup': convertSubSuperscript, // Sub-superscript (both)
'm:sPre': convertPreSubSuperscript, // Pre-sub-superscript (left of base)

// ── Not yet implemented (community contributions welcome) ────────────────
'm:acc': null, // Accent (diacritical mark above base)
Expand All @@ -62,7 +64,6 @@ const MATH_OBJECT_REGISTRY: Record<string, MathObjectConverter | null> = {
'm:m': null, // Matrix (grid of elements)
'm:nary': null, // N-ary operator (integral, summation, product)
'm:phant': null, // Phantom (invisible spacing placeholder)
'm:sPre': null, // Pre-sub-superscript (left of base)
};

/** OMML argument/container elements that wrap children in <mrow>. */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, it, expect } from 'vitest';
import { translatePassthroughNode } from '../../exporter.js';

// Math nodes (mathInline / mathBlock) serialize back to OOXML via a generic
// passthrough that deep-copies node.attrs.originalXml. These tests lock in
// that behavior so m:sPre (and other math objects) round-trip on export.

describe('math export passthrough', () => {
it('deep-copies m:sPre originalXml with child order preserved', () => {
// Spec-correct child order per ECMA-376 §22.1.2.99: (m:sPrePr, m:sub, m:sup, m:e)
const originalXml = {
name: 'm:oMath',
elements: [
{
name: 'm:sPre',
elements: [
{ name: 'm:sPrePr', elements: [{ name: 'm:ctrlPr' }] },
{
name: 'm:sub',
elements: [{ 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: '2' }] }] }],
},
{
name: 'm:e',
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'A' }] }] }],
},
],
},
],
};

const node = { attrs: { originalXml } };
const result = translatePassthroughNode({ node });

expect(result).not.toBe(originalXml);
expect(result.name).toBe('m:oMath');
expect(result.elements[0].name).toBe('m:sPre');
expect(result.elements[0].elements.map((e) => e.name)).toEqual(['m:sPrePr', 'm:sub', 'm:sup', 'm:e']);

// Verify deep copy: mutating the result must not affect the source
result.elements[0].elements[1].elements[0].elements[0].elements[0].text = 'MUTATED';
expect(originalXml.elements[0].elements[1].elements[0].elements[0].elements[0].text).toBe('1');
});

it('passes through m:oMathPara wrapping m:sPre for display-mode export', () => {
const originalXml = {
name: 'm:oMathPara',
elements: [
{
name: 'm:oMath',
elements: [
{
name: 'm:sPre',
elements: [
{ name: 'm:sPrePr', elements: [{ name: 'm:ctrlPr' }] },
{
name: 'm:sub',
elements: [{ 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: '2' }] }] }],
},
{
name: 'm:e',
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'Z' }] }] }],
},
],
},
],
},
],
};

const result = translatePassthroughNode({ node: { attrs: { originalXml } } });

expect(result.name).toBe('m:oMathPara');
expect(result.elements[0].name).toBe('m:oMath');
expect(result.elements[0].elements[0].name).toBe('m:sPre');
});

it('returns null when originalXml is missing', () => {
expect(translatePassthroughNode({ node: { attrs: {} } })).toBeNull();
expect(translatePassthroughNode({ node: {} })).toBeNull();
expect(translatePassthroughNode({})).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,42 @@ describe('mathNodeHandler', () => {
expect(original).not.toBe(oMathNode);
expect(original.elements[0].name).toBe('m:sSup');
});

it('preserves m:sPre subtree verbatim in originalXml', () => {
// Spec-correct child order per ECMA-376 §22.1.2.99: (m:sPrePr?, m:sub, m:sup, m:e)
const oMathNode = {
name: 'm:oMath',
elements: [
{
name: 'm:sPre',
elements: [
{ name: 'm:sPrePr', elements: [{ name: 'm:ctrlPr' }] },
{
name: 'm:sub',
elements: [{ 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: '2' }] }] }],
},
{
name: 'm:e',
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'A' }] }] }],
},
],
},
],
};

const result = handler({ nodes: [oMathNode] });
const original = result.nodes[0].attrs.originalXml;

expect(original).not.toBe(oMathNode);
expect(original.elements[0].name).toBe('m:sPre');
// Child order is preserved — the layout-engine converter relies on tag-based
// lookup, but the importer must not rearrange the tree.
expect(original.elements[0].elements.map((e) => e.name)).toEqual(['m:sPrePr', 'm:sub', 'm:sup', 'm:e']);
});
});

describe('m:oMathPara (display math)', () => {
Expand Down
Binary file not shown.
Loading
Loading