Skip to content

Commit 47f92bc

Browse files
feat(math): implement m:phant phantom converter (#2749)
* feat(math): implement m:phant phantom converter (closes #2608) Made-with: Cursor * fix(math): parse full ST_OnOff values in phantom converter Made-with: Cursor * fix(math): treat bare m:show element as visible in phantom converter Made-with: Cursor * fix(math): render phantom as visible when m:show is omitted Per ECMA-376 §22.1.2.96, when <m:show> is absent from <m:phantPr> the base content should be shown. The previous check only handled bare and truthy-val variants, so a missing element was treated as hidden — breaking the most common phantom pattern (zeroing a dimension without hiding content). Adds unit coverage for the explicit-hide case and zeroed-dimension-only case, plus a behavior test loading a 12-case fixture that walks every m:phantPr configuration in the spec (show absent/bare/val=0/1/true/false, each zero flag alone and combined, m:transp passthrough). --------- Co-authored-by: Caio Pizzol <caio@harbourshare.com>
1 parent 02543d6 commit 47f92bc

6 files changed

Lines changed: 407 additions & 1 deletion

File tree

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
@@ -21,3 +21,4 @@ export { convertRadical } from './radical.js';
2121
export { convertLowerLimit } from './lower-limit.js';
2222
export { convertUpperLimit } from './upper-limit.js';
2323
export { convertNary } from './nary.js';
24+
export { convertPhantom } from './phantom.js';
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import type { MathObjectConverter } from '../types.js';
2+
3+
const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
4+
5+
/**
6+
* Convert m:phant (phantom) to MathML <mphantom> or styled <mpadded>.
7+
*
8+
* OMML structure:
9+
* m:phant → m:phantPr (optional: m:show, m:zeroWid, m:zeroAsc, m:zeroDesc), m:e (content)
10+
*
11+
* MathML output:
12+
* Full phantom (default): <mphantom> content </mphantom>
13+
* Visible with zeroed dimensions: <mpadded> with width/height/depth="0"
14+
*
15+
* A phantom reserves the space its content would occupy but renders invisibly.
16+
* Property flags can zero-out individual dimensions or force visibility.
17+
*
18+
* @spec ECMA-376 §22.1.2.81
19+
*/
20+
export const convertPhantom: MathObjectConverter = (node, doc, convertChildren) => {
21+
const elements = node.elements ?? [];
22+
const phantPr = elements.find((e) => e.name === 'm:phantPr');
23+
const base = elements.find((e) => e.name === 'm:e');
24+
25+
const show = phantPr?.elements?.find((e) => e.name === 'm:show');
26+
const zeroWid = phantPr?.elements?.find((e) => e.name === 'm:zeroWid');
27+
const zeroAsc = phantPr?.elements?.find((e) => e.name === 'm:zeroAsc');
28+
const zeroDesc = phantPr?.elements?.find((e) => e.name === 'm:zeroDesc');
29+
30+
/** OOXML ST_OnOff true values. */
31+
const isOnOffTrue = (val?: string) => val === '1' || val === 'on' || val === 'true';
32+
33+
// Per ECMA-376 §22.1.2.96: when m:show is omitted, the base is shown.
34+
const isVisible = show == null || !show.attributes || isOnOffTrue(show.attributes['m:val']);
35+
const hasZeroDimension = zeroWid || zeroAsc || zeroDesc;
36+
37+
const content = convertChildren(base?.elements ?? []);
38+
39+
if (!isVisible && !hasZeroDimension) {
40+
const mphantom = doc.createElementNS(MATHML_NS, 'mphantom');
41+
mphantom.appendChild(content);
42+
return mphantom;
43+
}
44+
45+
const mpadded = doc.createElementNS(MATHML_NS, 'mpadded');
46+
47+
const isZeroVal = (el?: typeof zeroWid) => el && (isOnOffTrue(el.attributes?.['m:val']) || !el.attributes);
48+
49+
if (isZeroVal(zeroWid)) mpadded.setAttribute('width', '0');
50+
if (isZeroVal(zeroAsc)) mpadded.setAttribute('height', '0');
51+
if (isZeroVal(zeroDesc)) mpadded.setAttribute('depth', '0');
52+
53+
if (!isVisible) {
54+
const mphantom = doc.createElementNS(MATHML_NS, 'mphantom');
55+
mphantom.appendChild(content);
56+
mpadded.appendChild(mphantom);
57+
} else {
58+
mpadded.appendChild(content);
59+
}
60+
61+
return mpadded;
62+
};

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

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3292,3 +3292,164 @@ describe('m:nary converter', () => {
32923292
expect(mo!.textContent).toBe('');
32933293
});
32943294
});
3295+
3296+
describe('m:phant converter', () => {
3297+
it('renders phantom with no properties as visible (m:show default)', () => {
3298+
const omml = {
3299+
name: 'm:oMath',
3300+
elements: [
3301+
{
3302+
name: 'm:phant',
3303+
elements: [
3304+
{
3305+
name: 'm:e',
3306+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
3307+
},
3308+
],
3309+
},
3310+
],
3311+
};
3312+
const result = convertOmmlToMathml(omml, doc);
3313+
expect(result).not.toBeNull();
3314+
expect(result!.querySelector('mphantom')).toBeNull();
3315+
expect(result!.textContent).toBe('x');
3316+
});
3317+
3318+
it('hides content when m:show has m:val="0"', () => {
3319+
const omml = {
3320+
name: 'm:oMath',
3321+
elements: [
3322+
{
3323+
name: 'm:phant',
3324+
elements: [
3325+
{
3326+
name: 'm:phantPr',
3327+
elements: [{ name: 'm:show', attributes: { 'm:val': '0' } }],
3328+
},
3329+
{
3330+
name: 'm:e',
3331+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
3332+
},
3333+
],
3334+
},
3335+
],
3336+
};
3337+
const result = convertOmmlToMathml(omml, doc);
3338+
const mphantom = result!.querySelector('mphantom');
3339+
expect(mphantom).not.toBeNull();
3340+
expect(mphantom!.textContent).toBe('x');
3341+
});
3342+
3343+
it('converts visible phantom with zeroed width to <mpadded width="0">', () => {
3344+
const omml = {
3345+
name: 'm:oMath',
3346+
elements: [
3347+
{
3348+
name: 'm:phant',
3349+
elements: [
3350+
{
3351+
name: 'm:phantPr',
3352+
elements: [
3353+
{ name: 'm:show', attributes: { 'm:val': '1' } },
3354+
{ name: 'm:zeroWid', attributes: { 'm:val': '1' } },
3355+
],
3356+
},
3357+
{
3358+
name: 'm:e',
3359+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'y' }] }] }],
3360+
},
3361+
],
3362+
},
3363+
],
3364+
};
3365+
const result = convertOmmlToMathml(omml, doc);
3366+
const mpadded = result!.querySelector('mpadded');
3367+
expect(mpadded).not.toBeNull();
3368+
expect(mpadded!.getAttribute('width')).toBe('0');
3369+
expect(mpadded!.textContent).toBe('y');
3370+
});
3371+
3372+
it('treats bare <m:show/> (no attributes) as visible', () => {
3373+
const omml = {
3374+
name: 'm:oMath',
3375+
elements: [
3376+
{
3377+
name: 'm:phant',
3378+
elements: [
3379+
{
3380+
name: 'm:phantPr',
3381+
elements: [{ name: 'm:show' }, { name: 'm:zeroWid', attributes: { 'm:val': '1' } }],
3382+
},
3383+
{
3384+
name: 'm:e',
3385+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'v' }] }] }],
3386+
},
3387+
],
3388+
},
3389+
],
3390+
};
3391+
const result = convertOmmlToMathml(omml, doc);
3392+
const mpadded = result!.querySelector('mpadded');
3393+
expect(mpadded).not.toBeNull();
3394+
expect(mpadded!.getAttribute('width')).toBe('0');
3395+
const mphantom = mpadded!.querySelector('mphantom');
3396+
expect(mphantom).toBeNull();
3397+
expect(mpadded!.textContent).toBe('v');
3398+
});
3399+
3400+
it('renders visible phantom with zeroed ascent as <mpadded height="0"> without hiding', () => {
3401+
const omml = {
3402+
name: 'm:oMath',
3403+
elements: [
3404+
{
3405+
name: 'm:phant',
3406+
elements: [
3407+
{
3408+
name: 'm:phantPr',
3409+
elements: [{ name: 'm:zeroAsc', attributes: { 'm:val': '1' } }],
3410+
},
3411+
{
3412+
name: 'm:e',
3413+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'z' }] }] }],
3414+
},
3415+
],
3416+
},
3417+
],
3418+
};
3419+
const result = convertOmmlToMathml(omml, doc);
3420+
const mpadded = result!.querySelector('mpadded');
3421+
expect(mpadded).not.toBeNull();
3422+
expect(mpadded!.getAttribute('height')).toBe('0');
3423+
expect(mpadded!.querySelector('mphantom')).toBeNull();
3424+
expect(mpadded!.textContent).toBe('z');
3425+
});
3426+
3427+
it('renders invisible phantom with m:show="0" and zeroed height as <mpadded> wrapping <mphantom>', () => {
3428+
const omml = {
3429+
name: 'm:oMath',
3430+
elements: [
3431+
{
3432+
name: 'm:phant',
3433+
elements: [
3434+
{
3435+
name: 'm:phantPr',
3436+
elements: [
3437+
{ name: 'm:show', attributes: { 'm:val': '0' } },
3438+
{ name: 'm:zeroAsc', attributes: { 'm:val': '1' } },
3439+
],
3440+
},
3441+
{
3442+
name: 'm:e',
3443+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'z' }] }] }],
3444+
},
3445+
],
3446+
},
3447+
],
3448+
};
3449+
const result = convertOmmlToMathml(omml, doc);
3450+
const mpadded = result!.querySelector('mpadded');
3451+
expect(mpadded).not.toBeNull();
3452+
expect(mpadded!.getAttribute('height')).toBe('0');
3453+
expect(mpadded!.querySelector('mphantom')).not.toBeNull();
3454+
});
3455+
});

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
@@ -26,6 +26,7 @@ import {
2626
convertLowerLimit,
2727
convertUpperLimit,
2828
convertNary,
29+
convertPhantom,
2930
} from './converters/index.js';
3031

3132
export const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
@@ -55,6 +56,7 @@ const MATH_OBJECT_REGISTRY: Record<string, MathObjectConverter | null> = {
5556
'm:limLow': convertLowerLimit, // Lower limit (e.g., lim)
5657
'm:limUpp': convertUpperLimit, // Upper limit
5758
'm:nary': convertNary, // N-ary operator (integral, summation, product)
59+
'm:phant': convertPhantom, // Phantom (invisible spacing placeholder)
5860
'm:rad': convertRadical, // Radical (square root, nth root)
5961
'm:sSub': convertSubscript, // Subscript
6062
'm:sSup': convertSuperscript, // Superscript
@@ -66,7 +68,6 @@ const MATH_OBJECT_REGISTRY: Record<string, MathObjectConverter | null> = {
6668
'm:box': null, // Box (invisible grouping container)
6769
'm:groupChr': null, // Group character (overbrace, underbrace)
6870
'm:m': null, // Matrix (grid of elements)
69-
'm:phant': null, // Phantom (invisible spacing placeholder)
7071
};
7172

7273
/** OMML argument/container elements that wrap children in <mrow>. */
Binary file not shown.

0 commit comments

Comments
 (0)