diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/group-character.ts b/packages/layout-engine/painters/dom/src/features/math/converters/group-character.ts new file mode 100644 index 0000000000..25ffc51741 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/features/math/converters/group-character.ts @@ -0,0 +1,78 @@ +import type { MathObjectConverter } from '../types.js'; + +const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; + +/** Default group character: bottom curly bracket (U+23DF). */ +const DEFAULT_GROUP_CHAR = '\u23DF'; + +// Approximate shift used to distinguish non-natural m:vertJc combinations from their +// natural counterparts. Chrome's MathML engine ignores , and overriding +// `display` on / breaks their native vertical stacking, so we use +// `position: relative` + `top` instead. The value approximates the group-character +// object's half-height at 1em font size. +const VERT_JC_SHIFT_EM = 1; + +/** + * Convert m:groupChr (group character) to MathML or . + * + * OMML structure: + * m:groupChr → m:groupChrPr (optional: m:chr@m:val, m:pos@m:val, m:vertJc@m:val), m:e + * + * MathML output: + * pos="bot" (default): base char + * pos="top": base char + * + * Defaults (ECMA-376 §22.1.2.20, §22.1.2.42, §22.1.2.119): + * m:chr absent → U+23DF (bottom curly bracket) + * m:chr present without m:val → hidden character + * m:pos absent → "bot" + * m:vertJc present without m:val → "bot" + * + * vertJc handling: m:vertJc specifies which edge of the group-character object aligns + * with the surrounding baseline. Natural / rendering puts the base on + * the baseline, which matches (pos=bot, vertJc=top) and (pos=top, vertJc=bot). Word + * renders an absent m:vertJc as the natural layout for the given position, so a shift + * is only applied when m:vertJc is explicitly set to the non-natural value for the pos. + * + * @spec ECMA-376 §22.1.2.41 + */ +export const convertGroupCharacter: MathObjectConverter = (node, doc, convertChildren) => { + const elements = node.elements ?? []; + const groupChrPr = elements.find((e) => e.name === 'm:groupChrPr'); + const base = elements.find((e) => e.name === 'm:e'); + + const chr = groupChrPr?.elements?.find((e) => e.name === 'm:chr'); + const pos = groupChrPr?.elements?.find((e) => e.name === 'm:pos'); + const vertJc = groupChrPr?.elements?.find((e) => e.name === 'm:vertJc'); + + const groupChar = chr ? (chr.attributes?.['m:val'] ?? '') : DEFAULT_GROUP_CHAR; + const position = pos?.attributes?.['m:val'] ?? 'bot'; + const vertJustify = vertJc ? (vertJc.attributes?.['m:val'] ?? 'bot') : null; + + const wrapper = doc.createElementNS(MATHML_NS, position === 'top' ? 'mover' : 'munder'); + + const baseRow = doc.createElementNS(MATHML_NS, 'mrow'); + baseRow.appendChild(convertChildren(base?.elements ?? [])); + wrapper.appendChild(baseRow); + + const mo = doc.createElementNS(MATHML_NS, 'mo'); + mo.setAttribute('stretchy', 'true'); + mo.textContent = groupChar; + wrapper.appendChild(mo); + + // Natural baseline: pos=top pairs with vertJc=bot, pos=bot pairs with vertJc=top. + // Only shift when vertJc is explicitly the non-natural value; an absent vertJc + // renders naturally (matches Word). + if (vertJustify) { + wrapper.setAttribute('data-vert-jc', vertJustify); + const naturalVertJc = position === 'top' ? 'bot' : 'top'; + if (vertJustify !== naturalVertJc) { + // pos=top,vertJc=top → shift the whole construct DOWN (char top to baseline). + // pos=bot,vertJc=bot → shift the whole construct UP (char bottom to baseline). + const direction = position === 'top' ? 1 : -1; + wrapper.setAttribute('style', `position: relative; top: ${direction * VERT_JC_SHIFT_EM}em;`); + } + } + + 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 c1f79b657f..3d9960dda6 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 @@ -22,3 +22,4 @@ export { convertLowerLimit } from './lower-limit.js'; export { convertUpperLimit } from './upper-limit.js'; export { convertNary } from './nary.js'; export { convertPhantom } from './phantom.js'; +export { convertGroupCharacter } from './group-character.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 1883f32b72..ea75cd9e55 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 @@ -3453,3 +3453,170 @@ describe('m:phant converter', () => { expect(mpadded!.querySelector('mphantom')).not.toBeNull(); }); }); + +describe('m:groupChr converter', () => { + it('converts bottom underbrace to with default character', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:groupChr', + 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(); + const munder = result!.querySelector('munder'); + expect(munder).not.toBeNull(); + expect(munder!.children[0]!.textContent).toBe('x'); + const groupMo = munder!.children[1] as Element; + expect(groupMo.localName).toBe('mo'); + expect(groupMo.textContent).toBe('\u23DF'); + }); + + it('hides the group character when m:chr is present without m:val', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:groupChr', + elements: [ + { + name: 'm:groupChrPr', + elements: [{ name: 'm:chr' }], + }, + { + 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 munder = result!.querySelector('munder'); + expect(munder).not.toBeNull(); + const mo = munder!.querySelector('mo'); + expect(mo!.textContent).toBe(''); + }); + + it('converts top overbrace to ', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:groupChr', + elements: [ + { + name: 'm:groupChrPr', + elements: [ + { name: 'm:chr', attributes: { 'm:val': '\u23DE' } }, + { name: 'm:pos', attributes: { 'm:val': 'top' } }, + ], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'y' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + const mover = result!.querySelector('mover'); + expect(mover).not.toBeNull(); + expect(mover!.children[0]!.textContent).toBe('y'); + const mo = mover!.querySelector('mo'); + expect(mo!.textContent).toBe('\u23DE'); + }); + + describe('m:vertJc baseline alignment', () => { + const buildGroupChr = (props: Array<{ name: string; attributes?: Record }>) => ({ + name: 'm:oMath', + elements: [ + { + name: 'm:groupChr', + elements: [ + { name: 'm:groupChrPr', elements: props }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }], + }, + ], + }, + ], + }); + + it('applies no shift when m:vertJc is absent (natural layout)', () => { + const omml = buildGroupChr([ + { name: 'm:chr', attributes: { 'm:val': '\u23DE' } }, + { name: 'm:pos', attributes: { 'm:val': 'top' } }, + ]); + const mover = convertOmmlToMathml(omml, doc)!.querySelector('mover')!; + expect(mover.getAttribute('style')).toBeNull(); + expect(mover.getAttribute('data-vert-jc')).toBeNull(); + }); + + it('pos=top, vertJc=bot renders natural mover without shift', () => { + const omml = buildGroupChr([ + { name: 'm:chr', attributes: { 'm:val': '\u23DE' } }, + { name: 'm:pos', attributes: { 'm:val': 'top' } }, + { name: 'm:vertJc', attributes: { 'm:val': 'bot' } }, + ]); + const mover = convertOmmlToMathml(omml, doc)!.querySelector('mover')!; + expect(mover.getAttribute('data-vert-jc')).toBe('bot'); + expect(mover.getAttribute('style')).toBeNull(); + }); + + it('pos=bot, vertJc=top renders natural munder without shift', () => { + const omml = buildGroupChr([ + { name: 'm:chr', attributes: { 'm:val': '\u23DF' } }, + { name: 'm:pos', attributes: { 'm:val': 'bot' } }, + { name: 'm:vertJc', attributes: { 'm:val': 'top' } }, + ]); + const munder = convertOmmlToMathml(omml, doc)!.querySelector('munder')!; + expect(munder.getAttribute('data-vert-jc')).toBe('top'); + expect(munder.getAttribute('style')).toBeNull(); + }); + + it('pos=top, vertJc=top shifts the construct down', () => { + const omml = buildGroupChr([ + { name: 'm:chr', attributes: { 'm:val': '\u23DE' } }, + { name: 'm:pos', attributes: { 'm:val': 'top' } }, + { name: 'm:vertJc', attributes: { 'm:val': 'top' } }, + ]); + const mover = convertOmmlToMathml(omml, doc)!.querySelector('mover')!; + expect(mover.getAttribute('data-vert-jc')).toBe('top'); + expect(mover.getAttribute('style')).toContain('top: 1em'); + }); + + it('pos=bot, vertJc=bot shifts the construct up', () => { + const omml = buildGroupChr([ + { name: 'm:chr', attributes: { 'm:val': '\u23DF' } }, + { name: 'm:pos', attributes: { 'm:val': 'bot' } }, + { name: 'm:vertJc', attributes: { 'm:val': 'bot' } }, + ]); + const munder = convertOmmlToMathml(omml, doc)!.querySelector('munder')!; + expect(munder.getAttribute('data-vert-jc')).toBe('bot'); + expect(munder.getAttribute('style')).toContain('top: -1em'); + }); + + it('vertJc present without m:val defaults to "bot"', () => { + const omml = buildGroupChr([ + { name: 'm:chr', attributes: { 'm:val': '\u23DE' } }, + { name: 'm:pos', attributes: { 'm:val': 'top' } }, + { name: 'm:vertJc' }, + ]); + const mover = convertOmmlToMathml(omml, doc)!.querySelector('mover')!; + expect(mover.getAttribute('data-vert-jc')).toBe('bot'); + expect(mover.getAttribute('style')).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 b55e400ae4..f959e9054b 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 @@ -27,6 +27,7 @@ import { convertUpperLimit, convertNary, convertPhantom, + convertGroupCharacter, } from './converters/index.js'; export const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; @@ -66,7 +67,7 @@ const MATH_OBJECT_REGISTRY: Record = { // ── Not yet implemented (community contributions welcome) ──────────────── 'm:borderBox': null, // Border box (border around math content) 'm:box': null, // Box (invisible grouping container) - 'm:groupChr': null, // Group character (overbrace, underbrace) + 'm:groupChr': convertGroupCharacter, // Group character (overbrace, underbrace) 'm:m': null, // Matrix (grid of elements) }; diff --git a/packages/layout-engine/pm-adapter/src/converters/math-constants.test.ts b/packages/layout-engine/pm-adapter/src/converters/math-constants.test.ts index 45a7e44a4a..21a0f26775 100644 --- a/packages/layout-engine/pm-adapter/src/converters/math-constants.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/math-constants.test.ts @@ -111,6 +111,20 @@ describe('estimateMathDimensions', () => { expect(width).toBe(50); // 5 chars * 10px }); + it('increases height for group character (m:groupChr)', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:groupChr', + elements: [{ name: 'm:e', elements: [{ name: 'm:r' }] }], + }, + ], + }; + const { height } = estimateMathDimensions('x', omml); + expect(height).toBeGreaterThan(MATH_DEFAULT_HEIGHT); + }); + it('enforces minimum width', () => { const { width } = estimateMathDimensions('x'); expect(width).toBe(20); // MATH_MIN_WIDTH diff --git a/packages/layout-engine/pm-adapter/src/converters/math-constants.ts b/packages/layout-engine/pm-adapter/src/converters/math-constants.ts index 74e5a430e0..a42ddf3870 100644 --- a/packages/layout-engine/pm-adapter/src/converters/math-constants.ts +++ b/packages/layout-engine/pm-adapter/src/converters/math-constants.ts @@ -23,6 +23,7 @@ const VERTICAL_ELEMENTS: Record = { 'm:sSup': 0.1, // Superscript 'm:sSubSup': 0.2, // Sub-superscript 'm:sPre': 0.2, // Pre-sub-superscript + 'm:groupChr': 0.35, // Group character (overbrace/underbrace) }; /** Count elements in an m:eqArr (equation array) for row-based height. */ diff --git a/tests/behavior/tests/importing/fixtures/math-groupchr-tests.docx b/tests/behavior/tests/importing/fixtures/math-groupchr-tests.docx new file mode 100644 index 0000000000..bc07b05d13 Binary files /dev/null and b/tests/behavior/tests/importing/fixtures/math-groupchr-tests.docx differ diff --git a/tests/behavior/tests/importing/math-equations.spec.ts b/tests/behavior/tests/importing/math-equations.spec.ts index 72faec4e32..9cef64f904 100644 --- a/tests/behavior/tests/importing/math-equations.spec.ts +++ b/tests/behavior/tests/importing/math-equations.spec.ts @@ -12,6 +12,7 @@ const LIMIT_DOC = path.resolve(__dirname, 'fixtures/math-limit-tests.docx'); const EQARR_DOC = path.resolve(__dirname, 'fixtures/math-eqarr-tests.docx'); const NARY_DOC = path.resolve(__dirname, 'fixtures/math-nary-tests.docx'); const PHANTOM_DOC = path.resolve(__dirname, 'fixtures/math-phantom-tests.docx'); +const GROUPCHR_DOC = path.resolve(__dirname, 'fixtures/math-groupchr-tests.docx'); // Single-object test docs are used for focused verification by community contributors. // The all-objects doc is used for behavior tests since it exercises the full pipeline. @@ -1265,3 +1266,141 @@ test.describe('m:phant (phantom) rendering', () => { expect(leaked).toEqual([]); }); }); + +test.describe('m:groupChr (group character) rendering', () => { + // Fixture has 12 m:groupChr variants covering every ECMA-376 §22.1.2.41 case: + // 1 default, 2 empty m:chr, 3 explicit underbrace, 4 overbrace, 5 ← arrow, 6 → arrow, + // 7-10 four pos×vertJc combos, 11 vertJc empty-val, 12 complex base. + test('renders every groupChr variant as or ', async ({ superdoc }) => { + await superdoc.loadDocument(GROUPCHR_DOC); + await superdoc.waitForStable(); + + const counts = await superdoc.page.evaluate(() => ({ + math: document.querySelectorAll('math').length, + wrappers: document.querySelectorAll('munder, mover').length, + movers: document.querySelectorAll('mover').length, + munders: document.querySelectorAll('munder').length, + })); + + expect(counts.math).toBe(12); + expect(counts.wrappers).toBe(12); + // Variants 4, 5, 7, 8, 11 are pos=top → (5 total). + expect(counts.movers).toBe(5); + // Variants 1, 2, 3, 6, 9, 10, 12 are pos=bot or default → (7 total). + expect(counts.munders).toBe(7); + }); + + test('default (no groupChrPr) falls back to U+23DF bottom curly bracket', async ({ superdoc }) => { + await superdoc.loadDocument(GROUPCHR_DOC); + await superdoc.waitForStable(); + + const firstMunder = await superdoc.page.evaluate(() => { + const munder = document.querySelector('munder'); + const mo = munder?.querySelector('mo'); + return mo ? { text: mo.textContent, stretchy: mo.getAttribute('stretchy') } : null; + }); + + expect(firstMunder).not.toBeNull(); + expect(firstMunder!.text).toBe('\u23DF'); + expect(firstMunder!.stretchy).toBe('true'); + }); + + test('empty renders a hidden character', async ({ superdoc }) => { + await superdoc.loadDocument(GROUPCHR_DOC); + await superdoc.waitForStable(); + + // Variant 2 — second munder in DOM order. + const hiddenChar = await superdoc.page.evaluate(() => { + const munders = document.querySelectorAll('munder'); + const mo = munders[1]?.querySelector('mo'); + return mo?.textContent; + }); + + expect(hiddenChar).toBe(''); + }); + + test('custom m:chr values are preserved (U+23DE, U+2190, U+2192)', async ({ superdoc }) => { + await superdoc.loadDocument(GROUPCHR_DOC); + await superdoc.waitForStable(); + + const chars = await superdoc.page.evaluate(() => { + const wrappers = document.querySelectorAll('munder, mover'); + return Array.from(wrappers).map((w) => w.querySelector('mo')?.textContent ?? null); + }); + + // Variants 4 (U+23DE), 5 (U+2190), 6 (U+2192). + expect(chars[3]).toBe('\u23DE'); + expect(chars[4]).toBe('\u2190'); + expect(chars[5]).toBe('\u2192'); + }); + + test('natural vertJc combinations render without baseline shift', async ({ superdoc }) => { + await superdoc.loadDocument(GROUPCHR_DOC); + await superdoc.waitForStable(); + + const natural = await superdoc.page.evaluate(() => { + const wrappers = document.querySelectorAll('munder, mover'); + // Variant 8 (pos=top, vertJc=bot) and variant 9 (pos=bot, vertJc=top) are natural. + return { + v8: { + vertJc: wrappers[7]?.getAttribute('data-vert-jc'), + style: wrappers[7]?.getAttribute('style'), + }, + v9: { + vertJc: wrappers[8]?.getAttribute('data-vert-jc'), + style: wrappers[8]?.getAttribute('style'), + }, + }; + }); + + expect(natural.v8.vertJc).toBe('bot'); + expect(natural.v8.style).toBeNull(); + expect(natural.v9.vertJc).toBe('top'); + expect(natural.v9.style).toBeNull(); + }); + + test('non-natural vertJc combinations shift the construct vertically', async ({ superdoc }) => { + await superdoc.loadDocument(GROUPCHR_DOC); + await superdoc.waitForStable(); + + const shifted = await superdoc.page.evaluate(() => { + const wrappers = document.querySelectorAll('munder, mover'); + return { + // Variant 7 (pos=top, vertJc=top) shifts down. + v7: { + vertJc: wrappers[6]?.getAttribute('data-vert-jc'), + style: wrappers[6]?.getAttribute('style'), + }, + // Variant 10 (pos=bot, vertJc=bot) shifts up. + v10: { + vertJc: wrappers[9]?.getAttribute('data-vert-jc'), + style: wrappers[9]?.getAttribute('style'), + }, + }; + }); + + expect(shifted.v7.vertJc).toBe('top'); + expect(shifted.v7.style).toContain('top: 1em'); + expect(shifted.v10.vertJc).toBe('bot'); + expect(shifted.v10.style).toContain('top: -1em'); + }); + + test('m:vertJc without m:val defaults to "bot"', async ({ superdoc }) => { + await superdoc.loadDocument(GROUPCHR_DOC); + await superdoc.waitForStable(); + + // Variant 11 — pos=top with (no val) → defaults to "bot" = natural for pos=top. + const v11 = await superdoc.page.evaluate(() => { + const wrappers = document.querySelectorAll('munder, mover'); + return { + tag: wrappers[10]?.localName, + vertJc: wrappers[10]?.getAttribute('data-vert-jc'), + style: wrappers[10]?.getAttribute('style'), + }; + }); + + expect(v11.tag).toBe('mover'); + expect(v11.vertJc).toBe('bot'); + expect(v11.style).toBeNull(); + }); +});