From 89dfb20b29d352714b7ddaead0bc047db37f2260 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Sun, 22 Feb 2026 07:28:42 -0300 Subject: [PATCH 1/2] fix(layout-engine): recursive pagination for deeply nested tables (SD-1962) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../layout-engine/src/layout-table.ts | 119 +++++-- .../painters/dom/src/table/renderTableCell.ts | 305 ++++++++++++++++-- 2 files changed, 375 insertions(+), 49 deletions(-) diff --git a/packages/layout-engine/layout-engine/src/layout-table.ts b/packages/layout-engine/layout-engine/src/layout-table.ts index 8268ee5e5..5fc287651 100644 --- a/packages/layout-engine/layout-engine/src/layout-table.ts +++ b/packages/layout-engine/layout-engine/src/layout-table.ts @@ -345,19 +345,64 @@ const MIN_PARTIAL_ROW_HEIGHT = 20; * @param cell - Cell measure * @returns Array of all lines with their lineHeight */ +/** + * Get the line segments for a single embedded table row. + * + * If any cell in the row contains nested tables, recursively expand using + * the tallest cell's segments. This enables the layout engine to split at + * 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 }> { + // Check if any cell has nested table blocks + const hasNestedTable = row.cells.some((cell) => cell.blocks?.some((b) => b.kind === 'table')); + + if (!hasNestedTable) { + // Simple case: no nested tables, row is one segment + return [{ lineHeight: row.height || 0 }]; + } + + // Recursive case: find the cell with the most segments (tallest content) + let tallestLines: Array<{ lineHeight: number }> = []; + for (const cell of row.cells) { + const cellLines = getCellLines(cell); + if (cellLines.length > tallestLines.length) { + tallestLines = cellLines; + } + } + + return tallestLines.length > 0 ? tallestLines : [{ lineHeight: row.height || 0 }]; +} + function getCellLines(cell: TableRowMeasure['cells'][number]): Array<{ lineHeight: number }> { // Multi-block cells use the `blocks` array if (cell.blocks && cell.blocks.length > 0) { const allLines: Array<{ lineHeight: number }> = []; for (const block of cell.blocks) { if (block.kind === 'paragraph') { - // Type guard ensures block is ParagraphMeasure - if (block.kind === 'paragraph' && 'lines' in block) { + if ('lines' in block) { const paraBlock = block as ParagraphMeasure; if (paraBlock.lines) { allLines.push(...paraBlock.lines); } } + } else if (block.kind === 'table') { + // Embedded tables: expand individual rows as separate segments so the + // outer table splitter can break at embedded-table row boundaries, + // matching MS Word behavior where nested tables paginate across pages. + // Recursively expand rows that contain further nested tables. + const tableBlock = block as TableMeasure; + for (const row of tableBlock.rows) { + allLines.push(...getEmbeddedRowLines(row)); + } + } else { + // Non-paragraph blocks (images, drawings) are represented as a single + // unsplittable segment with their full height. This ensures computePartialRow + // accounts for their height when splitting rows across pages. + const blockHeight = 'height' in block ? (block as { height: number }).height : 0; + if (blockHeight > 0) { + allLines.push({ lineHeight: blockHeight }); + } } } return allLines; @@ -371,26 +416,6 @@ function getCellLines(cell: TableRowMeasure['cells'][number]): Array<{ lineHeigh return []; } -/** - * Calculate the height of lines from startLine to endLine for a cell. - * - * @param cell - Cell measure containing paragraph with lines - * @param fromLine - Starting line index (inclusive, must be >= 0) - * @param toLine - Ending line index (exclusive), -1 means to end - * @returns Height in pixels - */ -function _calculateCellLinesHeight(cell: TableRowMeasure['cells'][number], fromLine: number, toLine: number): number { - if (fromLine < 0) { - throw new Error(`Invalid fromLine ${fromLine}: must be >= 0`); - } - const lines = getCellLines(cell); - const endLine = toLine === -1 ? lines.length : toLine; - let height = 0; - for (let i = fromLine; i < endLine && i < lines.length; i++) { - height += lines[i].lineHeight || 0; - } - return height; -} type CellPadding = { top: number; bottom: number; left: number; right: number }; @@ -575,7 +600,30 @@ function computeCellPmRange( continue; } - mergePmRange(range, extractBlockPmRange(block as { attrs?: Record })); + // Non-paragraph blocks: advance cumulative count to stay aligned with getCellLines(). + // Embedded tables expand to N segments (recursively, matching getEmbeddedRowLines); + // images/drawings are 1 segment. + if (blockMeasure.kind === 'table') { + const tableMeasure = blockMeasure as TableMeasure; + let tableSegments = 0; + for (const row of tableMeasure.rows) { + tableSegments += getEmbeddedRowLines(row).length; + } + const blockStart = cumulativeLineCount; + const blockEnd = cumulativeLineCount + tableSegments; + // Only include PM range if this block overlaps the requested line range + if (blockStart < toLine && blockEnd > fromLine) { + mergePmRange(range, extractBlockPmRange(block as { attrs?: Record })); + } + cumulativeLineCount += tableSegments; + } else { + // Images, drawings: 1 segment each + const blockStart = cumulativeLineCount; + cumulativeLineCount += 1; + if (blockStart < toLine && blockStart >= fromLine) { + mergePmRange(range, extractBlockPmRange(block as { attrs?: Record })); + } + } } return range; @@ -777,6 +825,7 @@ function computePartialRow( measure: TableMeasure, availableHeight: number, fromLineByCell?: number[], + fullPageHeight?: number, ): PartialRowInfo { const row = measure.rows[rowIndex]; if (!row) { @@ -810,7 +859,22 @@ function computePartialRow( for (let i = startLine; i < lines.length; i++) { const lineHeight = lines[i].lineHeight || 0; if (cumulativeHeight + lineHeight > availableForLines) { - break; // Can't fit this line + // 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. + if ( + cumulativeHeight === 0 && + i === startLine && + availableForLines > 0 && + fullPageHeight != null && + lineHeight > fullPageHeight + ) { + // Cap height to available space — overflow:hidden on the cell clips the rest. + cumulativeHeight += Math.min(lineHeight, availableForLines); + cutLine = i + 1; + } + break; } cumulativeHeight += lineHeight; cutLine = i + 1; // Exclusive index @@ -934,7 +998,7 @@ function findSplitPoint( // Check if this is an over-tall row (exceeds full page height) - force split regardless of cantSplit // This handles edge case where a row is taller than an entire page if (fullPageHeight && rowHeight > fullPageHeight) { - const partialRow = computePartialRow(i, block.rows[i], measure, remainingHeight); + const partialRow = computePartialRow(i, block.rows[i], measure, remainingHeight, undefined, fullPageHeight); return { endRow: i + 1, partialRow }; } @@ -955,7 +1019,7 @@ function findSplitPoint( // Row doesn't have cantSplit - try to split mid-row (MS Word default behavior) // Only split if we have meaningful space (at least MIN_PARTIAL_ROW_HEIGHT for one line) if (remainingHeight >= MIN_PARTIAL_ROW_HEIGHT) { - const partialRow = computePartialRow(i, block.rows[i], measure, remainingHeight); + const partialRow = computePartialRow(i, block.rows[i], measure, remainingHeight, undefined, fullPageHeight); // Check if we can actually fit any lines const hasContent = partialRow.toLineByCell.some( @@ -1249,6 +1313,7 @@ export function layoutTableBlock({ measure, availableForBody, fromLineByCell, + fullPageHeight, ); const madeProgress = continuationPartialRow.toLineByCell.some( @@ -1339,7 +1404,7 @@ export function layoutTableBlock({ // If still no rows fit after retry, force split // This handles edge case where row is too tall to fit on empty page if (endRow === bodyStartRow && partialRow === null) { - const forcedPartialRow = computePartialRow(bodyStartRow, block.rows[bodyStartRow], measure, availableForBody); + const forcedPartialRow = computePartialRow(bodyStartRow, block.rows[bodyStartRow], measure, availableForBody, undefined, fullPageHeight); const forcedEndRow = bodyStartRow + 1; const fragmentHeight = forcedPartialRow.partialHeight + (repeatHeaderCount > 0 ? headerHeight : 0); diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index 3dd46dad3..29d68fbf7 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -3,34 +3,35 @@ import type { DrawingBlock, DrawingMeasure, Fragment, - Line, - ParagraphBlock, - ParagraphMeasure, ImageBlock, ImageMeasure, + Line, + ParagraphBlock, ParagraphIndent, + ParagraphMeasure, + PartialRowInfo, + RenderedLineInfo, SdtMetadata, TableBlock, TableFragment, TableMeasure, - WrapTextMode, WrapExclusion, - RenderedLineInfo, + WrapTextMode, } from '@superdoc/contracts'; -import { applyCellBorders } from './border-utils.js'; -import { applyImageClipPath } from '../utils/image-clip-path.js'; -import type { FragmentRenderContext, BlockLookup } from '../renderer.js'; +import { toCssFontFamily } from '@superdoc/font-utils'; +import { normalizeZIndex } from '@superdoc/pm-adapter/utilities.js'; +import type { BlockLookup, FragmentRenderContext } from '../renderer.js'; import { applyParagraphBorderStyles, applyParagraphShadingStyles } from '../renderer.js'; import { applySquareWrapExclusionsToLines } from '../utils/anchor-helpers'; -import { toCssFontFamily } from '@superdoc/font-utils'; -import { renderTableFragment as renderTableFragmentElement } from './renderTableFragment.js'; +import { applyImageClipPath } from '../utils/image-clip-path.js'; import { applySdtContainerStyling, getSdtContainerConfig, getSdtContainerKey, type SdtBoundaryOptions, } from '../utils/sdt-helpers.js'; -import { normalizeZIndex } from '@superdoc/pm-adapter/utilities.js'; +import { applyCellBorders } from './border-utils.js'; +import { renderTableFragment as renderTableFragmentElement } from './renderTableFragment.js'; /** * Default gap between list marker and text content in pixels. @@ -88,6 +89,67 @@ type WordLayoutInfo = { }; type TableRowMeasure = TableMeasure['rows'][number]; +type TableCellMeasure = TableRowMeasure['cells'][number]; + +/** + * Compute the total segment count for a cell's blocks, matching the layout engine's + * recursive getCellLines() expansion. Paragraph blocks contribute their line count, + * embedded tables contribute the sum of their rows' recursive segment counts, + * and other blocks (images, drawings) contribute 1 segment. + */ +function getCellSegmentCount(cell: TableCellMeasure): number { + if (cell.blocks && cell.blocks.length > 0) { + let total = 0; + for (const block of cell.blocks) { + if (block.kind === 'paragraph') { + total += (block as ParagraphMeasure).lines?.length || 0; + } else if (block.kind === 'table') { + const tableMeasure = block as TableMeasure; + for (const row of tableMeasure.rows) { + total += getEmbeddedRowSegmentCount(row); + } + } else { + const blockHeight = 'height' in block ? (block as { height: number }).height : 0; + if (blockHeight > 0) total += 1; + } + } + return total; + } + if (cell.paragraph) { + return (cell.paragraph as ParagraphMeasure).lines?.length || 0; + } + return 0; +} + +/** + * Compute the segment count for a single embedded table row. + * If any cell in the row contains nested tables, recursively expand using the + * tallest cell's segment count. Otherwise, the row is 1 segment. + * This mirrors the layout engine's getEmbeddedRowLines() logic. + */ +function getEmbeddedRowSegmentCount(row: TableRowMeasure): number { + const hasNestedTable = row.cells.some((cell: TableCellMeasure) => + cell.blocks?.some((b) => b.kind === 'table'), + ); + if (!hasNestedTable) return 1; + + let maxSegments = 0; + for (const cell of row.cells) { + maxSegments = Math.max(maxSegments, getCellSegmentCount(cell)); + } + return maxSegments > 0 ? maxSegments : 1; +} + +/** + * Compute the total recursive segment count for an embedded table. + */ +function getEmbeddedTableSegmentCount(tableMeasure: TableMeasure): number { + let total = 0; + for (const row of tableMeasure.rows) { + total += getEmbeddedRowSegmentCount(row); + } + return total; +} /** * Parameters for rendering a list marker element. @@ -334,6 +396,8 @@ type EmbeddedTableRenderParams = { table: TableBlock; /** Measurement data for the nested table */ measure: TableMeasure; + /** Available width for the embedded table (render-scale cell content area) */ + availableWidth: number; /** Rendering context (section, page, column info) */ context: FragmentRenderContext; /** Function to render a line of paragraph content */ @@ -354,6 +418,12 @@ type EmbeddedTableRenderParams = { renderDrawingContent?: (block: DrawingBlock) => HTMLElement; /** Function to apply SDT metadata as data attributes */ applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; + /** Starting row index for partial rendering (inclusive, default 0) */ + fromRow?: number; + /** Ending row index for partial rendering (exclusive, default all rows) */ + toRow?: number; + /** Partial row info for mid-row splits within the embedded table */ + partialRow?: PartialRowInfo; }; /** @@ -386,17 +456,62 @@ const EMBEDDED_TABLE_VERSION = 'embedded-table'; * ``` */ const renderEmbeddedTable = (params: EmbeddedTableRenderParams): HTMLElement => { - const { doc, table, measure, context, renderLine, captureLineSnapshot, renderDrawingContent, applySdtDataset } = - params; + const { + doc, + table, + measure, + availableWidth, + context, + renderLine, + captureLineSnapshot, + renderDrawingContent, + applySdtDataset, + fromRow: paramFromRow, + toRow: paramToRow, + partialRow: paramPartialRow, + } = params; + + const effectiveFromRow = paramFromRow ?? 0; + const effectiveToRow = paramToRow ?? table.rows.length; + + // Calculate the height for the visible row range. + // For rows with partial rendering (mid-row split), use the partial height. + let visibleHeight = 0; + for (let r = effectiveFromRow; r < effectiveToRow; r++) { + if (paramPartialRow && paramPartialRow.rowIndex === r) { + visibleHeight += paramPartialRow.partialHeight; + } else { + visibleHeight += measure.rows[r]?.height || 0; + } + } + + // Rescale column widths when measurement-scale exceeds render-scale (SD-1962). + // Top-level tables get rescaled by layout-engine's rescaleColumnWidths(), but + // embedded tables bypass that path. We apply the same scaling here. + let fragmentWidth = measure.totalWidth; + let columnWidths: number[] | undefined; + if (measure.totalWidth > availableWidth && measure.columnWidths?.length && availableWidth > 0) { + const scale = availableWidth / measure.totalWidth; + columnWidths = measure.columnWidths.map((w) => Math.max(1, Math.round(w * scale))); + const scaledSum = columnWidths.reduce((a, b) => a + b, 0); + const target = Math.round(availableWidth); + if (scaledSum !== target && columnWidths.length > 0) { + columnWidths[columnWidths.length - 1] = Math.max(1, columnWidths[columnWidths.length - 1] + (target - scaledSum)); + } + fragmentWidth = availableWidth; + } + const fragment: TableFragment = { kind: 'table', blockId: table.id, - fromRow: 0, - toRow: table.rows.length, + fromRow: effectiveFromRow, + toRow: effectiveToRow, x: 0, y: 0, - width: measure.totalWidth, - height: measure.totalHeight, + width: fragmentWidth, + height: visibleHeight, + columnWidths, + partialRow: paramPartialRow, }; const blockLookup: BlockLookup = new Map([ [ @@ -733,14 +848,21 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen // (Needed for negative z-index behindDoc behavior.) content.style.zIndex = '0'; - // Calculate total lines across all blocks for proper global index mapping + // Calculate total segments across all blocks for proper global index mapping. + // Embedded tables expand recursively (matching the layout engine's getCellLines() + // which uses getEmbeddedRowLines() for recursive nested table expansion). + // Other non-paragraph blocks (images, drawings) occupy 1 segment each. 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); } } const totalLines = blockLineCounts.reduce((a, b) => a + b, 0); @@ -764,26 +886,147 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen if (blockMeasure.kind === 'table' && block?.kind === 'table') { const tableMeasure = blockMeasure as TableMeasure; + + // Compute per-row segment counts (recursive, matching getCellLines/getEmbeddedRowLines). + const rowSegmentCounts = tableMeasure.rows.map((row: TableRowMeasure) => getEmbeddedRowSegmentCount(row)); + const totalTableSegments = rowSegmentCounts.reduce((s: number, c: number) => s + c, 0); + + const tableStartSegment = cumulativeLineCount; + cumulativeLineCount += totalTableSegments; + const tableEndSegment = cumulativeLineCount; + + // Skip entirely if no segments are in the visible range + if (tableEndSegment <= globalFromLine || tableStartSegment >= globalToLine) { + continue; + } + + // Map global line range to local segment range within this embedded table + const localFrom = Math.max(0, globalFromLine - tableStartSegment); + const localTo = Math.min(totalTableSegments, globalToLine - tableStartSegment); + + // Determine which rows to render and whether any need partial rendering + let segmentOffset = 0; + let embeddedFromRow = -1; + let embeddedToRow = -1; + 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, + }; + } + } + + if (embeddedFromRow === -1) { + continue; + } + + // Calculate visible height (sum of visible row heights, using partial height where applicable) + let visibleHeight = 0; + for (let r = embeddedFromRow; r < embeddedToRow; r++) { + if (partialRowInfo && partialRowInfo.rowIndex === r) { + visibleHeight += partialRowInfo.partialHeight; + } else { + visibleHeight += tableMeasure.rows[r]?.height || 0; + } + } + const tableWrapper = doc.createElement('div'); tableWrapper.style.position = 'relative'; tableWrapper.style.width = '100%'; - tableWrapper.style.height = `${tableMeasure.totalHeight}px`; + tableWrapper.style.height = `${visibleHeight}px`; + tableWrapper.style.flexShrink = '0'; tableWrapper.style.boxSizing = 'border-box'; const tableEl = renderEmbeddedTable({ doc, table: block as TableBlock, measure: tableMeasure, + availableWidth: contentWidthPx, context: { ...context, section: 'body' }, renderLine, captureLineSnapshot, renderDrawingContent, applySdtDataset, + fromRow: embeddedFromRow, + toRow: embeddedToRow, + partialRow: partialRowInfo, }); tableWrapper.appendChild(tableEl); content.appendChild(tableWrapper); - flowCursorY += tableMeasure.totalHeight; - // Tables don't contribute to line count (they have their own internal line tracking) + flowCursorY += visibleHeight; continue; } @@ -793,10 +1036,19 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen continue; } + // Non-paragraph blocks occupy 1 segment in the combined line/segment index. + const imgSegmentIndex = cumulativeLineCount; + cumulativeLineCount += 1; + + if (imgSegmentIndex < globalFromLine || imgSegmentIndex >= globalToLine) { + continue; + } + const imageWrapper = doc.createElement('div'); imageWrapper.style.position = 'relative'; imageWrapper.style.width = `${blockMeasure.width}px`; imageWrapper.style.height = `${blockMeasure.height}px`; + imageWrapper.style.flexShrink = '0'; imageWrapper.style.maxWidth = '100%'; imageWrapper.style.boxSizing = 'border-box'; applySdtDataset(imageWrapper, (block as ImageBlock).attrs?.sdt); @@ -829,10 +1081,19 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen continue; } + // Non-paragraph blocks occupy 1 segment in the combined line/segment index. + const drawSegmentIndex = cumulativeLineCount; + cumulativeLineCount += 1; + + if (drawSegmentIndex < globalFromLine || drawSegmentIndex >= globalToLine) { + continue; + } + const drawingWrapper = doc.createElement('div'); drawingWrapper.style.position = 'relative'; drawingWrapper.style.width = `${blockMeasure.width}px`; drawingWrapper.style.height = `${blockMeasure.height}px`; + drawingWrapper.style.flexShrink = '0'; drawingWrapper.style.maxWidth = '100%'; drawingWrapper.style.boxSizing = 'border-box'; applySdtDataset(drawingWrapper, (block as DrawingBlock).attrs as SdtMetadata | undefined); From 5d5a3fb74ff1f5d74b4007fcb1c1a1cf79cccd5a Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Sun, 22 Feb 2026 07:31:54 -0300 Subject: [PATCH 2/2] fix(layout-bridge): per-section measurement constraints for mixed-orientation docs (SD-1962) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../layout-bridge/src/incrementalLayout.ts | 123 +++++++++++++++--- 1 file changed, 102 insertions(+), 21 deletions(-) diff --git a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts index c67389d74..89cc9191c 100644 --- a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts +++ b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts @@ -6,6 +6,7 @@ import type { SectionMetadata, ParagraphBlock, ColumnLayout, + SectionBreakBlock, } from '@superdoc/contracts'; import { layoutDocument, @@ -748,6 +749,13 @@ export async function incrementalLayout( // Perf summary emitted at the end of the function. + // Per-section constraints: each block is measured at its own section's content width. + // This prevents text clipping in mixed-orientation documents (SD-1962) where the old + // global-max approach measured all blocks at the widest section's width, causing line + // breaks to be too wide for narrower sections. + const perSectionConstraints = computePerSectionConstraints(options, nextBlocks); + + // Global max constraints are still used for cache invalidation comparison. const { measurementWidth, measurementHeight } = resolveMeasurementConstraints(options, nextBlocks); if (measurementWidth <= 0 || measurementHeight <= 0) { @@ -765,7 +773,6 @@ export async function incrementalLayout( : null; const measureStart = performance.now(); - const constraints = { maxWidth: measurementWidth, maxHeight: measurementHeight }; const measures: Measure[] = []; let cacheHits = 0; let cacheMisses = 0; @@ -773,12 +780,18 @@ export async function incrementalLayout( let cacheLookupTime = 0; let actualMeasureTime = 0; - for (const block of nextBlocks) { + for (let blockIndex = 0; blockIndex < nextBlocks.length; blockIndex++) { + const block = nextBlocks[blockIndex]; if (block.kind === 'sectionBreak') { measures.push({ kind: 'sectionBreak' }); continue; } + // Use per-section constraints for this block's measurement. + const sectionConstraints = perSectionConstraints[blockIndex]; + const blockMeasureWidth = sectionConstraints.maxWidth; + const blockMeasureHeight = sectionConstraints.maxHeight; + if (canReusePreviousMeasures && dirty.stableBlockIds.has(block.id)) { const previousMeasure = previousMeasuresById?.get(block.id); if (previousMeasure) { @@ -790,7 +803,7 @@ export async function incrementalLayout( // Time the cache lookup (includes hashRuns computation) const lookupStart = performance.now(); - const cached = measureCache.get(block, measurementWidth, measurementHeight); + const cached = measureCache.get(block, blockMeasureWidth, blockMeasureHeight); cacheLookupTime += performance.now() - lookupStart; if (cached) { @@ -801,10 +814,10 @@ export async function incrementalLayout( // Time the actual DOM measurement const measureBlockStart = performance.now(); - const measurement = await measureBlock(block, constraints); + const measurement = await measureBlock(block, sectionConstraints); actualMeasureTime += performance.now() - measureBlockStart; - measureCache.set(block, measurementWidth, measurementHeight, measurement); + measureCache.set(block, blockMeasureWidth, blockMeasureHeight, measurement); measures.push(measurement); cacheMisses++; } @@ -1104,14 +1117,16 @@ export async function incrementalLayout( // Invalidate cache for affected blocks measureCache.invalidate(Array.from(tokenResult.affectedBlockIds)); - // Re-measure affected blocks + // Re-measure affected blocks using per-section constraints const remeasureStart = performance.now(); + const currentPerSectionConstraints = computePerSectionConstraints(options, currentBlocks); currentMeasures = await remeasureAffectedBlocks( currentBlocks, currentMeasures, tokenResult.affectedBlockIds, - constraints, + currentPerSectionConstraints, measureBlock, + measureCache, ); const remeasureEnd = performance.now(); const remeasureTime = remeasureEnd - remeasureStart; @@ -1893,20 +1908,84 @@ const DEFAULT_MARGINS = { top: 72, right: 72, bottom: 72, left: 72 }; export const normalizeMargin = (value: number | undefined, fallback: number): number => Number.isFinite(value) ? (value as number) : fallback; +/** + * Computes measurement constraints for each block based on its section's properties. + * + * In mixed-orientation documents (e.g., portrait + landscape sections), each section has a + * different content width. Measuring ALL blocks at the maximum width (the old approach) + * causes text line breaks to be computed for wider cells than actually rendered, leading to + * text clipping in table cells with `overflow: hidden` (SD-1962). + * + * This function returns a per-block constraint array so each block is measured at its own + * section's content width. Section breaks act as state transitions: each break defines the + * constraints for subsequent content blocks until the next break. + * + * @param options - Layout options containing default page size, margins, and columns + * @param blocks - Array of flow blocks (content + section breaks) + * @returns Array parallel to `blocks` with per-block measurement constraints. + * Section break entries have the constraints of the section they introduce. + */ +function computePerSectionConstraints( + options: LayoutOptions, + blocks: FlowBlock[], +): Array<{ maxWidth: number; maxHeight: number }> { + const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE; + const defaultMargins = { + top: normalizeMargin(options.margins?.top, DEFAULT_MARGINS.top), + right: normalizeMargin(options.margins?.right, DEFAULT_MARGINS.right), + bottom: normalizeMargin(options.margins?.bottom, DEFAULT_MARGINS.bottom), + left: normalizeMargin(options.margins?.left, DEFAULT_MARGINS.left), + }; + const computeColumnWidth = (contentWidth: number, columns?: { count: number; gap?: number }): number => { + if (!columns || columns.count <= 1) return contentWidth; + const gap = Math.max(0, columns.gap ?? 0); + const totalGap = gap * (columns.count - 1); + return (contentWidth - totalGap) / columns.count; + }; + + const defaultContentWidth = pageSize.w - (defaultMargins.left + defaultMargins.right); + const defaultContentHeight = pageSize.h - (defaultMargins.top + defaultMargins.bottom); + const defaultConstraints = { + maxWidth: computeColumnWidth(defaultContentWidth, options.columns), + maxHeight: defaultContentHeight, + }; + + let current = defaultConstraints; + const result: Array<{ maxWidth: number; maxHeight: number }> = []; + + for (const block of blocks) { + if (block.kind === 'sectionBreak') { + const sb = block as SectionBreakBlock; + const sectionPageSize = sb.pageSize ?? pageSize; + const sectionMargins = { + top: normalizeMargin(sb.margins?.top, defaultMargins.top), + right: normalizeMargin(sb.margins?.right, defaultMargins.right), + bottom: normalizeMargin(sb.margins?.bottom, defaultMargins.bottom), + left: normalizeMargin(sb.margins?.left, defaultMargins.left), + }; + const contentWidth = sectionPageSize.w - (sectionMargins.left + sectionMargins.right); + const contentHeight = sectionPageSize.h - (sectionMargins.top + sectionMargins.bottom); + if (contentWidth > 0 && contentHeight > 0) { + current = { + maxWidth: computeColumnWidth(contentWidth, sb.columns ?? options.columns), + maxHeight: contentHeight, + }; + } + } + result.push(current); + } + + return result; +} + /** * Resolves the maximum measurement constraints (width and height) needed for measuring blocks * across all sections in a document. * * This function scans the entire document (including all section breaks) to determine the * widest column configuration and tallest content area that will be encountered during layout. - * All blocks must be measured at these maximum constraints to ensure they fit correctly when - * placed in any section, preventing remeasurement during pagination. - * - * Why maximum constraints are needed: - * - Documents can have multiple sections with different page sizes, margins, and column counts - * - Each section may have a different effective column width (e.g., 2 columns vs 3 columns) - * - Blocks measured too narrow will overflow when placed in wider sections - * - Blocks measured at maximum width will fit in all sections (may have extra space in narrower ones) + * The result is used for cache invalidation and backward-compatible comparison (see + * `canReusePreviousMeasures`). Actual per-block measurement uses `computePerSectionConstraints`. * * Algorithm: * 1. Start with base content width/height from options.pageSize and options.margins @@ -2054,7 +2133,7 @@ function buildNumberingContext(layout: Layout, sections: SectionMetadata[]): Num * @param blocks - Current blocks array (with resolved tokens) * @param measures - Current measures array (parallel to blocks) * @param affectedBlockIds - Set of block IDs that need re-measurement - * @param constraints - Measurement constraints (width, height) + * @param perBlockConstraints - Per-block measurement constraints (parallel to blocks) * @param measureBlock - Function to measure a block * @returns Updated measures array with re-measured blocks */ @@ -2062,8 +2141,9 @@ async function remeasureAffectedBlocks( blocks: FlowBlock[], measures: Measure[], affectedBlockIds: Set, - constraints: { maxWidth: number; maxHeight: number }, + perBlockConstraints: Array<{ maxWidth: number; maxHeight: number }>, measureBlock: (block: FlowBlock, constraints: { maxWidth: number; maxHeight: number }) => Promise, + measureCache?: MeasureCache, ): Promise { const updatedMeasures: Measure[] = [...measures]; @@ -2076,14 +2156,15 @@ async function remeasureAffectedBlocks( } try { - // Re-measure the block - const newMeasure = await measureBlock(block, constraints); + // Re-measure the block with its section's constraints + const newMeasure = await measureBlock(block, perBlockConstraints[i]); // Update in the measures array updatedMeasures[i] = newMeasure; - // Cache the new measurement - measureCache.set(block, constraints.maxWidth, constraints.maxHeight, newMeasure); + // Cache the new measurement using per-block section constraints + const blockConstraints = perBlockConstraints[i]; + measureCache?.set(block, blockConstraints.maxWidth, blockConstraints.maxHeight, newMeasure); } catch (error) { // Error handling per plan: log warning, keep prior layout for block console.warn(`[incrementalLayout] Failed to re-measure block ${block.id} after token resolution:`, error);