Skip to content

Commit b9010a4

Browse files
authored
feat(painter-dom): implement w:bidiVisual RTL table rendering (SD-2278) (#2556)
* feat(painter-dom): implement w:bidiVisual RTL table rendering (ECMA-376 §17.4.1) Detect `tableProperties.rightToLeft` from `w:bidiVisual` and render tables with visually reversed column order: - Set `dir="rtl"` + `direction: rtl` on table fragment container - Mirror cell X positions so first logical column appears on the right - Swap resolved border left↔right in CSS output for correct visual edges - Swap cell padding left↔right per Part 4 §14.3.3–14.3.8 - Handle cell spacing and colspan in mirrored layout - Mirror ghost cell positions for cross-page rowspan continuations Fixes SD-2278 * test(painter-dom): add unit tests for RTL table border swap and cell mirroring - border-utils: tests for swapTableBordersLR and swapCellBordersLR - renderTableRow: tests for RTL x-position mirroring with asymmetric widths, isRtl passthrough to renderTableCell, and border left↔right swap * fix(painter-dom): address RTL table review feedback - Remove dir="rtl" from table container — w:bidiVisual only affects cell layout order, not paragraph text direction. Cell reversal is handled by X mirroring already. Setting dir on the container would flip bidi behavior for LTR/neutral content inside cells. - Fix ghost cell spacing mismatch — ghostX was computed without cell spacing but totalWidth included it, causing misaligned continuation cells in RTL tables with non-zero cellSpacing. * fix(painter-dom): swap container L/R borders for RTL separate-border tables For tables with borderCollapse=separate and cellSpacing, the container paints outer table borders directly. Swap left↔right for RTL tables so the container border matches the mirrored cell layout. * test(painter-dom): add missing RTL table test coverage - renderTableCell: RTL padding swap with asymmetric left/right values - renderTableRow: RTL mirroring with non-zero cellSpacing + colspan - renderTableFragment: ghost cell RTL x-position mirroring for rowspan continuations, isRtl propagation to body rows
1 parent 8ebcce4 commit b9010a4

8 files changed

Lines changed: 510 additions & 29 deletions

File tree

packages/layout-engine/painters/dom/src/table/border-utils.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import {
2020
resolveTableCellBorders,
2121
createTableBorderOverlay,
2222
hasExplicitCellBorders,
23+
swapTableBordersLR,
24+
swapCellBordersLR,
2325
} from './border-utils.js';
2426

2527
describe('applyBorder', () => {
@@ -469,3 +471,54 @@ describe('createTableBorderOverlay', () => {
469471
expect(overlay?.style.borderBottom).toMatch(/2px solid (#0000FF|rgb\(0,\s*0,\s*255\))/i);
470472
});
471473
});
474+
475+
describe('swapTableBordersLR', () => {
476+
it('swaps left and right borders', () => {
477+
const borders: TableBorders = {
478+
top: { style: 'single', width: 1, color: '#000000' },
479+
bottom: { style: 'single', width: 1, color: '#000000' },
480+
left: { style: 'thick', width: 3, color: '#0000FF' },
481+
right: { style: 'single', width: 0.5, color: '#FF0000' },
482+
insideH: { style: 'single', width: 1, color: '#111111' },
483+
insideV: { style: 'single', width: 1, color: '#222222' },
484+
};
485+
const swapped = swapTableBordersLR(borders)!;
486+
expect(swapped.left).toEqual(borders.right);
487+
expect(swapped.right).toEqual(borders.left);
488+
expect(swapped.top).toEqual(borders.top);
489+
expect(swapped.bottom).toEqual(borders.bottom);
490+
expect(swapped.insideH).toEqual(borders.insideH);
491+
expect(swapped.insideV).toEqual(borders.insideV);
492+
});
493+
494+
it('returns undefined for undefined input', () => {
495+
expect(swapTableBordersLR(undefined)).toBeUndefined();
496+
});
497+
498+
it('handles missing left or right', () => {
499+
const borders: TableBorders = { top: { style: 'single', width: 1, color: '#000' } };
500+
const swapped = swapTableBordersLR(borders)!;
501+
expect(swapped.left).toBeUndefined();
502+
expect(swapped.right).toBeUndefined();
503+
});
504+
});
505+
506+
describe('swapCellBordersLR', () => {
507+
it('swaps left and right borders', () => {
508+
const borders: CellBorders = {
509+
top: { style: 'single', width: 1, color: '#000000' },
510+
bottom: { style: 'single', width: 1, color: '#000000' },
511+
left: { style: 'thick', width: 3, color: '#0000FF' },
512+
right: { style: 'single', width: 0.5, color: '#FF0000' },
513+
};
514+
const swapped = swapCellBordersLR(borders)!;
515+
expect(swapped.left).toEqual(borders.right);
516+
expect(swapped.right).toEqual(borders.left);
517+
expect(swapped.top).toEqual(borders.top);
518+
expect(swapped.bottom).toEqual(borders.bottom);
519+
});
520+
521+
it('returns undefined for undefined input', () => {
522+
expect(swapCellBordersLR(undefined)).toBeUndefined();
523+
});
524+
});

packages/layout-engine/painters/dom/src/table/border-utils.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,3 +309,28 @@ export const resolveTableCellBorders = (
309309
right: borderValueToSpec(cellBounds.touchesRightEdge ? tableBorders?.right : null),
310310
};
311311
};
312+
313+
/**
314+
* Swap left↔right on table borders for RTL tables (ECMA-376 Part 4 §14.3.2, §14.3.6).
315+
* insideH/insideV and top/bottom are not affected by direction.
316+
*/
317+
export const swapTableBordersLR = (borders: TableBorders | undefined): TableBorders | undefined => {
318+
if (!borders) return undefined;
319+
return {
320+
...borders,
321+
left: borders.right,
322+
right: borders.left,
323+
};
324+
};
325+
326+
/**
327+
* Swap left↔right on cell borders for RTL tables (ECMA-376 Part 4 §14.3.1, §14.3.5).
328+
*/
329+
export const swapCellBordersLR = (borders: CellBorders | undefined): CellBorders | undefined => {
330+
if (!borders) return undefined;
331+
return {
332+
...borders,
333+
left: borders.right,
334+
right: borders.left,
335+
};
336+
};

packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3936,3 +3936,75 @@ describe('segment count sync: renderer vs layout engine', () => {
39363936
expect(getCellSegmentCount(cell)).toBe(2);
39373937
});
39383938
});
3939+
3940+
describe('RTL cell padding swap', () => {
3941+
let doc: Document;
3942+
3943+
beforeEach(() => {
3944+
doc = document.implementation.createHTMLDocument('table-cell-rtl');
3945+
});
3946+
3947+
const createBaseDeps = () => ({
3948+
doc,
3949+
x: 0,
3950+
y: 0,
3951+
rowHeight: 40,
3952+
borders: undefined,
3953+
useDefaultBorder: false,
3954+
context: { sectionIndex: 0, pageIndex: 0, columnIndex: 0 },
3955+
renderLine: () => doc.createElement('div'),
3956+
applySdtDataset: () => {},
3957+
});
3958+
3959+
const cellMeasure: TableCellMeasure = {
3960+
blocks: [
3961+
{
3962+
kind: 'paragraph' as const,
3963+
lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 1, width: 10, ascent: 12, descent: 4, lineHeight: 20 }],
3964+
totalHeight: 20,
3965+
},
3966+
],
3967+
width: 100,
3968+
height: 20,
3969+
gridColumnStart: 0,
3970+
colSpan: 1,
3971+
rowSpan: 1,
3972+
};
3973+
3974+
it('swaps asymmetric padding left↔right when isRtl is true', () => {
3975+
const cell: TableCell = {
3976+
id: 'rtl-cell' as unknown as import('@superdoc/contracts').BlockId,
3977+
blocks: [{ kind: 'paragraph', id: 'p1', runs: [{ text: 'x', fontFamily: 'Arial', fontSize: 12 }] }],
3978+
attrs: { padding: { top: 2, left: 3, right: 8, bottom: 2 } },
3979+
};
3980+
3981+
const { cellElement } = renderTableCell({
3982+
...createBaseDeps(),
3983+
cellMeasure,
3984+
cell,
3985+
isRtl: true,
3986+
});
3987+
3988+
expect(cellElement.style.paddingLeft).toBe('8px');
3989+
expect(cellElement.style.paddingRight).toBe('3px');
3990+
expect(cellElement.style.paddingTop).toBe('2px');
3991+
expect(cellElement.style.paddingBottom).toBe('2px');
3992+
});
3993+
3994+
it('does not swap padding when isRtl is false', () => {
3995+
const cell: TableCell = {
3996+
id: 'ltr-cell' as unknown as import('@superdoc/contracts').BlockId,
3997+
blocks: [{ kind: 'paragraph', id: 'p1', runs: [{ text: 'x', fontFamily: 'Arial', fontSize: 12 }] }],
3998+
attrs: { padding: { top: 2, left: 3, right: 8, bottom: 2 } },
3999+
};
4000+
4001+
const { cellElement } = renderTableCell({
4002+
...createBaseDeps(),
4003+
cellMeasure,
4004+
cell,
4005+
});
4006+
4007+
expect(cellElement.style.paddingLeft).toBe('3px');
4008+
expect(cellElement.style.paddingRight).toBe('8px');
4009+
});
4010+
});

