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
2 changes: 2 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1618,6 +1618,8 @@ export type TableRowBoundary = {
index: number;
y: number;
height: number;
minHeight: number;
resizable: boolean;
};

export type TableFragmentMetadata = {
Expand Down
181 changes: 179 additions & 2 deletions packages/layout-engine/layout-engine/src/layout-table.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ describe('layoutTableBlock', () => {
});
});

it('should not include rowBoundaries metadata (Phase 1 scope)', () => {
it('should include rowBoundaries metadata', () => {
const block = createMockTableBlock(3);
const measure = createMockTableMeasure([100, 150], [20, 25, 30]);

Expand All @@ -336,7 +336,184 @@ describe('layoutTableBlock', () => {
});

const fragment = fragments[0];
expect(fragment.metadata?.rowBoundaries).toBeUndefined();
const rowBoundaries = fragment.metadata?.rowBoundaries;
expect(rowBoundaries).toBeDefined();
expect(rowBoundaries).toHaveLength(3);

// Each boundary should have required fields
expect(rowBoundaries![0]).toMatchObject({
index: 0,
y: 0,
height: 20,
resizable: true,
});
expect(rowBoundaries![1]).toMatchObject({
index: 1,
y: 20,
height: 25,
resizable: true,
});
expect(rowBoundaries![2]).toMatchObject({
index: 2,
y: 45,
height: 30,
resizable: true,
});

// minHeight should be at least ROW_MIN_HEIGHT_PX (10)
rowBoundaries!.forEach((rb) => {
expect(rb.minHeight).toBeGreaterThanOrEqual(10);
});
});

it('uses partial row height in rowBoundaries and marks it non-resizable', () => {
const block = createMockTableBlock(1, [{ cantSplit: false }]);
const measure = createMockTableMeasure([100], [200], [[10, 10, 10, 10, 10, 10]]);

const fragments: TableFragment[] = [];
let cursorY = 0;
let contentBottom = 40; // Force a partial-row first fragment

layoutTableBlock({
block,
measure,
columnWidth: 100,
ensurePage: () => ({
page: { fragments },
columnIndex: 0,
cursorY,
contentBottom,
}),
advanceColumn: () => {
cursorY = 0;
contentBottom = 300;
return {
page: { fragments },
columnIndex: 0,
cursorY,
contentBottom,
};
},
columnX: () => 0,
});

const partialFragment = fragments.find((fragment) => fragment.partialRow != null);
expect(partialFragment).toBeDefined();
expect(partialFragment!.partialRow).toBeTruthy();

const rowBoundaries = partialFragment!.metadata?.rowBoundaries;
expect(rowBoundaries).toHaveLength(1);
expect(rowBoundaries![0].height).toBe(partialFragment!.partialRow!.partialHeight);
expect(rowBoundaries![0].resizable).toBe(false);
expect(rowBoundaries![0].minHeight).toBe(partialFragment!.partialRow!.partialHeight);
});

it('marks repeated header row boundaries as non-resizable on continuation fragments', () => {
const block = createMockTableBlock(4, [
{ repeatHeader: true },
{ repeatHeader: false },
{ repeatHeader: false },
{ repeatHeader: false },
]);
const measure = createMockTableMeasure([100], [20, 20, 20, 20]);

const fragments: TableFragment[] = [];
let cursorY = 0;
let contentBottom = 60; // First page fits 3 rows; continuation should repeat header

layoutTableBlock({
block,
measure,
columnWidth: 100,
ensurePage: () => ({
page: { fragments },
columnIndex: 0,
cursorY,
contentBottom,
}),
advanceColumn: () => {
cursorY = 0;
contentBottom = 60;
return {
page: { fragments },
columnIndex: 0,
cursorY,
contentBottom,
};
},
columnX: () => 0,
});

const continuation = fragments.find((fragment) => (fragment.repeatHeaderCount ?? 0) > 0);
expect(continuation).toBeDefined();

const rowBoundaries = continuation!.metadata?.rowBoundaries;
expect(rowBoundaries).toBeDefined();
expect(rowBoundaries!.length).toBeGreaterThanOrEqual(2);
expect(rowBoundaries![0].index).toBe(0);
expect(rowBoundaries![0].resizable).toBe(false);
expect(rowBoundaries![1].resizable).toBe(true);
});

it('marks row boundaries as non-resizable when a rowspan from a prior fragment crosses them', () => {
// 5 rows, 2 columns. First cell in row 0 has rowSpan=4, covering rows 0-3.
// When the table splits so a continuation fragment renders rows 2-4,
// the boundary after row 2 must be blocked because the span from row 0
// still extends through it (the span covers rows 0,1,2,3).
// The boundary after row 3 (end of span) and row 4 should be resizable.
const block = createMockTableBlock(5);
const measure = createMockTableMeasure([100, 100], [30, 30, 30, 30, 30]);

// Inject rowSpan=4 on the first cell of row 0
(measure.rows[0].cells[0] as any).rowSpan = 4;

const fragments: TableFragment[] = [];
let cursorY = 0;
let contentBottom = 65; // Fits rows 0-1 (30+30=60 < 65), forces split before row 2

layoutTableBlock({
block,
measure,
columnWidth: 200,
ensurePage: () => ({
page: { fragments },
columnIndex: 0,
cursorY,
contentBottom,
}),
advanceColumn: () => {
cursorY = 0;
contentBottom = 200; // continuation page has room for remaining rows
return {
page: { fragments },
columnIndex: 0,
cursorY,
contentBottom,
};
},
columnX: () => 0,
});

// Collect all row boundaries across continuation fragments (fromRow >= 2)
const continuationFragments = fragments.filter((f) => f.fromRow >= 2);
expect(continuationFragments.length).toBeGreaterThan(0);

const allRowBoundaries = continuationFragments.flatMap((f) => f.metadata?.rowBoundaries ?? []);

// Row 2 boundary should be blocked (rowSpan from row 0 extends through row 3)
const row2 = allRowBoundaries.find((rb) => rb.index === 2);
expect(row2).toBeDefined();
expect(row2!.resizable).toBe(false);

// Row 3 is the last row of the span — its bottom boundary is NOT blocked
const row3 = allRowBoundaries.find((rb) => rb.index === 3);
expect(row3).toBeDefined();
expect(row3!.resizable).toBe(true);

// Row 4 is entirely outside the span (may be in a later fragment)
const row4 = allRowBoundaries.find((rb) => rb.index === 4);
expect(row4).toBeDefined();
expect(row4!.resizable).toBe(true);
});
});

Expand Down
Loading
Loading