Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 <mpadded voffset>, and overriding
// `display` on <munder>/<mover> 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 <munder> or <mover>.
*
* OMML structure:
* m:groupChr → m:groupChrPr (optional: m:chr@m:val, m:pos@m:val, m:vertJc@m:val), m:e
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the spec also has a m:vertJc property that controls where the baseline sits — Word renders it differently for each combination. not blocking, just something to keep in mind for later.

*
* MathML output:
* pos="bot" (default): <munder> <mrow>base</mrow> <mo>char</mo> </munder>
* pos="top": <mover> <mrow>base</mrow> <mo>char</mo> </mover>
*
* 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 <munder>/<mover> 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;
};
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -3453,3 +3453,170 @@ describe('m:phant converter', () => {
expect(mpadded!.querySelector('mphantom')).not.toBeNull();
});
});

describe('m:groupChr converter', () => {
it('converts bottom underbrace to <munder> 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 <mover>', () => {
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<string, string> }>) => ({
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();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
convertUpperLimit,
convertNary,
convertPhantom,
convertGroupCharacter,
} from './converters/index.js';

export const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
Expand Down Expand Up @@ -66,7 +67,7 @@ const MATH_OBJECT_REGISTRY: Record<string, MathObjectConverter | null> = {
// ── 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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update math height estimation for m:groupChr runs

Enabling m:groupChr conversion here changes output from a flat fallback to real <munder>/<mover> layout, but run sizing still comes from estimateMathDimensions() (packages/layout-engine/pm-adapter/src/converters/math-constants.ts), where VERTICAL_ELEMENTS does not include m:groupChr. The DOM measurer then uses run.height for line height budgeting (packages/layout-engine/measuring/dom/src/index.ts, math run branch via maxImageHeight), so under/over-brace formulas can render taller than allocated space and visually collide with neighboring lines. This registration should be paired with a m:groupChr height multiplier update (and a sizing test) to avoid layout regressions.

Useful? React with 👍 / 👎.

'm:m': null, // Matrix (grid of elements)
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const VERTICAL_ELEMENTS: Record<string, number> = {
'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. */
Expand Down
Binary file not shown.
Loading
Loading