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