Skip to content

Commit f229576

Browse files
feat(math): implement m:box and m:borderBox converters (#2750)
* feat(math): implement m:box and m:borderBox converters (closes #2605) Made-with: Cursor * fix(math): parse full ST_OnOff values in borderBox converter Made-with: Cursor * fix(math): fall back to mrow when borderBox hides all sides with no strikes Made-with: Cursor * fix(math): address review findings for m:box/m:borderBox - isOn now checks m:val === undefined instead of !el.attributes so elements with namespace-only attributes are still treated as on, matching the ST_OnOff default per §22.9.2.7. - convertBorderBox returns null for empty m:e, consistent with convertBox and convertFunction (no empty <menclose> wrappers). - m:box JSDoc now reflects that boxPr semantics (opEmu, noBreak, aln, diff, argSz) are silently dropped — not "purely a grouping mechanism". - Registry comment drift fixed: m:box and m:borderBox moved into the Implemented block. - Tests: strike direction mapping (BLTR→up, TLBR→down, V), full ST_OnOff matrix (1/true/on/bare/0/false), tightened assertions to exact-string equality, pinned the current boxPr-drop behavior. * feat(math): polyfill MathML <menclose> via CSS MathML Core (Chrome 109+, 2023) dropped <menclose> — no browser paints it natively. Without this, m:borderBox content imports correctly but renders invisibly. Ship a small CSS polyfill that maps every notation token to borders or pseudo-element strike overlays: - box / top / bottom / left / right → CSS border sides - horizontalstrike / verticalstrike → ::after gradient layer (H or V) - updiagonalstrike / downdiagonalstrike → layered gradients via CSS custom properties so X patterns stack correctly Wired through the existing ensure*Styles pattern in renderer.ts. Zero bundle cost, no runtime polling, fully semantic (the DOM still says <menclose notation="box">). * fix(math): correct diagonal strike directions in menclose polyfill CSS linear-gradient direction keywords confusingly produce stripes perpendicular to the direction vector: - "to top right" progresses toward the top-right corner, which makes the visible color stripe run top-left to bottom-right ("\") - "to bottom right" progresses toward the bottom-right corner, which makes the stripe run bottom-left to top-right ("/") The polyfill had them swapped, so updiagonalstrike rendered as "\" and downdiagonalstrike as "/" — the opposite of what Word shows and what MathML 3 specifies. Swap the direction keywords and add a comment so the next reader doesn't re-flip them. * fix(math): wrap borderBox content in <mrow> for horizontal row layout MathML Core does not define <menclose>, so Chrome treats it as an unknown element and does not run the row-layout algorithm on its children. Each child rendered with display: block math and stacked vertically — a multi-element expression inside a borderBox (e.g. Annex L.6.1.3's a² = b² + c²) became a column of letters. Wrap the content in an inner <mrow> before appending to <menclose>. <mrow> is in MathML Core, so the row layout runs on its children and everything stays inline. The outer <menclose> remains the polyfill target for borders and strikes. * test(behavior): cover m:borderBox + menclose polyfill end-to-end Loads the 30-scenario fixture (sd-2750-borderbox.docx) and asserts: - every scenario produces the expected notation attribute in DOM order - multi-child content (Annex L.6.1.3: a² = b² + c²) renders as a horizontal row — width > 1.5× height, inner <mrow> present, 5 children - ST_OnOff variants (1/true/on/bare/0/false) resolve correctly through the full import path, not just the unit converter - m:box silently drops boxPr (opEmu/noBreak/aln/diff) and emits <mrow> - the menclose CSS polyfill stylesheet is injected into the document Runs across chromium/firefox/webkit. Complements the 53 unit tests by exercising the cross-package path: OMML import → pm-adapter → painter-dom → rendered MathML. --------- Co-authored-by: Caio Pizzol <caio@harbourshare.com>
1 parent 9c05a6f commit f229576

8 files changed

Lines changed: 703 additions & 3 deletions

File tree

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import type { MathObjectConverter } from '../types.js';
2+
3+
const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
4+
5+
/**
6+
* Convert m:box (grouping container) to MathML <mrow>.
7+
*
8+
* OMML structure:
9+
* m:box → m:boxPr (optional), m:e (content)
10+
*
11+
* MathML output:
12+
* <mrow> content </mrow>
13+
*
14+
* Per §22.1.2.13 / §22.1.2.14, m:box can carry boxPr children that affect
15+
* layout and spacing — opEmu (operator emulator), noBreak (disallow line
16+
* breaks), aln (alignment point), diff (differential spacing), argSz. These
17+
* have no clean MathML equivalent and are currently dropped; the box
18+
* degrades to a plain <mrow> that preserves grouping but not the other
19+
* semantics. Extend here when any of these need first-class support.
20+
*
21+
* @spec ECMA-376 §22.1.2.13, §22.1.2.14
22+
*/
23+
export const convertBox: MathObjectConverter = (node, doc, convertChildren) => {
24+
const elements = node.elements ?? [];
25+
const base = elements.find((e) => e.name === 'm:e');
26+
27+
const mrow = doc.createElementNS(MATHML_NS, 'mrow');
28+
mrow.appendChild(convertChildren(base?.elements ?? []));
29+
30+
return mrow.childNodes.length > 0 ? mrow : null;
31+
};
32+
33+
/**
34+
* Convert m:borderBox (bordered box) to MathML <menclose>.
35+
*
36+
* OMML structure:
37+
* m:borderBox → m:borderBoxPr (optional: m:hideTop, m:hideBot, m:hideLeft, m:hideRight,
38+
* m:strikeBLTR, m:strikeH, m:strikeTLBR, m:strikeV),
39+
* m:e (content)
40+
*
41+
* MathML output:
42+
* <menclose notation="..."> content </menclose>
43+
*
44+
* By default all four borders are shown (notation="box"). Individual borders
45+
* can be hidden via m:hide* flags, and diagonal/horizontal/vertical strikes
46+
* can be added via m:strike* flags.
47+
*
48+
* @spec ECMA-376 §22.1.2.11
49+
*/
50+
export const convertBorderBox: MathObjectConverter = (node, doc, convertChildren) => {
51+
const elements = node.elements ?? [];
52+
const props = elements.find((e) => e.name === 'm:borderBoxPr');
53+
const base = elements.find((e) => e.name === 'm:e');
54+
55+
/**
56+
* OOXML ST_OnOff (§22.9.2.7): on when the element is present and either
57+
* `m:val` is absent (spec default = 1) or equals "1" / "true". "on" is
58+
* accepted for leniency — Annex L.6.1.3 uses that form even though the
59+
* normative enum is {0, 1, true, false}.
60+
* TODO: extract to a shared util when m:acc / m:phant / matrix m:tblLook land.
61+
*/
62+
const isOn = (el?: { attributes?: Record<string, string> }) => {
63+
if (!el) return false;
64+
const val = el.attributes?.['m:val'];
65+
if (val === undefined) return true;
66+
return val === '1' || val === 'true' || val === 'on';
67+
};
68+
69+
const hideTop = props?.elements?.find((e) => e.name === 'm:hideTop');
70+
const hideBot = props?.elements?.find((e) => e.name === 'm:hideBot');
71+
const hideLeft = props?.elements?.find((e) => e.name === 'm:hideLeft');
72+
const hideRight = props?.elements?.find((e) => e.name === 'm:hideRight');
73+
const strikeBLTR = props?.elements?.find((e) => e.name === 'm:strikeBLTR');
74+
const strikeH = props?.elements?.find((e) => e.name === 'm:strikeH');
75+
const strikeTLBR = props?.elements?.find((e) => e.name === 'm:strikeTLBR');
76+
const strikeV = props?.elements?.find((e) => e.name === 'm:strikeV');
77+
78+
const notations: string[] = [];
79+
80+
const allHidden = isOn(hideTop) && isOn(hideBot) && isOn(hideLeft) && isOn(hideRight);
81+
82+
if (!allHidden) {
83+
if (!isOn(hideTop) && !isOn(hideBot) && !isOn(hideLeft) && !isOn(hideRight)) {
84+
notations.push('box');
85+
} else {
86+
if (!isOn(hideTop)) notations.push('top');
87+
if (!isOn(hideBot)) notations.push('bottom');
88+
if (!isOn(hideLeft)) notations.push('left');
89+
if (!isOn(hideRight)) notations.push('right');
90+
}
91+
}
92+
93+
if (isOn(strikeBLTR)) notations.push('updiagonalstrike');
94+
if (isOn(strikeH)) notations.push('horizontalstrike');
95+
if (isOn(strikeTLBR)) notations.push('downdiagonalstrike');
96+
if (isOn(strikeV)) notations.push('verticalstrike');
97+
98+
const content = convertChildren(base?.elements ?? []);
99+
100+
// Drop empty wrappers — matches convertBox / convertFunction.
101+
if (content.childNodes.length === 0) return null;
102+
103+
if (notations.length === 0) {
104+
const mrow = doc.createElementNS(MATHML_NS, 'mrow');
105+
mrow.appendChild(content);
106+
return mrow;
107+
}
108+
109+
// Wrap the content in an inner <mrow> before placing it inside <menclose>.
110+
// MathML Core dropped <menclose>, so Chrome treats it as unknown and does
111+
// not apply row layout — each child would render as its own `block math`
112+
// line, stacking vertically. An inner <mrow> is a MathML Core element, so
113+
// the row layout runs on its children and everything stays inline.
114+
const innerMrow = doc.createElementNS(MATHML_NS, 'mrow');
115+
innerMrow.appendChild(content);
116+
117+
const menclose = doc.createElementNS(MATHML_NS, 'menclose');
118+
menclose.setAttribute('notation', notations.join(' '));
119+
menclose.appendChild(innerMrow);
120+
121+
return menclose;
122+
};

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
@@ -24,3 +24,4 @@ export { convertNary } from './nary.js';
2424
export { convertPhantom } from './phantom.js';
2525
export { convertGroupCharacter } from './group-character.js';
2626
export { convertMatrix } from './matrix.js';
27+
export { convertBox, convertBorderBox } from './box.js';

0 commit comments

Comments
 (0)