From d35146b45e925cf759df3864069e1b0c5ac0936e Mon Sep 17 00:00:00 2001 From: Abdeltoto Date: Tue, 7 Apr 2026 22:40:48 -0400 Subject: [PATCH 1/3] feat(math): implement m:groupChr group-character converter (closes #2606) Made-with: Cursor --- .../math/converters/group-character.ts | 46 +++++++++++++++ .../dom/src/features/math/converters/index.ts | 1 + .../src/features/math/omml-to-mathml.test.ts | 57 +++++++++++++++++++ .../dom/src/features/math/omml-to-mathml.ts | 3 +- 4 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 packages/layout-engine/painters/dom/src/features/math/converters/group-character.ts diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/group-character.ts b/packages/layout-engine/painters/dom/src/features/math/converters/group-character.ts new file mode 100644 index 0000000000..bb3f4f92af --- /dev/null +++ b/packages/layout-engine/painters/dom/src/features/math/converters/group-character.ts @@ -0,0 +1,46 @@ +import type { MathObjectConverter } from '../types.js'; + +const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; + +/** Default group character: bottom curly bracket (U+23DF). */ +const DEFAULT_GROUP_CHAR = '\u23DF'; + +/** + * Convert m:groupChr (group character) to MathML or . + * + * OMML structure: + * m:groupChr → m:groupChrPr (optional: m:chr@m:val, m:pos@m:val, m:vertJc@m:val), m:e + * + * MathML output: + * pos="bot" (default): base char + * pos="top": base char + * + * The group character defaults to U+23DF (bottom curly bracket) when m:chr is absent. + * Position defaults to "bot" when m:pos is absent, matching Word's behavior. + * + * @spec ECMA-376 §22.1.2.41 + */ +export const convertGroupCharacter: MathObjectConverter = (node, doc, convertChildren) => { + const elements = node.elements ?? []; + const groupChrPr = elements.find((e) => e.name === 'm:groupChrPr'); + const base = elements.find((e) => e.name === 'm:e'); + + const chr = groupChrPr?.elements?.find((e) => e.name === 'm:chr'); + const pos = groupChrPr?.elements?.find((e) => e.name === 'm:pos'); + + const groupChar = chr?.attributes?.['m:val'] ?? DEFAULT_GROUP_CHAR; + const position = pos?.attributes?.['m:val'] ?? 'bot'; + + const wrapper = doc.createElementNS(MATHML_NS, position === 'top' ? 'mover' : 'munder'); + + const baseRow = doc.createElementNS(MATHML_NS, 'mrow'); + baseRow.appendChild(convertChildren(base?.elements ?? [])); + wrapper.appendChild(baseRow); + + const mo = doc.createElementNS(MATHML_NS, 'mo'); + mo.setAttribute('stretchy', 'true'); + mo.textContent = groupChar; + wrapper.appendChild(mo); + + return wrapper; +}; 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 c1f79b657f..3d9960dda6 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 @@ -22,3 +22,4 @@ export { convertLowerLimit } from './lower-limit.js'; export { convertUpperLimit } from './upper-limit.js'; export { convertNary } from './nary.js'; export { convertPhantom } from './phantom.js'; +export { convertGroupCharacter } from './group-character.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 1883f32b72..e6e470f7b0 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 @@ -3453,3 +3453,60 @@ describe('m:phant converter', () => { expect(mpadded!.querySelector('mphantom')).not.toBeNull(); }); }); + +describe('m:groupChr converter', () => { + it('converts bottom underbrace to with default character', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:groupChr', + 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(); + const munder = result!.querySelector('munder'); + expect(munder).not.toBeNull(); + expect(munder!.children[0]!.textContent).toBe('x'); + const groupMo = munder!.children[1] as Element; + expect(groupMo.localName).toBe('mo'); + expect(groupMo.textContent).toBe('\u23DF'); + }); + + it('converts top overbrace to ', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:groupChr', + elements: [ + { + name: 'm:groupChrPr', + elements: [ + { name: 'm:chr', attributes: { 'm:val': '\u23DE' } }, + { name: 'm:pos', attributes: { 'm:val': 'top' } }, + ], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'y' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + const mover = result!.querySelector('mover'); + expect(mover).not.toBeNull(); + expect(mover!.children[0]!.textContent).toBe('y'); + const mo = mover!.querySelector('mo'); + expect(mo!.textContent).toBe('\u23DE'); + }); +}); 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 b55e400ae4..f959e9054b 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 @@ -27,6 +27,7 @@ import { convertUpperLimit, convertNary, convertPhantom, + convertGroupCharacter, } from './converters/index.js'; export const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; @@ -66,7 +67,7 @@ const MATH_OBJECT_REGISTRY: Record = { // ── Not yet implemented (community contributions welcome) ──────────────── 'm:borderBox': null, // Border box (border around math content) 'm:box': null, // Box (invisible grouping container) - 'm:groupChr': null, // Group character (overbrace, underbrace) + 'm:groupChr': convertGroupCharacter, // Group character (overbrace, underbrace) 'm:m': null, // Matrix (grid of elements) }; From cdea90a4aa142ec1a223856a9ffb3f183f0c742c Mon Sep 17 00:00:00 2001 From: Abdeltoto Date: Tue, 7 Apr 2026 22:59:25 -0400 Subject: [PATCH 2/3] fix(math): add m:groupChr to VERTICAL_ELEMENTS for height estimation Made-with: Cursor --- .../src/converters/math-constants.test.ts | 14 ++++++++++++++ .../pm-adapter/src/converters/math-constants.ts | 1 + 2 files changed, 15 insertions(+) diff --git a/packages/layout-engine/pm-adapter/src/converters/math-constants.test.ts b/packages/layout-engine/pm-adapter/src/converters/math-constants.test.ts index 45a7e44a4a..21a0f26775 100644 --- a/packages/layout-engine/pm-adapter/src/converters/math-constants.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/math-constants.test.ts @@ -111,6 +111,20 @@ describe('estimateMathDimensions', () => { expect(width).toBe(50); // 5 chars * 10px }); + it('increases height for group character (m:groupChr)', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:groupChr', + elements: [{ name: 'm:e', elements: [{ name: 'm:r' }] }], + }, + ], + }; + const { height } = estimateMathDimensions('x', omml); + expect(height).toBeGreaterThan(MATH_DEFAULT_HEIGHT); + }); + it('enforces minimum width', () => { const { width } = estimateMathDimensions('x'); expect(width).toBe(20); // MATH_MIN_WIDTH diff --git a/packages/layout-engine/pm-adapter/src/converters/math-constants.ts b/packages/layout-engine/pm-adapter/src/converters/math-constants.ts index 74e5a430e0..a42ddf3870 100644 --- a/packages/layout-engine/pm-adapter/src/converters/math-constants.ts +++ b/packages/layout-engine/pm-adapter/src/converters/math-constants.ts @@ -23,6 +23,7 @@ const VERTICAL_ELEMENTS: Record = { 'm:sSup': 0.1, // Superscript 'm:sSubSup': 0.2, // Sub-superscript 'm:sPre': 0.2, // Pre-sub-superscript + 'm:groupChr': 0.35, // Group character (overbrace/underbrace) }; /** Count elements in an m:eqArr (equation array) for row-based height. */ From 1a6bc26255baa689c29bcaa194d3038a202bc3ab Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 13 Apr 2026 16:23:23 -0700 Subject: [PATCH 3/3] fix(math): honor m:vertJc and hide empty m:chr in groupChr converter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - empty now renders as a hidden character per ECMA-376 §22.1.2.20 - read m:vertJc, stamp data-vert-jc attribute, and shift the construct via position: relative when the value is non-natural for the given m:pos - add unit coverage for all four pos × vertJc combinations and empty-val vertJc - add behavior tests and a multi-variant fixture covering every §22.1.2.41 case --- .../math/converters/group-character.ts | 38 ++++- .../src/features/math/omml-to-mathml.test.ts | 110 ++++++++++++++ .../fixtures/math-groupchr-tests.docx | Bin 0 -> 11845 bytes .../tests/importing/math-equations.spec.ts | 139 ++++++++++++++++++ 4 files changed, 284 insertions(+), 3 deletions(-) create mode 100644 tests/behavior/tests/importing/fixtures/math-groupchr-tests.docx diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/group-character.ts b/packages/layout-engine/painters/dom/src/features/math/converters/group-character.ts index bb3f4f92af..25ffc51741 100644 --- a/packages/layout-engine/painters/dom/src/features/math/converters/group-character.ts +++ b/packages/layout-engine/painters/dom/src/features/math/converters/group-character.ts @@ -5,6 +5,13 @@ const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; /** Default group character: bottom curly bracket (U+23DF). */ const DEFAULT_GROUP_CHAR = '\u23DF'; +// Approximate shift used to distinguish non-natural m:vertJc combinations from their +// natural counterparts. Chrome's MathML engine ignores , and overriding +// `display` on / breaks their native vertical stacking, so we use +// `position: relative` + `top` instead. The value approximates the group-character +// object's half-height at 1em font size. +const VERT_JC_SHIFT_EM = 1; + /** * Convert m:groupChr (group character) to MathML or . * @@ -15,8 +22,17 @@ const DEFAULT_GROUP_CHAR = '\u23DF'; * pos="bot" (default): base char * pos="top": base char * - * The group character defaults to U+23DF (bottom curly bracket) when m:chr is absent. - * Position defaults to "bot" when m:pos is absent, matching Word's behavior. + * Defaults (ECMA-376 §22.1.2.20, §22.1.2.42, §22.1.2.119): + * m:chr absent → U+23DF (bottom curly bracket) + * m:chr present without m:val → hidden character + * m:pos absent → "bot" + * m:vertJc present without m:val → "bot" + * + * vertJc handling: m:vertJc specifies which edge of the group-character object aligns + * with the surrounding baseline. Natural / rendering puts the base on + * the baseline, which matches (pos=bot, vertJc=top) and (pos=top, vertJc=bot). Word + * renders an absent m:vertJc as the natural layout for the given position, so a shift + * is only applied when m:vertJc is explicitly set to the non-natural value for the pos. * * @spec ECMA-376 §22.1.2.41 */ @@ -27,9 +43,11 @@ export const convertGroupCharacter: MathObjectConverter = (node, doc, convertChi const chr = groupChrPr?.elements?.find((e) => e.name === 'm:chr'); const pos = groupChrPr?.elements?.find((e) => e.name === 'm:pos'); + const vertJc = groupChrPr?.elements?.find((e) => e.name === 'm:vertJc'); - const groupChar = chr?.attributes?.['m:val'] ?? DEFAULT_GROUP_CHAR; + const groupChar = chr ? (chr.attributes?.['m:val'] ?? '') : DEFAULT_GROUP_CHAR; const position = pos?.attributes?.['m:val'] ?? 'bot'; + const vertJustify = vertJc ? (vertJc.attributes?.['m:val'] ?? 'bot') : null; const wrapper = doc.createElementNS(MATHML_NS, position === 'top' ? 'mover' : 'munder'); @@ -42,5 +60,19 @@ export const convertGroupCharacter: MathObjectConverter = (node, doc, convertChi mo.textContent = groupChar; wrapper.appendChild(mo); + // Natural baseline: pos=top pairs with vertJc=bot, pos=bot pairs with vertJc=top. + // Only shift when vertJc is explicitly the non-natural value; an absent vertJc + // renders naturally (matches Word). + if (vertJustify) { + wrapper.setAttribute('data-vert-jc', vertJustify); + const naturalVertJc = position === 'top' ? 'bot' : 'top'; + if (vertJustify !== naturalVertJc) { + // pos=top,vertJc=top → shift the whole construct DOWN (char top to baseline). + // pos=bot,vertJc=bot → shift the whole construct UP (char bottom to baseline). + const direction = position === 'top' ? 1 : -1; + wrapper.setAttribute('style', `position: relative; top: ${direction * VERT_JC_SHIFT_EM}em;`); + } + } + return wrapper; }; 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 e6e470f7b0..ea75cd9e55 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 @@ -3480,6 +3480,33 @@ describe('m:groupChr converter', () => { expect(groupMo.textContent).toBe('\u23DF'); }); + it('hides the group character when m:chr is present without m:val', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:groupChr', + elements: [ + { + name: 'm:groupChrPr', + elements: [{ name: 'm:chr' }], + }, + { + 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(); + const munder = result!.querySelector('munder'); + expect(munder).not.toBeNull(); + const mo = munder!.querySelector('mo'); + expect(mo!.textContent).toBe(''); + }); + it('converts top overbrace to ', () => { const omml = { name: 'm:oMath', @@ -3509,4 +3536,87 @@ describe('m:groupChr converter', () => { const mo = mover!.querySelector('mo'); expect(mo!.textContent).toBe('\u23DE'); }); + + describe('m:vertJc baseline alignment', () => { + const buildGroupChr = (props: Array<{ name: string; attributes?: Record }>) => ({ + name: 'm:oMath', + elements: [ + { + name: 'm:groupChr', + elements: [ + { name: 'm:groupChrPr', elements: props }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }], + }, + ], + }, + ], + }); + + it('applies no shift when m:vertJc is absent (natural layout)', () => { + const omml = buildGroupChr([ + { name: 'm:chr', attributes: { 'm:val': '\u23DE' } }, + { name: 'm:pos', attributes: { 'm:val': 'top' } }, + ]); + const mover = convertOmmlToMathml(omml, doc)!.querySelector('mover')!; + expect(mover.getAttribute('style')).toBeNull(); + expect(mover.getAttribute('data-vert-jc')).toBeNull(); + }); + + it('pos=top, vertJc=bot renders natural mover without shift', () => { + const omml = buildGroupChr([ + { name: 'm:chr', attributes: { 'm:val': '\u23DE' } }, + { name: 'm:pos', attributes: { 'm:val': 'top' } }, + { name: 'm:vertJc', attributes: { 'm:val': 'bot' } }, + ]); + const mover = convertOmmlToMathml(omml, doc)!.querySelector('mover')!; + expect(mover.getAttribute('data-vert-jc')).toBe('bot'); + expect(mover.getAttribute('style')).toBeNull(); + }); + + it('pos=bot, vertJc=top renders natural munder without shift', () => { + const omml = buildGroupChr([ + { name: 'm:chr', attributes: { 'm:val': '\u23DF' } }, + { name: 'm:pos', attributes: { 'm:val': 'bot' } }, + { name: 'm:vertJc', attributes: { 'm:val': 'top' } }, + ]); + const munder = convertOmmlToMathml(omml, doc)!.querySelector('munder')!; + expect(munder.getAttribute('data-vert-jc')).toBe('top'); + expect(munder.getAttribute('style')).toBeNull(); + }); + + it('pos=top, vertJc=top shifts the construct down', () => { + const omml = buildGroupChr([ + { name: 'm:chr', attributes: { 'm:val': '\u23DE' } }, + { name: 'm:pos', attributes: { 'm:val': 'top' } }, + { name: 'm:vertJc', attributes: { 'm:val': 'top' } }, + ]); + const mover = convertOmmlToMathml(omml, doc)!.querySelector('mover')!; + expect(mover.getAttribute('data-vert-jc')).toBe('top'); + expect(mover.getAttribute('style')).toContain('top: 1em'); + }); + + it('pos=bot, vertJc=bot shifts the construct up', () => { + const omml = buildGroupChr([ + { name: 'm:chr', attributes: { 'm:val': '\u23DF' } }, + { name: 'm:pos', attributes: { 'm:val': 'bot' } }, + { name: 'm:vertJc', attributes: { 'm:val': 'bot' } }, + ]); + const munder = convertOmmlToMathml(omml, doc)!.querySelector('munder')!; + expect(munder.getAttribute('data-vert-jc')).toBe('bot'); + expect(munder.getAttribute('style')).toContain('top: -1em'); + }); + + it('vertJc present without m:val defaults to "bot"', () => { + const omml = buildGroupChr([ + { name: 'm:chr', attributes: { 'm:val': '\u23DE' } }, + { name: 'm:pos', attributes: { 'm:val': 'top' } }, + { name: 'm:vertJc' }, + ]); + const mover = convertOmmlToMathml(omml, doc)!.querySelector('mover')!; + expect(mover.getAttribute('data-vert-jc')).toBe('bot'); + expect(mover.getAttribute('style')).toBeNull(); + }); + }); }); diff --git a/tests/behavior/tests/importing/fixtures/math-groupchr-tests.docx b/tests/behavior/tests/importing/fixtures/math-groupchr-tests.docx new file mode 100644 index 0000000000000000000000000000000000000000..bc07b05d136a04858aa7eb06535c9969cd1dc985 GIT binary patch literal 11845 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 HU$y@SRzBm> 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 72faec4e32..9cef64f904 100644 --- a/tests/behavior/tests/importing/math-equations.spec.ts +++ b/tests/behavior/tests/importing/math-equations.spec.ts @@ -12,6 +12,7 @@ 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'); +const GROUPCHR_DOC = path.resolve(__dirname, 'fixtures/math-groupchr-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. @@ -1265,3 +1266,141 @@ test.describe('m:phant (phantom) rendering', () => { expect(leaked).toEqual([]); }); }); + +test.describe('m:groupChr (group character) rendering', () => { + // Fixture has 12 m:groupChr variants covering every ECMA-376 §22.1.2.41 case: + // 1 default, 2 empty m:chr, 3 explicit underbrace, 4 overbrace, 5 ← arrow, 6 → arrow, + // 7-10 four pos×vertJc combos, 11 vertJc empty-val, 12 complex base. + test('renders every groupChr variant as or ', async ({ superdoc }) => { + await superdoc.loadDocument(GROUPCHR_DOC); + await superdoc.waitForStable(); + + const counts = await superdoc.page.evaluate(() => ({ + math: document.querySelectorAll('math').length, + wrappers: document.querySelectorAll('munder, mover').length, + movers: document.querySelectorAll('mover').length, + munders: document.querySelectorAll('munder').length, + })); + + expect(counts.math).toBe(12); + expect(counts.wrappers).toBe(12); + // Variants 4, 5, 7, 8, 11 are pos=top → (5 total). + expect(counts.movers).toBe(5); + // Variants 1, 2, 3, 6, 9, 10, 12 are pos=bot or default → (7 total). + expect(counts.munders).toBe(7); + }); + + test('default (no groupChrPr) falls back to U+23DF bottom curly bracket', async ({ superdoc }) => { + await superdoc.loadDocument(GROUPCHR_DOC); + await superdoc.waitForStable(); + + const firstMunder = await superdoc.page.evaluate(() => { + const munder = document.querySelector('munder'); + const mo = munder?.querySelector('mo'); + return mo ? { text: mo.textContent, stretchy: mo.getAttribute('stretchy') } : null; + }); + + expect(firstMunder).not.toBeNull(); + expect(firstMunder!.text).toBe('\u23DF'); + expect(firstMunder!.stretchy).toBe('true'); + }); + + test('empty renders a hidden character', async ({ superdoc }) => { + await superdoc.loadDocument(GROUPCHR_DOC); + await superdoc.waitForStable(); + + // Variant 2 — second munder in DOM order. + const hiddenChar = await superdoc.page.evaluate(() => { + const munders = document.querySelectorAll('munder'); + const mo = munders[1]?.querySelector('mo'); + return mo?.textContent; + }); + + expect(hiddenChar).toBe(''); + }); + + test('custom m:chr values are preserved (U+23DE, U+2190, U+2192)', async ({ superdoc }) => { + await superdoc.loadDocument(GROUPCHR_DOC); + await superdoc.waitForStable(); + + const chars = await superdoc.page.evaluate(() => { + const wrappers = document.querySelectorAll('munder, mover'); + return Array.from(wrappers).map((w) => w.querySelector('mo')?.textContent ?? null); + }); + + // Variants 4 (U+23DE), 5 (U+2190), 6 (U+2192). + expect(chars[3]).toBe('\u23DE'); + expect(chars[4]).toBe('\u2190'); + expect(chars[5]).toBe('\u2192'); + }); + + test('natural vertJc combinations render without baseline shift', async ({ superdoc }) => { + await superdoc.loadDocument(GROUPCHR_DOC); + await superdoc.waitForStable(); + + const natural = await superdoc.page.evaluate(() => { + const wrappers = document.querySelectorAll('munder, mover'); + // Variant 8 (pos=top, vertJc=bot) and variant 9 (pos=bot, vertJc=top) are natural. + return { + v8: { + vertJc: wrappers[7]?.getAttribute('data-vert-jc'), + style: wrappers[7]?.getAttribute('style'), + }, + v9: { + vertJc: wrappers[8]?.getAttribute('data-vert-jc'), + style: wrappers[8]?.getAttribute('style'), + }, + }; + }); + + expect(natural.v8.vertJc).toBe('bot'); + expect(natural.v8.style).toBeNull(); + expect(natural.v9.vertJc).toBe('top'); + expect(natural.v9.style).toBeNull(); + }); + + test('non-natural vertJc combinations shift the construct vertically', async ({ superdoc }) => { + await superdoc.loadDocument(GROUPCHR_DOC); + await superdoc.waitForStable(); + + const shifted = await superdoc.page.evaluate(() => { + const wrappers = document.querySelectorAll('munder, mover'); + return { + // Variant 7 (pos=top, vertJc=top) shifts down. + v7: { + vertJc: wrappers[6]?.getAttribute('data-vert-jc'), + style: wrappers[6]?.getAttribute('style'), + }, + // Variant 10 (pos=bot, vertJc=bot) shifts up. + v10: { + vertJc: wrappers[9]?.getAttribute('data-vert-jc'), + style: wrappers[9]?.getAttribute('style'), + }, + }; + }); + + expect(shifted.v7.vertJc).toBe('top'); + expect(shifted.v7.style).toContain('top: 1em'); + expect(shifted.v10.vertJc).toBe('bot'); + expect(shifted.v10.style).toContain('top: -1em'); + }); + + test('m:vertJc without m:val defaults to "bot"', async ({ superdoc }) => { + await superdoc.loadDocument(GROUPCHR_DOC); + await superdoc.waitForStable(); + + // Variant 11 — pos=top with (no val) → defaults to "bot" = natural for pos=top. + const v11 = await superdoc.page.evaluate(() => { + const wrappers = document.querySelectorAll('munder, mover'); + return { + tag: wrappers[10]?.localName, + vertJc: wrappers[10]?.getAttribute('data-vert-jc'), + style: wrappers[10]?.getAttribute('style'), + }; + }); + + expect(v11.tag).toBe('mover'); + expect(v11.vertJc).toBe('bot'); + expect(v11.style).toBeNull(); + }); +});