diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/index.ts b/packages/layout-engine/painters/dom/src/features/math/converters/index.ts index 2857cfa29b..527abd192f 100644 --- a/packages/layout-engine/painters/dom/src/features/math/converters/index.ts +++ b/packages/layout-engine/painters/dom/src/features/math/converters/index.ts @@ -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'; 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..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 @@ -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 ?? [])); @@ -36,6 +38,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); diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/upper-limit.ts b/packages/layout-engine/painters/dom/src/features/math/converters/upper-limit.ts new file mode 100644 index 0000000000..aefbb30f6f --- /dev/null +++ b/packages/layout-engine/painters/dom/src/features/math/converters/upper-limit.ts @@ -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 . + * + * OMML structure: + * m:limUpp → m:e (base), m:lim (limit) + * + * MathML output: + * + * base + * limit + * + * + * @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 : 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; +}; 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..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 @@ -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 + // 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 +1657,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/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.ts b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.ts index 8c3d9509a8..f5788380b4 100644 --- a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.ts +++ b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.ts @@ -20,6 +20,7 @@ import { convertSuperscript, convertSubSuperscript, convertRadical, + convertUpperLimit, } from './converters/index.js'; export const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; @@ -55,7 +56,7 @@ const MATH_OBJECT_REGISTRY: Record = { '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) 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 0000000000..5f36dc860c Binary files /dev/null and b/tests/behavior/tests/importing/fixtures/math-radical-tests.docx differ diff --git a/tests/behavior/tests/importing/math-equations.spec.ts b/tests/behavior/tests/importing/math-equations.spec.ts index 0f166dd3da..96610aa69f 100644 --- a/tests/behavior/tests/importing/math-equations.spec.ts +++ b/tests/behavior/tests/importing/math-equations.spec.ts @@ -6,6 +6,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ALL_OBJECTS_DOC = path.resolve(__dirname, 'fixtures/math-all-objects.docx'); const FUNC_DOC = path.resolve(__dirname, 'fixtures/math-func-tests.docx'); const DELIMITER_DOC = path.resolve(__dirname, 'fixtures/math-delimiter-tests.docx'); +const RADICAL_DOC = path.resolve(__dirname, 'fixtures/math-radical-tests.docx'); // Single-object test docs are used for focused verification by community contributors. // The all-objects doc is used for behavior tests since it exercises the full pipeline. @@ -360,3 +361,83 @@ test.describe('m:d (delimiter) rendering', () => { 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); + }); +});