From 983df1346b24d3431991afaa964d4c3d647f0834 Mon Sep 17 00:00:00 2001 From: Muhammad Nur Alamsyah Anwar Date: Sat, 28 Mar 2026 14:23:17 +0800 Subject: [PATCH 1/2] feat(math): implement m:bar overbar/underbar OMML converter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #2610 - Add convertBar() in converters/bar.ts: - Reads m:barPr/m:pos@m:val to determine position - 'top' (default) → with U+203E (overline) - 'bot' → with U+0332 (combining low line) - stretchy='true' so the bar stretches over the base expression - Register 'm:bar': convertBar in MATH_OBJECT_REGISTRY - Export convertBar from converters/index.ts - Add 3 unit tests: overbar, underbar, missing barPr fallback --- .../dom/src/features/math/converters/bar.ts | 37 ++++++++++++ .../dom/src/features/math/converters/index.ts | 1 + .../src/features/math/omml-to-mathml.test.ts | 56 +++++++++++++++++++ .../dom/src/features/math/omml-to-mathml.ts | 4 +- 4 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 packages/layout-engine/painters/dom/src/features/math/converters/bar.ts 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..ba7924b666 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/features/math/converters/bar.ts @@ -0,0 +1,37 @@ +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 (default): base + * bot: base ̲ + * + * @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 === 'bot'; + + const base = elements.find((e) => e.name === 'm:e'); + + const wrapper = doc.createElementNS(MATHML_NS, isUnder ? 'munder' : 'mover'); + wrapper.appendChild(convertChildren(base?.elements ?? [])); + + const accent = doc.createElementNS(MATHML_NS, 'mo'); + accent.setAttribute('stretchy', 'true'); + // U+203E = overline, U+0332 = combining low line (underbar) + accent.textContent = isUnder ? '\u0332' : '\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..cfdc8d755b 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,59 @@ 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(); + const mo = mover!.querySelector('mo'); + expect(mo?.textContent).toBe('\u203E'); + }); + + it('renders underbar (bot) as with U+0332', () => { + 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(); + const mo = munder!.querySelector('mo'); + expect(mo?.textContent).toBe('\u0332'); + }); + + it('defaults to overbar when m:barPr is missing', () => { + 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 mover = result!.querySelector('mover'); + expect(mover).not.toBeNull(); + }); +}); 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..304c832509 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'; @@ -33,7 +33,7 @@ const MATH_OBJECT_REGISTRY: Record = { // ── Not yet implemented (community contributions welcome) ──────────────── 'm:acc': null, // Accent (diacritical mark above base) - 'm:bar': null, // Bar (overbar/underbar) + 'm:bar': convertBar, // 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) From 7ab09f9f083bea2cdf47b0562da50f5d00ee2a70 Mon Sep 17 00:00:00 2001 From: Muhammad Nur Alamsyah Anwar Date: Sat, 28 Mar 2026 18:20:05 +0800 Subject: [PATCH 2/2] fix(math): address review feedback on m:bar converter - Default to munder (underbar) when no position is specified, matching Word's rendering behavior (posVal !== 'top') - Wrap base content in to correctly group multi-token expressions like 'x + y' as a single MathML child - Move m:bar entry from 'Not yet implemented' to 'Implemented' section in the registry - Strengthen tests: assert base content text and character for all three cases (top, bot, default) --- .../dom/src/features/math/converters/bar.ts | 14 ++++++++++---- .../dom/src/features/math/omml-to-mathml.test.ts | 11 ++++++++--- .../dom/src/features/math/omml-to-mathml.ts | 2 +- 3 files changed, 19 insertions(+), 8 deletions(-) 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 index ba7924b666..8d731b9de0 100644 --- a/packages/layout-engine/painters/dom/src/features/math/converters/bar.ts +++ b/packages/layout-engine/painters/dom/src/features/math/converters/bar.ts @@ -9,8 +9,10 @@ const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; * m:bar → m:barPr (optional: m:pos@m:val="top"|"bot"), m:e (base expression) * * MathML output: - * top (default): base - * bot: base ̲ + * 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 */ @@ -20,12 +22,16 @@ export const convertBar: MathObjectConverter = (node, doc, convertChildren) => { 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 === 'bot'; + const isUnder = posVal !== 'top'; const base = elements.find((e) => e.name === 'm:e'); const wrapper = doc.createElementNS(MATHML_NS, isUnder ? 'munder' : 'mover'); - wrapper.appendChild(convertChildren(base?.elements ?? [])); + + 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'); 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 cfdc8d755b..4b7921b6e2 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 @@ -221,6 +221,7 @@ describe('m:bar converter', () => { 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'); }); @@ -240,11 +241,12 @@ describe('m:bar converter', () => { 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('\u0332'); }); - it('defaults to overbar when m:barPr is missing', () => { + it('defaults to underbar when m:barPr is missing (matches Word behavior)', () => { const omml = { name: 'm:oMath', elements: [{ @@ -256,7 +258,10 @@ describe('m:bar converter', () => { }; const result = convertOmmlToMathml(omml, doc); expect(result).not.toBeNull(); - const mover = result!.querySelector('mover'); - expect(mover).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('\u0332'); }); }); 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 304c832509..c941133d44 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 @@ -30,10 +30,10 @@ 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) // ── Not yet implemented (community contributions welcome) ──────────────── 'm:acc': null, // Accent (diacritical mark above base) - 'm:bar': convertBar, // 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)