From 94d09c096586d2be2cd445a1b55458ea543171d0 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 18 May 2026 11:30:50 -0300 Subject: [PATCH 01/11] refactor(layout-engine): unify nested table cell-slice helpers via contracts Move getCellLines, getEmbeddedRowLines, describeCellRenderBlocks, and the cell-slice cursor/height utilities into @superdoc/contracts so the DOM painter and layout engine share a single source of truth for nested table rendering. Replace the painter's duplicated segment counters and even-split height approximations with calls into the shared module, and extract embedded table fragment assembly into a dedicated embeddedTableFragment helper. --- packages/layout-engine/contracts/src/index.ts | 10 + .../contracts/src/table-cell-slice.test.ts | 80 +++ .../contracts/src/table-cell-slice.ts | 372 +++++++++++++ .../layout-engine/layout-engine/src/index.ts | 3 +- .../layout-engine/src/layout-table.ts | 79 +-- .../layout-engine/src/table-cell-slice.ts | 498 +----------------- .../dom/src/table/embeddedTableFragment.ts | 173 ++++++ .../dom/src/table/renderTableCell.test.ts | 123 ++++- .../painters/dom/src/table/renderTableCell.ts | 246 ++------- 9 files changed, 801 insertions(+), 783 deletions(-) create mode 100644 packages/layout-engine/contracts/src/table-cell-slice.test.ts create mode 100644 packages/layout-engine/contracts/src/table-cell-slice.ts create mode 100644 packages/layout-engine/painters/dom/src/table/embeddedTableFragment.ts diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index df9232099a..770ebf1bf4 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -28,6 +28,16 @@ export { } from './engines/tables.js'; export { effectiveTableCellSpacing } from './table-cell-spacing.js'; +export { + computeCellSliceContentHeight, + computeFullCellContentHeight, + createCellSliceCursor, + describeCellRenderBlocks, + getCellLines, + getEmbeddedRowLines, + type CellRenderBlock, + type CellSliceCursor, +} from './table-cell-slice.js'; // Table column rescaling (moved from layout-engine for cross-stage use) export { rescaleColumnWidths } from './table-column-rescale.js'; diff --git a/packages/layout-engine/contracts/src/table-cell-slice.test.ts b/packages/layout-engine/contracts/src/table-cell-slice.test.ts new file mode 100644 index 0000000000..acf781b47b --- /dev/null +++ b/packages/layout-engine/contracts/src/table-cell-slice.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import type { ParagraphMeasure, TableCellMeasure, TableMeasure } from './index.js'; +import { getCellLines } from './table-cell-slice.js'; + +describe('table cell segment mapping', () => { + const makeParagraph = (lineCount: number): ParagraphMeasure => ({ + kind: 'paragraph', + lines: Array.from({ length: lineCount }, () => ({ + lineHeight: 20, + width: 100, + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 1, + ascent: 14, + descent: 6, + })), + totalHeight: lineCount * 20, + }); + + const makeImage = (height: number) => ({ + kind: 'image' as const, + width: 100, + height, + }); + + it('counts paragraph and positive-height object segments', () => { + const cell: TableCellMeasure = { + blocks: [makeParagraph(2), makeImage(50), makeImage(0), makeParagraph(3)], + width: 200, + height: 150, + }; + + expect(getCellLines(cell)).toHaveLength(6); + }); + + it('falls back to legacy single-paragraph cells', () => { + const cell: TableCellMeasure = { + paragraph: makeParagraph(3), + width: 200, + height: 60, + }; + + expect(getCellLines(cell)).toHaveLength(3); + }); + + it('expands nested table rows recursively', () => { + 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, + }; + const middleTable: TableMeasure = { + kind: 'table', + rows: [{ cells: [{ blocks: [innermostTable], width: 80, height: 100 }], height: 100 }], + columnWidths: [80], + totalWidth: 80, + totalHeight: 100, + }; + 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(getCellLines(cell)).toHaveLength(2); + }); +}); diff --git a/packages/layout-engine/contracts/src/table-cell-slice.ts b/packages/layout-engine/contracts/src/table-cell-slice.ts new file mode 100644 index 0000000000..e30aa2e11b --- /dev/null +++ b/packages/layout-engine/contracts/src/table-cell-slice.ts @@ -0,0 +1,372 @@ +import type { ParagraphMeasure, TableCell, TableCellMeasure, TableMeasure, TableRowMeasure } from './index.js'; +import { effectiveTableCellSpacing } from './table-cell-spacing.js'; + +export type CellRenderBlock = { + kind: 'paragraph' | 'table' | 'other'; + globalStartLine: number; + globalEndLine: number; + lineHeights: number[]; + totalHeight: number; + visibleHeight: number; + isFirstBlock: boolean; + isLastBlock: boolean; + spacingBefore: number; + spacingAfter: number; +}; + +export interface CellSliceCursor { + advanceLine(globalLineIndex: number): number; + minSegmentCost(globalLineIndex: number): number; +} + +export function getEmbeddedRowLines(row: TableRowMeasure): Array<{ lineHeight: number }> { + const hasNestedTable = row.cells.some((cell) => cell.blocks?.some((block) => block.kind === 'table')); + + if (!hasNestedTable) { + return [{ lineHeight: row.height || 0 }]; + } + + 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 }]; +} + +export function getCellLines(cell: TableCellMeasure): Array<{ lineHeight: number }> { + if (cell.blocks && cell.blocks.length > 0) { + const allLines: Array<{ lineHeight: number }> = []; + for (const block of cell.blocks) { + if (block.kind === 'paragraph') { + allLines.push(...((block as ParagraphMeasure).lines ?? [])); + } else if (block.kind === 'table') { + const table = block as TableMeasure; + for (const row of table.rows) { + allLines.push(...getEmbeddedRowLines(row)); + } + } else { + const blockHeight = 'height' in block ? (block as { height: number }).height : 0; + if (blockHeight > 0) { + allLines.push({ lineHeight: blockHeight }); + } + } + } + return allLines; + } + + return cell.paragraph?.lines ?? []; +} + +export function describeCellRenderBlocks( + cellMeasure: TableCellMeasure, + cellBlock: TableCell | undefined, + cellPadding: { top: number; bottom: number }, +): CellRenderBlock[] { + const measuredBlocks = cellMeasure.blocks; + const blockDataArray = cellBlock?.blocks; + + if (!measuredBlocks || measuredBlocks.length === 0) { + if (cellMeasure.paragraph) { + return buildSingleParagraphBlock(cellMeasure.paragraph, cellBlock?.paragraph, cellPadding); + } + return []; + } + + const result: CellRenderBlock[] = []; + let globalLine = 0; + + for (let i = 0; i < measuredBlocks.length; i += 1) { + const measure = measuredBlocks[i]; + const data = i < (blockDataArray?.length ?? 0) ? blockDataArray![i] : undefined; + const isFirstBlock = i === 0; + const isLastBlock = i === measuredBlocks.length - 1; + + if (measure.kind === 'paragraph') { + const paraMeasure = measure as ParagraphMeasure; + const paraData = data?.kind === 'paragraph' ? data : undefined; + const lineHeights = (paraMeasure.lines ?? []).map((line) => line.lineHeight); + const sumLines = sumArray(lineHeights); + const startLine = globalLine; + globalLine += lineHeights.length; + + result.push({ + kind: 'paragraph', + globalStartLine: startLine, + globalEndLine: globalLine, + lineHeights, + totalHeight: paraMeasure.totalHeight ?? sumLines, + visibleHeight: sumLines, + isFirstBlock, + isLastBlock, + spacingBefore: effectiveTableCellSpacing(paraData?.attrs?.spacing?.before, isFirstBlock, cellPadding.top), + spacingAfter: resolveSpacingAfter(paraData?.attrs?.spacing?.after, isLastBlock), + }); + } else if (measure.kind === 'table') { + const tableMeasure = measure as TableMeasure; + const lineHeights: number[] = []; + for (const row of tableMeasure.rows) { + for (const segment of getEmbeddedRowLines(row)) { + lineHeights.push(segment.lineHeight); + } + } + + const startLine = globalLine; + globalLine += lineHeights.length; + const sumLines = sumArray(lineHeights); + + result.push({ + kind: 'table', + globalStartLine: startLine, + globalEndLine: globalLine, + lineHeights, + totalHeight: sumLines, + visibleHeight: sumLines, + isFirstBlock, + isLastBlock, + spacingBefore: 0, + spacingAfter: 0, + }); + } else { + const blockHeight = 'height' in measure ? (measure as { height: number }).height : 0; + if (blockHeight <= 0) continue; + + const outOfFlow = isAnchoredOutOfFlow(data); + const startLine = globalLine; + globalLine += 1; + + result.push({ + kind: 'other', + globalStartLine: startLine, + globalEndLine: globalLine, + lineHeights: [blockHeight], + totalHeight: outOfFlow ? 0 : blockHeight, + visibleHeight: outOfFlow ? 0 : blockHeight, + isFirstBlock, + isLastBlock, + spacingBefore: 0, + spacingAfter: 0, + }); + } + } + + return result; +} + +export function computeCellSliceContentHeight(blocks: CellRenderBlock[], fromLine: number, toLine: number): number { + let height = 0; + + for (const block of blocks) { + if (block.globalEndLine <= fromLine || block.globalStartLine >= toLine) continue; + + const localStart = Math.max(0, fromLine - block.globalStartLine); + const localEnd = Math.min(block.lineHeights.length, toLine - block.globalStartLine); + const rendersEntireBlock = localStart === 0 && localEnd >= block.lineHeights.length; + + if (block.kind === 'paragraph') { + if (localStart === 0) { + height += block.spacingBefore; + } + + const sliceLineSum = sumArray(block.lineHeights.slice(localStart, localEnd)); + if (rendersEntireBlock) { + height += Math.max(sliceLineSum, block.totalHeight); + height += block.spacingAfter; + } else { + height += sliceLineSum; + } + } else if (block.visibleHeight > 0) { + for (let i = localStart; i < localEnd; i += 1) { + height += block.lineHeights[i] ?? 0; + } + } + } + + return height; +} + +export function createCellSliceCursor(blocks: CellRenderBlock[], startLine: number): CellSliceCursor { + let blockIdx = 0; + let startedFromLine0 = false; + let blockLineSum = 0; + + while (blockIdx < blocks.length && blocks[blockIdx].globalEndLine <= startLine) { + blockIdx += 1; + } + if (blockIdx < blocks.length) { + const block = blocks[blockIdx]; + startedFromLine0 = startLine <= block.globalStartLine; + if (!startedFromLine0) { + for (let li = 0; li < startLine - block.globalStartLine; li += 1) { + blockLineSum += block.lineHeights[li] ?? 0; + } + } + } + + return { + advanceLine(globalLineIndex: number): number { + while (blockIdx < blocks.length && blocks[blockIdx].globalEndLine <= globalLineIndex) { + blockIdx += 1; + startedFromLine0 = true; + blockLineSum = 0; + } + if (blockIdx >= blocks.length) return 0; + + const block = blocks[blockIdx]; + const localLine = globalLineIndex - block.globalStartLine; + const lineHeight = block.lineHeights[localLine] ?? 0; + let cost = 0; + + if (localLine === 0 && startedFromLine0 && block.kind === 'paragraph') { + cost += block.spacingBefore; + } + if (block.kind === 'paragraph' || block.visibleHeight > 0) { + cost += lineHeight; + } + + blockLineSum += lineHeight; + + const isBlockComplete = localLine === block.lineHeights.length - 1; + if (isBlockComplete && startedFromLine0 && block.kind === 'paragraph') { + cost += Math.max(0, block.totalHeight - blockLineSum); + cost += block.spacingAfter; + } + if (isBlockComplete) { + blockIdx += 1; + startedFromLine0 = true; + blockLineSum = 0; + } + + return cost; + }, + + minSegmentCost(globalLineIndex: number): number { + const block = findBlockForLine(blocks, globalLineIndex); + if (!block) return 0; + + const localLine = globalLineIndex - block.globalStartLine; + const lineHeight = block.lineHeights[localLine] ?? 0; + let cost = 0; + + if (localLine === 0 && block.kind === 'paragraph') { + cost += block.spacingBefore; + } + if (block.kind === 'paragraph' || block.visibleHeight > 0) { + cost += lineHeight; + } + if (block.lineHeights.length === 1 && block.kind === 'paragraph') { + cost += Math.max(0, block.totalHeight - lineHeight); + cost += block.spacingAfter; + } + + return cost; + }, + }; +} + +export function computeFullCellContentHeight( + cellMeasure: TableCellMeasure, + cellBlock: TableCell | undefined, + cellPadding: { top: number; bottom: number }, +): number { + const measuredBlocks = cellMeasure.blocks; + const blockDataArray = cellBlock?.blocks; + + if (!measuredBlocks || measuredBlocks.length === 0) { + if (!cellMeasure.paragraph) return 0; + + const lineSum = sumArray(cellMeasure.paragraph.lines.map((line) => line.lineHeight)); + const paraData = cellBlock?.paragraph; + const spacingBefore = effectiveTableCellSpacing(paraData?.attrs?.spacing?.before, true, cellPadding.top); + const spacingAfter = effectiveTableCellSpacing(paraData?.attrs?.spacing?.after, true, cellPadding.bottom); + return spacingBefore + Math.max(lineSum, cellMeasure.paragraph.totalHeight ?? lineSum) + spacingAfter; + } + + let height = 0; + for (let i = 0; i < measuredBlocks.length; i += 1) { + const measure = measuredBlocks[i]; + const data = i < (blockDataArray?.length ?? 0) ? blockDataArray![i] : undefined; + const isFirstBlock = i === 0; + const isLastBlock = i === measuredBlocks.length - 1; + + if (measure.kind === 'paragraph') { + const paraMeasure = measure as ParagraphMeasure; + const paraData = data?.kind === 'paragraph' ? data : undefined; + const lineSum = sumArray((paraMeasure.lines ?? []).map((line) => line.lineHeight)); + + height += effectiveTableCellSpacing(paraData?.attrs?.spacing?.before, isFirstBlock, cellPadding.top); + height += Math.max(lineSum, paraMeasure.totalHeight ?? lineSum); + if (isLastBlock) { + height += effectiveTableCellSpacing(paraData?.attrs?.spacing?.after, true, cellPadding.bottom); + } else { + height += resolveSpacingAfter(paraData?.attrs?.spacing?.after, false); + } + } else if (measure.kind === 'table') { + const table = measure as TableMeasure; + for (const row of table.rows) { + height += row.height; + } + } else { + const blockHeight = 'height' in measure ? (measure as { height: number }).height : 0; + if (blockHeight > 0 && !isAnchoredOutOfFlow(data)) { + height += blockHeight; + } + } + } + + return height; +} + +function buildSingleParagraphBlock( + paraMeasure: ParagraphMeasure, + paraData: { attrs?: { spacing?: { before?: number; after?: number } } } | undefined, + cellPadding: { top: number; bottom: number }, +): CellRenderBlock[] { + const lines = paraMeasure.lines ?? []; + if (lines.length === 0) return []; + + const lineHeights = lines.map((line) => line.lineHeight); + const sumLines = sumArray(lineHeights); + + return [ + { + kind: 'paragraph', + globalStartLine: 0, + globalEndLine: lines.length, + lineHeights, + totalHeight: paraMeasure.totalHeight ?? sumLines, + visibleHeight: sumLines, + isFirstBlock: true, + isLastBlock: true, + spacingBefore: effectiveTableCellSpacing(paraData?.attrs?.spacing?.before, true, cellPadding.top), + spacingAfter: 0, + }, + ]; +} + +function resolveSpacingAfter(spacingAfter: number | undefined, isLastBlock: boolean): number { + if (isLastBlock) return 0; + return typeof spacingAfter === 'number' && spacingAfter > 0 ? spacingAfter : 0; +} + +function isAnchoredOutOfFlow(block: unknown): boolean { + if (!block || typeof block !== 'object') return false; + const b = block as Record; + const anchor = b.anchor as Record | undefined; + if (!anchor?.isAnchored) return false; + const wrap = b.wrap as Record | undefined; + return (wrap?.type ?? 'Inline') !== 'Inline'; +} + +function findBlockForLine(blocks: CellRenderBlock[], globalLineIndex: number): CellRenderBlock | undefined { + return blocks.find((block) => globalLineIndex >= block.globalStartLine && globalLineIndex < block.globalEndLine); +} + +function sumArray(arr: number[]): number { + let total = 0; + for (const value of arr) total += value; + return total; +} diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index c6d5e91903..00a59ba0b8 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -3399,7 +3399,8 @@ export { resolvePageNumberTokens } from './resolvePageTokens.js'; export type { NumberingContext, ResolvePageTokensResult } from './resolvePageTokens.js'; // Table utilities consumed by layout-bridge and cross-package sync tests -export { getCellLines, getEmbeddedRowLines, resolveTableFrame, resolveRenderedTableWidth } from './layout-table.js'; +export { resolveTableFrame, resolveRenderedTableWidth } from './layout-table.js'; export { describeCellRenderBlocks, computeCellSliceContentHeight } from './table-cell-slice.js'; +export { getCellLines, getEmbeddedRowLines } from '@superdoc/contracts'; export { SINGLE_COLUMN_DEFAULT } from './section-breaks.js'; diff --git a/packages/layout-engine/layout-engine/src/layout-table.ts b/packages/layout-engine/layout-engine/src/layout-table.ts index f4933438a7..fe18b0dae2 100644 --- a/packages/layout-engine/layout-engine/src/layout-table.ts +++ b/packages/layout-engine/layout-engine/src/layout-table.ts @@ -11,10 +11,14 @@ import type { ParagraphMeasure, ParagraphBlock, } from '@superdoc/contracts'; -import { OOXML_PCT_DIVISOR, rescaleColumnWidths, resolveTableWidthAttr } from '@superdoc/contracts'; +import { OOXML_PCT_DIVISOR, rescaleColumnWidths, resolveTableWidthAttr, + describeCellRenderBlocks, + createCellSliceCursor, + computeFullCellContentHeight, + getCellLines, + getEmbeddedRowLines } from '@superdoc/contracts'; import type { PageState } from './paginator.js'; import { computeFragmentPmRange, extractBlockPmRange } from './layout-utils.js'; -import { describeCellRenderBlocks, createCellSliceCursor, computeFullCellContentHeight } from './table-cell-slice.js'; /** * Ratio of column width (0..1). An anchored table with totalWidth >= columnWidth * this value @@ -473,77 +477,6 @@ type SplitPointResult = { */ const MIN_PARTIAL_ROW_HEIGHT = 20; -/** - * 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. - */ -export function getEmbeddedRowLines(row: TableRowMeasure): Array<{ lineHeight: number }> { - // Check if any cell has nested table blocks - const hasNestedTable = row.cells.some((cell) => cell.blocks?.some((b) => b.kind === 'table')); - - 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 }]; -} - -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 }> = []; - for (const block of cell.blocks) { - if (block.kind === 'paragraph') { - 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; - } - - // Fallback to single paragraph (backward compatibility) - if (cell.paragraph?.lines) { - return cell.paragraph.lines; - } - - return []; -} - type CellPadding = { top: number; bottom: number; left: number; right: number }; function getCellPadding(cellIdx: number, blockRow?: TableRow): CellPadding { diff --git a/packages/layout-engine/layout-engine/src/table-cell-slice.ts b/packages/layout-engine/layout-engine/src/table-cell-slice.ts index c084e4ed26..9772a36e70 100644 --- a/packages/layout-engine/layout-engine/src/table-cell-slice.ts +++ b/packages/layout-engine/layout-engine/src/table-cell-slice.ts @@ -1,488 +1,10 @@ -/** - * Shared cell-slice-height module for table pagination. - * - * Provides a single source of truth for computing rendered cell-slice heights - * that match the DOM painter's actual rendering semantics (spacing.before, - * totalHeight promotion, spacing.after). Used by: - * - * - `computePartialRow()` — fitting loop via incremental cursor (Layer 2) - * - `getRowContentHeight()` — one-shot full-row height (Layer 1) - * - `layout-bridge` — selection-rect vertical positioning (Layer 1) - * - * Lives in `@superdoc/layout-engine` because it depends on layout-engine - * internals (`getEmbeddedRowLines`) that are not part of the contract surface. - */ - -import type { TableCellMeasure, TableCell, ParagraphMeasure, TableMeasure, TableRowMeasure } from '@superdoc/contracts'; -import { effectiveTableCellSpacing } from '@superdoc/contracts'; -import { getEmbeddedRowLines } from './layout-table.js'; - -// ─── Types ─────────────────────────────────────────────────────────────────── - -/** - * Describes one block in a table cell with renderer-semantic height values. - * - * Maps each measured block to its global line index range and the spacing / - * height values the DOM painter actually applies. Layout decisions that use - * these descriptors stay synchronized with `renderTableCell.ts`. - */ -export type CellRenderBlock = { - kind: 'paragraph' | 'table' | 'other'; - /** First global line index (inclusive). */ - globalStartLine: number; - /** Past-the-end global line index (exclusive). */ - globalEndLine: number; - /** Per-segment heights matching `getCellLines()` output. */ - lineHeights: number[]; - /** `ParagraphMeasure.totalHeight ?? sum(lineHeights)`. */ - totalHeight: number; - /** Height contributing to content flow. 0 for anchored out-of-flow blocks. */ - visibleHeight: number; - isFirstBlock: boolean; - isLastBlock: boolean; - /** Effective spacing.before (first block: excess over padding.top; others: full). */ - spacingBefore: number; - /** Raw spacing.after; always 0 for last block (renderer skips it). */ - spacingAfter: number; -}; - -/** - * Stateful cursor for the `computePartialRow()` fitting loop. - * - * Advances one line at a time and reports the rendered cost of each line - * including block-boundary spacing and totalHeight promotion. O(1) per step. - */ -export interface CellSliceCursor { - /** - * Compute the rendered cost of including the line at `globalLineIndex`. - * Advances internal state — call exactly once per line, in ascending order. - * After calling, if the line doesn't fit, break; the cursor state no longer - * matters since it won't be used again for this cell. - */ - advanceLine(globalLineIndex: number): number; - - /** - * Minimum rendered cost of the segment at `globalLineIndex`, for - * force-progress checks. Pure peek — does not modify cursor state. - */ - minSegmentCost(globalLineIndex: number): number; -} - -// ─── Builder ───────────────────────────────────────────────────────────────── - -/** - * Build an ordered array of block descriptors from a cell's measurement and - * block data. Descriptors carry all renderer-semantic information needed by - * `computeCellSliceContentHeight` and the fitting cursor. - * - * **Iteration rule**: driven by measured blocks (source of truth for line - * counts). Block data is attached by index when available; missing data - * degrades to zero spacing and `totalHeight = sum(lineHeights)`. - */ -export function describeCellRenderBlocks( - cellMeasure: TableCellMeasure, - cellBlock: TableCell | undefined, - cellPadding: { top: number; bottom: number }, -): CellRenderBlock[] { - const measuredBlocks = cellMeasure.blocks; - const blockDataArray = cellBlock?.blocks; - - // Backward-compat: single-paragraph cells - if (!measuredBlocks || measuredBlocks.length === 0) { - if (cellMeasure.paragraph) { - return buildSingleParagraphBlock(cellMeasure.paragraph, cellBlock?.paragraph, cellPadding); - } - return []; - } - - const result: CellRenderBlock[] = []; - let globalLine = 0; - const blockCount = measuredBlocks.length; - - for (let i = 0; i < blockCount; i++) { - const measure = measuredBlocks[i]; - const data = i < (blockDataArray?.length ?? 0) ? blockDataArray![i] : undefined; - const isFirstBlock = i === 0; - const isLastBlock = i === blockCount - 1; - - if (measure.kind === 'paragraph') { - const paraMeasure = measure as ParagraphMeasure; - const paraData = data?.kind === 'paragraph' ? data : undefined; - - const lines = paraMeasure.lines ?? []; - const lineHeights = lines.map((l) => l.lineHeight); - const sumLines = sumArray(lineHeights); - - const spacingBefore = effectiveTableCellSpacing(paraData?.attrs?.spacing?.before, isFirstBlock, cellPadding.top); - const rawAfter = paraData?.attrs?.spacing?.after; - const spacingAfter = isLastBlock ? 0 : typeof rawAfter === 'number' && rawAfter > 0 ? rawAfter : 0; - - const startLine = globalLine; - globalLine += lines.length; - - result.push({ - kind: 'paragraph', - globalStartLine: startLine, - globalEndLine: globalLine, - lineHeights, - totalHeight: paraMeasure.totalHeight ?? sumLines, - visibleHeight: sumLines, - isFirstBlock, - isLastBlock, - spacingBefore, - spacingAfter, - }); - } else if (measure.kind === 'table') { - // Embedded table — expand rows the same way getCellLines() does - const tableMeasure = measure as TableMeasure; - const lineHeights: number[] = []; - for (const row of tableMeasure.rows) { - for (const seg of getEmbeddedRowLines(row)) { - lineHeights.push(seg.lineHeight); - } - } - - const startLine = globalLine; - globalLine += lineHeights.length; - const sumLines = sumArray(lineHeights); - - result.push({ - kind: 'table', - globalStartLine: startLine, - globalEndLine: globalLine, - lineHeights, - totalHeight: sumLines, - visibleHeight: sumLines, - isFirstBlock, - isLastBlock, - spacingBefore: 0, - spacingAfter: 0, - }); - } else { - // Image, drawing, or other non-paragraph block. - // getCellLines() only adds a segment when height > 0. - const blockHeight = 'height' in measure ? (measure as { height: number }).height : 0; - if (blockHeight > 0) { - const outOfFlow = isAnchoredOutOfFlow(data); - const startLine = globalLine; - globalLine += 1; - - result.push({ - kind: 'other', - globalStartLine: startLine, - globalEndLine: globalLine, - lineHeights: [blockHeight], - totalHeight: outOfFlow ? 0 : blockHeight, - visibleHeight: outOfFlow ? 0 : blockHeight, - isFirstBlock, - isLastBlock, - spacingBefore: 0, - spacingAfter: 0, - }); - } - // height === 0 → getCellLines() skips it, no line index consumed - } - } - - return result; -} - -// ─── Layer 1: Pure full-slice function ─────────────────────────────────────── - -/** - * Content-area height of a cell slice `[fromLine, toLine)`. - * - * Matches the DOM painter's rendering semantics: - * - `spacing.before` when rendering from the start of a block - * - `totalHeight` promotion for fully rendered paragraphs - * - `spacing.after` for fully rendered non-last paragraphs - * - * Returns content height only — cell padding is NOT included. - * O(blocks) per call. - */ -export function computeCellSliceContentHeight(blocks: CellRenderBlock[], fromLine: number, toLine: number): number { - let height = 0; - - for (const block of blocks) { - if (block.globalEndLine <= fromLine || block.globalStartLine >= toLine) continue; - - const localStart = Math.max(0, fromLine - block.globalStartLine); - const localEnd = Math.min(block.lineHeights.length, toLine - block.globalStartLine); - const rendersEntireBlock = localStart === 0 && localEnd >= block.lineHeights.length; - - if (block.kind === 'paragraph') { - // spacing.before when rendering from line 0 — matches renderTableCell.ts:1386-1394 - if (localStart === 0) { - height += block.spacingBefore; - } - - let sliceLineSum = 0; - for (let i = localStart; i < localEnd; i++) { - sliceLineSum += block.lineHeights[i]; - } - - if (rendersEntireBlock) { - // Promote to totalHeight — matches renderTableCell.ts:1478-1482 - height += Math.max(sliceLineSum, block.totalHeight); - // spacing.after for non-last blocks — matches renderTableCell.ts:1492-1500 - // (block.spacingAfter is already 0 for the last block) - height += block.spacingAfter; - } else { - height += sliceLineSum; - } - } else { - // Table / other blocks — contribute overlapped visible heights - if (block.visibleHeight === 0) continue; // anchored out-of-flow - for (let i = localStart; i < localEnd; i++) { - height += block.lineHeights[i]; - } - } - } - - return height; -} - -// ─── Layer 2: Incremental cursor ───────────────────────────────────────────── - -/** - * Create a stateful cursor for the `computePartialRow()` fitting loop. - * - * The cursor tracks block boundaries and accumulates spacing / promotion costs - * so that each `advanceLine()` call is O(1). If the fitting loop starts from a - * continuation (mid-block), the cursor correctly skips spacing.before and - * totalHeight promotion for the partially consumed block. - */ -export function createCellSliceCursor(blocks: CellRenderBlock[], startLine: number): CellSliceCursor { - let blockIdx = 0; - let startedFromLine0 = false; - let blockLineSum = 0; - - // Advance to the block containing startLine - while (blockIdx < blocks.length && blocks[blockIdx].globalEndLine <= startLine) { - blockIdx++; - } - if (blockIdx < blocks.length) { - const block = blocks[blockIdx]; - startedFromLine0 = startLine <= block.globalStartLine; - // Pre-accumulate line heights for lines already consumed in this block - if (!startedFromLine0) { - for (let li = 0; li < startLine - block.globalStartLine; li++) { - blockLineSum += block.lineHeights[li] ?? 0; - } - } - } - - return { - advanceLine(globalLineIndex: number): number { - // Handle block transitions - while (blockIdx < blocks.length && blocks[blockIdx].globalEndLine <= globalLineIndex) { - blockIdx++; - startedFromLine0 = true; - blockLineSum = 0; - } - if (blockIdx >= blocks.length) return 0; - - const block = blocks[blockIdx]; - const localLine = globalLineIndex - block.globalStartLine; - const lineHeight = block.lineHeights[localLine] ?? 0; - let cost = 0; - - // spacing.before when entering a paragraph block at its first line - if (localLine === 0 && startedFromLine0 && block.kind === 'paragraph') { - cost += block.spacingBefore; - } - - // Line's visible contribution - if (block.kind === 'paragraph' || block.visibleHeight > 0) { - cost += lineHeight; - } - - // Track line height within the block (before block-completion check) - blockLineSum += lineHeight; - - // Block completion: totalHeight promotion + spacingAfter - const isBlockComplete = localLine === block.lineHeights.length - 1; - if (isBlockComplete && startedFromLine0 && block.kind === 'paragraph') { - cost += Math.max(0, block.totalHeight - blockLineSum); - cost += block.spacingAfter; - } - - // Advance to next block if this one is complete - if (isBlockComplete) { - blockIdx++; - startedFromLine0 = true; - blockLineSum = 0; - } - - return cost; - }, - - minSegmentCost(globalLineIndex: number): number { - // Pure peek — does not modify cursor state - const block = findBlockForLine(blocks, globalLineIndex); - if (!block) return 0; - - const localLine = globalLineIndex - block.globalStartLine; - const lineHeight = block.lineHeights[localLine] ?? 0; - let cost = 0; - - // Include spacing.before if this is the first line of a paragraph block - if (localLine === 0 && block.kind === 'paragraph') { - cost += block.spacingBefore; - } - - // Include visible line height - if (block.kind === 'paragraph' || block.visibleHeight > 0) { - cost += lineHeight; - } - - // For single-line blocks, include completion costs - if (block.lineHeights.length === 1 && block.kind === 'paragraph') { - cost += Math.max(0, block.totalHeight - lineHeight); - cost += block.spacingAfter; - } - - return cost; - }, - }; -} - -// ─── Hot-path: allocation-free full-cell height ────────────────────────────── - -/** - * Content height of a fully rendered cell, using **measurement** semantics. - * - * Unlike `describeCellRenderBlocks` + `computeCellSliceContentHeight` (which - * use renderer semantics and skip last-block spacing.after), this function - * includes last-block spacing.after via `effectiveTableCellSpacing` to match - * how `rowMeasure.height` was computed by the measurer. This keeps - * `getRowContentHeight()` aligned with `rowMeasure.height` so that - * `hasExplicitRowHeightSlack()` compares like-for-like. - * - * Computes in a single pass without allocating intermediate arrays. - * Returns content height only — cell padding is NOT included. - */ -export function computeFullCellContentHeight( - cellMeasure: TableCellMeasure, - cellBlock: TableCell | undefined, - cellPadding: { top: number; bottom: number }, -): number { - const measuredBlocks = cellMeasure.blocks; - const blockDataArray = cellBlock?.blocks; - - // Single paragraph fallback (first + last block) - if (!measuredBlocks || measuredBlocks.length === 0) { - if (cellMeasure.paragraph) { - const pm = cellMeasure.paragraph; - let sumLines = 0; - for (const l of pm.lines) sumLines += l.lineHeight; - const paraData = cellBlock?.paragraph; - const spacingBefore = effectiveTableCellSpacing(paraData?.attrs?.spacing?.before, true, cellPadding.top); - // Measurement semantics: last-block spacing.after is absorbed into - // paddingBottom, but excess still contributes to measured height. - const spacingAfter = effectiveTableCellSpacing(paraData?.attrs?.spacing?.after, true, cellPadding.bottom); - return spacingBefore + Math.max(sumLines, pm.totalHeight ?? sumLines) + spacingAfter; - } - return 0; - } - - let height = 0; - const blockCount = measuredBlocks.length; - - for (let i = 0; i < blockCount; i++) { - const measure = measuredBlocks[i]; - const data = i < (blockDataArray?.length ?? 0) ? blockDataArray![i] : undefined; - const isFirstBlock = i === 0; - const isLastBlock = i === blockCount - 1; - - if (measure.kind === 'paragraph') { - const pm = measure as ParagraphMeasure; - const paraData = data?.kind === 'paragraph' ? data : undefined; - let sumLines = 0; - for (const l of pm.lines ?? []) sumLines += l.lineHeight; - - height += effectiveTableCellSpacing(paraData?.attrs?.spacing?.before, isFirstBlock, cellPadding.top); - height += Math.max(sumLines, pm.totalHeight ?? sumLines); - if (!isLastBlock) { - const rawAfter = paraData?.attrs?.spacing?.after; - if (typeof rawAfter === 'number' && rawAfter > 0) height += rawAfter; - } else { - // Measurement semantics: last-block spacing.after is absorbed into - // paddingBottom, but excess still contributes to measured height. - // This keeps getRowContentHeight aligned with rowMeasure.height. - height += effectiveTableCellSpacing(paraData?.attrs?.spacing?.after, true, cellPadding.bottom); - } - } else if (measure.kind === 'table') { - // Sum row heights directly — avoids getEmbeddedRowLines() expansion. - // For a fully rendered table this equals the sum of all segments. - const tm = measure as TableMeasure; - for (const row of tm.rows) height += row.height; - } else { - // Image, drawing: contribute height only when inline (not anchored out-of-flow) - const blockHeight = 'height' in measure ? (measure as { height: number }).height : 0; - if (blockHeight > 0 && !isAnchoredOutOfFlow(data)) { - height += blockHeight; - } - } - } - - return height; -} - -// ─── Private helpers ───────────────────────────────────────────────────────── - -function buildSingleParagraphBlock( - paraMeasure: ParagraphMeasure, - paraData: { attrs?: { spacing?: { before?: number; after?: number } } } | undefined, - cellPadding: { top: number; bottom: number }, -): CellRenderBlock[] { - const lines = paraMeasure.lines ?? []; - if (lines.length === 0) return []; - - const lineHeights = lines.map((l) => l.lineHeight); - const sumLines = sumArray(lineHeights); - - return [ - { - kind: 'paragraph', - globalStartLine: 0, - globalEndLine: lines.length, - lineHeights, - totalHeight: paraMeasure.totalHeight ?? sumLines, - visibleHeight: sumLines, - isFirstBlock: true, - isLastBlock: true, - spacingBefore: effectiveTableCellSpacing(paraData?.attrs?.spacing?.before, true, cellPadding.top), - spacingAfter: 0, // Last block → renderer skips spacing.after - }, - ]; -} - -/** - * Detect anchored out-of-flow blocks (images/drawings positioned outside - * the normal content flow). These consume a line index in `getCellLines()` - * but contribute zero visible height in the renderer. - */ -function isAnchoredOutOfFlow(block: unknown): boolean { - if (!block || typeof block !== 'object') return false; - const b = block as Record; - const anchor = b.anchor as Record | undefined; - if (!anchor?.isAnchored) return false; - const wrap = b.wrap as Record | undefined; - return (wrap?.type ?? 'Inline') !== 'Inline'; -} - -function findBlockForLine(blocks: CellRenderBlock[], globalLineIndex: number): CellRenderBlock | undefined { - for (const block of blocks) { - if (globalLineIndex >= block.globalStartLine && globalLineIndex < block.globalEndLine) { - return block; - } - } - return undefined; -} - -function sumArray(arr: number[]): number { - let total = 0; - for (const v of arr) total += v; - return total; -} +export { + computeCellSliceContentHeight, + computeFullCellContentHeight, + createCellSliceCursor, + describeCellRenderBlocks, + getCellLines, + getEmbeddedRowLines, + type CellRenderBlock, + type CellSliceCursor, +} from '@superdoc/contracts'; diff --git a/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.ts b/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.ts new file mode 100644 index 0000000000..02607decde --- /dev/null +++ b/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.ts @@ -0,0 +1,173 @@ +import type { PartialRowInfo, TableBlock, TableMeasure, TableRow } from '@superdoc/contracts'; +import { + computeCellSliceContentHeight, + describeCellRenderBlocks, + getCellLines, + getCellSpacingPx, + getEmbeddedRowLines, + rescaleColumnWidths, +} from '@superdoc/contracts'; + +type RowSliceResult = { + fromRow: number; + toRow: number; + partialRow?: PartialRowInfo; +}; + +export function getEmbeddedTableSegmentCount(measure: TableMeasure): number { + let total = 0; + for (const row of measure.rows) { + total += getEmbeddedRowLines(row).length; + } + return total; +} + +export function computeRenderedTableFragmentHeight(params: { + block: TableBlock; + measure: TableMeasure; + fromRow: number; + toRow: number; + partialRow?: PartialRowInfo; + repeatHeaderCount?: number; +}): number { + const { block, measure, fromRow, toRow, partialRow, repeatHeaderCount = 0 } = params; + let height = 0; + let rowCount = 0; + + for (let r = 0; r < repeatHeaderCount && r < measure.rows.length; r += 1) { + height += measure.rows[r].height; + rowCount += 1; + } + + for (let r = fromRow; r < toRow && r < measure.rows.length; r += 1) { + height += partialRow?.rowIndex === r ? partialRow.partialHeight : measure.rows[r].height; + rowCount += 1; + } + + const cellSpacingPx = measure.cellSpacingPx ?? getCellSpacingPx(block.attrs?.cellSpacing); + if (rowCount > 0 && cellSpacingPx > 0) { + height += (rowCount + 1) * cellSpacingPx; + } + + const borderCollapse = block.attrs?.borderCollapse ?? (block.attrs?.cellSpacing != null ? 'separate' : 'collapse'); + if (rowCount > 0 && borderCollapse === 'separate' && measure.tableBorderWidths) { + height += measure.tableBorderWidths.top + measure.tableBorderWidths.bottom; + } + + return height; +} + +export function createEmbeddedTableFragment(params: { + block: TableBlock; + measure: TableMeasure; + availableWidth: number; + fromRow?: number; + toRow?: number; + partialRow?: PartialRowInfo; +}) { + const { block, measure, availableWidth, fromRow = 0, toRow = block.rows.length, partialRow } = params; + const columnWidths = rescaleColumnWidths(measure.columnWidths, measure.totalWidth, availableWidth); + const fragmentWidth = columnWidths ? availableWidth : measure.totalWidth; + const height = computeRenderedTableFragmentHeight({ block, measure, fromRow, toRow, partialRow }); + + return { + fragment: { + kind: 'table' as const, + blockId: block.id, + fromRow, + toRow, + x: 0, + y: 0, + width: fragmentWidth, + height, + columnWidths, + partialRow, + }, + effectiveColumnWidths: columnWidths ?? measure.columnWidths, + cellSpacingPx: measure.cellSpacingPx ?? getCellSpacingPx(block.attrs?.cellSpacing), + }; +} + +export function mapEmbeddedTableRowSlice(params: { + block: TableBlock; + measure: TableMeasure; + localFrom: number; + localTo: number; +}): RowSliceResult | null { + const { block, measure, localFrom, localTo } = params; + let segmentOffset = 0; + let fromRow = -1; + let toRow = -1; + let partialRow: PartialRowInfo | undefined; + + for (let r = 0; r < measure.rows.length; r += 1) { + const rowSegmentCount = getEmbeddedRowLines(measure.rows[r]).length; + const rowStart = segmentOffset; + const rowEnd = segmentOffset + rowSegmentCount; + segmentOffset = rowEnd; + + if (rowEnd <= localFrom || rowStart >= localTo) continue; + + if (fromRow === -1) fromRow = r; + toRow = r + 1; + + if (rowSegmentCount > 1 && (rowStart < localFrom || rowEnd > localTo)) { + partialRow = buildPartialRowInfo({ + blockRow: block.rows[r], + row: measure.rows[r], + rowIndex: r, + rowLocalFrom: Math.max(0, localFrom - rowStart), + rowLocalTo: Math.min(rowSegmentCount, localTo - rowStart), + }); + } + } + + if (fromRow === -1) return null; + return { fromRow, toRow, partialRow }; +} + +function buildPartialRowInfo(params: { + blockRow: TableRow | undefined; + row: TableMeasure['rows'][number]; + rowIndex: number; + rowLocalFrom: number; + rowLocalTo: number; +}): PartialRowInfo { + const { blockRow, row, rowIndex, rowLocalFrom, rowLocalTo } = params; + const fromLineByCell: number[] = []; + const toLineByCell: number[] = []; + let partialHeight = 0; + + for (let cellIndex = 0; cellIndex < row.cells.length; cellIndex += 1) { + const cellMeasure = row.cells[cellIndex]; + const cellTotal = getCellLines(cellMeasure).length; + const cellFrom = Math.min(rowLocalFrom, cellTotal); + const cellTo = Math.min(rowLocalTo, cellTotal); + const padding = getCellPadding(blockRow, cellIndex); + const blocks = describeCellRenderBlocks(cellMeasure, blockRow?.cells?.[cellIndex], padding); + + fromLineByCell.push(cellFrom); + toLineByCell.push(cellTo); + partialHeight = Math.max( + partialHeight, + computeCellSliceContentHeight(blocks, cellFrom, cellTo) + padding.top + padding.bottom, + ); + } + + return { + rowIndex, + fromLineByCell, + toLineByCell, + isFirstPart: rowLocalFrom === 0, + isLastPart: rowLocalTo >= getEmbeddedRowLines(row).length, + partialHeight, + }; +} + +function getCellPadding(blockRow: TableRow | undefined, cellIndex: number): { top: number; bottom: number } { + const padding = blockRow?.cells?.[cellIndex]?.attrs?.padding; + return { + top: padding?.top ?? 0, + bottom: padding?.bottom ?? 0, + }; +} 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 b96793df02..9fc20d51aa 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { renderTableCell, getCellSegmentCount } from './renderTableCell.js'; -import { getCellLines } from '@superdoc/layout-engine'; +import { getCellLines } from '@superdoc/contracts'; import type { ParagraphBlock, ParagraphMeasure, @@ -4426,6 +4426,120 @@ describe('renderTableCell', () => { expect(tableChrome?.querySelector('.superdoc-structured-content__label')?.textContent).toBe('Nested Table'); }); + it('includes embedded table cell spacing in fragment height', () => { + const nestedParagraph: ParagraphBlock = { + kind: 'paragraph', + id: 'nested-spacing-para', + runs: [{ text: 'Nested', fontFamily: 'Arial', fontSize: 16 }], + }; + const nestedTable: TableBlock = { + kind: 'table', + id: 'nested-spacing-table', + rows: [{ id: 'nested-spacing-row', cells: [{ id: 'nested-spacing-cell', blocks: [nestedParagraph] }] }], + }; + const nestedMeasure: TableMeasure = { + kind: 'table', + rows: [ + { + height: 20, + cells: [{ width: 80, height: 20, gridColumnStart: 0, colSpan: 1, rowSpan: 1, blocks: [paragraphMeasure] }], + }, + ], + columnWidths: [80], + totalWidth: 80, + totalHeight: 24, + cellSpacingPx: 2, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { ...baseCellMeasure, blocks: [nestedMeasure], height: 24 }, + cell: { ...baseCell, blocks: [nestedTable] }, + }); + + const tableEl = cellElement.querySelector('[data-block-id="nested-spacing-table"]'); + expect(tableEl?.style.height).toBe('24px'); + expect(tableEl?.parentElement?.style.height).toBe('24px'); + }); + + it('includes separate-border outer table height for embedded table fragments', () => { + const nestedParagraph: ParagraphBlock = { + kind: 'paragraph', + id: 'nested-border-para', + runs: [{ text: 'Nested', fontFamily: 'Arial', fontSize: 16 }], + }; + const nestedTable: TableBlock = { + kind: 'table', + id: 'nested-border-table', + attrs: { borderCollapse: 'separate' }, + rows: [{ id: 'nested-border-row', cells: [{ id: 'nested-border-cell', blocks: [nestedParagraph] }] }], + }; + const nestedMeasure: TableMeasure = { + kind: 'table', + rows: [ + { + height: 20, + cells: [{ width: 80, height: 20, gridColumnStart: 0, colSpan: 1, rowSpan: 1, blocks: [paragraphMeasure] }], + }, + ], + columnWidths: [80], + totalWidth: 80, + totalHeight: 28, + tableBorderWidths: { top: 3, right: 0, bottom: 5, left: 0 }, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { ...baseCellMeasure, blocks: [nestedMeasure], height: 28 }, + cell: { ...baseCell, blocks: [nestedTable] }, + }); + + const tableEl = cellElement.querySelector('[data-block-id="nested-border-table"]'); + expect(tableEl?.style.height).toBe('28px'); + expect(tableEl?.parentElement?.style.height).toBe('28px'); + }); + + it('preserves header and footer render context for nested table paragraphs', () => { + const contexts: string[] = []; + const nestedParagraph: ParagraphBlock = { + kind: 'paragraph', + id: 'nested-header-context-para', + runs: [{ text: 'Nested', fontFamily: 'Arial', fontSize: 16 }], + }; + const nestedTable: TableBlock = { + kind: 'table', + id: 'nested-header-context-table', + rows: [ + { id: 'nested-header-context-row', cells: [{ id: 'nested-header-context-cell', blocks: [nestedParagraph] }] }, + ], + }; + const nestedMeasure: TableMeasure = { + kind: 'table', + rows: [ + { + height: 20, + cells: [{ width: 80, height: 20, gridColumnStart: 0, colSpan: 1, rowSpan: 1, blocks: [paragraphMeasure] }], + }, + ], + columnWidths: [80], + totalWidth: 80, + totalHeight: 20, + }; + + renderTableCell({ + ...createBaseDeps(), + context: { pageNumber: 1, totalPages: 1, section: 'header' }, + cellMeasure: { ...baseCellMeasure, blocks: [nestedMeasure] }, + cell: { ...baseCell, blocks: [nestedTable] }, + renderLine: (_block, _line, ctx) => { + contexts.push(ctx.section); + return doc.createElement('div'); + }, + }); + + expect(contexts).toEqual(['header']); + }); + it('should set overflow:visible when only rendered nested descendants have SDT chrome', () => { const descendantSdt: SdtMetadata = { type: 'structuredContent', @@ -5464,12 +5578,11 @@ describe('renderTableCell', () => { }); /** - * Sync test: renderer's getCellSegmentCount must agree with layout engine's getCellLines().length. + * Sync test: renderer's getCellSegmentCount must agree with shared 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. + * This keeps the painter-level wrapper tied to the same segment source used by pagination. */ -describe('segment count sync: renderer vs layout engine', () => { +describe('segment count sync: renderer vs shared table helper', () => { const makeParagraph = (lineCount: number): ParagraphMeasure => ({ kind: 'paragraph', lines: Array.from({ length: lineCount }, (_, i) => ({ diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index 4be5072ce4..4f0a70fc29 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -12,12 +12,11 @@ import type { PartialRowInfo, SdtMetadata, TableBlock, - TableFragment, TableMeasure, WrapExclusion, WrapTextMode, } from '@superdoc/contracts'; -import { rescaleColumnWidths, normalizeZIndex, getCellSpacingPx } from '@superdoc/contracts'; +import { getCellLines, normalizeZIndex } from '@superdoc/contracts'; import type { MinimalWordLayout } from '@superdoc/common/list-marker-utils'; import type { FragmentRenderContext, RenderedLineInfo } from '../renderer.js'; import { applySquareWrapExclusionsToLines } from '../utils/anchor-helpers'; @@ -34,140 +33,18 @@ import { renderTableFragment as renderTableFragmentElement } from './renderTable import { renderParagraphContent } from '../paragraph/renderParagraphContent.js'; import { renderTableDrawingFrame } from '../drawings/tableDrawingFrame.js'; import { renderDrawingContent as renderSharedDrawingContent } from '../drawings/renderDrawingContent.js'; +import { + computeRenderedTableFragmentHeight, + createEmbeddedTableFragment, + getEmbeddedTableSegmentCount, + mapEmbeddedTableRowSlice, +} from './embeddedTableFragment.js'; 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. - */ export 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; -} - -/** - * 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; + return getCellLines(cell).length; } /** @@ -297,31 +174,14 @@ const renderEmbeddedTable = ( onSdtContainerChrome, } = params; - const effectiveFromRow = paramFromRow ?? 0; - const effectiveToRow = paramToRow ?? table.rows.length; - - 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 reuse the same function here. - const columnWidths = rescaleColumnWidths(measure.columnWidths, measure.totalWidth, availableWidth); - const fragmentWidth = columnWidths ? availableWidth : measure.totalWidth; - - const fragment: TableFragment = { - kind: 'table', - blockId: table.id, - fromRow: effectiveFromRow, - toRow: effectiveToRow, - x: 0, - y: 0, - width: fragmentWidth, - height: visibleHeight, - columnWidths, + const { fragment, effectiveColumnWidths, cellSpacingPx } = createEmbeddedTableFragment({ + block: table, + measure, + availableWidth, + fromRow: paramFromRow, + toRow: paramToRow, partialRow: paramPartialRow, - }; - const effectiveColumnWidths = columnWidths ?? measure.columnWidths; - const embeddedCellSpacingPx = measure.cellSpacingPx ?? getCellSpacingPx(table.attrs?.cellSpacing); + }); const applyFragmentFrame = (el: HTMLElement, frag: Fragment): void => { el.style.left = `${frag.x}px`; @@ -337,7 +197,7 @@ const renderEmbeddedTable = ( context, block: table, measure, - cellSpacingPx: embeddedCellSpacingPx, + cellSpacingPx, effectiveColumnWidths, renderLine, captureLineSnapshot, @@ -407,10 +267,7 @@ function renderPartialEmbeddedTable(params: { onSdtContainerChrome, } = 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 totalTableSegments = getEmbeddedTableSegmentCount(tableMeasure); const tableStartSegment = cumulativeLineCount; const nextCumulativeLineCount = cumulativeLineCount + totalTableSegments; const tableEndSegment = nextCumulativeLineCount; @@ -424,62 +281,19 @@ function renderPartialEmbeddedTable(params: { 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) { + const rowSlice = mapEmbeddedTableRowSlice({ block, measure: tableMeasure, localFrom, localTo }); + if (!rowSlice) { return { element: null, height: 0, nextCumulativeLineCount, hasSdtContainerChrome: false }; } + const { fromRow: embeddedFromRow, toRow: embeddedToRow, partialRow: partialRowInfo } = rowSlice; - const visibleHeight = computeVisibleHeight(tableMeasure.rows, embeddedFromRow, embeddedToRow, partialRowInfo); + const visibleHeight = computeRenderedTableFragmentHeight({ + block, + measure: tableMeasure, + fromRow: embeddedFromRow, + toRow: embeddedToRow, + partialRow: partialRowInfo, + }); const effectiveSdtBoundary = sdtBoundary ? { ...sdtBoundary, @@ -501,7 +315,7 @@ function renderPartialEmbeddedTable(params: { table: block, measure: tableMeasure, availableWidth: contentWidthPx, - context: { ...context, section: 'body' }, + context, renderLine, captureLineSnapshot, renderDrawingContent, @@ -988,7 +802,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen }, applySdtDataset, renderLine: ({ block, line, lineIndex, isLastLine, resolvedListTextStartPx }) => - renderLine(block, line, { ...context, section: 'body' }, lineIndex, isLastLine, resolvedListTextStartPx), + renderLine(block, line, context, lineIndex, isLastLine, resolvedListTextStartPx), convertFinalParagraphMark: isLastBlockInCell, lineTopOffset: flowCursorY, }); @@ -1104,7 +918,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen continue; } const wrapperEl = rendered.el.classList.contains('superdoc-line') ? undefined : rendered.el; - captureLineSnapshot(candidateLine, { ...context, section: 'body' }, { inTableParagraph: false, wrapperEl }); + captureLineSnapshot(candidateLine, context, { inTableParagraph: false, wrapperEl }); } } } From 5349a5ef5a9643d1a5f1d94f09078dde2c8f1201 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 18 May 2026 11:47:36 -0300 Subject: [PATCH 02/11] refactor(painters/dom): extract table fragment rendering from DomPainter Pull the table branch of DomPainter.renderFragment out of renderer.ts into dedicated modules under painters/dom/src/table/: - renderResolvedTableFragment: the former renderTableFragment method plus its resolveTableRenderData helper, taking renderLine, snapshot capture, frame application, and SDT helpers as dependencies - fragmentKey: tableFragmentKey() with the partial-row-aware cache key - snapshot: getTableSnapshotFlags() for the inTableFragment/inTableParagraph closest() checks used during paint-snapshot capture Each extraction has a co-located test. DomPainter now dispatches table fragments through renderResolvedTableFragment and delegates fragmentKey / snapshot-flag work to the new helpers, shrinking renderer.ts by ~150 lines. --- .../dom/src/renderer-dispatch.test.ts | 13 +- .../painters/dom/src/renderer.ts | 182 +++----------- .../dom/src/table/fragmentKey.test.ts | 41 +++ .../painters/dom/src/table/fragmentKey.ts | 8 + .../table/renderResolvedTableFragment.test.ts | 234 ++++++++++++++++++ .../src/table/renderResolvedTableFragment.ts | 182 ++++++++++++++ .../painters/dom/src/table/snapshot.test.ts | 30 +++ .../painters/dom/src/table/snapshot.ts | 9 + 8 files changed, 541 insertions(+), 158 deletions(-) create mode 100644 packages/layout-engine/painters/dom/src/table/fragmentKey.test.ts create mode 100644 packages/layout-engine/painters/dom/src/table/fragmentKey.ts create mode 100644 packages/layout-engine/painters/dom/src/table/renderResolvedTableFragment.test.ts create mode 100644 packages/layout-engine/painters/dom/src/table/renderResolvedTableFragment.ts create mode 100644 packages/layout-engine/painters/dom/src/table/snapshot.test.ts create mode 100644 packages/layout-engine/painters/dom/src/table/snapshot.ts diff --git a/packages/layout-engine/painters/dom/src/renderer-dispatch.test.ts b/packages/layout-engine/painters/dom/src/renderer-dispatch.test.ts index eeea052fc7..bddec899fe 100644 --- a/packages/layout-engine/painters/dom/src/renderer-dispatch.test.ts +++ b/packages/layout-engine/painters/dom/src/renderer-dispatch.test.ts @@ -9,8 +9,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { createTestPainter as createDomPainter } from './_test-utils.js'; import { DomPainter } from './renderer.js'; +import { renderResolvedTableFragment } from './table/renderResolvedTableFragment.js'; import type { FlowBlock, Measure, Layout } from '@superdoc/contracts'; +vi.mock('./table/renderResolvedTableFragment.js', () => ({ + renderResolvedTableFragment: vi.fn(), +})); + // --------------------------------------------------------------------------- // Minimal fixtures per fragment kind // --------------------------------------------------------------------------- @@ -309,14 +314,14 @@ describe('renderFragment dispatch', () => { expect(spy.mock.calls[0]![0].drawingKind).toBe('chart'); }); - it('routes table fragment to renderTableFragment', () => { + it('routes table fragment to table-owned renderResolvedTableFragment', () => { const dummyDiv = document.createElement('div'); - const spy = vi.spyOn(DomPainter.prototype as any, 'renderTableFragment').mockReturnValue(dummyDiv); + vi.mocked(renderResolvedTableFragment).mockReturnValue(dummyDiv); const { blocks, measures, layout } = tableFixtures(); const painter = createDomPainter({ blocks, measures }); painter.paint(layout, container); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy.mock.calls[0]![0].kind).toBe('table'); + expect(renderResolvedTableFragment).toHaveBeenCalledTimes(1); + expect(vi.mocked(renderResolvedTableFragment).mock.calls[0]![0].fragment.kind).toBe('table'); }); it('throws for unknown fragment kind', () => { diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 55511474ff..2f9e3de20c 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -1,6 +1,5 @@ import type { ColumnLayout, - DrawingBlock, DrawingFragment, FlowMode, Fragment, @@ -13,19 +12,15 @@ import type { ParagraphBlock, Run, SourceAnchor, - TableBlock, - TableFragment, - TableMeasure, TextRun, ResolvedLayout, ResolvedFragmentItem, ResolvedPage, ResolvedPaintItem, - ResolvedTableItem, ResolvedImageItem, ResolvedDrawingItem, } from '@superdoc/contracts'; -import { expandRunsForInlineNewlines, getCellSpacingPx, normalizeColumnLayout } from '@superdoc/contracts'; +import { normalizeColumnLayout } from '@superdoc/contracts'; import { DOM_CLASS_NAMES } from './constants.js'; import { createRulerElement, ensureRulerStyles, generateRulerDefinitionFromPx } from './ruler/index.js'; import { @@ -45,7 +40,9 @@ import { spreadStyles, type PageStyles, } from './styles.js'; -import { renderTableFragment as renderTableFragmentElement } from './table/renderTableFragment.js'; +import { renderResolvedTableFragment } from './table/renderResolvedTableFragment.js'; +import { tableFragmentKey } from './table/fragmentKey.js'; +import { getTableSnapshotFlags } from './table/snapshot.js'; import { computeSdtBoundaries } from './sdt/boundaries.js'; import { shouldRebuildForSdtBoundary, type SdtBoundaryOptions } from './sdt/container.js'; import { applyContainerSdtDataset, applySdtDataset } from './sdt/dataset.js'; @@ -69,7 +66,6 @@ import { renderImageFragment as renderImageFragmentElement } from './images/imag import { buildImageHyperlinkAnchor as buildSharedImageHyperlinkAnchor } from './images/hyperlink.js'; import { applyStyles } from './utils/apply-styles.js'; import { applyTrackedChangeDecorations, resolveTrackedChangesConfig } from './runs/tracked-changes.js'; -import { renderDrawingContent as renderSharedDrawingContent } from './drawings/renderDrawingContent.js'; import { isHeaderWordArtWatermark, renderDrawingFragment as renderDrawingFragmentElement, @@ -334,6 +330,8 @@ type PaintSnapshotCaptureOptions = { sourceAnchor?: SourceAnchor; }; +type ResolvedTablePaintItem = Extract; + function roundSnapshotMetric(value: number): number | null { if (!Number.isFinite(value)) return null; return Math.round(value * 1000) / 1000; @@ -992,11 +990,12 @@ export class DomPainter { tabCount += tabs.length; lineCount += 1; + const tableFlags = getTableSnapshotFlags(lineEl); lines.push( compactSnapshotObject({ index: lineIndex, - inTableFragment: Boolean(lineEl.closest('.superdoc-table-fragment')), - inTableParagraph: Boolean(lineEl.closest('.superdoc-table-paragraph')), + inTableFragment: tableFlags.inTableFragment, + inTableParagraph: tableFlags.inTableParagraph, style: snapshotLineStyleFromElement(lineEl), markers, tabs, @@ -2390,7 +2389,21 @@ export class DomPainter { return this.renderDrawingFragment(fragment, context, resolvedItem as ResolvedDrawingItem | undefined); } if (fragment.kind === 'table') { - return this.renderTableFragment(fragment, context, sdtBoundary, resolvedItem as ResolvedTableItem | undefined); + return renderResolvedTableFragment({ + doc: this.doc, + fragment, + context, + sdtBoundary, + resolvedItem: resolvedItem as ResolvedTablePaintItem | undefined, + renderLine: this.renderLine.bind(this), + capturePaintSnapshotLine: this.capturePaintSnapshotLine.bind(this), + applyFragmentFrame: this.applyFragmentFrame.bind(this), + applyResolvedFragmentFrame: this.applyResolvedFragmentFrame.bind(this), + createErrorPlaceholder: this.createErrorPlaceholder.bind(this), + applySdtDataset, + applyContainerSdtDataset, + applyStyles, + }); } throw new Error(`DomPainter: unsupported fragment kind ${(fragment as Fragment).kind}`); } @@ -2548,139 +2561,6 @@ export class DomPainter { }); } - private resolveTableRenderData( - fragment: TableFragment, - resolvedItem?: ResolvedTableItem, - ): { - block: TableBlock; - measure: TableMeasure; - cellSpacingPx: number; - effectiveColumnWidths: number[]; - } { - if (!resolvedItem) { - throw new Error(`DomPainter: missing resolved table item for fragment ${fragment.blockId}`); - } - return { - block: resolvedItem.block, - measure: resolvedItem.measure, - cellSpacingPx: resolvedItem.cellSpacingPx, - effectiveColumnWidths: resolvedItem.effectiveColumnWidths, - }; - } - - private renderTableFragment( - fragment: TableFragment, - context: FragmentRenderContext, - sdtBoundary?: SdtBoundaryOptions, - resolvedItem?: ResolvedTableItem, - ): HTMLElement { - try { - if (!this.doc) { - throw new Error('DomPainter: document is not available'); - } - - // Wrap applyFragmentFrame to capture section from context. - // Table cell inner fragments always stay on the legacy frame path for now. - const applyFragmentFrameWithSection = (el: HTMLElement, frag: Fragment): void => { - this.applyFragmentFrame(el, frag, context.section); - }; - - // Word justifies text inside table cells, but not the final line unless the - // paragraph ends with an explicit line break. - const tableCellExpandedRunsCache = new WeakMap(); - const renderLineForTableCell = ( - block: ParagraphBlock, - line: Line, - ctx: FragmentRenderContext, - lineIndex: number, - isLastLine: boolean, - resolvedListTextStartPx?: number, - ): HTMLElement => { - const lastRun = block.runs.length > 0 ? block.runs[block.runs.length - 1] : null; - const paragraphEndsWithLineBreak = lastRun?.kind === 'lineBreak'; - const shouldSkipJustify = isLastLine && !paragraphEndsWithLineBreak; - - let expandedRuns = tableCellExpandedRunsCache.get(block); - if (!expandedRuns) { - expandedRuns = expandRunsForInlineNewlines(block.runs); - tableCellExpandedRunsCache.set(block, expandedRuns); - } - - return this.renderLine( - block, - line, - ctx, - undefined, - lineIndex, - shouldSkipJustify, - expandedRuns, - resolvedListTextStartPx, - ); - }; - - const buildTableImageHyperlinkAnchor = ( - imageEl: HTMLElement, - hyperlink: ImageHyperlink | undefined, - display: 'block' | 'inline-block', - ): HTMLElement => buildSharedImageHyperlinkAnchor(this.doc!, imageEl, hyperlink, display); - - const renderDrawingContentForTableCell = ( - block: DrawingBlock, - options?: { clipContainer?: HTMLElement }, - ): HTMLElement => - renderSharedDrawingContent({ - doc: this.doc!, - block, - geometry: 'geometry' in block ? block.geometry : undefined, - context, - clipContainer: options?.clipContainer, - buildImageHyperlinkAnchor: buildTableImageHyperlinkAnchor, - }); - - const tableRenderData = this.resolveTableRenderData(fragment, resolvedItem); - - const el = renderTableFragmentElement({ - doc: this.doc, - fragment, - context, - block: tableRenderData.block, - measure: tableRenderData.measure, - cellSpacingPx: tableRenderData.cellSpacingPx, - effectiveColumnWidths: tableRenderData.effectiveColumnWidths, - sdtBoundary, - renderLine: renderLineForTableCell, - captureLineSnapshot: (lineEl, lineContext, options) => { - this.capturePaintSnapshotLine(lineEl, lineContext, { - inTableFragment: true, - inTableParagraph: options?.inTableParagraph ?? false, - wrapperEl: options?.wrapperEl, - }); - }, - renderDrawingContent: renderDrawingContentForTableCell, - applyFragmentFrame: applyFragmentFrameWithSection, - applySdtDataset, - applyContainerSdtDataset, - applyStyles, - }); - - // Override outer wrapper positioning with resolved data when available. - // Inner cell fragments still use legacy applyFragmentFrame via deps closure. - if (resolvedItem) { - this.applyResolvedFragmentFrame(el, resolvedItem, fragment, context.section); - // Re-apply the SDT group width override after the resolved frame, so block-SDT - // containers can stretch table fragments to match sibling paragraph widths. - if (sdtBoundary?.widthOverride != null) { - el.style.width = `${sdtBoundary.widthOverride}px`; - } - } - - return el; - } catch (error) { - console.error('[DomPainter] Table fragment rendering failed:', { fragment, error }); - return this.createErrorPlaceholder(fragment.blockId, error); - } - } - private renderLine( block: ParagraphBlock, line: Line, @@ -2805,7 +2685,7 @@ export class DomPainter { el: HTMLElement, fragment: Fragment, section?: 'body' | 'header' | 'footer', - resolvedItem?: ResolvedFragmentItem | ResolvedTableItem | ResolvedImageItem | ResolvedDrawingItem, + resolvedItem?: ResolvedFragmentItem | ResolvedTablePaintItem | ResolvedImageItem | ResolvedDrawingItem, ): void { // Footnote content is read-only: prevent cursor placement and typing if (typeof fragment.blockId === 'string' && fragment.blockId.startsWith('footnote-')) { @@ -2856,7 +2736,7 @@ export class DomPainter { private applyResolvedFragmentFrame( el: HTMLElement, - item: ResolvedFragmentItem | ResolvedTableItem | ResolvedImageItem | ResolvedDrawingItem, + item: ResolvedFragmentItem | ResolvedTablePaintItem | ResolvedImageItem | ResolvedDrawingItem, fragment: Fragment, section?: 'body' | 'header' | 'footer', ): void { @@ -2907,14 +2787,8 @@ const fragmentKey = (fragment: Fragment): string => { return `image:${fragment.blockId}:${fragment.x}:${fragment.y}`; case 'drawing': return `drawing:${fragment.blockId}:${fragment.x}:${fragment.y}`; - case 'table': { - // Include row range and partial row info to uniquely identify table fragments - // This is critical for mid-row splitting where multiple fragments can exist for the same table - const partialKey = fragment.partialRow - ? `:${fragment.partialRow.fromLineByCell.join(',')}-${fragment.partialRow.toLineByCell.join(',')}` - : ''; - return `table:${fragment.blockId}:${fragment.fromRow}:${fragment.toRow}${partialKey}`; - } + case 'table': + return tableFragmentKey(fragment); default: { const _exhaustiveCheck: never = fragment; return _exhaustiveCheck; diff --git a/packages/layout-engine/painters/dom/src/table/fragmentKey.test.ts b/packages/layout-engine/painters/dom/src/table/fragmentKey.test.ts new file mode 100644 index 0000000000..ee7fee55e0 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/table/fragmentKey.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import type { TableFragment } from '@superdoc/contracts'; +import { tableFragmentKey } from './fragmentKey.js'; + +describe('tableFragmentKey', () => { + it('preserves full-row table fragment key format', () => { + const fragment: TableFragment = { + kind: 'table', + blockId: 'table-a', + fromRow: 1, + toRow: 3, + x: 0, + y: 0, + width: 100, + height: 50, + }; + + expect(tableFragmentKey(fragment)).toBe('table:table-a:1:3'); + }); + + it('preserves partial-row table fragment key format byte-for-byte', () => { + const fragment: TableFragment = { + kind: 'table', + blockId: 'table-a', + fromRow: 2, + toRow: 4, + x: 0, + y: 0, + width: 100, + height: 50, + partialRow: { + rowIndex: 2, + fromLineByCell: [0, 2, 4], + toLineByCell: [1, 3, 5], + partialHeight: 25, + }, + }; + + expect(tableFragmentKey(fragment)).toBe('table:table-a:2:4:0,2,4-1,3,5'); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/table/fragmentKey.ts b/packages/layout-engine/painters/dom/src/table/fragmentKey.ts new file mode 100644 index 0000000000..cd3dc92d63 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/table/fragmentKey.ts @@ -0,0 +1,8 @@ +import type { TableFragment } from '@superdoc/contracts'; + +export const tableFragmentKey = (fragment: TableFragment): string => { + const partialKey = fragment.partialRow + ? `:${fragment.partialRow.fromLineByCell.join(',')}-${fragment.partialRow.toLineByCell.join(',')}` + : ''; + return `table:${fragment.blockId}:${fragment.fromRow}:${fragment.toRow}${partialKey}`; +}; diff --git a/packages/layout-engine/painters/dom/src/table/renderResolvedTableFragment.test.ts b/packages/layout-engine/painters/dom/src/table/renderResolvedTableFragment.test.ts new file mode 100644 index 0000000000..48d1b32cc9 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/table/renderResolvedTableFragment.test.ts @@ -0,0 +1,234 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { + DrawingBlock, + Line, + ParagraphBlock, + ResolvedTableItem, + TableBlock, + TableFragment, + TableMeasure, +} from '@superdoc/contracts'; +import { applyContainerSdtDataset, applySdtDataset } from '../sdt/dataset.js'; +import { applyStyles } from '../utils/apply-styles.js'; +import type { FragmentRenderContext } from '../renderer.js'; +import { renderDrawingContent } from '../drawings/renderDrawingContent.js'; +import { renderResolvedTableFragment } from './renderResolvedTableFragment.js'; +import { renderTableFragment } from './renderTableFragment.js'; + +vi.mock('./renderTableFragment.js', () => ({ + renderTableFragment: vi.fn(), +})); + +vi.mock('../drawings/renderDrawingContent.js', () => ({ + renderDrawingContent: vi.fn(), +})); + +const context: FragmentRenderContext = { + pageNumber: 2, + totalPages: 4, + section: 'body', + pageIndex: 1, +}; + +const fragment: TableFragment = { + kind: 'table', + blockId: 'table-1', + fromRow: 0, + toRow: 1, + x: 10, + y: 20, + width: 120, + height: 30, +}; + +const block: TableBlock = { + kind: 'table', + id: 'table-1', + rows: [{ id: 'row-1', cells: [{ id: 'cell-1', blocks: [], attrs: {} }], attrs: {} }], +}; + +const measure: TableMeasure = { + kind: 'table', + rows: [{ height: 30, cells: [{ width: 120, height: 30, gridColumnStart: 0, blocks: [] }] }], + columnWidths: [120], + totalWidth: 120, + totalHeight: 30, +}; + +const resolvedItem: ResolvedTableItem = { + kind: 'fragment', + fragmentKind: 'table', + id: 'table:table-1:0:1', + pageIndex: 1, + x: 11, + y: 22, + width: 130, + height: 31, + blockId: 'table-1', + fragment, + fragmentIndex: 0, + block, + measure, + cellSpacingPx: 0, + effectiveColumnWidths: [120], +}; + +const line: Line = { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 5, + width: 30, + ascent: 10, + descent: 4, + lineHeight: 16, +}; + +function createDeps(overrides: Partial[0]> = {}) { + const doc = document.implementation.createHTMLDocument(); + return { + doc, + fragment, + context, + resolvedItem, + renderLine: vi.fn(() => doc.createElement('span')), + capturePaintSnapshotLine: vi.fn(), + applyFragmentFrame: vi.fn(), + applyResolvedFragmentFrame: vi.fn(), + createErrorPlaceholder: vi.fn((blockId: string, error: unknown) => { + const el = doc.createElement('div'); + el.className = 'render-error-placeholder'; + el.textContent = `[Render Error: ${blockId}]`; + if (error instanceof Error) el.title = error.message; + return el; + }), + applySdtDataset, + applyContainerSdtDataset, + applyStyles, + ...overrides, + }; +} + +describe('renderResolvedTableFragment', () => { + beforeEach(() => { + vi.mocked(renderTableFragment).mockReset(); + vi.mocked(renderDrawingContent).mockReset(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns the renderer error placeholder when resolved table data is missing', () => { + const deps = createDeps({ resolvedItem: undefined }); + const el = renderResolvedTableFragment(deps); + + expect(renderTableFragment).not.toHaveBeenCalled(); + expect(deps.createErrorPlaceholder).toHaveBeenCalledTimes(1); + expect(el.textContent).toBe('[Render Error: table-1]'); + expect(el.title).toContain('missing resolved table item'); + }); + + it('skips table-cell final-line justification unless the paragraph ends with a line break', () => { + vi.mocked(renderTableFragment).mockImplementation((deps) => { + const paragraph: ParagraphBlock = { + kind: 'paragraph', + id: 'p1', + runs: [{ text: 'value' }], + }; + deps.renderLine(paragraph, line, context, 0, true); + + const paragraphWithBreak: ParagraphBlock = { + kind: 'paragraph', + id: 'p2', + runs: [{ text: 'value' }, { kind: 'lineBreak' } as ParagraphBlock['runs'][number]], + }; + deps.renderLine(paragraphWithBreak, line, context, 0, true); + return deps.doc.createElement('div'); + }); + const deps = createDeps(); + + renderResolvedTableFragment(deps); + + expect(deps.renderLine).toHaveBeenNthCalledWith( + 1, + expect.anything(), + line, + context, + undefined, + 0, + true, + expect.any(Array), + undefined, + ); + expect(deps.renderLine).toHaveBeenNthCalledWith( + 2, + expect.anything(), + line, + context, + undefined, + 0, + false, + expect.any(Array), + undefined, + ); + }); + + it('reuses expanded runs for repeated lines from the same paragraph block', () => { + vi.mocked(renderTableFragment).mockImplementation((deps) => { + const paragraph: ParagraphBlock = { + kind: 'paragraph', + id: 'p-cache', + runs: [{ text: 'first\nsecond' }], + }; + deps.renderLine(paragraph, line, context, 0, false); + deps.renderLine(paragraph, line, context, 1, true); + return deps.doc.createElement('div'); + }); + const deps = createDeps(); + + renderResolvedTableFragment(deps); + + expect(deps.renderLine.mock.calls[0]![6]).toBe(deps.renderLine.mock.calls[1]![6]); + }); + + it('passes table-cell drawing content the current context and keeps image hyperlink wrapping', () => { + vi.mocked(renderTableFragment).mockImplementation((deps) => { + const drawingBlock: DrawingBlock = { + kind: 'drawing', + id: 'drawing-1', + drawingKind: 'image', + src: 'data:image/gif;base64,R0lGODlhAQABAAAAACw=', + hyperlink: { url: 'https://example.com' }, + } as DrawingBlock; + return deps.renderDrawingContent!(drawingBlock); + }); + vi.mocked(renderDrawingContent).mockImplementation((params) => { + const img = params.doc.createElement('img'); + return params.buildImageHyperlinkAnchor(img, { url: 'https://example.com' }, 'block'); + }); + + const el = renderResolvedTableFragment(createDeps()); + + expect(renderDrawingContent).toHaveBeenCalledWith(expect.objectContaining({ context })); + expect(el.tagName).toBe('A'); + expect(el.getAttribute('href')).toBe('https://example.com'); + expect(el.classList.contains('superdoc-link')).toBe(true); + }); + + it('re-applies SDT width override after resolved frame positioning', () => { + vi.mocked(renderTableFragment).mockImplementation((deps) => deps.doc.createElement('div')); + const deps = createDeps({ + sdtBoundary: { widthOverride: 555 }, + applyResolvedFragmentFrame: vi.fn((el) => { + el.style.width = '130px'; + }), + }); + + const el = renderResolvedTableFragment(deps); + + expect(deps.applyResolvedFragmentFrame).toHaveBeenCalledTimes(1); + expect(el.style.width).toBe('555px'); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/table/renderResolvedTableFragment.ts b/packages/layout-engine/painters/dom/src/table/renderResolvedTableFragment.ts new file mode 100644 index 0000000000..89be237aff --- /dev/null +++ b/packages/layout-engine/painters/dom/src/table/renderResolvedTableFragment.ts @@ -0,0 +1,182 @@ +import type { + DrawingBlock, + Fragment, + ImageHyperlink, + Line, + ParagraphBlock, + ResolvedTableItem, + Run, + TableBlock, + TableFragment, + TableMeasure, +} from '@superdoc/contracts'; +import { expandRunsForInlineNewlines } from '@superdoc/contracts'; +import { renderDrawingContent as renderSharedDrawingContent } from '../drawings/renderDrawingContent.js'; +import { buildImageHyperlinkAnchor as buildSharedImageHyperlinkAnchor } from '../images/hyperlink.js'; +import type { FragmentRenderContext } from '../renderer.js'; +import type { SdtBoundaryOptions } from '../sdt/container.js'; +import type { applyContainerSdtDataset, applySdtDataset } from '../sdt/dataset.js'; +import type { applyStyles } from '../utils/apply-styles.js'; +import { renderTableFragment } from './renderTableFragment.js'; + +type TableRenderData = { + block: TableBlock; + measure: TableMeasure; + cellSpacingPx: number; + effectiveColumnWidths: number[]; +}; + +export type RenderResolvedTableFragmentDeps = { + doc: Document | null; + fragment: TableFragment; + context: FragmentRenderContext; + sdtBoundary?: SdtBoundaryOptions; + resolvedItem?: ResolvedTableItem; + renderLine: ( + block: ParagraphBlock, + line: Line, + context: FragmentRenderContext, + availableWidthOverride?: number, + lineIndex?: number, + skipJustify?: boolean, + preExpandedRuns?: Run[], + resolvedListTextStartPx?: number, + ) => HTMLElement; + capturePaintSnapshotLine: ( + lineEl: HTMLElement, + context: FragmentRenderContext, + options?: { inTableFragment?: boolean; inTableParagraph?: boolean; wrapperEl?: HTMLElement }, + ) => void; + applyFragmentFrame: (el: HTMLElement, fragment: Fragment, section?: 'body' | 'header' | 'footer') => void; + applyResolvedFragmentFrame: ( + el: HTMLElement, + item: ResolvedTableItem, + fragment: Fragment, + section?: 'body' | 'header' | 'footer', + ) => void; + createErrorPlaceholder: (blockId: string, error: unknown) => HTMLElement; + applySdtDataset: typeof applySdtDataset; + applyContainerSdtDataset: typeof applyContainerSdtDataset; + applyStyles: typeof applyStyles; +}; + +const resolveTableRenderData = (fragment: TableFragment, resolvedItem?: ResolvedTableItem): TableRenderData => { + if (!resolvedItem) { + throw new Error(`DomPainter: missing resolved table item for fragment ${fragment.blockId}`); + } + return { + block: resolvedItem.block, + measure: resolvedItem.measure, + cellSpacingPx: resolvedItem.cellSpacingPx, + effectiveColumnWidths: resolvedItem.effectiveColumnWidths, + }; +}; + +export const renderResolvedTableFragment = ({ + doc, + fragment, + context, + sdtBoundary, + resolvedItem, + renderLine, + capturePaintSnapshotLine, + applyFragmentFrame, + applyResolvedFragmentFrame, + createErrorPlaceholder, + applySdtDataset, + applyContainerSdtDataset, + applyStyles, +}: RenderResolvedTableFragmentDeps): HTMLElement => { + try { + if (!doc) { + throw new Error('DomPainter: document is not available'); + } + + const tableCellExpandedRunsCache = new WeakMap(); + const renderLineForTableCell = ( + block: ParagraphBlock, + line: Line, + ctx: FragmentRenderContext, + lineIndex: number, + isLastLine: boolean, + resolvedListTextStartPx?: number, + ): HTMLElement => { + const lastRun = block.runs.length > 0 ? block.runs[block.runs.length - 1] : null; + const paragraphEndsWithLineBreak = lastRun?.kind === 'lineBreak'; + const shouldSkipJustify = isLastLine && !paragraphEndsWithLineBreak; + + let expandedRuns = tableCellExpandedRunsCache.get(block); + if (!expandedRuns) { + expandedRuns = expandRunsForInlineNewlines(block.runs); + tableCellExpandedRunsCache.set(block, expandedRuns); + } + + return renderLine( + block, + line, + ctx, + undefined, + lineIndex, + shouldSkipJustify, + expandedRuns, + resolvedListTextStartPx, + ); + }; + + const buildTableImageHyperlinkAnchor = ( + imageEl: HTMLElement, + hyperlink: ImageHyperlink | undefined, + display: 'block' | 'inline-block', + ): HTMLElement => buildSharedImageHyperlinkAnchor(doc, imageEl, hyperlink, display); + + const renderDrawingContentForTableCell = ( + block: DrawingBlock, + options?: { clipContainer?: HTMLElement }, + ): HTMLElement => + renderSharedDrawingContent({ + doc, + block, + geometry: 'geometry' in block ? block.geometry : undefined, + context, + clipContainer: options?.clipContainer, + buildImageHyperlinkAnchor: buildTableImageHyperlinkAnchor, + }); + + const tableRenderData = resolveTableRenderData(fragment, resolvedItem); + const el = renderTableFragment({ + doc, + fragment, + context, + block: tableRenderData.block, + measure: tableRenderData.measure, + cellSpacingPx: tableRenderData.cellSpacingPx, + effectiveColumnWidths: tableRenderData.effectiveColumnWidths, + sdtBoundary, + renderLine: renderLineForTableCell, + captureLineSnapshot: (lineEl, lineContext, options) => { + capturePaintSnapshotLine(lineEl, lineContext, { + inTableFragment: true, + inTableParagraph: options?.inTableParagraph ?? false, + wrapperEl: options?.wrapperEl, + }); + }, + renderDrawingContent: renderDrawingContentForTableCell, + applyFragmentFrame: (element, innerFragment) => applyFragmentFrame(element, innerFragment, context.section), + applySdtDataset, + applyContainerSdtDataset, + applyStyles, + }); + + if (resolvedItem) { + applyResolvedFragmentFrame(el, resolvedItem, fragment, context.section); + if (sdtBoundary?.widthOverride != null) { + el.style.width = `${sdtBoundary.widthOverride}px`; + } + } + + return el; + } catch (error) { + console.error('[DomPainter] Table fragment rendering failed:', { fragment, error }); + return createErrorPlaceholder(fragment.blockId, error); + } +}; diff --git a/packages/layout-engine/painters/dom/src/table/snapshot.test.ts b/packages/layout-engine/painters/dom/src/table/snapshot.test.ts new file mode 100644 index 0000000000..b535e61811 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/table/snapshot.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { getTableSnapshotFlags } from './snapshot.js'; + +describe('getTableSnapshotFlags', () => { + it('detects lines inside table fragments and table paragraphs', () => { + const wrapper = document.createElement('div'); + wrapper.className = 'superdoc-table-fragment'; + const paragraph = document.createElement('div'); + paragraph.className = 'superdoc-table-paragraph'; + const line = document.createElement('div'); + line.className = 'superdoc-line'; + paragraph.appendChild(line); + wrapper.appendChild(paragraph); + + expect(getTableSnapshotFlags(line)).toEqual({ + inTableFragment: true, + inTableParagraph: true, + }); + }); + + it('leaves both flags false for non-table lines', () => { + const line = document.createElement('div'); + line.className = 'superdoc-line'; + + expect(getTableSnapshotFlags(line)).toEqual({ + inTableFragment: false, + inTableParagraph: false, + }); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/table/snapshot.ts b/packages/layout-engine/painters/dom/src/table/snapshot.ts new file mode 100644 index 0000000000..2cea34c639 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/table/snapshot.ts @@ -0,0 +1,9 @@ +export type TableSnapshotFlags = { + inTableFragment: boolean; + inTableParagraph: boolean; +}; + +export const getTableSnapshotFlags = (lineEl: HTMLElement): TableSnapshotFlags => ({ + inTableFragment: Boolean(lineEl.closest('.superdoc-table-fragment')), + inTableParagraph: Boolean(lineEl.closest('.superdoc-table-paragraph')), +}); From 78f683b452dded0b9d9f0419593ad5bdbd81227e Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 18 May 2026 12:02:35 -0300 Subject: [PATCH 03/11] fix(layout-engine): price embedded table slice height --- .../contracts/src/table-cell-slice.test.ts | 30 ++++++++++++++++++- .../contracts/src/table-cell-slice.ts | 20 +++++++++---- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/packages/layout-engine/contracts/src/table-cell-slice.test.ts b/packages/layout-engine/contracts/src/table-cell-slice.test.ts index acf781b47b..d039afbdfd 100644 --- a/packages/layout-engine/contracts/src/table-cell-slice.test.ts +++ b/packages/layout-engine/contracts/src/table-cell-slice.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest'; import type { ParagraphMeasure, TableCellMeasure, TableMeasure } from './index.js'; -import { getCellLines } from './table-cell-slice.js'; +import { + computeCellSliceContentHeight, + computeFullCellContentHeight, + createCellSliceCursor, + describeCellRenderBlocks, + getCellLines, +} from './table-cell-slice.js'; describe('table cell segment mapping', () => { const makeParagraph = (lineCount: number): ParagraphMeasure => ({ @@ -77,4 +83,26 @@ describe('table cell segment mapping', () => { expect(getCellLines(cell)).toHaveLength(2); }); + + it('uses embedded table total height for full table slices', () => { + const nestedTable: TableMeasure = { + kind: 'table', + rows: [{ cells: [{ blocks: [makeParagraph(1)], width: 80, height: 20 }], height: 20 }], + columnWidths: [80], + totalWidth: 80, + totalHeight: 24, + cellSpacingPx: 2, + }; + const cell: TableCellMeasure = { + blocks: [nestedTable], + width: 100, + height: 24, + }; + const blocks = describeCellRenderBlocks(cell, undefined, { top: 0, bottom: 0 }); + + expect(computeCellSliceContentHeight(blocks, 0, 1)).toBe(24); + expect(computeFullCellContentHeight(cell, undefined, { top: 0, bottom: 0 })).toBe(24); + expect(createCellSliceCursor(blocks, 0).advanceLine(0)).toBe(24); + expect(createCellSliceCursor(blocks, 0).minSegmentCost(0)).toBe(24); + }); }); diff --git a/packages/layout-engine/contracts/src/table-cell-slice.ts b/packages/layout-engine/contracts/src/table-cell-slice.ts index e30aa2e11b..0905fa1d7f 100644 --- a/packages/layout-engine/contracts/src/table-cell-slice.ts +++ b/packages/layout-engine/contracts/src/table-cell-slice.ts @@ -123,7 +123,7 @@ export function describeCellRenderBlocks( globalStartLine: startLine, globalEndLine: globalLine, lineHeights, - totalHeight: sumLines, + totalHeight: tableMeasure.totalHeight ?? sumLines, visibleHeight: sumLines, isFirstBlock, isLastBlock, @@ -179,6 +179,12 @@ export function computeCellSliceContentHeight(blocks: CellRenderBlock[], fromLin height += sliceLineSum; } } else if (block.visibleHeight > 0) { + const sliceLineSum = sumArray(block.lineHeights.slice(localStart, localEnd)); + if (block.kind === 'table' && rendersEntireBlock) { + height += Math.max(sliceLineSum, block.totalHeight); + continue; + } + for (let i = localStart; i < localEnd; i += 1) { height += block.lineHeights[i] ?? 0; } @@ -230,8 +236,10 @@ export function createCellSliceCursor(blocks: CellRenderBlock[], startLine: numb blockLineSum += lineHeight; const isBlockComplete = localLine === block.lineHeights.length - 1; - if (isBlockComplete && startedFromLine0 && block.kind === 'paragraph') { + if (isBlockComplete && startedFromLine0 && (block.kind === 'paragraph' || block.kind === 'table')) { cost += Math.max(0, block.totalHeight - blockLineSum); + } + if (isBlockComplete && startedFromLine0 && block.kind === 'paragraph') { cost += block.spacingAfter; } if (isBlockComplete) { @@ -257,8 +265,10 @@ export function createCellSliceCursor(blocks: CellRenderBlock[], startLine: numb if (block.kind === 'paragraph' || block.visibleHeight > 0) { cost += lineHeight; } - if (block.lineHeights.length === 1 && block.kind === 'paragraph') { + if (block.lineHeights.length === 1 && (block.kind === 'paragraph' || block.kind === 'table')) { cost += Math.max(0, block.totalHeight - lineHeight); + } + if (block.lineHeights.length === 1 && block.kind === 'paragraph') { cost += block.spacingAfter; } @@ -306,9 +316,7 @@ export function computeFullCellContentHeight( } } else if (measure.kind === 'table') { const table = measure as TableMeasure; - for (const row of table.rows) { - height += row.height; - } + height += table.totalHeight; } else { const blockHeight = 'height' in measure ? (measure as { height: number }).height : 0; if (blockHeight > 0 && !isAnchoredOutOfFlow(data)) { From b4a9a94c69935491bad32dd44cbbde9708ffe60b Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 18 May 2026 12:09:24 -0300 Subject: [PATCH 04/11] fix(layout-engine): price partial embedded table slices --- .../contracts/src/table-cell-slice.test.ts | 24 ++++ .../contracts/src/table-cell-slice.ts | 108 +++++++++++++++++- 2 files changed, 126 insertions(+), 6 deletions(-) diff --git a/packages/layout-engine/contracts/src/table-cell-slice.test.ts b/packages/layout-engine/contracts/src/table-cell-slice.test.ts index d039afbdfd..2e4a766779 100644 --- a/packages/layout-engine/contracts/src/table-cell-slice.test.ts +++ b/packages/layout-engine/contracts/src/table-cell-slice.test.ts @@ -105,4 +105,28 @@ describe('table cell segment mapping', () => { expect(createCellSliceCursor(blocks, 0).advanceLine(0)).toBe(24); expect(createCellSliceCursor(blocks, 0).minSegmentCost(0)).toBe(24); }); + + it('includes embedded table fragment spacing for partial row-boundary slices', () => { + const nestedTable: TableMeasure = { + kind: 'table', + rows: [ + { cells: [{ blocks: [makeParagraph(1)], width: 80, height: 20 }], height: 20 }, + { cells: [{ blocks: [makeParagraph(1)], width: 80, height: 20 }], height: 20 }, + ], + columnWidths: [80], + totalWidth: 80, + totalHeight: 46, + cellSpacingPx: 2, + }; + const cell: TableCellMeasure = { + blocks: [nestedTable], + width: 100, + height: 46, + }; + const blocks = describeCellRenderBlocks(cell, undefined, { top: 0, bottom: 0 }); + + expect(computeCellSliceContentHeight(blocks, 0, 1)).toBe(24); + expect(createCellSliceCursor(blocks, 0).advanceLine(0)).toBe(24); + expect(createCellSliceCursor(blocks, 0).minSegmentCost(0)).toBe(24); + }); }); diff --git a/packages/layout-engine/contracts/src/table-cell-slice.ts b/packages/layout-engine/contracts/src/table-cell-slice.ts index 0905fa1d7f..9cfa8e554e 100644 --- a/packages/layout-engine/contracts/src/table-cell-slice.ts +++ b/packages/layout-engine/contracts/src/table-cell-slice.ts @@ -12,6 +12,9 @@ export type CellRenderBlock = { isLastBlock: boolean; spacingBefore: number; spacingAfter: number; + tableRows?: TableRenderRow[]; + cellSpacingPx?: number; + tableBorderVerticalPx?: number; }; export interface CellSliceCursor { @@ -19,6 +22,13 @@ export interface CellSliceCursor { minSegmentCost(globalLineIndex: number): number; } +type TableRenderRow = { + localStartLine: number; + localEndLine: number; + height: number; + lineHeights: number[]; +}; + export function getEmbeddedRowLines(row: TableRowMeasure): Array<{ lineHeight: number }> { const hasNestedTable = row.cells.some((cell) => cell.blocks?.some((block) => block.kind === 'table')); @@ -108,15 +118,33 @@ export function describeCellRenderBlocks( } else if (measure.kind === 'table') { const tableMeasure = measure as TableMeasure; const lineHeights: number[] = []; + const tableRows: TableRenderRow[] = []; + let tableLocalLine = 0; for (const row of tableMeasure.rows) { + const rowLineHeights: number[] = []; for (const segment of getEmbeddedRowLines(row)) { - lineHeights.push(segment.lineHeight); + rowLineHeights.push(segment.lineHeight); } + lineHeights.push(...rowLineHeights); + tableRows.push({ + localStartLine: tableLocalLine, + localEndLine: tableLocalLine + rowLineHeights.length, + height: row.height, + lineHeights: rowLineHeights, + }); + tableLocalLine += rowLineHeights.length; } const startLine = globalLine; globalLine += lineHeights.length; const sumLines = sumArray(lineHeights); + const tableData = data?.kind === 'table' ? data : undefined; + const borderCollapse = + tableData?.attrs?.borderCollapse ?? (tableData?.attrs?.cellSpacing != null ? 'separate' : 'collapse'); + const tableBorderVerticalPx = + borderCollapse === 'separate' && tableMeasure.tableBorderWidths + ? tableMeasure.tableBorderWidths.top + tableMeasure.tableBorderWidths.bottom + : 0; result.push({ kind: 'table', @@ -129,6 +157,9 @@ export function describeCellRenderBlocks( isLastBlock, spacingBefore: 0, spacingAfter: 0, + tableRows, + cellSpacingPx: tableMeasure.cellSpacingPx ?? 0, + tableBorderVerticalPx, }); } else { const blockHeight = 'height' in measure ? (measure as { height: number }).height : 0; @@ -179,9 +210,13 @@ export function computeCellSliceContentHeight(blocks: CellRenderBlock[], fromLin height += sliceLineSum; } } else if (block.visibleHeight > 0) { - const sliceLineSum = sumArray(block.lineHeights.slice(localStart, localEnd)); - if (block.kind === 'table' && rendersEntireBlock) { - height += Math.max(sliceLineSum, block.totalHeight); + if (block.kind === 'table') { + const tableSliceHeight = computeTableBlockSliceHeight(block, localStart, localEnd); + if (rendersEntireBlock) { + height += Math.max(tableSliceHeight, block.totalHeight); + } else { + height += tableSliceHeight; + } continue; } @@ -198,6 +233,8 @@ export function createCellSliceCursor(blocks: CellRenderBlock[], startLine: numb let blockIdx = 0; let startedFromLine0 = false; let blockLineSum = 0; + let tableSliceStartLocal = 0; + let tableSliceHeight = 0; while (blockIdx < blocks.length && blocks[blockIdx].globalEndLine <= startLine) { blockIdx += 1; @@ -205,6 +242,7 @@ export function createCellSliceCursor(blocks: CellRenderBlock[], startLine: numb if (blockIdx < blocks.length) { const block = blocks[blockIdx]; startedFromLine0 = startLine <= block.globalStartLine; + tableSliceStartLocal = Math.max(0, startLine - block.globalStartLine); if (!startedFromLine0) { for (let li = 0; li < startLine - block.globalStartLine; li += 1) { blockLineSum += block.lineHeights[li] ?? 0; @@ -218,6 +256,8 @@ export function createCellSliceCursor(blocks: CellRenderBlock[], startLine: numb blockIdx += 1; startedFromLine0 = true; blockLineSum = 0; + tableSliceStartLocal = 0; + tableSliceHeight = 0; } if (blockIdx >= blocks.length) return 0; @@ -226,6 +266,26 @@ export function createCellSliceCursor(blocks: CellRenderBlock[], startLine: numb const lineHeight = block.lineHeights[localLine] ?? 0; let cost = 0; + if (block.kind === 'table') { + const nextTableSliceHeight = computeTableBlockSliceHeight(block, tableSliceStartLocal, localLine + 1); + cost = Math.max(0, nextTableSliceHeight - tableSliceHeight); + tableSliceHeight = nextTableSliceHeight; + + const isBlockComplete = localLine === block.lineHeights.length - 1; + if (isBlockComplete) { + if (startedFromLine0) { + cost += Math.max(0, block.totalHeight - tableSliceHeight); + } + blockIdx += 1; + startedFromLine0 = true; + blockLineSum = 0; + tableSliceStartLocal = 0; + tableSliceHeight = 0; + } + + return cost; + } + if (localLine === 0 && startedFromLine0 && block.kind === 'paragraph') { cost += block.spacingBefore; } @@ -236,7 +296,7 @@ export function createCellSliceCursor(blocks: CellRenderBlock[], startLine: numb blockLineSum += lineHeight; const isBlockComplete = localLine === block.lineHeights.length - 1; - if (isBlockComplete && startedFromLine0 && (block.kind === 'paragraph' || block.kind === 'table')) { + if (isBlockComplete && startedFromLine0 && block.kind === 'paragraph') { cost += Math.max(0, block.totalHeight - blockLineSum); } if (isBlockComplete && startedFromLine0 && block.kind === 'paragraph') { @@ -246,6 +306,8 @@ export function createCellSliceCursor(blocks: CellRenderBlock[], startLine: numb blockIdx += 1; startedFromLine0 = true; blockLineSum = 0; + tableSliceStartLocal = 0; + tableSliceHeight = 0; } return cost; @@ -265,7 +327,10 @@ export function createCellSliceCursor(blocks: CellRenderBlock[], startLine: numb if (block.kind === 'paragraph' || block.visibleHeight > 0) { cost += lineHeight; } - if (block.lineHeights.length === 1 && (block.kind === 'paragraph' || block.kind === 'table')) { + if (block.kind === 'table') { + return computeTableBlockSliceHeight(block, localLine, localLine + 1); + } + if (block.lineHeights.length === 1 && block.kind === 'paragraph') { cost += Math.max(0, block.totalHeight - lineHeight); } if (block.lineHeights.length === 1 && block.kind === 'paragraph') { @@ -373,6 +438,37 @@ function findBlockForLine(blocks: CellRenderBlock[], globalLineIndex: number): C return blocks.find((block) => globalLineIndex >= block.globalStartLine && globalLineIndex < block.globalEndLine); } +function computeTableBlockSliceHeight(block: CellRenderBlock, localStart: number, localEnd: number): number { + if (!block.tableRows) { + return sumArray(block.lineHeights.slice(localStart, localEnd)); + } + + let height = 0; + let rowCount = 0; + + for (const row of block.tableRows) { + if (row.localEndLine <= localStart || row.localStartLine >= localEnd) continue; + + rowCount += 1; + const rowLocalStart = Math.max(0, localStart - row.localStartLine); + const rowLocalEnd = Math.min(row.lineHeights.length, localEnd - row.localStartLine); + const rendersFullRow = rowLocalStart === 0 && rowLocalEnd >= row.lineHeights.length; + + if (rendersFullRow) { + height += row.height; + } else { + height += sumArray(row.lineHeights.slice(rowLocalStart, rowLocalEnd)); + } + } + + if (rowCount > 0) { + height += (rowCount + 1) * (block.cellSpacingPx ?? 0); + height += block.tableBorderVerticalPx ?? 0; + } + + return height; +} + function sumArray(arr: number[]): number { let total = 0; for (const value of arr) total += value; From 4fdc962805c472d732589b409b8c2dd87c19c304 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 18 May 2026 13:29:13 -0300 Subject: [PATCH 05/11] refactor(layout-engine): share table fragment height math --- packages/layout-engine/contracts/src/index.ts | 1 + .../src/table-fragment-height.test.ts | 51 +++++++++++++++++++ .../contracts/src/table-fragment-height.ts | 36 +++++++++++++ .../layout-engine/src/layout-table.ts | 41 +++------------ .../dom/src/table/embeddedTableFragment.ts | 31 ++++------- 5 files changed, 106 insertions(+), 54 deletions(-) create mode 100644 packages/layout-engine/contracts/src/table-fragment-height.test.ts create mode 100644 packages/layout-engine/contracts/src/table-fragment-height.ts diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 770ebf1bf4..73e7db801f 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -28,6 +28,7 @@ export { } from './engines/tables.js'; export { effectiveTableCellSpacing } from './table-cell-spacing.js'; +export { computeTableFragmentHeight } from './table-fragment-height.js'; export { computeCellSliceContentHeight, computeFullCellContentHeight, diff --git a/packages/layout-engine/contracts/src/table-fragment-height.test.ts b/packages/layout-engine/contracts/src/table-fragment-height.test.ts new file mode 100644 index 0000000000..a1340c69d9 --- /dev/null +++ b/packages/layout-engine/contracts/src/table-fragment-height.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import type { PartialRowInfo, TableMeasure } from './index.js'; +import { computeTableFragmentHeight } from './table-fragment-height.js'; + +describe('computeTableFragmentHeight', () => { + const measure: TableMeasure = { + kind: 'table', + rows: [ + { height: 10, cells: [] }, + { height: 20, cells: [] }, + { height: 30, cells: [] }, + ], + columnWidths: [100], + totalWidth: 100, + totalHeight: 72, + cellSpacingPx: 2, + tableBorderWidths: { top: 3, right: 0, bottom: 5, left: 0 }, + }; + + it('includes repeated headers, body rows, spacing, and separate borders', () => { + expect( + computeTableFragmentHeight({ + measure, + fromRow: 1, + toRow: 3, + repeatHeaderCount: 1, + borderCollapse: 'separate', + }), + ).toBe(10 + 20 + 30 + 4 * 2 + 3 + 5); + }); + + it('substitutes partial row height', () => { + const partialRow: PartialRowInfo = { + rowIndex: 1, + fromLineByCell: [0], + toLineByCell: [1], + isFirstPart: true, + isLastPart: false, + partialHeight: 12, + }; + + expect( + computeTableFragmentHeight({ + measure, + fromRow: 1, + toRow: 2, + partialRow, + }), + ).toBe(12 + 2 * 2); + }); +}); diff --git a/packages/layout-engine/contracts/src/table-fragment-height.ts b/packages/layout-engine/contracts/src/table-fragment-height.ts new file mode 100644 index 0000000000..94ebc6d5e5 --- /dev/null +++ b/packages/layout-engine/contracts/src/table-fragment-height.ts @@ -0,0 +1,36 @@ +import type { PartialRowInfo, TableMeasure } from './index.js'; + +export function computeTableFragmentHeight(params: { + measure: TableMeasure; + fromRow: number; + toRow: number; + repeatHeaderCount?: number; + borderCollapse?: 'collapse' | 'separate'; + partialRow?: PartialRowInfo | null; + cellSpacingPx?: number; +}): number { + const { measure, fromRow, toRow, repeatHeaderCount = 0, borderCollapse, partialRow } = params; + const cellSpacingPx = params.cellSpacingPx ?? measure.cellSpacingPx ?? 0; + let height = 0; + let rowCount = 0; + + for (let r = 0; r < repeatHeaderCount && r < measure.rows.length; r += 1) { + height += measure.rows[r].height; + rowCount += 1; + } + + for (let r = fromRow; r < toRow && r < measure.rows.length; r += 1) { + height += partialRow?.rowIndex === r ? partialRow.partialHeight : measure.rows[r].height; + rowCount += 1; + } + + if (rowCount > 0 && cellSpacingPx > 0) { + height += (rowCount + 1) * cellSpacingPx; + } + + if (rowCount > 0 && borderCollapse === 'separate' && measure.tableBorderWidths) { + height += measure.tableBorderWidths.top + measure.tableBorderWidths.bottom; + } + + return height; +} diff --git a/packages/layout-engine/layout-engine/src/layout-table.ts b/packages/layout-engine/layout-engine/src/layout-table.ts index fe18b0dae2..7b20406be0 100644 --- a/packages/layout-engine/layout-engine/src/layout-table.ts +++ b/packages/layout-engine/layout-engine/src/layout-table.ts @@ -11,12 +11,17 @@ import type { ParagraphMeasure, ParagraphBlock, } from '@superdoc/contracts'; -import { OOXML_PCT_DIVISOR, rescaleColumnWidths, resolveTableWidthAttr, +import { + OOXML_PCT_DIVISOR, + computeTableFragmentHeight, + rescaleColumnWidths, + resolveTableWidthAttr, describeCellRenderBlocks, createCellSliceCursor, computeFullCellContentHeight, getCellLines, - getEmbeddedRowLines } from '@superdoc/contracts'; + getEmbeddedRowLines, +} from '@superdoc/contracts'; import type { PageState } from './paginator.js'; import { computeFragmentPmRange, extractBlockPmRange } from './layout-utils.js'; @@ -431,37 +436,7 @@ function computeFragmentHeight( borderCollapse?: 'collapse' | 'separate', partialRow?: PartialRowInfo | null, ): number { - let height = 0; - let rowCount = 0; - - // Repeated headers - if (repeatHeaderCount > 0) { - height += sumRowHeights(measure.rows, 0, repeatHeaderCount); - rowCount += repeatHeaderCount; - } - - // Body rows — substitute partialRow height when applicable - for (let i = fromRow; i < toRow && i < measure.rows.length; i++) { - if (partialRow && partialRow.rowIndex === i) { - height += partialRow.partialHeight; - } else { - height += measure.rows[i].height; - } - rowCount++; - } - - // Cell spacing: gaps before first row, between rows, and after last row - const cellSpacingPx = measure.cellSpacingPx ?? 0; - if (rowCount > 0 && cellSpacingPx > 0) { - height += (rowCount + 1) * cellSpacingPx; - } - - // Outer border height when border-collapse is separate - if (rowCount > 0 && measure.tableBorderWidths && borderCollapse === 'separate') { - height += measure.tableBorderWidths.top + measure.tableBorderWidths.bottom; - } - - return height; + return computeTableFragmentHeight({ measure, fromRow, toRow, repeatHeaderCount, borderCollapse, partialRow }); } type SplitPointResult = { diff --git a/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.ts b/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.ts index 02607decde..22108e3ac1 100644 --- a/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.ts @@ -1,6 +1,7 @@ import type { PartialRowInfo, TableBlock, TableMeasure, TableRow } from '@superdoc/contracts'; import { computeCellSliceContentHeight, + computeTableFragmentHeight, describeCellRenderBlocks, getCellLines, getCellSpacingPx, @@ -31,30 +32,18 @@ export function computeRenderedTableFragmentHeight(params: { repeatHeaderCount?: number; }): number { const { block, measure, fromRow, toRow, partialRow, repeatHeaderCount = 0 } = params; - let height = 0; - let rowCount = 0; - - for (let r = 0; r < repeatHeaderCount && r < measure.rows.length; r += 1) { - height += measure.rows[r].height; - rowCount += 1; - } - - for (let r = fromRow; r < toRow && r < measure.rows.length; r += 1) { - height += partialRow?.rowIndex === r ? partialRow.partialHeight : measure.rows[r].height; - rowCount += 1; - } - const cellSpacingPx = measure.cellSpacingPx ?? getCellSpacingPx(block.attrs?.cellSpacing); - if (rowCount > 0 && cellSpacingPx > 0) { - height += (rowCount + 1) * cellSpacingPx; - } - const borderCollapse = block.attrs?.borderCollapse ?? (block.attrs?.cellSpacing != null ? 'separate' : 'collapse'); - if (rowCount > 0 && borderCollapse === 'separate' && measure.tableBorderWidths) { - height += measure.tableBorderWidths.top + measure.tableBorderWidths.bottom; - } - return height; + return computeTableFragmentHeight({ + measure, + fromRow, + toRow, + repeatHeaderCount, + borderCollapse, + partialRow, + cellSpacingPx, + }); } export function createEmbeddedTableFragment(params: { From c7ee758e27934b5f8c2775a99e3dc1610ca57ea3 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 18 May 2026 13:30:03 -0300 Subject: [PATCH 06/11] refactor(layout-engine): remove table slice shim --- packages/layout-engine/layout-bridge/src/index.ts | 4 +++- packages/layout-engine/layout-engine/src/index.ts | 2 -- .../layout-engine/src/table-cell-slice.test.ts | 2 +- .../layout-engine/src/table-cell-slice.ts | 10 ---------- 4 files changed, 4 insertions(+), 14 deletions(-) delete mode 100644 packages/layout-engine/layout-engine/src/table-cell-slice.ts diff --git a/packages/layout-engine/layout-bridge/src/index.ts b/packages/layout-engine/layout-bridge/src/index.ts index a1820b6e9b..511b660e35 100644 --- a/packages/layout-engine/layout-bridge/src/index.ts +++ b/packages/layout-engine/layout-bridge/src/index.ts @@ -15,11 +15,13 @@ import type { } from '@superdoc/contracts'; import { adjustAvailableWidthForTextIndent, + computeCellSliceContentHeight, + describeCellRenderBlocks, computeLinePmRange as computeLinePmRangeUnified, effectiveTableCellSpacing, getFirstLineIndentOffset, + getEmbeddedRowLines, } from '@superdoc/contracts'; -import { describeCellRenderBlocks, computeCellSliceContentHeight, getEmbeddedRowLines } from '@superdoc/layout-engine'; import { measureCharacterX } from './text-measurement.js'; import { clickToPositionDom, findPageElement } from './dom-mapping.js'; import { diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 00a59ba0b8..5883b88e6e 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -3400,7 +3400,5 @@ export type { NumberingContext, ResolvePageTokensResult } from './resolvePageTok // Table utilities consumed by layout-bridge and cross-package sync tests export { resolveTableFrame, resolveRenderedTableWidth } from './layout-table.js'; -export { describeCellRenderBlocks, computeCellSliceContentHeight } from './table-cell-slice.js'; -export { getCellLines, getEmbeddedRowLines } from '@superdoc/contracts'; export { SINGLE_COLUMN_DEFAULT } from './section-breaks.js'; diff --git a/packages/layout-engine/layout-engine/src/table-cell-slice.test.ts b/packages/layout-engine/layout-engine/src/table-cell-slice.test.ts index da222c7e67..f12972fb3f 100644 --- a/packages/layout-engine/layout-engine/src/table-cell-slice.test.ts +++ b/packages/layout-engine/layout-engine/src/table-cell-slice.test.ts @@ -7,7 +7,7 @@ import { computeFullCellContentHeight, createCellSliceCursor, type CellRenderBlock, -} from './table-cell-slice.js'; +} from '@superdoc/contracts'; // ─── Test helpers ──────────────────────────────────────────────────────────── diff --git a/packages/layout-engine/layout-engine/src/table-cell-slice.ts b/packages/layout-engine/layout-engine/src/table-cell-slice.ts deleted file mode 100644 index 9772a36e70..0000000000 --- a/packages/layout-engine/layout-engine/src/table-cell-slice.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { - computeCellSliceContentHeight, - computeFullCellContentHeight, - createCellSliceCursor, - describeCellRenderBlocks, - getCellLines, - getEmbeddedRowLines, - type CellRenderBlock, - type CellSliceCursor, -} from '@superdoc/contracts'; From 9f2cca81767ad8cfc5006f5689249d8a17088d90 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 18 May 2026 13:30:34 -0300 Subject: [PATCH 07/11] docs(contracts): restore table slice height rationale --- .../layout-engine/contracts/src/table-cell-slice.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/layout-engine/contracts/src/table-cell-slice.ts b/packages/layout-engine/contracts/src/table-cell-slice.ts index 9cfa8e554e..780d670bf1 100644 --- a/packages/layout-engine/contracts/src/table-cell-slice.ts +++ b/packages/layout-engine/contracts/src/table-cell-slice.ts @@ -1,6 +1,13 @@ import type { ParagraphMeasure, TableCell, TableCellMeasure, TableMeasure, TableRowMeasure } from './index.js'; import { effectiveTableCellSpacing } from './table-cell-spacing.js'; +/** + * Shared cell-slice helpers for table pagination and rendering. + * + * These descriptors are consumed by layout pagination, layout-bridge selection + * geometry, and DomPainter nested-table rendering. Keep their height semantics + * aligned with the actual table-cell renderer. + */ export type CellRenderBlock = { kind: 'paragraph' | 'table' | 'other'; globalStartLine: number; @@ -360,6 +367,12 @@ export function computeFullCellContentHeight( return spacingBefore + Math.max(lineSum, cellMeasure.paragraph.totalHeight ?? lineSum) + spacingAfter; } + // This function uses measurement semantics: the final paragraph's spacing.after + // contributes only when it exceeds cell padding. Renderer-slice helpers skip + // last-block spacing.after because DomPainter positions the visible content + // inside the already padded cell. Keeping that distinction explicit prevents + // row-height preflight from comparing measured row heights to renderer-only + // slice heights. let height = 0; for (let i = 0; i < measuredBlocks.length; i += 1) { const measure = measuredBlocks[i]; From 15f5c2cdf36f601ccea1a3dc5402b205718762ab Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 18 May 2026 13:31:15 -0300 Subject: [PATCH 08/11] test(painter-dom): cover embedded row slice mapping --- .../src/table/embeddedTableFragment.test.ts | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 packages/layout-engine/painters/dom/src/table/embeddedTableFragment.test.ts diff --git a/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.test.ts b/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.test.ts new file mode 100644 index 0000000000..33ea4143d1 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; +import type { TableBlock, TableMeasure } from '@superdoc/contracts'; +import { mapEmbeddedTableRowSlice } from './embeddedTableFragment.js'; + +const makeNestedTableMeasure = (rowHeights: number[]): TableMeasure => ({ + kind: 'table', + rows: rowHeights.map((height) => ({ height, cells: [{ width: 40, height, blocks: [] }] })), + columnWidths: [40], + totalWidth: 40, + totalHeight: rowHeights.reduce((sum, height) => sum + height, 0), +}); + +const makeNestedTableBlock = (id: string, rowCount: number): TableBlock => ({ + kind: 'table', + id, + rows: Array.from({ length: rowCount }, (_, index) => ({ + id: `${id}-row-${index}`, + cells: [{ id: `${id}-cell-${index}`, blocks: [], attrs: {} }], + attrs: {}, + })), +}); + +describe('mapEmbeddedTableRowSlice', () => { + it('maps a single-segment row without creating partial row info', () => { + const block: TableBlock = { + kind: 'table', + id: 'table', + rows: [ + { id: 'row-0', cells: [{ id: 'cell-0', blocks: [], attrs: {} }], attrs: {} }, + { id: 'row-1', cells: [{ id: 'cell-1', blocks: [], attrs: {} }], attrs: {} }, + ], + }; + const measure: TableMeasure = { + kind: 'table', + rows: [ + { height: 10, cells: [{ width: 40, height: 10, blocks: [] }] }, + { height: 12, cells: [{ width: 40, height: 12, blocks: [] }] }, + ], + columnWidths: [40], + totalWidth: 40, + totalHeight: 22, + }; + + expect(mapEmbeddedTableRowSlice({ block, measure, localFrom: 0, localTo: 1 })).toEqual({ + fromRow: 0, + toRow: 1, + partialRow: undefined, + }); + }); + + it('computes partial row info for a multi-segment row clipped at both ends', () => { + const innerMeasure = makeNestedTableMeasure([5, 7, 11, 13]); + const innerBlock = makeNestedTableBlock('inner', 4); + const block: TableBlock = { + kind: 'table', + id: 'table', + rows: [ + { + id: 'row-0', + cells: [{ id: 'cell-0', blocks: [innerBlock], attrs: { padding: { top: 2, bottom: 3 } } }], + attrs: {}, + }, + ], + }; + const measure: TableMeasure = { + kind: 'table', + rows: [{ height: 36, cells: [{ width: 40, height: 36, blocks: [innerMeasure] }] }], + columnWidths: [40], + totalWidth: 40, + totalHeight: 36, + }; + + expect(mapEmbeddedTableRowSlice({ block, measure, localFrom: 1, localTo: 3 })).toEqual({ + fromRow: 0, + toRow: 1, + partialRow: { + rowIndex: 0, + fromLineByCell: [1], + toLineByCell: [3], + isFirstPart: false, + isLastPart: false, + partialHeight: 23, + }, + }); + }); + + it('returns null for an out-of-range segment window', () => { + const block = makeNestedTableBlock('table', 1); + const measure: TableMeasure = { + kind: 'table', + rows: [{ height: 10, cells: [{ width: 40, height: 10, blocks: [] }] }], + columnWidths: [40], + totalWidth: 40, + totalHeight: 10, + }; + + expect(mapEmbeddedTableRowSlice({ block, measure, localFrom: 2, localTo: 3 })).toBeNull(); + }); +}); From 56a098a2c5e12bc231dc3b8f57b4cda55d1686e5 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 18 May 2026 13:31:34 -0300 Subject: [PATCH 09/11] docs(painter-dom): note embedded partial row limit --- .../painters/dom/src/table/embeddedTableFragment.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.ts b/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.ts index 22108e3ac1..27d80ee8f7 100644 --- a/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.ts @@ -101,6 +101,8 @@ export function mapEmbeddedTableRowSlice(params: { toRow = r + 1; if (rowSegmentCount > 1 && (rowStart < localFrom || rowEnd > localTo)) { + // AIDEV-NOTE: TableFragment supports one partialRow, so a slice that clips + // multiple multi-segment rows keeps the last partial row's metadata. partialRow = buildPartialRowInfo({ blockRow: block.rows[r], row: measure.rows[r], From 7dfb9a281dea56807a2eae6ba5e494aafb37ee6f Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 18 May 2026 13:31:52 -0300 Subject: [PATCH 10/11] docs(contracts): mark table traversal helpers internal --- packages/layout-engine/contracts/src/table-cell-slice.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/layout-engine/contracts/src/table-cell-slice.ts b/packages/layout-engine/contracts/src/table-cell-slice.ts index 780d670bf1..9ad5df6577 100644 --- a/packages/layout-engine/contracts/src/table-cell-slice.ts +++ b/packages/layout-engine/contracts/src/table-cell-slice.ts @@ -36,6 +36,7 @@ type TableRenderRow = { lineHeights: number[]; }; +/** @internal */ export function getEmbeddedRowLines(row: TableRowMeasure): Array<{ lineHeight: number }> { const hasNestedTable = row.cells.some((cell) => cell.blocks?.some((block) => block.kind === 'table')); @@ -54,6 +55,7 @@ export function getEmbeddedRowLines(row: TableRowMeasure): Array<{ lineHeight: n return tallestLines.length > 0 ? tallestLines : [{ lineHeight: row.height || 0 }]; } +/** @internal */ export function getCellLines(cell: TableCellMeasure): Array<{ lineHeight: number }> { if (cell.blocks && cell.blocks.length > 0) { const allLines: Array<{ lineHeight: number }> = []; From fa69af7b0877595968e3c34ff76841f42fe6e60c Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 18 May 2026 13:32:30 -0300 Subject: [PATCH 11/11] refactor(painter-dom): import table renderer helpers --- packages/layout-engine/painters/dom/src/renderer.ts | 3 --- .../dom/src/table/renderResolvedTableFragment.test.ts | 5 ----- .../dom/src/table/renderResolvedTableFragment.ts | 10 ++-------- 3 files changed, 2 insertions(+), 16 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 2f9e3de20c..3fd6db52e6 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -2400,9 +2400,6 @@ export class DomPainter { applyFragmentFrame: this.applyFragmentFrame.bind(this), applyResolvedFragmentFrame: this.applyResolvedFragmentFrame.bind(this), createErrorPlaceholder: this.createErrorPlaceholder.bind(this), - applySdtDataset, - applyContainerSdtDataset, - applyStyles, }); } throw new Error(`DomPainter: unsupported fragment kind ${(fragment as Fragment).kind}`); diff --git a/packages/layout-engine/painters/dom/src/table/renderResolvedTableFragment.test.ts b/packages/layout-engine/painters/dom/src/table/renderResolvedTableFragment.test.ts index 48d1b32cc9..b9cf3ae6bf 100644 --- a/packages/layout-engine/painters/dom/src/table/renderResolvedTableFragment.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderResolvedTableFragment.test.ts @@ -8,8 +8,6 @@ import type { TableFragment, TableMeasure, } from '@superdoc/contracts'; -import { applyContainerSdtDataset, applySdtDataset } from '../sdt/dataset.js'; -import { applyStyles } from '../utils/apply-styles.js'; import type { FragmentRenderContext } from '../renderer.js'; import { renderDrawingContent } from '../drawings/renderDrawingContent.js'; import { renderResolvedTableFragment } from './renderResolvedTableFragment.js'; @@ -102,9 +100,6 @@ function createDeps(overrides: Partial void; createErrorPlaceholder: (blockId: string, error: unknown) => HTMLElement; - applySdtDataset: typeof applySdtDataset; - applyContainerSdtDataset: typeof applyContainerSdtDataset; - applyStyles: typeof applyStyles; }; const resolveTableRenderData = (fragment: TableFragment, resolvedItem?: ResolvedTableItem): TableRenderData => { @@ -83,9 +80,6 @@ export const renderResolvedTableFragment = ({ applyFragmentFrame, applyResolvedFragmentFrame, createErrorPlaceholder, - applySdtDataset, - applyContainerSdtDataset, - applyStyles, }: RenderResolvedTableFragmentDeps): HTMLElement => { try { if (!doc) {