diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/box.ts b/packages/layout-engine/painters/dom/src/features/math/converters/box.ts new file mode 100644 index 0000000000..45dbf14bda --- /dev/null +++ b/packages/layout-engine/painters/dom/src/features/math/converters/box.ts @@ -0,0 +1,122 @@ +import type { MathObjectConverter } from '../types.js'; + +const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; + +/** + * Convert m:box (grouping container) to MathML . + * + * OMML structure: + * m:box → m:boxPr (optional), m:e (content) + * + * MathML output: + * content + * + * Per §22.1.2.13 / §22.1.2.14, m:box can carry boxPr children that affect + * layout and spacing — opEmu (operator emulator), noBreak (disallow line + * breaks), aln (alignment point), diff (differential spacing), argSz. These + * have no clean MathML equivalent and are currently dropped; the box + * degrades to a plain that preserves grouping but not the other + * semantics. Extend here when any of these need first-class support. + * + * @spec ECMA-376 §22.1.2.13, §22.1.2.14 + */ +export const convertBox: MathObjectConverter = (node, doc, convertChildren) => { + const elements = node.elements ?? []; + const base = elements.find((e) => e.name === 'm:e'); + + const mrow = doc.createElementNS(MATHML_NS, 'mrow'); + mrow.appendChild(convertChildren(base?.elements ?? [])); + + return mrow.childNodes.length > 0 ? mrow : null; +}; + +/** + * Convert m:borderBox (bordered box) to MathML . + * + * OMML structure: + * m:borderBox → m:borderBoxPr (optional: m:hideTop, m:hideBot, m:hideLeft, m:hideRight, + * m:strikeBLTR, m:strikeH, m:strikeTLBR, m:strikeV), + * m:e (content) + * + * MathML output: + * content + * + * By default all four borders are shown (notation="box"). Individual borders + * can be hidden via m:hide* flags, and diagonal/horizontal/vertical strikes + * can be added via m:strike* flags. + * + * @spec ECMA-376 §22.1.2.11 + */ +export const convertBorderBox: MathObjectConverter = (node, doc, convertChildren) => { + const elements = node.elements ?? []; + const props = elements.find((e) => e.name === 'm:borderBoxPr'); + const base = elements.find((e) => e.name === 'm:e'); + + /** + * OOXML ST_OnOff (§22.9.2.7): on when the element is present and either + * `m:val` is absent (spec default = 1) or equals "1" / "true". "on" is + * accepted for leniency — Annex L.6.1.3 uses that form even though the + * normative enum is {0, 1, true, false}. + * TODO: extract to a shared util when m:acc / m:phant / matrix m:tblLook land. + */ + const isOn = (el?: { attributes?: Record }) => { + if (!el) return false; + const val = el.attributes?.['m:val']; + if (val === undefined) return true; + return val === '1' || val === 'true' || val === 'on'; + }; + + const hideTop = props?.elements?.find((e) => e.name === 'm:hideTop'); + const hideBot = props?.elements?.find((e) => e.name === 'm:hideBot'); + const hideLeft = props?.elements?.find((e) => e.name === 'm:hideLeft'); + const hideRight = props?.elements?.find((e) => e.name === 'm:hideRight'); + const strikeBLTR = props?.elements?.find((e) => e.name === 'm:strikeBLTR'); + const strikeH = props?.elements?.find((e) => e.name === 'm:strikeH'); + const strikeTLBR = props?.elements?.find((e) => e.name === 'm:strikeTLBR'); + const strikeV = props?.elements?.find((e) => e.name === 'm:strikeV'); + + const notations: string[] = []; + + const allHidden = isOn(hideTop) && isOn(hideBot) && isOn(hideLeft) && isOn(hideRight); + + if (!allHidden) { + if (!isOn(hideTop) && !isOn(hideBot) && !isOn(hideLeft) && !isOn(hideRight)) { + notations.push('box'); + } else { + if (!isOn(hideTop)) notations.push('top'); + if (!isOn(hideBot)) notations.push('bottom'); + if (!isOn(hideLeft)) notations.push('left'); + if (!isOn(hideRight)) notations.push('right'); + } + } + + if (isOn(strikeBLTR)) notations.push('updiagonalstrike'); + if (isOn(strikeH)) notations.push('horizontalstrike'); + if (isOn(strikeTLBR)) notations.push('downdiagonalstrike'); + if (isOn(strikeV)) notations.push('verticalstrike'); + + const content = convertChildren(base?.elements ?? []); + + // Drop empty wrappers — matches convertBox / convertFunction. + if (content.childNodes.length === 0) return null; + + if (notations.length === 0) { + const mrow = doc.createElementNS(MATHML_NS, 'mrow'); + mrow.appendChild(content); + return mrow; + } + + // Wrap the content in an inner before placing it inside . + // MathML Core dropped , so Chrome treats it as unknown and does + // not apply row layout — each child would render as its own `block math` + // line, stacking vertically. An inner is a MathML Core element, so + // the row layout runs on its children and everything stays inline. + const innerMrow = doc.createElementNS(MATHML_NS, 'mrow'); + innerMrow.appendChild(content); + + const menclose = doc.createElementNS(MATHML_NS, 'menclose'); + menclose.setAttribute('notation', notations.join(' ')); + menclose.appendChild(innerMrow); + + return menclose; +}; diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/index.ts b/packages/layout-engine/painters/dom/src/features/math/converters/index.ts index fcc7916fcb..0af6795656 100644 --- a/packages/layout-engine/painters/dom/src/features/math/converters/index.ts +++ b/packages/layout-engine/painters/dom/src/features/math/converters/index.ts @@ -24,3 +24,4 @@ export { convertNary } from './nary.js'; export { convertPhantom } from './phantom.js'; export { convertGroupCharacter } from './group-character.js'; export { convertMatrix } from './matrix.js'; +export { convertBox, convertBorderBox } from './box.js'; diff --git a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts index c5a1345725..898ab4da70 100644 --- a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts +++ b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts @@ -3929,3 +3929,344 @@ describe('m:m converter', () => { expect(mtable!.textContent).toBe('ab'); }); }); +describe('m:box converter', () => { + it('converts m:box to ', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:box', + 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(); + expect(result!.querySelector('mrow')).not.toBeNull(); + expect(result!.textContent).toBe('x'); + }); + + it('returns null for empty m:box', () => { + const omml = { + name: 'm:oMath', + elements: [{ name: 'm:box', elements: [] }], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).toBeNull(); + }); + + it('drops m:boxPr children (opEmu / noBreak / aln / diff are not yet mapped)', () => { + // Pins current scope: we render and silently ignore boxPr semantics. + // When opEmu or noBreak grow real MathML mappings, this test should fail + // and be updated — that failure is the point. + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:box', + elements: [ + { + name: 'm:boxPr', + elements: [ + { name: 'm:opEmu', attributes: { 'm:val': '1' } }, + { name: 'm:noBreak', attributes: { 'm:val': '1' } }, + { name: 'm:aln' }, + { name: 'm:diff', attributes: { 'm:val': '1' } }, + ], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '==' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + expect(result!.querySelector('mrow')).not.toBeNull(); + expect(result!.querySelector('menclose')).toBeNull(); + expect(result!.textContent).toBe('=='); + }); +}); + +describe('m:borderBox converter', () => { + it('converts m:borderBox to by default', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:borderBox', + elements: [ + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'E' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const menclose = result!.querySelector('menclose'); + expect(menclose).not.toBeNull(); + expect(menclose!.getAttribute('notation')).toBe('box'); + expect(menclose!.textContent).toBe('E'); + }); + + it('hides top and bottom sides (notation="left right")', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:borderBox', + elements: [ + { + name: 'm:borderBoxPr', + elements: [ + { name: 'm:hideTop', attributes: { 'm:val': '1' } }, + { name: 'm:hideBot', attributes: { 'm:val': '1' } }, + ], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + const menclose = result!.querySelector('menclose'); + expect(menclose).not.toBeNull(); + // Exact string — production order is top/bottom/left/right, so a side-swap regression fails here. + expect(menclose!.getAttribute('notation')).toBe('left right'); + }); + + it('adds strike notations', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:borderBox', + elements: [ + { + name: 'm:borderBoxPr', + elements: [ + { name: 'm:hideTop', attributes: { 'm:val': '1' } }, + { name: 'm:hideBot', attributes: { 'm:val': '1' } }, + { name: 'm:hideLeft', attributes: { 'm:val': '1' } }, + { name: 'm:hideRight', attributes: { 'm:val': '1' } }, + { name: 'm:strikeH', attributes: { 'm:val': '1' } }, + ], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'y' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + const menclose = result!.querySelector('menclose'); + expect(menclose).not.toBeNull(); + expect(menclose!.getAttribute('notation')).toBe('horizontalstrike'); + }); + + it('falls back to when all borders hidden and no strikes', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:borderBox', + elements: [ + { + name: 'm:borderBoxPr', + elements: [ + { name: 'm:hideTop', attributes: { 'm:val': '1' } }, + { name: 'm:hideBot', attributes: { 'm:val': '1' } }, + { name: 'm:hideLeft', attributes: { 'm:val': '1' } }, + { name: 'm:hideRight', attributes: { 'm:val': '1' } }, + ], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'q' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + const menclose = result!.querySelector('menclose'); + expect(menclose).toBeNull(); + expect(result!.textContent).toBe('q'); + }); + + // ── ST_OnOff variants (ECMA-376 §22.9.2.7) ──────────────────────────────── + // isOn accepts "1", "true", "on", and bare tags; rejects "0" / "false" / "off". + // Annex L.6.1.3 itself uses m:val="on" even though the normative enum is {0,1,true,false}. + + const makeBorderBox = (hideTopFlag: Record) => ({ + name: 'm:oMath', + elements: [ + { + name: 'm:borderBox', + elements: [ + { name: 'm:borderBoxPr', elements: [{ name: 'm:hideTop', ...hideTopFlag }] }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }], + }, + ], + }, + ], + }); + + it('treats m:val="true" as on (ST_OnOff)', () => { + const result = convertOmmlToMathml(makeBorderBox({ attributes: { 'm:val': 'true' } }), doc); + expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('bottom left right'); + }); + + it('treats m:val="on" as on (Annex L.6.1.3 form)', () => { + const result = convertOmmlToMathml(makeBorderBox({ attributes: { 'm:val': 'on' } }), doc); + expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('bottom left right'); + }); + + it('treats bare as on (spec default val=1)', () => { + const result = convertOmmlToMathml(makeBorderBox({}), doc); + expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('bottom left right'); + }); + + it('treats m:val="0" as off (top remains visible)', () => { + const result = convertOmmlToMathml(makeBorderBox({ attributes: { 'm:val': '0' } }), doc); + expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('box'); + }); + + it('treats m:val="false" as off', () => { + const result = convertOmmlToMathml(makeBorderBox({ attributes: { 'm:val': 'false' } }), doc); + expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('box'); + }); + + // ── Strike directions ───────────────────────────────────────────────────── + // BLTR (bottom-left → top-right = "/") maps to updiagonalstrike. + // TLBR (top-left → bottom-right = "\") maps to downdiagonalstrike. + // The directional naming is counter-intuitive — these tests pin it. + + const makeStrike = (strikeName: string) => ({ + name: 'm:oMath', + elements: [ + { + name: 'm:borderBox', + elements: [ + { + name: 'm:borderBoxPr', + elements: [ + { name: 'm:hideTop', attributes: { 'm:val': '1' } }, + { name: 'm:hideBot', attributes: { 'm:val': '1' } }, + { name: 'm:hideLeft', attributes: { 'm:val': '1' } }, + { name: 'm:hideRight', attributes: { 'm:val': '1' } }, + { name: strikeName, attributes: { 'm:val': '1' } }, + ], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }], + }, + ], + }, + ], + }); + + it('maps m:strikeBLTR to notation="updiagonalstrike" (/ direction)', () => { + const result = convertOmmlToMathml(makeStrike('m:strikeBLTR'), doc); + expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('updiagonalstrike'); + }); + + it('maps m:strikeTLBR to notation="downdiagonalstrike" (\\ direction)', () => { + const result = convertOmmlToMathml(makeStrike('m:strikeTLBR'), doc); + expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('downdiagonalstrike'); + }); + + it('maps m:strikeV to notation="verticalstrike"', () => { + const result = convertOmmlToMathml(makeStrike('m:strikeV'), doc); + expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('verticalstrike'); + }); + + it('combines multiple strikes in a fixed order', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:borderBox', + elements: [ + { + name: 'm:borderBoxPr', + elements: [ + { name: 'm:strikeBLTR', attributes: { 'm:val': '1' } }, + { name: 'm:strikeH', attributes: { 'm:val': '1' } }, + { name: 'm:strikeTLBR', attributes: { 'm:val': '1' } }, + { name: 'm:strikeV', attributes: { 'm:val': '1' } }, + ], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe( + 'box updiagonalstrike horizontalstrike downdiagonalstrike verticalstrike', + ); + }); + + it('combines partial hide flags with a strike (hideTop + strikeH)', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:borderBox', + elements: [ + { + name: 'm:borderBoxPr', + elements: [ + { name: 'm:hideTop', attributes: { 'm:val': '1' } }, + { name: 'm:strikeH', attributes: { 'm:val': '1' } }, + ], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('bottom left right horizontalstrike'); + }); + + it('returns null when m:e is empty (no bordered-but-empty )', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:borderBox', + elements: [{ name: 'm:borderBoxPr', elements: [{ name: 'm:strikeH', attributes: { 'm:val': '1' } }] }], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + // oMath still renders but has no children because borderBox dropped itself. + expect(result).toBeNull(); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.ts b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.ts index e2f8f016f9..2f49e3d5f7 100644 --- a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.ts +++ b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.ts @@ -29,6 +29,8 @@ import { convertPhantom, convertGroupCharacter, convertMatrix, + convertBox, + convertBorderBox, } from './converters/index.js'; export const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; @@ -66,9 +68,8 @@ const MATH_OBJECT_REGISTRY: Record = { 'm:sSubSup': convertSubSuperscript, // Sub-superscript (both) 'm:sPre': convertPreSubSuperscript, // Pre-sub-superscript (left of base) - // ── Not yet implemented (community contributions welcome) ──────────────── - 'm:borderBox': null, // Border box (border around math content) - 'm:box': null, // Box (invisible grouping container) + 'm:borderBox': convertBorderBox, // Border box (border around math content) + 'm:box': convertBox, // Box (invisible grouping container) 'm:groupChr': convertGroupCharacter, // Group character (overbrace, underbrace) }; diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 912c7db70b..9dcaf5dda0 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -91,6 +91,7 @@ import { ensureFieldAnnotationStyles, ensureImageSelectionStyles, ensureLinkStyles, + ensureMathMencloseStyles, ensurePrintStyles, ensureSdtContainerStyles, ensureTrackChangeStyles, @@ -1675,6 +1676,7 @@ export class DomPainter { ensureFieldAnnotationStyles(doc); ensureSdtContainerStyles(doc); ensureImageSelectionStyles(doc); + ensureMathMencloseStyles(doc); if (!this.isSemanticFlow && this.options.ruler?.enabled) { ensureRulerStyles(doc); } diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index e5bd524427..b548d37107 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -646,12 +646,85 @@ const IMAGE_SELECTION_STYLES = ` } `; +const MATH_MENCLOSE_STYLES = ` +/* MathML polyfill. + * + * MathML 3 defined with borders, strikes, and other + * enclosure notations. MathML Core (the subset shipped in Chrome 109+, 2023) + * dropped — the WG moved its rendering to CSS/SVG. Firefox and + * WebKit also do not paint it. Without this polyfill, m:borderBox content + * imports correctly (the notation attribute is right) but renders invisibly. + * + * Each notation token is composable: "box horizontalstrike" draws the box + * border and a horizontal strike together. Diagonal strikes layer through + * CSS custom properties so X patterns (both diagonals) stack correctly. + * + * @spec MathML 3 §3.3.8 menclose + */ +menclose { + display: inline-block; + position: relative; + padding: 0.15em 0.25em; + + --sd-menclose-stroke: currentColor; + --sd-menclose-h: none; + --sd-menclose-v: none; + --sd-menclose-up: none; + --sd-menclose-down: none; +} + +menclose[notation~="box"] { border: 1px solid var(--sd-menclose-stroke); } +menclose[notation~="roundedbox"] { border: 1px solid var(--sd-menclose-stroke); border-radius: 0.3em; } +menclose[notation~="top"] { border-top: 1px solid var(--sd-menclose-stroke); } +menclose[notation~="bottom"] { border-bottom: 1px solid var(--sd-menclose-stroke); } +menclose[notation~="left"] { border-left: 1px solid var(--sd-menclose-stroke); } +menclose[notation~="right"] { border-right: 1px solid var(--sd-menclose-stroke); } + +menclose[notation~="horizontalstrike"] { + --sd-menclose-h: linear-gradient(var(--sd-menclose-stroke), var(--sd-menclose-stroke)) no-repeat center / 100% 1px; +} +menclose[notation~="verticalstrike"] { + --sd-menclose-v: linear-gradient(var(--sd-menclose-stroke), var(--sd-menclose-stroke)) no-repeat center / 1px 100%; +} +/* Gradient direction is perpendicular to the stripe it produces. + * "to bottom right" → stripe runs bottom-left → top-right (visually "/") = updiagonalstrike. + * "to top right" → stripe runs top-left → bottom-right (visually "\") = downdiagonalstrike. + */ +menclose[notation~="updiagonalstrike"] { + --sd-menclose-up: linear-gradient( + to bottom right, + transparent calc(50% - 0.5px), + var(--sd-menclose-stroke) calc(50% - 0.5px), + var(--sd-menclose-stroke) calc(50% + 0.5px), + transparent calc(50% + 0.5px) + ); +} +menclose[notation~="downdiagonalstrike"] { + --sd-menclose-down: linear-gradient( + to top right, + transparent calc(50% - 0.5px), + var(--sd-menclose-stroke) calc(50% - 0.5px), + var(--sd-menclose-stroke) calc(50% + 0.5px), + transparent calc(50% + 0.5px) + ); +} + +menclose::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: var(--sd-menclose-h), var(--sd-menclose-v), var(--sd-menclose-up), var(--sd-menclose-down); +} +`; + let printStylesInjected = false; let linkStylesInjected = false; let trackChangeStylesInjected = false; let sdtContainerStylesInjected = false; let fieldAnnotationStylesInjected = false; let imageSelectionStylesInjected = false; +let mathMencloseStylesInjected = false; export const ensurePrintStyles = (doc: Document | null | undefined) => { if (printStylesInjected || !doc) return; @@ -712,3 +785,17 @@ export const ensureImageSelectionStyles = (doc: Document | null | undefined) => doc.head?.appendChild(styleEl); imageSelectionStylesInjected = true; }; + +/** + * Injects the MathML polyfill into the document head. Required + * because no browser paints menclose natively (MathML Core dropped it). See + * MATH_MENCLOSE_STYLES for the full rationale. + */ +export const ensureMathMencloseStyles = (doc: Document | null | undefined) => { + if (mathMencloseStylesInjected || !doc) return; + const styleEl = doc.createElement('style'); + styleEl.setAttribute('data-superdoc-math-menclose-styles', 'true'); + styleEl.textContent = MATH_MENCLOSE_STYLES; + doc.head?.appendChild(styleEl); + mathMencloseStylesInjected = true; +}; diff --git a/tests/behavior/tests/importing/fixtures/sd-2750-borderbox.docx b/tests/behavior/tests/importing/fixtures/sd-2750-borderbox.docx new file mode 100644 index 0000000000..99495ef04c Binary files /dev/null and b/tests/behavior/tests/importing/fixtures/sd-2750-borderbox.docx differ diff --git a/tests/behavior/tests/importing/math-box-border-box.spec.ts b/tests/behavior/tests/importing/math-box-border-box.spec.ts new file mode 100644 index 0000000000..1dd0eb9f88 --- /dev/null +++ b/tests/behavior/tests/importing/math-box-border-box.spec.ts @@ -0,0 +1,146 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, expect } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const BORDERBOX_DOC = path.resolve(__dirname, 'fixtures/sd-2750-borderbox.docx'); + +test.use({ config: { toolbar: 'none', comments: 'off' } }); + +/** + * Covers the 30 ECMA-376 §22.1.2.11–14 / §22.9.2.7 scenarios bundled in + * sd-2750-borderbox.docx: defaults, ST_OnOff value variants, individual hide + * flags, strike directions, combinations, Annex L.6.1.3, m:box with boxPr, + * and nested structures. + */ +test.describe('m:borderBox import → ', () => { + test('every scenario produces the expected notation attribute', async ({ superdoc }) => { + await superdoc.loadDocument(BORDERBOX_DOC); + await superdoc.waitForStable(); + + const notations = await superdoc.page.evaluate(() => + Array.from(document.querySelectorAll('menclose')).map((el) => el.getAttribute('notation')), + ); + + // Ordered — matches the scenario numbering inside the DOCX. + expect(notations).toEqual([ + 'box', // 1. default + 'box', // 2. empty borderBoxPr + 'bottom left right', // 3. m:val="1" + 'bottom left right', // 4. m:val="true" + 'bottom left right', // 5. m:val="on" (Annex L) + 'bottom left right', // 6. bare + 'box', // 7. m:val="0" → not hidden + 'box', // 8. m:val="false" + 'top left right', // 9. hideBot only + 'top bottom right', // 10. hideLeft only + 'top bottom left', // 11. hideRight only + 'bottom left', // 12. hideTop + hideRight (spec §22.1.2.12) + // 13 (all hidden, no strikes) → fallback, no menclose + 'updiagonalstrike', // 14. strikeBLTR → "/" + 'downdiagonalstrike', // 15. strikeTLBR → "\" + 'horizontalstrike', // 16 + 'verticalstrike', // 17 + 'updiagonalstrike downdiagonalstrike', // 18. X pattern + 'updiagonalstrike horizontalstrike downdiagonalstrike verticalstrike', // 19 + 'box horizontalstrike', // 20 + 'bottom left right horizontalstrike', // 21 + 'box downdiagonalstrike', // 22. Annex L.6.1.3 (m:val="on") + // 23–27 are m:box scenarios → , no menclose + 'box', // 28. m:box inside m:borderBox → outer menclose + 'box', // 29. m:borderBox inside m:box → inner menclose + 'left right', // 30. nested borderBox — outer + 'horizontalstrike', // 30. nested borderBox — inner + ]); + }); + + test('multi-child borderBox content renders as a horizontal row (Annex L.6.1.3)', async ({ superdoc }) => { + await superdoc.loadDocument(BORDERBOX_DOC); + await superdoc.waitForStable(); + + // Without the inner wrap, Chrome's MathML Core treats as + // unknown and each child renders with `display: block math` stacked + // vertically. This test asserts horizontal layout. + const layout = await superdoc.page.evaluate(() => { + const annex = Array.from(document.querySelectorAll('menclose')).find( + (el) => el.getAttribute('notation') === 'box downdiagonalstrike', + ); + if (!annex) return null; + const rect = annex.getBoundingClientRect(); + return { + wider_than_tall: rect.width > rect.height * 1.5, + hasInnerMrow: annex.children[0]?.localName === 'mrow', + innerChildCount: annex.children[0]?.children.length ?? 0, + }; + }); + + expect(layout).not.toBeNull(); + expect(layout!.wider_than_tall).toBe(true); + expect(layout!.hasInnerMrow).toBe(true); + expect(layout!.innerChildCount).toBe(5); // a², =, b², +, c² + }); + + test('ST_OnOff variants (1/true/on/bare/0/false) all resolve correctly', async ({ superdoc }) => { + await superdoc.loadDocument(BORDERBOX_DOC); + await superdoc.waitForStable(); + + // Scenarios 3-8 all share a single hideTop flag; only the m:val form differs. + // "1", "true", "on", and bare-tag → top hidden. "0" and "false" → top visible. + const notations = await superdoc.page.evaluate(() => { + const all = Array.from(document.querySelectorAll('menclose')).map((el) => el.getAttribute('notation')); + return all.slice(2, 8); // indexes 2..7 = scenarios 3..8 + }); + + // First four should all mean "top hidden" + expect(notations.slice(0, 4)).toEqual([ + 'bottom left right', // "1" + 'bottom left right', // "true" + 'bottom left right', // "on" + 'bottom left right', // bare tag + ]); + // Last two should mean "nothing hidden" → default box + expect(notations.slice(4)).toEqual(['box', 'box']); + }); + + test('m:box drops boxPr semantics and falls back to ', async ({ superdoc }) => { + await superdoc.loadDocument(BORDERBOX_DOC); + await superdoc.waitForStable(); + + // Scenarios 23-26 all produce (opEmu / noBreak / aln / diff currently ignored). + // Scenario 27 (empty m:box) should drop entirely. + const mrowOnlyTexts = await superdoc.page.evaluate(() => { + return Array.from(document.querySelectorAll('math')) + .filter((m) => !m.querySelector('menclose')) + .map((m) => m.textContent?.trim()); + }); + + // 13 (all-hides fallback), 23, 24, 25, 26 → mrow (5 total). 27 is dropped. + expect(mrowOnlyTexts).toEqual(['nobdr', '==', 'a==b', 'nbr', 'pAll']); + }); + + test('menclose polyfill stylesheet is injected', async ({ superdoc }) => { + await superdoc.loadDocument(BORDERBOX_DOC); + await superdoc.waitForStable(); + + // Without this stylesheet, borders and strikes are invisible in Chrome + // because MathML Core dropped . The polyfill lives in + // styles.ts → ensureMathMencloseStyles(). + const polyfill = await superdoc.page.evaluate(() => { + const style = document.querySelector('style[data-superdoc-math-menclose-styles]'); + if (!style) return null; + const css = style.textContent || ''; + return { + bytes: css.length, + hasBoxBorder: css.includes('notation~="box"') && css.includes('border:'), + hasUpDiagonal: css.includes('updiagonalstrike'), + hasDownDiagonal: css.includes('downdiagonalstrike'), + }; + }); + + expect(polyfill).not.toBeNull(); + expect(polyfill!.bytes).toBeGreaterThan(500); + expect(polyfill!.hasBoxBorder).toBe(true); + expect(polyfill!.hasUpDiagonal).toBe(true); + expect(polyfill!.hasDownDiagonal).toBe(true); + }); +});