Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion packages/layout-engine/contracts/src/direction-context.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { getParagraphInlineDirection } from './direction-context.js';
import { getParagraphInlineDirection, getTableVisualDirection } from './direction-context.js';

describe('getParagraphInlineDirection', () => {
it('returns undefined for null/undefined attrs', () => {
Expand Down Expand Up @@ -49,3 +49,50 @@ describe('getParagraphInlineDirection', () => {
expect(getParagraphInlineDirection({ paragraphProperties: {} })).toBeUndefined();
});
});

describe('getTableVisualDirection', () => {
it('returns undefined for null/undefined attrs', () => {
expect(getTableVisualDirection(undefined)).toBeUndefined();
expect(getTableVisualDirection(null)).toBeUndefined();
});

it('prefers tableDirectionContext.visualDirection over legacy fields', () => {
const attrs = {
tableDirectionContext: { visualDirection: 'rtl' as const },
tableProperties: { rightToLeft: false },
};
expect(getTableVisualDirection(attrs)).toBe('rtl');
});

it('falls back past tableDirectionContext when visualDirection is null', () => {
const attrs = {
tableDirectionContext: { visualDirection: null },
tableProperties: { rightToLeft: true },
};
expect(getTableVisualDirection(attrs)).toBe('rtl');
});

it('falls back past tableDirectionContext when visualDirection is undefined', () => {
const attrs = {
tableDirectionContext: { visualDirection: undefined },
tableProperties: { rightToLeft: true },
};
expect(getTableVisualDirection(attrs)).toBe('rtl');
});

it('falls back to tableProperties.rightToLeft', () => {
expect(getTableVisualDirection({ tableProperties: { rightToLeft: true } })).toBe('rtl');
expect(getTableVisualDirection({ tableProperties: { rightToLeft: false } })).toBe('ltr');
});

it('accepts bidiVisual as an alias for rightToLeft', () => {
expect(getTableVisualDirection({ tableProperties: { bidiVisual: true } })).toBe('rtl');
expect(getTableVisualDirection({ tableProperties: { bidiVisual: false } })).toBe('ltr');
});

it('returns undefined when no signal is present', () => {
expect(getTableVisualDirection({})).toBeUndefined();
expect(getTableVisualDirection({ tableDirectionContext: {} })).toBeUndefined();
expect(getTableVisualDirection({ tableProperties: {} })).toBeUndefined();
});
});
35 changes: 35 additions & 0 deletions packages/layout-engine/contracts/src/direction-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,38 @@ export function getParagraphInlineDirection(
}
return undefined;
}

/**
* Read a table's visual direction (cell ordering axis) from its attributes.
*
* Prefers the resolved {@link TableDirectionContext} when present, falls
* back to the legacy `tableProperties.rightToLeft` (or `bidiVisual` alias)
* for compatibility. The AIDEV-NOTE on the fallback branch names the
* retirement signal.
*
* Per ECMA-376 §17.4.1, `w:bidiVisual` affects only cell ordering and
* table-visual properties. Cell paragraph inline direction is independent;
* use {@link getParagraphInlineDirection} for that axis.
*
* Consumers should call this instead of reading `tableProperties.rightToLeft`
* directly so the source check stays in one place and the resolver can take
* over once pm-adapter populates `tableDirectionContext` everywhere.
*/
export function getTableVisualDirection(
attrs:
| {
tableDirectionContext?: { visualDirection?: BaseDirection | null } | null;
tableProperties?: { rightToLeft?: boolean | null; bidiVisual?: boolean | null } | null;
}
| null
| undefined,
): BaseDirection | undefined {
const fromContext = attrs?.tableDirectionContext?.visualDirection;
if (fromContext != null) return fromContext;
// AIDEV-NOTE: compat-fallback - used when TableAttrs.tableDirectionContext is absent.
// Retire once pm-adapter writes the resolved context onto every TableAttrs site.
const tp = attrs?.tableProperties;
if (tp?.rightToLeft === true || tp?.bidiVisual === true) return 'rtl';
if (tp?.rightToLeft === false || tp?.bidiVisual === false) return 'ltr';
return undefined;
}
2 changes: 1 addition & 1 deletion packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export type {
RunBidiContext,
RunScriptContext,
} from './direction-context.js';
export { getParagraphInlineDirection } from './direction-context.js';
export { getParagraphInlineDirection, getTableVisualDirection } from './direction-context.js';
import type { ParagraphDirectionContext, RunBidiContext, RunScriptContext } from './direction-context.js';

// Export table contracts
Expand Down
9 changes: 7 additions & 2 deletions packages/layout-engine/layout-engine/src/layout-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ import type {
ParagraphMeasure,
ParagraphBlock,
} from '@superdoc/contracts';
import { OOXML_PCT_DIVISOR, rescaleColumnWidths, resolveTableWidthAttr } from '@superdoc/contracts';
import {
OOXML_PCT_DIVISOR,
rescaleColumnWidths,
resolveTableWidthAttr,
getTableVisualDirection,
} from '@superdoc/contracts';
import type { PageState } from './paginator.js';
import { computeFragmentPmRange, extractBlockPmRange } from './layout-utils.js';
import { describeCellRenderBlocks, createCellSliceCursor, computeFullCellContentHeight } from './table-cell-slice.js';
Expand Down Expand Up @@ -182,7 +187,7 @@ export function resolveTableFrame(
): { x: number; width: number } {
const width = resolveRenderedTableWidth(columnWidth, tableWidth, attrs);
const explicitJustification = typeof attrs?.justification === 'string' ? attrs.justification : undefined;
const isRtlTable = attrs?.tableProperties?.rightToLeft === true;
const isRtlTable = getTableVisualDirection(attrs) === 'rtl';
const effectiveJustification = explicitJustification ?? (isRtlTable ? 'end' : undefined);
const tableIndent = getTableIndentWidth(attrs);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
TableFragment,
TableMeasure,
} from '@superdoc/contracts';
import { getTableVisualDirection } from '@superdoc/contracts';
import { CLASS_NAMES, fragmentStyles } from '../styles.js';
import { DOM_CLASS_NAMES } from '../constants.js';
import type { FragmentRenderContext } from '../renderer.js';
Expand Down Expand Up @@ -173,8 +174,7 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement

// RTL table: w:bidiVisual (ECMA-376 §17.4.1) — cells displayed right-to-left,
// table-level properties (borders, margins, indent) are mirrored.
const tableProperties = block.attrs?.tableProperties as Record<string, unknown> | undefined;
const isRtl = tableProperties?.rightToLeft === true;
const isRtl = getTableVisualDirection(block.attrs) === 'rtl';
// Note: We don't use createTableBorderOverlay because we implement single-owner
// border model where cells handle all borders (including outer table borders)
// to prevent double borders when rendering with absolutely-positioned divs.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @ts-check
import { Plugin, PluginKey, Selection, TextSelection } from 'prosemirror-state';
import { CellSelection, TableMap } from 'prosemirror-tables';
import { getTableVisualDirection } from '@superdoc/contracts';

const TABLE_CELL_ROLES = new Set(['cell', 'header_cell']);

Expand Down Expand Up @@ -168,8 +169,7 @@ function getTableContext($head) {
* @returns {boolean}
*/
function isRtlTable(table) {
const tableProperties = table?.attrs?.tableProperties;
return tableProperties?.rightToLeft === true;
return getTableVisualDirection(table?.attrs) === 'rtl';
}

/**
Expand Down
Loading