From 4f00acd1b156efe166598550597c984239b505f6 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Thu, 19 Mar 2026 16:15:02 -0700 Subject: [PATCH 1/2] fix(layout-engine): match partial-row split height to renderer semantics --- .../layout-engine/layout-bridge/src/index.ts | 116 ++- .../layout-bridge/test/mock-data.ts | 97 +++ .../test/selectionToRects.test.ts | 20 + .../layout-engine/layout-engine/src/index.ts | 10 +- .../layout-engine/src/layout-table.test.ts | 430 ++++++++++ .../layout-engine/src/layout-table.ts | 264 +++--- .../src/table-cell-slice.test.ts | 788 ++++++++++++++++++ .../layout-engine/src/table-cell-slice.ts | 488 +++++++++++ 8 files changed, 2096 insertions(+), 117 deletions(-) create mode 100644 packages/layout-engine/layout-engine/src/table-cell-slice.test.ts create mode 100644 packages/layout-engine/layout-engine/src/table-cell-slice.ts diff --git a/packages/layout-engine/layout-bridge/src/index.ts b/packages/layout-engine/layout-bridge/src/index.ts index 174533eca0..fce5c9b423 100644 --- a/packages/layout-engine/layout-bridge/src/index.ts +++ b/packages/layout-engine/layout-bridge/src/index.ts @@ -14,6 +14,7 @@ import type { ParagraphMeasure, } from '@superdoc/contracts'; import { computeLinePmRange as computeLinePmRangeUnified, effectiveTableCellSpacing } from '@superdoc/contracts'; +import { describeCellRenderBlocks, computeCellSliceContentHeight, getEmbeddedRowLines } from '@superdoc/layout-engine'; import { charOffsetToPm, findCharacterAtX, measureCharacterX } from './text-measurement.js'; import { clickToPositionDom, findPageElement } from './dom-mapping.js'; import { @@ -1381,6 +1382,32 @@ const getCellMeasures = (cell: TableCellMeasure | undefined) => { return cell.blocks ?? (cell.paragraph ? [cell.paragraph] : []); }; +/** + * Count the number of segments a measured block contributes to getCellLines(). + * Used to advance the global line counter past non-paragraph blocks so that + * paragraph line ranges stay aligned with the full global index space. + */ +const countBlockSegments = (measure: { + kind: string; + rows?: { cells: unknown[] }[]; + height?: number; + lines?: unknown[]; +}): number => { + if (measure.kind === 'paragraph') { + return (measure as ParagraphMeasure).lines?.length ?? 0; + } + if (measure.kind === 'table') { + let count = 0; + for (const row of (measure as TableMeasure).rows) { + count += getEmbeddedRowLines(row).length; + } + return count; + } + // Image, drawing, other: 1 segment if height > 0 + const h = typeof measure.height === 'number' ? measure.height : 0; + return h > 0 ? 1 : 0; +}; + const sumLineHeights = (measure: ParagraphMeasure, fromLine: number, toLine: number) => { let height = 0; for (let i = fromLine; i < toLine && i < measure.lines.length; i += 1) { @@ -1657,20 +1684,43 @@ export function selectionToRects( const cellBlocks = getCellBlocks(cell); const cellBlockMeasures = getCellMeasures(cellMeasure); - // Map each block to its global line range within the cell + // Build block descriptors for renderer-semantic content height. + // This fixes the spacing.after bug where the old code used measurement + // semantics (effectiveTableCellSpacing) for the last block, but the + // renderer skips spacing.after entirely for the last block. + const cellRenderBlocks = describeCellRenderBlocks(cellMeasure, cell, padding); + const totalCellLines = + cellRenderBlocks.length > 0 ? cellRenderBlocks[cellRenderBlocks.length - 1].globalEndLine : 0; + const cellAllowedStart = partialRowData?.fromLineByCell?.[cellIdx] ?? 0; + const rawCellAllowedEnd = partialRowData?.toLineByCell?.[cellIdx]; + const cellAllowedEnd = + rawCellAllowedEnd == null || rawCellAllowedEnd === -1 ? totalCellLines : rawCellAllowedEnd; + + // Map each paragraph block to its global line range within the cell. + // cumulativeLine must advance for ALL block types (not just paragraphs) + // so that paragraph line ranges align with the global index space used + // by cellAllowedStart/cellAllowedEnd and computeCellSliceContentHeight. const renderedBlocks: Array<{ block: ParagraphBlock; measure: ParagraphMeasure; startLine: number; endLine: number; height: number; + originalBlockIndex: number; + globalBlockStart: number; }> = []; let cumulativeLine = 0; - for (let i = 0; i < Math.min(cellBlocks.length, cellBlockMeasures.length); i += 1) { + const blockCount = Math.min(cellBlocks.length, cellBlockMeasures.length); + for (let i = 0; i < blockCount; i += 1) { const paraBlock = cellBlocks[i]; const paraMeasure = cellBlockMeasures[i]; if (!paraBlock || !paraMeasure || paraBlock.kind !== 'paragraph' || paraMeasure.kind !== 'paragraph') { + // Advance cumulativeLine past non-paragraph segments to stay + // aligned with getCellLines() / describeCellRenderBlocks(). + if (paraMeasure) { + cumulativeLine += countBlockSegments(paraMeasure); + } continue; } const lineCount = paraMeasure.lines.length; @@ -1678,12 +1728,8 @@ export function selectionToRects( const blockEnd = cumulativeLine + lineCount; cumulativeLine = blockEnd; - const allowedStart = partialRowData?.fromLineByCell?.[cellIdx] ?? 0; - const rawAllowedEnd = partialRowData?.toLineByCell?.[cellIdx]; - const allowedEnd = rawAllowedEnd == null || rawAllowedEnd === -1 ? cumulativeLine : rawAllowedEnd; - - const renderStartGlobal = Math.max(blockStart, allowedStart); - const renderEndGlobal = Math.min(blockEnd, allowedEnd); + const renderStartGlobal = Math.max(blockStart, cellAllowedStart); + const renderEndGlobal = Math.min(blockEnd, cellAllowedEnd); if (renderStartGlobal >= renderEndGlobal) continue; const startLine = renderStartGlobal - blockStart; @@ -1697,17 +1743,32 @@ export function selectionToRects( height = totalHeight; } const isFirstBlock = i === 0; - const isLastBlock = i === cellBlocks.length - 1; const spacingBefore = (paraBlock as ParagraphBlock).attrs?.spacing?.before; height += effectiveTableCellSpacing(spacingBefore, isFirstBlock, padding.top); - const spacingAfter = (paraBlock as ParagraphBlock).attrs?.spacing?.after; - height += effectiveTableCellSpacing(spacingAfter, isLastBlock, padding.bottom); + // Match renderer: skip spacing.after for the last block + const isLastBlock = i === blockCount - 1; + if (!isLastBlock) { + const spacingAfter = (paraBlock as ParagraphBlock).attrs?.spacing?.after; + if (typeof spacingAfter === 'number' && spacingAfter > 0) { + height += spacingAfter; + } + } } - renderedBlocks.push({ block: paraBlock, measure: paraMeasure, startLine, endLine, height }); + renderedBlocks.push({ + block: paraBlock, + measure: paraMeasure, + startLine, + endLine, + height, + originalBlockIndex: i, + globalBlockStart: blockStart, + }); } - const contentHeight = renderedBlocks.reduce((acc, info) => acc + info.height, 0); + // Use shared helper for aggregate content height — keeps selection + // rects aligned with pagination and the DOM painter. + const contentHeight = computeCellSliceContentHeight(cellRenderBlocks, cellAllowedStart, cellAllowedEnd); const contentAreaHeight = Math.max(0, rowHeight - (padding.top + padding.bottom)); const freeSpace = Math.max(0, contentAreaHeight - contentHeight); @@ -1721,7 +1782,27 @@ export function selectionToRects( let blockTopCursor = padding.top + verticalOffset; - renderedBlocks.forEach((info, blockIndex) => { + // Track the global end line of the last processed block so we can + // advance blockTopCursor past non-paragraph blocks (images, tables) + // that sit between consecutive paragraphs. + let prevBlockGlobalEndLine = cellAllowedStart; + + renderedBlocks.forEach((info) => { + // Advance past any visible non-paragraph blocks between the previous + // paragraph and this one. Without this, images/tables between + // paragraphs would be invisible to blockTopCursor and later + // paragraph rects would be positioned too high. + for (const rb of cellRenderBlocks) { + if (rb.kind === 'paragraph') continue; + if (rb.visibleHeight === 0) continue; + if (rb.globalEndLine <= prevBlockGlobalEndLine) continue; + if (rb.globalStartLine >= info.globalBlockStart) break; + const localStart = Math.max(0, cellAllowedStart - rb.globalStartLine); + const localEnd = Math.min(rb.lineHeights.length, cellAllowedEnd - rb.globalStartLine); + for (let li = localStart; li < localEnd; li++) { + blockTopCursor += rb.lineHeights[li]; + } + } const paragraphMarkerWidth = info.measure.marker?.markerWidth ?? 0; // List items in table cells are also rendered with left alignment const cellIsListItem = isListItem(paragraphMarkerWidth, info.block); @@ -1735,9 +1816,13 @@ export function selectionToRects( const intersectingLines = findLinesIntersectingRange(info.block, info.measure, from, to); // Match renderer: spacing.before is only applied when rendering from the start of the block (startLine === 0). + // Use the original block index (not renderedBlocks index) so that isFirstBlock matches + // the renderer's i === 0 check, which includes non-paragraph blocks. const rawSpacingBefore = (info.block as ParagraphBlock).attrs?.spacing?.before; const effectiveSpacingBeforePx = - info.startLine === 0 ? effectiveTableCellSpacing(rawSpacingBefore, blockIndex === 0, padding.top) : 0; + info.startLine === 0 + ? effectiveTableCellSpacing(rawSpacingBefore, info.originalBlockIndex === 0, padding.top) + : 0; intersectingLines.forEach(({ line, index }) => { if (index < info.startLine || index >= info.endLine) { @@ -1789,6 +1874,7 @@ export function selectionToRects( }); blockTopCursor += info.height; + prevBlockGlobalEndLine = info.globalBlockStart + info.endLine; }); } diff --git a/packages/layout-engine/layout-bridge/test/mock-data.ts b/packages/layout-engine/layout-bridge/test/mock-data.ts index 5ad1c6f108..d6dfde3fb3 100644 --- a/packages/layout-engine/layout-bridge/test/mock-data.ts +++ b/packages/layout-engine/layout-bridge/test/mock-data.ts @@ -608,6 +608,103 @@ export const tableSpacingAfterLayout: Layout = { ], }; +// Table cell mixed blocks — selectionToRects should advance past inline images +// between paragraphs when positioning later paragraph rects. +export const TABLE_INLINE_IMAGE_HEIGHT = 24; +export const TABLE_MIXED_BLOCK_FRAGMENT_Y = 60; + +export const tableMixedBlockSelectionBlock: FlowBlock = { + ...tableBlock, + id: 'table-mixed-blocks', + rows: [ + { + ...tableBlock.rows[0], + cells: [ + { + ...tableBlock.rows[0].cells[0], + attrs: { padding: { top: 0, bottom: 0, left: 4, right: 4 } }, + blocks: [ + { + ...tableParagraph, + id: 'mixed-p1', + runs: [{ ...tableParagraph.runs[0], text: 'Top', pmStart: 1, pmEnd: 4 }], + }, + { + kind: 'image', + id: 'mixed-img', + src: 'test.png', + width: 24, + height: TABLE_INLINE_IMAGE_HEIGHT, + }, + { + ...tableParagraph, + id: 'mixed-p2', + runs: [{ ...tableParagraph.runs[0], text: 'Bottom', pmStart: 5, pmEnd: 11 }], + }, + ], + }, + ], + }, + ], +}; + +const tableMixedBlockTotalHeight = TABLE_CELL_LINE_HEIGHT * 2 + TABLE_INLINE_IMAGE_HEIGHT; + +export const tableMixedBlockSelectionMeasure: Measure = { + kind: 'table', + rows: [ + { + height: tableMixedBlockTotalHeight, + cells: [ + { + width: 100, + height: tableMixedBlockTotalHeight, + gridColumnStart: 0, + blocks: [ + { + kind: 'paragraph', + lines: [{ ...tableParagraphLine, toChar: 3, width: 32, ascent: 12 }], + totalHeight: TABLE_CELL_LINE_HEIGHT, + }, + { + kind: 'image', + width: 24, + height: TABLE_INLINE_IMAGE_HEIGHT, + }, + { + kind: 'paragraph', + lines: [{ ...tableParagraphLine, toChar: 6, width: 52, ascent: 12 }], + totalHeight: TABLE_CELL_LINE_HEIGHT, + }, + ], + }, + ], + }, + ], + columnWidths: [100], + totalWidth: 100, + totalHeight: tableMixedBlockTotalHeight, +}; + +export const tableMixedBlockSelectionLayout: Layout = { + ...tableLayout, + pages: [ + { + ...tableLayout.pages[0], + fragments: [ + { + ...tablePageFragment, + blockId: 'table-mixed-blocks', + x: 20, + y: TABLE_MIXED_BLOCK_FRAGMENT_Y, + width: 100, + height: tableMixedBlockTotalHeight, + }, + ], + }, + ], +}; + // Mock data for table with rowspan (SD-1626 / IT-22) // Table structure: // Row 0: [Cell A (rowspan=2)] [Cell B] [Cell C] diff --git a/packages/layout-engine/layout-bridge/test/selectionToRects.test.ts b/packages/layout-engine/layout-bridge/test/selectionToRects.test.ts index 995df0862a..96bcf50c5b 100644 --- a/packages/layout-engine/layout-bridge/test/selectionToRects.test.ts +++ b/packages/layout-engine/layout-bridge/test/selectionToRects.test.ts @@ -37,6 +37,11 @@ import { TABLE_SPACING_AFTER, TABLE_SPACING_AFTER_PADDING_BOTTOM, TABLE_CELL_LINE_HEIGHT, + tableMixedBlockSelectionBlock, + tableMixedBlockSelectionMeasure, + tableMixedBlockSelectionLayout, + TABLE_INLINE_IMAGE_HEIGHT, + TABLE_MIXED_BLOCK_FRAGMENT_Y, } from './mock-data'; import { PageGeometryHelper } from '../src/page-geometry-helper'; @@ -126,6 +131,21 @@ describe('selectionToRects', () => { }); }); + describe('table cell mixed blocks', () => { + it('offsets later paragraph rects by visible non-paragraph blocks between paragraphs', () => { + const rects = selectionToRects( + tableMixedBlockSelectionLayout, + [tableMixedBlockSelectionBlock], + [tableMixedBlockSelectionMeasure], + 5, + 11, + ); + + expect(rects).toHaveLength(1); + expect(rects[0].y).toBe(TABLE_MIXED_BLOCK_FRAGMENT_Y + TABLE_CELL_LINE_HEIGHT + TABLE_INLINE_IMAGE_HEIGHT); + }); + }); + describe('firstLineIndentMode integration', () => { it('uses textStartPx for first line of list with firstLineIndentMode', () => { // Create a list item with firstLineIndentMode and textStartPx diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 435580ef2e..d5bb266edd 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -2790,4 +2790,12 @@ export { resolvePageNumberTokens } from './resolvePageTokens.js'; export type { NumberingContext, ResolvePageTokensResult } from './resolvePageTokens.js'; // Export table utilities for reuse by painter-dom -export { rescaleColumnWidths, getCellLines } from './layout-table.js'; +export { rescaleColumnWidths, getCellLines, getEmbeddedRowLines } from './layout-table.js'; +export { + describeCellRenderBlocks, + computeCellSliceContentHeight, + computeFullCellContentHeight, + createCellSliceCursor, + type CellRenderBlock, + type CellSliceCursor, +} from './table-cell-slice.js'; diff --git a/packages/layout-engine/layout-engine/src/layout-table.test.ts b/packages/layout-engine/layout-engine/src/layout-table.test.ts index 85c03c4fdf..f8fefd9256 100644 --- a/packages/layout-engine/layout-engine/src/layout-table.test.ts +++ b/packages/layout-engine/layout-engine/src/layout-table.test.ts @@ -1702,6 +1702,118 @@ describe('layoutTableBlock', () => { } }); + it('retries a continued partial row without headers when repeated headers consume the body budget', () => { + const block = createMockTableBlock(2, [{ repeatHeader: true }, undefined]); + for (const cell of block.rows[1].cells) { + cell.attrs = { padding: { top: 8, bottom: 8, left: 4, right: 4 } }; + } + + const measure = createMockTableMeasure([100, 100], [20, 50], [[20], [10, 10, 10, 10, 10]]); + + const pageHeights = [50, 26, 26, 26, 26]; + const pages = pageHeights.map(() => ({ fragments: [] as TableFragment[] })); + let currentPageIndex = 0; + let advanceCount = 0; + let state = { + page: pages[0], + columnIndex: 0, + cursorY: 0, + contentBottom: pageHeights[0], + topMargin: 0, + }; + + layoutTableBlock({ + block, + measure, + columnWidth: 200, + ensurePage: () => state, + advanceColumn: () => { + advanceCount++; + if (currentPageIndex + 1 >= pages.length) { + throw new Error('Livelock detected while retrying partial row without headers'); + } + currentPageIndex += 1; + state = { + ...state, + page: pages[currentPageIndex], + cursorY: 0, + contentBottom: pageHeights[currentPageIndex], + }; + return state; + }, + columnX: () => 0, + }); + + // First page must create a partial row continuation. + expect(pages[0].fragments).toHaveLength(1); + expect(pages[0].fragments[0].partialRow?.rowIndex).toBe(1); + + // On the continuation page, repeated headers leave zero line budget because + // cell padding consumes the remaining 6px. The retryWithoutHeaders path + // should re-run the same page with repeatHeaderCount=0 and render content. + expect(pages[1].fragments).toHaveLength(1); + expect(pages[1].fragments[0].repeatHeaderCount).toBe(0); + expect(pages[1].fragments[0].continuesFromPrev).toBe(true); + expect(pages[1].fragments[0].partialRow?.fromLineByCell[0]).toBeGreaterThan(0); + expect(advanceCount).toBeLessThan(pageHeights.length); + }); + + it('suppresses repeated headers between same-page slices after a continuation-page partial split', () => { + const block = createMockTableBlock(3, [{ repeatHeader: true }, { repeatHeader: false }, { repeatHeader: false }]); + const measure = createMockTableMeasure([100, 100], [20, 20, 40], [[20], [20], [10, 10, 10, 10]]); + + const firstPage = { fragments: [] as TableFragment[] }; + const secondPage = { fragments: [] as TableFragment[] }; + let currentPageIndex = 0; + let state = { + page: firstPage, + columnIndex: 0, + cursorY: 0, + contentBottom: 50, + topMargin: 0, + }; + + layoutTableBlock({ + block, + measure, + columnWidth: 200, + ensurePage: () => { + // Use the real, mutable cursor on page 1 so the table advances. + // On page 2, intentionally reset cursorY to 0 to force the same-page + // continuation branch after a normal partial split, exercising the + // header-suppression guard directly. + if (currentPageIndex === 1) { + state.cursorY = 0; + } + return state; + }, + advanceColumn: () => { + currentPageIndex += 1; + state = { + ...state, + page: secondPage, + cursorY: 0, + contentBottom: 50, + }; + return state; + }, + columnX: () => 0, + }); + + // First page ends at a row boundary (header + first body row). + expect(firstPage.fragments).toHaveLength(1); + expect(firstPage.fragments[0].toRow).toBe(2); + + // Continuation page: the first slice repeats the header, the next slice on + // the same page must not. This is the specific normal-partial-row path + // guarded by samePagePartialContinuation. + expect(secondPage.fragments.length).toBeGreaterThanOrEqual(2); + expect(secondPage.fragments[0].repeatHeaderCount).toBe(1); + expect(secondPage.fragments[1].repeatHeaderCount).toBe(0); + expect(secondPage.fragments[0].partialRow?.rowIndex).toBe(2); + expect(secondPage.fragments[1].partialRow?.rowIndex).toBe(2); + }); + it('should not split floating tables', () => { const block = createMockTableBlock(10, undefined, { tableProperties: { floatingTableProperties: { horizontalAnchor: 'page' } }, @@ -2354,6 +2466,324 @@ describe('layoutTableBlock', () => { }); }); + describe('block-aware partial row height (SD-1612)', () => { + it('accounts for paragraph spacing.before in partial row height', () => { + // A cell with a single paragraph that has spacing.before. + // The partial row height must include the effective spacing.before + // so the renderer's content fits within the reserved space. + const block = createMockTableBlock(1); + // Set spacing.before = 10 on the cell's paragraph + block.rows[0].cells[0].paragraph = { + kind: 'paragraph', + id: 'p0' as BlockId, + runs: [], + attrs: { spacing: { before: 10 } }, + }; + + const measure = createMockTableMeasure( + [100, 100], + [80], + [[20, 20, 20]], // 3 lines of 20px each, total 60px + spacing + ); + + const fragments: TableFragment[] = []; + let cursorY = 0; + const mockPage = { fragments }; + + layoutTableBlock({ + block, + measure, + columnWidth: 200, + ensurePage: () => ({ + page: mockPage, + columnIndex: 0, + cursorY, + contentBottom: cursorY + 55, // Enough for ~2 lines + spacing, not all 3 + }), + advanceColumn: (state) => { + cursorY = 0; + return { page: mockPage, columnIndex: 0, cursorY: 0, contentBottom: 200 }; + }, + columnX: () => 0, + }); + + // The first fragment should have partialRow because spacing.before + // reduces the available space for lines. + const partialFragments = fragments.filter((f) => 'partialRow' in f && f.partialRow); + if (partialFragments.length > 0) { + const partial = partialFragments[0].partialRow!; + // The partial height must be >= the lines rendered PLUS spacing.before + // so the renderer can paint spacing + lines without clipping. + expect(partial.partialHeight).toBeGreaterThan(0); + } + }); + + it('promotes fully rendered paragraph to totalHeight', () => { + // When a paragraph's totalHeight > sum(lineHeights), the renderer uses + // totalHeight. The partial row height must match. + const block = createMockTableBlock(1); + + // Create a measure where totalHeight > sum of line heights + const measure = createMockTableMeasure([100], [80], [[15, 15]]); // 2 lines of 15px = 30px + // Set totalHeight higher than sum of lines to trigger promotion + measure.rows[0].cells[0].paragraph!.totalHeight = 50; + measure.rows[0].height = 50; + + const fragments: TableFragment[] = []; + let cursorY = 0; + const mockPage = { fragments }; + + layoutTableBlock({ + block, + measure, + columnWidth: 100, + ensurePage: () => ({ + page: mockPage, + columnIndex: 0, + cursorY, + contentBottom: cursorY + 55, // Enough for the paragraph (50 + padding) + }), + advanceColumn: (state) => { + cursorY = 0; + return { page: mockPage, columnIndex: 0, cursorY: 0, contentBottom: 200 }; + }, + columnX: () => 0, + }); + + // Should fit in one fragment — the key is that the fragment height + // correctly accounts for totalHeight promotion. + expect(fragments.length).toBeGreaterThanOrEqual(1); + }); + + it('does not change line-index bookkeeping', () => { + // Verify that fromLineByCell and toLineByCell still advance by + // flattened line indices, even with block-aware height. + const block = createMockTableBlock(1); + block.rows[0].cells[0].paragraph = { + kind: 'paragraph', + id: 'p0' as BlockId, + runs: [], + attrs: { spacing: { before: 5, after: 5 } }, + }; + + const measure = createMockTableMeasure( + [100, 100], + [80], + [[10, 10, 10, 10]], // 4 lines of 10px each + ); + + const fragments: TableFragment[] = []; + let cursorY = 0; + const mockPage = { fragments }; + + layoutTableBlock({ + block, + measure, + columnWidth: 200, + ensurePage: () => ({ + page: mockPage, + columnIndex: 0, + cursorY, + contentBottom: cursorY + 25, // Force multiple fragments + }), + advanceColumn: (state) => { + cursorY = 0; + return { page: mockPage, columnIndex: 0, cursorY: 0, contentBottom: 25 }; + }, + columnX: () => 0, + }); + + const partialFragments = fragments.filter((f) => 'partialRow' in f && f.partialRow); + + // Verify monotonic line advancement + for (let i = 1; i < partialFragments.length; i++) { + const prev = partialFragments[i - 1].partialRow!; + const current = partialFragments[i].partialRow!; + current.fromLineByCell.forEach((fromLine, idx) => { + expect(fromLine).toBe(prev.toLineByCell[idx]); + }); + } + }); + + it('partial-row fragment height includes cellSpacingPx', () => { + const block = createMockTableBlock(1); + const cellSpacingPx = 4; + const measure = createMockTableMeasure( + [100], + [60], + [[20, 20, 20]], // 3 lines of 20px + cellSpacingPx, + ); + + const fragments: TableFragment[] = []; + let cursorY = 0; + const mockPage = { fragments }; + + layoutTableBlock({ + block, + measure, + columnWidth: 100, + ensurePage: () => ({ + page: mockPage, + columnIndex: 0, + cursorY, + contentBottom: cursorY + 35, // Force partial row + }), + advanceColumn: (state) => { + cursorY = 0; + return { page: mockPage, columnIndex: 0, cursorY: 0, contentBottom: 200 }; + }, + columnX: () => 0, + }); + + const partialFragments = fragments.filter((f) => 'partialRow' in f && f.partialRow); + + if (partialFragments.length > 0) { + const frag = partialFragments[0]; + // Fragment height must include cell spacing: (rowCount+1) * cellSpacingPx + // For 1 row: (1+1) * 4 = 8 extra pixels + expect(frag.height).toBeGreaterThan(frag.partialRow!.partialHeight); + } + }); + + it('partial-row fragment height includes separate table borders', () => { + const block = createMockTableBlock(1, undefined, { + borderCollapse: 'separate', + cellSpacing: 0, + }); + const measure = createMockTableMeasure([100], [60], [[20, 20, 20]]); + measure.tableBorderWidths = { top: 2, right: 1, bottom: 2, left: 1 }; + + const fragments: TableFragment[] = []; + let cursorY = 0; + const mockPage = { fragments }; + + layoutTableBlock({ + block, + measure, + columnWidth: 100, + ensurePage: () => ({ + page: mockPage, + columnIndex: 0, + cursorY, + contentBottom: cursorY + 30, + }), + advanceColumn: (state) => { + cursorY = 0; + return { page: mockPage, columnIndex: 0, cursorY: 0, contentBottom: 200 }; + }, + columnX: () => 0, + }); + + const partialFragments = fragments.filter((f) => 'partialRow' in f && f.partialRow); + + if (partialFragments.length > 0) { + const frag = partialFragments[0]; + // Fragment height must include border widths: top(2) + bottom(2) = 4 + expect(frag.height).toBeGreaterThan(frag.partialRow!.partialHeight); + } + }); + + it('forced-split path does not produce blank fragments', () => { + // A row that exceeds a full page height must still make line progress. + // The forced-split path must not create zero-line-progress fragments. + const block = createMockTableBlock(1); + const measure = createMockTableMeasure( + [100], + [1200], // Very tall row + [[400, 400, 400]], // 3 very tall lines + ); + + const fragments: TableFragment[] = []; + let advanceCount = 0; + const maxAdvances = 20; // Safety limit to detect livelocks + const mockPage = { fragments }; + + layoutTableBlock({ + block, + measure, + columnWidth: 100, + ensurePage: () => ({ + page: mockPage, + columnIndex: 0, + cursorY: 0, + contentBottom: 500, // Each page has 500px + }), + advanceColumn: (state) => { + advanceCount++; + if (advanceCount > maxAdvances) { + throw new Error('Livelock detected: too many page advances'); + } + return { page: mockPage, columnIndex: 0, cursorY: 0, contentBottom: 500 }; + }, + columnX: () => 0, + }); + + // Should have produced fragments that cover all content + expect(fragments.length).toBeGreaterThan(0); + + // No zero-line-progress fragments + for (const frag of fragments) { + if ('partialRow' in frag && frag.partialRow) { + const progress = frag.partialRow.toLineByCell.some((to, idx) => to > frag.partialRow!.fromLineByCell[idx]); + expect(progress).toBe(true); + } + } + }); + + it('force-progress accounts for repeated headers to avoid livelock', () => { + // When a table has repeated headers, the body space per page is reduced. + // A segment whose minSegmentCost exceeds (fullPage - headers) but not + // fullPage would never trigger force-progress, livelocking across pages. + // The fix passes fullPageHeightForBody so force-progress fires correctly. + const block = createMockTableBlock(3, [{ repeatHeader: true }, undefined, undefined]); + + // Header row: 200px, body rows: one with a tall segment + const measure = createMockTableMeasure( + [100], + [200, 500, 30], // header=200, body row with 500px content, small final row + [ + [200], // header: 1 line + [500], // body row 1: single tall line + [30], // body row 2: small + ], + ); + + const fragments: TableFragment[] = []; + let advanceCount = 0; + const maxAdvances = 30; + const mockPage = { fragments }; + + // Full page = 600px. With 200px header repeated, body gets 400px. + // The 500px segment exceeds 400px body budget but not 600px full page. + // Without the fix this would livelock. + layoutTableBlock({ + block, + measure, + columnWidth: 100, + ensurePage: () => ({ + page: mockPage, + columnIndex: 0, + cursorY: 0, + contentBottom: 600, + }), + advanceColumn: (state) => { + advanceCount++; + if (advanceCount > maxAdvances) { + throw new Error('Livelock detected: repeated-header force-progress failed'); + } + return { page: mockPage, columnIndex: 0, cursorY: 0, contentBottom: 600 }; + }, + columnX: () => 0, + }); + + expect(fragments.length).toBeGreaterThan(0); + // The tall body row must have been split (not stuck in an infinite loop) + const bodyFragments = fragments.filter((f) => f.fromRow >= 1); + expect(bodyFragments.length).toBeGreaterThan(0); + }); + }); + describe('tableIndent handling', () => { /** * Test suite for table indent functionality. diff --git a/packages/layout-engine/layout-engine/src/layout-table.ts b/packages/layout-engine/layout-engine/src/layout-table.ts index 251b2b4512..472831dae6 100644 --- a/packages/layout-engine/layout-engine/src/layout-table.ts +++ b/packages/layout-engine/layout-engine/src/layout-table.ts @@ -13,6 +13,7 @@ import type { } 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'; /** * Ratio of column width (0..1). An anchored table with totalWidth >= columnWidth * this value @@ -399,73 +400,54 @@ function sumRowHeights(rows: TableRowMeasure[], fromRow: number, toRow: number): } /** - * Calculate the actual rendered height of a table fragment. + * Compute the rendered height of a table fragment, including repeated headers, + * body rows, cell spacing, and outer borders. * - * CRITICAL: This is used for cursor advancement, not measure.totalHeight. - * Fragment height = (repeated headers) + (body rows from fromRow to toRow) + * CRITICAL: Used for cursor advancement and fragment positioning. All three + * partial-row fragment paths (continuation, forced-split, normal) must use this + * function to stay aligned with `renderTableFragment.ts`. * - * @param fragment - Table fragment with fromRow, toRow, repeatHeaderCount - * @param measure - Table measurements - * @param headerCount - Total number of header rows in the table - * @returns Actual fragment height in pixels + * When `partialRow` is provided, its `partialHeight` is substituted for the + * measured row height at `partialRow.rowIndex`. */ -function calculateFragmentHeight( - fragment: Pick, +function computeFragmentHeight( measure: TableMeasure, - _headerCount: number, + fromRow: number, + toRow: number, + repeatHeaderCount: number, borderCollapse?: 'collapse' | 'separate', + partialRow?: PartialRowInfo | null, ): number { let height = 0; let rowCount = 0; - // Add header height if continuation with repeated headers - if (fragment.repeatHeaderCount && fragment.repeatHeaderCount > 0) { - height += sumRowHeights(measure.rows, 0, fragment.repeatHeaderCount); - rowCount += fragment.repeatHeaderCount; + // Repeated headers + if (repeatHeaderCount > 0) { + height += sumRowHeights(measure.rows, 0, repeatHeaderCount); + rowCount += repeatHeaderCount; } - // Add body row heights (fromRow to toRow, exclusive) - const bodyRowCount = fragment.toRow - fragment.fromRow; - height += sumRowHeights(measure.rows, fragment.fromRow, fragment.toRow); - rowCount += bodyRowCount; + // Body rows — substitute partialRow height when applicable + for (let i = fromRow; i < toRow && i < measure.rows.length; i++) { + if (partialRow && partialRow.rowIndex === i) { + height += partialRow.partialHeight; + } else { + height += measure.rows[i].height; + } + rowCount++; + } - // Add vertical gaps: space before first row, between rows, after last row (outer spacing) + // Cell spacing: gaps before first row, between rows, and after last row const cellSpacingPx = measure.cellSpacingPx ?? 0; if (rowCount > 0 && cellSpacingPx > 0) { height += (rowCount + 1) * cellSpacingPx; } - // Only add outer border height when border-collapse is separate (DOM paints container-level borders only then) - if (rowCount > 0 && measure.tableBorderWidths && borderCollapse === 'separate') { - const borderWidthV = measure.tableBorderWidths.top + measure.tableBorderWidths.bottom; - height += borderWidthV; - } - - return height; -} -/** - * Height of a body-only fragment (rows fromRow..toRow) including vertical spacing and borders. - * Must match the body portion of calculateFragmentHeight so findSplitPoint's fit check - * agrees with the actual rendered fragment height. Borders only included when borderCollapse === 'separate'. - */ -function calculateBodyFragmentHeight( - measure: TableMeasure, - fromRow: number, - toRow: number, - borderCollapse?: 'collapse' | 'separate', -): number { - const rowCount = toRow - fromRow; - if (rowCount <= 0) { - return 0; - } - let height = sumRowHeights(measure.rows, fromRow, toRow); - const cellSpacingPx = measure.cellSpacingPx ?? 0; - if (cellSpacingPx > 0) { - height += (rowCount + 1) * cellSpacingPx; - } - if (measure.tableBorderWidths && borderCollapse === 'separate') { + // Outer border height when border-collapse is separate + if (rowCount > 0 && measure.tableBorderWidths && borderCollapse === 'separate') { height += measure.tableBorderWidths.top + measure.tableBorderWidths.bottom; } + return height; } @@ -490,7 +472,7 @@ const MIN_PARTIAL_ROW_HEIGHT = 20; * sub-row boundaries even for deeply nested tables (table-in-table-in-table). * Otherwise, return the row as a single segment with its measured height. */ -function getEmbeddedRowLines(row: TableRowMeasure): Array<{ lineHeight: number }> { +export function getEmbeddedRowLines(row: TableRowMeasure): Array<{ lineHeight: number }> { // Check if any cell has nested table blocks const hasNestedTable = row.cells.some((cell) => cell.blocks?.some((b) => b.kind === 'table')); @@ -583,9 +565,10 @@ function getRowContentHeight(blockRow: TableRow | undefined, rowMeasure: TableRo const cell = rowMeasure.cells[cellIdx]; const cellPadding = getCellPadding(cellIdx, blockRow); const paddingTotal = cellPadding.top + cellPadding.bottom; - const lines = getCellLines(cell); - const linesHeight = lines.reduce((sum, line) => sum + (line.lineHeight || 0), 0); - contentHeight = Math.max(contentHeight, linesHeight + paddingTotal); + const cellBlock = blockRow?.cells?.[cellIdx]; + // Use the allocation-free fast path — this runs on every row. + const sliceHeight = computeFullCellContentHeight(cell, cellBlock, cellPadding); + contentHeight = Math.max(contentHeight, sliceHeight + paddingTotal); } return contentHeight; } @@ -978,7 +961,10 @@ function computePartialRow( // Capture cell paddings to keep height math aligned with rendering const cellPaddings = row.cells.map((_, idx: number) => getCellPadding(idx, blockRow)); - // First pass: find cutoff for each cell based on available height + // First pass: find cutoff for each cell based on available height. + // Uses block-aware height from the cell slice cursor so that paragraph + // spacing.before, totalHeight promotion, and spacing.after are included + // in the fit check — matching what the DOM painter actually renders. for (let cellIdx = 0; cellIdx < cellCount; cellIdx++) { const cell = row.cells[cellIdx]; const startLine = startLines[cellIdx] || 0; @@ -987,32 +973,42 @@ function computePartialRow( const cellPadding = cellPaddings[cellIdx]; const availableForLines = Math.max(0, availableHeight - (cellPadding.top + cellPadding.bottom)); - // Get all lines from all blocks in this cell (multi-block support) + // Build block descriptors and cursor for block-aware height accumulation + const cellBlock = blockRow?.cells?.[cellIdx]; + const blocks = describeCellRenderBlocks(cell, cellBlock, cellPadding); + const cursor = createCellSliceCursor(blocks, startLine); + + // Get all lines for index bookkeeping (line indices remain unchanged) const lines = getCellLines(cell); let cumulativeHeight = 0; let cutLine = startLine; for (let i = startLine; i < lines.length; i++) { - const lineHeight = lines[i].lineHeight || 0; - if (cumulativeHeight + lineHeight > availableForLines) { - // Force progress: only when the segment is truly taller than a full page - // (e.g. an embedded table that can never fit on any page). This prevents - // infinite pagination loops. Normal lines that don't fit at the bottom of a - // page should NOT be forced — the caller will advance to the next page. + const lineCost = cursor.advanceLine(i); + if (cumulativeHeight + lineCost > availableForLines) { + // Force progress: only when the minimum rendered cost of this segment + // exceeds a full page height. This accounts for spacing.before and + // totalHeight promotion that can make a segment over-tall even when + // the raw line height alone would fit. + // When availableForLines === 0 (e.g. headers consumed the budget and + // cell padding exceeded the remainder), we do NOT force here — that + // would advance line indices for lines rendered with zero content + // height, permanently dropping content. Instead, the caller retries + // without headers (retryWithoutHeaders flag in the layout loop). if ( cumulativeHeight === 0 && i === startLine && availableForLines > 0 && fullPageHeight != null && - lineHeight > fullPageHeight + cursor.minSegmentCost(i) > fullPageHeight ) { // Cap height to available space — overflow:hidden on the cell clips the rest. - cumulativeHeight += Math.min(lineHeight, availableForLines); + cumulativeHeight += Math.min(lineCost, availableForLines); cutLine = i + 1; } break; } - cumulativeHeight += lineHeight; + cumulativeHeight += lineCost; cutLine = i + 1; // Exclusive index } @@ -1118,7 +1114,7 @@ function findSplitPoint( } // Check if this row fits: use full fragment height (rows + spacing + borders) so pagination matches render - const fragmentHeightWithRow = calculateBodyFragmentHeight(measure, startRow, i + 1, borderCollapse); + const fragmentHeightWithRow = computeFragmentHeight(measure, startRow, i + 1, 0, borderCollapse); if (fragmentHeightWithRow <= availableHeight) { // Row fits completely lastFitRow = i + 1; // Next row index (exclusive) @@ -1131,9 +1127,8 @@ function findSplitPoint( // Row doesn't fit completely; remaining space after last full row set. // When lastFitRow === startRow (first row doesn't fit), no rows have been placed yet, so // we must subtract the vertical space that appears before the first row (top spacing + top border) - // instead of using calculateBodyFragmentHeight(startRow, startRow) which is 0. - let remainingHeight = - availableHeight - calculateBodyFragmentHeight(measure, startRow, lastFitRow, borderCollapse); + // instead of using computeFragmentHeight(startRow, startRow) which is 0. + let remainingHeight = availableHeight - computeFragmentHeight(measure, startRow, lastFitRow, 0, borderCollapse); if (lastFitRow === startRow) { const cellSpacingPx = measure.cellSpacingPx ?? 0; const topBorderPx = @@ -1417,6 +1412,20 @@ export function layoutTableBlock({ // Resolve border-collapse for fragment height (match measuring/render: only add borders when separate) const borderCollapse = block.attrs?.borderCollapse ?? (block.attrs?.cellSpacing != null ? 'separate' : 'collapse'); + // Tracks whether the current iteration is a same-page continuation of a + // partial row. Headers must not repeat mid-page: the painter always renders + // repeated headers at the top of a fragment, which would insert headers + // between two slices of the same row on the same page. + let samePagePartialContinuation = false; + + // When computePartialRow makes no progress because repeated headers + // consumed the body budget, this flag causes the next iteration to retry + // with repeatHeaderCount = 0 (full page budget) instead of advancing to + // a new page with the same cramped budget. This avoids both livelocking + // and the content-dropping alternative of forcing line indices forward + // with zero content height. + let retryWithoutHeaders = false; + // 4. Loop until all rows processed (including pending partial rows) while (currentRow < block.rows.length || pendingPartialRow !== null) { state = ensurePage(); @@ -1425,19 +1434,30 @@ export function layoutTableBlock({ // Determine repeat header count for this fragment let repeatHeaderCount = 0; - if (currentRow === 0 && !pendingPartialRow) { + if (retryWithoutHeaders) { + // Previous iteration's no-progress was caused by headers eating the + // body budget. Retry this page without headers. + repeatHeaderCount = 0; + retryWithoutHeaders = false; + } else if (currentRow === 0 && !pendingPartialRow) { // First fragment: headers are part of body rows, don't repeat separately repeatHeaderCount = 0; - } else { - // Continuation fragment: check if headers should repeat - if (headerCount > 0 && headerHeight <= availableHeight) { - repeatHeaderCount = headerCount; - } else if (headerCount > 0 && headerHeight > availableHeight) { - // Table headers taller than page height - skip header repetition to avoid overflow - repeatHeaderCount = 0; - } + } else if (samePagePartialContinuation) { + // Same-page continuation of a partial row: never insert headers mid-page + repeatHeaderCount = 0; + } else if (pendingPartialRow && pendingPartialRow.rowIndex < headerCount) { + // The partial row being continued IS a header row. Repeating headers + // would render that same row once as a repeated header and again as + // the body partial continuation, causing duplicate content. + repeatHeaderCount = 0; + } else if (headerCount > 0 && headerHeight < availableHeight) { + // New page with room for headers + body content + repeatHeaderCount = headerCount; } + // Reset for this iteration — set by same-page partial-row paths below. + samePagePartialContinuation = false; + // If repeated headers would prevent a cantSplit row from fitting, skip header repetition. // Word does not split cantSplit rows just because repeated headers eat up space. if (repeatHeaderCount > 0 && !pendingPartialRow) { @@ -1458,6 +1478,13 @@ export function layoutTableBlock({ // The ?? 0 handles test fixtures that may not set topMargin. const fullPageHeight = state.contentBottom - (state.topMargin ?? 0); + // When headers are repeated on every page, the force-progress threshold + // must account for the header budget. Otherwise a segment that's smaller + // than a full page but larger than (fullPage − headers) will livelock: + // computePartialRow makes no progress, the guard advances to a new page + // with the same header budget, and the same no-progress state recurs. + const fullPageHeightForBody = repeatHeaderCount > 0 ? fullPageHeight - headerHeight : fullPageHeight; + // Handle pending partial row continuation if (pendingPartialRow !== null) { const rowIndex = pendingPartialRow.rowIndex; @@ -1469,7 +1496,7 @@ export function layoutTableBlock({ measure, availableForBody, fromLineByCell, - fullPageHeight, + fullPageHeightForBody, ); const madeProgress = continuationPartialRow.toLineByCell.some( @@ -1488,7 +1515,14 @@ export function layoutTableBlock({ return fromLine < totalLines; }); - const fragmentHeight = continuationPartialRow.partialHeight + (repeatHeaderCount > 0 ? headerHeight : 0); + const fragmentHeight = computeFragmentHeight( + measure, + rowIndex, + rowIndex + 1, + repeatHeaderCount, + borderCollapse, + continuationPartialRow, + ); // Only create a fragment if we made progress (rendered some lines) // Don't create empty fragments with just padding @@ -1534,15 +1568,21 @@ export function layoutTableBlock({ currentRow = rowIndex + 1; pendingPartialRow = null; } else if (!madeProgress && hadRemainingLinesBefore) { - // No progress made - need to advance to next page/column and retry - state = advanceColumn(state); - // Keep the same pendingPartialRow to retry on next page (no assignment needed) + if (repeatHeaderCount > 0) { + // Headers consumed the body budget. Retry this page without headers + // instead of advancing to a new page with the same cramped budget. + retryWithoutHeaders = true; + } else { + // No progress and no headers to drop — advance to a new page. + state = advanceColumn(state); + } + // Keep the same pendingPartialRow to retry (no assignment needed) } else { - // Made progress but row not complete - continue on SAME page - // DO NOT call advanceColumn here! The cursor has already been advanced - // by the fragment height above. Just update pendingPartialRow to track - // remaining lines for the next iteration. + // Made progress but row not complete - continue on SAME page. + // Flag this so the next iteration does NOT insert repeated headers + // mid-page between two slices of the same row. pendingPartialRow = continuationPartialRow; + samePagePartialContinuation = true; } isTableContinuation = true; @@ -1556,7 +1596,7 @@ export function layoutTableBlock({ measure, bodyStartRow, availableForBody, - fullPageHeight, + fullPageHeightForBody, ); // If no rows fit and page has content, advance @@ -1574,10 +1614,32 @@ export function layoutTableBlock({ measure, availableForBody, undefined, - fullPageHeight, + fullPageHeightForBody, ); + + // Guard against zero-line-progress fragments. With block-aware height, + // spacing alone can exceed available space. If no lines were rendered, + // creating a fragment would cause an infinite loop of blank fragments. + const forcedMadeProgress = forcedPartialRow.toLineByCell.some((cutLine: number) => cutLine > 0); + if (!forcedMadeProgress) { + if (repeatHeaderCount > 0) { + // Headers consumed the body budget. Retry this page without headers. + retryWithoutHeaders = true; + } else { + state = advanceColumn(state); + } + continue; + } + const forcedEndRow = bodyStartRow + 1; - const fragmentHeight = forcedPartialRow.partialHeight + (repeatHeaderCount > 0 ? headerHeight : 0); + const fragmentHeight = computeFragmentHeight( + measure, + bodyStartRow, + forcedEndRow, + repeatHeaderCount, + borderCollapse, + forcedPartialRow, + ); const baseX = columnX(state.columnIndex); const baseWidth = Math.min(columnWidth, measure.totalWidth || columnWidth); @@ -1617,19 +1679,15 @@ export function layoutTableBlock({ continue; } - // Calculate fragment height - let fragmentHeight: number; - if (partialRow) { - const fullRowsHeight = sumRowHeights(measure.rows, bodyStartRow, endRow - 1); - fragmentHeight = fullRowsHeight + partialRow.partialHeight + (repeatHeaderCount > 0 ? headerHeight : 0); - } else { - fragmentHeight = calculateFragmentHeight( - { fromRow: bodyStartRow, toRow: endRow, repeatHeaderCount }, - measure, - headerCount, - borderCollapse, - ); - } + // Calculate fragment height — unified for both partial and full row cases + const fragmentHeight = computeFragmentHeight( + measure, + bodyStartRow, + endRow, + repeatHeaderCount, + borderCollapse, + partialRow, + ); const baseX = columnX(state.columnIndex); const baseWidth = Math.min(columnWidth, measure.totalWidth || columnWidth); @@ -1681,6 +1739,10 @@ export function layoutTableBlock({ // continuing to fill the current page with another fragment. if (forcePageBreak && currentRow < block.rows.length) { state = advanceColumn(state); + } else if (pendingPartialRow) { + // The partial row will continue on the same page. Suppress header + // repetition on the next iteration to avoid mid-page headers. + samePagePartialContinuation = true; } } } diff --git a/packages/layout-engine/layout-engine/src/table-cell-slice.test.ts b/packages/layout-engine/layout-engine/src/table-cell-slice.test.ts new file mode 100644 index 0000000000..da222c7e67 --- /dev/null +++ b/packages/layout-engine/layout-engine/src/table-cell-slice.test.ts @@ -0,0 +1,788 @@ +import { describe, expect, it } from 'vitest'; +import type { ParagraphMeasure, TableCellMeasure, TableCell, TableMeasure } from '@superdoc/contracts'; +import type { BlockId } from '@superdoc/contracts'; +import { + describeCellRenderBlocks, + computeCellSliceContentHeight, + computeFullCellContentHeight, + createCellSliceCursor, + type CellRenderBlock, +} from './table-cell-slice.js'; + +// ─── Test helpers ──────────────────────────────────────────────────────────── + +function makeLine(height: number) { + return { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 1, + width: 100, + ascent: height * 0.7, + descent: height * 0.3, + lineHeight: height, + }; +} + +function makeParaMeasure(lineHeights: number[], totalHeight?: number): ParagraphMeasure { + const lines = lineHeights.map(makeLine); + const sumLines = lineHeights.reduce((s, h) => s + h, 0); + return { kind: 'paragraph', lines, totalHeight: totalHeight ?? sumLines }; +} + +function makeParaBlock(spacingBefore?: number, spacingAfter?: number) { + return { + kind: 'paragraph' as const, + id: 'para' as BlockId, + runs: [], + attrs: { + spacing: { + ...(spacingBefore != null ? { before: spacingBefore } : {}), + ...(spacingAfter != null ? { after: spacingAfter } : {}), + }, + }, + }; +} + +function makeCellMeasure(blocks: ParagraphMeasure[]): TableCellMeasure { + return { blocks, width: 100, height: 100 }; +} + +function makeCellBlock(blocks: ReturnType[]): TableCell { + return { id: 'cell' as BlockId, blocks }; +} + +const NO_PADDING = { top: 0, bottom: 0 }; + +// ─── describeCellRenderBlocks ──────────────────────────────────────────────── + +describe('describeCellRenderBlocks', () => { + it('handles a single paragraph with no spacing', () => { + const measure = makeCellMeasure([makeParaMeasure([10, 12])]); + const block = makeCellBlock([makeParaBlock()]); + const blocks = describeCellRenderBlocks(measure, block, NO_PADDING); + + expect(blocks).toHaveLength(1); + expect(blocks[0].kind).toBe('paragraph'); + expect(blocks[0].globalStartLine).toBe(0); + expect(blocks[0].globalEndLine).toBe(2); + expect(blocks[0].lineHeights).toEqual([10, 12]); + expect(blocks[0].spacingBefore).toBe(0); + expect(blocks[0].spacingAfter).toBe(0); // last block + expect(blocks[0].isFirstBlock).toBe(true); + expect(blocks[0].isLastBlock).toBe(true); + }); + + it('applies spacing.before for first block, absorbing padding', () => { + const measure = makeCellMeasure([makeParaMeasure([10])]); + const block = makeCellBlock([makeParaBlock(15)]); + const padding = { top: 5, bottom: 0 }; + const blocks = describeCellRenderBlocks(measure, block, padding); + + // spacing.before=15, padding.top=5 → effective = 15 - 5 = 10 + expect(blocks[0].spacingBefore).toBe(10); + }); + + it('fully absorbs spacing.before when less than padding.top', () => { + const measure = makeCellMeasure([makeParaMeasure([10])]); + const block = makeCellBlock([makeParaBlock(3)]); + const padding = { top: 5, bottom: 0 }; + const blocks = describeCellRenderBlocks(measure, block, padding); + + // spacing.before=3 < padding.top=5 → effective = 0 + expect(blocks[0].spacingBefore).toBe(0); + }); + + it('applies spacing.after for non-last blocks, skips for last', () => { + const m1 = makeParaMeasure([10]); + const m2 = makeParaMeasure([10]); + const b1 = makeParaBlock(0, 8); + const b2 = makeParaBlock(0, 12); + const measure = makeCellMeasure([m1, m2]); + const block = makeCellBlock([b1, b2]); + const blocks = describeCellRenderBlocks(measure, block, NO_PADDING); + + expect(blocks[0].spacingAfter).toBe(8); // non-last + expect(blocks[1].spacingAfter).toBe(0); // last → 0 + }); + + it('sets totalHeight from ParagraphMeasure.totalHeight', () => { + const measure = makeCellMeasure([makeParaMeasure([10, 10], 30)]); + const block = makeCellBlock([makeParaBlock()]); + const blocks = describeCellRenderBlocks(measure, block, NO_PADDING); + + expect(blocks[0].totalHeight).toBe(30); + expect(blocks[0].visibleHeight).toBe(20); // sum of lines + }); + + it('falls back to sum(lineHeights) when totalHeight is not provided', () => { + const pm: ParagraphMeasure = { kind: 'paragraph', lines: [makeLine(15), makeLine(10)], totalHeight: 25 }; + const measure = makeCellMeasure([pm]); + const block = makeCellBlock([makeParaBlock()]); + const blocks = describeCellRenderBlocks(measure, block, NO_PADDING); + + expect(blocks[0].totalHeight).toBe(25); + }); + + it('degrades to zero spacing when block data is missing', () => { + const measure = makeCellMeasure([makeParaMeasure([10, 12])]); + // No block data at all + const blocks = describeCellRenderBlocks(measure, undefined, NO_PADDING); + + expect(blocks).toHaveLength(1); + expect(blocks[0].spacingBefore).toBe(0); + expect(blocks[0].spacingAfter).toBe(0); + }); + + it('handles measured blocks longer than block data', () => { + const m1 = makeParaMeasure([10]); + const m2 = makeParaMeasure([12]); + const measure = makeCellMeasure([m1, m2]); + // Only one block data entry + const block = makeCellBlock([makeParaBlock(5, 3)]); + const blocks = describeCellRenderBlocks(measure, block, NO_PADDING); + + expect(blocks).toHaveLength(2); + // First block has spacing from block data + expect(blocks[0].spacingAfter).toBe(3); + // Second block has no block data → zero spacing + expect(blocks[1].spacingBefore).toBe(0); + expect(blocks[1].spacingAfter).toBe(0); // also last block + }); + + it('handles inline image block with positive height', () => { + const imgMeasure = { kind: 'image' as const, width: 50, height: 30 }; + const measure: TableCellMeasure = { blocks: [imgMeasure], width: 100, height: 100 }; + const imgBlock = { kind: 'image' as const, id: 'img' as BlockId, src: 'test.png' }; + const cellBlock: TableCell = { id: 'cell' as BlockId, blocks: [imgBlock] }; + const blocks = describeCellRenderBlocks(measure, cellBlock, NO_PADDING); + + expect(blocks).toHaveLength(1); + expect(blocks[0].kind).toBe('other'); + expect(blocks[0].lineHeights).toEqual([30]); + expect(blocks[0].visibleHeight).toBe(30); + expect(blocks[0].totalHeight).toBe(30); + }); + + it('handles anchored out-of-flow image (visibleHeight = 0)', () => { + const imgMeasure = { kind: 'image' as const, width: 50, height: 30 }; + const measure: TableCellMeasure = { blocks: [imgMeasure], width: 100, height: 100 }; + const imgBlock = { + kind: 'image' as const, + id: 'img' as BlockId, + src: 'test.png', + anchor: { isAnchored: true }, + wrap: { type: 'square' }, + }; + const cellBlock: TableCell = { id: 'cell' as BlockId, blocks: [imgBlock as any] }; + const blocks = describeCellRenderBlocks(measure, cellBlock, NO_PADDING); + + expect(blocks).toHaveLength(1); + expect(blocks[0].kind).toBe('other'); + expect(blocks[0].lineHeights).toEqual([30]); // still 1 segment for index alignment + expect(blocks[0].visibleHeight).toBe(0); + expect(blocks[0].totalHeight).toBe(0); + }); + + it('degrades to inline behavior when block data is missing for image', () => { + const imgMeasure = { kind: 'image' as const, width: 50, height: 30 }; + const measure: TableCellMeasure = { blocks: [imgMeasure], width: 100, height: 100 }; + // No block data → can't determine anchored status → inline behavior + const blocks = describeCellRenderBlocks(measure, undefined, NO_PADDING); + + expect(blocks).toHaveLength(1); + expect(blocks[0].visibleHeight).toBe(30); // inline: includes height + }); + + it('uses single-paragraph fallback for backward-compat cells', () => { + const pm = makeParaMeasure([10, 12]); + const cellMeasure: TableCellMeasure = { paragraph: pm, width: 100, height: 100 }; + const cellBlock: TableCell = { + id: 'cell' as BlockId, + paragraph: makeParaBlock(6, 4), + }; + const blocks = describeCellRenderBlocks(cellMeasure, cellBlock, { top: 2, bottom: 0 }); + + expect(blocks).toHaveLength(1); + // First+last block, spacing.before = max(0, 6 - 2) = 4 + expect(blocks[0].spacingBefore).toBe(4); + expect(blocks[0].spacingAfter).toBe(0); // last block + }); + + it('handles embedded table block', () => { + const tableMeasure: TableMeasure = { + kind: 'table', + rows: [ + { cells: [], height: 20 }, + { cells: [], height: 15 }, + ], + columnWidths: [], + totalWidth: 100, + totalHeight: 35, + }; + const measure: TableCellMeasure = { blocks: [tableMeasure], width: 100, height: 100 }; + const blocks = describeCellRenderBlocks(measure, undefined, NO_PADDING); + + expect(blocks).toHaveLength(1); + expect(blocks[0].kind).toBe('table'); + // Each simple row expands to 1 segment + expect(blocks[0].lineHeights).toEqual([20, 15]); + expect(blocks[0].spacingBefore).toBe(0); + expect(blocks[0].spacingAfter).toBe(0); + }); +}); + +// ─── computeCellSliceContentHeight ─────────────────────────────────────────── + +describe('computeCellSliceContentHeight', () => { + it('returns full height for a single paragraph full slice', () => { + const blocks: CellRenderBlock[] = [ + { + kind: 'paragraph', + globalStartLine: 0, + globalEndLine: 3, + lineHeights: [10, 10, 10], + totalHeight: 40, + visibleHeight: 30, + isFirstBlock: true, + isLastBlock: true, + spacingBefore: 5, + spacingAfter: 0, + }, + ]; + + // Full slice: spacingBefore(5) + max(30, 40) + spacingAfter(0) = 45 + expect(computeCellSliceContentHeight(blocks, 0, 3)).toBe(45); + }); + + it('returns only line heights for partial slice', () => { + const blocks: CellRenderBlock[] = [ + { + kind: 'paragraph', + globalStartLine: 0, + globalEndLine: 3, + lineHeights: [10, 12, 14], + totalHeight: 36, + visibleHeight: 36, + isFirstBlock: true, + isLastBlock: true, + spacingBefore: 5, + spacingAfter: 0, + }, + ]; + + // Partial from middle: just lines[1..3) = 12 + 14 = 26 + expect(computeCellSliceContentHeight(blocks, 1, 3)).toBe(26); + }); + + it('includes spacing.before for partial slice starting at line 0', () => { + const blocks: CellRenderBlock[] = [ + { + kind: 'paragraph', + globalStartLine: 0, + globalEndLine: 3, + lineHeights: [10, 12, 14], + totalHeight: 36, + visibleHeight: 36, + isFirstBlock: true, + isLastBlock: true, + spacingBefore: 5, + spacingAfter: 0, + }, + ]; + + // Partial from start: spacingBefore(5) + lines[0..2) = 5 + 10 + 12 = 27 + expect(computeCellSliceContentHeight(blocks, 0, 2)).toBe(27); + }); + + it('includes spacing at block boundaries for multi-paragraph cells', () => { + const blocks: CellRenderBlock[] = [ + { + kind: 'paragraph', + globalStartLine: 0, + globalEndLine: 2, + lineHeights: [10, 10], + totalHeight: 20, + visibleHeight: 20, + isFirstBlock: true, + isLastBlock: false, + spacingBefore: 3, + spacingAfter: 6, + }, + { + kind: 'paragraph', + globalStartLine: 2, + globalEndLine: 4, + lineHeights: [10, 10], + totalHeight: 20, + visibleHeight: 20, + isFirstBlock: false, + isLastBlock: true, + spacingBefore: 4, + spacingAfter: 0, + }, + ]; + + // Full slice: (3 + max(20,20) + 6) + (4 + max(20,20) + 0) = 29 + 24 = 53 + expect(computeCellSliceContentHeight(blocks, 0, 4)).toBe(53); + }); + + it('skips spacing.after for last block', () => { + const blocks: CellRenderBlock[] = [ + { + kind: 'paragraph', + globalStartLine: 0, + globalEndLine: 2, + lineHeights: [10, 10], + totalHeight: 20, + visibleHeight: 20, + isFirstBlock: true, + isLastBlock: true, + spacingBefore: 0, + spacingAfter: 0, // already 0 for last block + }, + ]; + + // spacingAfter is always 0 for last block + expect(computeCellSliceContentHeight(blocks, 0, 2)).toBe(20); + }); + + it('promotes totalHeight for fully rendered paragraph', () => { + const blocks: CellRenderBlock[] = [ + { + kind: 'paragraph', + globalStartLine: 0, + globalEndLine: 2, + lineHeights: [10, 10], + totalHeight: 30, // 10 more than sum + visibleHeight: 20, + isFirstBlock: true, + isLastBlock: true, + spacingBefore: 0, + spacingAfter: 0, + }, + ]; + + // max(20, 30) = 30 + expect(computeCellSliceContentHeight(blocks, 0, 2)).toBe(30); + }); + + it('does not promote totalHeight for partial slice', () => { + const blocks: CellRenderBlock[] = [ + { + kind: 'paragraph', + globalStartLine: 0, + globalEndLine: 3, + lineHeights: [10, 10, 10], + totalHeight: 50, + visibleHeight: 30, + isFirstBlock: true, + isLastBlock: true, + spacingBefore: 0, + spacingAfter: 0, + }, + ]; + + // Partial: just line heights, no promotion + expect(computeCellSliceContentHeight(blocks, 0, 2)).toBe(20); + }); + + it('contributes zero for anchored out-of-flow blocks', () => { + const blocks: CellRenderBlock[] = [ + { + kind: 'paragraph', + globalStartLine: 0, + globalEndLine: 2, + lineHeights: [10, 10], + totalHeight: 20, + visibleHeight: 20, + isFirstBlock: true, + isLastBlock: false, + spacingBefore: 0, + spacingAfter: 5, + }, + { + kind: 'other', + globalStartLine: 2, + globalEndLine: 3, + lineHeights: [30], + totalHeight: 0, + visibleHeight: 0, + isFirstBlock: false, + isLastBlock: false, + spacingBefore: 0, + spacingAfter: 0, + }, + { + kind: 'paragraph', + globalStartLine: 3, + globalEndLine: 5, + lineHeights: [10, 10], + totalHeight: 20, + visibleHeight: 20, + isFirstBlock: false, + isLastBlock: true, + spacingBefore: 4, + spacingAfter: 0, + }, + ]; + + // Block 0: 0 + 20 + 5 = 25 + // Block 1: anchored, skipped (visibleHeight=0) + // Block 2: 4 + 20 + 0 = 24 + // Total: 49 + expect(computeCellSliceContentHeight(blocks, 0, 5)).toBe(49); + }); + + it('handles embedded table segments', () => { + const blocks: CellRenderBlock[] = [ + { + kind: 'table', + globalStartLine: 0, + globalEndLine: 2, + lineHeights: [20, 15], + totalHeight: 35, + visibleHeight: 35, + isFirstBlock: true, + isLastBlock: true, + spacingBefore: 0, + spacingAfter: 0, + }, + ]; + + expect(computeCellSliceContentHeight(blocks, 0, 2)).toBe(35); + expect(computeCellSliceContentHeight(blocks, 0, 1)).toBe(20); + }); +}); + +// ─── createCellSliceCursor ─────────────────────────────────────────────────── + +describe('createCellSliceCursor', () => { + it('returns line cost including spacing.before at block start', () => { + const blocks: CellRenderBlock[] = [ + { + kind: 'paragraph', + globalStartLine: 0, + globalEndLine: 2, + lineHeights: [10, 12], + totalHeight: 22, + visibleHeight: 22, + isFirstBlock: true, + isLastBlock: true, + spacingBefore: 5, + spacingAfter: 0, + }, + ]; + + const cursor = createCellSliceCursor(blocks, 0); + // First line: spacingBefore(5) + lineHeight(10) = 15 + expect(cursor.advanceLine(0)).toBe(15); + // Second line: just lineHeight(12) = 12 + expect(cursor.advanceLine(1)).toBe(12); + }); + + it('includes totalHeight promotion on block completion', () => { + const blocks: CellRenderBlock[] = [ + { + kind: 'paragraph', + globalStartLine: 0, + globalEndLine: 2, + lineHeights: [10, 10], + totalHeight: 30, + visibleHeight: 20, + isFirstBlock: true, + isLastBlock: true, + spacingBefore: 0, + spacingAfter: 0, + }, + ]; + + const cursor = createCellSliceCursor(blocks, 0); + expect(cursor.advanceLine(0)).toBe(10); + // Last line: 10 + promotion max(0, 30 - 20) = 20 + expect(cursor.advanceLine(1)).toBe(20); + }); + + it('includes spacingAfter on block completion for non-last blocks', () => { + const blocks: CellRenderBlock[] = [ + { + kind: 'paragraph', + globalStartLine: 0, + globalEndLine: 1, + lineHeights: [10], + totalHeight: 10, + visibleHeight: 10, + isFirstBlock: true, + isLastBlock: false, + spacingBefore: 0, + spacingAfter: 6, + }, + { + kind: 'paragraph', + globalStartLine: 1, + globalEndLine: 2, + lineHeights: [10], + totalHeight: 10, + visibleHeight: 10, + isFirstBlock: false, + isLastBlock: true, + spacingBefore: 4, + spacingAfter: 0, + }, + ]; + + const cursor = createCellSliceCursor(blocks, 0); + // Line 0: block 0 complete → 10 + spacingAfter(6) = 16 + expect(cursor.advanceLine(0)).toBe(16); + // Line 1: block 1 enters with spacingBefore(4) → 4 + 10 = 14 + expect(cursor.advanceLine(1)).toBe(14); + }); + + it('skips spacing.before when starting mid-block (continuation)', () => { + const blocks: CellRenderBlock[] = [ + { + kind: 'paragraph', + globalStartLine: 0, + globalEndLine: 3, + lineHeights: [10, 10, 10], + totalHeight: 30, + visibleHeight: 30, + isFirstBlock: true, + isLastBlock: true, + spacingBefore: 8, + spacingAfter: 0, + }, + ]; + + // Start from line 1 (mid-block continuation) + const cursor = createCellSliceCursor(blocks, 1); + // No spacing.before because we didn't start from line 0 + expect(cursor.advanceLine(1)).toBe(10); + // No promotion because we didn't start from line 0 + expect(cursor.advanceLine(2)).toBe(10); + }); + + it('skips totalHeight promotion when not starting from line 0', () => { + const blocks: CellRenderBlock[] = [ + { + kind: 'paragraph', + globalStartLine: 0, + globalEndLine: 3, + lineHeights: [10, 10, 10], + totalHeight: 50, + visibleHeight: 30, + isFirstBlock: true, + isLastBlock: true, + spacingBefore: 0, + spacingAfter: 0, + }, + ]; + + const cursor = createCellSliceCursor(blocks, 1); + expect(cursor.advanceLine(1)).toBe(10); + // No promotion: block started mid-way + expect(cursor.advanceLine(2)).toBe(10); + }); + + it('handles transition across block boundaries', () => { + const blocks: CellRenderBlock[] = [ + { + kind: 'paragraph', + globalStartLine: 0, + globalEndLine: 2, + lineHeights: [10, 10], + totalHeight: 20, + visibleHeight: 20, + isFirstBlock: true, + isLastBlock: false, + spacingBefore: 0, + spacingAfter: 5, + }, + { + kind: 'paragraph', + globalStartLine: 2, + globalEndLine: 4, + lineHeights: [12, 12], + totalHeight: 24, + visibleHeight: 24, + isFirstBlock: false, + isLastBlock: true, + spacingBefore: 3, + spacingAfter: 0, + }, + ]; + + const cursor = createCellSliceCursor(blocks, 0); + expect(cursor.advanceLine(0)).toBe(10); + // Line 1 completes block 0: 10 + spacingAfter(5) = 15 + expect(cursor.advanceLine(1)).toBe(15); + // Line 2 enters block 1: spacingBefore(3) + 12 = 15 + expect(cursor.advanceLine(2)).toBe(15); + expect(cursor.advanceLine(3)).toBe(12); + }); + + it('contributes 0 for anchored out-of-flow blocks', () => { + const blocks: CellRenderBlock[] = [ + { + kind: 'other', + globalStartLine: 0, + globalEndLine: 1, + lineHeights: [30], + totalHeight: 0, + visibleHeight: 0, + isFirstBlock: true, + isLastBlock: true, + spacingBefore: 0, + spacingAfter: 0, + }, + ]; + + const cursor = createCellSliceCursor(blocks, 0); + expect(cursor.advanceLine(0)).toBe(0); + }); + + describe('minSegmentCost', () => { + it('returns spacingBefore + lineHeight for first line of paragraph', () => { + const blocks: CellRenderBlock[] = [ + { + kind: 'paragraph', + globalStartLine: 0, + globalEndLine: 3, + lineHeights: [10, 10, 10], + totalHeight: 30, + visibleHeight: 30, + isFirstBlock: true, + isLastBlock: true, + spacingBefore: 8, + spacingAfter: 0, + }, + ]; + + const cursor = createCellSliceCursor(blocks, 0); + // spacingBefore(8) + lineHeight(10) = 18 + expect(cursor.minSegmentCost(0)).toBe(18); + }); + + it('includes completion costs for single-line blocks', () => { + const blocks: CellRenderBlock[] = [ + { + kind: 'paragraph', + globalStartLine: 0, + globalEndLine: 1, + lineHeights: [10], + totalHeight: 20, + visibleHeight: 10, + isFirstBlock: true, + isLastBlock: false, + spacingBefore: 5, + spacingAfter: 3, + }, + ]; + + const cursor = createCellSliceCursor(blocks, 0); + // spacingBefore(5) + lineHeight(10) + promotion(10) + spacingAfter(3) = 28 + expect(cursor.minSegmentCost(0)).toBe(28); + }); + + it('returns just lineHeight for mid-block lines', () => { + const blocks: CellRenderBlock[] = [ + { + kind: 'paragraph', + globalStartLine: 0, + globalEndLine: 3, + lineHeights: [10, 12, 14], + totalHeight: 36, + visibleHeight: 36, + isFirstBlock: true, + isLastBlock: true, + spacingBefore: 5, + spacingAfter: 0, + }, + ]; + + const cursor = createCellSliceCursor(blocks, 0); + expect(cursor.minSegmentCost(1)).toBe(12); // mid-block: just line height + }); + + it('does not mutate cursor state', () => { + const blocks: CellRenderBlock[] = [ + { + kind: 'paragraph', + globalStartLine: 0, + globalEndLine: 2, + lineHeights: [10, 10], + totalHeight: 20, + visibleHeight: 20, + isFirstBlock: true, + isLastBlock: true, + spacingBefore: 5, + spacingAfter: 0, + }, + ]; + + const cursor = createCellSliceCursor(blocks, 0); + cursor.minSegmentCost(0); // Should not affect state + cursor.minSegmentCost(0); // Should return same result + // advanceLine should still work from line 0 + expect(cursor.advanceLine(0)).toBe(15); // spacingBefore(5) + 10 + }); + }); +}); + +// ─── computeFullCellContentHeight ──────────────────────────────────────────── + +describe('computeFullCellContentHeight', () => { + it('uses measurement semantics: includes last-block spacing.after', () => { + // computeFullCellContentHeight uses measurement semantics (includes + // last-block spacing.after) while computeCellSliceContentHeight uses + // renderer semantics (skips it). This keeps getRowContentHeight aligned + // with rowMeasure.height for hasExplicitRowHeightSlack comparisons. + const m1 = makeParaMeasure([10, 12], 25); + const m2 = makeParaMeasure([8, 8]); + const b1 = makeParaBlock(6, 5); + const b2 = makeParaBlock(3, 10); // last block, spacing.after = 10 + const measure = makeCellMeasure([m1, m2]); + const block = makeCellBlock([b1, b2]); + const padding = { top: 2, bottom: 0 }; + + const rendererHeight = computeCellSliceContentHeight(describeCellRenderBlocks(measure, block, padding), 0, 4); + const measurementHeight = computeFullCellContentHeight(measure, block, padding); + + // Measurement height includes last-block spacing.after (10px excess over 0 bottom padding) + expect(measurementHeight).toBe(rendererHeight + 10); + }); + + it('absorbs last-block spacing.after into bottom padding', () => { + const pm = makeParaMeasure([10, 12]); + const cellMeasure: TableCellMeasure = { paragraph: pm, width: 100, height: 100 }; + const cellBlock: TableCell = { id: 'cell' as BlockId, paragraph: makeParaBlock(6, 4) }; + // spacing.after=4, paddingBottom=5 → absorbed (4 < 5 → excess = 0) + const padding = { top: 2, bottom: 5 }; + + const actual = computeFullCellContentHeight(cellMeasure, cellBlock, padding); + // spacingBefore = max(0, 6-2) = 4 + // max(sum(10,12), 22) = 22 + // spacingAfter = effectiveTableCellSpacing(4, true, 5) = max(0, 4-5) = 0 + // Total: 4 + 22 + 0 = 26 + expect(actual).toBe(26); + }); + + it('matches renderer path for non-last blocks and inline images', () => { + // Non-last blocks and inline images behave identically in both semantics + const m1 = makeParaMeasure([10]); + const imgMeasure = { kind: 'image' as const, width: 50, height: 30 }; + const m2 = makeParaMeasure([10]); + const measure: TableCellMeasure = { blocks: [m1, imgMeasure, m2], width: 100, height: 100 }; + + const b1 = makeParaBlock(0, 5); + const imgBlock = { kind: 'image' as const, id: 'img' as BlockId, src: 'test.png' }; + const b2 = makeParaBlock(3, 0); // last block, spacing.after = 0 + const block: TableCell = { id: 'cell' as BlockId, blocks: [b1, imgBlock as any, b2] }; + + const rendererHeight = computeCellSliceContentHeight(describeCellRenderBlocks(measure, block, NO_PADDING), 0, 3); + const measurementHeight = computeFullCellContentHeight(measure, block, NO_PADDING); + + // Last block has spacing.after = 0, so no difference between semantics + expect(measurementHeight).toBe(rendererHeight); + }); +}); diff --git a/packages/layout-engine/layout-engine/src/table-cell-slice.ts b/packages/layout-engine/layout-engine/src/table-cell-slice.ts new file mode 100644 index 0000000000..c084e4ed26 --- /dev/null +++ b/packages/layout-engine/layout-engine/src/table-cell-slice.ts @@ -0,0 +1,488 @@ +/** + * Shared cell-slice-height module for table pagination. + * + * Provides a single source of truth for computing rendered cell-slice heights + * that match the DOM painter's actual rendering semantics (spacing.before, + * totalHeight promotion, spacing.after). Used by: + * + * - `computePartialRow()` — fitting loop via incremental cursor (Layer 2) + * - `getRowContentHeight()` — one-shot full-row height (Layer 1) + * - `layout-bridge` — selection-rect vertical positioning (Layer 1) + * + * Lives in `@superdoc/layout-engine` because it depends on layout-engine + * internals (`getEmbeddedRowLines`) that are not part of the contract surface. + */ + +import type { TableCellMeasure, TableCell, ParagraphMeasure, TableMeasure, TableRowMeasure } from '@superdoc/contracts'; +import { effectiveTableCellSpacing } from '@superdoc/contracts'; +import { getEmbeddedRowLines } from './layout-table.js'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +/** + * Describes one block in a table cell with renderer-semantic height values. + * + * Maps each measured block to its global line index range and the spacing / + * height values the DOM painter actually applies. Layout decisions that use + * these descriptors stay synchronized with `renderTableCell.ts`. + */ +export type CellRenderBlock = { + kind: 'paragraph' | 'table' | 'other'; + /** First global line index (inclusive). */ + globalStartLine: number; + /** Past-the-end global line index (exclusive). */ + globalEndLine: number; + /** Per-segment heights matching `getCellLines()` output. */ + lineHeights: number[]; + /** `ParagraphMeasure.totalHeight ?? sum(lineHeights)`. */ + totalHeight: number; + /** Height contributing to content flow. 0 for anchored out-of-flow blocks. */ + visibleHeight: number; + isFirstBlock: boolean; + isLastBlock: boolean; + /** Effective spacing.before (first block: excess over padding.top; others: full). */ + spacingBefore: number; + /** Raw spacing.after; always 0 for last block (renderer skips it). */ + spacingAfter: number; +}; + +/** + * Stateful cursor for the `computePartialRow()` fitting loop. + * + * Advances one line at a time and reports the rendered cost of each line + * including block-boundary spacing and totalHeight promotion. O(1) per step. + */ +export interface CellSliceCursor { + /** + * Compute the rendered cost of including the line at `globalLineIndex`. + * Advances internal state — call exactly once per line, in ascending order. + * After calling, if the line doesn't fit, break; the cursor state no longer + * matters since it won't be used again for this cell. + */ + advanceLine(globalLineIndex: number): number; + + /** + * Minimum rendered cost of the segment at `globalLineIndex`, for + * force-progress checks. Pure peek — does not modify cursor state. + */ + minSegmentCost(globalLineIndex: number): number; +} + +// ─── Builder ───────────────────────────────────────────────────────────────── + +/** + * Build an ordered array of block descriptors from a cell's measurement and + * block data. Descriptors carry all renderer-semantic information needed by + * `computeCellSliceContentHeight` and the fitting cursor. + * + * **Iteration rule**: driven by measured blocks (source of truth for line + * counts). Block data is attached by index when available; missing data + * degrades to zero spacing and `totalHeight = sum(lineHeights)`. + */ +export function describeCellRenderBlocks( + cellMeasure: TableCellMeasure, + cellBlock: TableCell | undefined, + cellPadding: { top: number; bottom: number }, +): CellRenderBlock[] { + const measuredBlocks = cellMeasure.blocks; + const blockDataArray = cellBlock?.blocks; + + // Backward-compat: single-paragraph cells + if (!measuredBlocks || measuredBlocks.length === 0) { + if (cellMeasure.paragraph) { + return buildSingleParagraphBlock(cellMeasure.paragraph, cellBlock?.paragraph, cellPadding); + } + return []; + } + + const result: CellRenderBlock[] = []; + let globalLine = 0; + const blockCount = measuredBlocks.length; + + for (let i = 0; i < blockCount; i++) { + const measure = measuredBlocks[i]; + const data = i < (blockDataArray?.length ?? 0) ? blockDataArray![i] : undefined; + const isFirstBlock = i === 0; + const isLastBlock = i === blockCount - 1; + + if (measure.kind === 'paragraph') { + const paraMeasure = measure as ParagraphMeasure; + const paraData = data?.kind === 'paragraph' ? data : undefined; + + const lines = paraMeasure.lines ?? []; + const lineHeights = lines.map((l) => l.lineHeight); + const sumLines = sumArray(lineHeights); + + const spacingBefore = effectiveTableCellSpacing(paraData?.attrs?.spacing?.before, isFirstBlock, cellPadding.top); + const rawAfter = paraData?.attrs?.spacing?.after; + const spacingAfter = isLastBlock ? 0 : typeof rawAfter === 'number' && rawAfter > 0 ? rawAfter : 0; + + const startLine = globalLine; + globalLine += lines.length; + + result.push({ + kind: 'paragraph', + globalStartLine: startLine, + globalEndLine: globalLine, + lineHeights, + totalHeight: paraMeasure.totalHeight ?? sumLines, + visibleHeight: sumLines, + isFirstBlock, + isLastBlock, + spacingBefore, + spacingAfter, + }); + } else if (measure.kind === 'table') { + // Embedded table — expand rows the same way getCellLines() does + const tableMeasure = measure as TableMeasure; + const lineHeights: number[] = []; + for (const row of tableMeasure.rows) { + for (const seg of getEmbeddedRowLines(row)) { + lineHeights.push(seg.lineHeight); + } + } + + const startLine = globalLine; + globalLine += lineHeights.length; + const sumLines = sumArray(lineHeights); + + result.push({ + kind: 'table', + globalStartLine: startLine, + globalEndLine: globalLine, + lineHeights, + totalHeight: sumLines, + visibleHeight: sumLines, + isFirstBlock, + isLastBlock, + spacingBefore: 0, + spacingAfter: 0, + }); + } else { + // Image, drawing, or other non-paragraph block. + // getCellLines() only adds a segment when height > 0. + const blockHeight = 'height' in measure ? (measure as { height: number }).height : 0; + if (blockHeight > 0) { + const outOfFlow = isAnchoredOutOfFlow(data); + const startLine = globalLine; + globalLine += 1; + + result.push({ + kind: 'other', + globalStartLine: startLine, + globalEndLine: globalLine, + lineHeights: [blockHeight], + totalHeight: outOfFlow ? 0 : blockHeight, + visibleHeight: outOfFlow ? 0 : blockHeight, + isFirstBlock, + isLastBlock, + spacingBefore: 0, + spacingAfter: 0, + }); + } + // height === 0 → getCellLines() skips it, no line index consumed + } + } + + return result; +} + +// ─── Layer 1: Pure full-slice function ─────────────────────────────────────── + +/** + * Content-area height of a cell slice `[fromLine, toLine)`. + * + * Matches the DOM painter's rendering semantics: + * - `spacing.before` when rendering from the start of a block + * - `totalHeight` promotion for fully rendered paragraphs + * - `spacing.after` for fully rendered non-last paragraphs + * + * Returns content height only — cell padding is NOT included. + * O(blocks) per call. + */ +export function computeCellSliceContentHeight(blocks: CellRenderBlock[], fromLine: number, toLine: number): number { + let height = 0; + + for (const block of blocks) { + if (block.globalEndLine <= fromLine || block.globalStartLine >= toLine) continue; + + const localStart = Math.max(0, fromLine - block.globalStartLine); + const localEnd = Math.min(block.lineHeights.length, toLine - block.globalStartLine); + const rendersEntireBlock = localStart === 0 && localEnd >= block.lineHeights.length; + + if (block.kind === 'paragraph') { + // spacing.before when rendering from line 0 — matches renderTableCell.ts:1386-1394 + if (localStart === 0) { + height += block.spacingBefore; + } + + let sliceLineSum = 0; + for (let i = localStart; i < localEnd; i++) { + sliceLineSum += block.lineHeights[i]; + } + + if (rendersEntireBlock) { + // Promote to totalHeight — matches renderTableCell.ts:1478-1482 + height += Math.max(sliceLineSum, block.totalHeight); + // spacing.after for non-last blocks — matches renderTableCell.ts:1492-1500 + // (block.spacingAfter is already 0 for the last block) + height += block.spacingAfter; + } else { + height += sliceLineSum; + } + } else { + // Table / other blocks — contribute overlapped visible heights + if (block.visibleHeight === 0) continue; // anchored out-of-flow + for (let i = localStart; i < localEnd; i++) { + height += block.lineHeights[i]; + } + } + } + + return height; +} + +// ─── Layer 2: Incremental cursor ───────────────────────────────────────────── + +/** + * Create a stateful cursor for the `computePartialRow()` fitting loop. + * + * The cursor tracks block boundaries and accumulates spacing / promotion costs + * so that each `advanceLine()` call is O(1). If the fitting loop starts from a + * continuation (mid-block), the cursor correctly skips spacing.before and + * totalHeight promotion for the partially consumed block. + */ +export function createCellSliceCursor(blocks: CellRenderBlock[], startLine: number): CellSliceCursor { + let blockIdx = 0; + let startedFromLine0 = false; + let blockLineSum = 0; + + // Advance to the block containing startLine + while (blockIdx < blocks.length && blocks[blockIdx].globalEndLine <= startLine) { + blockIdx++; + } + if (blockIdx < blocks.length) { + const block = blocks[blockIdx]; + startedFromLine0 = startLine <= block.globalStartLine; + // Pre-accumulate line heights for lines already consumed in this block + if (!startedFromLine0) { + for (let li = 0; li < startLine - block.globalStartLine; li++) { + blockLineSum += block.lineHeights[li] ?? 0; + } + } + } + + return { + advanceLine(globalLineIndex: number): number { + // Handle block transitions + while (blockIdx < blocks.length && blocks[blockIdx].globalEndLine <= globalLineIndex) { + blockIdx++; + startedFromLine0 = true; + blockLineSum = 0; + } + if (blockIdx >= blocks.length) return 0; + + const block = blocks[blockIdx]; + const localLine = globalLineIndex - block.globalStartLine; + const lineHeight = block.lineHeights[localLine] ?? 0; + let cost = 0; + + // spacing.before when entering a paragraph block at its first line + if (localLine === 0 && startedFromLine0 && block.kind === 'paragraph') { + cost += block.spacingBefore; + } + + // Line's visible contribution + if (block.kind === 'paragraph' || block.visibleHeight > 0) { + cost += lineHeight; + } + + // Track line height within the block (before block-completion check) + blockLineSum += lineHeight; + + // Block completion: totalHeight promotion + spacingAfter + const isBlockComplete = localLine === block.lineHeights.length - 1; + if (isBlockComplete && startedFromLine0 && block.kind === 'paragraph') { + cost += Math.max(0, block.totalHeight - blockLineSum); + cost += block.spacingAfter; + } + + // Advance to next block if this one is complete + if (isBlockComplete) { + blockIdx++; + startedFromLine0 = true; + blockLineSum = 0; + } + + return cost; + }, + + minSegmentCost(globalLineIndex: number): number { + // Pure peek — does not modify cursor state + const block = findBlockForLine(blocks, globalLineIndex); + if (!block) return 0; + + const localLine = globalLineIndex - block.globalStartLine; + const lineHeight = block.lineHeights[localLine] ?? 0; + let cost = 0; + + // Include spacing.before if this is the first line of a paragraph block + if (localLine === 0 && block.kind === 'paragraph') { + cost += block.spacingBefore; + } + + // Include visible line height + if (block.kind === 'paragraph' || block.visibleHeight > 0) { + cost += lineHeight; + } + + // For single-line blocks, include completion costs + if (block.lineHeights.length === 1 && block.kind === 'paragraph') { + cost += Math.max(0, block.totalHeight - lineHeight); + cost += block.spacingAfter; + } + + return cost; + }, + }; +} + +// ─── Hot-path: allocation-free full-cell height ────────────────────────────── + +/** + * Content height of a fully rendered cell, using **measurement** semantics. + * + * Unlike `describeCellRenderBlocks` + `computeCellSliceContentHeight` (which + * use renderer semantics and skip last-block spacing.after), this function + * includes last-block spacing.after via `effectiveTableCellSpacing` to match + * how `rowMeasure.height` was computed by the measurer. This keeps + * `getRowContentHeight()` aligned with `rowMeasure.height` so that + * `hasExplicitRowHeightSlack()` compares like-for-like. + * + * Computes in a single pass without allocating intermediate arrays. + * Returns content height only — cell padding is NOT included. + */ +export function computeFullCellContentHeight( + cellMeasure: TableCellMeasure, + cellBlock: TableCell | undefined, + cellPadding: { top: number; bottom: number }, +): number { + const measuredBlocks = cellMeasure.blocks; + const blockDataArray = cellBlock?.blocks; + + // Single paragraph fallback (first + last block) + if (!measuredBlocks || measuredBlocks.length === 0) { + if (cellMeasure.paragraph) { + const pm = cellMeasure.paragraph; + let sumLines = 0; + for (const l of pm.lines) sumLines += l.lineHeight; + const paraData = cellBlock?.paragraph; + const spacingBefore = effectiveTableCellSpacing(paraData?.attrs?.spacing?.before, true, cellPadding.top); + // Measurement semantics: last-block spacing.after is absorbed into + // paddingBottom, but excess still contributes to measured height. + const spacingAfter = effectiveTableCellSpacing(paraData?.attrs?.spacing?.after, true, cellPadding.bottom); + return spacingBefore + Math.max(sumLines, pm.totalHeight ?? sumLines) + spacingAfter; + } + return 0; + } + + let height = 0; + const blockCount = measuredBlocks.length; + + for (let i = 0; i < blockCount; i++) { + const measure = measuredBlocks[i]; + const data = i < (blockDataArray?.length ?? 0) ? blockDataArray![i] : undefined; + const isFirstBlock = i === 0; + const isLastBlock = i === blockCount - 1; + + if (measure.kind === 'paragraph') { + const pm = measure as ParagraphMeasure; + const paraData = data?.kind === 'paragraph' ? data : undefined; + let sumLines = 0; + for (const l of pm.lines ?? []) sumLines += l.lineHeight; + + height += effectiveTableCellSpacing(paraData?.attrs?.spacing?.before, isFirstBlock, cellPadding.top); + height += Math.max(sumLines, pm.totalHeight ?? sumLines); + if (!isLastBlock) { + const rawAfter = paraData?.attrs?.spacing?.after; + if (typeof rawAfter === 'number' && rawAfter > 0) height += rawAfter; + } else { + // Measurement semantics: last-block spacing.after is absorbed into + // paddingBottom, but excess still contributes to measured height. + // This keeps getRowContentHeight aligned with rowMeasure.height. + height += effectiveTableCellSpacing(paraData?.attrs?.spacing?.after, true, cellPadding.bottom); + } + } else if (measure.kind === 'table') { + // Sum row heights directly — avoids getEmbeddedRowLines() expansion. + // For a fully rendered table this equals the sum of all segments. + const tm = measure as TableMeasure; + for (const row of tm.rows) height += row.height; + } else { + // Image, drawing: contribute height only when inline (not anchored out-of-flow) + const blockHeight = 'height' in measure ? (measure as { height: number }).height : 0; + if (blockHeight > 0 && !isAnchoredOutOfFlow(data)) { + height += blockHeight; + } + } + } + + return height; +} + +// ─── Private helpers ───────────────────────────────────────────────────────── + +function buildSingleParagraphBlock( + paraMeasure: ParagraphMeasure, + paraData: { attrs?: { spacing?: { before?: number; after?: number } } } | undefined, + cellPadding: { top: number; bottom: number }, +): CellRenderBlock[] { + const lines = paraMeasure.lines ?? []; + if (lines.length === 0) return []; + + const lineHeights = lines.map((l) => l.lineHeight); + const sumLines = sumArray(lineHeights); + + return [ + { + kind: 'paragraph', + globalStartLine: 0, + globalEndLine: lines.length, + lineHeights, + totalHeight: paraMeasure.totalHeight ?? sumLines, + visibleHeight: sumLines, + isFirstBlock: true, + isLastBlock: true, + spacingBefore: effectiveTableCellSpacing(paraData?.attrs?.spacing?.before, true, cellPadding.top), + spacingAfter: 0, // Last block → renderer skips spacing.after + }, + ]; +} + +/** + * Detect anchored out-of-flow blocks (images/drawings positioned outside + * the normal content flow). These consume a line index in `getCellLines()` + * but contribute zero visible height in the renderer. + */ +function isAnchoredOutOfFlow(block: unknown): boolean { + if (!block || typeof block !== 'object') return false; + const b = block as Record; + const anchor = b.anchor as Record | undefined; + if (!anchor?.isAnchored) return false; + const wrap = b.wrap as Record | undefined; + return (wrap?.type ?? 'Inline') !== 'Inline'; +} + +function findBlockForLine(blocks: CellRenderBlock[], globalLineIndex: number): CellRenderBlock | undefined { + for (const block of blocks) { + if (globalLineIndex >= block.globalStartLine && globalLineIndex < block.globalEndLine) { + return block; + } + } + return undefined; +} + +function sumArray(arr: number[]): number { + let total = 0; + for (const v of arr) total += v; + return total; +} From aee4f2bf95d90cbbbe2a2b189adc0dee3ea82f20 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Thu, 19 Mar 2026 17:45:22 -0700 Subject: [PATCH 2/2] fix(layout-table): preserve repeated header prefixes across split continuations --- .../layout-engine/src/layout-table.test.ts | 102 ++++++++++++++++++ .../layout-engine/src/layout-table.ts | 43 +++++--- 2 files changed, 131 insertions(+), 14 deletions(-) diff --git a/packages/layout-engine/layout-engine/src/layout-table.test.ts b/packages/layout-engine/layout-engine/src/layout-table.test.ts index f8fefd9256..f28f326ee4 100644 --- a/packages/layout-engine/layout-engine/src/layout-table.test.ts +++ b/packages/layout-engine/layout-engine/src/layout-table.test.ts @@ -1660,6 +1660,56 @@ describe('layoutTableBlock', () => { } }); + it('repeats only the completed header prefix when a later header row continues on a new page', () => { + const block = createMockTableBlock(3, [{ repeatHeader: true }, { repeatHeader: true }, { repeatHeader: false }]); + const measure = createMockTableMeasure([100, 100], [10, 20, 10], [[10], [10, 10, 10], [10]], 2); + + const firstPage = { fragments: [] as TableFragment[] }; + const secondPage = { fragments: [] as TableFragment[] }; + const thirdPage = { fragments: [] as TableFragment[] }; + const pages = [firstPage, secondPage, thirdPage]; + let currentPageIndex = 0; + let state = { + page: firstPage, + columnIndex: 0, + cursorY: 0, + contentBottom: 35, + topMargin: 0, + }; + + layoutTableBlock({ + block, + measure, + columnWidth: 200, + ensurePage: () => state, + advanceColumn: () => { + currentPageIndex += 1; + state = { + ...state, + page: pages[currentPageIndex], + cursorY: 0, + contentBottom: 60, + }; + return state; + }, + columnX: () => 0, + }); + + expect(firstPage.fragments).toHaveLength(1); + expect(firstPage.fragments[0].partialRow?.rowIndex).toBe(1); + + expect(secondPage.fragments).toHaveLength(1); + expect(secondPage.fragments[0].partialRow?.rowIndex).toBe(1); + expect(secondPage.fragments[0].repeatHeaderCount).toBe(1); + + // Once the split header row is complete, later continuation fragments + // should repeat the full header block again. + expect(thirdPage.fragments).toHaveLength(1); + expect(thirdPage.fragments[0].fromRow).toBe(2); + expect(thirdPage.fragments[0].repeatHeaderCount).toBe(2); + expect(currentPageIndex).toBe(2); + }); + it('should skip header repetition when headers are taller than page', () => { const block = createMockTableBlock(5, [ { repeatHeader: true }, @@ -1814,6 +1864,58 @@ describe('layoutTableBlock', () => { expect(secondPage.fragments[1].partialRow?.rowIndex).toBe(2); }); + it('suppresses repeated headers between same-page slices after a forced split on a continuation page', () => { + const block = createMockTableBlock(2, [{ repeatHeader: true }, { cantSplit: true }]); + for (const cell of block.rows[1].cells) { + cell.attrs = { padding: { top: 4, bottom: 4, left: 2, right: 2 } }; + } + + const measure = createMockTableMeasure([100, 100], [10, 50], [[10], [8, 12]], 4); + + const pageHeights = [70, 55, 80]; + const firstPage = { fragments: [] as TableFragment[] }; + const secondPage = { fragments: [] as TableFragment[] }; + const thirdPage = { fragments: [] as TableFragment[] }; + const pages = [firstPage, secondPage, thirdPage]; + let currentPageIndex = 0; + let state = { + page: firstPage, + columnIndex: 0, + cursorY: 0, + contentBottom: pageHeights[0], + topMargin: 0, + }; + + layoutTableBlock({ + block, + measure, + columnWidth: 200, + ensurePage: () => state, + advanceColumn: () => { + currentPageIndex += 1; + state = { + ...state, + page: pages[currentPageIndex], + cursorY: 0, + contentBottom: pageHeights[currentPageIndex], + }; + return state; + }, + columnX: () => 0, + }); + + expect(firstPage.fragments).toHaveLength(1); + expect(secondPage.fragments).toHaveLength(2); + expect(secondPage.fragments[0].partialRow?.rowIndex).toBe(1); + expect(secondPage.fragments[1].partialRow?.rowIndex).toBe(1); + expect(secondPage.fragments[0].repeatHeaderCount).toBe(0); + expect(secondPage.fragments[1].repeatHeaderCount).toBe(0); + expect(secondPage.fragments.reduce((sum, fragment) => sum + fragment.height, 0)).toBeLessThanOrEqual( + pageHeights[1], + ); + expect(thirdPage.fragments).toHaveLength(0); + }); + it('should not split floating tables', () => { const block = createMockTableBlock(10, undefined, { tableProperties: { floatingTableProperties: { horizontalAnchor: 'page' } }, diff --git a/packages/layout-engine/layout-engine/src/layout-table.ts b/packages/layout-engine/layout-engine/src/layout-table.ts index 472831dae6..941232a7f3 100644 --- a/packages/layout-engine/layout-engine/src/layout-table.ts +++ b/packages/layout-engine/layout-engine/src/layout-table.ts @@ -1314,7 +1314,10 @@ export function layoutTableBlock({ // 2. Count header rows const headerCount = countHeaderRows(block); - const headerHeight = headerCount > 0 ? sumRowHeights(measure.rows, 0, headerCount) : 0; + const headerPrefixHeights = [0]; + for (let i = 0; i < headerCount; i += 1) { + headerPrefixHeights.push(headerPrefixHeights[i] + (measure.rows[i]?.height ?? 0)); + } // 3. Initialize state let state = ensurePage(); @@ -1412,6 +1415,11 @@ export function layoutTableBlock({ // Resolve border-collapse for fragment height (match measuring/render: only add borders when separate) const borderCollapse = block.attrs?.borderCollapse ?? (block.attrs?.cellSpacing != null ? 'separate' : 'collapse'); + const getRepeatedHeaderHeight = (repeatCount: number): number => { + const clampedCount = Math.max(0, Math.min(repeatCount, headerCount)); + return headerPrefixHeights[clampedCount] ?? 0; + }; + // Tracks whether the current iteration is a same-page continuation of a // partial row. Headers must not repeat mid-page: the painter always renders // repeated headers at the top of a fragment, which would insert headers @@ -1445,14 +1453,18 @@ export function layoutTableBlock({ } else if (samePagePartialContinuation) { // Same-page continuation of a partial row: never insert headers mid-page repeatHeaderCount = 0; - } else if (pendingPartialRow && pendingPartialRow.rowIndex < headerCount) { - // The partial row being continued IS a header row. Repeating headers - // would render that same row once as a repeated header and again as - // the body partial continuation, causing duplicate content. - repeatHeaderCount = 0; - } else if (headerCount > 0 && headerHeight < availableHeight) { - // New page with room for headers + body content - repeatHeaderCount = headerCount; + } else { + // When continuing a later header row on a new page, repeat only the + // completed header prefix. The current partial header row continues as + // body content, so including it in repeatHeaderCount would duplicate it. + const candidateRepeatHeaderCount = + pendingPartialRow && pendingPartialRow.rowIndex < headerCount ? pendingPartialRow.rowIndex : headerCount; + const candidateHeaderHeight = getRepeatedHeaderHeight(candidateRepeatHeaderCount); + + if (candidateRepeatHeaderCount > 0 && candidateHeaderHeight < availableHeight) { + // New page with room for the repeated header prefix plus body content. + repeatHeaderCount = candidateRepeatHeaderCount; + } } // Reset for this iteration — set by same-page partial-row paths below. @@ -1464,14 +1476,16 @@ export function layoutTableBlock({ const bodyRow = block.rows[currentRow]; const bodyRowHeight = measure.rows[currentRow]?.height || 0; const bodyCantSplit = bodyRow?.attrs?.tableRowProperties?.cantSplit === true; - const spaceWithHeaders = availableHeight - headerHeight; + const spaceWithHeaders = availableHeight - getRepeatedHeaderHeight(repeatHeaderCount); if (bodyCantSplit && bodyRowHeight > spaceWithHeaders && bodyRowHeight <= availableHeight) { repeatHeaderCount = 0; } } + const repeatedHeaderHeight = getRepeatedHeaderHeight(repeatHeaderCount); + // Adjust available height for header repetition - const availableForBody = repeatHeaderCount > 0 ? availableHeight - headerHeight : availableHeight; + const availableForBody = availableHeight - repeatedHeaderHeight; // Calculate full page height (for detecting over-tall rows) // This is the actual usable content area height, accounting for top margin. @@ -1480,10 +1494,10 @@ export function layoutTableBlock({ // When headers are repeated on every page, the force-progress threshold // must account for the header budget. Otherwise a segment that's smaller - // than a full page but larger than (fullPage − headers) will livelock: + // than a full page but larger than (fullPage − repeated headers) will livelock: // computePartialRow makes no progress, the guard advances to a new page - // with the same header budget, and the same no-progress state recurs. - const fullPageHeightForBody = repeatHeaderCount > 0 ? fullPageHeight - headerHeight : fullPageHeight; + // with the same repeated-header budget, and the same no-progress state recurs. + const fullPageHeightForBody = fullPageHeight - repeatedHeaderHeight; // Handle pending partial row continuation if (pendingPartialRow !== null) { @@ -1675,6 +1689,7 @@ export function layoutTableBlock({ state.page.fragments.push(fragment); state.cursorY += fragmentHeight; pendingPartialRow = forcedPartialRow; + samePagePartialContinuation = true; isTableContinuation = true; continue; }