Skip to content
Closed
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 @@ -15,3 +15,4 @@ export { convertSubscript } from './subscript.js';
export { convertSuperscript } from './superscript.js';
export { convertSubSuperscript } from './sub-superscript.js';
export { convertRadical } from './radical.js';
export { convertUpperLimit } from './upper-limit.js';
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,14 @@ export const convertRadical: MathObjectConverter = (node, doc, convertChildren)
const deg = elements.find((e) => e.name === 'm:deg');
const radicand = elements.find((e) => e.name === 'm:e');

// m:degHide val defaults to false; presence with val="1" or "true" means hidden
// m:degHide is an ST_OnOff property: presence with no val (or val="1"/"true"/"on") means
// the degree is hidden; val="0"/"false"/"off" means it is shown. ECMA-376 §22.9.2.7.
const degHideEl = radPr?.elements?.find((e) => e.name === 'm:degHide');
const degHideVal = degHideEl?.attributes?.['m:val'];
const degreeHidden = degHideEl !== undefined && degHideVal !== '0' && degHideVal !== 'false';
const degreeHidden = degHideEl !== undefined && degHideVal !== '0' && degHideVal !== 'false' && degHideVal !== 'off';

if (degreeHidden || !deg) {
// Use msqrt if degree is explicitly hidden OR if m:deg is missing/empty
if (degreeHidden || !deg || (deg.elements ?? []).length === 0) {
const msqrt = doc.createElementNS(MATHML_NS, 'msqrt');
const radicandRow = doc.createElementNS(MATHML_NS, 'mrow');
radicandRow.appendChild(convertChildren(radicand?.elements ?? []));
Expand All @@ -36,6 +38,7 @@ export const convertRadical: MathObjectConverter = (node, doc, convertChildren)

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

// MathML <mroot>: first child is base (radicand), second is index (degree)
const radicandRow = doc.createElementNS(MATHML_NS, 'mrow');
radicandRow.appendChild(convertChildren(radicand?.elements ?? []));
mroot.appendChild(radicandRow);
Expand Down
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:limUpp (upper limit) to MathML <mover>.
*
* OMML structure:
* m:limUpp → m:e (base), m:lim (limit)
*
* MathML output:
* <mover>
* <mrow>base</mrow>
* <mrow>limit</mrow>
* </mover>
*
* @spec ECMA-376 §22.1.2.56
*/
export const convertUpperLimit: MathObjectConverter = (node, doc, convertChildren) => {
const elements = node.elements ?? [];

const base = elements.find((e) => e.name === 'm:e');
const limit = elements.find((e) => e.name === 'm:lim');

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

// MathML <mover>: first child is base, second is overscript
const baseRow = doc.createElementNS(MATHML_NS, 'mrow');
baseRow.appendChild(convertChildren(base?.elements ?? []));
mover.appendChild(baseRow);

const limitRow = doc.createElementNS(MATHML_NS, 'mrow');
limitRow.appendChild(convertChildren(limit?.elements ?? []));
mover.appendChild(limitRow);

return mover;
};
Original file line number Diff line number Diff line change
Expand Up @@ -898,6 +898,158 @@ describe('m:rad converter', () => {
expect(msqrt).not.toBeNull();
expect(msqrt!.textContent).toBe('');
});

it('treats m:degHide m:val="1" as hidden (canonical Word output)', () => {
const omml = {
name: 'm:oMath',
elements: [
{
name: 'm:rad',
elements: [
{
name: 'm:radPr',
elements: [{ name: 'm:degHide', attributes: { 'm:val': '1' } }],
},
{ name: 'm:deg', 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();
expect(result!.querySelector('msqrt')).not.toBeNull();
expect(result!.querySelector('mroot')).toBeNull();
});

it('treats m:degHide m:val="true" as hidden (ST_OnOff true alias)', () => {
const omml = {
name: 'm:oMath',
elements: [
{
name: 'm:rad',
elements: [
{
name: 'm:radPr',
elements: [{ name: 'm:degHide', attributes: { 'm:val': 'true' } }],
},
{
name: 'm:deg',
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '3' }] }] }],
},
{
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();
expect(result!.querySelector('msqrt')).not.toBeNull();
expect(result!.querySelector('mroot')).toBeNull();
});

// Word's round-trip canonical form for "no explicit degree": Word adds an empty
// <m:deg/> on save even when there is no <m:degHide>. Without the empty-deg
// check this falls into the <mroot> branch and produces an invalid
// <mroot><mrow>x</mrow><mrow></mrow></mroot> with an empty index.
it('produces <msqrt> when m:deg is present but empty and no m:degHide', () => {
const omml = {
name: 'm:oMath',
elements: [
{
name: 'm:rad',
elements: [
{ name: 'm:deg', 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 msqrt = result!.querySelector('msqrt');
expect(msqrt).not.toBeNull();
expect(msqrt!.textContent).toBe('x');
expect(result!.querySelector('mroot')).toBeNull();
});

// ST_OnOff (ECMA-376 §22.9.2.7) accepts "1"/"true"/"on" as true and
// "0"/"false"/"off" as false. Word normalizes "on"/"off" away on save but
// other DOCX producers (Google Docs, LibreOffice, Pages) may emit them.
it('treats m:degHide m:val="on" as hidden (ST_OnOff true alias)', () => {
const omml = {
name: 'm:oMath',
elements: [
{
name: 'm:rad',
elements: [
{
name: 'm:radPr',
elements: [{ name: 'm:degHide', attributes: { 'm:val': 'on' } }],
},
{
name: 'm:deg',
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '3' }] }] }],
},
{
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();
expect(result!.querySelector('msqrt')).not.toBeNull();
expect(result!.querySelector('mroot')).toBeNull();
});

it('treats m:degHide m:val="off" as not hidden (ST_OnOff false alias)', () => {
const omml = {
name: 'm:oMath',
elements: [
{
name: 'm:rad',
elements: [
{
name: 'm:radPr',
elements: [{ name: 'm:degHide', attributes: { 'm:val': 'off' } }],
},
{
name: 'm:deg',
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '3' }] }] }],
},
{
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 mroot = result!.querySelector('mroot');
expect(mroot).not.toBeNull();
expect(mroot!.children[0]!.textContent).toBe('x');
expect(mroot!.children[1]!.textContent).toBe('3');
expect(result!.querySelector('msqrt')).toBeNull();
});
});

describe('m:sSub converter', () => {
Expand Down Expand Up @@ -1505,140 +1657,3 @@ describe('m:func converter', () => {
expect(mis[1]!.textContent).toBe('cos');
});
});

describe('m:rad converter', () => {
it('converts m:rad with degHide to <msqrt>', () => {
const omml = {
name: 'm:oMath',
elements: [
{
name: 'm:rad',
elements: [
{
name: 'm:radPr',
elements: [{ name: 'm:degHide' }],
},
{ name: 'm:deg', 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 msqrt = result!.querySelector('msqrt');
expect(msqrt).not.toBeNull();
expect(msqrt!.textContent).toBe('x');
expect(result!.querySelector('mroot')).toBeNull();
});

it('converts m:rad without degHide to <mroot> with radicand first, degree second', () => {
const omml = {
name: 'm:oMath',
elements: [
{
name: 'm:rad',
elements: [
{
name: 'm:deg',
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '3' }] }] }],
},
{
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 mroot = result!.querySelector('mroot');
expect(mroot).not.toBeNull();
// MathML <mroot> order: first child = radicand, second child = degree
expect(mroot!.children[0]!.textContent).toBe('x');
expect(mroot!.children[1]!.textContent).toBe('3');
expect(result!.querySelector('msqrt')).toBeNull();
});

it('converts m:rad with degHide m:val="0" to <mroot> (degree explicitly visible)', () => {
const omml = {
name: 'm:oMath',
elements: [
{
name: 'm:rad',
elements: [
{
name: 'm:radPr',
elements: [{ name: 'm:degHide', attributes: { 'm:val': '0' } }],
},
{
name: 'm:deg',
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '3' }] }] }],
},
{
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();
expect(result!.querySelector('mroot')).not.toBeNull();
expect(result!.querySelector('msqrt')).toBeNull();
});

it('produces <msqrt> when m:deg is missing entirely', () => {
const omml = {
name: 'm:oMath',
elements: [
{
name: 'm:rad',
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();
expect(result!.querySelector('msqrt')).not.toBeNull();
expect(result!.querySelector('mroot')).toBeNull();
});

it('handles missing m:e gracefully', () => {
const omml = {
name: 'm:oMath',
elements: [
{
name: 'm:rad',
elements: [
{
name: 'm:radPr',
elements: [{ name: 'm:degHide' }],
},
{ name: 'm:deg', elements: [] },
],
},
],
};

const result = convertOmmlToMathml(omml, doc);
expect(result).not.toBeNull();
const msqrt = result!.querySelector('msqrt');
expect(msqrt).not.toBeNull();
expect(msqrt!.textContent).toBe('');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
convertSuperscript,
convertSubSuperscript,
convertRadical,
convertUpperLimit,
} from './converters/index.js';

export const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
Expand Down Expand Up @@ -55,7 +56,7 @@ const MATH_OBJECT_REGISTRY: Record<string, MathObjectConverter | null> = {
'm:eqArr': null, // Equation array (vertical array of equations)
'm:groupChr': null, // Group character (overbrace, underbrace)
'm:limLow': null, // Lower limit (e.g., lim)
'm:limUpp': null, // Upper limit
'm:limUpp': convertUpperLimit, // Upper limit
'm:m': null, // Matrix (grid of elements)
'm:nary': null, // N-ary operator (integral, summation, product)
'm:phant': null, // Phantom (invisible spacing placeholder)
Expand Down
Binary file not shown.
Loading
Loading