Skip to content

Commit 5485d54

Browse files
feat(math): implement m:groupChr group-character converter (#2751)
* feat(math): implement m:groupChr group-character converter (closes #2606) Made-with: Cursor * fix(math): add m:groupChr to VERTICAL_ELEMENTS for height estimation Made-with: Cursor * fix(math): honor m:vertJc and hide empty m:chr in groupChr converter - empty <m:chr/> now renders as a hidden character per ECMA-376 §22.1.2.20 - read m:vertJc, stamp data-vert-jc attribute, and shift the construct via position: relative when the value is non-natural for the given m:pos - add unit coverage for all four pos × vertJc combinations and empty-val vertJc - add behavior tests and a multi-variant fixture covering every §22.1.2.41 case --------- Co-authored-by: Caio Pizzol <caio@harbourshare.com>
1 parent 47f92bc commit 5485d54

File tree

8 files changed

+402
-1
lines changed

8 files changed

+402
-1
lines changed
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import type { MathObjectConverter } from '../types.js';
2+
3+
const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
4+
5+
/** Default group character: bottom curly bracket (U+23DF). */
6+
const DEFAULT_GROUP_CHAR = '\u23DF';
7+
8+
// Approximate shift used to distinguish non-natural m:vertJc combinations from their
9+
// natural counterparts. Chrome's MathML engine ignores <mpadded voffset>, and overriding
10+
// `display` on <munder>/<mover> breaks their native vertical stacking, so we use
11+
// `position: relative` + `top` instead. The value approximates the group-character
12+
// object's half-height at 1em font size.
13+
const VERT_JC_SHIFT_EM = 1;
14+
15+
/**
16+
* Convert m:groupChr (group character) to MathML <munder> or <mover>.
17+
*
18+
* OMML structure:
19+
* m:groupChr → m:groupChrPr (optional: m:chr@m:val, m:pos@m:val, m:vertJc@m:val), m:e
20+
*
21+
* MathML output:
22+
* pos="bot" (default): <munder> <mrow>base</mrow> <mo>char</mo> </munder>
23+
* pos="top": <mover> <mrow>base</mrow> <mo>char</mo> </mover>
24+
*
25+
* Defaults (ECMA-376 §22.1.2.20, §22.1.2.42, §22.1.2.119):
26+
* m:chr absent → U+23DF (bottom curly bracket)
27+
* m:chr present without m:val → hidden character
28+
* m:pos absent → "bot"
29+
* m:vertJc present without m:val → "bot"
30+
*
31+
* vertJc handling: m:vertJc specifies which edge of the group-character object aligns
32+
* with the surrounding baseline. Natural <munder>/<mover> rendering puts the base on
33+
* the baseline, which matches (pos=bot, vertJc=top) and (pos=top, vertJc=bot). Word
34+
* renders an absent m:vertJc as the natural layout for the given position, so a shift
35+
* is only applied when m:vertJc is explicitly set to the non-natural value for the pos.
36+
*
37+
* @spec ECMA-376 §22.1.2.41
38+
*/
39+
export const convertGroupCharacter: MathObjectConverter = (node, doc, convertChildren) => {
40+
const elements = node.elements ?? [];
41+
const groupChrPr = elements.find((e) => e.name === 'm:groupChrPr');
42+
const base = elements.find((e) => e.name === 'm:e');
43+
44+
const chr = groupChrPr?.elements?.find((e) => e.name === 'm:chr');
45+
const pos = groupChrPr?.elements?.find((e) => e.name === 'm:pos');
46+
const vertJc = groupChrPr?.elements?.find((e) => e.name === 'm:vertJc');
47+
48+
const groupChar = chr ? (chr.attributes?.['m:val'] ?? '') : DEFAULT_GROUP_CHAR;
49+
const position = pos?.attributes?.['m:val'] ?? 'bot';
50+
const vertJustify = vertJc ? (vertJc.attributes?.['m:val'] ?? 'bot') : null;
51+
52+
const wrapper = doc.createElementNS(MATHML_NS, position === 'top' ? 'mover' : 'munder');
53+
54+
const baseRow = doc.createElementNS(MATHML_NS, 'mrow');
55+
baseRow.appendChild(convertChildren(base?.elements ?? []));
56+
wrapper.appendChild(baseRow);
57+
58+
const mo = doc.createElementNS(MATHML_NS, 'mo');
59+
mo.setAttribute('stretchy', 'true');
60+
mo.textContent = groupChar;
61+
wrapper.appendChild(mo);
62+
63+
// Natural baseline: pos=top pairs with vertJc=bot, pos=bot pairs with vertJc=top.
64+
// Only shift when vertJc is explicitly the non-natural value; an absent vertJc
65+
// renders naturally (matches Word).
66+
if (vertJustify) {
67+
wrapper.setAttribute('data-vert-jc', vertJustify);
68+
const naturalVertJc = position === 'top' ? 'bot' : 'top';
69+
if (vertJustify !== naturalVertJc) {
70+
// pos=top,vertJc=top → shift the whole construct DOWN (char top to baseline).
71+
// pos=bot,vertJc=bot → shift the whole construct UP (char bottom to baseline).
72+
const direction = position === 'top' ? 1 : -1;
73+
wrapper.setAttribute('style', `position: relative; top: ${direction * VERT_JC_SHIFT_EM}em;`);
74+
}
75+
}
76+
77+
return wrapper;
78+
};

packages/layout-engine/painters/dom/src/features/math/converters/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,4 @@ export { convertLowerLimit } from './lower-limit.js';
2222
export { convertUpperLimit } from './upper-limit.js';
2323
export { convertNary } from './nary.js';
2424
export { convertPhantom } from './phantom.js';
25+
export { convertGroupCharacter } from './group-character.js';

packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3453,3 +3453,170 @@ describe('m:phant converter', () => {
34533453
expect(mpadded!.querySelector('mphantom')).not.toBeNull();
34543454
});
34553455
});
3456+
3457+
describe('m:groupChr converter', () => {
3458+
it('converts bottom underbrace to <munder> with default character', () => {
3459+
const omml = {
3460+
name: 'm:oMath',
3461+
elements: [
3462+
{
3463+
name: 'm:groupChr',
3464+
elements: [
3465+
{
3466+
name: 'm:e',
3467+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
3468+
},
3469+
],
3470+
},
3471+
],
3472+
};
3473+
const result = convertOmmlToMathml(omml, doc);
3474+
expect(result).not.toBeNull();
3475+
const munder = result!.querySelector('munder');
3476+
expect(munder).not.toBeNull();
3477+
expect(munder!.children[0]!.textContent).toBe('x');
3478+
const groupMo = munder!.children[1] as Element;
3479+
expect(groupMo.localName).toBe('mo');
3480+
expect(groupMo.textContent).toBe('\u23DF');
3481+
});
3482+
3483+
it('hides the group character when m:chr is present without m:val', () => {
3484+
const omml = {
3485+
name: 'm:oMath',
3486+
elements: [
3487+
{
3488+
name: 'm:groupChr',
3489+
elements: [
3490+
{
3491+
name: 'm:groupChrPr',
3492+
elements: [{ name: 'm:chr' }],
3493+
},
3494+
{
3495+
name: 'm:e',
3496+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
3497+
},
3498+
],
3499+
},
3500+
],
3501+
};
3502+
const result = convertOmmlToMathml(omml, doc);
3503+
expect(result).not.toBeNull();
3504+
const munder = result!.querySelector('munder');
3505+
expect(munder).not.toBeNull();
3506+
const mo = munder!.querySelector('mo');
3507+
expect(mo!.textContent).toBe('');
3508+
});
3509+
3510+
it('converts top overbrace to <mover>', () => {
3511+
const omml = {
3512+
name: 'm:oMath',
3513+
elements: [
3514+
{
3515+
name: 'm:groupChr',
3516+
elements: [
3517+
{
3518+
name: 'm:groupChrPr',
3519+
elements: [
3520+
{ name: 'm:chr', attributes: { 'm:val': '\u23DE' } },
3521+
{ name: 'm:pos', attributes: { 'm:val': 'top' } },
3522+
],
3523+
},
3524+
{
3525+
name: 'm:e',
3526+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'y' }] }] }],
3527+
},
3528+
],
3529+
},
3530+
],
3531+
};
3532+
const result = convertOmmlToMathml(omml, doc);
3533+
const mover = result!.querySelector('mover');
3534+
expect(mover).not.toBeNull();
3535+
expect(mover!.children[0]!.textContent).toBe('y');
3536+
const mo = mover!.querySelector('mo');
3537+
expect(mo!.textContent).toBe('\u23DE');
3538+
});
3539+
3540+
describe('m:vertJc baseline alignment', () => {
3541+
const buildGroupChr = (props: Array<{ name: string; attributes?: Record<string, string> }>) => ({
3542+
name: 'm:oMath',
3543+
elements: [
3544+
{
3545+
name: 'm:groupChr',
3546+
elements: [
3547+
{ name: 'm:groupChrPr', elements: props },
3548+
{
3549+
name: 'm:e',
3550+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
3551+
},
3552+
],
3553+
},
3554+
],
3555+
});
3556+
3557+
it('applies no shift when m:vertJc is absent (natural layout)', () => {
3558+
const omml = buildGroupChr([
3559+
{ name: 'm:chr', attributes: { 'm:val': '\u23DE' } },
3560+
{ name: 'm:pos', attributes: { 'm:val': 'top' } },
3561+
]);
3562+
const mover = convertOmmlToMathml(omml, doc)!.querySelector('mover')!;
3563+
expect(mover.getAttribute('style')).toBeNull();
3564+
expect(mover.getAttribute('data-vert-jc')).toBeNull();
3565+
});
3566+
3567+
it('pos=top, vertJc=bot renders natural mover without shift', () => {
3568+
const omml = buildGroupChr([
3569+
{ name: 'm:chr', attributes: { 'm:val': '\u23DE' } },
3570+
{ name: 'm:pos', attributes: { 'm:val': 'top' } },
3571+
{ name: 'm:vertJc', attributes: { 'm:val': 'bot' } },
3572+
]);
3573+
const mover = convertOmmlToMathml(omml, doc)!.querySelector('mover')!;
3574+
expect(mover.getAttribute('data-vert-jc')).toBe('bot');
3575+
expect(mover.getAttribute('style')).toBeNull();
3576+
});
3577+
3578+
it('pos=bot, vertJc=top renders natural munder without shift', () => {
3579+
const omml = buildGroupChr([
3580+
{ name: 'm:chr', attributes: { 'm:val': '\u23DF' } },
3581+
{ name: 'm:pos', attributes: { 'm:val': 'bot' } },
3582+
{ name: 'm:vertJc', attributes: { 'm:val': 'top' } },
3583+
]);
3584+
const munder = convertOmmlToMathml(omml, doc)!.querySelector('munder')!;
3585+
expect(munder.getAttribute('data-vert-jc')).toBe('top');
3586+
expect(munder.getAttribute('style')).toBeNull();
3587+
});
3588+
3589+
it('pos=top, vertJc=top shifts the construct down', () => {
3590+
const omml = buildGroupChr([
3591+
{ name: 'm:chr', attributes: { 'm:val': '\u23DE' } },
3592+
{ name: 'm:pos', attributes: { 'm:val': 'top' } },
3593+
{ name: 'm:vertJc', attributes: { 'm:val': 'top' } },
3594+
]);
3595+
const mover = convertOmmlToMathml(omml, doc)!.querySelector('mover')!;
3596+
expect(mover.getAttribute('data-vert-jc')).toBe('top');
3597+
expect(mover.getAttribute('style')).toContain('top: 1em');
3598+
});
3599+
3600+
it('pos=bot, vertJc=bot shifts the construct up', () => {
3601+
const omml = buildGroupChr([
3602+
{ name: 'm:chr', attributes: { 'm:val': '\u23DF' } },
3603+
{ name: 'm:pos', attributes: { 'm:val': 'bot' } },
3604+
{ name: 'm:vertJc', attributes: { 'm:val': 'bot' } },
3605+
]);
3606+
const munder = convertOmmlToMathml(omml, doc)!.querySelector('munder')!;
3607+
expect(munder.getAttribute('data-vert-jc')).toBe('bot');
3608+
expect(munder.getAttribute('style')).toContain('top: -1em');
3609+
});
3610+
3611+
it('vertJc present without m:val defaults to "bot"', () => {
3612+
const omml = buildGroupChr([
3613+
{ name: 'm:chr', attributes: { 'm:val': '\u23DE' } },
3614+
{ name: 'm:pos', attributes: { 'm:val': 'top' } },
3615+
{ name: 'm:vertJc' },
3616+
]);
3617+
const mover = convertOmmlToMathml(omml, doc)!.querySelector('mover')!;
3618+
expect(mover.getAttribute('data-vert-jc')).toBe('bot');
3619+
expect(mover.getAttribute('style')).toBeNull();
3620+
});
3621+
});
3622+
});

packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
convertUpperLimit,
2828
convertNary,
2929
convertPhantom,
30+
convertGroupCharacter,
3031
} from './converters/index.js';
3132

3233
export const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
@@ -66,7 +67,7 @@ const MATH_OBJECT_REGISTRY: Record<string, MathObjectConverter | null> = {
6667
// ── Not yet implemented (community contributions welcome) ────────────────
6768
'm:borderBox': null, // Border box (border around math content)
6869
'm:box': null, // Box (invisible grouping container)
69-
'm:groupChr': null, // Group character (overbrace, underbrace)
70+
'm:groupChr': convertGroupCharacter, // Group character (overbrace, underbrace)
7071
'm:m': null, // Matrix (grid of elements)
7172
};
7273

packages/layout-engine/pm-adapter/src/converters/math-constants.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,20 @@ describe('estimateMathDimensions', () => {
111111
expect(width).toBe(50); // 5 chars * 10px
112112
});
113113

114+
it('increases height for group character (m:groupChr)', () => {
115+
const omml = {
116+
name: 'm:oMath',
117+
elements: [
118+
{
119+
name: 'm:groupChr',
120+
elements: [{ name: 'm:e', elements: [{ name: 'm:r' }] }],
121+
},
122+
],
123+
};
124+
const { height } = estimateMathDimensions('x', omml);
125+
expect(height).toBeGreaterThan(MATH_DEFAULT_HEIGHT);
126+
});
127+
114128
it('enforces minimum width', () => {
115129
const { width } = estimateMathDimensions('x');
116130
expect(width).toBe(20); // MATH_MIN_WIDTH

packages/layout-engine/pm-adapter/src/converters/math-constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const VERTICAL_ELEMENTS: Record<string, number> = {
2323
'm:sSup': 0.1, // Superscript
2424
'm:sSubSup': 0.2, // Sub-superscript
2525
'm:sPre': 0.2, // Pre-sub-superscript
26+
'm:groupChr': 0.35, // Group character (overbrace/underbrace)
2627
};
2728

2829
/** Count elements in an m:eqArr (equation array) for row-based height. */
11.6 KB
Binary file not shown.

0 commit comments

Comments
 (0)