Skip to content
Open
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
24 changes: 23 additions & 1 deletion packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,7 @@ export type TableCellAttrs = {
export type TableAttrs = {
borders?: TableBorders;
borderCollapse?: 'collapse' | 'separate';
cellSpacing?: number;
cellSpacing?: CellSpacing;
sdt?: SdtMetadata;
containerSdt?: SdtMetadata;
[key: string]: unknown;
Expand Down Expand Up @@ -1405,12 +1405,34 @@ export type TableRowMeasure = {
height: number;
};

/** Outer table border widths in pixels (top, right, bottom, left). Used for total dimensions and content offset. */
export type TableBorderWidths = {
top: number;
right: number;
bottom: number;
left: number;
};

export type TableMeasure = {
kind: 'table';
rows: TableRowMeasure[];
columnWidths: number[];
totalWidth: number;
totalHeight: number;
/**
* Cell spacing in pixels (border-spacing between cells).
* Used for total table dimensions and cell x/y positioning when border-collapse is 'separate'.
*/
cellSpacingPx?: number;
/**
* Outer table border widths in pixels. Included in totalWidth/totalHeight; content is offset by (left, top).
*/
tableBorderWidths?: TableBorderWidths;
};

export type CellSpacing = {
type: 'dxa' | 'px';
value: number;
};

export type SectionBreakMeasure = {
Expand Down
19 changes: 14 additions & 5 deletions packages/layout-engine/layout-bridge/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1539,11 +1539,16 @@ export function selectionToRects(
return rowMeasure?.height ?? 0;
});

const cellSpacingPx = tableMeasure.cellSpacingPx ?? 0;
const tableBorderWidths = tableMeasure.tableBorderWidths;
const contentOffsetX = tableBlock.attrs?.borderCollapse === 'separate' ? (tableBorderWidths?.left ?? 0) : 0;
const contentOffsetY = tableBlock.attrs?.borderCollapse === 'separate' ? (tableBorderWidths?.top ?? 0) : 0;

const calculateCellX = (cellIdx: number, cellMeasure: TableCellMeasure) => {
const gridStart = cellMeasure.gridColumnStart ?? cellIdx;
let x = 0;
let x = cellSpacingPx; // space before first column
for (let i = 0; i < gridStart && i < tableMeasure.columnWidths.length; i += 1) {
x += tableMeasure.columnWidths[i];
x += tableMeasure.columnWidths[i] + cellSpacingPx;
}
return x;
};
Expand Down Expand Up @@ -1674,14 +1679,15 @@ export function selectionToRects(
wordLayout: cellWordLayout,
});

const rectX = fragment.x + cellX + padding.left + textIndentAdjust + Math.min(startX, endX);
const rectX =
fragment.x + contentOffsetX + cellX + padding.left + textIndentAdjust + Math.min(startX, endX);
const rectWidth = Math.max(
1,
Math.min(Math.abs(endX - startX), line.width), // clamp to line width to prevent runaway widths
);
const lineOffset =
lineHeightBeforeIndex(info.measure, index) - lineHeightBeforeIndex(info.measure, info.startLine);
const rectY = fragment.y + rowOffset + blockTopCursor + lineOffset;
const rectY = fragment.y + contentOffsetY + rowOffset + blockTopCursor + lineOffset;

rects.push({
x: rectX,
Expand All @@ -1699,15 +1705,18 @@ export function selectionToRects(
return rowOffset + rowHeight;
};

let rowCursor = 0;
// First row starts after space before table content (space between table border and first row)
let rowCursor = cellSpacingPx;

const repeatHeaderCount = tableFragment.repeatHeaderCount ?? 0;
for (let r = 0; r < repeatHeaderCount && r < tableMeasure.rows.length; r += 1) {
rowCursor = processRow(r, rowCursor);
rowCursor += cellSpacingPx; // spacing after every row (including last) for outer spacing
}

for (let r = tableFragment.fromRow; r < tableFragment.toRow && r < tableMeasure.rows.length; r += 1) {
rowCursor = processRow(r, rowCursor);
rowCursor += cellSpacingPx; // spacing after every row (including last) for outer spacing
}

return;
Expand Down
186 changes: 185 additions & 1 deletion packages/layout-engine/layout-engine/src/layout-table.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,17 @@ function createMockTableBlock(
* Format: lineHeightsPerRow[rowIndex] = [lineHeight1, lineHeight2, ...]
* If omitted, cells will have no lines. This parameter enables testing of mid-row
* splitting behavior where rows are split at line boundaries.
* @param cellSpacingPx - Optional cell spacing in pixels (border-spacing). When set,
* column boundary x positions and fragment height include spacing.
* @returns A TableMeasure object with mocked cell, row, and line data
*/
function createMockTableMeasure(
columnWidths: number[],
rowHeights: number[],
lineHeightsPerRow?: number[][],
cellSpacingPx?: number,
): TableMeasure {
return {
const base = {
kind: 'table',
rows: rowHeights.map((height, rowIdx) => ({
cells: columnWidths.map((width) => ({
Expand All @@ -116,6 +119,10 @@ function createMockTableMeasure(
totalWidth: columnWidths.reduce((sum, w) => sum + w, 0),
totalHeight: rowHeights.reduce((sum, h) => sum + h, 0),
};
if (cellSpacingPx !== undefined) {
return { ...base, cellSpacingPx };
}
return base;
}

describe('layoutTableBlock', () => {
Expand Down Expand Up @@ -335,6 +342,183 @@ describe('layoutTableBlock', () => {
});
});

describe('cellSpacing', () => {
it('should position column boundaries with cellSpacingPx (space before first column and between columns)', () => {
const block = createMockTableBlock(1);
const measure = createMockTableMeasure([100, 150, 200], [20], undefined, 4);

const fragments: TableFragment[] = [];
const mockPage = { fragments };

layoutTableBlock({
block,
measure,
columnWidth: 458, // 4 + 100 + 4 + 150 + 4 + 200 + 4
ensurePage: () => ({
page: mockPage,
columnIndex: 0,
cursorY: 0,
contentBottom: 1000,
}),
advanceColumn: (state) => state,
columnX: () => 0,
});

const boundaries = fragments[0].metadata?.columnBoundaries;
expect(boundaries).toBeDefined();
expect(boundaries!.length).toBe(3);
// First column: x = cellSpacingPx
expect(boundaries![0].x).toBe(4);
expect(boundaries![0].width).toBe(100);
// Second column: x = cellSpacingPx + col0 + cellSpacingPx
expect(boundaries![1].x).toBe(108); // 4 + 100 + 4
expect(boundaries![1].width).toBe(150);
// Third column: x = prev + col1 + cellSpacingPx
expect(boundaries![2].x).toBe(262); // 108 + 150 + 4
expect(boundaries![2].width).toBe(200);
});

it('should use zero column boundary offset when cellSpacingPx is 0', () => {
const block = createMockTableBlock(1);
const measure = createMockTableMeasure([100, 150], [20], undefined, 0);

const fragments: TableFragment[] = [];
const mockPage = { fragments };

layoutTableBlock({
block,
measure,
columnWidth: 250,
ensurePage: () => ({
page: mockPage,
columnIndex: 0,
cursorY: 0,
contentBottom: 1000,
}),
advanceColumn: (state) => state,
columnX: () => 0,
});

const boundaries = fragments[0].metadata?.columnBoundaries;
expect(boundaries).toBeDefined();
expect(boundaries![0].x).toBe(0);
expect(boundaries![1].x).toBe(100);
});

it('should include vertical cell spacing in fragment height', () => {
const block = createMockTableBlock(2);
const measure = createMockTableMeasure([100, 150], [20, 25], undefined, 4);

const fragments: TableFragment[] = [];
const mockPage = { fragments };

layoutTableBlock({
block,
measure,
columnWidth: 250,
ensurePage: () => ({
page: mockPage,
columnIndex: 0,
cursorY: 50,
contentBottom: 1000,
}),
advanceColumn: (state) => state,
columnX: () => 10,
});

expect(fragments).toHaveLength(1);
// Row heights 20 + 25 = 45; vertical gaps (rowCount+1)*cellSpacingPx = 3*4 = 12
expect(fragments[0].height).toBe(57); // 45 + 12
});

it('should not add vertical spacing when cellSpacingPx is 0', () => {
const block = createMockTableBlock(2);
const measure = createMockTableMeasure([100, 150], [20, 25], undefined, 0);

const fragments: TableFragment[] = [];
const mockPage = { fragments };

layoutTableBlock({
block,
measure,
columnWidth: 250,
ensurePage: () => ({
page: mockPage,
columnIndex: 0,
cursorY: 50,
contentBottom: 1000,
}),
advanceColumn: (state) => state,
columnX: () => 10,
});

expect(fragments).toHaveLength(1);
expect(fragments[0].height).toBe(45); // 20 + 25 only
});

it('should not add vertical spacing when measure.cellSpacingPx is undefined', () => {
const block = createMockTableBlock(2);
const measure = createMockTableMeasure([100, 150], [20, 25]);

const fragments: TableFragment[] = [];
const mockPage = { fragments };

layoutTableBlock({
block,
measure,
columnWidth: 250,
ensurePage: () => ({
page: mockPage,
columnIndex: 0,
cursorY: 50,
contentBottom: 1000,
}),
advanceColumn: (state) => state,
columnX: () => 10,
});

expect(fragments).toHaveLength(1);
expect(fragments[0].height).toBe(45);
});

it('should include cell spacing in fragment height when table splits across pages', () => {
const block = createMockTableBlock(4);
const measure = createMockTableMeasure([100], [20, 20, 20, 20], undefined, 2);

const fragments: TableFragment[] = [];
let cursorY = 0;
const mockPage = { fragments };

layoutTableBlock({
block,
measure,
columnWidth: 100,
ensurePage: () => ({
page: mockPage,
columnIndex: 0,
cursorY,
contentBottom: 50, // Fits 2 rows + spacing (2+20+2+20+2 = 46), not 3 rows (68)
}),
advanceColumn: (state) => {
cursorY = 0;
return {
page: mockPage,
columnIndex: 0,
cursorY: 0,
contentBottom: 50,
};
},
columnX: () => 0,
});

expect(fragments.length).toBeGreaterThan(1);
// First fragment: 2 rows => height = 20+20 + (2+1)*2 = 46
expect(fragments[0].height).toBe(46);
// Second fragment: 2 rows => height = 46
expect(fragments[1].height).toBe(46);
});
});

describe('justification alignment', () => {
it('positions the table based on justification', () => {
const measure = createMockTableMeasure([100, 100], [20]);
Expand Down
Loading