From 8cf3e3ae09ed781bdcf2670ea9d14aee40e867d0 Mon Sep 17 00:00:00 2001 From: Abdeltoto Date: Tue, 7 Apr 2026 22:38:56 -0400 Subject: [PATCH 1/4] feat(math): implement m:phant phantom converter (closes #2608) Made-with: Cursor --- .../dom/src/features/math/converters/index.ts | 1 + .../src/features/math/converters/phantom.ts | 59 +++++++++++++++++++ .../dom/src/features/math/omml-to-mathml.ts | 3 +- 3 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 packages/layout-engine/painters/dom/src/features/math/converters/phantom.ts 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..d4100bbeb0 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/features/math/converters/phantom.ts @@ -0,0 +1,59 @@ +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'); + + const isVisible = show?.attributes?.['m:val'] === '1' || show?.attributes?.['m:val'] === 'on'; + 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 && (el.attributes?.['m:val'] === '1' || el.attributes?.['m:val'] === 'on' || !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.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 . */ From 4e2d2531282178a38ec236d38f599e694765fe26 Mon Sep 17 00:00:00 2001 From: Abdeltoto Date: Tue, 7 Apr 2026 22:50:55 -0400 Subject: [PATCH 2/4] fix(math): parse full ST_OnOff values in phantom converter Made-with: Cursor --- .../painters/dom/src/features/math/converters/phantom.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 index d4100bbeb0..f1f90ca543 100644 --- a/packages/layout-engine/painters/dom/src/features/math/converters/phantom.ts +++ b/packages/layout-engine/painters/dom/src/features/math/converters/phantom.ts @@ -27,7 +27,10 @@ export const convertPhantom: MathObjectConverter = (node, doc, convertChildren) const zeroAsc = phantPr?.elements?.find((e) => e.name === 'm:zeroAsc'); const zeroDesc = phantPr?.elements?.find((e) => e.name === 'm:zeroDesc'); - const isVisible = show?.attributes?.['m:val'] === '1' || show?.attributes?.['m:val'] === 'on'; + /** OOXML ST_OnOff true values. */ + const isOnOffTrue = (val?: string) => val === '1' || val === 'on' || val === 'true'; + + const isVisible = isOnOffTrue(show?.attributes?.['m:val']); const hasZeroDimension = zeroWid || zeroAsc || zeroDesc; const content = convertChildren(base?.elements ?? []); @@ -40,8 +43,7 @@ export const convertPhantom: MathObjectConverter = (node, doc, convertChildren) const mpadded = doc.createElementNS(MATHML_NS, 'mpadded'); - const isZeroVal = (el?: typeof zeroWid) => - el && (el.attributes?.['m:val'] === '1' || el.attributes?.['m:val'] === 'on' || !el.attributes); + 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'); From 05f858290e005986917f19f41e8a915f74c4fff9 Mon Sep 17 00:00:00 2001 From: Abdeltoto Date: Tue, 7 Apr 2026 22:56:11 -0400 Subject: [PATCH 3/4] fix(math): treat bare m:show element as visible in phantom converter Made-with: Cursor --- .../painters/dom/src/features/math/converters/phantom.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index f1f90ca543..97b01cc658 100644 --- a/packages/layout-engine/painters/dom/src/features/math/converters/phantom.ts +++ b/packages/layout-engine/painters/dom/src/features/math/converters/phantom.ts @@ -30,7 +30,7 @@ export const convertPhantom: MathObjectConverter = (node, doc, convertChildren) /** OOXML ST_OnOff true values. */ const isOnOffTrue = (val?: string) => val === '1' || val === 'on' || val === 'true'; - const isVisible = isOnOffTrue(show?.attributes?.['m:val']); + const isVisible = isOnOffTrue(show?.attributes?.['m:val']) || (show != null && !show.attributes); const hasZeroDimension = zeroWid || zeroAsc || zeroDesc; const content = convertChildren(base?.elements ?? []); From 6b0cb18e16632bc99d1b7d5362dcf096041c2b96 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 13 Apr 2026 16:12:50 -0700 Subject: [PATCH 4/4] fix(math): render phantom as visible when m:show is omitted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per ECMA-376 §22.1.2.96, when is absent from 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). --- .../src/features/math/converters/phantom.ts | 3 +- .../src/features/math/omml-to-mathml.test.ts | 161 ++++++++++++++++ .../fixtures/math-phantom-tests.docx | Bin 0 -> 14350 bytes .../tests/importing/math-equations.spec.ts | 181 ++++++++++++++++++ 4 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 tests/behavior/tests/importing/fixtures/math-phantom-tests.docx 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 index 97b01cc658..5fb61cae95 100644 --- a/packages/layout-engine/painters/dom/src/features/math/converters/phantom.ts +++ b/packages/layout-engine/painters/dom/src/features/math/converters/phantom.ts @@ -30,7 +30,8 @@ export const convertPhantom: MathObjectConverter = (node, doc, convertChildren) /** OOXML ST_OnOff true values. */ const isOnOffTrue = (val?: string) => val === '1' || val === 'on' || val === 'true'; - const isVisible = isOnOffTrue(show?.attributes?.['m:val']) || (show != null && !show.attributes); + // 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 ?? []); 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/tests/behavior/tests/importing/fixtures/math-phantom-tests.docx b/tests/behavior/tests/importing/fixtures/math-phantom-tests.docx new file mode 100644 index 0000000000000000000000000000000000000000..5e232f4d7be7526eeadcfb8e2b8c02c9ab6806f1 GIT binary patch literal 14350 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 literal 0 HcmV?d00001 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([]); + }); +});