From 3966500e28c410e9baad4402814385b8431a8d9e Mon Sep 17 00:00:00 2001 From: Abdeltoto Date: Tue, 7 Apr 2026 22:39:58 -0400 Subject: [PATCH 1/8] feat(math): implement m:box and m:borderBox converters (closes #2605) Made-with: Cursor --- .../dom/src/features/math/converters/box.ts | 91 ++++++++++++++ .../dom/src/features/math/converters/index.ts | 1 + .../src/features/math/omml-to-mathml.test.ts | 118 ++++++++++++++++++ .../dom/src/features/math/omml-to-mathml.ts | 7 +- 4 files changed, 214 insertions(+), 3 deletions(-) create mode 100644 packages/layout-engine/painters/dom/src/features/math/converters/box.ts diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/box.ts b/packages/layout-engine/painters/dom/src/features/math/converters/box.ts new file mode 100644 index 0000000000..4952f80c2f --- /dev/null +++ b/packages/layout-engine/painters/dom/src/features/math/converters/box.ts @@ -0,0 +1,91 @@ +import type { MathObjectConverter } from '../types.js'; + +const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; + +/** + * Convert m:box (box / invisible grouping container) to MathML . + * + * OMML structure: + * m:box → m:boxPr (optional), m:e (content) + * + * MathML output: + * content + * + * The box is purely a grouping mechanism with no visual rendering; + * it maps directly to MathML's . + * + * @spec ECMA-376 §22.1.2.13 + */ +export const convertBox: MathObjectConverter = (node, doc, convertChildren) => { + const elements = node.elements ?? []; + const base = elements.find((e) => e.name === 'm:e'); + + const mrow = doc.createElementNS(MATHML_NS, 'mrow'); + mrow.appendChild(convertChildren(base?.elements ?? [])); + + return mrow.childNodes.length > 0 ? mrow : null; +}; + +/** + * Convert m:borderBox (bordered box) to MathML . + * + * OMML structure: + * m:borderBox → m:borderBoxPr (optional: m:hideTop, m:hideBot, m:hideLeft, m:hideRight, + * m:strikeBLTR, m:strikeH, m:strikeTLBR, m:strikeV), + * m:e (content) + * + * MathML output: + * content + * + * By default all four borders are shown (notation="box"). Individual borders + * can be hidden via m:hide* flags, and diagonal/horizontal/vertical strikes + * can be added via m:strike* flags. + * + * @spec ECMA-376 §22.1.2.11 + */ +export const convertBorderBox: MathObjectConverter = (node, doc, convertChildren) => { + const elements = node.elements ?? []; + const props = elements.find((e) => e.name === 'm:borderBoxPr'); + const base = elements.find((e) => e.name === 'm:e'); + + const isOn = (el?: { attributes?: Record }) => + el && (el.attributes?.['m:val'] === '1' || el.attributes?.['m:val'] === 'on' || !el.attributes); + + const hideTop = props?.elements?.find((e) => e.name === 'm:hideTop'); + const hideBot = props?.elements?.find((e) => e.name === 'm:hideBot'); + const hideLeft = props?.elements?.find((e) => e.name === 'm:hideLeft'); + const hideRight = props?.elements?.find((e) => e.name === 'm:hideRight'); + const strikeBLTR = props?.elements?.find((e) => e.name === 'm:strikeBLTR'); + const strikeH = props?.elements?.find((e) => e.name === 'm:strikeH'); + const strikeTLBR = props?.elements?.find((e) => e.name === 'm:strikeTLBR'); + const strikeV = props?.elements?.find((e) => e.name === 'm:strikeV'); + + const notations: string[] = []; + + const allHidden = isOn(hideTop) && isOn(hideBot) && isOn(hideLeft) && isOn(hideRight); + + if (!allHidden) { + if (!isOn(hideTop) && !isOn(hideBot) && !isOn(hideLeft) && !isOn(hideRight)) { + notations.push('box'); + } else { + if (!isOn(hideTop)) notations.push('top'); + if (!isOn(hideBot)) notations.push('bottom'); + if (!isOn(hideLeft)) notations.push('left'); + if (!isOn(hideRight)) notations.push('right'); + } + } + + if (isOn(strikeBLTR)) notations.push('updiagonalstrike'); + if (isOn(strikeH)) notations.push('horizontalstrike'); + if (isOn(strikeTLBR)) notations.push('downdiagonalstrike'); + if (isOn(strikeV)) notations.push('verticalstrike'); + + const menclose = doc.createElementNS(MATHML_NS, 'menclose'); + if (notations.length > 0) { + menclose.setAttribute('notation', notations.join(' ')); + } + + menclose.appendChild(convertChildren(base?.elements ?? [])); + + return menclose; +}; 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 fcc7916fcb..0af6795656 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 @@ -24,3 +24,4 @@ export { convertNary } from './nary.js'; export { convertPhantom } from './phantom.js'; export { convertGroupCharacter } from './group-character.js'; export { convertMatrix } from './matrix.js'; +export { convertBox, convertBorderBox } from './box.js'; 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 c5a1345725..a117f953d3 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 @@ -3929,3 +3929,121 @@ describe('m:m converter', () => { expect(mtable!.textContent).toBe('ab'); }); }); +describe('m:box converter', () => { + it('converts m:box to ', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:box', + 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!.textContent).toBe('x'); + }); + + it('returns null for empty m:box', () => { + const omml = { + name: 'm:oMath', + elements: [{ name: 'm:box', elements: [] }], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).toBeNull(); + }); +}); + +describe('m:borderBox converter', () => { + it('converts m:borderBox to by default', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:borderBox', + elements: [ + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'E' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const menclose = result!.querySelector('menclose'); + expect(menclose).not.toBeNull(); + expect(menclose!.getAttribute('notation')).toBe('box'); + expect(menclose!.textContent).toBe('E'); + }); + + it('hides individual sides when hide flags are set', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:borderBox', + elements: [ + { + name: 'm:borderBoxPr', + elements: [ + { name: 'm:hideTop', attributes: { 'm:val': '1' } }, + { name: 'm:hideBot', attributes: { 'm:val': '1' } }, + ], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + const menclose = result!.querySelector('menclose'); + expect(menclose).not.toBeNull(); + const notation = menclose!.getAttribute('notation')!; + expect(notation).toContain('left'); + expect(notation).toContain('right'); + expect(notation).not.toContain('top'); + expect(notation).not.toContain('bottom'); + }); + + it('adds strike notations', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:borderBox', + elements: [ + { + name: 'm:borderBoxPr', + elements: [ + { name: 'm:hideTop', attributes: { 'm:val': '1' } }, + { name: 'm:hideBot', attributes: { 'm:val': '1' } }, + { name: 'm:hideLeft', attributes: { 'm:val': '1' } }, + { name: 'm:hideRight', attributes: { 'm:val': '1' } }, + { name: 'm:strikeH', attributes: { 'm:val': '1' } }, + ], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'y' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + const menclose = result!.querySelector('menclose'); + expect(menclose).not.toBeNull(); + expect(menclose!.getAttribute('notation')).toBe('horizontalstrike'); + }); +}); 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 e2f8f016f9..2f49e3d5f7 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 @@ -29,6 +29,8 @@ import { convertPhantom, convertGroupCharacter, convertMatrix, + convertBox, + convertBorderBox, } from './converters/index.js'; export const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; @@ -66,9 +68,8 @@ const MATH_OBJECT_REGISTRY: Record = { 'm:sSubSup': convertSubSuperscript, // Sub-superscript (both) 'm:sPre': convertPreSubSuperscript, // Pre-sub-superscript (left of base) - // ── Not yet implemented (community contributions welcome) ──────────────── - 'm:borderBox': null, // Border box (border around math content) - 'm:box': null, // Box (invisible grouping container) + 'm:borderBox': convertBorderBox, // Border box (border around math content) + 'm:box': convertBox, // Box (invisible grouping container) 'm:groupChr': convertGroupCharacter, // Group character (overbrace, underbrace) }; From 14d640b9104f26dc31abe94330f043f85675ee2e Mon Sep 17 00:00:00 2001 From: Abdeltoto Date: Tue, 7 Apr 2026 22:51:25 -0400 Subject: [PATCH 2/8] fix(math): parse full ST_OnOff values in borderBox converter Made-with: Cursor --- .../painters/dom/src/features/math/converters/box.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/box.ts b/packages/layout-engine/painters/dom/src/features/math/converters/box.ts index 4952f80c2f..b26445df5a 100644 --- a/packages/layout-engine/painters/dom/src/features/math/converters/box.ts +++ b/packages/layout-engine/painters/dom/src/features/math/converters/box.ts @@ -48,8 +48,13 @@ export const convertBorderBox: MathObjectConverter = (node, doc, convertChildren const props = elements.find((e) => e.name === 'm:borderBoxPr'); const base = elements.find((e) => e.name === 'm:e'); + /** OOXML ST_OnOff true values: "1", "on", "true", or boolean-flag presence. */ const isOn = (el?: { attributes?: Record }) => - el && (el.attributes?.['m:val'] === '1' || el.attributes?.['m:val'] === 'on' || !el.attributes); + el && + (el.attributes?.['m:val'] === '1' || + el.attributes?.['m:val'] === 'on' || + el.attributes?.['m:val'] === 'true' || + !el.attributes); const hideTop = props?.elements?.find((e) => e.name === 'm:hideTop'); const hideBot = props?.elements?.find((e) => e.name === 'm:hideBot'); From 1919418efd15c79812f56c3418f825e8d3611189 Mon Sep 17 00:00:00 2001 From: Abdeltoto Date: Tue, 7 Apr 2026 22:57:59 -0400 Subject: [PATCH 3/8] fix(math): fall back to mrow when borderBox hides all sides with no strikes Made-with: Cursor --- .../dom/src/features/math/converters/box.ts | 13 +++++--- .../src/features/math/omml-to-mathml.test.ts | 30 +++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/box.ts b/packages/layout-engine/painters/dom/src/features/math/converters/box.ts index b26445df5a..47cae2fa4c 100644 --- a/packages/layout-engine/painters/dom/src/features/math/converters/box.ts +++ b/packages/layout-engine/painters/dom/src/features/math/converters/box.ts @@ -85,12 +85,17 @@ export const convertBorderBox: MathObjectConverter = (node, doc, convertChildren if (isOn(strikeTLBR)) notations.push('downdiagonalstrike'); if (isOn(strikeV)) notations.push('verticalstrike'); - const menclose = doc.createElementNS(MATHML_NS, 'menclose'); - if (notations.length > 0) { - menclose.setAttribute('notation', notations.join(' ')); + const content = convertChildren(base?.elements ?? []); + + if (notations.length === 0) { + const mrow = doc.createElementNS(MATHML_NS, 'mrow'); + mrow.appendChild(content); + return mrow; } - menclose.appendChild(convertChildren(base?.elements ?? [])); + const menclose = doc.createElementNS(MATHML_NS, 'menclose'); + menclose.setAttribute('notation', notations.join(' ')); + menclose.appendChild(content); return menclose; }; 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 a117f953d3..2f7ec3fe5d 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 @@ -4046,4 +4046,34 @@ describe('m:borderBox converter', () => { expect(menclose).not.toBeNull(); expect(menclose!.getAttribute('notation')).toBe('horizontalstrike'); }); + + it('falls back to when all borders hidden and no strikes', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:borderBox', + elements: [ + { + name: 'm:borderBoxPr', + elements: [ + { name: 'm:hideTop', attributes: { 'm:val': '1' } }, + { name: 'm:hideBot', attributes: { 'm:val': '1' } }, + { name: 'm:hideLeft', attributes: { 'm:val': '1' } }, + { name: 'm:hideRight', attributes: { 'm:val': '1' } }, + ], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'q' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + const menclose = result!.querySelector('menclose'); + expect(menclose).toBeNull(); + expect(result!.textContent).toBe('q'); + }); }); From f659ca6dde1554b1624b4514f56aaa258462697c Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 20 Apr 2026 10:31:00 -0300 Subject: [PATCH 4/8] fix(math): address review findings for m:box/m:borderBox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isOn now checks m:val === undefined instead of !el.attributes so elements with namespace-only attributes are still treated as on, matching the ST_OnOff default per §22.9.2.7. - convertBorderBox returns null for empty m:e, consistent with convertBox and convertFunction (no empty wrappers). - m:box JSDoc now reflects that boxPr semantics (opEmu, noBreak, aln, diff, argSz) are silently dropped — not "purely a grouping mechanism". - Registry comment drift fixed: m:box and m:borderBox moved into the Implemented block. - Tests: strike direction mapping (BLTR→up, TLBR→down, V), full ST_OnOff matrix (1/true/on/bare/0/false), tightened assertions to exact-string equality, pinned the current boxPr-drop behavior. --- .../dom/src/features/math/converters/box.ts | 35 ++- .../src/features/math/omml-to-mathml.test.ts | 205 +++++++++++++++++- 2 files changed, 223 insertions(+), 17 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/box.ts b/packages/layout-engine/painters/dom/src/features/math/converters/box.ts index 47cae2fa4c..6eb6a8bf6a 100644 --- a/packages/layout-engine/painters/dom/src/features/math/converters/box.ts +++ b/packages/layout-engine/painters/dom/src/features/math/converters/box.ts @@ -3,7 +3,7 @@ import type { MathObjectConverter } from '../types.js'; const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; /** - * Convert m:box (box / invisible grouping container) to MathML . + * Convert m:box (grouping container) to MathML . * * OMML structure: * m:box → m:boxPr (optional), m:e (content) @@ -11,10 +11,14 @@ const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; * MathML output: * content * - * The box is purely a grouping mechanism with no visual rendering; - * it maps directly to MathML's . + * Per §22.1.2.13 / §22.1.2.14, m:box can carry boxPr children that affect + * layout and spacing — opEmu (operator emulator), noBreak (disallow line + * breaks), aln (alignment point), diff (differential spacing), argSz. These + * have no clean MathML equivalent and are currently dropped; the box + * degrades to a plain that preserves grouping but not the other + * semantics. Extend here when any of these need first-class support. * - * @spec ECMA-376 §22.1.2.13 + * @spec ECMA-376 §22.1.2.13, §22.1.2.14 */ export const convertBox: MathObjectConverter = (node, doc, convertChildren) => { const elements = node.elements ?? []; @@ -48,13 +52,19 @@ export const convertBorderBox: MathObjectConverter = (node, doc, convertChildren const props = elements.find((e) => e.name === 'm:borderBoxPr'); const base = elements.find((e) => e.name === 'm:e'); - /** OOXML ST_OnOff true values: "1", "on", "true", or boolean-flag presence. */ - const isOn = (el?: { attributes?: Record }) => - el && - (el.attributes?.['m:val'] === '1' || - el.attributes?.['m:val'] === 'on' || - el.attributes?.['m:val'] === 'true' || - !el.attributes); + /** + * OOXML ST_OnOff (§22.9.2.7): on when the element is present and either + * `m:val` is absent (spec default = 1) or equals "1" / "true". "on" is + * accepted for leniency — Annex L.6.1.3 uses that form even though the + * normative enum is {0, 1, true, false}. + * TODO: extract to a shared util when m:acc / m:phant / matrix m:tblLook land. + */ + const isOn = (el?: { attributes?: Record }) => { + if (!el) return false; + const val = el.attributes?.['m:val']; + if (val === undefined) return true; + return val === '1' || val === 'true' || val === 'on'; + }; const hideTop = props?.elements?.find((e) => e.name === 'm:hideTop'); const hideBot = props?.elements?.find((e) => e.name === 'm:hideBot'); @@ -87,6 +97,9 @@ export const convertBorderBox: MathObjectConverter = (node, doc, convertChildren const content = convertChildren(base?.elements ?? []); + // Drop empty wrappers — matches convertBox / convertFunction. + if (content.childNodes.length === 0) return null; + if (notations.length === 0) { const mrow = doc.createElementNS(MATHML_NS, 'mrow'); mrow.appendChild(content); 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 2f7ec3fe5d..898ab4da70 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 @@ -3947,6 +3947,7 @@ describe('m:box converter', () => { }; const result = convertOmmlToMathml(omml, doc); expect(result).not.toBeNull(); + expect(result!.querySelector('mrow')).not.toBeNull(); expect(result!.textContent).toBe('x'); }); @@ -3958,6 +3959,40 @@ describe('m:box converter', () => { const result = convertOmmlToMathml(omml, doc); expect(result).toBeNull(); }); + + it('drops m:boxPr children (opEmu / noBreak / aln / diff are not yet mapped)', () => { + // Pins current scope: we render and silently ignore boxPr semantics. + // When opEmu or noBreak grow real MathML mappings, this test should fail + // and be updated — that failure is the point. + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:box', + elements: [ + { + name: 'm:boxPr', + elements: [ + { name: 'm:opEmu', attributes: { 'm:val': '1' } }, + { name: 'm:noBreak', attributes: { 'm:val': '1' } }, + { name: 'm:aln' }, + { name: 'm:diff', attributes: { 'm:val': '1' } }, + ], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '==' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + expect(result!.querySelector('mrow')).not.toBeNull(); + expect(result!.querySelector('menclose')).toBeNull(); + expect(result!.textContent).toBe('=='); + }); }); describe('m:borderBox converter', () => { @@ -3984,7 +4019,7 @@ describe('m:borderBox converter', () => { expect(menclose!.textContent).toBe('E'); }); - it('hides individual sides when hide flags are set', () => { + it('hides top and bottom sides (notation="left right")', () => { const omml = { name: 'm:oMath', elements: [ @@ -4009,11 +4044,8 @@ describe('m:borderBox converter', () => { const result = convertOmmlToMathml(omml, doc); const menclose = result!.querySelector('menclose'); expect(menclose).not.toBeNull(); - const notation = menclose!.getAttribute('notation')!; - expect(notation).toContain('left'); - expect(notation).toContain('right'); - expect(notation).not.toContain('top'); - expect(notation).not.toContain('bottom'); + // Exact string — production order is top/bottom/left/right, so a side-swap regression fails here. + expect(menclose!.getAttribute('notation')).toBe('left right'); }); it('adds strike notations', () => { @@ -4076,4 +4108,165 @@ describe('m:borderBox converter', () => { expect(menclose).toBeNull(); expect(result!.textContent).toBe('q'); }); + + // ── ST_OnOff variants (ECMA-376 §22.9.2.7) ──────────────────────────────── + // isOn accepts "1", "true", "on", and bare tags; rejects "0" / "false" / "off". + // Annex L.6.1.3 itself uses m:val="on" even though the normative enum is {0,1,true,false}. + + const makeBorderBox = (hideTopFlag: Record) => ({ + name: 'm:oMath', + elements: [ + { + name: 'm:borderBox', + elements: [ + { name: 'm:borderBoxPr', elements: [{ name: 'm:hideTop', ...hideTopFlag }] }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }], + }, + ], + }, + ], + }); + + it('treats m:val="true" as on (ST_OnOff)', () => { + const result = convertOmmlToMathml(makeBorderBox({ attributes: { 'm:val': 'true' } }), doc); + expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('bottom left right'); + }); + + it('treats m:val="on" as on (Annex L.6.1.3 form)', () => { + const result = convertOmmlToMathml(makeBorderBox({ attributes: { 'm:val': 'on' } }), doc); + expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('bottom left right'); + }); + + it('treats bare as on (spec default val=1)', () => { + const result = convertOmmlToMathml(makeBorderBox({}), doc); + expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('bottom left right'); + }); + + it('treats m:val="0" as off (top remains visible)', () => { + const result = convertOmmlToMathml(makeBorderBox({ attributes: { 'm:val': '0' } }), doc); + expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('box'); + }); + + it('treats m:val="false" as off', () => { + const result = convertOmmlToMathml(makeBorderBox({ attributes: { 'm:val': 'false' } }), doc); + expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('box'); + }); + + // ── Strike directions ───────────────────────────────────────────────────── + // BLTR (bottom-left → top-right = "/") maps to updiagonalstrike. + // TLBR (top-left → bottom-right = "\") maps to downdiagonalstrike. + // The directional naming is counter-intuitive — these tests pin it. + + const makeStrike = (strikeName: string) => ({ + name: 'm:oMath', + elements: [ + { + name: 'm:borderBox', + elements: [ + { + name: 'm:borderBoxPr', + elements: [ + { name: 'm:hideTop', attributes: { 'm:val': '1' } }, + { name: 'm:hideBot', attributes: { 'm:val': '1' } }, + { name: 'm:hideLeft', attributes: { 'm:val': '1' } }, + { name: 'm:hideRight', attributes: { 'm:val': '1' } }, + { name: strikeName, attributes: { 'm:val': '1' } }, + ], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }], + }, + ], + }, + ], + }); + + it('maps m:strikeBLTR to notation="updiagonalstrike" (/ direction)', () => { + const result = convertOmmlToMathml(makeStrike('m:strikeBLTR'), doc); + expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('updiagonalstrike'); + }); + + it('maps m:strikeTLBR to notation="downdiagonalstrike" (\\ direction)', () => { + const result = convertOmmlToMathml(makeStrike('m:strikeTLBR'), doc); + expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('downdiagonalstrike'); + }); + + it('maps m:strikeV to notation="verticalstrike"', () => { + const result = convertOmmlToMathml(makeStrike('m:strikeV'), doc); + expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('verticalstrike'); + }); + + it('combines multiple strikes in a fixed order', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:borderBox', + elements: [ + { + name: 'm:borderBoxPr', + elements: [ + { name: 'm:strikeBLTR', attributes: { 'm:val': '1' } }, + { name: 'm:strikeH', attributes: { 'm:val': '1' } }, + { name: 'm:strikeTLBR', attributes: { 'm:val': '1' } }, + { name: 'm:strikeV', attributes: { 'm:val': '1' } }, + ], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe( + 'box updiagonalstrike horizontalstrike downdiagonalstrike verticalstrike', + ); + }); + + it('combines partial hide flags with a strike (hideTop + strikeH)', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:borderBox', + elements: [ + { + name: 'm:borderBoxPr', + elements: [ + { name: 'm:hideTop', attributes: { 'm:val': '1' } }, + { name: 'm:strikeH', attributes: { 'm:val': '1' } }, + ], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('bottom left right horizontalstrike'); + }); + + it('returns null when m:e is empty (no bordered-but-empty )', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:borderBox', + elements: [{ name: 'm:borderBoxPr', elements: [{ name: 'm:strikeH', attributes: { 'm:val': '1' } }] }], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + // oMath still renders but has no children because borderBox dropped itself. + expect(result).toBeNull(); + }); }); From dc49c6c43e3cb83df7627d3c8b5a74a88531f5ab Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 20 Apr 2026 10:31:14 -0300 Subject: [PATCH 5/8] feat(math): polyfill MathML via CSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MathML Core (Chrome 109+, 2023) dropped — no browser paints it natively. Without this, m:borderBox content imports correctly but renders invisibly. Ship a small CSS polyfill that maps every notation token to borders or pseudo-element strike overlays: - box / top / bottom / left / right → CSS border sides - horizontalstrike / verticalstrike → ::after gradient layer (H or V) - updiagonalstrike / downdiagonalstrike → layered gradients via CSS custom properties so X patterns stack correctly Wired through the existing ensure*Styles pattern in renderer.ts. Zero bundle cost, no runtime polling, fully semantic (the DOM still says ). --- .../painters/dom/src/renderer.ts | 2 + .../layout-engine/painters/dom/src/styles.ts | 83 +++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 912c7db70b..9dcaf5dda0 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -91,6 +91,7 @@ import { ensureFieldAnnotationStyles, ensureImageSelectionStyles, ensureLinkStyles, + ensureMathMencloseStyles, ensurePrintStyles, ensureSdtContainerStyles, ensureTrackChangeStyles, @@ -1675,6 +1676,7 @@ export class DomPainter { ensureFieldAnnotationStyles(doc); ensureSdtContainerStyles(doc); ensureImageSelectionStyles(doc); + ensureMathMencloseStyles(doc); if (!this.isSemanticFlow && this.options.ruler?.enabled) { ensureRulerStyles(doc); } diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index e5bd524427..d3d4f06ba6 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -646,12 +646,81 @@ const IMAGE_SELECTION_STYLES = ` } `; +const MATH_MENCLOSE_STYLES = ` +/* MathML polyfill. + * + * MathML 3 defined with borders, strikes, and other + * enclosure notations. MathML Core (the subset shipped in Chrome 109+, 2023) + * dropped — the WG moved its rendering to CSS/SVG. Firefox and + * WebKit also do not paint it. Without this polyfill, m:borderBox content + * imports correctly (the notation attribute is right) but renders invisibly. + * + * Each notation token is composable: "box horizontalstrike" draws the box + * border and a horizontal strike together. Diagonal strikes layer through + * CSS custom properties so X patterns (both diagonals) stack correctly. + * + * @spec MathML 3 §3.3.8 menclose + */ +menclose { + display: inline-block; + position: relative; + padding: 0.15em 0.25em; + + --sd-menclose-stroke: currentColor; + --sd-menclose-h: none; + --sd-menclose-v: none; + --sd-menclose-up: none; + --sd-menclose-down: none; +} + +menclose[notation~="box"] { border: 1px solid var(--sd-menclose-stroke); } +menclose[notation~="roundedbox"] { border: 1px solid var(--sd-menclose-stroke); border-radius: 0.3em; } +menclose[notation~="top"] { border-top: 1px solid var(--sd-menclose-stroke); } +menclose[notation~="bottom"] { border-bottom: 1px solid var(--sd-menclose-stroke); } +menclose[notation~="left"] { border-left: 1px solid var(--sd-menclose-stroke); } +menclose[notation~="right"] { border-right: 1px solid var(--sd-menclose-stroke); } + +menclose[notation~="horizontalstrike"] { + --sd-menclose-h: linear-gradient(var(--sd-menclose-stroke), var(--sd-menclose-stroke)) no-repeat center / 100% 1px; +} +menclose[notation~="verticalstrike"] { + --sd-menclose-v: linear-gradient(var(--sd-menclose-stroke), var(--sd-menclose-stroke)) no-repeat center / 1px 100%; +} +menclose[notation~="updiagonalstrike"] { + --sd-menclose-up: linear-gradient( + to top right, + transparent calc(50% - 0.5px), + var(--sd-menclose-stroke) calc(50% - 0.5px), + var(--sd-menclose-stroke) calc(50% + 0.5px), + transparent calc(50% + 0.5px) + ); +} +menclose[notation~="downdiagonalstrike"] { + --sd-menclose-down: linear-gradient( + to bottom right, + transparent calc(50% - 0.5px), + var(--sd-menclose-stroke) calc(50% - 0.5px), + var(--sd-menclose-stroke) calc(50% + 0.5px), + transparent calc(50% + 0.5px) + ); +} + +menclose::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: var(--sd-menclose-h), var(--sd-menclose-v), var(--sd-menclose-up), var(--sd-menclose-down); +} +`; + let printStylesInjected = false; let linkStylesInjected = false; let trackChangeStylesInjected = false; let sdtContainerStylesInjected = false; let fieldAnnotationStylesInjected = false; let imageSelectionStylesInjected = false; +let mathMencloseStylesInjected = false; export const ensurePrintStyles = (doc: Document | null | undefined) => { if (printStylesInjected || !doc) return; @@ -712,3 +781,17 @@ export const ensureImageSelectionStyles = (doc: Document | null | undefined) => doc.head?.appendChild(styleEl); imageSelectionStylesInjected = true; }; + +/** + * Injects the MathML polyfill into the document head. Required + * because no browser paints menclose natively (MathML Core dropped it). See + * MATH_MENCLOSE_STYLES for the full rationale. + */ +export const ensureMathMencloseStyles = (doc: Document | null | undefined) => { + if (mathMencloseStylesInjected || !doc) return; + const styleEl = doc.createElement('style'); + styleEl.setAttribute('data-superdoc-math-menclose-styles', 'true'); + styleEl.textContent = MATH_MENCLOSE_STYLES; + doc.head?.appendChild(styleEl); + mathMencloseStylesInjected = true; +}; From 9c8aadc3c6722ef85d879b94da5e7396af850e03 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 20 Apr 2026 12:22:22 -0300 Subject: [PATCH 6/8] fix(math): correct diagonal strike directions in menclose polyfill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CSS linear-gradient direction keywords confusingly produce stripes perpendicular to the direction vector: - "to top right" progresses toward the top-right corner, which makes the visible color stripe run top-left to bottom-right ("\") - "to bottom right" progresses toward the bottom-right corner, which makes the stripe run bottom-left to top-right ("/") The polyfill had them swapped, so updiagonalstrike rendered as "\" and downdiagonalstrike as "/" — the opposite of what Word shows and what MathML 3 specifies. Swap the direction keywords and add a comment so the next reader doesn't re-flip them. --- packages/layout-engine/painters/dom/src/styles.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index d3d4f06ba6..b548d37107 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -686,9 +686,13 @@ menclose[notation~="horizontalstrike"] { menclose[notation~="verticalstrike"] { --sd-menclose-v: linear-gradient(var(--sd-menclose-stroke), var(--sd-menclose-stroke)) no-repeat center / 1px 100%; } +/* Gradient direction is perpendicular to the stripe it produces. + * "to bottom right" → stripe runs bottom-left → top-right (visually "/") = updiagonalstrike. + * "to top right" → stripe runs top-left → bottom-right (visually "\") = downdiagonalstrike. + */ menclose[notation~="updiagonalstrike"] { --sd-menclose-up: linear-gradient( - to top right, + to bottom right, transparent calc(50% - 0.5px), var(--sd-menclose-stroke) calc(50% - 0.5px), var(--sd-menclose-stroke) calc(50% + 0.5px), @@ -697,7 +701,7 @@ menclose[notation~="updiagonalstrike"] { } menclose[notation~="downdiagonalstrike"] { --sd-menclose-down: linear-gradient( - to bottom right, + to top right, transparent calc(50% - 0.5px), var(--sd-menclose-stroke) calc(50% - 0.5px), var(--sd-menclose-stroke) calc(50% + 0.5px), From 74d64b163341b00ea6ad645918de58947497d099 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 20 Apr 2026 12:27:58 -0300 Subject: [PATCH 7/8] fix(math): wrap borderBox content in for horizontal row layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MathML Core does not define , so Chrome treats it as an unknown element and does not run the row-layout algorithm on its children. Each child rendered with display: block math and stacked vertically — a multi-element expression inside a borderBox (e.g. Annex L.6.1.3's a² = b² + c²) became a column of letters. Wrap the content in an inner before appending to . is in MathML Core, so the row layout runs on its children and everything stays inline. The outer remains the polyfill target for borders and strikes. --- .../painters/dom/src/features/math/converters/box.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/box.ts b/packages/layout-engine/painters/dom/src/features/math/converters/box.ts index 6eb6a8bf6a..45dbf14bda 100644 --- a/packages/layout-engine/painters/dom/src/features/math/converters/box.ts +++ b/packages/layout-engine/painters/dom/src/features/math/converters/box.ts @@ -106,9 +106,17 @@ export const convertBorderBox: MathObjectConverter = (node, doc, convertChildren return mrow; } + // Wrap the content in an inner before placing it inside . + // MathML Core dropped , so Chrome treats it as unknown and does + // not apply row layout — each child would render as its own `block math` + // line, stacking vertically. An inner is a MathML Core element, so + // the row layout runs on its children and everything stays inline. + const innerMrow = doc.createElementNS(MATHML_NS, 'mrow'); + innerMrow.appendChild(content); + const menclose = doc.createElementNS(MATHML_NS, 'menclose'); menclose.setAttribute('notation', notations.join(' ')); - menclose.appendChild(content); + menclose.appendChild(innerMrow); return menclose; }; From 95942a19fee51c58aefbb9c91b877f55c27a9044 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 20 Apr 2026 12:41:23 -0300 Subject: [PATCH 8/8] test(behavior): cover m:borderBox + menclose polyfill end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loads the 30-scenario fixture (sd-2750-borderbox.docx) and asserts: - every scenario produces the expected notation attribute in DOM order - multi-child content (Annex L.6.1.3: a² = b² + c²) renders as a horizontal row — width > 1.5× height, inner present, 5 children - ST_OnOff variants (1/true/on/bare/0/false) resolve correctly through the full import path, not just the unit converter - m:box silently drops boxPr (opEmu/noBreak/aln/diff) and emits - the menclose CSS polyfill stylesheet is injected into the document Runs across chromium/firefox/webkit. Complements the 53 unit tests by exercising the cross-package path: OMML import → pm-adapter → painter-dom → rendered MathML. --- .../importing/fixtures/sd-2750-borderbox.docx | Bin 0 -> 12579 bytes .../importing/math-box-border-box.spec.ts | 146 ++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 tests/behavior/tests/importing/fixtures/sd-2750-borderbox.docx create mode 100644 tests/behavior/tests/importing/math-box-border-box.spec.ts diff --git a/tests/behavior/tests/importing/fixtures/sd-2750-borderbox.docx b/tests/behavior/tests/importing/fixtures/sd-2750-borderbox.docx new file mode 100644 index 0000000000000000000000000000000000000000..99495ef04cd3850a81b73ced2d8f65216c94f4ae GIT binary patch literal 12579 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', () => { + test('every scenario produces the expected notation attribute', async ({ superdoc }) => { + await superdoc.loadDocument(BORDERBOX_DOC); + await superdoc.waitForStable(); + + const notations = await superdoc.page.evaluate(() => + Array.from(document.querySelectorAll('menclose')).map((el) => el.getAttribute('notation')), + ); + + // Ordered — matches the scenario numbering inside the DOCX. + expect(notations).toEqual([ + 'box', // 1. default + 'box', // 2. empty borderBoxPr + 'bottom left right', // 3. m:val="1" + 'bottom left right', // 4. m:val="true" + 'bottom left right', // 5. m:val="on" (Annex L) + 'bottom left right', // 6. bare + 'box', // 7. m:val="0" → not hidden + 'box', // 8. m:val="false" + 'top left right', // 9. hideBot only + 'top bottom right', // 10. hideLeft only + 'top bottom left', // 11. hideRight only + 'bottom left', // 12. hideTop + hideRight (spec §22.1.2.12) + // 13 (all hidden, no strikes) → fallback, no menclose + 'updiagonalstrike', // 14. strikeBLTR → "/" + 'downdiagonalstrike', // 15. strikeTLBR → "\" + 'horizontalstrike', // 16 + 'verticalstrike', // 17 + 'updiagonalstrike downdiagonalstrike', // 18. X pattern + 'updiagonalstrike horizontalstrike downdiagonalstrike verticalstrike', // 19 + 'box horizontalstrike', // 20 + 'bottom left right horizontalstrike', // 21 + 'box downdiagonalstrike', // 22. Annex L.6.1.3 (m:val="on") + // 23–27 are m:box scenarios → , no menclose + 'box', // 28. m:box inside m:borderBox → outer menclose + 'box', // 29. m:borderBox inside m:box → inner menclose + 'left right', // 30. nested borderBox — outer + 'horizontalstrike', // 30. nested borderBox — inner + ]); + }); + + test('multi-child borderBox content renders as a horizontal row (Annex L.6.1.3)', async ({ superdoc }) => { + await superdoc.loadDocument(BORDERBOX_DOC); + await superdoc.waitForStable(); + + // Without the inner wrap, Chrome's MathML Core treats as + // unknown and each child renders with `display: block math` stacked + // vertically. This test asserts horizontal layout. + const layout = await superdoc.page.evaluate(() => { + const annex = Array.from(document.querySelectorAll('menclose')).find( + (el) => el.getAttribute('notation') === 'box downdiagonalstrike', + ); + if (!annex) return null; + const rect = annex.getBoundingClientRect(); + return { + wider_than_tall: rect.width > rect.height * 1.5, + hasInnerMrow: annex.children[0]?.localName === 'mrow', + innerChildCount: annex.children[0]?.children.length ?? 0, + }; + }); + + expect(layout).not.toBeNull(); + expect(layout!.wider_than_tall).toBe(true); + expect(layout!.hasInnerMrow).toBe(true); + expect(layout!.innerChildCount).toBe(5); // a², =, b², +, c² + }); + + test('ST_OnOff variants (1/true/on/bare/0/false) all resolve correctly', async ({ superdoc }) => { + await superdoc.loadDocument(BORDERBOX_DOC); + await superdoc.waitForStable(); + + // Scenarios 3-8 all share a single hideTop flag; only the m:val form differs. + // "1", "true", "on", and bare-tag → top hidden. "0" and "false" → top visible. + const notations = await superdoc.page.evaluate(() => { + const all = Array.from(document.querySelectorAll('menclose')).map((el) => el.getAttribute('notation')); + return all.slice(2, 8); // indexes 2..7 = scenarios 3..8 + }); + + // First four should all mean "top hidden" + expect(notations.slice(0, 4)).toEqual([ + 'bottom left right', // "1" + 'bottom left right', // "true" + 'bottom left right', // "on" + 'bottom left right', // bare tag + ]); + // Last two should mean "nothing hidden" → default box + expect(notations.slice(4)).toEqual(['box', 'box']); + }); + + test('m:box drops boxPr semantics and falls back to ', async ({ superdoc }) => { + await superdoc.loadDocument(BORDERBOX_DOC); + await superdoc.waitForStable(); + + // Scenarios 23-26 all produce (opEmu / noBreak / aln / diff currently ignored). + // Scenario 27 (empty m:box) should drop entirely. + const mrowOnlyTexts = await superdoc.page.evaluate(() => { + return Array.from(document.querySelectorAll('math')) + .filter((m) => !m.querySelector('menclose')) + .map((m) => m.textContent?.trim()); + }); + + // 13 (all-hides fallback), 23, 24, 25, 26 → mrow (5 total). 27 is dropped. + expect(mrowOnlyTexts).toEqual(['nobdr', '==', 'a==b', 'nbr', 'pAll']); + }); + + test('menclose polyfill stylesheet is injected', async ({ superdoc }) => { + await superdoc.loadDocument(BORDERBOX_DOC); + await superdoc.waitForStable(); + + // Without this stylesheet, borders and strikes are invisible in Chrome + // because MathML Core dropped . The polyfill lives in + // styles.ts → ensureMathMencloseStyles(). + const polyfill = await superdoc.page.evaluate(() => { + const style = document.querySelector('style[data-superdoc-math-menclose-styles]'); + if (!style) return null; + const css = style.textContent || ''; + return { + bytes: css.length, + hasBoxBorder: css.includes('notation~="box"') && css.includes('border:'), + hasUpDiagonal: css.includes('updiagonalstrike'), + hasDownDiagonal: css.includes('downdiagonalstrike'), + }; + }); + + expect(polyfill).not.toBeNull(); + expect(polyfill!.bytes).toBeGreaterThan(500); + expect(polyfill!.hasBoxBorder).toBe(true); + expect(polyfill!.hasUpDiagonal).toBe(true); + expect(polyfill!.hasDownDiagonal).toBe(true); + }); +});