packages/layout-engine/painters/dom/src/table/renderTableCell.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -904,6 +904,8 @@ type TableCellRenderDependencies = {
904904
tableSdt?: SdtMetadata | null;
905905
/** Table indent in pixels (applied to table fragment positioning) */
906906
tableIndent?: number;
907+
/** Whether the table is visually right-to-left (w:bidiVisual, ECMA-376 §17.4.1) */
908+
isRtl?: boolean;
907909
/** Computed cell width from rescaled columnWidths (overrides cellMeasure.width when present) */
908910
cellWidth?: number;
909911
/** Starting line index for partial row rendering (inclusive) */
@@ -992,16 +994,18 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen
992994
applySdtDataset,
993995
tableSdt,
994996
tableIndent,
997+
isRtl,
995998
cellWidth,
996999
fromLine,
9971000
toLine,
9981001
} = deps;
9991002

10001003
const attrs = cell?.attrs;
10011004
const padding = attrs?.padding || { top: 0, left: 4, right: 4, bottom: 0 };
1002-
const paddingLeft = padding.left ?? 4;
1005+
// RTL: swap left↔right cell margins (ECMA-376 Part 4 §14.3.3–14.3.4, §14.3.7–14.3.8)
1006+
const paddingLeft = isRtl ? (padding.right ?? 4) : (padding.left ?? 4);
10031007
const paddingTop = padding.top ?? 0;
1004-
const paddingRight = padding.right ?? 4;
1008+
const paddingRight = isRtl ? (padding.left ?? 4) : (padding.right ?? 4);
10051009
const paddingBottom = padding.bottom ?? 0;
10061010

