Skip to content

Comments

fix(layout-engine): text clipping inside table in document with multi-orientation, recursive pagination for deeply nested tables (SD-1962)#2140

Open
tupizz wants to merge 2 commits intomainfrom
tadeu/sd-1962-nested-table-pagination
Open

fix(layout-engine): text clipping inside table in document with multi-orientation, recursive pagination for deeply nested tables (SD-1962)#2140
tupizz wants to merge 2 commits intomainfrom
tadeu/sd-1962-nested-table-pagination

Conversation

@tupizz
Copy link
Contributor

@tupizz tupizz commented Feb 22, 2026

Before / After

Before (main) After (this PR)
CleanShot 2026-02-22 at 07 38 47 CleanShot 2026-02-22 at 07 38 55
CleanShot 2026-02-22 at 07 37 21 CleanShot 2026-02-22 at 07 38 06

Summary

  • Embedded tables inside table cells now paginate correctly at row boundaries across pages, even when nested multiple levels deep (table-in-table-in-table)
  • Previously, a deeply nested table (e.g., the second "Collection Time" table inside the first "Collection Time" table inside the outer amendment table) was treated as a single unsplittable segment, causing its content to be clipped or lost on continuation pages
  • The fix makes both the layout engine's segment expansion and the renderer's segment counting recursive, so any depth of table nesting paginates correctly

Test plan

  • All 480 layout-engine tests pass
  • All 629 painter-dom tests pass
  • Browser verification: text_cut_table.docx renders all three nesting levels correctly across pages 11-13
  • Visual regression tests

🔗 Relates to SD-1962

…-1962)

Embedded tables inside table cells now paginate correctly at row
boundaries across pages, even when nested multiple levels deep
(table-in-table-in-table). Previously, a nested table was treated as a
single unsplittable segment, causing its content to be clipped or lost
on continuation pages.

Layout engine changes:
- Add getEmbeddedRowLines() to recursively expand nested table rows
  into sub-segments instead of treating each row as one segment
- Update getCellLines() to call recursive expansion for table blocks
- Update computeCellPmRange() to use recursive segment counts

Renderer changes:
- Add getCellSegmentCount(), getEmbeddedRowSegmentCount(), and
  getEmbeddedTableSegmentCount() helpers mirroring the layout logic
- Update blockLineCounts to use recursive segment counting
- Compute per-row partial rendering info (PartialRowInfo) when a
  nested table row straddles a page break
- Pass partialRow through renderEmbeddedTable → TableFragment so
  renderTableFragmentElement handles mid-row splits naturally
Copilot AI review requested due to automatic review settings February 22, 2026 10:30
@linear
Copy link

linear bot commented Feb 22, 2026

@chatgpt-codex-connector
Copy link

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

…entation docs (SD-1962)

Blocks are now measured at their own section's content width instead of
the global maximum across all sections. In mixed-orientation documents
(portrait + landscape), the old approach measured all blocks at the
widest section width, causing text line breaks to be computed too wide
for narrower sections — resulting in text clipping inside table cells.

- Add computePerSectionConstraints() to resolve per-block measurement
  dimensions from section breaks
- Update measurement loop to use per-block constraints
- Update remeasureAffectedBlocks() to use per-block constraints
- Keep resolveMeasurementConstraints() for cache invalidation
@tupizz tupizz self-assigned this Feb 22, 2026
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request fixes recursive pagination for deeply nested tables (table-in-table-in-table) by making both the layout engine's segment expansion and the renderer's segment counting recursive. Previously, deeply nested tables were treated as single unsplittable segments, causing content to be clipped or lost on continuation pages.

Changes:

  • Added recursive segment expansion in the layout engine via getEmbeddedRowLines() to enable splitting at sub-row boundaries within nested tables
  • Updated renderer segment counting to recursively count embedded table segments, ensuring alignment with layout engine logic
  • Added support for partial row rendering in embedded tables with new parameters (fromRow, toRow, partialRow, availableWidth) in renderEmbeddedTable()
  • Implemented column width rescaling for embedded tables when measurement-scale exceeds render-scale
  • Updated non-paragraph blocks (images, drawings) to occupy 1 segment in pagination calculations
  • Added force-progress logic in computePartialRow() to handle segments taller than a full page

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

