From d0c84f44d48203d63c3ed24b2097471c7916772b Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Tue, 10 Feb 2026 14:39:06 -0300 Subject: [PATCH 1/4] fix(tables): fix autofit table column rendering and page break splitting Fix autofit tables dropping columns when rows use rowspan/colspan patterns and improve page break splitting to match Word behavior. Column rendering fixes: - Prefer grid values over colwidth for column widths (grid sums to page width) - Use colSpan sums for column count instead of physical cell count - Distribute colspan tcW proportionally across grid columns during DOCX import Page break improvements: - Rowspan-aware split point selection (prefer breaks at rowspan boundaries) - Ghost cell rendering for rowspan cells spanning page breaks - Skip header repeat when it would prevent a cantSplit row from fitting - Fix fullPageHeight to use actual content area (contentBottom - topMargin) Cell height refinement: - Skip last paragraph spacing.after in cell measuring and rendering SD-1797 --- .../layout-engine/src/layout-table.ts | 63 ++++- .../measuring/dom/src/index.test.ts | 258 +++++++++++++++++- .../layout-engine/measuring/dom/src/index.ts | 15 +- .../dom/src/table/renderTableCell.test.ts | 22 +- .../painters/dom/src/table/renderTableCell.ts | 7 +- .../dom/src/table/renderTableFragment.ts | 103 +++++++ .../pm-adapter/src/converters/table.test.ts | 12 +- .../pm-adapter/src/converters/table.ts | 45 +-- .../helpers/legacy-handle-table-cell-node.js | 12 +- .../src/tests/data/pci-table.docx | Bin 0 -> 18135 bytes .../src/tests/data/table-autofit-colspan.docx | Bin 0 -> 18135 bytes .../super-editor/src/tests/data/table.docx | Bin 0 -> 14760 bytes .../regression/table-autofit-colspan.test.js | 57 ++++ 13 files changed, 533 insertions(+), 61 deletions(-) create mode 100644 packages/super-editor/src/tests/data/pci-table.docx create mode 100644 packages/super-editor/src/tests/data/table-autofit-colspan.docx create mode 100644 packages/super-editor/src/tests/data/table.docx create mode 100644 packages/super-editor/src/tests/regression/table-autofit-colspan.test.js diff --git a/packages/layout-engine/layout-engine/src/layout-table.ts b/packages/layout-engine/layout-engine/src/layout-table.ts index 4fe6a0233a..588b3b9cdd 100644 --- a/packages/layout-engine/layout-engine/src/layout-table.ts +++ b/packages/layout-engine/layout-engine/src/layout-table.ts @@ -300,6 +300,7 @@ function calculateFragmentHeight( type SplitPointResult = { endRow: number; // Exclusive row index (next row after last included) partialRow: PartialRowInfo | null; // Null for row-boundary splits, PartialRowInfo for mid-row splits + forcePageBreak?: boolean; // When true, force a page break after this fragment (rowspan-aware clean break) }; /** @@ -864,6 +865,13 @@ function findSplitPoint( let accumulatedHeight = 0; let lastFitRow = startRow; // Last row that fit completely + // Rowspan-aware splitting: track the farthest row reached by any active rowspan + // and the last boundary where no rowspan crosses (a "clean" break point). + // When the standard break point splits a rowspan group, prefer the clean break + // to avoid continuation cells and match Word's behavior. + let maxRowspanEnd = startRow; + let lastCleanFitRow = startRow; + for (let i = startRow; i < block.rows.length; i++) { const row = block.rows[i]; const rowMeasure = measure.rows[i]; @@ -873,11 +881,26 @@ function findSplitPoint( cantSplit = true; } + // Track the farthest rowspan extent from this row's cells + if (rowMeasure) { + for (const cellMeasure of rowMeasure.cells) { + const rs = cellMeasure.rowSpan ?? 1; + if (rs > 1) { + maxRowspanEnd = Math.max(maxRowspanEnd, i + rs); + } + } + } + // Check if this row fits completely if (accumulatedHeight + rowHeight <= availableHeight) { // Row fits completely accumulatedHeight += rowHeight; lastFitRow = i + 1; // Next row index (exclusive) + + // A boundary is "clean" if no active rowspan crosses it + if (maxRowspanEnd <= i + 1) { + lastCleanFitRow = i + 1; + } } else { // Row doesn't fit completely const remainingHeight = availableHeight - accumulatedHeight; @@ -895,6 +918,10 @@ function findSplitPoint( if (lastFitRow === startRow) { return { endRow: startRow, partialRow: null }; } + // Prefer a clean break point that avoids splitting rowspan groups + if (maxRowspanEnd > lastFitRow && lastCleanFitRow > startRow) { + return { endRow: lastCleanFitRow, partialRow: null, forcePageBreak: true }; + } // Break before the cantSplit row return { endRow: lastFitRow, partialRow: null }; } @@ -915,7 +942,10 @@ function findSplitPoint( } } - // Can't fit any content from this row - break before it + // Can't fit any content from this row - prefer clean break if available + if (maxRowspanEnd > lastFitRow && lastCleanFitRow > startRow) { + return { endRow: lastCleanFitRow, partialRow: null, forcePageBreak: true }; + } return { endRow: lastFitRow, partialRow: null }; } } @@ -1148,11 +1178,25 @@ export function layoutTableBlock({ } } + // If repeated headers would prevent a cantSplit row from fitting, skip header repetition. + // Word does not split cantSplit rows just because repeated headers eat up space. + if (repeatHeaderCount > 0 && !pendingPartialRow) { + const bodyRow = block.rows[currentRow]; + const bodyRowHeight = measure.rows[currentRow]?.height || 0; + const bodyCantSplit = bodyRow?.attrs?.tableRowProperties?.cantSplit === true; + const spaceWithHeaders = availableHeight - headerHeight; + if (bodyCantSplit && bodyRowHeight > spaceWithHeaders && bodyRowHeight <= availableHeight) { + repeatHeaderCount = 0; + } + } + // Adjust available height for header repetition const availableForBody = repeatHeaderCount > 0 ? availableHeight - headerHeight : availableHeight; // Calculate full page height (for detecting over-tall rows) - const fullPageHeight = state.contentBottom; // Assumes content starts at y=0 + // This is the actual usable content area height, accounting for top margin. + // The ?? 0 handles test fixtures that may not set topMargin. + const fullPageHeight = state.contentBottom - (state.topMargin ?? 0); // Handle pending partial row continuation if (pendingPartialRow !== null) { @@ -1236,7 +1280,13 @@ export function layoutTableBlock({ // Normal row processing const bodyStartRow = currentRow; - const { endRow, partialRow } = findSplitPoint(block, measure, bodyStartRow, availableForBody, fullPageHeight); + const { endRow, partialRow, forcePageBreak } = findSplitPoint( + block, + measure, + bodyStartRow, + availableForBody, + fullPageHeight, + ); // If no rows fit and page has content, advance if (endRow === bodyStartRow && partialRow === null && state.page.fragments.length > 0) { @@ -1326,6 +1376,13 @@ export function layoutTableBlock({ } isTableContinuation = true; + + // If findSplitPoint chose a clean rowspan boundary (earlier than the standard break), + // force a page break so the remaining rows start on the next page instead of + // continuing to fill the current page with another fragment. + if (forcePageBreak && currentRow < block.rows.length) { + state = advanceColumn(state); + } } } diff --git a/packages/layout-engine/measuring/dom/src/index.test.ts b/packages/layout-engine/measuring/dom/src/index.test.ts index dd35aa5b11..7b39ecd8a1 100644 --- a/packages/layout-engine/measuring/dom/src/index.test.ts +++ b/packages/layout-engine/measuring/dom/src/index.test.ts @@ -7,6 +7,7 @@ import type { Measure, DrawingMeasure, DrawingBlock, + TableMeasure, } from '@superdoc/contracts'; const expectParagraphMeasure = (measure: Measure): ParagraphMeasure => { @@ -3428,6 +3429,145 @@ describe('measureBlock', () => { }); }); + describe('autofit tables with colspan should not truncate grid columns', () => { + const makeCell = (id: string) => ({ + id, + blocks: [ + { + kind: 'paragraph' as const, + id: `para-${id}`, + runs: [{ text: 'Text', fontFamily: 'Arial', fontSize: 12 }], + }, + ], + }); + + it('preserves all 4 grid columns when max physical cells is 3 but colspans sum to 4', async () => { + // Reproduces SD-1797: table with 4-column grid where no row has 4 physical cells. + // Row patterns: 2 cells (span 3+1), 3 cells (span 1+2+1) + // maxCellCount must be 4 (from colspan sums), not 3 (from physical cell count) + const block: FlowBlock = { + kind: 'table', + id: 'autofit-colspan-table', + rows: [ + { + id: 'row-0', + cells: [ + { ...makeCell('c-0-0'), colSpan: 3 }, + { ...makeCell('c-0-1'), colSpan: 1 }, + ], + }, + { + id: 'row-1', + cells: [ + { ...makeCell('c-1-0'), colSpan: 1 }, + { ...makeCell('c-1-1'), colSpan: 2 }, + { ...makeCell('c-1-2'), colSpan: 1 }, + ], + }, + ], + columnWidths: [172, 13, 128, 310], // 4 grid columns from w:tblGrid + }; + + const measure = await measureBlock(block, { maxWidth: 800 }); + expect(measure.kind).toBe('table'); + if (measure.kind !== 'table') throw new Error('expected table measure'); + + // All 4 column widths should be preserved (not truncated to 3) + expect(measure.columnWidths).toHaveLength(4); + expect(measure.columnWidths).toEqual([172, 13, 128, 310]); + expect(measure.totalWidth).toBe(623); + + // Row 0: 2 cells spanning 3+1 = both cells measured + expect(measure.rows[0].cells).toHaveLength(2); + + // Row 1: 3 cells spanning 1+2+1 = all 3 cells measured + expect(measure.rows[1].cells).toHaveLength(3); + }); + + it('does not drop rightmost cell when colspan exhausts truncated grid', async () => { + // 4-column grid, rows with span patterns [2,2] and [3,1] + // Without the fix, grid gets truncated to 2 columns (max physical cells = 2), + // and the second cell in span [3,1] rows is dropped + const block: FlowBlock = { + kind: 'table', + id: 'autofit-colspan-table-2', + rows: [ + { + id: 'row-0', + cells: [ + { ...makeCell('c-0-0'), colSpan: 2 }, + { ...makeCell('c-0-1'), colSpan: 2 }, + ], + }, + { + id: 'row-1', + cells: [ + { ...makeCell('c-1-0'), colSpan: 3 }, + { ...makeCell('c-1-1'), colSpan: 1 }, + ], + }, + ], + columnWidths: [100, 50, 100, 300], // 4 grid columns + }; + + const measure = await measureBlock(block, { maxWidth: 800 }); + expect(measure.kind).toBe('table'); + if (measure.kind !== 'table') throw new Error('expected table measure'); + + // Grid should not be truncated + expect(measure.columnWidths).toHaveLength(4); + + // Both cells in each row must be present + expect(measure.rows[0].cells).toHaveLength(2); + expect(measure.rows[1].cells).toHaveLength(2); + + // Cell widths should correctly sum their spanned columns + // Row 0 cell 0: cols 0+1 = 100+50 = 150 + expect(measure.rows[0].cells[0].width).toBe(150); + // Row 0 cell 1: cols 2+3 = 100+300 = 400 + expect(measure.rows[0].cells[1].width).toBe(400); + // Row 1 cell 0: cols 0+1+2 = 100+50+100 = 250 + expect(measure.rows[1].cells[0].width).toBe(250); + // Row 1 cell 1: col 3 = 300 + expect(measure.rows[1].cells[1].width).toBe(300); + }); + + it('handles single-cell full-span row correctly', async () => { + const block: FlowBlock = { + kind: 'table', + id: 'autofit-fullspan-table', + rows: [ + { + id: 'row-0', + cells: [{ ...makeCell('c-0-0'), colSpan: 4 }], + }, + { + id: 'row-1', + cells: [ + { ...makeCell('c-1-0'), colSpan: 1 }, + { ...makeCell('c-1-1'), colSpan: 2 }, + { ...makeCell('c-1-2'), colSpan: 1 }, + ], + }, + ], + columnWidths: [100, 50, 100, 300], + }; + + const measure = await measureBlock(block, { maxWidth: 800 }); + expect(measure.kind).toBe('table'); + if (measure.kind !== 'table') throw new Error('expected table measure'); + + expect(measure.columnWidths).toHaveLength(4); + + // Full-span row: 1 cell spanning all 4 columns + expect(measure.rows[0].cells).toHaveLength(1); + expect(measure.rows[0].cells[0].width).toBe(550); // 100+50+100+300 + + // 3-cell row: all cells present + expect(measure.rows[1].cells).toHaveLength(3); + }); + }); + describe('scaleColumnWidths behavior', () => { it('scales column widths proportionally when exceeding target', async () => { const block: FlowBlock = { @@ -4530,7 +4670,7 @@ describe('measureBlock', () => { }); describe('table cell measurement with spacing.after', () => { - it('should add spacing.after to content height for all paragraphs', async () => { + it('should add spacing.after to content height for non-last paragraphs', async () => { const table: FlowBlock = { kind: 'table', id: 'table-spacing', @@ -4570,17 +4710,19 @@ describe('measureBlock', () => { const block0Measure = cellMeasure.blocks[0]; const block1Measure = cellMeasure.blocks[1]; - // Content height should include both paragraph heights and both spacing.after values + // Content height should include first paragraph's spacing.after but NOT the last paragraph's. + // In Word, the last paragraph's spacing.after is absorbed by the cell's bottom padding. // First paragraph: height + 10px spacing - // Last paragraph: height + 20px spacing (even though it's the last paragraph) + // Last paragraph: height only (spacing.after=20 is skipped) expect(block0Measure.kind).toBe('paragraph'); expect(block1Measure.kind).toBe('paragraph'); const para0Height = block0Measure.kind === 'paragraph' ? block0Measure.totalHeight : 0; const para1Height = block1Measure.kind === 'paragraph' ? block1Measure.totalHeight : 0; - // Cell height includes: para0Height + 10 + para1Height + 20 + padding (default 2 top + 2 bottom) - const expectedCellHeight = para0Height + 10 + para1Height + 20 + 4; + // Cell height includes: para0Height + 10 + para1Height + padding (default 2 top + 2 bottom) + // Last paragraph's spacing.after (20) is NOT included + const expectedCellHeight = para0Height + 10 + para1Height + 4; expect(cellMeasure.height).toBe(expectedCellHeight); }); @@ -4635,10 +4777,11 @@ describe('measureBlock', () => { const para1Height = block1.kind === 'paragraph' ? block1.totalHeight : 0; const para2Height = block2.kind === 'paragraph' ? block2.totalHeight : 0; - // Only positive spacing should be added - // Zero and negative spacing should not be added - // Cell height = para0 + para1 + para2 + 15 (positive spacing) + 4 (padding) - const expectedCellHeight = para0Height + para1Height + para2Height + 15 + 4; + // Only positive spacing should be added, and not for the last paragraph. + // Zero and negative spacing should not be added. + // para-2 is the last paragraph so its spacing.after (15) is skipped. + // Cell height = para0 + para1 + para2 + 4 (padding) + const expectedCellHeight = para0Height + para1Height + para2Height + 4; expect(cellMeasure.height).toBe(expectedCellHeight); }); @@ -4831,7 +4974,8 @@ describe('measureBlock', () => { const cellMeasure = measure.rows[0].cells[0]; // Should handle mixed block types correctly - // Paragraphs should have spacing.after applied, image should not + // Non-last paragraphs should have spacing.after applied, image should not + // Last paragraph's spacing.after is skipped expect(cellMeasure.blocks).toHaveLength(3); expect(cellMeasure.blocks[0].kind).toBe('paragraph'); expect(cellMeasure.blocks[1].kind).toBe('image'); @@ -4845,9 +4989,99 @@ describe('measureBlock', () => { const imageHeight = block1.kind === 'image' ? block1.height : 0; const para1Height = block2.kind === 'paragraph' ? block2.totalHeight : 0; - // Cell height = para0 + 10 + image + para1 + 5 + 4 (padding) - const expectedCellHeight = para0Height + 10 + imageHeight + para1Height + 5 + 4; + // Cell height = para0 + 10 + image + para1 + 4 (padding) + // Last paragraph's spacing.after (5) is NOT included + const expectedCellHeight = para0Height + 10 + imageHeight + para1Height + 4; expect(cellMeasure.height).toBe(expectedCellHeight); }); }); + + describe('table column count with rowspan', () => { + it('should preserve all grid columns when rows have fewer physical cells due to rowspan', async () => { + // Simulates PCI table structure: 4 grid columns, but some rows have only 2-3 physical cells + // because rowspan cells from above occupy grid slots. + const table: FlowBlock = { + kind: 'table', + id: 'table-rowspan-cols', + attrs: {}, + columnWidths: [170, 15, 130, 310], // 4 grid columns, sum = 625 + rows: [ + { + id: 'row-0', + cells: [ + { + id: 'cell-0-0', + colSpan: 2, + attrs: {}, + blocks: [{ kind: 'paragraph', id: 'p-0-0', runs: [{ text: 'A', fontFamily: 'Arial', fontSize: 12 }] }], + }, + { + id: 'cell-0-1', + colSpan: 2, + attrs: {}, + blocks: [{ kind: 'paragraph', id: 'p-0-1', runs: [{ text: 'B', fontFamily: 'Arial', fontSize: 12 }] }], + }, + ], + }, + { + id: 'row-1', + cells: [ + { + id: 'cell-1-0', + colSpan: 1, + rowSpan: 2, + attrs: {}, + blocks: [{ kind: 'paragraph', id: 'p-1-0', runs: [{ text: 'C', fontFamily: 'Arial', fontSize: 12 }] }], + }, + { + id: 'cell-1-1', + colSpan: 2, + attrs: {}, + blocks: [{ kind: 'paragraph', id: 'p-1-1', runs: [{ text: 'D', fontFamily: 'Arial', fontSize: 12 }] }], + }, + { + id: 'cell-1-2', + colSpan: 1, + attrs: {}, + blocks: [{ kind: 'paragraph', id: 'p-1-2', runs: [{ text: 'E', fontFamily: 'Arial', fontSize: 12 }] }], + }, + ], + }, + { + // Row 2: only 2 physical cells because col 0 is occupied by row-1 rowSpan=2 + id: 'row-2', + cells: [ + { + id: 'cell-2-0', + colSpan: 2, + attrs: {}, + blocks: [{ kind: 'paragraph', id: 'p-2-0', runs: [{ text: 'F', fontFamily: 'Arial', fontSize: 12 }] }], + }, + { + id: 'cell-2-1', + colSpan: 1, + attrs: {}, + blocks: [{ kind: 'paragraph', id: 'p-2-1', runs: [{ text: 'G', fontFamily: 'Arial', fontSize: 12 }] }], + }, + ], + }, + ], + }; + + const measure = await measureBlock(table, 625); + expect(measure.kind).toBe('table'); + const tableMeasure = measure as TableMeasure; + + // All 4 grid columns must be preserved (not truncated to 3 based on max physical cell count) + expect(tableMeasure.columnWidths).toHaveLength(4); + expect(tableMeasure.columnWidths[0]).toBe(170); + expect(tableMeasure.columnWidths[1]).toBe(15); + expect(tableMeasure.columnWidths[2]).toBe(130); + expect(tableMeasure.columnWidths[3]).toBe(310); + + // Total width should match page width + const totalWidth = tableMeasure.columnWidths.reduce((a: number, b: number) => a + b, 0); + expect(totalWidth).toBe(625); + }); + }); }); diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index 20f39c3daf..57a8a0a385 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -2524,8 +2524,11 @@ async function measureTableBlock(block: TableBlock, constraints: MeasureConstrai return scaled; }; - // Determine actual column count from table structure - const maxCellCount = Math.max(1, Math.max(...block.rows.map((r) => r.cells.length))); + // Determine actual column count from table structure (accounting for colspan) + const maxCellCount = Math.max( + 1, + Math.max(...block.rows.map((r) => r.cells.reduce((sum, cell) => sum + (cell.colSpan ?? 1), 0))), + ); // Effective target width: use resolvedTableWidth if set (from percentage or explicit px), // but never exceed maxWidth (available column space) @@ -2701,9 +2704,11 @@ async function measureTableBlock(block: TableBlock, constraints: MeasureConstrai contentHeight += blockHeight; - // Add paragraph spacing.after to content height for all paragraphs. - // Word applies spacing.after even to the last paragraph in a cell, creating space at the bottom. - if (block.kind === 'paragraph') { + // Add paragraph spacing.after to content height for non-last paragraphs. + // In Word, the last paragraph's spacing.after is absorbed by the cell's bottom padding + // and doesn't add extra height beyond the cell margin. + const isLastBlock = blockIndex === cellBlocks.length - 1; + if (block.kind === 'paragraph' && !isLastBlock) { const spacingAfter = (block as ParagraphBlock).attrs?.spacing?.after; if (typeof spacingAfter === 'number' && spacingAfter > 0) { contentHeight += spacingAfter; 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 644c0f0083..cdb3fd34b3 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -591,12 +591,13 @@ describe('renderTableCell', () => { const firstParaWrapper = paraWrappers[0] as HTMLElement; const secondParaWrapper = paraWrappers[1] as HTMLElement; - // Both paragraphs should have margin-bottom for spacing.after + // First paragraph should have margin-bottom, last paragraph should NOT + // (last paragraph's spacing.after is absorbed by cell bottom padding) expect(firstParaWrapper.style.marginBottom).toBe('10px'); - expect(secondParaWrapper.style.marginBottom).toBe('20px'); + expect(secondParaWrapper.style.marginBottom).toBe(''); }); - it('should apply spacing.after even to the last paragraph', () => { + it('should NOT apply spacing.after to the last paragraph', () => { const lastPara: ParagraphBlock = { kind: 'paragraph', id: 'para-last', @@ -645,9 +646,9 @@ describe('renderTableCell', () => { const contentElement = cellElement.firstElementChild as HTMLElement; const paraWrapper = contentElement.firstElementChild as HTMLElement; - // Last paragraph should still have margin-bottom applied - // This matches Word's behavior - expect(paraWrapper.style.marginBottom).toBe('15px'); + // Last paragraph should NOT have margin-bottom applied + // In Word, the last paragraph's spacing.after is absorbed by the cell's bottom padding + expect(paraWrapper.style.marginBottom).toBe(''); }); it('should only apply margin-bottom when spacing.after > 0', () => { @@ -721,8 +722,8 @@ describe('renderTableCell', () => { expect(wrapper1.style.marginBottom).toBe(''); expect(wrapper2.style.marginBottom).toBe(''); - // Positive spacing should have margin-bottom - expect(wrapper3.style.marginBottom).toBe('10px'); + // Last paragraph's spacing.after is skipped (absorbed by cell bottom padding) + expect(wrapper3.style.marginBottom).toBe(''); }); it('should handle paragraphs without spacing.after attribute', () => { @@ -919,8 +920,9 @@ describe('renderTableCell', () => { const fullContent = fullCell.firstElementChild as HTMLElement; const fullWrapper = fullContent.firstElementChild as HTMLElement; - // Full render SHOULD apply spacing.after - expect(fullWrapper.style.marginBottom).toBe('15px'); + // Full render of last paragraph should NOT apply spacing.after + // (last paragraph's spacing.after is absorbed by cell bottom padding) + expect(fullWrapper.style.marginBottom).toBe(''); }); }); diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index c694c4d7cf..fb4145accd 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -1018,9 +1018,10 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen flowCursorY += renderedHeight; - // Apply paragraph spacing.after as margin-bottom for all paragraphs. - // Word applies spacing.after even to the last paragraph in a cell, creating space at the bottom. - if (renderedEntireBlock) { + // Apply paragraph spacing.after as margin-bottom for non-last paragraphs. + // In Word, the last paragraph's spacing.after is absorbed by the cell's bottom padding. + const isLastBlock = i === Math.min(blockMeasures.length, cellBlocks.length) - 1; + if (renderedEntireBlock && !isLastBlock) { const spacingAfter = (block as ParagraphBlock).attrs?.spacing?.after; if (typeof spacingAfter === 'number' && spacingAfter > 0) { paraWrapper.style.marginBottom = `${spacingAfter}px`; diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts index 55c64f4c6b..6215c75b5a 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts @@ -12,6 +12,7 @@ import { CLASS_NAMES, fragmentStyles } from '../styles.js'; import type { FragmentRenderContext, BlockLookup } from '../renderer.js'; import { renderTableRow } from './renderTableRow.js'; import { applySdtContainerStyling, type SdtBoundaryOptions } from '../utils/sdt-helpers.js'; +import { applyBorder, borderValueToSpec } from './border-utils.js'; type ApplyStylesFn = (el: HTMLElement, styles: Partial) => void; @@ -317,6 +318,108 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement } } + // Render rowspan continuation cells ("ghost cells") + // When a table continues from a previous fragment, some grid columns in the + // first body rows may be occupied by rowspan cells that started on a previous page. + // Create empty cells to maintain table structure and borders (matching Word behavior). + if (fragment.continuesFromPrev && fragment.fromRow > 0) { + const repeatCount = fragment.repeatHeaderCount ?? 0; + + for (let r = repeatCount; r < fragment.fromRow; r++) { + const srcRowMeasure = measure.rows[r]; + if (!srcRowMeasure) continue; + + for (let ci = 0; ci < srcRowMeasure.cells.length; ci++) { + const srcCellMeasure = srcRowMeasure.cells[ci]; + const rowSpan = srcCellMeasure.rowSpan ?? 1; + if (rowSpan <= 1) continue; + + const spanEndRow = r + rowSpan; + if (spanEndRow <= fragment.fromRow) continue; + + // This cell's rowspan extends into this fragment's body rows + const gridCol = srcCellMeasure.gridColumnStart ?? 0; + const colSpan = srcCellMeasure.colSpan ?? 1; + + // Calculate x position (sum of columns before gridCol) + let ghostX = 0; + for (let i = 0; i < gridCol && i < measure.columnWidths.length; i++) { + ghostX += measure.columnWidths[i]; + } + + // Calculate width (sum of spanned columns) + let ghostWidth = 0; + for (let i = gridCol; i < gridCol + colSpan && i < measure.columnWidths.length; i++) { + ghostWidth += measure.columnWidths[i]; + } + + // Calculate height: from fromRow to min(spanEndRow, toRow) + const effectiveEnd = Math.min(spanEndRow, fragment.toRow); + let ghostHeight = 0; + for (let ri = fragment.fromRow; ri < effectiveEnd; ri++) { + ghostHeight += allRowHeights[ri] ?? 0; + } + + if (ghostWidth <= 0 || ghostHeight <= 0) continue; + + // Create ghost cell + const ghostDiv = doc.createElement('div'); + ghostDiv.style.position = 'absolute'; + ghostDiv.style.left = `${ghostX}px`; + ghostDiv.style.top = `${y}px`; + ghostDiv.style.width = `${ghostWidth}px`; + ghostDiv.style.height = `${ghostHeight}px`; + ghostDiv.style.boxSizing = 'border-box'; + ghostDiv.style.overflow = 'hidden'; + + // Resolve borders for the ghost cell + const srcCell = block.rows[r]?.cells?.[ci]; + const cellBordersAttr = srcCell?.attrs?.borders; + const hasExplicitBorders = + cellBordersAttr && + (cellBordersAttr.top !== undefined || + cellBordersAttr.right !== undefined || + cellBordersAttr.bottom !== undefined || + cellBordersAttr.left !== undefined); + const isFirstCol = gridCol === 0; + const isLastCol = gridCol + colSpan >= measure.columnWidths.length; + + if (hasExplicitBorders && tableBorders) { + // Use cell's borders, with table top border for continuation + applyBorder(ghostDiv, 'Top', cellBordersAttr.top ?? borderValueToSpec(tableBorders.top)); + applyBorder( + ghostDiv, + 'Left', + cellBordersAttr.left ?? borderValueToSpec(isFirstCol ? tableBorders.left : tableBorders.insideV), + ); + applyBorder( + ghostDiv, + 'Right', + cellBordersAttr.right ?? borderValueToSpec(isLastCol ? tableBorders.right : tableBorders.insideV), + ); + if (effectiveEnd <= fragment.toRow && spanEndRow <= fragment.toRow) { + applyBorder(ghostDiv, 'Bottom', cellBordersAttr.bottom ?? borderValueToSpec(tableBorders.insideH)); + } + } else if (tableBorders) { + // Resolve from table borders + applyBorder(ghostDiv, 'Top', borderValueToSpec(tableBorders.top)); + applyBorder(ghostDiv, 'Left', borderValueToSpec(isFirstCol ? tableBorders.left : tableBorders.insideV)); + applyBorder(ghostDiv, 'Right', borderValueToSpec(isLastCol ? tableBorders.right : tableBorders.insideV)); + if (effectiveEnd <= fragment.toRow && spanEndRow <= fragment.toRow) { + applyBorder(ghostDiv, 'Bottom', borderValueToSpec(tableBorders.insideH)); + } + } + + // Apply cell background if present + if (srcCell?.attrs?.background) { + ghostDiv.style.backgroundColor = srcCell.attrs.background; + } + + container.appendChild(ghostDiv); + } + } + } + // Render body rows (fromRow to toRow) for (let r = fragment.fromRow; r < fragment.toRow; r += 1) { const rowMeasure = measure.rows[r]; diff --git a/packages/layout-engine/pm-adapter/src/converters/table.test.ts b/packages/layout-engine/pm-adapter/src/converters/table.test.ts index 4d9afab225..db6ba705e9 100644 --- a/packages/layout-engine/pm-adapter/src/converters/table.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/table.test.ts @@ -1414,7 +1414,7 @@ describe('table converter', () => { expect(tableBlock.columnWidths).toEqual([100, 150]); }); - it('Priority 2/3 interplay: should prefer colwidth when userEdited is false even if grid present', () => { + it('Priority 2/3 interplay: should prefer grid over colwidth when userEdited is false', () => { const node: PMNode = { type: 'table', attrs: { @@ -1457,13 +1457,15 @@ describe('table converter', () => { const tableBlock = result as TableBlock; // When userEdited is false and both grid and colwidth are present, - // colwidth (Priority 2) takes precedence over grid (Priority 3) + // grid (Priority 2) takes precedence over colwidth (Priority 3). + // Grid values represent actual column positions and sum to the page width. expect(tableBlock.columnWidths).toBeDefined(); expect(tableBlock.columnWidths).toHaveLength(2); - expect(tableBlock.columnWidths).toEqual([50, 100]); + // 1440 twips = 96px, 2880 twips = 192px + expect(tableBlock.columnWidths).toEqual([96, 192]); }); - it('Priority 3: should use grid when no colwidth present', () => { + it('Priority 2: should use grid when no colwidth present', () => { const node: PMNode = { type: 'table', attrs: { @@ -1507,7 +1509,7 @@ describe('table converter', () => { expect(tableBlock.columnWidths![1]).toBeCloseTo(192, 1); }); - it('Priority 4: should auto-calculate when no width attributes', () => { + it('Priority 4: should leave columnWidths undefined when no width attributes', () => { const node: PMNode = { type: 'table', attrs: { diff --git a/packages/layout-engine/pm-adapter/src/converters/table.ts b/packages/layout-engine/pm-adapter/src/converters/table.ts index 1534d15c1d..3c76ef3fce 100644 --- a/packages/layout-engine/pm-adapter/src/converters/table.ts +++ b/packages/layout-engine/pm-adapter/src/converters/table.ts @@ -762,15 +762,16 @@ export function tableNodeToBlock( }; /** - * Column width priority hierarchy (per plan Phase 3): + * Column width priority hierarchy: * 1. User-edited grid (userEdited flag + grid attribute) - * 2. PM colwidth attributes (fallback for PM-native edits) - * 3. Original OOXML grid (untouched documents) + * 2. Original OOXML grid (untouched documents — grid values sum to page width) + * 3. PM colwidth attributes (fallback for PM-native edits or missing grid) * 4. Auto-calculate from content (no explicit widths) * - * When both grid and colwidth are present: - * - If userEdited=true: use grid (Priority 1) - * - Otherwise: use colwidth (Priority 2) over grid (Priority 3) + * Grid values (from w:tblGrid) represent actual column positions on the page and + * sum to exactly the content width. Cell colwidth values may be scaled up from tcW + * (cell width hints) during import and require down-scaling in the measuring code, + * which introduces proportion changes that make columns narrower than they should be. */ // Priority 1: User-edited grid (preserves resize operations) @@ -791,7 +792,22 @@ export function tableNodeToBlock( } } - // Priority 2: PM colwidth attributes (higher priority than grid when userEdited !== true) + // Priority 2: Original OOXML grid (grid values are authoritative for column positions) + if (!columnWidths && Array.isArray(node.attrs?.grid) && node.attrs.grid.length > 0) { + columnWidths = (node.attrs.grid as Array<{ col?: number } | null | undefined>) + .filter((col): col is { col?: number } => col != null && typeof col === 'object') + .map((col) => { + const twips = typeof col.col === 'number' ? col.col : 0; + return twips > 0 ? twipsToPixels(twips) : 0; + }) + .filter((width: number) => width > 0); + + if (columnWidths.length === 0) { + columnWidths = undefined; + } + } + + // Priority 3: PM colwidth attributes (fallback when no grid is available) if (!columnWidths && Array.isArray(node.content) && node.content.length > 0) { const firstRow = node.content[0]; if (firstRow && isTableRowNode(firstRow) && Array.isArray(firstRow.content) && firstRow.content.length > 0) { @@ -812,21 +828,6 @@ export function tableNodeToBlock( } } - // Priority 3: Original OOXML grid (fallback when no colwidth) - if (!columnWidths && Array.isArray(node.attrs?.grid) && node.attrs.grid.length > 0) { - columnWidths = (node.attrs.grid as Array<{ col?: number } | null | undefined>) - .filter((col): col is { col?: number } => col != null && typeof col === 'object') - .map((col) => { - const twips = typeof col.col === 'number' ? col.col : 0; - return twips > 0 ? twipsToPixels(twips) : 0; - }) - .filter((width: number) => width > 0); - - if (columnWidths.length === 0) { - columnWidths = undefined; - } - } - // Priority 4: Auto-calculate from content (columnWidths remains undefined) // Extract floating table anchor/wrap properties diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.js index 6ad889fbbc..00ccd33189 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.js @@ -70,6 +70,7 @@ export function handleTableCellNode({ } if (widthType) attributes['widthType'] = widthType; + const cellOwnWidth = width; // tcW-derived width (before grid fallback) if (!width && columnWidth) width = columnWidth; if (width) { attributes['colwidth'] = [width]; @@ -80,13 +81,22 @@ export function handleTableCellNode({ if (colspan > 1 && hasDefaultColWidths) { let colwidth = []; + // When cell has its own tcW width that exceeds the grid span total, + // distribute tcW proportionally across grid columns to match Word behavior. + // Only scale UP (tcW > grid), not down — smaller tcW is just a minimum. + const gridSpanTotal = defaultColWidths + .slice(columnIndex, columnIndex + colspan) + .reduce((sum, w) => sum + (w || 0), 0); + const shouldScale = cellOwnWidth && gridSpanTotal > 0 && cellOwnWidth > gridSpanTotal + 1; for (let i = 0; i < colspan; i++) { let colwidthValue = defaultColWidths[columnIndex + i]; let defaultColwidth = 100; if (typeof colwidthValue !== 'undefined') { - colwidth.push(colwidthValue); + colwidth.push( + shouldScale ? Math.round(colwidthValue * (cellOwnWidth / gridSpanTotal) * 1000) / 1000 : colwidthValue, + ); } else { colwidth.push(defaultColwidth); } diff --git a/packages/super-editor/src/tests/data/pci-table.docx b/packages/super-editor/src/tests/data/pci-table.docx new file mode 100644 index 0000000000000000000000000000000000000000..6668f2fb18c182c011bdbf61b949bca394ffda46 GIT binary patch literal 18135 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 literal 0 HcmV?d00001 diff --git a/packages/super-editor/src/tests/data/table-autofit-colspan.docx b/packages/super-editor/src/tests/data/table-autofit-colspan.docx new file mode 100644 index 0000000000000000000000000000000000000000..6668f2fb18c182c011bdbf61b949bca394ffda46 GIT binary patch literal 18135 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 literal 0 HcmV?d00001 diff --git a/packages/super-editor/src/tests/data/table.docx b/packages/super-editor/src/tests/data/table.docx new file mode 100644 index 0000000000000000000000000000000000000000..677a0fa25b64f918caa3b6149b4db424d85e9136 GIT binary patch literal 14760 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 { + let tableNode = null; + doc.descendants((node) => { + if (!tableNode && node.type?.name === 'table') { + tableNode = node; + return false; + } + return true; + }); + return tableNode; +}; + +describe('SD-1797: autofit tables with colspan should not drop columns', () => { + it('preserves all grid columns when rows use colspan patterns', async () => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests('table-autofit-colspan.docx'); + const { editor } = await initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true }); + + try { + const table = findFirstTable(editor.state.doc); + expect(table).toBeDefined(); + + // The table has a 4-column grid + const grid = Array.isArray(table.attrs?.grid) ? table.attrs.grid : []; + expect(grid.length).toBe(4); + + // Verify no row has more than 3 physical cells + // (this is the condition that triggers the bug — physical cells < grid columns) + let maxPhysicalCells = 0; + table.forEach((row) => { + let cellCount = 0; + row.forEach(() => { + cellCount++; + }); + maxPhysicalCells = Math.max(maxPhysicalCells, cellCount); + }); + expect(maxPhysicalCells).toBeLessThan(grid.length); + + // The key assertion: all cells should have valid colwidth arrays with positive values + // If the bug is present, cells in the last grid column would be missing or have zero width + let allColwidthsValid = true; + table.forEach((row) => { + row.forEach((cell) => { + const colwidth = cell.attrs?.colwidth; + if (!colwidth || !Array.isArray(colwidth) || colwidth.some((w) => w <= 0)) { + allColwidthsValid = false; + } + }); + }); + expect(allColwidthsValid).toBe(true); + } finally { + editor.destroy(); + } + }); +}); From 2ad4912110bba75723bb0968ebec030f651a72b1 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Thu, 12 Feb 2026 11:41:46 -0300 Subject: [PATCH 2/4] fix(tables): fix autofit column scaling and cell width overflow (SD-1797, SD-1859, SD-1895) Fix three table rendering bugs: - SD-1797: Autofit tables losing columns due to wrong column width priority, incorrect colspan counting, missing scale-up logic, and page breaks splitting rowspan groups - SD-1859: Percent-width tables overflowing in portrait pages of mixed-orientation documents because cell widths used measurement values instead of rescaled fragment column widths - SD-1895: Autofit tables from DOCX rendering narrow instead of filling page width due to cell width hints taking priority over the authoritative tblGrid values Add three visual regression tests covering all fixed scenarios. --- devtools/visual-testing/pnpm-lock.yaml | 2 +- .../tables/autofit-table-docx-rendering.ts | 48 ++++++ .../tables/autofit-table-fill-width.ts | 107 ++++++++++++++ .../tables/percent-width-table-overflow.ts | 44 ++++++ packages/layout-engine/contracts/src/index.ts | 3 + .../layout-bridge/src/incrementalLayout.ts | 23 +++ .../resolveMeasurementConstraints.test.ts | 52 +++++++ .../layout-engine/src/layout-table.test.ts | 117 ++++++++++++++- .../layout-engine/src/layout-table.ts | 38 +++++ .../layout-engine/measuring/dom/src/index.ts | 12 +- .../painters/dom/src/table/renderTableCell.ts | 8 +- .../dom/src/table/renderTableFragment.test.ts | 138 ++++++++++++++++++ .../dom/src/table/renderTableFragment.ts | 19 ++- .../painters/dom/src/table/renderTableRow.ts | 11 ++ 14 files changed, 608 insertions(+), 14 deletions(-) create mode 100644 devtools/visual-testing/tests/interactions/stories/tables/autofit-table-docx-rendering.ts create mode 100644 devtools/visual-testing/tests/interactions/stories/tables/autofit-table-fill-width.ts create mode 100644 devtools/visual-testing/tests/interactions/stories/tables/percent-width-table-overflow.ts diff --git a/devtools/visual-testing/pnpm-lock.yaml b/devtools/visual-testing/pnpm-lock.yaml index 9c960fa4f6..7ac085f4a3 100644 --- a/devtools/visual-testing/pnpm-lock.yaml +++ b/devtools/visual-testing/pnpm-lock.yaml @@ -2072,7 +2072,7 @@ packages: resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} superdoc@file:../../packages/superdoc/superdoc.tgz: - resolution: {integrity: sha512-jBc73CsYGAxZJwWjMnPPLh4HhvRFv1uwKA/6KGgGOqV3RVaC0xFzRtMJsCt4ffj1YQgC6SSdxKyH3wKcLwsiUw==, tarball: file:../../packages/superdoc/superdoc.tgz} + resolution: {integrity: sha512-fq7xlmIhxeo8oE9pKOefs9PLoSE1MB1ZN1Qt4HLUkNezv/u3UjbR9jYQKl7fiw1doH2w6h7afgXSYY0/Rhj9Ng==, tarball: file:../../packages/superdoc/superdoc.tgz} version: 1.11.0 peerDependencies: '@hocuspocus/provider': ^2.13.6 diff --git a/devtools/visual-testing/tests/interactions/stories/tables/autofit-table-docx-rendering.ts b/devtools/visual-testing/tests/interactions/stories/tables/autofit-table-docx-rendering.ts new file mode 100644 index 0000000000..0a5d7b6bac --- /dev/null +++ b/devtools/visual-testing/tests/interactions/stories/tables/autofit-table-docx-rendering.ts @@ -0,0 +1,48 @@ +import { defineStory } from '@superdoc-testing/helpers'; + +const WAIT_LONG_MS = 800; + +/** + * SD-1895: Auto-layout tables from DOCX should fill page width + * + * Uses the actual document from the SD-1895 bug report which has + * auto-layout tables with column widths defined by cell size. + * Before the fix, columns rendered at their raw measurement widths + * leaving unused space. After the fix, columns scale up to fill + * the available page width. + */ +export default defineStory({ + name: 'autofit-table-docx-rendering', + description: 'SD-1895: Auto-layout tables from DOCX should fill page width', + tickets: ['SD-1895'], + startDocument: 'tables/SD-1895-autofit-issue.docx', + layout: true, + hideCaret: true, + hideSelection: true, + + async run(page, helpers): Promise { + const { step, waitForStable, milestone } = helpers; + + await step('Wait for document to load', async () => { + await page.waitForSelector('.superdoc-page', { timeout: 30_000 }); + await waitForStable(WAIT_LONG_MS); + await milestone('page1-autofit-table', 'Auto-layout table should fill page width'); + }); + + await step('Scroll to page 2 if present', async () => { + const hasPage2 = await page.evaluate(() => { + const pages = document.querySelectorAll('.superdoc-page'); + if (pages.length > 1) { + const container = document.querySelector('.harness-main'); + pages[1].scrollIntoView({ block: 'start' }); + return true; + } + return false; + }); + if (hasPage2) { + await waitForStable(WAIT_LONG_MS); + await milestone('page2-autofit-table', 'Table on page 2 should also fill page width'); + } + }); + }, +}); diff --git a/devtools/visual-testing/tests/interactions/stories/tables/autofit-table-fill-width.ts b/devtools/visual-testing/tests/interactions/stories/tables/autofit-table-fill-width.ts new file mode 100644 index 0000000000..2d489af583 --- /dev/null +++ b/devtools/visual-testing/tests/interactions/stories/tables/autofit-table-fill-width.ts @@ -0,0 +1,107 @@ +import { defineStory } from '@superdoc-testing/helpers'; + +const WAIT_MS = 400; +const WAIT_LONG_MS = 800; + +/** + * SD-1895: Auto-layout tables should fill page width + * + * Tables inserted via the editor use auto-layout mode. With the fix, + * auto-layout tables scale their column widths UP to fill the available + * page width (matching Word behavior) rather than leaving unused space. + * + * This test inserts tables with different column counts and verifies + * they render cleanly filling the page width. + */ +export default defineStory({ + name: 'autofit-table-fill-width', + description: 'Verify auto-layout tables fill page width with proportional columns', + tickets: ['SD-1895'], + startDocument: null, + layout: true, + hideCaret: true, + hideSelection: true, + + async run(_page, helpers): Promise { + const { step, focus, type, press, waitForStable, milestone, executeCommand } = helpers; + + await step('Insert a 4-column table', async () => { + await focus(); + await type('Table with 4 columns:'); + await press('Enter'); + await executeCommand('insertTable', { rows: 3, cols: 4, withHeaderRow: false }); + await waitForStable(WAIT_LONG_MS); + }); + + await step('Fill table cells with content', async () => { + // Row 1 + await type('Name'); + await press('Tab'); + await type('Department'); + await press('Tab'); + await type('Role'); + await press('Tab'); + await type('Status'); + // Row 2 + await press('Tab'); + await type('Alice Smith'); + await press('Tab'); + await type('Engineering'); + await press('Tab'); + await type('Senior Developer'); + await press('Tab'); + await type('Active'); + // Row 3 + await press('Tab'); + await type('Bob Johnson'); + await press('Tab'); + await type('Marketing'); + await press('Tab'); + await type('Content Lead'); + await press('Tab'); + await type('Active'); + await waitForStable(WAIT_LONG_MS); + await milestone('four-column-table', 'Table with 4 columns should fill page width'); + }); + + await step('Add paragraph and insert 6-column table', async () => { + // Move cursor after table + await press('ArrowDown'); + await press('ArrowDown'); + await waitForStable(WAIT_MS); + await press('Enter'); + await type('Table with 6 columns:'); + await press('Enter'); + await executeCommand('insertTable', { rows: 2, cols: 6, withHeaderRow: false }); + await waitForStable(WAIT_LONG_MS); + }); + + await step('Fill 6-column table', async () => { + await type('Col 1'); + await press('Tab'); + await type('Col 2'); + await press('Tab'); + await type('Col 3'); + await press('Tab'); + await type('Col 4'); + await press('Tab'); + await type('Col 5'); + await press('Tab'); + await type('Col 6'); + await press('Tab'); + await type('Data A'); + await press('Tab'); + await type('Data B'); + await press('Tab'); + await type('Data C'); + await press('Tab'); + await type('Data D'); + await press('Tab'); + await type('Data E'); + await press('Tab'); + await type('Data F'); + await waitForStable(WAIT_LONG_MS); + await milestone('six-column-table', 'Both tables should fill page width with proportional columns'); + }); + }, +}); diff --git a/devtools/visual-testing/tests/interactions/stories/tables/percent-width-table-overflow.ts b/devtools/visual-testing/tests/interactions/stories/tables/percent-width-table-overflow.ts new file mode 100644 index 0000000000..8c9313821e --- /dev/null +++ b/devtools/visual-testing/tests/interactions/stories/tables/percent-width-table-overflow.ts @@ -0,0 +1,44 @@ +import { defineStory } from '@superdoc-testing/helpers'; + +const WAIT_LONG_MS = 800; + +/** + * SD-1859: Percent-width tables should not overflow page bounds in mixed orientation docs + * + * Uses the actual document from the SD-1859 bug report: NuraBio document + * which has a percent-width table in a document with mixed portrait/landscape sections. + * Before the fix, the table overflowed the right edge of portrait pages because + * column widths were computed using landscape dimensions but rendered in portrait. + * After the fix, column widths are rescaled to fit the current page's content area. + */ +export default defineStory({ + name: 'percent-width-table-overflow', + description: 'SD-1859: Percent-width tables should fit within portrait page bounds', + tickets: ['SD-1859'], + startDocument: 'tables/SD-1859-mixed-orientation.docx', + layout: true, + hideCaret: true, + hideSelection: true, + + async run(page, helpers): Promise { + const { step, waitForStable, milestone } = helpers; + + await step('Wait for document to load', async () => { + await page.waitForSelector('.superdoc-page', { timeout: 30_000 }); + await waitForStable(WAIT_LONG_MS); + await milestone('page1-table', 'Page 1 table should fit within page bounds'); + }); + + await step('Scroll to see the table on page 2', async () => { + await page.evaluate(() => { + const container = document.querySelector('.harness-main'); + const scrollTarget = container?.querySelector('.superdoc-page:nth-child(2)'); + if (scrollTarget) { + scrollTarget.scrollIntoView({ block: 'start' }); + } + }); + await waitForStable(WAIT_LONG_MS); + await milestone('page2-table', 'Table should not overflow right edge of portrait page'); + }); + }, +}); diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 77bd061260..e7aa16147c 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -1584,6 +1584,9 @@ export type TableFragment = { metadata?: TableFragmentMetadata; pmStart?: number; pmEnd?: number; + /** Per-fragment column widths, rescaled when table is clamped to section width. + * When set, the renderer uses these instead of measure.columnWidths. */ + columnWidths?: number[]; }; export type ImageFragment = { diff --git a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts index 82433951d4..a3efaba015 100644 --- a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts +++ b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts @@ -1597,6 +1597,28 @@ export async function incrementalLayout( tableX += indent; tableWidth = Math.max(0, tableWidth - indent); } + // Rescale column widths when table was clamped to section width. + // This happens in mixed-orientation docs where measurement uses the + // widest section but rendering is per-section (SD-1859). + let fragmentColumnWidths: number[] | undefined; + if ( + tableWidthRaw > tableWidth && + measure.columnWidths && + measure.columnWidths.length > 0 && + tableWidthRaw > 0 + ) { + const scale = tableWidth / tableWidthRaw; + fragmentColumnWidths = measure.columnWidths.map((w: number) => Math.max(1, Math.round(w * scale))); + const scaledSum = fragmentColumnWidths.reduce((a: number, b: number) => a + b, 0); + const target = Math.round(tableWidth); + if (scaledSum !== target && fragmentColumnWidths.length > 0) { + fragmentColumnWidths[fragmentColumnWidths.length - 1] = Math.max( + 1, + fragmentColumnWidths[fragmentColumnWidths.length - 1] + (target - scaledSum), + ); + } + } + page.fragments.push({ kind: 'table', blockId: range.blockId, @@ -1606,6 +1628,7 @@ export async function incrementalLayout( y: cursorY, width: tableWidth, height: Math.max(0, measure.totalHeight ?? 0), + columnWidths: fragmentColumnWidths, }); cursorY += getRangeRenderHeight(range); return; diff --git a/packages/layout-engine/layout-bridge/test/resolveMeasurementConstraints.test.ts b/packages/layout-engine/layout-bridge/test/resolveMeasurementConstraints.test.ts index 21bb7e85f8..8b5b7b3e5f 100644 --- a/packages/layout-engine/layout-bridge/test/resolveMeasurementConstraints.test.ts +++ b/packages/layout-engine/layout-bridge/test/resolveMeasurementConstraints.test.ts @@ -451,4 +451,56 @@ describe('resolveMeasurementConstraints', () => { expect(result.measurementHeight).toBe(20); }); }); + + describe('mixed-orientation documents (SD-1859)', () => { + it('takes max width across portrait and landscape sections', () => { + // First section: portrait (612 x 792) + const options: LayoutOptions = { + pageSize: { w: 612, h: 792 }, + margins: { top: 72, right: 72, bottom: 72, left: 72 }, + }; + + // Second section: landscape (792 x 612) + const blocks: FlowBlock[] = [ + { + kind: 'sectionBreak', + id: 'sb-1', + pageSize: { w: 792, h: 612 }, + margins: { top: 72, right: 72, bottom: 72, left: 72 }, + } as SectionBreakBlock, + ]; + + const result = resolveMeasurementConstraints(options, blocks); + + // Portrait content width: 612 - 144 = 468 + // Landscape content width: 792 - 144 = 648 + // Should take MAX: 648 (landscape width) + expect(result.measurementWidth).toBe(648); + }); + + it('takes max width across sections with different margins', () => { + // First section: narrow margins (content width = 612 - 100 = 512) + const options: LayoutOptions = { + pageSize: { w: 612, h: 792 }, + margins: { top: 72, right: 50, bottom: 72, left: 50 }, + }; + + // Second section: wider margins (content width = 612 - 200 = 412) + const blocks: FlowBlock[] = [ + { + kind: 'sectionBreak', + id: 'sb-1', + pageSize: { w: 612, h: 792 }, + margins: { top: 72, right: 100, bottom: 72, left: 100 }, + } as SectionBreakBlock, + ]; + + const result = resolveMeasurementConstraints(options, blocks); + + // First section content width: 612 - 100 = 512 + // Second section content width: 612 - 200 = 412 + // Should take MAX: 512 (first section is wider) + expect(result.measurementWidth).toBe(512); + }); + }); }); diff --git a/packages/layout-engine/layout-engine/src/layout-table.test.ts b/packages/layout-engine/layout-engine/src/layout-table.test.ts index 9a286b75c2..1133939451 100644 --- a/packages/layout-engine/layout-engine/src/layout-table.test.ts +++ b/packages/layout-engine/layout-engine/src/layout-table.test.ts @@ -2,9 +2,9 @@ * Tests for table layout with column boundary metadata generation */ -import { describe, it, expect } from 'vitest'; +import type { BlockId, TableAttrs, TableBlock, TableFragment, TableMeasure } from '@superdoc/contracts'; +import { describe, expect, it } from 'vitest'; import { layoutTableBlock } from './layout-table.js'; -import type { TableBlock, TableMeasure, TableFragment, BlockId, TableAttrs } from '@superdoc/contracts'; /** * Creates a dummy table fragment for test scenarios where prior page content is needed. @@ -3167,4 +3167,117 @@ describe('layoutTableBlock', () => { } }); }); + + describe('column width rescaling (SD-1859)', () => { + it('should rescale column widths when table is wider than section content width', () => { + // Simulate a table measured at landscape width (700px) but rendered in + // a portrait section (450px). Column widths should be rescaled to fit. + const block = createMockTableBlock(2); + const measure = createMockTableMeasure([250, 200, 250], [30, 30]); + // measure.totalWidth = 700 + + const fragments: TableFragment[] = []; + const mockPage = { fragments }; + + layoutTableBlock({ + block, + measure, + columnWidth: 450, // Portrait section width (narrower than table) + ensurePage: () => ({ + page: mockPage, + columnIndex: 0, + cursorY: 0, + contentBottom: 1000, + }), + advanceColumn: (state) => state, + columnX: () => 0, + }); + + expect(fragments).toHaveLength(1); + const fragment = fragments[0]; + + // Fragment width should be clamped to section width + expect(fragment.width).toBe(450); + + // Column widths should be rescaled proportionally + expect(fragment.columnWidths).toBeDefined(); + expect(fragment.columnWidths!.length).toBe(3); + + // Sum of rescaled column widths should equal fragment width + const sum = fragment.columnWidths!.reduce((a, b) => a + b, 0); + expect(sum).toBe(450); + + // Proportions should be maintained (250:200:250 → ~161:129:161) + expect(fragment.columnWidths![0]).toBeGreaterThan(fragment.columnWidths![1]); + expect(fragment.columnWidths![0]).toBeCloseTo(fragment.columnWidths![2], -1); + }); + + it('should not set fragment columnWidths when table fits within section width', () => { + const block = createMockTableBlock(2); + const measure = createMockTableMeasure([100, 150, 100], [30, 30]); + // measure.totalWidth = 350 + + const fragments: TableFragment[] = []; + const mockPage = { fragments }; + + layoutTableBlock({ + block, + measure, + columnWidth: 450, // Section is wider than table + ensurePage: () => ({ + page: mockPage, + columnIndex: 0, + cursorY: 0, + contentBottom: 1000, + }), + advanceColumn: (state) => state, + columnX: () => 0, + }); + + expect(fragments).toHaveLength(1); + // No rescaling needed — columnWidths should be undefined + expect(fragments[0].columnWidths).toBeUndefined(); + }); + + it('should rescale column widths on paginated table fragments', () => { + // Table that splits across pages should have rescaled column widths on each fragment + const block = createMockTableBlock(4); + const measure = createMockTableMeasure([300, 300], [200, 200, 200, 200]); + // totalWidth = 600, each row = 200px + + const fragments: TableFragment[] = []; + let pageIndex = 0; + + layoutTableBlock({ + block, + measure, + columnWidth: 400, // Narrower than table + ensurePage: () => ({ + page: { fragments }, + columnIndex: 0, + cursorY: 0, + contentBottom: 500, // Only fits ~2 rows per page + }), + advanceColumn: (state) => { + pageIndex++; + return { + ...state, + cursorY: 0, + contentBottom: 500, + }; + }, + columnX: () => 0, + }); + + // Should have multiple fragments (table paginated) + expect(fragments.length).toBeGreaterThanOrEqual(1); + + // Every fragment should have rescaled column widths + for (const fragment of fragments) { + expect(fragment.columnWidths).toBeDefined(); + const sum = fragment.columnWidths!.reduce((a, b) => a + b, 0); + expect(sum).toBe(400); + } + }); + }); }); diff --git a/packages/layout-engine/layout-engine/src/layout-table.ts b/packages/layout-engine/layout-engine/src/layout-table.ts index 588b3b9cdd..f0d8da01d4 100644 --- a/packages/layout-engine/layout-engine/src/layout-table.ts +++ b/packages/layout-engine/layout-engine/src/layout-table.ts @@ -168,6 +168,39 @@ function resolveTableFrame( return applyTableIndent(baseX, width, tableIndent); } +/** + * Rescales column widths when a table is clamped to fit a narrower section. + * + * In mixed-orientation documents, tables are measured at the widest section's + * content width but may render in narrower sections. When the measured total + * width exceeds the fragment width, column widths must be proportionally + * rescaled so cells don't overflow the fragment container (SD-1859). + * + * @returns Rescaled column widths if clamping occurred, undefined otherwise. + */ +function rescaleColumnWidths( + measureColumnWidths: number[] | undefined, + measureTotalWidth: number, + fragmentWidth: number, +): number[] | undefined { + if ( + !measureColumnWidths || + measureColumnWidths.length === 0 || + measureTotalWidth <= fragmentWidth || + measureTotalWidth <= 0 + ) { + return undefined; + } + const scale = fragmentWidth / measureTotalWidth; + const scaled = measureColumnWidths.map((w) => Math.max(1, Math.round(w * scale))); + const scaledSum = scaled.reduce((a, b) => a + b, 0); + const target = Math.round(fragmentWidth); + if (scaledSum !== target && scaled.length > 0) { + scaled[scaled.length - 1] = Math.max(1, scaled[scaled.length - 1] + (target - scaledSum)); + } + return scaled; +} + /** * Calculate minimum width for a table column. * @@ -1011,6 +1044,7 @@ function layoutMonolithicTable(context: TableLayoutContext): void { width, height, metadata, + columnWidths: rescaleColumnWidths(context.measure.columnWidths, context.measure.totalWidth, width), }; applyTableFragmentPmRange(fragment, context.block, context.measure); state.page.fragments.push(fragment); @@ -1150,6 +1184,7 @@ export function layoutTableBlock({ width, height, metadata, + columnWidths: rescaleColumnWidths(measure.columnWidths, measure.totalWidth, width), }; applyTableFragmentPmRange(fragment, block, measure); state.page.fragments.push(fragment); @@ -1250,6 +1285,7 @@ export function layoutTableBlock({ repeatHeaderCount, partialRow: continuationPartialRow, metadata: generateFragmentMetadata(measure, rowIndex, rowIndex + 1, repeatHeaderCount), + columnWidths: rescaleColumnWidths(measure.columnWidths, measure.totalWidth, width), }; applyTableFragmentPmRange(fragment, block, measure); @@ -1319,6 +1355,7 @@ export function layoutTableBlock({ repeatHeaderCount, partialRow: forcedPartialRow, metadata: generateFragmentMetadata(measure, bodyStartRow, forcedEndRow, repeatHeaderCount), + columnWidths: rescaleColumnWidths(measure.columnWidths, measure.totalWidth, width), }; applyTableFragmentPmRange(fragment, block, measure); @@ -1360,6 +1397,7 @@ export function layoutTableBlock({ repeatHeaderCount, partialRow: partialRow || undefined, metadata: generateFragmentMetadata(measure, bodyStartRow, endRow, repeatHeaderCount), + columnWidths: rescaleColumnWidths(measure.columnWidths, measure.totalWidth, width), }; applyTableFragmentPmRange(fragment, block, measure); diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index 57a8a0a385..9dba53923b 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -2580,10 +2580,20 @@ async function measureTableBlock(block: TableBlock, constraints: MeasureConstrai columnWidths = columnWidths.slice(0, maxCellCount); } - // Scale proportionally if total width exceeds effective target width + // Scale proportionally to fit effective target width. + // Auto-layout tables in Word fill the available page width, so we scale + // both up (when grid widths are smaller) and down (when they exceed it). const totalWidth = columnWidths.reduce((a, b) => a + b, 0); if (totalWidth > effectiveTargetWidth) { columnWidths = scaleColumnWidths(columnWidths, effectiveTargetWidth); + } else if (totalWidth < effectiveTargetWidth && effectiveTargetWidth > 0 && totalWidth > 0) { + const scale = effectiveTargetWidth / totalWidth; + columnWidths = columnWidths.map((w) => Math.max(1, Math.round(w * scale))); + const scaledSum = columnWidths.reduce((a, b) => a + b, 0); + if (scaledSum !== effectiveTargetWidth && columnWidths.length > 0) { + const diff = effectiveTargetWidth - scaledSum; + columnWidths[columnWidths.length - 1] = Math.max(1, columnWidths[columnWidths.length - 1] + diff); + } } } } else { diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index fb4145accd..e9d7aeafb1 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -509,6 +509,8 @@ type TableCellRenderDependencies = { tableSdt?: SdtMetadata | null; /** Table indent in pixels (applied to table fragment positioning) */ tableIndent?: number; + /** Computed cell width from rescaled columnWidths (overrides cellMeasure.width when present) */ + cellWidth?: number; /** Starting line index for partial row rendering (inclusive) */ fromLine?: number; /** Ending line index for partial row rendering (exclusive), -1 means render to end */ @@ -594,6 +596,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen applySdtDataset, tableSdt, tableIndent, + cellWidth, fromLine, toLine, } = deps; @@ -609,7 +612,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen cellEl.style.position = 'absolute'; cellEl.style.left = `${x}px`; cellEl.style.top = `${y}px`; - cellEl.style.width = `${cellMeasure.width}px`; + cellEl.style.width = `${cellWidth ?? cellMeasure.width}px`; cellEl.style.height = `${rowHeight}px`; cellEl.style.boxSizing = 'border-box'; // Cell clips all overflow - no scrollbars, content just gets clipped at boundaries @@ -728,7 +731,8 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen const globalFromLine = fromLine ?? 0; const globalToLine = toLine === -1 || toLine === undefined ? totalLines : toLine; - const contentWidthPx = Math.max(0, cellMeasure.width - paddingLeft - paddingRight); + const effectiveCellWidth = cellWidth ?? cellMeasure.width; + const contentWidthPx = Math.max(0, effectiveCellWidth - paddingLeft - paddingRight); const contentHeightPx = Math.max(0, rowHeight - paddingTop - paddingBottom); const paragraphTopById = new Map(); let flowCursorY = 0; diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts index f5f8fe7c9d..c5d9a27426 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts @@ -572,6 +572,144 @@ describe('renderTableFragment', () => { }); }); + describe('cell width rescaling (SD-1859)', () => { + it('should use fragment.columnWidths for cell widths when present', () => { + // Simulates a mixed-orientation doc: table measured at landscape width (432px per col) + // but rendered in portrait where fragment.columnWidths rescales to 312px per col. + const block: TableBlock = { + kind: 'table', + id: 'test-table-1' as BlockId, + rows: [ + { + id: 'row-1' as BlockId, + cells: [ + { + id: 'cell-1-1' as BlockId, + paragraph: { + kind: 'paragraph', + id: 'para-1-1' as BlockId, + runs: [], + }, + }, + { + id: 'cell-1-2' as BlockId, + paragraph: { + kind: 'paragraph', + id: 'para-1-2' as BlockId, + runs: [], + }, + }, + ], + }, + ], + }; + + const measure: TableMeasure = { + kind: 'table', + rows: [ + { + cells: [ + { + paragraph: { kind: 'paragraph', lines: [], totalHeight: 20 }, + width: 432, + height: 20, + gridColumnStart: 0, + colSpan: 1, + }, + { + paragraph: { kind: 'paragraph', lines: [], totalHeight: 20 }, + width: 432, + height: 20, + gridColumnStart: 1, + colSpan: 1, + }, + ], + height: 20, + }, + ], + columnWidths: [432, 432], + totalWidth: 864, + totalHeight: 20, + }; + + // Fragment with rescaled column widths (portrait: 624px total) + const fragment: TableFragment = { + kind: 'table', + blockId: 'test-table-1' as BlockId, + fromRow: 0, + toRow: 1, + x: 0, + y: 0, + width: 624, + height: 20, + columnWidths: [312, 312], // rescaled from [432, 432] + }; + + blockLookup.set(fragment.blockId, { block, measure }); + + const element = renderTableFragment({ + doc, + fragment, + context, + blockLookup, + renderLine: (_block, _line, _ctx, _lineIndex, _isLastLine) => doc.createElement('div'), + applyFragmentFrame: () => {}, + applySdtDataset: () => {}, + applyStyles: () => {}, + }); + + // Find rendered cell elements (absolutely positioned divs inside container) + const cells = element.querySelectorAll('div[style*="position: absolute"]'); + expect(cells.length).toBeGreaterThanOrEqual(2); + + // Cell 1: should be at x=0, width=312 (not 432) + const cell1 = cells[0]; + expect(cell1.style.left).toBe('0px'); + expect(cell1.style.width).toBe('312px'); + + // Cell 2: should be at x=312, width=312 (not 432) + const cell2 = cells[1]; + expect(cell2.style.left).toBe('312px'); + expect(cell2.style.width).toBe('312px'); + }); + + it('should fall back to cellMeasure.width when fragment.columnWidths is absent', () => { + const block = createTestTableBlock(); + const measure = createTestTableMeasure(); + // Fragment without columnWidths — should use measure.columnWidths + const fragment: TableFragment = { + kind: 'table', + blockId: 'test-table-1' as BlockId, + fromRow: 0, + toRow: 1, + x: 0, + y: 0, + width: 100, + height: 20, + // no columnWidths + }; + + blockLookup.set(fragment.blockId, { block, measure }); + + const element = renderTableFragment({ + doc, + fragment, + context, + blockLookup, + renderLine: (_block, _line, _ctx, _lineIndex, _isLastLine) => doc.createElement('div'), + applyFragmentFrame: () => {}, + applySdtDataset: () => {}, + applyStyles: () => {}, + }); + + const cells = element.querySelectorAll('div[style*="position: absolute"]'); + expect(cells.length).toBeGreaterThanOrEqual(1); + + // Should use measure.columnWidths[0] = 100 + expect(cells[0].style.width).toBe('100px'); + }); + }); + describe('boundary segment logic', () => { it('should create segments for cells with varying rowspan', () => { // Create a table with mixed rowspans: diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts index 0a52ea54ee..95982356f2 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts @@ -162,6 +162,9 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement const block = lookup.block as TableBlock; const measure = lookup.measure as TableMeasure; + // Use per-fragment rescaled column widths when available (SD-1859: mixed-orientation docs + // where measurement width differs from section width). Falls back to measured widths. + const effectiveColumnWidths = fragment.columnWidths ?? measure.columnWidths; const tableBorders = block.attrs?.borders; const tableIndentValue = (block.attrs?.tableIndent as { width?: unknown } | null | undefined)?.width; const tableIndent = typeof tableIndentValue === 'number' && Number.isFinite(tableIndentValue) ? tableIndentValue : 0; @@ -188,7 +191,7 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement // When a table splits across pages, each fragment only renders a subset of rows // (repeated headers + body rows from fromRow to toRow). Segments must match // exactly the rendered rows so resize handles don't overflow the fragment. - const columnCount = measure.columnWidths.length; + const columnCount = effectiveColumnWidths.length; // boundarySegments[colIndex] = array of {fromRow, toRow, y, height} segments where this boundary exists const boundarySegments: Array> = []; @@ -331,7 +334,7 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement row: block.rows[r], totalRows: block.rows.length, tableBorders, - columnWidths: measure.columnWidths, + columnWidths: effectiveColumnWidths, allRowHeights, tableIndent, context, @@ -372,14 +375,14 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement // Calculate x position (sum of columns before gridCol) let ghostX = 0; - for (let i = 0; i < gridCol && i < measure.columnWidths.length; i++) { - ghostX += measure.columnWidths[i]; + for (let i = 0; i < gridCol && i < effectiveColumnWidths.length; i++) { + ghostX += effectiveColumnWidths[i]; } // Calculate width (sum of spanned columns) let ghostWidth = 0; - for (let i = gridCol; i < gridCol + colSpan && i < measure.columnWidths.length; i++) { - ghostWidth += measure.columnWidths[i]; + for (let i = gridCol; i < gridCol + colSpan && i < effectiveColumnWidths.length; i++) { + ghostWidth += effectiveColumnWidths[i]; } // Calculate height: from fromRow to min(spanEndRow, toRow) @@ -411,7 +414,7 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement cellBordersAttr.bottom !== undefined || cellBordersAttr.left !== undefined); const isFirstCol = gridCol === 0; - const isLastCol = gridCol + colSpan >= measure.columnWidths.length; + const isLastCol = gridCol + colSpan >= effectiveColumnWidths.length; if (hasExplicitBorders && tableBorders) { // Use cell's borders, with table top border for continuation @@ -471,7 +474,7 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement row: block.rows[r], totalRows: block.rows.length, tableBorders, - columnWidths: measure.columnWidths, + columnWidths: effectiveColumnWidths, allRowHeights, tableIndent, context, diff --git a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts index cc8cf221fe..e154874717 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts @@ -323,6 +323,16 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { const fromLine = partialRow?.fromLineByCell?.[cellIndex]; const toLine = partialRow?.toLineByCell?.[cellIndex]; + // Compute cell width from rescaled columnWidths (SD-1859: mixed-orientation docs + // where cellMeasure.width may reflect landscape measurement but the fragment renders + // in portrait). The columnWidths array is already rescaled by the layout engine. + const colSpan = cellMeasure.colSpan ?? 1; + const gridStart = cellMeasure.gridColumnStart ?? cellIndex; + let computedCellWidth = 0; + for (let i = gridStart; i < gridStart + colSpan && i < columnWidths.length; i++) { + computedCellWidth += columnWidths[i]; + } + // Never use default borders - cells are either explicitly styled or borderless // This prevents gray borders on cells with borders={} (intentionally borderless) const { cellElement } = renderTableCell({ @@ -342,6 +352,7 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { fromLine, toLine, tableIndent, + cellWidth: computedCellWidth > 0 ? computedCellWidth : undefined, }); container.appendChild(cellElement); From 7a97b2c396060f53f6b1de3379cc02d0485f9808 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Thu, 12 Feb 2026 13:51:17 -0300 Subject: [PATCH 3/4] test(visual): add regression tests for table rendering issues (SD-1797, SD-1859, SD-1895) Introduce new visual tests to validate fixes for table rendering bugs: - SD-1797: Ensure autofit tables with colspan preserve all columns. - SD-1859: Verify percent-width tables fit within portrait page bounds in mixed-orientation documents. - SD-1895: Confirm autofit tables fill the page width as expected. Tests are conditionally skipped if the required documents are not available. --- .../tests/rendering/table-fixes.spec.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 tests/visual/tests/rendering/table-fixes.spec.ts diff --git a/tests/visual/tests/rendering/table-fixes.spec.ts b/tests/visual/tests/rendering/table-fixes.spec.ts new file mode 100644 index 0000000000..54a91cfc32 --- /dev/null +++ b/tests/visual/tests/rendering/table-fixes.spec.ts @@ -0,0 +1,37 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from '../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOCS_DIR = path.resolve(__dirname, '../../test-data/rendering'); + +// SD-1859: Percent-width table in mixed portrait/landscape document +// Table measured at landscape width but rendered in portrait — cells should not overflow +const SD_1859_PATH = path.join(DOCS_DIR, 'SD-1859-mixed-orientation.docx'); +test.skip(!fs.existsSync(SD_1859_PATH), 'SD-1859 test document not available'); + +test('@rendering SD-1859 percent-width table fits within portrait page bounds', async ({ superdoc }) => { + await superdoc.loadDocument(SD_1859_PATH); + await superdoc.screenshotPages('rendering/sd-1859-mixed-orientation'); +}); + +// SD-1895: Auto-layout table from DOCX should fill page width +// Grid columns should scale up proportionally to fill available content area +const SD_1895_PATH = path.join(DOCS_DIR, 'SD-1895-autofit-issue.docx'); +test.skip(!fs.existsSync(SD_1895_PATH), 'SD-1895 test document not available'); + +test('@rendering SD-1895 autofit table fills page width', async ({ superdoc }) => { + await superdoc.loadDocument(SD_1895_PATH); + await superdoc.screenshotPages('rendering/sd-1895-autofit-table'); +}); + +// SD-1797: Autofit table with colspan should not drop columns +// Rows with rowspan continuations have fewer physical cells — all grid columns must be preserved +const SD_1797_PATH = path.join(DOCS_DIR, 'table-autofit-colspan.docx'); +test.skip(!fs.existsSync(SD_1797_PATH), 'SD-1797 test document not available'); + +test('@rendering SD-1797 autofit table with colspan preserves all columns', async ({ superdoc }) => { + await superdoc.loadDocument(SD_1797_PATH); + await superdoc.screenshotPages('rendering/sd-1797-autofit-colspan'); +}); From 65875975397102fab6be538fb2e25ee6428f20ce Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Thu, 12 Feb 2026 15:14:10 -0300 Subject: [PATCH 4/4] refactor(tests): update test document availability checks for table rendering tests Modified the visual tests for table rendering issues (SD-1797, SD-1859, SD-1895) to improve document availability checks. Tests now skip with a more generic message if the required documents are not found, enhancing clarity and maintainability. --- tests/visual/tests/rendering/table-fixes.spec.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/tests/visual/tests/rendering/table-fixes.spec.ts b/tests/visual/tests/rendering/table-fixes.spec.ts index 54a91cfc32..9436774809 100644 --- a/tests/visual/tests/rendering/table-fixes.spec.ts +++ b/tests/visual/tests/rendering/table-fixes.spec.ts @@ -6,32 +6,30 @@ import { test } from '../fixtures/superdoc.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DOCS_DIR = path.resolve(__dirname, '../../test-data/rendering'); -// SD-1859: Percent-width table in mixed portrait/landscape document -// Table measured at landscape width but rendered in portrait — cells should not overflow const SD_1859_PATH = path.join(DOCS_DIR, 'SD-1859-mixed-orientation.docx'); -test.skip(!fs.existsSync(SD_1859_PATH), 'SD-1859 test document not available'); +const SD_1895_PATH = path.join(DOCS_DIR, 'SD-1895-autofit-issue.docx'); +const SD_1797_PATH = path.join(DOCS_DIR, 'table-autofit-colspan.docx'); +// SD-1859: Percent-width table in mixed portrait/landscape document +// Table measured at landscape width but rendered in portrait — cells should not overflow test('@rendering SD-1859 percent-width table fits within portrait page bounds', async ({ superdoc }) => { + test.skip(!fs.existsSync(SD_1859_PATH), 'Test document not available'); await superdoc.loadDocument(SD_1859_PATH); await superdoc.screenshotPages('rendering/sd-1859-mixed-orientation'); }); // SD-1895: Auto-layout table from DOCX should fill page width // Grid columns should scale up proportionally to fill available content area -const SD_1895_PATH = path.join(DOCS_DIR, 'SD-1895-autofit-issue.docx'); -test.skip(!fs.existsSync(SD_1895_PATH), 'SD-1895 test document not available'); - test('@rendering SD-1895 autofit table fills page width', async ({ superdoc }) => { + test.skip(!fs.existsSync(SD_1895_PATH), 'Test document not available'); await superdoc.loadDocument(SD_1895_PATH); await superdoc.screenshotPages('rendering/sd-1895-autofit-table'); }); // SD-1797: Autofit table with colspan should not drop columns // Rows with rowspan continuations have fewer physical cells — all grid columns must be preserved -const SD_1797_PATH = path.join(DOCS_DIR, 'table-autofit-colspan.docx'); -test.skip(!fs.existsSync(SD_1797_PATH), 'SD-1797 test document not available'); - test('@rendering SD-1797 autofit table with colspan preserves all columns', async ({ superdoc }) => { + test.skip(!fs.existsSync(SD_1797_PATH), 'Test document not available'); await superdoc.loadDocument(SD_1797_PATH); await superdoc.screenshotPages('rendering/sd-1797-autofit-colspan'); });