From 89dfb20b29d352714b7ddaead0bc047db37f2260 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Sun, 22 Feb 2026 07:28:42 -0300 Subject: [PATCH 1/6] 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 8268ee5e55..5fc2876516 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 3dd46dad39..29d68fbf7e 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/6] 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 c67389d741..89cc9191cb 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); From d44f37007698f59046633eecb396af314a2206fb Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Mon, 23 Feb 2026 15:00:36 -0300 Subject: [PATCH 3/6] refactor(layout-engine): address PR review feedback for nested table pagination (SD-1962) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove orphaned JSDoc above getEmbeddedRowLines - Export rescaleColumnWidths and getCellLines from layout-engine - Extract computeVisibleHeight and computeCellVisibleHeight helpers - Extract renderPartialEmbeddedTable (~143-line inline block → named function) - Fix anchored block segment counting drift in initial counting loop - Fix missing cell.paragraph fallback in partial height computation - Replace inline column rescaling with imported rescaleColumnWidths - Add TODOs for partialRowInfo overwrite and approximate height split - Add 8 sync tests asserting renderer segment counts match layout engine --- .../layout-engine/layout-engine/src/index.ts | 3 + .../layout-engine/src/layout-table.ts | 23 +- .../layout-engine/painters/dom/package.json | 1 + .../dom/src/table/renderTableCell.test.ts | 187 +++++++- .../painters/dom/src/table/renderTableCell.ts | 408 +++++++++++------- pnpm-lock.yaml | 3 + 6 files changed, 445 insertions(+), 180 deletions(-) diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 448c3e487d..a884d50b81 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -2659,3 +2659,6 @@ export type { PageNumberFormat, DisplayPageInfo } from './pageNumbering.js'; // Export page token resolution utilities 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'; diff --git a/packages/layout-engine/layout-engine/src/layout-table.ts b/packages/layout-engine/layout-engine/src/layout-table.ts index 5fc2876516..75fb4da9e1 100644 --- a/packages/layout-engine/layout-engine/src/layout-table.ts +++ b/packages/layout-engine/layout-engine/src/layout-table.ts @@ -170,7 +170,7 @@ function resolveTableFrame( * * @returns Rescaled column widths if clamping occurred, undefined otherwise. */ -function rescaleColumnWidths( +export function rescaleColumnWidths( measureColumnWidths: number[] | undefined, measureTotalWidth: number, fragmentWidth: number, @@ -336,15 +336,6 @@ type SplitPointResult = { */ const MIN_PARTIAL_ROW_HEIGHT = 20; -/** - * 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 - */ /** * Get the line segments for a single embedded table row. * @@ -374,7 +365,7 @@ function getEmbeddedRowLines(row: TableRowMeasure): Array<{ lineHeight: number } return tallestLines.length > 0 ? tallestLines : [{ lineHeight: row.height || 0 }]; } -function getCellLines(cell: TableRowMeasure['cells'][number]): Array<{ lineHeight: number }> { +export 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 }> = []; @@ -416,7 +407,6 @@ function getCellLines(cell: TableRowMeasure['cells'][number]): Array<{ lineHeigh return []; } - type CellPadding = { top: number; bottom: number; left: number; right: number }; function getCellPadding(cellIdx: number, blockRow?: TableRow): CellPadding { @@ -1404,7 +1394,14 @@ 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, undefined, fullPageHeight); + 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/package.json b/packages/layout-engine/painters/dom/package.json index c61ced9133..7905d7d452 100644 --- a/packages/layout-engine/painters/dom/package.json +++ b/packages/layout-engine/painters/dom/package.json @@ -19,6 +19,7 @@ "dependencies": { "@superdoc/contracts": "workspace:*", "@superdoc/font-utils": "workspace:*", + "@superdoc/layout-engine": "workspace:*", "@superdoc/pm-adapter": "workspace:*", "@superdoc/preset-geometry": "workspace:*", "@superdoc/url-validation": "workspace:*" diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts index e780c1ff79..d77b5d92af 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -1,10 +1,12 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { renderTableCell } from './renderTableCell.js'; +import { renderTableCell, getCellSegmentCount } from './renderTableCell.js'; +import { getCellLines } from '@superdoc/layout-engine'; import type { ParagraphBlock, ParagraphMeasure, TableCell, TableCellMeasure, + TableMeasure, ImageBlock, DrawingBlock, DrawingMeasure, @@ -3496,3 +3498,186 @@ describe('renderTableCell', () => { }); }); }); + +/** + * Sync test: renderer's getCellSegmentCount must agree with layout engine's getCellLines().length. + * + * These two systems must produce identical segment counts for every cell shape — + * if they drift, pagination will render the wrong rows or skip content. + */ +describe('segment count sync: renderer vs layout engine', () => { + const makeParagraph = (lineCount: number): ParagraphMeasure => ({ + kind: 'paragraph', + lines: Array.from({ length: lineCount }, (_, i) => ({ + lineHeight: 20, + width: 100, + x: 0, + })), + indent: {} as ParagraphMeasure['indent'], + height: lineCount * 20, + width: 100, + }); + + const makeImage = (height: number) => ({ + kind: 'image' as const, + width: 100, + height, + scale: 1, + }); + + it('simple paragraph cell', () => { + const cell: TableCellMeasure = { + blocks: [makeParagraph(5)], + width: 200, + height: 100, + }; + expect(getCellSegmentCount(cell)).toBe(getCellLines(cell).length); + expect(getCellSegmentCount(cell)).toBe(5); + }); + + it('legacy single-paragraph cell (no blocks array)', () => { + const cell: TableCellMeasure = { + paragraph: makeParagraph(3), + width: 200, + height: 60, + }; + expect(getCellSegmentCount(cell)).toBe(getCellLines(cell).length); + expect(getCellSegmentCount(cell)).toBe(3); + }); + + it('multi-block cell (paragraphs + image)', () => { + const cell: TableCellMeasure = { + blocks: [makeParagraph(2), makeImage(50), makeParagraph(3)], + width: 200, + height: 150, + }; + expect(getCellSegmentCount(cell)).toBe(getCellLines(cell).length); + // 2 lines + 1 image segment + 3 lines = 6 + expect(getCellSegmentCount(cell)).toBe(6); + }); + + it('cell with nested table (single level)', () => { + const nestedTable: TableMeasure = { + kind: 'table', + rows: [ + { cells: [{ blocks: [makeParagraph(4)], width: 100, height: 80 }], height: 80 }, + { cells: [{ blocks: [makeParagraph(2)], width: 100, height: 40 }], height: 40 }, + ], + columnWidths: [100], + totalWidth: 100, + totalHeight: 120, + }; + const cell: TableCellMeasure = { + blocks: [makeParagraph(1), nestedTable], + width: 200, + height: 140, + }; + expect(getCellSegmentCount(cell)).toBe(getCellLines(cell).length); + // 1 paragraph line + 1 (row1, no nested tables → single segment) + 1 (row2, same) = 3 + expect(getCellSegmentCount(cell)).toBe(3); + }); + + it('cell with deeply nested table (table-in-table)', () => { + const innerTable: TableMeasure = { + kind: 'table', + rows: [{ cells: [{ blocks: [makeParagraph(3)], width: 80, height: 60 }], height: 60 }], + columnWidths: [80], + totalWidth: 80, + totalHeight: 60, + }; + const outerTable: TableMeasure = { + kind: 'table', + rows: [ + { + cells: [{ blocks: [innerTable, makeParagraph(1)], width: 100, height: 80 }], + height: 80, + }, + ], + columnWidths: [100], + totalWidth: 100, + totalHeight: 80, + }; + const cell: TableCellMeasure = { + blocks: [outerTable], + width: 200, + height: 80, + }; + expect(getCellSegmentCount(cell)).toBe(getCellLines(cell).length); + // outerTable has 1 row with nested table → expands recursively. + // Tallest cell has: innerTable(1 row, no further nesting → 1 segment) + 1 paragraph line = 2 + // outerRow expands to 2 segments → cell total = 2 + expect(getCellSegmentCount(cell)).toBe(2); + }); + + it('empty cell', () => { + const cell: TableCellMeasure = { + blocks: [], + width: 200, + height: 0, + }; + expect(getCellSegmentCount(cell)).toBe(getCellLines(cell).length); + expect(getCellSegmentCount(cell)).toBe(0); + }); + + it('cell with triple-nested table (table-in-table-in-table, triggers recursive expansion)', () => { + // Innermost table: 2 rows of simple paragraphs + const innermostTable: TableMeasure = { + kind: 'table', + rows: [ + { cells: [{ blocks: [makeParagraph(2)], width: 60, height: 40 }], height: 40 }, + { cells: [{ blocks: [makeParagraph(3)], width: 60, height: 60 }], height: 60 }, + ], + columnWidths: [60], + totalWidth: 60, + totalHeight: 100, + }; + // Middle table: 1 row containing the innermost table (triggers expansion at this level) + const middleTable: TableMeasure = { + kind: 'table', + rows: [ + { + cells: [{ blocks: [innermostTable], width: 80, height: 100 }], + height: 100, + }, + ], + columnWidths: [80], + totalWidth: 80, + totalHeight: 100, + }; + // Outer table: 1 row containing the middle table (triggers expansion at outer level) + const outerTable: TableMeasure = { + kind: 'table', + rows: [ + { + cells: [{ blocks: [middleTable], width: 100, height: 100 }], + height: 100, + }, + ], + columnWidths: [100], + totalWidth: 100, + totalHeight: 100, + }; + const cell: TableCellMeasure = { + blocks: [outerTable], + width: 200, + height: 100, + }; + expect(getCellSegmentCount(cell)).toBe(getCellLines(cell).length); + // Innermost: 2 rows, no further nesting → 1 segment each = 2 segments + // Middle: 1 row with nested table → expands to tallest cell = 2 segments + // Outer: 1 row with nested table → expands to tallest cell = 2 segments + // Cell total = 2 + expect(getCellSegmentCount(cell)).toBe(2); + }); + + it('cell with zero-height image (should not count as segment)', () => { + const cell: TableCellMeasure = { + blocks: [makeParagraph(2), makeImage(0)], + width: 200, + height: 40, + }; + expect(getCellSegmentCount(cell)).toBe(getCellLines(cell).length); + // 2 lines + 0 (zero-height image skipped) = 2 + expect(getCellSegmentCount(cell)).toBe(2); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index 29d68fbf7e..b74447ef5f 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -19,6 +19,7 @@ import type { WrapTextMode, } from '@superdoc/contracts'; import { toCssFontFamily } from '@superdoc/font-utils'; +import { rescaleColumnWidths } from '@superdoc/layout-engine'; import { normalizeZIndex } from '@superdoc/pm-adapter/utilities.js'; import type { BlockLookup, FragmentRenderContext } from '../renderer.js'; import { applyParagraphBorderStyles, applyParagraphShadingStyles } from '../renderer.js'; @@ -97,7 +98,7 @@ type TableCellMeasure = TableRowMeasure['cells'][number]; * embedded tables contribute the sum of their rows' recursive segment counts, * and other blocks (images, drawings) contribute 1 segment. */ -function getCellSegmentCount(cell: TableCellMeasure): number { +export function getCellSegmentCount(cell: TableCellMeasure): number { if (cell.blocks && cell.blocks.length > 0) { let total = 0; for (const block of cell.blocks) { @@ -128,9 +129,7 @@ function getCellSegmentCount(cell: TableCellMeasure): number { * 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'), - ); + const hasNestedTable = row.cells.some((cell: TableCellMeasure) => cell.blocks?.some((b) => b.kind === 'table')); if (!hasNestedTable) return 1; let maxSegments = 0; @@ -151,6 +150,80 @@ function getEmbeddedTableSegmentCount(tableMeasure: TableMeasure): number { return total; } +/** + * Compute the visible height for a range of table rows, using partial height + * where a row is only partially rendered (mid-row split). + */ +function computeVisibleHeight( + rows: TableMeasure['rows'], + fromRow: number, + toRow: number, + partialRow?: PartialRowInfo, +): number { + let height = 0; + for (let r = fromRow; r < toRow; r++) { + if (partialRow && partialRow.rowIndex === r) { + height += partialRow.partialHeight; + } else { + height += rows[r]?.height || 0; + } + } + return height; +} + +/** + * Compute the visible height of a single cell's content for a given segment range. + * Handles paragraphs, embedded tables, and non-paragraph blocks (images, drawings). + * Falls back to cell.paragraph for legacy single-paragraph cells. + */ +function computeCellVisibleHeight(cell: TableCellMeasure, cellFrom: number, cellTo: number): number { + 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); + // TODO: use actual segment heights from getEmbeddedRowLines() instead of + // even split for more precise height when rows have non-uniform line heights. + 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++; + } + } + } + } else if (cell.paragraph) { + // Legacy single-paragraph fallback (matches getCellSegmentCount) + const lines = (cell.paragraph as ParagraphMeasure).lines || []; + for (let i = 0; i < lines.length; i++) { + if (i >= cellFrom && i < cellTo) { + cellVisHeight += lines[i].lineHeight || 0; + } + } + } + return cellVisHeight; +} + /** * Parameters for rendering a list marker element. */ @@ -474,32 +547,13 @@ const renderEmbeddedTable = (params: EmbeddedTableRenderParams): HTMLElement => 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; - } - } + const visibleHeight = computeVisibleHeight(measure.rows, effectiveFromRow, effectiveToRow, paramPartialRow); // 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; - } + // embedded tables bypass that path. We reuse the same function here. + const columnWidths = rescaleColumnWidths(measure.columnWidths, measure.totalWidth, availableWidth); + const fragmentWidth = columnWidths ? availableWidth : measure.totalWidth; const fragment: TableFragment = { kind: 'table', @@ -544,6 +598,142 @@ const renderEmbeddedTable = (params: EmbeddedTableRenderParams): HTMLElement => }); }; +/** + * Render an embedded table block within a cell, handling segment-based pagination. + * + * Maps the cell's global segment range into the embedded table's local row range, + * computes partial row info when a page break falls mid-row, and delegates to + * renderEmbeddedTable for actual DOM creation. + */ +function renderPartialEmbeddedTable(params: { + doc: Document; + block: TableBlock; + blockMeasure: TableMeasure; + cumulativeLineCount: number; + globalFromLine: number; + globalToLine: number; + contentWidthPx: number; + context: FragmentRenderContext; + renderLine: EmbeddedTableRenderParams['renderLine']; + captureLineSnapshot?: EmbeddedTableRenderParams['captureLineSnapshot']; + renderDrawingContent?: EmbeddedTableRenderParams['renderDrawingContent']; + applySdtDataset: EmbeddedTableRenderParams['applySdtDataset']; +}): { element: HTMLElement | null; height: number; nextCumulativeLineCount: number } { + const { + doc, + block, + blockMeasure: tableMeasure, + cumulativeLineCount, + globalFromLine, + globalToLine, + contentWidthPx, + context, + renderLine, + captureLineSnapshot, + renderDrawingContent, + applySdtDataset, + } = params; + + // 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; + const nextCumulativeLineCount = cumulativeLineCount + totalTableSegments; + const tableEndSegment = nextCumulativeLineCount; + + // Skip entirely if no segments are in the visible range + if (tableEndSegment <= globalFromLine || tableStartSegment >= globalToLine) { + return { element: null, height: 0, nextCumulativeLineCount }; + } + + // 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; + // TODO: partialRowInfo is overwritten each iteration — if the visible segment range + // cuts through two different multi-segment rows, only the last one's info survives. + // TableFragment only supports a single partialRow, so fixing this requires a design change. + 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 (multi-segment row spanning the boundary) + if (rowSegs > 1 && (rowStart < localFrom || rowEnd > localTo)) { + 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); + partialHeight = Math.max(partialHeight, computeCellVisibleHeight(cell, cellFrom, cellTo)); + } + + partialRowInfo = { + rowIndex: r, + fromLineByCell, + toLineByCell, + isFirstPart: rowLocalFrom === 0, + isLastPart: rowLocalTo >= rowSegs, + partialHeight, + }; + } + } + + if (embeddedFromRow === -1) { + return { element: null, height: 0, nextCumulativeLineCount }; + } + + const visibleHeight = computeVisibleHeight(tableMeasure.rows, embeddedFromRow, embeddedToRow, partialRowInfo); + + const tableWrapper = doc.createElement('div'); + tableWrapper.style.position = 'relative'; + tableWrapper.style.width = '100%'; + tableWrapper.style.height = `${visibleHeight}px`; + tableWrapper.style.flexShrink = '0'; + tableWrapper.style.boxSizing = 'border-box'; + + const tableEl = renderEmbeddedTable({ + doc, + table: block, + measure: tableMeasure, + availableWidth: contentWidthPx, + context: { ...context, section: 'body' }, + renderLine, + captureLineSnapshot, + renderDrawingContent, + applySdtDataset, + fromRow: embeddedFromRow, + toRow: embeddedToRow, + partialRow: partialRowInfo, + }); + tableWrapper.appendChild(tableEl); + + return { element: tableWrapper, height: visibleHeight, nextCumulativeLineCount }; +} + /** * Apply paragraph-level visual styling such as borders and shading. * Borders are set per side with sensible defaults and clamping. @@ -851,18 +1041,27 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen // 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. + // Non-paragraph blocks (images, drawings) occupy 1 segment each, except anchored + // blocks which are rendered out-of-flow and do not consume segment indices. const blockLineCounts: number[] = []; for (let i = 0; i < Math.min(blockMeasures.length, cellBlocks.length); i++) { const bm = blockMeasures[i]; + const blk = cellBlocks[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 { - // Non-paragraph blocks (image, drawing) occupy 1 segment - blockLineCounts.push(1); + // Anchored blocks are rendered out-of-flow — they don't consume segment slots. + // Skip them to stay in sync with the rendering loop which also skips them. + const anchor = (blk as ImageBlock | DrawingBlock)?.anchor; + if (anchor?.isAnchored) { + blockLineCounts.push(0); + } else { + // Non-anchored non-paragraph blocks (image, drawing) occupy 1 segment + blockLineCounts.push(1); + } } } const totalLines = blockLineCounts.reduce((a, b) => a + b, 0); @@ -885,148 +1084,25 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen const block = cellBlocks[i]; 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 = `${visibleHeight}px`; - tableWrapper.style.flexShrink = '0'; - tableWrapper.style.boxSizing = 'border-box'; - - const tableEl = renderEmbeddedTable({ + const result = renderPartialEmbeddedTable({ doc, - table: block as TableBlock, - measure: tableMeasure, - availableWidth: contentWidthPx, - context: { ...context, section: 'body' }, + block: block as TableBlock, + blockMeasure: blockMeasure as TableMeasure, + cumulativeLineCount, + globalFromLine, + globalToLine, + contentWidthPx, + context, renderLine, captureLineSnapshot, renderDrawingContent, applySdtDataset, - fromRow: embeddedFromRow, - toRow: embeddedToRow, - partialRow: partialRowInfo, }); - tableWrapper.appendChild(tableEl); - content.appendChild(tableWrapper); - flowCursorY += visibleHeight; + cumulativeLineCount = result.nextCumulativeLineCount; + if (result.element) { + content.appendChild(result.element); + flowCursorY += result.height; + } continue; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf97374caa..983d652df5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -787,6 +787,9 @@ importers: '@superdoc/font-utils': specifier: workspace:* version: link:../../../../shared/font-utils + '@superdoc/layout-engine': + specifier: workspace:* + version: link:../../layout-engine '@superdoc/pm-adapter': specifier: workspace:* version: link:../../pm-adapter From 558346659679a1cab59dbd8759404c74e6ed96fb Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Mon, 23 Feb 2026 15:57:33 -0300 Subject: [PATCH 4/6] fix(layout-engine): enhance tab stop handling in paragraph properties - Update tab stop merging logic to correctly handle 'clear' tab types, ensuring that matching tab stops are removed from lower-priority sources. - Adjust tab position calculations in word layout to exclude 'clear' and 'bar' tab types. - Implement explicit tab stop checks in list marker utilities to prevent width mismatches in rendering. --- .../style-engine/src/ooxml/index.ts | 23 ++++++++++++++++--- packages/word-layout/src/index.ts | 4 +++- shared/common/list-marker-utils.ts | 18 +++++++++++++++ 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/layout-engine/style-engine/src/ooxml/index.ts b/packages/layout-engine/style-engine/src/ooxml/index.ts index 44dafd8bc6..fd83dbc23b 100644 --- a/packages/layout-engine/style-engine/src/ooxml/index.ts +++ b/packages/layout-engine/style-engine/src/ooxml/index.ts @@ -7,7 +7,7 @@ import { combineIndentProperties, combineProperties, combineRunProperties } from '../cascade.js'; import type { PropertyObject } from '../cascade.js'; -import type { ParagraphProperties, RunProperties } from './types.ts'; +import type { ParagraphProperties, ParagraphTabStop, RunProperties } from './types.ts'; import type { NumberingProperties } from './numbering-types.ts'; import type { StylesDocumentProperties, @@ -217,9 +217,26 @@ export function resolveParagraphProperties( const finalProps = combineProperties(propsChain, { specialHandling: { tabStops: (target: ParagraphProperties, source: ParagraphProperties): unknown => { - // If a higher priority source defines firstLine, remove hanging from the final result if (target.tabStops != null && source.tabStops != null) { - return [...(target.tabStops as unknown[]), ...(source.tabStops as unknown[])]; + // Merge tab stops from lower-priority (target) and higher-priority (source). + // Per OOXML spec, 'clear' tabs in a higher-priority source remove matching + // tab stops (by position) from lower-priority sources. + const sourceArr = source.tabStops as ParagraphTabStop[]; + const clearPositions = new Set(); + for (const ts of sourceArr) { + if (ts.tab?.tabType === 'clear' && ts.tab.pos != null) { + clearPositions.add(ts.tab.pos); + } + } + const targetArr = target.tabStops as ParagraphTabStop[]; + // Keep target tabs not cleared by source, plus non-clear source tabs + const merged = targetArr.filter((ts) => !(ts.tab?.pos != null && clearPositions.has(ts.tab.pos))); + for (const ts of sourceArr) { + if (ts.tab?.tabType !== 'clear') { + merged.push(ts); + } + } + return merged; } return source.tabStops; }, diff --git a/packages/word-layout/src/index.ts b/packages/word-layout/src/index.ts index 9488f20d39..337013b3a3 100644 --- a/packages/word-layout/src/index.ts +++ b/packages/word-layout/src/index.ts @@ -88,7 +88,9 @@ export function computeWordParagraphLayout(input: WordParagraphLayoutInput): Wor indentLeftPx: paragraph.indent?.left ?? 0, hangingPx: paragraph.indent?.hanging ?? 0, firstLinePx: paragraph.indent?.firstLine, - tabsPx: paragraph.tabs?.map((tab) => twipsToPixels(tab.pos)) ?? [], + tabsPx: + paragraph.tabs?.filter((tab) => tab.val !== 'clear' && tab.val !== 'bar').map((tab) => twipsToPixels(tab.pos)) ?? + [], textStartPx: paragraph.indent?.left ?? 0, marker: undefined, defaultTabIntervalPx: paragraph.tabIntervalTwips, diff --git a/shared/common/list-marker-utils.ts b/shared/common/list-marker-utils.ts index b5b6f77661..5116df2a42 100644 --- a/shared/common/list-marker-utils.ts +++ b/shared/common/list-marker-utils.ts @@ -326,6 +326,24 @@ export function resolveListTextStartPx( : LIST_MARKER_GAP; const currentPosStandard = markerStartPos + markerWidthEffective; + // Check for explicit tab stops past the marker position. + // The renderer uses these to position the tab after the list marker, so the measurer + // must also account for them to avoid a width mismatch that causes extreme negative word-spacing. + let explicitTabStop: number | undefined; + if (Array.isArray(wordLayout?.tabsPx)) { + for (const tab of wordLayout.tabsPx) { + if (typeof tab === 'number' && tab > currentPosStandard) { + explicitTabStop = tab; + break; + } + } + } + + if (explicitTabStop !== undefined) { + // Use the explicit tab stop — this matches the renderer's computeTabWidth() behavior + return explicitTabStop; + } + if (textStartTarget !== undefined) { const gap = Math.max(textStartTarget - currentPosStandard, gutterWidth); return currentPosStandard + gap; From 9802a7a80ad836b1405201a839e69a57e1a6f2b0 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Mon, 23 Feb 2026 18:27:40 -0300 Subject: [PATCH 5/6] fix(layout-engine): update tab width calculations for list markers - Adjust tab width handling in `resolveListTextStartPx` to correctly advance to the next default tab stop when markers overflow hanging space. - Update related tests to reflect changes in expected tab width values, ensuring consistency with the renderer's behavior. --- .../measuring/dom/src/index.test.ts | 3 +- packages/word-layout/src/index.ts | 6 +- shared/common/list-marker-utils.test.ts | 76 +++++++++---------- shared/common/list-marker-utils.ts | 15 ++-- 4 files changed, 52 insertions(+), 48 deletions(-) diff --git a/packages/layout-engine/measuring/dom/src/index.test.ts b/packages/layout-engine/measuring/dom/src/index.test.ts index c7e997a5dd..a7f6dda0a7 100644 --- a/packages/layout-engine/measuring/dom/src/index.test.ts +++ b/packages/layout-engine/measuring/dom/src/index.test.ts @@ -250,7 +250,8 @@ describe('measureBlock', () => { const measure = expectParagraphMeasure(await measureBlock(block, maxWidth)); // Standard mode resolution is governed by resolveListTextStartPx (Step 8), // which computes text start from indent/marker geometry when textStartPx is absent. - expect(measure.lines[0].maxWidth).toBe(120); + // Marker overflows hanging space, so advances to next default tab stop (96px). + expect(measure.lines[0].maxWidth).toBe(104); }); it('prefers shared resolved text start over top-level textStartPx when both exist', async () => { diff --git a/packages/word-layout/src/index.ts b/packages/word-layout/src/index.ts index 337013b3a3..d9116d54f7 100644 --- a/packages/word-layout/src/index.ts +++ b/packages/word-layout/src/index.ts @@ -89,8 +89,10 @@ export function computeWordParagraphLayout(input: WordParagraphLayoutInput): Wor hangingPx: paragraph.indent?.hanging ?? 0, firstLinePx: paragraph.indent?.firstLine, tabsPx: - paragraph.tabs?.filter((tab) => tab.val !== 'clear' && tab.val !== 'bar').map((tab) => twipsToPixels(tab.pos)) ?? - [], + paragraph.tabs + ?.filter((tab) => tab.val !== 'clear' && tab.val !== 'bar') + .map((tab) => twipsToPixels(tab.pos)) + .sort((a, b) => a - b) ?? [], textStartPx: paragraph.indent?.left ?? 0, marker: undefined, defaultTabIntervalPx: paragraph.tabIntervalTwips, diff --git a/shared/common/list-marker-utils.test.ts b/shared/common/list-marker-utils.test.ts index 1224f7ff8c..2d39dd739d 100644 --- a/shared/common/list-marker-utils.test.ts +++ b/shared/common/list-marker-utils.test.ts @@ -131,11 +131,11 @@ describe('resolveListTextStartPx', () => { const hanging = 18; // markerStartPos = 36 - 18 + 0 = 18 // currentPos = 18 + 20 = 38 - // textStartTarget undefined, gap uses gutterWidthPx (8) - // tabWidth = gutterWidthPx (8) because textStart < currentPos - // result = 38 + 8 = 46 + // textStart = 36, tabWidth = 36 - 38 = -2 → overflow + // nextDefaultTab = 38 + 48 - (38 % 48) = 48 + // result = 48 const result = resolveListTextStartPx(wordLayout, indentLeft, firstLine, hanging, mockMeasureMarkerText); - expect(result).toBe(46); + expect(result).toBe(48); }); it('uses textStartPx when provided even in standard mode', () => { @@ -325,11 +325,11 @@ describe('resolveListTextStartPx', () => { // markerStartPos = indentLeft - hanging + firstLine = 36 - 18 + 0 = 18 // currentPos = 18 + 18 = 36 // textStart = indentLeft + firstLine = 36 + 0 = 36 - // tabWidth = textStart - currentPos = 36 - 36 = 0 (falls to gutterWidth) - // Since tabWidth <= 0, use gutter width (LIST_MARKER_GAP = 8px): - // result = markerStartPos + glyphWidth + gutterWidth = 18 + 18 + 8 = 44 + // tabWidth = textStart - currentPos = 36 - 36 = 0 → overflow + // nextDefaultTab = 36 + 48 - (36 % 48) = 48 + // result = 48 const result = resolveListTextStartPx(wordLayout, indentLeft, firstLine, hanging, mockMeasureMarkerText); - expect(result).toBe(44); // 18 + 18 + 8 + expect(result).toBe(48); }); it('uses minimum gutter when currentPos exceeds textStart', () => { @@ -345,11 +345,11 @@ describe('resolveListTextStartPx', () => { // markerStartPos = 36 - 18 + 0 = 18 // currentPos = 18 + 30 = 48 // textStart = 36 + 0 = 36 - // tabWidth = textStart - currentPos = 36 - 48 = -12 (negative, falls to gutterWidth) - // Since tabWidth <= 0, use gutter width (LIST_MARKER_GAP = 8px): - // result = markerStartPos + glyphWidth + gutterWidth = 18 + 30 + 8 = 56 + // tabWidth = textStart - currentPos = 36 - 48 = -12 → overflow + // nextDefaultTab = 48 + 48 - (48 % 48) = 96 + // result = 96 const result = resolveListTextStartPx(wordLayout, indentLeft, firstLine, hanging, mockMeasureMarkerText); - expect(result).toBe(56); // 18 + 30 + 8 + expect(result).toBe(96); }); it('enforces minimum LIST_MARKER_GAP tab width', () => { @@ -370,7 +370,7 @@ describe('resolveListTextStartPx', () => { expect(result).toBe(36); // textStart }); - it('enforces minimum when calculated tab width is too small', () => { + it('uses actual tab width when positive (matches renderer)', () => { const wordLayout: MinimalWordLayout = { marker: { glyphWidthPx: 12, @@ -383,9 +383,9 @@ describe('resolveListTextStartPx', () => { // markerStartPos = 36 - 18 + 0 = 18 // currentPos = 18 + 12 = 30 // textStart = 36 + 0 = 36 - // tabWidth = 36 - 30 = 6 (less than LIST_MARKER_GAP, so enforce minimum) + // tabWidth = 36 - 30 = 6 (positive, used as-is to match renderer) const result = resolveListTextStartPx(wordLayout, indentLeft, firstLine, hanging, mockMeasureMarkerText); - expect(result).toBe(30 + LIST_MARKER_GAP); // 30 + 8 = 38 + expect(result).toBe(36); // text starts at textStart position }); }); @@ -397,10 +397,10 @@ describe('resolveListTextStartPx', () => { // branch in Step 8 (standard hanging mode, lines 318–346). // --------------------------------------------------------------------------- - describe('Step 8: hanging-overflow safeguard (regression guard)', () => { - it('uses LIST_MARKER_GAP when marker overruns hanging space, not DEFAULT_TAB_INTERVAL_PX', () => { - // This is the exact regression condition: marker wider than hanging indent, - // no textStartPx → must NOT fall through to 48px default tab. + describe('Step 8: hanging-overflow advances to next default tab stop', () => { + it('advances to next default tab stop when marker overruns hanging space', () => { + // Marker wider than hanging indent, no textStartPx. + // Must advance to next 48px-aligned tab stop, matching the renderer's computeTabWidth(). const wordLayout: MinimalWordLayout = { marker: { glyphWidthPx: 25, // wider than hanging (18px) @@ -411,14 +411,14 @@ describe('resolveListTextStartPx', () => { const hanging = 18; // markerStartPos = 36 - 18 = 18 // currentPosStandard = 18 + 25 = 43 (past indentLeft 36) - // textStart = 36, tabWidth = 36 - 43 = -7 → hanging overflow → gutterWidth (8) + // textStart = 36, tabWidth = 36 - 43 = -7 → overflow + // nextDefaultTab = 43 + 48 - (43 % 48) = 48 const result = resolveListTextStartPx(wordLayout, indentLeft, 0, hanging, mockMeasureMarkerText); - expect(result).toBe(43 + LIST_MARKER_GAP); // 43 + 8 = 51 - expect(result).not.toBe(43 + DEFAULT_TAB_INTERVAL_PX); // must NOT be 43 + 48 = 91 + expect(result).toBe(48); // advances to next default tab stop }); - it('uses LIST_MARKER_GAP when marker exactly fills hanging space', () => { + it('advances to next default tab stop when marker exactly fills hanging space', () => { // Edge case: marker width === hanging indent → tabWidth = 0 → overflow branch const wordLayout: MinimalWordLayout = { marker: { @@ -430,14 +430,14 @@ describe('resolveListTextStartPx', () => { const hanging = 18; // markerStartPos = 36 - 18 = 18 // currentPosStandard = 18 + 18 = 36 (exactly at indentLeft) - // textStart = 36, tabWidth = 36 - 36 = 0 → overflow branch → gutterWidth (8) + // textStart = 36, tabWidth = 36 - 36 = 0 → overflow + // nextDefaultTab = 36 + 48 - (36 % 48) = 48 const result = resolveListTextStartPx(wordLayout, indentLeft, 0, hanging, mockMeasureMarkerText); - expect(result).toBe(36 + LIST_MARKER_GAP); // 36 + 8 = 44 - expect(result).not.toBe(36 + DEFAULT_TAB_INTERVAL_PX); + expect(result).toBe(48); // advances to next default tab stop }); - it('uses LIST_MARKER_GAP when hanging is zero and no textStartPx', () => { + it('advances to next default tab stop when hanging is zero and no textStartPx', () => { // hanging=0 → markerStartPos = indentLeft, textStart = indentLeft → tabWidth = -markerWidth const wordLayout: MinimalWordLayout = { marker: { @@ -449,11 +449,11 @@ describe('resolveListTextStartPx', () => { const hanging = 0; // markerStartPos = 24 - 0 = 24 // currentPosStandard = 24 + 10 = 34 - // textStart = 24, tabWidth = 24 - 34 = -10 → overflow → gutterWidth (8) + // textStart = 24, tabWidth = 24 - 34 = -10 → overflow + // nextDefaultTab = 34 + 48 - (34 % 48) = 48 const result = resolveListTextStartPx(wordLayout, indentLeft, 0, hanging, mockMeasureMarkerText); - expect(result).toBe(34 + LIST_MARKER_GAP); // 34 + 8 = 42 - expect(result).not.toBe(34 + DEFAULT_TAB_INTERVAL_PX); + expect(result).toBe(48); // advances to next default tab stop }); }); @@ -552,7 +552,7 @@ describe('resolveListTextStartPx', () => { expect(result).toBe(36); // 26 + 10 = 36 }); - it('clamps small positive gap to LIST_MARKER_GAP', () => { + it('uses actual tab width for small positive gap (matches renderer)', () => { const wordLayout: MinimalWordLayout = { marker: { glyphWidthPx: 14, @@ -562,12 +562,10 @@ describe('resolveListTextStartPx', () => { const indentLeft = 36; const hanging = 18; // markerStartPos = 18, currentPosStandard = 18 + 14 = 32 - // textStart = 36, tabWidth = 36 - 32 = 4 (< LIST_MARKER_GAP) - // Clamped to max(4, 8) = 8 + // textStart = 36, tabWidth = 36 - 32 = 4 (used as-is, matching renderer) const result = resolveListTextStartPx(wordLayout, indentLeft, 0, hanging, mockMeasureMarkerText); - expect(result).toBe(32 + LIST_MARKER_GAP); // 32 + 8 = 40 - expect(result).toBeGreaterThanOrEqual(32 + LIST_MARKER_GAP); + expect(result).toBe(36); // text starts at textStart position (indentLeft) }); }); @@ -754,17 +752,17 @@ describe('resolveListTextStartPx', () => { expect(result).toBe(24); // text at indentLeft }); - it('wide marker with no textStartPx gets minimum gap, not 48px jump', () => { + it('wide marker with no textStartPx advances to next default tab stop', () => { const wordLayout: MinimalWordLayout = { marker: { glyphWidthPx: 25, suffix: 'tab' }, // textStartPx intentionally omitted }; // markerStartPos = 24 - 18 = 6, currentPosStandard = 6 + 25 = 31 - // textStart = 24, tabWidth = 24 - 31 = -7 → overflow → gutterWidth (8) + // textStart = 24, tabWidth = 24 - 31 = -7 → overflow + // nextDefaultTab = 31 + 48 - (31 % 48) = 48 const result = resolveListTextStartPx(wordLayout, 24, 0, 18, mockMeasureMarkerText); - expect(result).toBe(31 + LIST_MARKER_GAP); // 39 - expect(result).not.toBe(31 + DEFAULT_TAB_INTERVAL_PX); // not 79 + expect(result).toBe(48); // advances to next default tab stop }); }); }); diff --git a/shared/common/list-marker-utils.ts b/shared/common/list-marker-utils.ts index 5116df2a42..276b9b4ba6 100644 --- a/shared/common/list-marker-utils.ts +++ b/shared/common/list-marker-utils.ts @@ -12,7 +12,7 @@ * - measuring/dom/src/index.ts (full typography measurement) */ -import { LIST_MARKER_GAP, SPACE_SUFFIX_GAP_PX } from './layout-constants.js'; +import { LIST_MARKER_GAP, SPACE_SUFFIX_GAP_PX, DEFAULT_TAB_INTERVAL_PX } from './layout-constants.js'; /** * Minimal marker run formatting information for text measurement. @@ -352,12 +352,15 @@ export function resolveListTextStartPx( const textStart = indentLeft + firstLine; let tabWidth = textStart - currentPosStandard; - // Hanging-overflow safeguard: marker overruns the hanging space, use minimum gutter gap + // Hanging-overflow safeguard: marker overruns the hanging space. + // Advance to the next default tab stop, matching the renderer's computeTabWidth() behavior. + // The renderer advances to the next 48px-aligned position when no explicit tab stop + // is found past the marker. Using LIST_MARKER_GAP instead would create a measurer/renderer + // width mismatch that causes incorrect negative word-spacing on justified lines. if (tabWidth <= 0) { - tabWidth = gutterWidth; - } else if (tabWidth < LIST_MARKER_GAP) { - // Enforce minimum gap (use gutter if larger) - tabWidth = Math.max(tabWidth, gutterWidth); + const nextDefaultTab = + currentPosStandard + DEFAULT_TAB_INTERVAL_PX - (currentPosStandard % DEFAULT_TAB_INTERVAL_PX); + tabWidth = nextDefaultTab - currentPosStandard; } return currentPosStandard + tabWidth; From 6bd7ecfd402b03fe515ea6412d61957777002062 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Mon, 23 Feb 2026 18:09:00 -0800 Subject: [PATCH 6/6] fix(layout-engine): guard previous-measure reuse and align anchored table segment indexing --- .../layout-bridge/src/incrementalLayout.ts | 13 +++- ...entalLayout.previous-measure-reuse.test.ts | 76 +++++++++++++++++++ .../dom/src/table/renderTableCell.test.ts | 64 ++++++++++++++++ .../painters/dom/src/table/renderTableCell.ts | 28 ++++--- 4 files changed, 169 insertions(+), 12 deletions(-) create mode 100644 packages/layout-engine/layout-bridge/test/incrementalLayout.previous-measure-reuse.test.ts diff --git a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts index 89cc9191cb..08e3e268d9 100644 --- a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts +++ b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts @@ -768,9 +768,15 @@ export async function incrementalLayout( hasPreviousMeasures && previousConstraints?.measurementWidth === measurementWidth && previousConstraints?.measurementHeight === measurementHeight; + const previousPerSectionConstraints = canReusePreviousMeasures + ? computePerSectionConstraints(options, previousBlocks) + : null; const previousMeasuresById = canReusePreviousMeasures ? new Map(previousBlocks.map((block, index) => [block.id, previousMeasures![index]])) : null; + const previousConstraintsById = canReusePreviousMeasures + ? new Map(previousBlocks.map((block, index) => [block.id, previousPerSectionConstraints![index]])) + : null; const measureStart = performance.now(); const measures: Measure[] = []; @@ -794,7 +800,12 @@ export async function incrementalLayout( if (canReusePreviousMeasures && dirty.stableBlockIds.has(block.id)) { const previousMeasure = previousMeasuresById?.get(block.id); - if (previousMeasure) { + const previousBlockConstraints = previousConstraintsById?.get(block.id); + if ( + previousMeasure && + previousBlockConstraints?.maxWidth === blockMeasureWidth && + previousBlockConstraints?.maxHeight === blockMeasureHeight + ) { measures.push(previousMeasure); reusedMeasures++; continue; diff --git a/packages/layout-engine/layout-bridge/test/incrementalLayout.previous-measure-reuse.test.ts b/packages/layout-engine/layout-bridge/test/incrementalLayout.previous-measure-reuse.test.ts new file mode 100644 index 0000000000..4256656d9d --- /dev/null +++ b/packages/layout-engine/layout-bridge/test/incrementalLayout.previous-measure-reuse.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FlowBlock, ParagraphMeasure, SectionBreakBlock } from '@superdoc/contracts'; +import { incrementalLayout, measureCache } from '../src/incrementalLayout'; + +const makeParagraph = (id: string, text: string): FlowBlock => ({ + kind: 'paragraph', + id, + runs: [{ text, fontFamily: 'Arial', fontSize: 12 }], +}); + +const makeSectionBreak = (id: string, left: number, right: number): SectionBreakBlock => ({ + kind: 'sectionBreak', + id, + margins: { top: 20, right, bottom: 20, left }, +}); + +describe('incrementalLayout previous-measure reuse', () => { + beforeEach(() => { + measureCache.clear(); + }); + + it('remeasures stable blocks when their section width changes even if global max constraints are unchanged', async () => { + const options = { + pageSize: { w: 300, h: 400 }, + margins: { top: 20, right: 20, bottom: 20, left: 20 }, + columns: { count: 1, gap: 0 }, + }; + + const intro = makeParagraph('intro', 'Intro paragraph'); + const sectionBreakBefore = makeSectionBreak('section-1', 60, 140); // 100px content width + const sectionBreakAfter = makeSectionBreak('section-1', 80, 140); // 80px content width + const body = makeParagraph('body', 'Body paragraph'); + + const previousBlocks: FlowBlock[] = [intro, sectionBreakBefore, body]; + const nextBlocks: FlowBlock[] = [intro, sectionBreakAfter, body]; + + const measureBlock = vi.fn(async (_block: FlowBlock, constraints: { maxWidth: number; maxHeight: number }) => { + return { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 1, + width: constraints.maxWidth, + ascent: 8, + descent: 2, + lineHeight: 10, + }, + ], + totalHeight: 10, + } satisfies ParagraphMeasure; + }); + + const firstPass = await incrementalLayout([], null, previousBlocks, options, measureBlock); + const firstPassBodyMeasure = firstPass.measures[2] as ParagraphMeasure; + expect(firstPassBodyMeasure.lines?.[0]?.width).toBe(100); + + measureBlock.mockClear(); + + const secondPass = await incrementalLayout( + previousBlocks, + firstPass.layout, + nextBlocks, + options, + measureBlock, + undefined, + firstPass.measures, + ); + + const secondPassBodyMeasure = secondPass.measures[2] as ParagraphMeasure; + expect(secondPassBodyMeasure.lines?.[0]?.width).toBe(80); + expect(measureBlock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts index d77b5d92af..c290c04320 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -239,6 +239,70 @@ describe('renderTableCell', () => { expect(imgEl?.parentElement?.style.top).toBe('5px'); }); + it('keeps partial-row segment indexing aligned when anchored blocks are between paragraphs', () => { + const paraBefore: ParagraphBlock = { + kind: 'paragraph', + id: 'para-before-anchor', + runs: [{ text: 'Before', fontFamily: 'Arial', fontSize: 16 }], + }; + + const paraAfter: ParagraphBlock = { + kind: 'paragraph', + id: 'para-after-anchor', + runs: [{ text: 'After', fontFamily: 'Arial', fontSize: 16 }], + }; + + const anchoredImage: ImageBlock = { + kind: 'image', + id: 'img-between', + src: 'data:image/png;base64,AAA', + anchor: { isAnchored: true, alignH: 'left', offsetH: 0, vRelativeFrom: 'paragraph', offsetV: 0 }, + wrap: { type: 'None' }, + attrs: { anchorParagraphId: 'para-before-anchor' }, + }; + + const cellMeasure: TableCellMeasure = { + blocks: [ + paragraphMeasure, + { + kind: 'image' as const, + width: 20, + height: 10, + }, + paragraphMeasure, + ], + width: 120, + height: 60, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }; + + const cell: TableCell = { + id: 'cell-partial-anchored-alignment', + blocks: [paraBefore, anchoredImage, paraAfter], + attrs: {}, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure, + cell, + fromLine: 2, + toLine: 3, + renderLine: (block) => { + const line = doc.createElement('div'); + line.classList.add('segment-alignment-line'); + line.dataset.blockId = (block as ParagraphBlock).id; + return line; + }, + }); + + const renderedLines = Array.from(cellElement.querySelectorAll('.segment-alignment-line')) as HTMLElement[]; + expect(renderedLines).toHaveLength(1); + expect(renderedLines[0]?.dataset.blockId).toBe('para-after-anchor'); + }); + it('adjusts column-relative anchored images by table indent and cell offset', () => { const para: ParagraphBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index b74447ef5f..dd239c3ce4 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -1041,8 +1041,8 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen // 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). - // Non-paragraph blocks (images, drawings) occupy 1 segment each, except anchored - // blocks which are rendered out-of-flow and do not consume segment indices. + // Non-paragraph blocks (images, drawings) occupy 1 segment each when height > 0, + // including anchored blocks (matching getCellLines() in layout-table.ts). const blockLineCounts: number[] = []; for (let i = 0; i < Math.min(blockMeasures.length, cellBlocks.length); i++) { const bm = blockMeasures[i]; @@ -1053,15 +1053,11 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen // Embedded tables: recursively count segments (matches getCellLines expansion) blockLineCounts.push(getEmbeddedTableSegmentCount(bm as TableMeasure)); } else { - // Anchored blocks are rendered out-of-flow — they don't consume segment slots. - // Skip them to stay in sync with the rendering loop which also skips them. - const anchor = (blk as ImageBlock | DrawingBlock)?.anchor; - if (anchor?.isAnchored) { - blockLineCounts.push(0); - } else { - // Non-anchored non-paragraph blocks (image, drawing) occupy 1 segment - blockLineCounts.push(1); - } + // Non-paragraph/non-table blocks (images, drawings) occupy 1 segment when + // their height > 0, matching getCellLines() in layout-table.ts which only + // counts non-paragraph blocks with positive height. + const blockHeight = 'height' in bm ? (bm as { height: number }).height : 0; + blockLineCounts.push(blockHeight > 0 ? 1 : 0); } } const totalLines = blockLineCounts.reduce((a, b) => a + b, 0); @@ -1109,6 +1105,11 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen if (blockMeasure.kind === 'image' && block?.kind === 'image') { if (block.anchor?.isAnchored) { anchoredBlocks.push({ block, measure: blockMeasure as ImageMeasure }); + // Advance cumulative count only when height > 0 to stay aligned with + // getCellLines() which only counts non-paragraph blocks with positive height. + if (blockMeasure.height > 0) { + cumulativeLineCount += 1; + } continue; } @@ -1154,6 +1155,11 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen if (blockMeasure.kind === 'drawing' && block?.kind === 'drawing') { if (block.anchor?.isAnchored) { anchoredBlocks.push({ block, measure: blockMeasure as DrawingMeasure }); + // Advance cumulative count only when height > 0 to stay aligned with + // getCellLines() which only counts non-paragraph blocks with positive height. + if (blockMeasure.height > 0) { + cumulativeLineCount += 1; + } continue; }