File Description
packages/layout-engine/layout-engine/src/layout-table.ts Added getEmbeddedRowLines() for recursive segment expansion; updated getCellLines() to recursively expand embedded table rows; updated computeCellPmRange() to handle embedded table segments; added fullPageHeight parameter to computePartialRow() for force-progress logic
packages/layout-engine/painters/dom/src/table/renderTableCell.ts Added segment counting helpers (getCellSegmentCount(), getEmbeddedRowSegmentCount(), getEmbeddedTableSegmentCount()); updated renderEmbeddedTable() to support partial rendering and column rescaling; updated cell rendering loop to handle embedded table pagination and non-paragraph block segments

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 339 to 347
/**
* Get all lines from a cell's blocks (multi-block or single paragraph).
*
* Cells can have multiple blocks (cell.blocks) or a single paragraph (cell.paragraph).
* This function normalizes access to all lines across all paragraph blocks.
*
* @param cell - Cell measure
* @returns Array of all lines with their lineHeight
*/
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a duplicate documentation comment. Lines 339-347 appear to be an orphaned JSDoc comment for getCellLines (which follows getEmbeddedRowLines). This creates confusion as it appears to document getEmbeddedRowLines but actually describes getCellLines. The orphaned comment should be removed since getCellLines already has proper inline documentation.

Copilot uses AI. Check for mistakes.
segIdx++;
}
}
}
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The partial row height computation (lines 936-979) doesn't handle cells that use the legacy cell.paragraph property instead of cell.blocks. When cell.blocks is undefined or empty, but cell.paragraph exists, the height computation will incorrectly return 0 instead of computing the height based on paragraph lines. This could lead to incorrect partial row heights for cells with legacy structure. Consider adding an else clause after line 977 to handle the cell.paragraph case, similar to how getCellSegmentCount handles it at lines 118-120.

