Skip to content

Commit bb74ea2

Browse files
authored
feat(direction): populate TableAttrs.tableDirectionContext in pm-adapter (SD-3138 Phase 1B) (#3285)
Phase 1A (PR #3279) landed the getTableVisualDirection helper and migrated three consumers. The helper preferred a resolved TableDirectionContext but pm-adapter never wrote one - consumers fell through to the legacy fallback path on every read. Phase 1B closes the loop: - Add tableDirectionContext?: TableDirectionContext to TableAttrs in @superdoc/contracts (mirrors directionContext on ParagraphAttrs from Wave 1a / SD-2776). - In pm-adapter table.ts, resolve the effective table direction from cascade-resolved table properties (style cascade + inline override) and write the result via resolveTableDirection. The cascade resolution uses style-engine's resolveTableProperties for the style chain and `??` to layer inline on top. Because `??` treats null/undefined as missing but preserves explicit false, inline w:bidiVisual w:val="0" correctly overrides a style cascade true and produces visualDirection: 'ltr' (relies on SD-3141 resolver symmetry). After this lands, the helper's fast path is exercised at runtime: - Inline true → 'rtl' - Style cascade true → 'rtl' - Inline false overriding style true → 'ltr' - Absent signal → undefined Tests: - 4 new converter tests for the cascade scenarios above - pm-adapter: 1836 tests pass - painter table: 174 tests pass
1 parent de0c33f commit bb74ea2

3 files changed

Lines changed: 205 additions & 1 deletion

File tree

packages/layout-engine/contracts/src/index.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@ export type {
1717
RunScriptContext,
1818
} from './direction-context.js';
1919
export { getParagraphInlineDirection, getTableVisualDirection } from './direction-context.js';
20-
import type { ParagraphDirectionContext, RunBidiContext, RunScriptContext } from './direction-context.js';
20+
import type {
21+
ParagraphDirectionContext,
22+
RunBidiContext,
23+
RunScriptContext,
24+
TableDirectionContext,
25+
} from './direction-context.js';
2126

2227
// Export table contracts
2328
export {
@@ -630,6 +635,18 @@ export type TableAttrs = {
630635
borderCollapse?: 'collapse' | 'separate';
631636
cellSpacing?: CellSpacing;
632637
tableProperties?: TablePropertiesAttrs;
638+
/**
639+
* Resolved table direction context (SD-3138). Populated by pm-adapter from
640+
* cascade-resolved table properties via `resolveTableDirection`. Consumers
641+
* should call `getTableVisualDirection(attrs)` instead of reading
642+
* `tableProperties.rightToLeft` directly — the helper prefers this field
643+
* and falls back to the legacy raw read for compatibility.
644+
*
645+
* Per ECMA-376 §17.4.1, `w:bidiVisual` affects cell ordering and
646+
* table-visual properties only; it does NOT propagate to cell paragraphs
647+
* as inline direction.
648+
*/
649+
tableDirectionContext?: TableDirectionContext;
633650
sdt?: SdtMetadata;
634651
containerSdt?: SdtMetadata;
635652
[key: string]: unknown;

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

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2526,4 +2526,161 @@ describe('tableCellNodeToBlock — SD-2516: documentPartObject children', () =>
25262526
expect(cellBlocks[0].kind).toBe('paragraph');
25272527
expect((cellBlocks[0] as ParagraphBlock).runs[0].text).toBe('Inner DPO');
25282528
});
2529+
2530+
describe('tableDirectionContext (SD-3138 Phase 1B)', () => {
2531+
const mockBlockIdGenerator: BlockIdGenerator = vi.fn((kind) => `test-${kind}`);
2532+
const mockPositionMap: PositionMap = new Map();
2533+
const mockParagraphConverter = vi.fn(() => [
2534+
{ kind: 'paragraph', id: 'p1', runs: [{ text: 'cell', fontFamily: 'Arial', fontSize: 12 }] } as ParagraphBlock,
2535+
]);
2536+
2537+
const buildTableNode = (tableProperties?: Record<string, unknown>, tableStyleId?: string): PMNode => ({
2538+
type: 'table',
2539+
attrs: { ...(tableStyleId ? { tableStyleId } : {}), ...(tableProperties ? { tableProperties } : {}) },
2540+
content: [
2541+
{
2542+
type: 'tableRow',
2543+
content: [{ type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'cell' }] }] }],
2544+
},
2545+
],
2546+
});
2547+
2548+
const contextWithStyle = (styleId: string, styleTableProps: Record<string, unknown>): ConverterContext =>
2549+
({
2550+
translatedNumbering: {},
2551+
translatedLinkedStyles: {
2552+
docDefaults: {},
2553+
latentStyles: {},
2554+
styles: {
2555+
[styleId]: {
2556+
type: 'table',
2557+
tableProperties: styleTableProps,
2558+
},
2559+
},
2560+
},
2561+
}) as ConverterContext;
2562+
2563+
it('inline rightToLeft=true produces visualDirection=rtl', () => {
2564+
const result = tableNodeToBlock(
2565+
buildTableNode({ rightToLeft: true }),
2566+
mockBlockIdGenerator,
2567+
mockPositionMap,
2568+
'Arial',
2569+
16,
2570+
undefined,
2571+
undefined,
2572+
undefined,
2573+
undefined,
2574+
mockParagraphConverter,
2575+
) as TableBlock;
2576+
expect(result?.attrs?.tableDirectionContext?.visualDirection).toBe('rtl');
2577+
});
2578+
2579+
it('style cascade rightToLeft=true produces visualDirection=rtl', () => {
2580+
const result = tableNodeToBlock(
2581+
buildTableNode(undefined, 'RtlStyle'),
2582+
mockBlockIdGenerator,
2583+
mockPositionMap,
2584+
'Arial',
2585+
16,
2586+
undefined,
2587+
undefined,
2588+
undefined,
2589+
undefined,
2590+
mockParagraphConverter,
2591+
contextWithStyle('RtlStyle', { rightToLeft: true }),
2592+
) as TableBlock;
2593+
expect(result?.attrs?.tableDirectionContext?.visualDirection).toBe('rtl');
2594+
});
2595+
2596+
it('inline rightToLeft=false overrides style cascade rightToLeft=true (visualDirection=ltr)', () => {
2597+
const result = tableNodeToBlock(
2598+
buildTableNode({ rightToLeft: false }, 'RtlStyle'),
2599+
mockBlockIdGenerator,
2600+
mockPositionMap,
2601+
'Arial',
2602+
16,
2603+
undefined,
2604+
undefined,
2605+
undefined,
2606+
undefined,
2607+
mockParagraphConverter,
2608+
contextWithStyle('RtlStyle', { rightToLeft: true }),
2609+
) as TableBlock;
2610+
expect(result?.attrs?.tableDirectionContext?.visualDirection).toBe('ltr');
2611+
});
2612+
2613+
it('inline bidiVisual=false overrides style cascade rightToLeft=true (alias-mixed override)', () => {
2614+
// 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.
2618+
const result = tableNodeToBlock(
2619+
buildTableNode({ bidiVisual: false }, 'RtlStyle'),
2620+
mockBlockIdGenerator,
2621+
mockPositionMap,
2622+
'Arial',
2623+
16,
2624+
undefined,
2625+
undefined,
2626+
undefined,
2627+
undefined,
2628+
mockParagraphConverter,
2629+
contextWithStyle('RtlStyle', { rightToLeft: true }),
2630+
) as TableBlock;
2631+
expect(result?.attrs?.tableDirectionContext?.visualDirection).toBe('ltr');
2632+
});
2633+
2634+
it('no signal anywhere leaves visualDirection undefined', () => {
2635+
const result = tableNodeToBlock(
2636+
buildTableNode(),
2637+
mockBlockIdGenerator,
2638+
mockPositionMap,
2639+
'Arial',
2640+
16,
2641+
undefined,
2642+
undefined,
2643+
undefined,
2644+
undefined,
2645+
mockParagraphConverter,
2646+
) as TableBlock;
2647+
expect(result?.attrs?.tableDirectionContext).toBeDefined();
2648+
expect(result?.attrs?.tableDirectionContext?.visualDirection).toBeUndefined();
2649+
});
2650+
2651+
it('tableDirectionContext.parentSection propagates from converterContext.sectionDirectionContext', () => {
2652+
// The full TableDirectionContext shape is { visualDirection, parentSection }.
2653+
// Existing tests pin visualDirection; this one pins the section pass-through
2654+
// so a future regression that drops the sectionContext arg is caught here
2655+
// instead of by a runtime consumer reading parentSection.
2656+
const customSectionContext = {
2657+
pageDirection: 'rtl' as const,
2658+
writingMode: 'horizontal-tb' as const,
2659+
rtlGutter: true,
2660+
};
2661+
const contextWithSection: ConverterContext = {
2662+
translatedNumbering: {},
2663+
translatedLinkedStyles: {
2664+
docDefaults: {},
2665+
latentStyles: {},
2666+
styles: {},
2667+
},
2668+
sectionDirectionContext: customSectionContext,
2669+
};
2670+
const result = tableNodeToBlock(
2671+
buildTableNode({ rightToLeft: true }),
2672+
mockBlockIdGenerator,
2673+
mockPositionMap,
2674+
'Arial',
2675+
16,
2676+
undefined,
2677+
undefined,
2678+
undefined,
2679+
undefined,
2680+
mockParagraphConverter,
2681+
contextWithSection,
2682+
) as TableBlock;
2683+
expect(result?.attrs?.tableDirectionContext?.parentSection).toBe(customSectionContext);
2684+
});
2685+
});
25292686
});

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,12 @@ import {
5555
import {
5656
TableProperties,
5757
resolveTableCellProperties,
58+
resolveTableProperties,
5859
resolveExistingTableEffectiveStyleId,
5960
type TableInfo,
6061
} from '@superdoc/style-engine/ooxml';
6162
import { resolveThemeColorValue } from '../marks/theme-color.js';
63+
import { resolveTableDirection, resolveSectionDirection } from '../direction/index.js';
6264

6365
/**
6466
* Normalizes tableCellSpacing from PM node to CellSpacing object format.
@@ -1021,6 +1023,34 @@ export function tableNodeToBlock(
10211023
tableAttrs.tableProperties = tableProperties as Record<string, unknown>;
10221024
}
10231025

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.
1042+
const inlineProps = rawTableProperties as { rightToLeft?: boolean; bidiVisual?: boolean } | undefined;
1043+
const styleProps = styleResolvedTableProps as { rightToLeft?: boolean; bidiVisual?: boolean } | undefined;
1044+
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.
1048+
const effectiveForDirection = {
1049+
rightToLeft: inlineVisual ?? styleVisual,
1050+
};
1051+
const sectionContext = converterContext?.sectionDirectionContext ?? resolveSectionDirection(undefined);
1052+
tableAttrs.tableDirectionContext = resolveTableDirection(effectiveForDirection, sectionContext);
1053+
10241054
let columnWidths: number[] | undefined = undefined;
10251055

10261056
const twipsToPixels = (twips: number): number => {

0 commit comments

Comments
 (0)