Skip to content

Commit da444eb

Browse files
Math: implement m:rad radical/sqrt converter (#2730)
* test(math): add tests for m:rad radical converter * Update packages/layout-engine/painters/dom/src/features/math/converters/radical.ts Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> * Update packages/layout-engine/painters/dom/src/features/math/converters/radical.ts Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> * chore(math): remove radicals from unimplemented equations example list * test(math): add edge case tests for m:rad converter * test(math): add behavior test for radical <msqrt> rendering Adds a Playwright test asserting that <msqrt> elements render in the DOM when loading a document with radical equations (degHide=true). --------- Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Co-authored-by: Caio Pizzol <caio@harbourshare.com>
1 parent 67e59a9 commit da444eb

5 files changed

Lines changed: 206 additions & 2 deletions

File tree

packages/layout-engine/painters/dom/src/features/math/converters/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ export { convertFunction } from './function.js';
1313
export { convertSubscript } from './subscript.js';
1414
export { convertSuperscript } from './superscript.js';
1515
export { convertSubSuperscript } from './sub-superscript.js';
16+
export { convertRadical } from './radical.js';
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { MathObjectConverter } from '../types.js';
2+
3+
const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
4+
5+
/**
6+
* Convert m:rad (radical) to MathML <msqrt> or <mroot>.
7+
*
8+
* OMML structure:
9+
* m:rad → m:radPr (optional: m:degHide), m:deg (degree), m:e (radicand)
10+
*
11+
* MathML output:
12+
* - degree hidden → <msqrt><mrow>radicand</mrow></msqrt>
13+
* - degree shown → <mroot><mrow>radicand</mrow><mrow>degree</mrow></mroot>
14+
*
15+
* @spec ECMA-376 §22.1.2.88
16+
*/
17+
export const convertRadical: MathObjectConverter = (node, doc, convertChildren) => {
18+
const elements = node.elements ?? [];
19+
20+
const radPr = elements.find((e) => e.name === 'm:radPr');
21+
const deg = elements.find((e) => e.name === 'm:deg');
22+
const radicand = elements.find((e) => e.name === 'm:e');
23+
24+
// m:degHide val defaults to false; presence with val="1" or "true" means hidden
25+
const degHideEl = radPr?.elements?.find((e) => e.name === 'm:degHide');
26+
const degHideVal = degHideEl?.attributes?.['m:val'];
27+
const degreeHidden = degHideEl !== undefined && degHideVal !== '0' && degHideVal !== 'false';
28+
29+
if (degreeHidden || !deg) {
30+
const msqrt = doc.createElementNS(MATHML_NS, 'msqrt');
31+
const radicandRow = doc.createElementNS(MATHML_NS, 'mrow');
32+
radicandRow.appendChild(convertChildren(radicand?.elements ?? []));
33+
msqrt.appendChild(radicandRow);
34+
return msqrt;
35+
}
36+
37+
const mroot = doc.createElementNS(MATHML_NS, 'mroot');
38+
39+
const radicandRow = doc.createElementNS(MATHML_NS, 'mrow');
40+
radicandRow.appendChild(convertChildren(radicand?.elements ?? []));
41+
mroot.appendChild(radicandRow);
42+
43+
const degRow = doc.createElementNS(MATHML_NS, 'mrow');
44+
degRow.appendChild(convertChildren(deg?.elements ?? []));
45+
mroot.appendChild(degRow);
46+
47+
return mroot;
48+
};

packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -927,3 +927,140 @@ describe('m:func converter', () => {
927927
expect(mis[1]!.textContent).toBe('cos');
928928
});
929929
});
930+
931+
describe('m:rad converter', () => {
932+
it('converts m:rad with degHide to <msqrt>', () => {
933+
const omml = {
934+
name: 'm:oMath',
935+
elements: [
936+
{
937+
name: 'm:rad',
938+
elements: [
939+
{
940+
name: 'm:radPr',
941+
elements: [{ name: 'm:degHide' }],
942+
},
943+
{ name: 'm:deg', elements: [] },
944+
{
945+
name: 'm:e',
946+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
947+
},
948+
],
949+
},
950+
],
951+
};
952+
953+
const result = convertOmmlToMathml(omml, doc);
954+
expect(result).not.toBeNull();
955+
const msqrt = result!.querySelector('msqrt');
956+
expect(msqrt).not.toBeNull();
957+
expect(msqrt!.textContent).toBe('x');
958+
expect(result!.querySelector('mroot')).toBeNull();
959+
});
960+
961+
it('converts m:rad without degHide to <mroot> with radicand first, degree second', () => {
962+
const omml = {
963+
name: 'm:oMath',
964+
elements: [
965+
{
966+
name: 'm:rad',
967+
elements: [
968+
{
969+
name: 'm:deg',
970+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '3' }] }] }],
971+
},
972+
{
973+
name: 'm:e',
974+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
975+
},
976+
],
977+
},
978+
],
979+
};
980+
981+
const result = convertOmmlToMathml(omml, doc);
982+
expect(result).not.toBeNull();
983+
const mroot = result!.querySelector('mroot');
984+
expect(mroot).not.toBeNull();
985+
// MathML <mroot> order: first child = radicand, second child = degree
986+
expect(mroot!.children[0]!.textContent).toBe('x');
987+
expect(mroot!.children[1]!.textContent).toBe('3');
988+
expect(result!.querySelector('msqrt')).toBeNull();
989+
});
990+
991+
it('converts m:rad with degHide m:val="0" to <mroot> (degree explicitly visible)', () => {
992+
const omml = {
993+
name: 'm:oMath',
994+
elements: [
995+
{
996+
name: 'm:rad',
997+
elements: [
998+
{
999+
name: 'm:radPr',
1000+
elements: [{ name: 'm:degHide', attributes: { 'm:val': '0' } }],
1001+
},
1002+
{
1003+
name: 'm:deg',
1004+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '3' }] }] }],
1005+
},
1006+
{
1007+
name: 'm:e',
1008+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
1009+
},
1010+
],
1011+
},
1012+
],
1013+
};
1014+
1015+
const result = convertOmmlToMathml(omml, doc);
1016+
expect(result).not.toBeNull();
1017+
expect(result!.querySelector('mroot')).not.toBeNull();
1018+
expect(result!.querySelector('msqrt')).toBeNull();
1019+
});
1020+
1021+
it('produces <msqrt> when m:deg is missing entirely', () => {
1022+
const omml = {
1023+
name: 'm:oMath',
1024+
elements: [
1025+
{
1026+
name: 'm:rad',
1027+
elements: [
1028+
{
1029+
name: 'm:e',
1030+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
1031+
},
1032+
],
1033+
},
1034+
],
1035+
};
1036+
1037+
const result = convertOmmlToMathml(omml, doc);
1038+
expect(result).not.toBeNull();
1039+
expect(result!.querySelector('msqrt')).not.toBeNull();
1040+
expect(result!.querySelector('mroot')).toBeNull();
1041+
});
1042+
1043+
it('handles missing m:e gracefully', () => {
1044+
const omml = {
1045+
name: 'm:oMath',
1046+
elements: [
1047+
{
1048+
name: 'm:rad',
1049+
elements: [
1050+
{
1051+
name: 'm:radPr',
1052+
elements: [{ name: 'm:degHide' }],
1053+
},
1054+
{ name: 'm:deg', elements: [] },
1055+
],
1056+
},
1057+
],
1058+
};
1059+
1060+
const result = convertOmmlToMathml(omml, doc);
1061+
expect(result).not.toBeNull();
1062+
const msqrt = result!.querySelector('msqrt');
1063+
expect(msqrt).not.toBeNull();
1064+
expect(msqrt!.textContent).toBe('');
1065+
});
1066+
});

packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
convertSubscript,
1919
convertSuperscript,
2020
convertSubSuperscript,
21+
convertRadical,
2122
} from './converters/index.js';
2223

2324
export const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
@@ -57,7 +58,7 @@ const MATH_OBJECT_REGISTRY: Record<string, MathObjectConverter | null> = {
5758
'm:m': null, // Matrix (grid of elements)
5859
'm:nary': null, // N-ary operator (integral, summation, product)
5960
'm:phant': null, // Phantom (invisible spacing placeholder)
60-
'm:rad': null, // Radical (square root, nth root)
61+
'm:rad': convertRadical, // Radical (square root, nth root)
6162
'm:sPre': null, // Pre-sub-superscript (left of base)
6263
};
6364

tests/behavior/tests/importing/math-equations.spec.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,11 +107,28 @@ test.describe('math equation import and rendering', () => {
107107
expect(subSupData!.superscript).toBe('2');
108108
});
109109

110+
test('renders radical as <msqrt> with radicand', async ({ superdoc }) => {
111+
await superdoc.loadDocument(ALL_OBJECTS_DOC);
112+
await superdoc.waitForStable();
113+
114+
// The test doc has √(b²-4ac) and √x — both with degHide, so both should be <msqrt>
115+
const sqrtData = await superdoc.page.evaluate(() => {
116+
const msqrts = document.querySelectorAll('msqrt');
117+
return Array.from(msqrts).map((el) => ({
118+
childCount: el.children.length,
119+
textContent: el.textContent,
120+
}));
121+
});
122+
123+
expect(sqrtData.length).toBeGreaterThanOrEqual(2);
124+
expect(sqrtData[0]!.childCount).toBeGreaterThan(0);
125+
});
126+
110127
test('math text content is preserved for unimplemented objects', async ({ superdoc }) => {
111128
await superdoc.loadDocument(ALL_OBJECTS_DOC);
112129
await superdoc.waitForStable();
113130

114-
// Unimplemented math objects (e.g., radical, delimiter) should still
131+
// Unimplemented math objects (e.g., delimiter) should still
115132
// have their text content accessible in the PM document
116133
const mathTexts = await superdoc.page.evaluate(() => {
117134
const view = (window as any).editor?.view;

0 commit comments

Comments
 (0)