diff --git a/packages/layout-engine/measuring/dom/src/index.test.ts b/packages/layout-engine/measuring/dom/src/index.test.ts index 57f0d90697..ef6750abf1 100644 --- a/packages/layout-engine/measuring/dom/src/index.test.ts +++ b/packages/layout-engine/measuring/dom/src/index.test.ts @@ -5283,4 +5283,243 @@ describe('measureBlock', () => { expect(totalWidth).toBe(625); }); }); + + describe('AutoFit table layout (ECMA-376 §17.18.87)', () => { + it('expands columns to fit content when grid widths are smaller than content (IT-679)', async () => { + // Simulates the IT-679 customer file: placeholder grid widths (tiny values) + // with a small percentage table width. AutoFit should expand columns to fit content. + const block: FlowBlock = { + kind: 'table', + id: 'autofit-expand', + attrs: { + tableWidth: { value: 100, type: 'pct' }, // 100/5000 = 2% → ~12px + }, + rows: [ + { + id: 'row-0', + cells: [ + { + id: 'cell-0-0', + blocks: [ + { + kind: 'paragraph', + id: 'para-0', + runs: [{ text: 'Role', fontFamily: 'Arial', fontSize: 12 }], + }, + ], + }, + { + id: 'cell-0-1', + blocks: [ + { + kind: 'paragraph', + id: 'para-1', + runs: [{ text: 'Name', fontFamily: 'Arial', fontSize: 12 }], + }, + ], + }, + ], + }, + ], + columnWidths: [7, 7], // Tiny placeholder grid widths (~100 twips converted to px) + }; + + const measure = await measureBlock(block, { maxWidth: 624 }); + + expect(measure.kind).toBe('table'); + const tableMeasure = measure as TableMeasure; + + // AutoFit should expand columns beyond the tiny grid widths to fit content + expect(tableMeasure.columnWidths[0]).toBeGreaterThan(7); + expect(tableMeasure.columnWidths[1]).toBeGreaterThan(7); + // Total should be much larger than the original 14px + const total = tableMeasure.columnWidths.reduce((a: number, b: number) => a + b, 0); + expect(total).toBeGreaterThan(14); + }); + + it('caps table width at page width when content is very wide', async () => { + const block: FlowBlock = { + kind: 'table', + id: 'autofit-cap', + rows: [ + { + id: 'row-0', + cells: [ + { + id: 'cell-0-0', + blocks: [ + { + kind: 'paragraph', + id: 'para-0', + runs: [{ text: 'VeryLongContentThatExceedsAvailableWidth', fontFamily: 'Arial', fontSize: 12 }], + }, + ], + }, + { + id: 'cell-0-1', + blocks: [ + { + kind: 'paragraph', + id: 'para-1', + runs: [{ text: 'AnotherVeryLongContentStringHere', fontFamily: 'Arial', fontSize: 12 }], + }, + ], + }, + ], + }, + ], + columnWidths: [5, 5], // Tiny grid widths + }; + + const measure = await measureBlock(block, { maxWidth: 300 }); + + expect(measure.kind).toBe('table'); + const tableMeasure = measure as TableMeasure; + + // Total should equal exactly maxWidth (normalization adjusts last column) + const total = tableMeasure.columnWidths.reduce((a: number, b: number) => a + b, 0); + expect(total).toBe(300); + // But columns should still be expanded beyond their original 5px + expect(tableMeasure.columnWidths[0]).toBeGreaterThan(5); + expect(tableMeasure.columnWidths[1]).toBeGreaterThan(5); + }); + + it('does not apply AutoFit to fixed layout tables', async () => { + const block: FlowBlock = { + kind: 'table', + id: 'fixed-no-autofit', + attrs: { + tableLayout: 'fixed', + tableWidth: { width: 200, type: 'px' }, + }, + rows: [ + { + id: 'row-0', + cells: [ + { + id: 'cell-0-0', + blocks: [ + { + kind: 'paragraph', + id: 'para-0', + runs: [{ text: 'WideContent', fontFamily: 'Arial', fontSize: 12 }], + }, + ], + }, + { + id: 'cell-0-1', + blocks: [ + { + kind: 'paragraph', + id: 'para-1', + runs: [{ text: 'MoreWideContent', fontFamily: 'Arial', fontSize: 12 }], + }, + ], + }, + ], + }, + ], + columnWidths: [50, 50], // Small grid widths + }; + + const measure = await measureBlock(block, { maxWidth: 600 }); + + expect(measure.kind).toBe('table'); + const tableMeasure = measure as TableMeasure; + + // Fixed layout: columns preserve original grid widths, NOT scaled to content or explicit width + expect(tableMeasure.columnWidths[0]).toBe(50); + expect(tableMeasure.columnWidths[1]).toBe(50); + }); + + it('preserves proportional column widths when content exceeds page width', async () => { + const block: FlowBlock = { + kind: 'table', + id: 'autofit-proportional', + rows: [ + { + id: 'row-0', + cells: [ + { + id: 'cell-0-0', + blocks: [ + { + kind: 'paragraph', + id: 'para-0', + runs: [{ text: 'Short', fontFamily: 'Arial', fontSize: 12 }], + }, + ], + }, + { + id: 'cell-0-1', + blocks: [ + { + kind: 'paragraph', + id: 'para-1', + runs: [{ text: 'AVeryMuchLongerPieceOfContent', fontFamily: 'Arial', fontSize: 12 }], + }, + ], + }, + ], + }, + ], + columnWidths: [3, 3], // Tiny grid widths + }; + + const measure = await measureBlock(block, { maxWidth: 200 }); + + expect(measure.kind).toBe('table'); + const tableMeasure = measure as TableMeasure; + + // The wider content column should get a proportionally larger share + expect(tableMeasure.columnWidths[1]).toBeGreaterThan(tableMeasure.columnWidths[0]); + }); + + it('skips AutoFit when grid widths are reasonable (not placeholder values)', async () => { + // Grid total = 400px, maxWidth = 600px → 66% of page width. + // This is well above the 10% placeholder threshold, so AutoFit should NOT run. + // Columns should keep their original grid widths even if content is wider. + const block: FlowBlock = { + kind: 'table', + id: 'autofit-skip-reasonable', + rows: [ + { + id: 'row-0', + cells: [ + { + id: 'cell-0-0', + blocks: [ + { + kind: 'paragraph', + id: 'para-0', + runs: [{ text: 'VeryWideContentThatExceedsColumnWidth', fontFamily: 'Arial', fontSize: 12 }], + }, + ], + }, + { + id: 'cell-0-1', + blocks: [ + { + kind: 'paragraph', + id: 'para-1', + runs: [{ text: 'Short', fontFamily: 'Arial', fontSize: 12 }], + }, + ], + }, + ], + }, + ], + columnWidths: [200, 200], // Reasonable grid widths (400/600 = 66%) + }; + + const measure = await measureBlock(block, { maxWidth: 600 }); + + expect(measure.kind).toBe('table'); + const tableMeasure = measure as TableMeasure; + + // Grid widths should be preserved — AutoFit should not have run + expect(tableMeasure.columnWidths[0]).toBe(200); + expect(tableMeasure.columnWidths[1]).toBe(200); + }); + }); }); diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index 4b38545a0b..922be9c46c 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -198,6 +198,7 @@ function getTableBorderWidths(borders: TableBorders | null | undefined): { const DEFAULT_TAB_INTERVAL_PX = twipsToPx(DEFAULT_TAB_INTERVAL_TWIPS); const TAB_EPSILON = 0.1; +const DEFAULT_CELL_PADDING = { top: 0, left: 4, right: 4, bottom: 0 }; const DEFAULT_DECIMAL_SEPARATOR = '.'; const ALLOWED_TAB_VALS = new Set(['start', 'center', 'end', 'decimal', 'bar', 'clear']); @@ -2603,6 +2604,161 @@ async function measureTableBlock(block: TableBlock, constraints: MeasureConstrai columnWidths = Array.from({ length: maxCellCount }, () => columnWidth); } + // AutoFit: content-based column sizing for auto-layout tables (ECMA-376 §17.18.87). + // When tableLayout is not 'fixed', columns must be wide enough to fit their content. + // The spec algorithm: + // 1. Calculate maximum content width per column (natural width, no line wrapping) + // 2. Use max content widths as target column widths + // 3. If total exceeds available width, proportionally scale down + // 4. Table can grow up to page width to accommodate content + // + // IMPORTANT — INTENTIONALLY LIMITED SCOPE (SD-2174): + // We only apply AutoFit when the grid column widths are clearly placeholder values + // (total grid width < 10% of available page width). Some DOCX generators (e.g. non-Word + // tools) emit dummy w:gridCol values like w=100 for every column, paired with a tiny + // w:tblW percentage, producing columns of ~7px that render as vertical slivers. + // + // A full AutoFit implementation would run on ALL non-fixed tables, but doing so today + // changes the layout of ~30 documents in our test corpus because the rest of the table + // pipeline (grid priority, percentage width scaling, cell measurement) was built without + // AutoFit in mind. Broadening this to all tables requires: + // - VRT baselines for every affected document + // - Verifying each change improves Word parity (not just "different") + // - Possibly adjusting the column width priority logic in pm-adapter + // + // Until then, we only rescue tables that are clearly broken. If you're here because a + // table renders too narrow, consider lowering the threshold or removing this gate — but + // run pnpm test:layout first to understand the blast radius. + const isFixedLayout = block.attrs?.tableLayout === 'fixed'; + const totalGridWidth = columnWidths.reduce((a, b) => a + b, 0); + const gridLooksLikePlaceholder = totalGridWidth < maxWidth * 0.1; + + if (!isFixedLayout && gridLooksLikePlaceholder) { + const gridColCount = columnWidths.length; + const maxContentWidths = new Array(gridColCount).fill(0); + + // Measure maximum content width per column (natural width with no wrapping). + // For each single-span cell, measure content with unconstrained width. The widest + // resulting line is the maximum content width per ECMA-376 §17.18.87. + const autoFitRowspanTracker: number[] = new Array(gridColCount).fill(0); + + for (const row of block.rows) { + let colIndex = 0; + + for (const cell of row.cells) { + const colspan = cell.colSpan ?? 1; + const rowspan = cell.rowSpan ?? 1; + + // Skip columns occupied by rowspans + while (colIndex < gridColCount && autoFitRowspanTracker[colIndex] > 0) { + autoFitRowspanTracker[colIndex]--; + colIndex++; + } + if (colIndex >= gridColCount) break; + + // Per spec: only single-span cells define column widths directly + if (colspan === 1) { + const cellPadding = cell.attrs?.padding ?? DEFAULT_CELL_PADDING; + const paddingH = (cellPadding.left ?? 4) + (cellPadding.right ?? 4); + + const cellBlocks = cell.blocks ?? (cell.paragraph ? [cell.paragraph] : []); + let cellMaxWidth = 0; + + for (const cellBlock of cellBlocks) { + // Measure with large maxWidth to get natural content width (no wrapping) + const maxMeasure = await measureBlock(cellBlock, { maxWidth: 99999, maxHeight: Infinity }); + + let blockMaxWidth = 0; + if (maxMeasure.kind === 'paragraph') { + for (const line of (maxMeasure as ParagraphMeasure).lines) { + if (line.width > blockMaxWidth) blockMaxWidth = line.width; + } + } else if (maxMeasure.kind === 'image' || maxMeasure.kind === 'drawing') { + blockMaxWidth = maxMeasure.width; + } else if (maxMeasure.kind === 'table') { + blockMaxWidth = maxMeasure.totalWidth; + } else if (maxMeasure.kind === 'list') { + for (const item of (maxMeasure as ListMeasure).items) { + if (item.paragraph) { + // line.width is text-only; add marker and indent space back + const gutterWidth = (item.indentLeft ?? 0) + (item.markerWidth ?? 0); + for (const line of item.paragraph.lines) { + const lineTotal = gutterWidth + line.width; + if (lineTotal > blockMaxWidth) blockMaxWidth = lineTotal; + } + } + } + } + + if (blockMaxWidth > cellMaxWidth) cellMaxWidth = blockMaxWidth; + } + + const totalWidth = cellMaxWidth + paddingH; + if (totalWidth > maxContentWidths[colIndex]) { + maxContentWidths[colIndex] = totalWidth; + } + } + + // Track rowspans + if (rowspan > 1) { + for (let c = 0; c < colspan && colIndex + c < gridColCount; c++) { + autoFitRowspanTracker[colIndex + c] = rowspan - 1; + } + } + + colIndex += colspan; + } + + // Decrement remaining rowspan trackers + for (let col = colIndex; col < gridColCount; col++) { + if (autoFitRowspanTracker[col] > 0) { + autoFitRowspanTracker[col]--; + } + } + } + + // Apply content-based widths: expand columns that are narrower than their + // maximum content width, capped at available width (maxWidth = page width). + const contentTotal = maxContentWidths.reduce((a, b) => a + b, 0); + + if (contentTotal > 0) { + if (contentTotal <= maxWidth) { + // All content fits within the page — use natural content widths directly. + for (let i = 0; i < gridColCount; i++) { + if (maxContentWidths[i] > columnWidths[i]) { + columnWidths[i] = maxContentWidths[i]; + } + } + // Guard: per-column max(content, grid) can exceed maxWidth even when + // contentTotal alone fits. Scale down if the expanded total overflows. + const expandedTotal = columnWidths.reduce((a, b) => a + b, 0); + if (expandedTotal > maxWidth && gridColCount > 0) { + const scale = maxWidth / expandedTotal; + for (let i = 0; i < gridColCount; i++) { + columnWidths[i] = Math.max(1, Math.round(columnWidths[i] * scale)); + } + const scaledSum = columnWidths.reduce((a, b) => a + b, 0); + if (scaledSum !== maxWidth) { + const diff = maxWidth - scaledSum; + columnWidths[gridColCount - 1] = Math.max(1, columnWidths[gridColCount - 1] + diff); + } + } + } else { + // Content exceeds page width — proportionally scale to fit within maxWidth. + const scale = maxWidth / contentTotal; + for (let i = 0; i < gridColCount; i++) { + columnWidths[i] = Math.max(1, Math.round(maxContentWidths[i] * scale)); + } + // Normalize to exact target width + const scaledSum = columnWidths.reduce((a, b) => a + b, 0); + if (scaledSum !== maxWidth && gridColCount > 0) { + const diff = maxWidth - scaledSum; + columnWidths[gridColCount - 1] = Math.max(1, columnWidths[gridColCount - 1] + diff); + } + } + } + } + // Derive grid column count from computed columnWidths (handles both explicit tblGrid and fallback cases) const gridColumnCount = columnWidths.length; @@ -2659,7 +2815,7 @@ async function measureTableBlock(block: TableBlock, constraints: MeasureConstrai } // Get cell padding for height calculation - const cellPadding = cell.attrs?.padding ?? { top: 0, left: 4, right: 4, bottom: 0 }; + const cellPadding = cell.attrs?.padding ?? DEFAULT_CELL_PADDING; const paddingTop = cellPadding.top ?? 0; const paddingBottom = cellPadding.bottom ?? 0; const paddingLeft = cellPadding.left ?? 4;