diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/bar.ts b/packages/layout-engine/painters/dom/src/features/math/converters/bar.ts new file mode 100644 index 0000000000..89685618f8 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/features/math/converters/bar.ts @@ -0,0 +1,43 @@ +import type { MathObjectConverter } from '../types.js'; + +const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; + +/** + * Convert m:bar (overbar/underbar) to MathML or . + * + * OMML structure: + * m:bar → m:barPr (optional: m:pos@m:val="top"|"bot"), m:e (base expression) + * + * MathML output: + * top: base + * bot (default): base + * + * Word renders an underbar when no position is specified, so the default is "bot". + * + * @spec ECMA-376 §22.1.2.7 + */ +export const convertBar: MathObjectConverter = (node, doc, convertChildren) => { + const elements = node.elements ?? []; + + const barPr = elements.find((e) => e.name === 'm:barPr'); + const pos = barPr?.elements?.find((e) => e.name === 'm:pos'); + const posVal = pos?.attributes?.['m:val']; + const isUnder = posVal !== 'top'; + + const base = elements.find((e) => e.name === 'm:e'); + + const wrapper = doc.createElementNS(MATHML_NS, isUnder ? 'munder' : 'mover'); + + const baseContent = convertChildren(base?.elements ?? []); + const mrow = doc.createElementNS(MATHML_NS, 'mrow'); + mrow.appendChild(baseContent); + wrapper.appendChild(mrow); + + const accent = doc.createElementNS(MATHML_NS, 'mo'); + accent.setAttribute('stretchy', 'true'); + // U+203E = overline, U+2015 = horizontal bar (underbar) + accent.textContent = isUnder ? '\u2015' : '\u203E'; + wrapper.appendChild(accent); + + return wrapper; +}; 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 4695d3608e..b1ec9c3e1c 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 @@ -8,3 +8,4 @@ */ export { convertMathRun } from './math-run.js'; export { convertFraction } from './fraction.js'; +export { convertBar } from './bar.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 4330cb04d2..6a31f8a695 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 @@ -204,3 +204,79 @@ describe('convertOmmlToMathml', () => { expect(children.some((c) => c.localName === 'mn')).toBe(true); // 1 }); }); + +describe('m:bar converter', () => { + it('renders overbar (top) as with U+203E', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:bar', + elements: [ + { name: 'm:barPr', elements: [{ name: 'm:pos', attributes: { 'm:val': 'top' } }] }, + { + 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 mover = result!.querySelector('mover'); + expect(mover).not.toBeNull(); + expect(mover!.firstElementChild!.textContent).toBe('x'); + const mo = mover!.querySelector('mo'); + expect(mo?.textContent).toBe('\u203E'); + }); + + it('renders underbar (bot) as with U+2015', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:bar', + elements: [ + { name: 'm:barPr', elements: [{ name: 'm:pos', attributes: { 'm:val': 'bot' } }] }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'y' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const munder = result!.querySelector('munder'); + expect(munder).not.toBeNull(); + expect(munder!.firstElementChild!.textContent).toBe('y'); + const mo = munder!.querySelector('mo'); + expect(mo?.textContent).toBe('\u2015'); + }); + + it('defaults to underbar when m:barPr is missing (matches Word behavior)', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:bar', + elements: [ + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'z' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const munder = result!.querySelector('munder'); + expect(munder).not.toBeNull(); + expect(munder!.firstElementChild!.textContent).toBe('z'); + const mo = munder!.querySelector('mo'); + expect(mo?.textContent).toBe('\u2015'); + }); +}); 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 6d64993a4c..958f74cd98 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 @@ -10,7 +10,7 @@ */ import type { OmmlJsonNode, MathObjectConverter } from './types.js'; -import { convertMathRun, convertFraction } from './converters/index.js'; +import { convertMathRun, convertFraction, convertBar } from './converters/index.js'; export const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; @@ -30,15 +30,15 @@ export const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; const MATH_OBJECT_REGISTRY: Record = { // ── Implemented ────────────────────────────────────────────────────────── 'm:r': convertMathRun, + 'm:bar': convertBar, // Bar (overbar/underbar) + 'm:f': convertFraction, // Fraction (numerator/denominator) // ── Not yet implemented (community contributions welcome) ──────────────── 'm:acc': null, // Accent (diacritical mark above base) - 'm:bar': null, // Bar (overbar/underbar) 'm:borderBox': null, // Border box (border around math content) 'm:box': null, // Box (invisible grouping container) 'm:d': null, // Delimiter (parentheses, brackets, braces) 'm:eqArr': null, // Equation array (vertical array of equations) - 'm:f': convertFraction, // Fraction (numerator/denominator) 'm:func': null, // Function apply (sin, cos, log, etc.) 'm:groupChr': null, // Group character (overbrace, underbrace) 'm:limLow': null, // Lower limit (e.g., lim)