Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor degHide=false when degree node is empty

The new || (deg.elements ?? []).length === 0 branch makes this converter emit <msqrt> even when m:degHide is absent or explicitly m:val="0" (degree should be visible). In documents with an intentionally visible but currently empty degree (for example, an unfinished n-th root placeholder), this now collapses the structure to a square root and drops the index slot semantics; <mroot> should still be preserved whenever degree visibility is on.

Useful? React with 👍 / 👎.

const msqrt = doc.createElementNS(MATHML_NS, 'msqrt');
const radicandRow = doc.createElementNS(MATHML_NS, 'mrow');
radicandRow.appendChild(convertChildren(radicand?.elements ?? []));
Expand All @@ -36,6 +38,7 @@ export const convertRadical: MathObjectConverter = (node, doc, convertChildren)

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

// MathML <mroot>: first child is base (radicand), second is index (degree)
const radicandRow = doc.createElementNS(MATHML_NS, 'mrow');
radicandRow.appendChild(convertChildren(radicand?.elements ?? []));
mroot.appendChild(radicandRow);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
// <m:deg/> on save even when there is no <m:degHide>. Without the empty-deg
// check this falls into the <mroot> branch and produces an invalid
// <mroot><mrow>x</mrow><mrow></mrow></mroot> with an empty index.
it('produces <msqrt> 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', () => {
Expand Down Expand Up @@ -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 <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('');
});
});
Binary file not shown.
Loading
Loading