From c680cca80449acd026a149ac66303388f1d2c1d2 Mon Sep 17 00:00:00 2001
From: Anuj52 <95843211+Anuj52@users.noreply.github.com>
Date: Sun, 12 Apr 2026 20:03:17 +0530
Subject: [PATCH 1/3] feat: implement m:rad radical/sqrt converter
Closes #2598
---
.../painters/dom/src/features/math/converters/radical.ts | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/radical.ts b/packages/layout-engine/painters/dom/src/features/math/converters/radical.ts
index 4b0d13de40..4574218201 100644
--- a/packages/layout-engine/painters/dom/src/features/math/converters/radical.ts
+++ b/packages/layout-engine/painters/dom/src/features/math/converters/radical.ts
@@ -12,7 +12,7 @@ const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
* - degree hidden → radicand
* - degree shown → radicanddegree
*
- * @spec ECMA-376 §22.1.2.88
+ * @spec ECMA-376 §22.1.2.86
*/
export const convertRadical: MathObjectConverter = (node, doc, convertChildren) => {
const elements = node.elements ?? [];
@@ -26,7 +26,8 @@ export const convertRadical: MathObjectConverter = (node, doc, convertChildren)
const degHideVal = degHideEl?.attributes?.['m:val'];
const degreeHidden = degHideEl !== undefined && degHideVal !== '0' && degHideVal !== 'false';
- 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 ?? []));
@@ -36,6 +37,7 @@ export const convertRadical: MathObjectConverter = (node, doc, convertChildren)
const mroot = doc.createElementNS(MATHML_NS, 'mroot');
+ // MathML : first child is base (radicand), second is index (degree)
const radicandRow = doc.createElementNS(MATHML_NS, 'mrow');
radicandRow.appendChild(convertChildren(radicand?.elements ?? []));
mroot.appendChild(radicandRow);
@@ -46,3 +48,4 @@ export const convertRadical: MathObjectConverter = (node, doc, convertChildren)
return mroot;
};
+
From a64000b6fe969d343b08fb7457b8de7f1e040ce4 Mon Sep 17 00:00:00 2001
From: Caio Pizzol
Date: Sun, 12 Apr 2026 09:16:07 -0700
Subject: [PATCH 2/3] test(math): add m:rad edge case coverage + handle
ST_OnOff "off"
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Dedupe duplicate `m:rad converter` describe block in omml-to-mathml.test.ts
- Add unit test for empty with no — Word's round-trip
canonical form for "no explicit degree". Without the empty-deg check this
case produced an invalid with an empty index.
- Add unit tests for ST_OnOff "on" / "off" variants on m:degHide
(ECMA-376 §22.9.2.7) — Word normalizes these on save but other DOCX
producers (Google Docs, LibreOffice, Pages) may emit them.
- Treat m:degHide val="off" as not hidden — previously interpreted as true.
- Restore correct spec citation §22.1.2.88 for m:rad (the prior change to
§22.1.2.86 was incorrect; §22.1.2.88 is the rad section in ECMA-376).
- Add math-radical-tests.docx behavior fixture (3 cases: canonical sqrt,
cube root, empty-deg-no-degHide).
- Add behavior tests asserting / shape per case and that
no ever has an empty index.
---
.../src/features/math/converters/radical.ts | 8 +-
.../src/features/math/omml-to-mathml.test.ts | 232 +++++++-----------
.../fixtures/math-radical-tests.docx | Bin 0 -> 13572 bytes
.../tests/importing/math-equations.spec.ts | 81 ++++++
4 files changed, 180 insertions(+), 141 deletions(-)
create mode 100644 tests/behavior/tests/importing/fixtures/math-radical-tests.docx
diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/radical.ts b/packages/layout-engine/painters/dom/src/features/math/converters/radical.ts
index 4574218201..2baf0352b3 100644
--- a/packages/layout-engine/painters/dom/src/features/math/converters/radical.ts
+++ b/packages/layout-engine/painters/dom/src/features/math/converters/radical.ts
@@ -12,7 +12,7 @@ const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
* - degree hidden → radicand
* - degree shown → radicanddegree
*
- * @spec ECMA-376 §22.1.2.86
+ * @spec ECMA-376 §22.1.2.88
*/
export const convertRadical: MathObjectConverter = (node, doc, convertChildren) => {
const elements = node.elements ?? [];
@@ -21,10 +21,11 @@ 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';
// Use msqrt if degree is explicitly hidden OR if m:deg is missing/empty
if (degreeHidden || !deg || (deg.elements ?? []).length === 0) {
@@ -48,4 +49,3 @@ export const convertRadical: MathObjectConverter = (node, doc, convertChildren)
return mroot;
};
-
diff --git a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts
index ba65368aa2..7ad4ab2638 100644
--- a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts
+++ b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts
@@ -898,6 +898,101 @@ describe('m:rad converter', () => {
expect(msqrt).not.toBeNull();
expect(msqrt!.textContent).toBe('');
});
+
+ // Word's round-trip canonical form for "no explicit degree": Word adds an empty
+ // on save even when there is no . Without the empty-deg
+ // check this falls into the branch and produces an invalid
+ // x with an empty index.
+ it('produces 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', () => {
@@ -1505,140 +1600,3 @@ describe('m:func converter', () => {
expect(mis[1]!.textContent).toBe('cos');
});
});
-
-describe('m:rad converter', () => {
- it('converts m:rad with degHide to ', () => {
- 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 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 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 (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 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('');
- });
-});
diff --git a/tests/behavior/tests/importing/fixtures/math-radical-tests.docx b/tests/behavior/tests/importing/fixtures/math-radical-tests.docx
new file mode 100644
index 0000000000000000000000000000000000000000..5f36dc860c79b4f2e9dce20fee9994bb053140f6
GIT binary patch
literal 13572
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 {
expect(nested!.text).toBe('((x+y)+z)');
});
});
+
+test.describe('m:rad (radical) edge cases', () => {
+ // Fixture has 3 cases the converter must handle distinctly:
+ // sqrt_degHide — canonical Word sqrt: degHide=1 + empty
+ // cube_root — explicit degree, no degHide
+ // empty_deg_no_degHide — Word's round-trip canonical for "no explicit degree":
+ // Word adds an empty on save, no
+ test('canonical sqrt (degHide) renders as ', async ({ superdoc }) => {
+ await superdoc.loadDocument(RADICAL_DOC);
+ await superdoc.waitForStable();
+
+ const data = await superdoc.page.evaluate(() => {
+ const maths = document.querySelectorAll('math');
+ const first = maths[0];
+ if (!first) return null;
+ return {
+ hasMsqrt: first.querySelector('msqrt') !== null,
+ hasMroot: first.querySelector('mroot') !== null,
+ text: first.textContent,
+ };
+ });
+
+ expect(data).not.toBeNull();
+ expect(data!.hasMsqrt).toBe(true);
+ expect(data!.hasMroot).toBe(false);
+ expect(data!.text).toBe('x');
+ });
+
+ test('cube root (visible degree) renders as with radicand and index', async ({ superdoc }) => {
+ await superdoc.loadDocument(RADICAL_DOC);
+ await superdoc.waitForStable();
+
+ const data = await superdoc.page.evaluate(() => {
+ const maths = document.querySelectorAll('math');
+ const second = maths[1];
+ if (!second) return null;
+ const mroot = second.querySelector('mroot');
+ if (!mroot) return null;
+ return {
+ childCount: mroot.children.length,
+ radicand: mroot.children[0]?.textContent,
+ degree: mroot.children[1]?.textContent,
+ };
+ });
+
+ expect(data).not.toBeNull();
+ expect(data!.childCount).toBe(2);
+ expect(data!.radicand).toBe('x');
+ expect(data!.degree).toBe('3');
+ });
+
+ test('empty with no degHide renders as , never with empty index', async ({ superdoc }) => {
+ await superdoc.loadDocument(RADICAL_DOC);
+ await superdoc.waitForStable();
+
+ // Without the empty-deg check, this case produces x.
+ // Assert the broken shape never appears anywhere on the page.
+ const data = await superdoc.page.evaluate(() => {
+ const maths = Array.from(document.querySelectorAll('math'));
+ const third = maths[2];
+ const brokenMroots = maths.filter((m) => {
+ const root = m.querySelector('mroot');
+ if (!root) return false;
+ const index = root.children[1];
+ return !index || index.textContent === '';
+ });
+ return {
+ thirdHasMsqrt: third?.querySelector('msqrt') !== null,
+ thirdHasMroot: third?.querySelector('mroot') !== null,
+ thirdText: third?.textContent,
+ brokenMrootCount: brokenMroots.length,
+ };
+ });
+
+ expect(data.thirdHasMsqrt).toBe(true);
+ expect(data.thirdHasMroot).toBe(false);
+ expect(data.thirdText).toBe('x');
+ expect(data.brokenMrootCount).toBe(0);
+ });
+});
From 9d4142a9400c727b0a20630c34fbbe668daf9562 Mon Sep 17 00:00:00 2001
From: Caio Pizzol
Date: Sun, 12 Apr 2026 09:31:38 -0700
Subject: [PATCH 3/3] test(math): pin m:rad behavior for explicit ST_OnOff true
values
Add unit tests for m:val="1" (canonical Word output) and m:val="true"
(ST_OnOff true alias) so degHide handling for the common true values is
locked in, complementing the existing "no val" / "on" / "off" / "0" cases.
---
.../src/features/math/omml-to-mathml.test.ts | 57 +++++++++++++++++++
1 file changed, 57 insertions(+)
diff --git a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts
index 7ad4ab2638..abe71965de 100644
--- a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts
+++ b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts
@@ -899,6 +899,63 @@ describe('m:rad converter', () => {
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
// on save even when there is no . Without the empty-deg
// check this falls into the branch and produces an invalid