From 50bddbc78f11ad3cbf06113dbdf2543bee559a9d Mon Sep 17 00:00:00 2001 From: Muhammad Nur Alamsyah Anwar Date: Sat, 28 Mar 2026 14:23:17 +0800 Subject: [PATCH 1/3] 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 ed988e30f9aacc3b1bca3cc93b60fe7abeb2e22c Mon Sep 17 00:00:00 2001 From: Muhammad Nur Alamsyah Anwar Date: Sat, 28 Mar 2026 18:20:05 +0800 Subject: [PATCH 2/3] 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) From 76ed90fe154e0b2750aa1fc7b8d7a66cb90c15b7 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 28 Mar 2026 09:04:57 -0300 Subject: [PATCH 3/3] fix(math): use U+2015 for underbar accent instead of U+0332 (SD-2403) U+0332 (combining low line) renders invisibly in some browsers when used as a standalone MathML operator. Replace with U+2015 (horizontal bar) which renders consistently. Also moves m:f to the "Implemented" section in the math object registry. --- .../dom/src/features/math/converters/bar.ts | 6 +- .../src/features/math/omml-to-mathml.test.ts | 61 ++++++++++++------- .../dom/src/features/math/omml-to-mathml.ts | 2 +- 3 files changed, 42 insertions(+), 27 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 8d731b9de0..89685618f8 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 @@ -10,7 +10,7 @@ const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; * * MathML output: * top: base - * bot (default): base ̲ + * bot (default): base * * Word renders an underbar when no position is specified, so the default is "bot". * @@ -35,8 +35,8 @@ export const convertBar: MathObjectConverter = (node, doc, convertChildren) => { 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'; + // 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/omml-to-mathml.test.ts b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts index 4b7921b6e2..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 @@ -209,13 +209,18 @@ 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' }] }] }] }, - ], - }], + 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(); @@ -226,16 +231,21 @@ describe('m:bar converter', () => { expect(mo?.textContent).toBe('\u203E'); }); - it('renders underbar (bot) as with U+0332', () => { + 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' }] }] }] }, - ], - }], + 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(); @@ -243,18 +253,23 @@ describe('m:bar converter', () => { expect(munder).not.toBeNull(); expect(munder!.firstElementChild!.textContent).toBe('y'); const mo = munder!.querySelector('mo'); - expect(mo?.textContent).toBe('\u0332'); + 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' }] }] }] }, - ], - }], + 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(); @@ -262,6 +277,6 @@ describe('m:bar converter', () => { expect(munder).not.toBeNull(); expect(munder!.firstElementChild!.textContent).toBe('z'); const mo = munder!.querySelector('mo'); - expect(mo?.textContent).toBe('\u0332'); + 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 c941133d44..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 @@ -31,6 +31,7 @@ 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) @@ -38,7 +39,6 @@ const MATH_OBJECT_REGISTRY: Record = { '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)