Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ export { convertFunction } from './function.js';
export { convertSubscript } from './subscript.js';
export { convertSuperscript } from './superscript.js';
export { convertSubSuperscript } from './sub-superscript.js';
export { convertRadical } from './radical.js';
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { MathObjectConverter } from '../types.js';

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

/**
* Convert m:rad (radical) to MathML <msqrt> or <mroot>.
*
* OMML structure:
* m:rad → m:radPr (optional: m:degHide), m:deg (degree), m:e (radicand)
*
* MathML output:
* - degree hidden → <msqrt><mrow>radicand</mrow></msqrt>
* - degree shown → <mroot><mrow>radicand</mrow><mrow>degree</mrow></mroot>
*
* @spec ECMA-376 §22.1.2.88
*/
export const convertRadical: MathObjectConverter = (node, doc, convertChildren) => {
const elements = node.elements ?? [];

const radPr = elements.find((e) => e.name === 'm:radPr');
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
const degHideEl = radPr?.elements?.find((e) => e.name === 'm:degHide');
const degHideVal = degHideEl?.attributes?.['m:val'];
const degreeHidden = degHideEl !== undefined && degHideVal !== '0' && degHideVal !== 'false';
Comment thread
caio-pizzol marked this conversation as resolved.

if (degreeHidden || !deg) {
const msqrt = doc.createElementNS(MATHML_NS, 'msqrt');
const radicandRow = doc.createElementNS(MATHML_NS, 'mrow');
radicandRow.appendChild(convertChildren(radicand?.elements ?? []));
msqrt.appendChild(radicandRow);
return msqrt;
}

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

const radicandRow = doc.createElementNS(MATHML_NS, 'mrow');
radicandRow.appendChild(convertChildren(radicand?.elements ?? []));
mroot.appendChild(radicandRow);

const degRow = doc.createElementNS(MATHML_NS, 'mrow');
degRow.appendChild(convertChildren(deg?.elements ?? []));
mroot.appendChild(degRow);

return mroot;
};
Original file line number Diff line number Diff line change
Expand Up @@ -927,3 +927,140 @@ describe('m:func converter', () => {
expect(mis[1]!.textContent).toBe('cos');
});
});
Comment thread
caio-pizzol marked this conversation as resolved.
Comment thread
caio-pizzol marked this conversation as resolved.

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 @@ -18,6 +18,7 @@ import {
convertSubscript,
convertSuperscript,
convertSubSuperscript,
convertRadical,
} from './converters/index.js';

export const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
Expand Down Expand Up @@ -57,7 +58,7 @@ 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:rad': null, // Radical (square root, nth root)
'm:rad': convertRadical, // Radical (square root, nth root)
'm:sPre': null, // Pre-sub-superscript (left of base)
};

Expand Down
19 changes: 18 additions & 1 deletion tests/behavior/tests/importing/math-equations.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,28 @@ test.describe('math equation import and rendering', () => {
expect(subSupData!.superscript).toBe('2');
});

test('renders radical as <msqrt> with radicand', async ({ superdoc }) => {
await superdoc.loadDocument(ALL_OBJECTS_DOC);
await superdoc.waitForStable();

// The test doc has √(b²-4ac) and √x — both with degHide, so both should be <msqrt>
const sqrtData = await superdoc.page.evaluate(() => {
const msqrts = document.querySelectorAll('msqrt');
return Array.from(msqrts).map((el) => ({
childCount: el.children.length,
textContent: el.textContent,
}));
});

expect(sqrtData.length).toBeGreaterThanOrEqual(2);
expect(sqrtData[0]!.childCount).toBeGreaterThan(0);
});

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., radical, delimiter) should still
// Unimplemented math objects (e.g., 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