Skip to content

Commit 004eac5

Browse files
authored
Merge pull request #3354 from superdoc-dev/caio-pizzol/SD-3171-fix-style-cascade-bidivisual
fix(direction): only honor inline w:bidiVisual for table visual direction (SD-3171)
2 parents f86b762 + 0c88505 commit 004eac5

5 files changed

Lines changed: 95 additions & 61 deletions

File tree

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

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2527,7 +2527,7 @@ describe('tableCellNodeToBlock — SD-2516: documentPartObject children', () =>
25272527
expect((cellBlocks[0] as ParagraphBlock).runs[0].text).toBe('Inner DPO');
25282528
});
25292529

2530-
describe('tableDirectionContext (SD-3138 Phase 1B)', () => {
2530+
describe('tableDirectionContext (SD-3138 Phase 1B + SD-3171 inline-only visual direction)', () => {
25312531
const mockBlockIdGenerator: BlockIdGenerator = vi.fn((kind) => `test-${kind}`);
25322532
const mockPositionMap: PositionMap = new Map();
25332533
const mockParagraphConverter = vi.fn(() => [
@@ -2576,7 +2576,11 @@ describe('tableCellNodeToBlock — SD-2516: documentPartObject children', () =>
25762576
expect(result?.attrs?.tableDirectionContext?.visualDirection).toBe('rtl');
25772577
});
25782578

2579-
it('style cascade rightToLeft=true produces visualDirection=rtl', () => {
2579+
// SD-3171: Word-parity contract. `w:bidiVisual` on a style does NOT visually
2580+
// flip cells - Word reports the table as wdTableDirectionLtr and renders
2581+
// cells in logical order despite the style cascade. SuperDoc must match.
2582+
// Style-cascade rightToLeft alone leaves visualDirection undefined.
2583+
it('style cascade rightToLeft=true alone leaves visualDirection undefined (SD-3171 Word-parity)', () => {
25802584
const result = tableNodeToBlock(
25812585
buildTableNode(undefined, 'RtlStyle'),
25822586
mockBlockIdGenerator,
@@ -2590,10 +2594,14 @@ describe('tableCellNodeToBlock — SD-2516: documentPartObject children', () =>
25902594
mockParagraphConverter,
25912595
contextWithStyle('RtlStyle', { rightToLeft: true }),
25922596
) as TableBlock;
2593-
expect(result?.attrs?.tableDirectionContext?.visualDirection).toBe('rtl');
2597+
expect(result?.attrs?.tableDirectionContext).toBeDefined();
2598+
expect(result?.attrs?.tableDirectionContext?.visualDirection).toBeUndefined();
25942599
});
25952600

2596-
it('inline rightToLeft=false overrides style cascade rightToLeft=true (visualDirection=ltr)', () => {
2601+
// SD-3171: even when style says RTL, inline-false still produces ltr - the
2602+
// inline layer is the only source we consult for visualDirection, and
2603+
// explicit `false` is honored.
2604+
it('inline rightToLeft=false produces visualDirection=ltr (style cascade ignored)', () => {
25972605
const result = tableNodeToBlock(
25982606
buildTableNode({ rightToLeft: false }, 'RtlStyle'),
25992607
mockBlockIdGenerator,
@@ -2610,11 +2618,10 @@ describe('tableCellNodeToBlock — SD-2516: documentPartObject children', () =>
26102618
expect(result?.attrs?.tableDirectionContext?.visualDirection).toBe('ltr');
26112619
});
26122620

2613-
it('inline bidiVisual=false overrides style cascade rightToLeft=true (alias-mixed override)', () => {
2621+
it('inline bidiVisual=false produces visualDirection=ltr (alias normalized, style cascade ignored)', () => {
26142622
// Importer normalizes w:bidiVisual to `rightToLeft` so this shape is rare
2615-
// in practice, but the resolver must treat the two aliases as one signal
2616-
// per layer or an inline-false override against a style-true silently
2617-
// resolves to RTL.
2623+
// in practice. SD-3171: style cascade is ignored regardless; the assertion
2624+
// is that inline `false` on the bidiVisual alias is still honored.
26182625
const result = tableNodeToBlock(
26192626
buildTableNode({ bidiVisual: false }, 'RtlStyle'),
26202627
mockBlockIdGenerator,

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

Lines changed: 14 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,6 @@ import {
5555
import {
5656
TableProperties,
5757
resolveTableCellProperties,
58-
resolveTableProperties,
5958
resolveExistingTableEffectiveStyleId,
6059
type TableInfo,
6160
} from '@superdoc/style-engine/ooxml';
@@ -1023,30 +1022,23 @@ export function tableNodeToBlock(
10231022
tableAttrs.tableProperties = tableProperties as Record<string, unknown>;
10241023
}
10251024

1026-
// SD-3138 Phase 1B: resolve the table direction context from cascade-resolved
1027-
// table properties so downstream consumers (painter, layout-engine, editor
1028-
// navigation) read a typed `TableDirectionContext` instead of inspecting raw
1029-
// tableProperties.rightToLeft. Inline `w:bidiVisual` wins over the style
1030-
// cascade; explicit `false` overrides a cascade `true` per §17.4.1 + §17.17.4
1031-
// (the resolver handles the explicit-false case via SD-3141).
1032-
const styleResolvedTableProps =
1033-
effectiveStyleId && converterContext?.translatedLinkedStyles
1034-
? resolveTableProperties(effectiveStyleId, converterContext.translatedLinkedStyles)
1035-
: undefined;
1036-
// Normalize the rightToLeft / bidiVisual aliases to a single signal PER LAYER
1037-
// before layering inline over style. Otherwise an inline `bidiVisual: false`
1038-
// paired with a style `rightToLeft: true` would resolve RTL because the two
1039-
// aliases get layered independently (inline-false on bidiVisual loses to
1040-
// style-true on rightToLeft). The importer normalizes w:bidiVisual to
1041-
// `rightToLeft` so this matters most when style-engine emits raw OOXML keys.
1025+
// SD-3171 Word-parity: `w:bidiVisual` visually flips cell order ONLY when
1026+
// set inline on the table itself. Word does not visually flip when the only
1027+
// source of `bidiVisual` is a style (verified empirically: a table style
1028+
// carrying `w:bidiVisual` produces TableDirection=wdTableDirectionLtr and
1029+
// renders cells in logical order in Word). The model-level cascade still
1030+
// happens via style-engine; we just don't feed style-derived bidiVisual into
1031+
// the painter's visual-direction signal.
1032+
//
1033+
// Normalize the rightToLeft / bidiVisual aliases on the inline layer
1034+
// (importer normalizes w:bidiVisual to `rightToLeft`; aliases stay possible
1035+
// when style-engine emits raw OOXML keys). `??` treats null/undefined as
1036+
// missing but preserves an explicit `false`, so an inline
1037+
// `<w:bidiVisual w:val="0"/>` is honored.
10421038
const inlineProps = rawTableProperties as { rightToLeft?: boolean; bidiVisual?: boolean } | undefined;
1043-
const styleProps = styleResolvedTableProps as { rightToLeft?: boolean; bidiVisual?: boolean } | undefined;
10441039
const inlineVisual = inlineProps?.rightToLeft ?? inlineProps?.bidiVisual;
1045-
const styleVisual = styleProps?.rightToLeft ?? styleProps?.bidiVisual;
1046-
// `??` treats null/undefined as missing but preserves an explicit `false`,
1047-
// so an inline `<w:bidiVisual w:val="0"/>` correctly overrides a style true.
10481040
const effectiveForDirection = {
1049-
rightToLeft: inlineVisual ?? styleVisual,
1041+
rightToLeft: inlineVisual,
10501042
};
10511043
const sectionContext = converterContext?.sectionDirectionContext ?? resolveSectionDirection(undefined);
10521044
tableAttrs.tableDirectionContext = resolveTableDirection(effectiveForDirection, sectionContext);
1.87 KB
Binary file not shown.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { test, expect } from '../../fixtures/superdoc.js';
2+
import path from 'node:path';
3+
import { fileURLToPath } from 'node:url';
4+
5+
test.use({ config: { toolbar: 'full', showSelection: true } });
6+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
7+
8+
// SD-3171 positive control: when `w:bidiVisual` is set INLINE on the table's
9+
// own `tblPr` (not via a style), Word visually flips the cell order. The
10+
// companion test rtl-style-derived-bidivisual.spec.ts pins the negative
11+
// control (style cascade does NOT visually flip).
12+
//
13+
// Verified empirically: opening this fixture in Word shows
14+
// `Document.Tables(1).TableDirection === 0` (wdTableDirectionRtl) and
15+
// renders cells visually right-to-left (logical first cell on visual right).
16+
//
17+
// Together these two tests prove the SD-3171 fix is direction-specific: the
18+
// inline path is preserved, the style-cascade path is what changed.
19+
20+
test('table with inline bidiVisual flips cells visually (logical first on visual right)', async ({ superdoc }) => {
21+
await superdoc.loadDocument(path.resolve(__dirname, 'fixtures/rtl-inline-bidivisual.docx'));
22+
await superdoc.waitForStable();
23+
24+
// Fixture: 1x3 table, logical cells A B C, inline `w:bidiVisual`.
25+
// Expected visual order (left to right): C B A.
26+
const cellLayout = await superdoc.page.evaluate(() => {
27+
const fragment = document.querySelector('.superdoc-table-fragment');
28+
if (!fragment) return null;
29+
const fragRect = fragment.getBoundingClientRect();
30+
const cells = Array.from(fragment.children).filter((el) => (el as HTMLElement).style?.position === 'absolute');
31+
if (cells.length === 0) return null;
32+
return cells
33+
.map((cell) => {
34+
const rect = (cell as HTMLElement).getBoundingClientRect();
35+
return { text: (cell.textContent ?? '').trim(), relLeft: rect.left - fragRect.left };
36+
})
37+
.filter((c) => c.text === 'A' || c.text === 'B' || c.text === 'C')
38+
.sort((a, b) => a.relLeft - b.relLeft);
39+
});
40+
41+
expect(cellLayout).not.toBeNull();
42+
if (!cellLayout) return;
43+
44+
expect(cellLayout).toHaveLength(3);
45+
// Inline bidiVisual produces visualDirection='rtl' → painter mirrors cells.
46+
expect(cellLayout.map((c) => c.text)).toEqual(['C', 'B', 'A']);
47+
});

tests/behavior/tests/tables/rtl-style-derived-bidivisual.spec.ts

Lines changed: 19 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,46 +5,38 @@ import { fileURLToPath } from 'node:url';
55
test.use({ config: { toolbar: 'full', showSelection: true } });
66
const __dirname = path.dirname(fileURLToPath(import.meta.url));
77

8-
// SD-2767 Wave 3 coverage gap: a table whose `w:bidiVisual` comes from the
9-
// style cascade (NOT inline on the table itself). The fixture defines a
10-
// custom table style with `w:bidiVisual` set, and the table references that
11-
// style via `w:tblStyle` with no inline `w:bidiVisual` override.
8+
// SD-3171 Word-parity contract: `w:bidiVisual` visually flips cell order ONLY
9+
// when set inline on the table itself. Word does NOT visually flip cells when
10+
// the only source of `w:bidiVisual` is a style cascade.
1211
//
13-
// Per ECMA-376 §17.4.1, `w:bidiVisual` flips the visual order of cells:
14-
// logical first cell renders on the visual right, logical last on the
15-
// visual left. The style cascade must resolve this the same way as inline
16-
// `w:bidiVisual` would.
12+
// Verified empirically: opening this fixture in Word shows
13+
// `Document.Tables(1).TableDirection === 1` (wdTableDirectionLtr) and renders
14+
// cells in logical order A B C. SuperDoc must match.
1715
//
18-
// Word confirms `Document.Tables(1).TableDirection === 1` (wdTableDirectionRtl)
19-
// when opening this fixture, even though `document.xml` has no inline
20-
// `w:bidiVisual`. SuperDoc must match.
21-
22-
test('RTL bidiVisual table inherited from a table style renders logical first cell on the visual right', async ({
23-
superdoc,
24-
}) => {
16+
// History: PR #3350 originally asserted this fixture renders C B A (cells
17+
// visually flipped). That pinned SuperDoc's own pre-SD-3171 behavior, not
18+
// Word's. The flip came from pm-adapter's SD-3138 Phase 1B cascade path that
19+
// fell through from inline to style-resolved `bidiVisual`. SD-3171 removes
20+
// that fallback so style-cascade `bidiVisual` is ignored for visual direction.
21+
// The companion test rtl-inline-bidivisual.spec.ts pins the positive control
22+
// (inline `bidiVisual` still flips cells).
23+
24+
test('table with style-derived bidiVisual renders cells in logical order (Word-parity)', async ({ superdoc }) => {
2525
await superdoc.loadDocument(path.resolve(__dirname, 'fixtures/rtl-style-derived-bidivisual.docx'));
2626
await superdoc.waitForStable();
2727

2828
// Fixture: 1x3 table, logical cells A B C, style-set `bidiVisual`.
29-
// Expected visual order (right to left): A B C.
30-
// Expected visual order (left to right): C B A.
29+
// Expected visual order (left to right): A B C.
3130
const cellLayout = await superdoc.page.evaluate(() => {
3231
const fragment = document.querySelector('.superdoc-table-fragment');
3332
if (!fragment) return null;
3433
const fragRect = fragment.getBoundingClientRect();
35-
36-
// Find all rendered cells. The painter positions cells absolutely within
37-
// the fragment with left offsets that reflect their visual order.
3834
const cells = Array.from(fragment.children).filter((el) => (el as HTMLElement).style?.position === 'absolute');
3935
if (cells.length === 0) return null;
40-
4136
return cells
4237
.map((cell) => {
4338
const rect = (cell as HTMLElement).getBoundingClientRect();
44-
return {
45-
text: (cell.textContent ?? '').trim(),
46-
relLeft: rect.left - fragRect.left,
47-
};
39+
return { text: (cell.textContent ?? '').trim(), relLeft: rect.left - fragRect.left };
4840
})
4941
.filter((c) => c.text === 'A' || c.text === 'B' || c.text === 'C')
5042
.sort((a, b) => a.relLeft - b.relLeft);
@@ -53,11 +45,7 @@ test('RTL bidiVisual table inherited from a table style renders logical first ce
5345
expect(cellLayout).not.toBeNull();
5446
if (!cellLayout) return;
5547

56-
// Three cells found, in left-to-right visual order.
5748
expect(cellLayout).toHaveLength(3);
58-
59-
// RTL via style cascade: visual L-to-R order should be C, B, A.
60-
// If the style cascade is direction-blind, cells would render A, B, C and
61-
// this assertion would fail.
62-
expect(cellLayout.map((c) => c.text)).toEqual(['C', 'B', 'A']);
49+
// SD-3171: style-cascade `bidiVisual` does not visually flip cells. Match Word.
50+
expect(cellLayout.map((c) => c.text)).toEqual(['A', 'B', 'C']);
6351
});

0 commit comments

Comments
 (0)