Skip to content

Commit d9dfeb1

Browse files
authored
fix(rtl-tables): drop double-mirror for logical cell margins (SD-3134) (#3272)
* fix(rtl-tables): drop double-mirror for logical cell margins (SD-3134) Two paths needed alignment with the borders fix from #3227: 1. getTableCellMargins (super-converter) only iterated left/right/top/bottom, so inline w:tcMar/w:start and w:end never projected to top-level cellMargins. Extend the iteration to add marginStart/marginEnd, preserving the OOXML logical names. 2. convertCellMarginsToPx (pm-adapter table-styles) still pre-swapped marginStart/marginEnd for RTL via an isRtlTable param. DomPainter already mirrors padding for bidiVisual tables, so the table-style hydration path was double-mirroring. Remove the pre-swap and the param; pm-adapter now stores logical start/end LTR-default. Same rule the borders fix encodes: importer/style-engine preserve OOXML logical names, pm-adapter maps logical to LTR-default physical, DomPainter performs the single visual RTL mirror. Flip the bad RTL unit test that was locking in the swap. Unfixme the behavior regression test at tests/behavior/tests/tables/ rtl-bidivisual-asymmetric.spec.ts. Verified: - pm-adapter tests: 201 pass (table-styles + borders + table) - super-converter tests: 2889 pass - Playwright RTL bidiVisual asymmetric spec: 12 pass across chromium/firefox/webkit (tcMar test now passes for real, no fixme) * fix(rtl-tables): resolve tcMar precedence over table-level defaults (SD-3134) Follow-up to the original fix in this PR. A second-round review caught a case the original change still got wrong: when a table has tblCellMar with physical w:left/w:right defaults and a cell has inline tcMar with logical w:start/w:end, the cell-level exception was silently dropped. Root cause: the importer stored physical defaults in result.left/right and logical inline values in result.marginStart/marginEnd. Downstream extractCellPadding gave physical precedence (padding.left was already non-null), so the inline w:start/w:end never reached the painter. Per ECMA-376 section 17.4.68: "This setting [tcMar], if present, shall override the table cell margins from the table-level cell margins". Rewrite getTableCellMargins to resolve precedence inside the importer: cell-level inline (physical or logical) wins over table-level defaults (physical or logical). Output is a single physical-shaped object with LTR-default semantic; DomPainter still owns the visual RTL mirror. Verified with a Word-native fixture (table-level w:tblCellMar w:left=40 and w:right=40 plus cell-level w:tcMar w:start=480 and w:end=60) loaded in the dev app: - before: paddingLeft=2.667, paddingRight=2.667 (table defaults; bug) - after: paddingLeft=32, paddingRight=4 (cell exception; correct) Tests: - pm-adapter: 213 pass - super-converter: 2889 pass - behavior spec rtl-bidivisual-asymmetric: 15 pass across chromium / firefox / webkit (new precedence test + 4 existing ones) * refactor(rtl-tables): drop dead defensive branch + freshen test comment Comment-audit follow-up on SD-3134: - readMargin had a defensive branch for a nested { value: { value, type } } shape, but no super-converter or layout-engine translator emits that shape. Per CLAUDE.md (do not add error handling for scenarios that cannot happen) the branch and its vague "some translators emit" comment are removed. - The regression note on the round-1 tcMar behavior test described the round-1 importer output (marginStart/marginEnd keys), which no longer matches the round-2 output (resolved physical left/right). Refreshed so future agents do not look for keys that no longer exist. No behavior change. Tests still pass.
1 parent 33b66e8 commit d9dfeb1

5 files changed

Lines changed: 145 additions & 68 deletions

File tree

packages/layout-engine/pm-adapter/src/converters/table-styles.test.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,7 @@ describe('hydrateTableStyleAttrs', () => {
330330
expect(result?.cellPadding?.right).toBeCloseTo((200 / 1440) * 96);
331331
});
332332

333-
it('maps style marginStart/marginEnd to physical sides for RTL tables', () => {
333+
it('keeps marginStart/marginEnd LTR-default for RTL tables (painter mirrors at paint)', () => {
334334
const styles: StylesDocumentProperties = {
335335
...emptyStyles,
336336
styles: {
@@ -354,8 +354,11 @@ describe('hydrateTableStyleAttrs', () => {
354354
} as unknown as PMNode;
355355

356356
const result = hydrateTableStyleAttrs(table, buildContext(styles));
357-
// In RTL, start => right, end => left
358-
expect(result?.cellPadding?.right).toBeCloseTo((100 / 1440) * 96);
359-
expect(result?.cellPadding?.left).toBeCloseTo((200 / 1440) * 96);
357+
// pm-adapter stores logical start/end LTR-default (start => left,
358+
// end => right). DomPainter performs the single visual RTL mirror at
359+
// paint time; pre-mirroring here would double-swap for bidiVisual
360+
// tables. SD-3134.
361+
expect(result?.cellPadding?.left).toBeCloseTo((100 / 1440) * 96);
362+
expect(result?.cellPadding?.right).toBeCloseTo((200 / 1440) * 96);
360363
});
361364
});

packages/layout-engine/pm-adapter/src/converters/table-styles.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,14 @@ export const hydrateTableStyleAttrs = (
3535
): TableStyleHydration | null => {
3636
const hydration: TableStyleHydration = {};
3737
const tableProps = (tableNode.attrs?.tableProperties ?? null) as Record<string, unknown> | null;
38-
const isRtlTable = tableProps?.rightToLeft === true;
3938

4039
// Collect inline values first, then merge with style-resolved values below.
4140
let inlineBorders: HydratedTableBorders | undefined;
4241
let inlinePadding: BoxSpacing | undefined;
4342

4443
// 1. Inline properties (highest priority)
4544
if (tableProps) {
46-
const padding = convertCellMarginsToPx(tableProps.cellMargins as Record<string, unknown>, isRtlTable);
45+
const padding = convertCellMarginsToPx(tableProps.cellMargins as Record<string, unknown>);
4746
if (padding) inlinePadding = normalizeCellPaddingTopBottom(padding);
4847

4948
if (tableProps.borders && typeof tableProps.borders === 'object') {
@@ -99,10 +98,7 @@ export const hydrateTableStyleAttrs = (
9998
}
10099

101100
if (resolved.cellMargins) {
102-
const stylePadding = convertCellMarginsToPx(
103-
resolved.cellMargins as unknown as Record<string, unknown>,
104-
isRtlTable,
105-
);
101+
const stylePadding = convertCellMarginsToPx(resolved.cellMargins as unknown as Record<string, unknown>);
106102
if (stylePadding) {
107103
const normalizedStylePadding = normalizeCellPaddingTopBottom(stylePadding);
108104
hydration.cellPadding = inlinePadding
@@ -163,11 +159,12 @@ const adjustBorderSize = (border: Record<string, unknown>): Record<string, unkno
163159
return size != null ? { ...border, size } : border;
164160
};
165161

166-
const convertCellMarginsToPx = (margins: Record<string, unknown>, isRtlTable = false): BoxSpacing | undefined => {
162+
const convertCellMarginsToPx = (margins: Record<string, unknown>): BoxSpacing | undefined => {
167163
if (!margins || typeof margins !== 'object') return undefined;
168164
const spacing: BoxSpacing = {};
169-
const startSide: keyof BoxSpacing = isRtlTable ? 'right' : 'left';
170-
const endSide: keyof BoxSpacing = isRtlTable ? 'left' : 'right';
165+
// LTR-default mapping. pm-adapter stores logical start/end as physical
166+
// left/right; DomPainter is the single owner of the visual RTL mirror.
167+
// SD-3134: pre-mirroring here was double-swapping for bidiVisual tables.
171168
const keyMap: Record<string, keyof BoxSpacing> = {
172169
top: 'top',
173170
bottom: 'bottom',
@@ -177,8 +174,8 @@ const convertCellMarginsToPx = (margins: Record<string, unknown>, isRtlTable = f
177174
marginBottom: 'bottom',
178175
marginLeft: 'left',
179176
marginRight: 'right',
180-
marginStart: startSide,
181-
marginEnd: endSide,
177+
marginStart: 'left',
178+
marginEnd: 'right',
182179
};
183180

184181
Object.entries(margins).forEach(([key, value]) => {

packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.js

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -287,19 +287,52 @@ const markTableCellAsVMergeConsumed = (node) => {
287287
*/
288288
const getTableCellMargins = (inlineMargins, referencedStyles) => {
289289
const { cellMargins = {} } = referencedStyles;
290-
return ['left', 'right', 'top', 'bottom'].reduce((acc, direction) => {
291-
const key = `margin${direction.charAt(0).toUpperCase() + direction.slice(1)}`;
292-
const inlineValue = inlineMargins ? inlineMargins?.[key]?.value : null;
293-
const styleValue = cellMargins ? cellMargins[key] : null;
294-
if (inlineValue != null) {
295-
acc[direction] = twipsToPixels(inlineValue);
296-
} else if (styleValue == null) {
297-
acc[direction] = undefined;
298-
} else if (typeof styleValue === 'object') {
299-
acc[direction] = twipsToPixels(styleValue.value);
290+
// Read a margin value as twips. Accepts numeric or { value, type } shape.
291+
// Returns null when the source key is missing or unreadable.
292+
const readMargin = (source, key) => {
293+
if (!source) return null;
294+
const v = source[key];
295+
if (v == null) return null;
296+
if (typeof v === 'number') return v;
297+
if (typeof v === 'object' && typeof v.value === 'number') return v.value;
298+
return null;
299+
};
300+
301+
// Per OOXML §17.4.68 (tcMar) + §17.4.42/41 (tblCellMar): cell-level inline
302+
// overrides table-level defaults. SD-3134: resolve precedence here so
303+
// downstream sees a single physical-shaped output regardless of whether
304+
// the source used physical (w:left/w:right) or logical (w:start/w:end)
305+
// names. LTR-default semantic - DomPainter mirrors for RTL.
306+
const sides = [
307+
{ physical: 'top', physicalKey: 'marginTop', logicalKey: null },
308+
{ physical: 'bottom', physicalKey: 'marginBottom', logicalKey: null },
309+
{ physical: 'left', physicalKey: 'marginLeft', logicalKey: 'marginStart' },
310+
{ physical: 'right', physicalKey: 'marginRight', logicalKey: 'marginEnd' },
311+
];
312+
const result = {};
313+
for (const { physical, physicalKey, logicalKey } of sides) {
314+
// Cell-level inline values win as a unit over any table-level default
315+
// (physical OR logical inline beats physical OR logical style).
316+
const inlinePhysical = readMargin(inlineMargins, physicalKey);
317+
const inlineLogical = logicalKey ? readMargin(inlineMargins, logicalKey) : null;
318+
if (inlinePhysical != null) {
319+
result[physical] = twipsToPixels(inlinePhysical);
320+
continue;
321+
}
322+
if (inlineLogical != null) {
323+
result[physical] = twipsToPixels(inlineLogical);
324+
continue;
325+
}
326+
const stylePhysical = readMargin(cellMargins, physicalKey);
327+
const styleLogical = logicalKey ? readMargin(cellMargins, logicalKey) : null;
328+
if (stylePhysical != null) {
329+
result[physical] = twipsToPixels(stylePhysical);
330+
} else if (styleLogical != null) {
331+
result[physical] = twipsToPixels(styleLogical);
300332
} else {
301-
acc[direction] = twipsToPixels(styleValue);
333+
result[physical] = undefined;
302334
}
303-
return acc;
304-
}, {});
335+
}
336+
337+
return result;
305338
};
Binary file not shown.

tests/behavior/tests/tables/rtl-bidivisual-asymmetric.spec.ts

Lines changed: 84 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -97,46 +97,90 @@ test('RTL bidiVisual table with asymmetric tblBorders renders start on visual ri
9797
// tcMar start/end asymmetric (§17.4.68 + cell-padding mirror)
9898
// ----------------------------------------------------------------------------
9999

100-
// AIDEV-NOTE: temporary - test.fixme until SD-2771 lands the style-engine
101-
// projection for tableCellProperties.cellMargins.marginStart/marginEnd to
102-
// top-level cellMargins. Today only top/bottom project to top-level (as px
103-
// numbers), so extractCellPadding misses the asymmetric start/end values
104-
// and the painter defaults both sides to ~4px. Fix lives in style-engine
105-
// or the projection step (pm-adapter and painter handle the correct shape
106-
// once they get it).
107-
test.fixme(
108-
'RTL bidiVisual cell with asymmetric tcMar renders larger start padding on visual right',
109-
async ({ superdoc }) => {
110-
await superdoc.loadDocument(path.resolve(__dirname, 'fixtures/rtl-bidivisual-tcmar-asymmetric.docx'));
111-
await superdoc.waitForStable();
112-
113-
// Fixture: first cell tcMar/start=480 twips (large, ~24px) end=60 twips (small, ~3px).
114-
// tcMar (§17.4.68) start/end follow table direction (same governance as
115-
// tblBorders/start/end per §17.4.33/12 and the leading-edge rule in
116-
// §17.4.15: left for LTR tables, right for RTL tables). In a bidiVisual
117-
// table, start lands on visual right, so paddingRight > paddingLeft.
118-
const padding = await superdoc.page.evaluate(() => {
119-
const fragment = document.querySelector('.superdoc-table-fragment');
120-
if (!fragment) return null;
121-
const cells = Array.from(fragment.children).filter((el) => (el as HTMLElement).style?.position === 'absolute');
122-
const target = cells.find((cell) => cell.textContent?.includes('start-padding-large'));
123-
if (!target) return null;
124-
const cs = window.getComputedStyle(target);
125-
return {
126-
paddingLeft: parseFloat(cs.paddingLeft),
127-
paddingRight: parseFloat(cs.paddingRight),
128-
};
129-
});
130-
131-
expect(padding).not.toBeNull();
132-
if (!padding) return;
133-
134-
// Larger padding should be on the visual right (where start renders in RTL).
135-
expect(padding.paddingRight).toBeGreaterThan(padding.paddingLeft);
136-
// And the difference should be substantial (~21px between 480 twips and 60 twips).
137-
expect(padding.paddingRight - padding.paddingLeft).toBeGreaterThan(10);
138-
},
139-
);
100+
// Regression for SD-3134: getTableCellMargins resolves cell-level
101+
// w:tcMar/start/end against table-level defaults inside the importer
102+
// and outputs LTR-default physical sides. convertCellMarginsToPx no
103+
// longer pre-swaps for RTL. With those two fixes the painter is the
104+
// single owner of the visual mirror and asymmetric tcMar renders
105+
// Word-equivalent.
106+
test('RTL bidiVisual cell with asymmetric tcMar renders larger start padding on visual right', async ({ superdoc }) => {
107+
await superdoc.loadDocument(path.resolve(__dirname, 'fixtures/rtl-bidivisual-tcmar-asymmetric.docx'));
108+
await superdoc.waitForStable();
109+
110+
// Fixture: first cell tcMar/start=480 twips (large, ~24px) end=60 twips (small, ~3px).
111+
// tcMar (§17.4.68) start/end follow table direction (same governance as
112+
// tblBorders/start/end per §17.4.33/12 and the leading-edge rule in
113+
// §17.4.15: left for LTR tables, right for RTL tables). In a bidiVisual
114+
// table, start lands on visual right, so paddingRight > paddingLeft.
115+
const padding = await superdoc.page.evaluate(() => {
116+
const fragment = document.querySelector('.superdoc-table-fragment');
117+
if (!fragment) return null;
118+
const cells = Array.from(fragment.children).filter((el) => (el as HTMLElement).style?.position === 'absolute');
119+
const target = cells.find((cell) => cell.textContent?.includes('start-padding-large'));
120+
if (!target) return null;
121+
const cs = window.getComputedStyle(target);
122+
return {
123+
paddingLeft: parseFloat(cs.paddingLeft),
124+
paddingRight: parseFloat(cs.paddingRight),
125+
};
126+
});
127+
128+
expect(padding).not.toBeNull();
129+
if (!padding) return;
130+
131+
// Larger padding should be on the visual right (where start renders in RTL).
132+
expect(padding.paddingRight).toBeGreaterThan(padding.paddingLeft);
133+
// And the difference should be substantial (~21px between 480 twips and 60 twips).
134+
expect(padding.paddingRight - padding.paddingLeft).toBeGreaterThan(10);
135+
});
136+
137+
// ----------------------------------------------------------------------------
138+
// tcMar overrides table-level cellMargins per §17.4.68
139+
// ----------------------------------------------------------------------------
140+
141+
// Regression for SD-3134 round-2: when a table has tblCellMar with physical
142+
// left/right defaults AND a cell has inline tcMar with logical start/end,
143+
// the cell-level exception must override the table-level default per
144+
// §17.4.68 ("This setting, if present, shall override the table cell margins
145+
// from the table-level cell margins"). Earlier the importer kept both shapes
146+
// in cellMargins and extractCellPadding gave physical left/right precedence,
147+
// so the inline start/end values were silently dropped.
148+
test('LTR table with table-level marginLeft/Right defaults + cell-level tcMar/start/end uses the cell-level values', async ({
149+
superdoc,
150+
}) => {
151+
await superdoc.loadDocument(path.resolve(__dirname, 'fixtures/ltr-tcmar-overrides-table-default.docx'));
152+
await superdoc.waitForStable();
153+
154+
// Fixture:
155+
// - Table tblPr/tblCellMar w:left=40 dxa and w:right=40 dxa (~2.7px each)
156+
// - Cell 1 tcPr/tcMar w:start=480 dxa (~32px) and w:end=60 dxa (~4px)
157+
// - Cell 2 has no inline tcMar (inherits the table defaults)
158+
const padding = await superdoc.page.evaluate(() => {
159+
const fragment = document.querySelector('.superdoc-table-fragment');
160+
if (!fragment) return null;
161+
const cells = Array.from(fragment.children).filter((el) => (el as HTMLElement).style?.position === 'absolute');
162+
const exception = cells.find((c) => c.textContent?.includes('inline tcMar exception cell'));
163+
const defaulted = cells.find((c) => c.textContent?.includes('table default cell'));
164+
if (!exception || !defaulted) return null;
165+
const csE = window.getComputedStyle(exception);
166+
const csD = window.getComputedStyle(defaulted);
167+
return {
168+
exception: { paddingLeft: parseFloat(csE.paddingLeft), paddingRight: parseFloat(csE.paddingRight) },
169+
defaulted: { paddingLeft: parseFloat(csD.paddingLeft), paddingRight: parseFloat(csD.paddingRight) },
170+
};
171+
});
172+
173+
expect(padding).not.toBeNull();
174+
if (!padding) return;
175+
176+
// Cell with inline tcMar uses its own asymmetric exception values.
177+
expect(padding.exception.paddingLeft).toBeGreaterThan(20);
178+
expect(padding.exception.paddingRight).toBeLessThan(10);
179+
expect(padding.exception.paddingLeft - padding.exception.paddingRight).toBeGreaterThan(20);
180+
// Cell without inline tcMar inherits the table-level defaults (~2.7px).
181+
expect(padding.defaulted.paddingLeft).toBeLessThan(5);
182+
expect(padding.defaulted.paddingRight).toBeLessThan(5);
183+
});
140184

141185
// ----------------------------------------------------------------------------
142186
// gridBefore / gridAfter (§17.4.14 + §17.4.15)

0 commit comments

Comments
 (0)