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 fc563c56c9..c1f79b657f 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 @@ -21,3 +21,4 @@ export { convertRadical } from './radical.js'; export { convertLowerLimit } from './lower-limit.js'; export { convertUpperLimit } from './upper-limit.js'; export { convertNary } from './nary.js'; +export { convertPhantom } from './phantom.js'; diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/phantom.ts b/packages/layout-engine/painters/dom/src/features/math/converters/phantom.ts new file mode 100644 index 0000000000..5fb61cae95 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/features/math/converters/phantom.ts @@ -0,0 +1,62 @@ +import type { MathObjectConverter } from '../types.js'; + +const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; + +/** + * Convert m:phant (phantom) to MathML or styled . + * + * OMML structure: + * m:phant → m:phantPr (optional: m:show, m:zeroWid, m:zeroAsc, m:zeroDesc), m:e (content) + * + * MathML output: + * Full phantom (default): content + * Visible with zeroed dimensions: with width/height/depth="0" + * + * A phantom reserves the space its content would occupy but renders invisibly. + * Property flags can zero-out individual dimensions or force visibility. + * + * @spec ECMA-376 §22.1.2.81 + */ +export const convertPhantom: MathObjectConverter = (node, doc, convertChildren) => { + const elements = node.elements ?? []; + const phantPr = elements.find((e) => e.name === 'm:phantPr'); + const base = elements.find((e) => e.name === 'm:e'); + + const show = phantPr?.elements?.find((e) => e.name === 'm:show'); + const zeroWid = phantPr?.elements?.find((e) => e.name === 'm:zeroWid'); + const zeroAsc = phantPr?.elements?.find((e) => e.name === 'm:zeroAsc'); + const zeroDesc = phantPr?.elements?.find((e) => e.name === 'm:zeroDesc'); + + /** OOXML ST_OnOff true values. */ + const isOnOffTrue = (val?: string) => val === '1' || val === 'on' || val === 'true'; + + // Per ECMA-376 §22.1.2.96: when m:show is omitted, the base is shown. + const isVisible = show == null || !show.attributes || isOnOffTrue(show.attributes['m:val']); + const hasZeroDimension = zeroWid || zeroAsc || zeroDesc; + + const content = convertChildren(base?.elements ?? []); + + if (!isVisible && !hasZeroDimension) { + const mphantom = doc.createElementNS(MATHML_NS, 'mphantom'); + mphantom.appendChild(content); + return mphantom; + } + + const mpadded = doc.createElementNS(MATHML_NS, 'mpadded'); + + const isZeroVal = (el?: typeof zeroWid) => el && (isOnOffTrue(el.attributes?.['m:val']) || !el.attributes); + + if (isZeroVal(zeroWid)) mpadded.setAttribute('width', '0'); + if (isZeroVal(zeroAsc)) mpadded.setAttribute('height', '0'); + if (isZeroVal(zeroDesc)) mpadded.setAttribute('depth', '0'); + + if (!isVisible) { + const mphantom = doc.createElementNS(MATHML_NS, 'mphantom'); + mphantom.appendChild(content); + mpadded.appendChild(mphantom); + } else { + mpadded.appendChild(content); + } + + return mpadded; +}; 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 1cd8a75240..1883f32b72 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 @@ -3292,3 +3292,164 @@ describe('m:nary converter', () => { expect(mo!.textContent).toBe(''); }); }); + +describe('m:phant converter', () => { + it('renders phantom with no properties as visible (m:show default)', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:phant', + 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('mphantom')).toBeNull(); + expect(result!.textContent).toBe('x'); + }); + + it('hides content when m:show has m:val="0"', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:phant', + elements: [ + { + name: 'm:phantPr', + elements: [{ name: 'm:show', attributes: { 'm:val': '0' } }], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + const mphantom = result!.querySelector('mphantom'); + expect(mphantom).not.toBeNull(); + expect(mphantom!.textContent).toBe('x'); + }); + + it('converts visible phantom with zeroed width to ', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:phant', + elements: [ + { + name: 'm:phantPr', + elements: [ + { name: 'm:show', attributes: { 'm:val': '1' } }, + { name: 'm:zeroWid', 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 mpadded = result!.querySelector('mpadded'); + expect(mpadded).not.toBeNull(); + expect(mpadded!.getAttribute('width')).toBe('0'); + expect(mpadded!.textContent).toBe('y'); + }); + + it('treats bare (no attributes) as visible', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:phant', + elements: [ + { + name: 'm:phantPr', + elements: [{ name: 'm:show' }, { name: 'm:zeroWid', attributes: { 'm:val': '1' } }], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'v' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + const mpadded = result!.querySelector('mpadded'); + expect(mpadded).not.toBeNull(); + expect(mpadded!.getAttribute('width')).toBe('0'); + const mphantom = mpadded!.querySelector('mphantom'); + expect(mphantom).toBeNull(); + expect(mpadded!.textContent).toBe('v'); + }); + + it('renders visible phantom with zeroed ascent as without hiding', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:phant', + elements: [ + { + name: 'm:phantPr', + elements: [{ name: 'm:zeroAsc', attributes: { 'm:val': '1' } }], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'z' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + const mpadded = result!.querySelector('mpadded'); + expect(mpadded).not.toBeNull(); + expect(mpadded!.getAttribute('height')).toBe('0'); + expect(mpadded!.querySelector('mphantom')).toBeNull(); + expect(mpadded!.textContent).toBe('z'); + }); + + it('renders invisible phantom with m:show="0" and zeroed height as wrapping ', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:phant', + elements: [ + { + name: 'm:phantPr', + elements: [ + { name: 'm:show', attributes: { 'm:val': '0' } }, + { name: 'm:zeroAsc', attributes: { 'm:val': '1' } }, + ], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'z' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + const mpadded = result!.querySelector('mpadded'); + expect(mpadded).not.toBeNull(); + expect(mpadded!.getAttribute('height')).toBe('0'); + expect(mpadded!.querySelector('mphantom')).not.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 efedb9cacb..b55e400ae4 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 @@ -26,6 +26,7 @@ import { convertLowerLimit, convertUpperLimit, convertNary, + convertPhantom, } from './converters/index.js'; export const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; @@ -55,6 +56,7 @@ const MATH_OBJECT_REGISTRY: Record = { 'm:limLow': convertLowerLimit, // Lower limit (e.g., lim) 'm:limUpp': convertUpperLimit, // Upper limit 'm:nary': convertNary, // N-ary operator (integral, summation, product) + 'm:phant': convertPhantom, // Phantom (invisible spacing placeholder) 'm:rad': convertRadical, // Radical (square root, nth root) 'm:sSub': convertSubscript, // Subscript 'm:sSup': convertSuperscript, // Superscript @@ -66,7 +68,6 @@ const MATH_OBJECT_REGISTRY: Record = { 'm:box': null, // Box (invisible grouping container) 'm:groupChr': null, // Group character (overbrace, underbrace) 'm:m': null, // Matrix (grid of elements) - 'm:phant': null, // Phantom (invisible spacing placeholder) }; /** OMML argument/container elements that wrap children in . */ diff --git a/tests/behavior/tests/importing/fixtures/math-phantom-tests.docx b/tests/behavior/tests/importing/fixtures/math-phantom-tests.docx new file mode 100644 index 0000000000..5e232f4d7b Binary files /dev/null and b/tests/behavior/tests/importing/fixtures/math-phantom-tests.docx differ diff --git a/tests/behavior/tests/importing/math-equations.spec.ts b/tests/behavior/tests/importing/math-equations.spec.ts index 6e033616ac..72faec4e32 100644 --- a/tests/behavior/tests/importing/math-equations.spec.ts +++ b/tests/behavior/tests/importing/math-equations.spec.ts @@ -11,6 +11,7 @@ const RADICAL_DOC = path.resolve(__dirname, 'fixtures/math-radical-tests.docx'); const LIMIT_DOC = path.resolve(__dirname, 'fixtures/math-limit-tests.docx'); const EQARR_DOC = path.resolve(__dirname, 'fixtures/math-eqarr-tests.docx'); const NARY_DOC = path.resolve(__dirname, 'fixtures/math-nary-tests.docx'); +const PHANTOM_DOC = path.resolve(__dirname, 'fixtures/math-phantom-tests.docx'); // Single-object test docs are used for focused verification by community contributors. // The all-objects doc is used for behavior tests since it exercises the full pipeline. @@ -1084,3 +1085,183 @@ test.describe('m:nary (n-ary operator) rendering', () => { expect(leaked).toEqual([]); }); }); + +test.describe('m:phant (phantom) rendering', () => { + // Fixture contains 12 phantom cases of `a + [phant(xyz)] + b`: + // 0: no phantPr → visible mpadded + // 1: empty phantPr → visible mpadded + // 2: bare → visible mpadded + // 3: m:show val=1 → visible mpadded + // 4: m:show val=0 → mphantom (hidden) + // 5: m:show val=true → visible mpadded + // 6: m:show val=false → mphantom (hidden) + // 7: m:zeroAsc=1 alone → mpadded height=0, visible + // 8: m:zeroDesc=1 alone → mpadded depth=0, visible + // 9: m:show=0 + m:zeroDesc=1 → mpadded depth=0 wrapping mphantom + // 10: m:show=0 + all three zero flags → mpadded all=0 wrapping mphantom + // 11: m:transp=1 (unsupported) → visible mpadded passthrough + + test('imports all 12 phantom equations from docx', async ({ superdoc }) => { + await superdoc.loadDocument(PHANTOM_DOC); + await superdoc.waitForStable(); + const mathCount = await superdoc.page.evaluate(() => document.querySelectorAll('math').length); + expect(mathCount).toBe(12); + }); + + test('renders visible phantom as without inner (default per ECMA-376 §22.1.2.96)', async ({ + superdoc, + }) => { + await superdoc.loadDocument(PHANTOM_DOC); + await superdoc.waitForStable(); + + // Cases 0, 1, 2, 3, 5: visibility-default variants (no m:show, empty, bare, val=1, val=true). + const visibleIndices = [0, 1, 2, 3, 5]; + const results = await superdoc.page.evaluate((indices) => { + const maths = document.querySelectorAll('math'); + return indices.map((i) => { + const m = maths[i]; + const mpadded = m?.querySelector('mpadded'); + return { + hasMphantom: m?.querySelector('mphantom') != null, + mpaddedWidth: mpadded?.getAttribute('width'), + mpaddedHeight: mpadded?.getAttribute('height'), + mpaddedDepth: mpadded?.getAttribute('depth'), + text: m?.textContent, + }; + }); + }, visibleIndices); + + for (const r of results) { + expect(r.hasMphantom).toBe(false); + expect(r.mpaddedWidth).toBeNull(); + expect(r.mpaddedHeight).toBeNull(); + expect(r.mpaddedDepth).toBeNull(); + expect(r.text).toBe('a+xyz+b'); + } + }); + + test('renders m:show val=0 / val=false as (hidden, space reserved)', async ({ superdoc }) => { + await superdoc.loadDocument(PHANTOM_DOC); + await superdoc.waitForStable(); + + const results = await superdoc.page.evaluate(() => { + const maths = document.querySelectorAll('math'); + return [4, 6].map((i) => { + const m = maths[i]; + const mphantom = m?.querySelector('mphantom'); + return { + hasMphantom: mphantom != null, + hasMpadded: m?.querySelector('mpadded') != null, + phantomText: mphantom?.textContent, + }; + }); + }); + + for (const r of results) { + expect(r.hasMphantom).toBe(true); + expect(r.hasMpadded).toBe(false); + expect(r.phantomText).toBe('xyz'); + } + }); + + test('zeros individual dimensions on without hiding content', async ({ superdoc }) => { + await superdoc.loadDocument(PHANTOM_DOC); + await superdoc.waitForStable(); + + // Case 7: m:zeroAsc=1 alone; Case 8: m:zeroDesc=1 alone. + const results = await superdoc.page.evaluate(() => { + const maths = document.querySelectorAll('math'); + const extract = (i: number) => { + const p = maths[i]?.querySelector('mpadded'); + return { + width: p?.getAttribute('width') ?? null, + height: p?.getAttribute('height') ?? null, + depth: p?.getAttribute('depth') ?? null, + hasMphantom: maths[i]?.querySelector('mphantom') != null, + }; + }; + return { zeroAsc: extract(7), zeroDesc: extract(8) }; + }); + + expect(results.zeroAsc).toEqual({ width: null, height: '0', depth: null, hasMphantom: false }); + expect(results.zeroDesc).toEqual({ width: null, height: null, depth: '0', hasMphantom: false }); + }); + + test('combines zeroed dimensions with hidden content as wrapping ', async ({ superdoc }) => { + await superdoc.loadDocument(PHANTOM_DOC); + await superdoc.waitForStable(); + + // Case 9: show=0 + zeroDesc=1; Case 10: show=0 + all three zero flags. + const results = await superdoc.page.evaluate(() => { + const maths = document.querySelectorAll('math'); + const inspect = (i: number) => { + const mpadded = maths[i]?.querySelector('mpadded'); + const inner = mpadded?.querySelector('mphantom'); + return { + mpadded: mpadded + ? { + width: mpadded.getAttribute('width'), + height: mpadded.getAttribute('height'), + depth: mpadded.getAttribute('depth'), + } + : null, + innerIsMphantom: inner != null, + innerText: inner?.textContent, + }; + }; + return { case9: inspect(9), case10: inspect(10) }; + }); + + expect(results.case9).toEqual({ + mpadded: { width: null, height: null, depth: '0' }, + innerIsMphantom: true, + innerText: 'xyz', + }); + expect(results.case10).toEqual({ + mpadded: { width: '0', height: '0', depth: '0' }, + innerIsMphantom: true, + innerText: 'xyz', + }); + }); + + test('m:transp passes through as visible phantom (unsupported in MathML)', async ({ superdoc }) => { + await superdoc.loadDocument(PHANTOM_DOC); + await superdoc.waitForStable(); + + // Case 11: m:transp=1. No direct MathML equivalent; should fall through + // to a visible with no dimension attributes. + const result = await superdoc.page.evaluate(() => { + const m = document.querySelectorAll('math')[11]; + const mpadded = m?.querySelector('mpadded'); + return { + hasMphantom: m?.querySelector('mphantom') != null, + width: mpadded?.getAttribute('width') ?? null, + height: mpadded?.getAttribute('height') ?? null, + depth: mpadded?.getAttribute('depth') ?? null, + text: m?.textContent, + }; + }); + + expect(result).toEqual({ + hasMphantom: false, + width: null, + height: null, + depth: null, + text: 'a+xyz+b', + }); + }); + + test('OMML phantom property elements do not leak into the MathML DOM', async ({ superdoc }) => { + await superdoc.loadDocument(PHANTOM_DOC); + await superdoc.waitForStable(); + + // m:phantPr, m:show, m:zeroWid, m:zeroAsc, m:zeroDesc, m:transp are OOXML property + // elements — they must not appear in the rendered MathML output. + const leaked = await superdoc.page.evaluate(() => { + return Array.from(document.querySelectorAll('math *')) + .map((el) => el.localName.toLowerCase()) + .filter((n) => ['phantpr', 'show', 'zerowid', 'zeroasc', 'zerodesc', 'transp'].includes(n)); + }); + expect(leaked).toEqual([]); + }); +});