Suggested change
}
}
} else if (cell.paragraph && cell.paragraph.lines && cell.paragraph.lines.length > 0) {
const lines = cell.paragraph.lines;
const start = Math.max(0, cellFrom);
const end = Math.min(cellTo, lines.length);
for (let i = start; i < end; i++) {
const line = lines[i];
cellVisHeight += (line && line.lineHeight) || 0;
}

Copilot uses AI. Check for mistakes.
Comment on lines +960 to +962
for (let s = 0; s < nestedRowSegs; s++) {
if (segIdx >= cellFrom && segIdx < cellTo) {
cellVisHeight += (nestedRow.height || 0) / nestedRowSegs;
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The height computation for partially visible nested table rows (line 962) divides the row height evenly by segment count. This is an approximation that may be inaccurate when nested table rows contain cells with non-uniform line heights. For example, if a nested table row has 3 segments but the first segment occupies 50% of the height and the other two segments split the remaining 50%, dividing evenly will misrepresent the actual visible height. Consider implementing a more precise height calculation that accounts for the actual distribution of heights within the nested table row, similar to how paragraph line heights are summed individually.

Suggested change
for (let s = 0; s < nestedRowSegs; s++) {
if (segIdx >= cellFrom && segIdx < cellTo) {
cellVisHeight += (nestedRow.height || 0) / nestedRowSegs;
const partialInfo = nestedRow as unknown as PartialRowInfo | undefined;
const segHeights =
partialInfo && Array.isArray(partialInfo.segmentHeights)
? partialInfo.segmentHeights
: undefined;
for (let s = 0; s < nestedRowSegs; s++) {
if (segIdx >= cellFrom && segIdx < cellTo) {
if (segHeights && segHeights.length === nestedRowSegs) {
cellVisHeight += segHeights[s] || 0;
} else {
cellVisHeight += (nestedRow.height || 0) / nestedRowSegs;
}

Copilot uses AI. Check for mistakes.
Comment on lines +911 to +989
let partialRowInfo: PartialRowInfo | undefined;

for (let r = 0; r < tableMeasure.rows.length; r++) {
const rowSegs = rowSegmentCounts[r];
const rowStart = segmentOffset;
const rowEnd = segmentOffset + rowSegs;
segmentOffset = rowEnd;

// Skip rows completely outside the range
if (rowEnd <= localFrom || rowStart >= localTo) continue;

if (embeddedFromRow === -1) embeddedFromRow = r;
embeddedToRow = r + 1;

// Check if this row needs partial rendering (recursive row with nested tables)
if (rowSegs > 1 && (rowStart < localFrom || rowEnd > localTo)) {
// This row is partially visible — compute per-cell fromLine/toLine
const rowLocalFrom = Math.max(0, localFrom - rowStart);
const rowLocalTo = Math.min(rowSegs, localTo - rowStart);
const row = tableMeasure.rows[r];

const fromLineByCell: number[] = [];
const toLineByCell: number[] = [];
let partialHeight = 0;

for (const cell of row.cells) {
const cellTotal = getCellSegmentCount(cell);
const cellFrom = Math.min(rowLocalFrom, cellTotal);
const cellTo = Math.min(rowLocalTo, cellTotal);
fromLineByCell.push(cellFrom);
toLineByCell.push(cellTo);

// Compute visible height for this cell's segment range
let cellVisHeight = 0;
if (cell.blocks && cell.blocks.length > 0) {
let segIdx = 0;
for (const blk of cell.blocks) {
if (blk.kind === 'paragraph') {
const lines = (blk as ParagraphMeasure).lines || [];
for (const line of lines) {
if (segIdx >= cellFrom && segIdx < cellTo) {
cellVisHeight += line.lineHeight || 0;
}
segIdx++;
}
} else if (blk.kind === 'table') {
const nestedTable = blk as TableMeasure;
for (const nestedRow of nestedTable.rows) {
const nestedRowSegs = getEmbeddedRowSegmentCount(nestedRow);
for (let s = 0; s < nestedRowSegs; s++) {
if (segIdx >= cellFrom && segIdx < cellTo) {
cellVisHeight += (nestedRow.height || 0) / nestedRowSegs;
}
segIdx++;
}
}
} else {
const blkHeight = 'height' in blk ? (blk as { height: number }).height : 0;
if (blkHeight > 0) {
if (segIdx >= cellFrom && segIdx < cellTo) {
cellVisHeight += blkHeight;
}
segIdx++;
}
}
}
}
partialHeight = Math.max(partialHeight, cellVisHeight);
}

partialRowInfo = {
rowIndex: r,
fromLineByCell,
toLineByCell,
isFirstPart: rowLocalFrom === 0,
isLastPart: rowLocalTo >= rowSegs,
partialHeight,
};
}
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The loop can identify multiple rows requiring partial rendering when the visible segment range cuts through multiple rows with nested content. The partialRowInfo variable is reassigned on each iteration (line 981), so only the last partially-rendered row's information is preserved. However, the TableFragment contract only supports a single partialRow, not an array. This means if the first row needs partial rendering (starting mid-row) AND the last row needs partial rendering (ending mid-row), only the last row's partial info will be passed to renderEmbeddedTable, causing the first row to render incorrectly (it would render all its content instead of being clipped). Consider either: (1) ensuring the logic correctly handles the case where both the first and last rows need partial rendering, or (2) adding assertions to verify at most one row can be partial in this context.

Copilot uses AI. Check for mistakes.
Comment on lines 855 to 866
const blockLineCounts: number[] = [];
for (let i = 0; i < Math.min(blockMeasures.length, cellBlocks.length); i++) {
const bm = blockMeasures[i];
if (bm.kind === 'paragraph') {
blockLineCounts.push((bm as ParagraphMeasure).lines?.length || 0);
} else if (bm.kind === 'table') {
// Embedded tables: recursively count segments (matches getCellLines expansion)
blockLineCounts.push(getEmbeddedTableSegmentCount(bm as TableMeasure));
} else {
blockLineCounts.push(0);
// Non-paragraph blocks (image, drawing) occupy 1 segment
blockLineCounts.push(1);
}
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR introduces segment counting for non-paragraph blocks (images, drawings), counting each as 1 segment. However, anchored images and drawings are excluded from normal flow rendering (lines 1034-1036, 1079-1081 skip them), but are still counted as segments by both the layout engine's getCellLines (layout-table.ts:398-406) and the renderer's segment counting loop (lines 855-866). If anchored blocks can appear inside table cells, this creates a mismatch: segments will be counted for anchored blocks, but they won't increment cumulativeLineCount during rendering, causing subsequent blocks to render at incorrect segment indices. Verify whether anchored blocks inside table cells are a valid scenario, and if so, both the layout engine and renderer should skip anchored blocks when counting segments.

Copilot uses AI. Check for mistakes.
@tupizz tupizz changed the title fix(layout-engine): recursive pagination for deeply nested tables (SD-1962) fix(layout-engine): text clipping inside table in document with multi-orientation, recursive pagination for deeply nested tables (SD-1962) Feb 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant