Skip to content

Commit 8e171d5

Browse files
authored
fix(math): estimate height from OMML structure instead of fixed 24px (SD-2413) (#2653)
Walk the OMML JSON tree to count vertical stacking elements (fractions, bars, limits, equation arrays) and scale the height estimate accordingly. Also fix measuring DOM to initialize currentLine for math-only paragraphs so the estimated height actually flows into line height calculation.
1 parent 9e2dfee commit 8e171d5

6 files changed

Lines changed: 216 additions & 7 deletions

File tree

packages/layout-engine/measuring/dom/src/index.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1640,7 +1640,20 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
16401640
const mathWidth = mathRun.width ?? 20;
16411641
const mathHeight = mathRun.height ?? 24;
16421642

1643-
if (currentLine) {
1643+
if (!currentLine) {
1644+
currentLine = {
1645+
fromRun: runIndex,
1646+
fromChar: 0,
1647+
toRun: runIndex,
1648+
toChar: 1,
1649+
width: mathWidth,
1650+
maxFontSize: lastFontSize,
1651+
maxWidth: getEffectiveWidth(lines.length === 0 ? initialAvailableWidth : bodyContentWidth),
1652+
segments: [{ runIndex, fromChar: 0, toChar: 1, width: mathWidth }],
1653+
spaceCount: 0,
1654+
maxImageHeight: mathHeight,
1655+
};
1656+
} else {
16441657
currentLine.toRun = runIndex;
16451658
currentLine.toChar = 1;
16461659
currentLine.width = roundValue(currentLine.width + mathWidth);

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import { estimateMathDimensions } from '../math-constants.js';
88
*/
99
export function mathInlineNodeToRun({ node, positions, sdtMetadata }: InlineConverterParams): MathRun | null {
1010
const textContent = String(node.attrs?.textContent ?? '');
11-
const { width, height } = estimateMathDimensions(textContent);
11+
const ommlJson = node.attrs?.originalXml ?? null;
12+
const { width, height } = estimateMathDimensions(textContent, ommlJson);
1213

1314
const run: MathRun = {
1415
kind: 'math',

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,26 @@ describe('handleMathBlockNode', () => {
7474
expect(run.width).toBe(50); // 5 chars * 10px
7575
});
7676

77+
it('estimates taller height for fractions', () => {
78+
const { context, blocks } = makeContext();
79+
const fractionXml = {
80+
name: 'm:oMathPara',
81+
elements: [{
82+
name: 'm:oMath',
83+
elements: [{
84+
name: 'm:f',
85+
elements: [
86+
{ name: 'm:num', elements: [{ name: 'm:r' }] },
87+
{ name: 'm:den', elements: [{ name: 'm:r' }] },
88+
],
89+
}],
90+
}],
91+
};
92+
handleMathBlockNode(makeNode({ textContent: 'ab', originalXml: fractionXml }) as any, context);
93+
const run = (blocks[0] as ParagraphBlock).runs[0] as MathRun;
94+
expect(run.height).toBeGreaterThan(24);
95+
});
96+
7797
it('generates unique block IDs', () => {
7898
const { context, blocks } = makeContext();
7999
handleMathBlockNode(makeNode({ textContent: 'a' }) as any, context);

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ export function handleMathBlockNode(node: PMNode, context: NodeHandlerContext):
1919

2020
const textContent = String(node.attrs?.textContent ?? '');
2121
const justification = String(node.attrs?.justification ?? 'centerGroup');
22-
const { width, height } = estimateMathDimensions(textContent);
22+
const ommlJson = node.attrs?.originalXml ?? null;
23+
const { width, height } = estimateMathDimensions(textContent, ommlJson);
2324

2425
const pos = positions.get(node);
2526

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { estimateMathDimensions, MATH_DEFAULT_HEIGHT } from './math-constants.js';
3+
4+
describe('estimateMathDimensions', () => {
5+
it('returns default height when no OMML JSON is provided', () => {
6+
const { height } = estimateMathDimensions('x+1');
7+
expect(height).toBe(MATH_DEFAULT_HEIGHT);
8+
});
9+
10+
it('returns default height for simple text runs (no vertical stacking)', () => {
11+
const omml = {
12+
name: 'm:oMath',
13+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
14+
};
15+
const { height } = estimateMathDimensions('x', omml);
16+
expect(height).toBe(MATH_DEFAULT_HEIGHT);
17+
});
18+
19+
it('increases height for fractions (m:f)', () => {
20+
const omml = {
21+
name: 'm:oMath',
22+
elements: [{
23+
name: 'm:f',
24+
elements: [
25+
{ name: 'm:num', elements: [{ name: 'm:r' }] },
26+
{ name: 'm:den', elements: [{ name: 'm:r' }] },
27+
],
28+
}],
29+
};
30+
const { height } = estimateMathDimensions('ab', omml);
31+
expect(height).toBeGreaterThan(MATH_DEFAULT_HEIGHT);
32+
});
33+
34+
it('increases height for bar elements (m:bar)', () => {
35+
const omml = {
36+
name: 'm:oMath',
37+
elements: [{
38+
name: 'm:bar',
39+
elements: [{ name: 'm:e', elements: [{ name: 'm:r' }] }],
40+
}],
41+
};
42+
const { height } = estimateMathDimensions('x', omml);
43+
expect(height).toBeGreaterThan(MATH_DEFAULT_HEIGHT);
44+
});
45+
46+
it('stacks multipliers for nested elements (bar over fraction)', () => {
47+
const omml = {
48+
name: 'm:oMath',
49+
elements: [{
50+
name: 'm:bar',
51+
elements: [{
52+
name: 'm:e',
53+
elements: [{
54+
name: 'm:f',
55+
elements: [
56+
{ name: 'm:num', elements: [{ name: 'm:r' }] },
57+
{ name: 'm:den', elements: [{ name: 'm:r' }] },
58+
],
59+
}],
60+
}],
61+
}],
62+
};
63+
const fractionOnly = {
64+
name: 'm:oMath',
65+
elements: [{
66+
name: 'm:f',
67+
elements: [
68+
{ name: 'm:num', elements: [{ name: 'm:r' }] },
69+
{ name: 'm:den', elements: [{ name: 'm:r' }] },
70+
],
71+
}],
72+
};
73+
const barOverFraction = estimateMathDimensions('ab', omml).height;
74+
const fractionHeight = estimateMathDimensions('ab', fractionOnly).height;
75+
expect(barOverFraction).toBeGreaterThan(fractionHeight);
76+
});
77+
78+
it('scales height with equation array row count', () => {
79+
const omml = {
80+
name: 'm:oMathPara',
81+
elements: [{
82+
name: 'm:eqArr',
83+
elements: [
84+
{ name: 'm:e', elements: [{ name: 'm:r' }] },
85+
{ name: 'm:e', elements: [{ name: 'm:r' }] },
86+
{ name: 'm:e', elements: [{ name: 'm:r' }] },
87+
],
88+
}],
89+
};
90+
const { height } = estimateMathDimensions('abc', omml);
91+
// 3 rows = 2 additional rows worth of height
92+
expect(height).toBeGreaterThan(MATH_DEFAULT_HEIGHT * 2);
93+
});
94+
95+
it('estimates width from text length', () => {
96+
const { width } = estimateMathDimensions('abcde');
97+
expect(width).toBe(50); // 5 chars * 10px
98+
});
99+
100+
it('enforces minimum width', () => {
101+
const { width } = estimateMathDimensions('x');
102+
expect(width).toBe(20); // MATH_MIN_WIDTH
103+
});
104+
});
Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,86 @@
11
/** Rough width estimate per character for math content (px). */
22
export const MATH_CHAR_WIDTH = 10;
33

4-
/** Default height for math content (px). */
4+
/** Default height for a single-line math expression (px). */
55
export const MATH_DEFAULT_HEIGHT = 24;
66

77
/** Minimum width for a math run (px). */
88
export const MATH_MIN_WIDTH = 20;
99

10-
/** Estimate math run dimensions from text content. */
11-
export function estimateMathDimensions(textContent: string): { width: number; height: number } {
10+
/**
11+
* OMML elements that stack content vertically, with their height multipliers.
12+
* Each element adds vertical space: a fraction has num+den (2x), a bar has
13+
* base+accent (~1.4x), sub/superscripts are modest (~1.3x).
14+
*/
15+
const VERTICAL_ELEMENTS: Record<string, number> = {
16+
'm:f': 0.6, // Fraction — stacks numerator over denominator
17+
'm:bar': 0.25, // Bar — accent above/below base
18+
'm:limLow': 0.35, // Lower limit
19+
'm:limUpp': 0.35, // Upper limit
20+
'm:nary': 0.4, // N-ary (integral/summation) with limits
21+
'm:rad': 0.2, // Radical — root symbol adds height
22+
'm:sSub': 0.1, // Subscript
23+
'm:sSup': 0.1, // Superscript
24+
'm:sSubSup': 0.2, // Sub-superscript
25+
'm:sPre': 0.2, // Pre-sub-superscript
26+
};
27+
28+
/** Count elements in an m:eqArr (equation array) for row-based height. */
29+
function countEqArrayRows(node: { elements?: unknown[] }): number {
30+
if (!Array.isArray(node.elements)) return 1;
31+
return node.elements.filter(
32+
(el: unknown) => el && typeof el === 'object' && (el as { name?: string }).name === 'm:e',
33+
).length;
34+
}
35+
36+
/**
37+
* Estimate height multiplier by walking the OMML JSON tree.
38+
* Returns the cumulative vertical stacking factor.
39+
*/
40+
function estimateHeightMultiplier(node: unknown): number {
41+
if (!node || typeof node !== 'object') return 0;
42+
const n = node as { name?: string; elements?: unknown[] };
43+
44+
// Equation array: height scales with row count + tallest row content
45+
if (n.name === 'm:eqArr') {
46+
const rows = countEqArrayRows(n as { elements?: unknown[] });
47+
const rowMultiplier = Math.max(0, rows - 1);
48+
// Also recurse into rows to find tall content (e.g., fraction inside a row)
49+
let maxRowContent = 0;
50+
if (Array.isArray(n.elements)) {
51+
for (const child of n.elements) {
52+
maxRowContent = Math.max(maxRowContent, estimateHeightMultiplier(child));
53+
}
54+
}
55+
return rowMultiplier + maxRowContent;
56+
}
57+
58+
// Check if this node adds vertical height
59+
const selfMultiplier = n.name ? (VERTICAL_ELEMENTS[n.name] ?? 0) : 0;
60+
61+
// Recurse into children, take the max child depth (deepest nesting path)
62+
let maxChild = 0;
63+
if (Array.isArray(n.elements)) {
64+
for (const child of n.elements) {
65+
maxChild = Math.max(maxChild, estimateHeightMultiplier(child));
66+
}
67+
}
68+
69+
return selfMultiplier + maxChild;
70+
}
71+
72+
/**
73+
* Estimate math run dimensions from text content and OMML structure.
74+
* When ommlJson is provided, the height scales based on vertical stacking
75+
* (fractions, bars, limits, equation arrays).
76+
*/
77+
export function estimateMathDimensions(
78+
textContent: string,
79+
ommlJson?: unknown,
80+
): { width: number; height: number } {
81+
const multiplier = ommlJson ? estimateHeightMultiplier(ommlJson) : 0;
1282
return {
1383
width: Math.max(textContent.length * MATH_CHAR_WIDTH, MATH_MIN_WIDTH),
14-
height: MATH_DEFAULT_HEIGHT,
84+
height: Math.round(MATH_DEFAULT_HEIGHT * (1 + multiplier)),
1585
};
1686
}

0 commit comments

Comments
 (0)