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..47cae2fa4c --- /dev/null +++ b/packages/layout-engine/painters/dom/src/features/math/converters/box.ts @@ -0,0 +1,101 @@ +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'); + + /** 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); + + 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 content = convertChildren(base?.elements ?? []); + + if (notations.length === 0) { + const mrow = doc.createElementNS(MATHML_NS, 'mrow'); + mrow.appendChild(content); + return mrow; + } + + 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/converters/index.ts b/packages/layout-engine/painters/dom/src/features/math/converters/index.ts index 0e7eb25496..74aa424577 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 @@ -13,3 +13,4 @@ export { convertFunction } from './function.js'; export { convertSubscript } from './subscript.js'; export { convertSuperscript } from './superscript.js'; export { convertSubSuperscript } from './sub-superscript.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 e689c8f842..bab5ef672a 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 @@ -927,3 +927,152 @@ describe('m:func converter', () => { expect(mis[1]!.textContent).toBe('cos'); }); }); + +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'); + }); + + 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'); + }); +}); 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 1a5672d61d..ac8d5fefa4 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 @@ -18,6 +18,8 @@ import { convertSubscript, convertSuperscript, convertSubSuperscript, + convertBox, + convertBorderBox, } from './converters/index.js'; export const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; @@ -47,8 +49,8 @@ const MATH_OBJECT_REGISTRY: Record = { // ── Not yet implemented (community contributions welcome) ──────────────── 'm:acc': null, // Accent (diacritical mark above base) - '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:d': null, // Delimiter (parentheses, brackets, braces) 'm:eqArr': null, // Equation array (vertical array of equations) 'm:groupChr': null, // Group character (overbrace, underbrace)