10071011
const cellEl = doc.createElement('div');

packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1695,4 +1695,162 @@ describe('renderTableFragment', () => {
16951695
expect(parsed2.segments[1][0].y).toBe(0);
16961696
});
16971697
});
1698+
1699+
describe('RTL table (bidiVisual)', () => {
1700+
it('mirrors ghost cell x positions for RTL tables with rowspan continuations', () => {
1701+
// 2-column RTL table where col 0 has rowSpan=2.
1702+
// Fragment 2 continues from fragment 1, so col 0 becomes a ghost cell.
1703+
const block: TableBlock = {
1704+
kind: 'table',
1705+
id: 'rtl-ghost-table' as BlockId,
1706+
attrs: {
1707+
tableProperties: { rightToLeft: true },
1708+
borders: {
1709+
top: { style: 'single', width: 1, color: '#000' },
1710+
bottom: { style: 'single', width: 1, color: '#000' },
1711+
insideH: { style: 'single', width: 1, color: '#000' },
1712+
insideV: { style: 'single', width: 1, color: '#000' },
1713+
},
1714+
},
1715+
rows: [
1716+
{
1717+
id: 'row-1' as BlockId,
1718+
cells: [
1719+
{ id: 'c-1-1' as BlockId, paragraph: { kind: 'paragraph', id: 'p-1-1' as BlockId, runs: [] } },
1720+
{ id: 'c-1-2' as BlockId, paragraph: { kind: 'paragraph', id: 'p-1-2' as BlockId, runs: [] } },
1721+
],
1722+
},
1723+
{
1724+
id: 'row-2' as BlockId,
1725+
cells: [
1726+
{ id: 'c-2-1' as BlockId, paragraph: { kind: 'paragraph', id: 'p-2-1' as BlockId, runs: [] } },
1727+
{ id: 'c-2-2' as BlockId, paragraph: { kind: 'paragraph', id: 'p-2-2' as BlockId, runs: [] } },
1728+
],
1729+
},
1730+
],
1731+
};
1732+
1733+
const measure: TableMeasure = {
1734+
kind: 'table',
1735+
rows: [
1736+
{
1737+
cells: [
1738+
{ width: 100, height: 20, gridColumnStart: 0, colSpan: 1, rowSpan: 2 },
1739+
{ width: 200, height: 20, gridColumnStart: 1, colSpan: 1, rowSpan: 1 },
1740+
],
1741+
height: 20,
1742+
},
1743+
{
1744+
cells: [{ width: 200, height: 20, gridColumnStart: 1, colSpan: 1, rowSpan: 1 }],
1745+
height: 20,
1746+
},
1747+
],
1748+
columnWidths: [100, 200],
1749+
totalWidth: 300,
1750+
totalHeight: 40,
1751+
};
1752+
1753+
// Fragment 2: continuation from row 1 (only row 1 body, ghost for col 0)
1754+
const fragment: TableFragment = {
1755+
kind: 'table',
1756+
blockId: 'rtl-ghost-table' as BlockId,
1757+
fromRow: 1,
1758+
toRow: 2,
1759+
continuesFromPrev: true,
1760+
x: 0,
1761+
y: 0,
1762+
width: 300,
1763+
height: 20,
1764+
};
1765+
1766+
blockLookup.set('rtl-ghost-table', { block, measure });
1767+
1768+
const el = renderTableFragment({
1769+
doc,
1770+
fragment,
1771+
context,
1772+
blockLookup,
1773+
renderLine: () => doc.createElement('div'),
1774+
applyStyles: (e, s) => Object.assign(e.style, s),
1775+
applyFragmentFrame: () => {},
1776+
applySdtDataset: () => {},
1777+
});
1778+
1779+
// Ghost cell for col 0 (rowSpan=2, width=100) should be mirrored.
1780+
// In LTR: ghostX=0. In RTL: ghostX = 300 - 0 - 100 = 200.
1781+
const ghostCells = Array.from(el.querySelectorAll('div')).filter(
1782+
(d) => d.style.position === 'absolute' && d.style.overflow === 'hidden' && d.childElementCount === 0,
1783+
);
1784+
1785+
expect(ghostCells.length).toBeGreaterThanOrEqual(1);
1786+
const ghostLeft = parseFloat(ghostCells[0].style.left);
1787+
// Ghost should be on the right side (x=200), not left (x=0)
1788+
expect(ghostLeft).toBe(200);
1789+
});
1790+
1791+
it('passes isRtl to renderTableRow for body rows', () => {
1792+
const block: TableBlock = {
1793+
kind: 'table',
1794+
id: 'rtl-pass-table' as BlockId,
1795+
attrs: { tableProperties: { rightToLeft: true } },
1796+
rows: [
1797+
{
1798+
id: 'row-1' as BlockId,
1799+
cells: [
1800+
{ id: 'c-1' as BlockId, paragraph: { kind: 'paragraph', id: 'p-1' as BlockId, runs: [] } },
1801+
{ id: 'c-2' as BlockId, paragraph: { kind: 'paragraph', id: 'p-2' as BlockId, runs: [] } },
1802+
],
1803+
},
1804+
],
1805+
};
1806+
1807+
const measure: TableMeasure = {
1808+
kind: 'table',
1809+
rows: [
1810+
{
1811+
cells: [
1812+
{ width: 100, height: 20, gridColumnStart: 0, colSpan: 1, rowSpan: 1 },
1813+
{ width: 100, height: 20, gridColumnStart: 1, colSpan: 1, rowSpan: 1 },
1814+
],
1815+
height: 20,
1816+
},
1817+
],
1818+
columnWidths: [100, 100],
1819+
totalWidth: 200,
1820+
totalHeight: 20,
1821+
};
1822+
1823+
const fragment: TableFragment = {
1824+
kind: 'table',
1825+
blockId: 'rtl-pass-table' as BlockId,
1826+
fromRow: 0,
1827+
toRow: 1,
1828+
x: 0,
1829+
y: 0,
1830+
width: 200,
1831+
height: 20,
1832+
};
1833+
1834+
blockLookup.set('rtl-pass-table', { block, measure });
1835+
1836+
const el = renderTableFragment({
1837+
doc,
1838+
fragment,
1839+
context,
1840+
blockLookup,
1841+
renderLine: () => doc.createElement('div'),
1842+
applyStyles: (e, s) => Object.assign(e.style, s),
1843+
applyFragmentFrame: () => {},
1844+
applySdtDataset: () => {},
1845+
});
1846+
1847+
// Cells should be mirrored: col 0 at x=100, col 1 at x=0
1848+
const cells = Array.from(el.querySelectorAll('div')).filter(
1849+
(d) => d.style.position === 'absolute' && d.style.width === '100px',
1850+
);
1851+
1852+
const positions = cells.map((c) => parseFloat(c.style.left)).sort((a, b) => a - b);
1853+
expect(positions).toEqual([0, 100]);
1854+
});
1855+
});
16981856
});

0 commit comments

Comments
 (0)