diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 69ca4dc0cc..1edf5d3b81 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -1790,6 +1790,8 @@ export type PartialRowInfo = { export type TableFragment = { kind: 'table'; blockId: BlockId; + /** Flow column that owns this fragment, distinct from visual x when overflow crosses margins. */ + columnIndex?: number; fromRow: number; toRow: number; x: number; diff --git a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts index 4f82c339fc..50af21af1d 100644 --- a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts +++ b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts @@ -9,7 +9,7 @@ import type { SectionBreakBlock, NormalizedColumnLayout, } from '@superdoc/contracts'; -import { cloneColumnLayout, normalizeColumnLayout } from '@superdoc/contracts'; +import { cloneColumnLayout, normalizeColumnLayout, rescaleColumnWidths } from '@superdoc/contracts'; import { layoutDocument, layoutHeaderFooter, @@ -19,6 +19,7 @@ import { resolvePageNumberTokens, type NumberingContext, SEMANTIC_PAGE_HEIGHT_PX, + resolveTableFrame, } from '@superdoc/layout-engine'; import { remeasureParagraph } from './remeasure'; import { computeDirtyRegions } from './diff'; @@ -223,7 +224,9 @@ const assignFootnotesToColumns = ( if (columns && columns.count > 1 && page) { const fragment = findFragmentForPos(page, ref.pos); - if (fragment && typeof fragment.x === 'number') { + if (fragment?.kind === 'table' && typeof fragment.columnIndex === 'number') { + columnIndex = Math.max(0, Math.min(columns.count - 1, fragment.columnIndex)); + } else if (fragment && typeof fragment.x === 'number') { const widths = Array.isArray(columns.widths) && columns.widths.length > 0 ? columns.widths : undefined; if (widths) { let cursorX = columns.left; @@ -1633,46 +1636,26 @@ export async function incrementalLayout( const block = blockById.get(range.blockId); if (!measure || measure.kind !== 'table') return; if (!block || block.kind !== 'table') return; - const tableWidthRaw = Math.max(0, measure.totalWidth ?? 0); - let tableWidth = Math.min(contentWidth, tableWidthRaw); - let tableX = columnX; - const justification = - typeof block.attrs?.justification === 'string' ? block.attrs.justification : undefined; - if (justification === 'center') { - tableX = columnX + Math.max(0, (contentWidth - tableWidth) / 2); - } else if (justification === 'right' || justification === 'end') { - tableX = columnX + Math.max(0, contentWidth - tableWidth); - } else { - const indentValue = (block.attrs?.tableIndent as { width?: unknown } | undefined)?.width; - const indent = typeof indentValue === 'number' && Number.isFinite(indentValue) ? indentValue : 0; - tableX += indent; - tableWidth = Math.max(0, tableWidth - indent); - } + const tableWidthRaw = Math.max(0, measure.totalWidth ?? contentWidth); + const { x: tableX, width: tableWidth } = resolveTableFrame( + columnX, + contentWidth, + tableWidthRaw, + block.attrs, + ); // 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), - ); - } - } + const fragmentColumnWidths = rescaleColumnWidths( + measure.columnWidths, + measure.totalWidth, + tableWidth, + ); page.fragments.push({ kind: 'table', blockId: range.blockId, + columnIndex, fromRow: 0, toRow: block.rows.length, x: tableX, diff --git a/packages/layout-engine/layout-bridge/src/position-hit.ts b/packages/layout-engine/layout-bridge/src/position-hit.ts index 53aa1ed448..3d3f66aab6 100644 --- a/packages/layout-engine/layout-bridge/src/position-hit.ts +++ b/packages/layout-engine/layout-bridge/src/position-hit.ts @@ -143,6 +143,14 @@ export const determineColumn = (layout: Layout, fragmentX: number): number => { return Math.max(0, Math.min(columns.count - 1, raw)); }; +const determineTableColumn = (layout: Layout, fragment: TableFragment): number => { + if (typeof fragment.columnIndex === 'number') { + const count = layout.columns?.count ?? 1; + return Math.max(0, Math.min(Math.max(0, count - 1), fragment.columnIndex)); + } + return determineColumn(layout, fragment.x); +}; + // --------------------------------------------------------------------------- // Line / position helpers // --------------------------------------------------------------------------- @@ -901,7 +909,7 @@ export function clickToPositionGeometry( layoutEpoch, blockId: tableHit.fragment.blockId, pageIndex, - column: determineColumn(layout, tableHit.fragment.x), + column: determineTableColumn(layout, tableHit.fragment), lineIndex, }; } @@ -915,7 +923,7 @@ export function clickToPositionGeometry( layoutEpoch, blockId: tableHit.fragment.blockId, pageIndex, - column: determineColumn(layout, tableHit.fragment.x), + column: determineTableColumn(layout, tableHit.fragment), lineIndex: 0, }; } diff --git a/packages/layout-engine/layout-bridge/test/clickToPosition.test.ts b/packages/layout-engine/layout-bridge/test/clickToPosition.test.ts index 8e9bd017fe..182d894937 100644 --- a/packages/layout-engine/layout-bridge/test/clickToPosition.test.ts +++ b/packages/layout-engine/layout-bridge/test/clickToPosition.test.ts @@ -1,6 +1,15 @@ import { describe, it, expect } from 'vitest'; import { clickToPosition, hitTestPage, hitTestTableFragment } from '../src/index.ts'; -import type { Layout, FlowBlock, Measure, Line, ParaFragment } from '@superdoc/contracts'; +import type { + Layout, + FlowBlock, + Measure, + Line, + ParaFragment, + TableBlock, + TableMeasure, + TableFragment, +} from '@superdoc/contracts'; import { simpleLayout, blocks, @@ -42,6 +51,90 @@ describe('clickToPosition', () => { expect(result?.blockId).toBe('drawing-0'); expect(result?.pos).toBe(20); }); + + it('uses table fragment columnIndex instead of visual x for multi-column overflow tables', () => { + const cellParagraph: FlowBlock = { + kind: 'paragraph', + id: 'table-cell-para', + runs: [{ text: 'Wide table', fontFamily: 'Arial', fontSize: 16, pmStart: 100, pmEnd: 110 }], + }; + + const tableBlock: TableBlock = { + kind: 'table', + id: 'wide-table', + rows: [ + { + id: 'row-0', + cells: [{ id: 'cell-0-0', blocks: [cellParagraph] }], + }, + ], + }; + + const cellParagraphMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 10, + width: 120, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }; + + const tableMeasure: TableMeasure = { + kind: 'table', + rows: [ + { + cells: [ + { + blocks: [cellParagraphMeasure], + paragraph: cellParagraphMeasure, + width: 320, + height: 28, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + ], + height: 28, + }, + ], + columnWidths: [320], + totalWidth: 320, + totalHeight: 28, + }; + + const tableFragment: TableFragment = { + kind: 'table', + blockId: 'wide-table', + columnIndex: 1, + fromRow: 0, + toRow: 1, + x: 220, + y: 40, + width: 320, + height: 28, + pmStart: 100, + pmEnd: 110, + }; + + const layout: Layout = { + pageSize: { w: 600, h: 800 }, + columns: { count: 2, gap: 20 }, + pages: [{ number: 1, fragments: [tableFragment] }], + }; + + const result = clickToPosition(layout, [tableBlock], [tableMeasure], { x: 340, y: 54 }); + + expect(result?.blockId).toBe('wide-table'); + expect(result?.column).toBe(1); + }); }); describe('hitTestPage with pageGap', () => { diff --git a/packages/layout-engine/layout-bridge/test/footnoteColumnPlacement.test.ts b/packages/layout-engine/layout-bridge/test/footnoteColumnPlacement.test.ts index 34a5e26555..ff9a3066c2 100644 --- a/packages/layout-engine/layout-bridge/test/footnoteColumnPlacement.test.ts +++ b/packages/layout-engine/layout-bridge/test/footnoteColumnPlacement.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import type { FlowBlock, Measure } from '@superdoc/contracts'; +import type { FlowBlock, Measure, TableBlock, TableMeasure } from '@superdoc/contracts'; import { incrementalLayout } from '../src/incrementalLayout'; const makeParagraph = (id: string, text: string, pmStart: number): FlowBlock => ({ @@ -80,4 +80,92 @@ describe('Footnotes in columns', () => { expect(footnoteOneFragment?.x).toBeCloseTo(columnOneX, 2); expect(footnoteTwoFragment?.x).toBeCloseTo(columnTwoX, 2); }); + + it('keeps footnotes in the owning column for wide overflow tables', async () => { + const paragraphOne = makeParagraph('para-1', 'Column 1 text', 0); + const columnBreak: FlowBlock = { kind: 'columnBreak', id: 'col-break-1' }; + + const tableCellParagraph = makeParagraph('table-cell-para', 'Wide table ref', 80); + const wideTable: TableBlock = { + kind: 'table', + id: 'wide-table', + attrs: { justification: 'right' }, + rows: [ + { + id: 'row-0', + cells: [{ id: 'cell-0-0', blocks: [tableCellParagraph] }], + }, + ], + }; + + const footnote = makeParagraph('footnote-wide-0-paragraph', 'Wide table footnote', 0); + + const tableCellMeasure = makeMeasure(18, 'Wide table ref'.length); + const wideTableMeasure: TableMeasure = { + kind: 'table', + rows: [ + { + cells: [ + { + blocks: [tableCellMeasure], + paragraph: tableCellMeasure, + width: 320, + height: 28, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + ], + height: 28, + }, + ], + columnWidths: [320], + totalWidth: 320, + totalHeight: 28, + }; + + const measureBlock = vi.fn(async (block: FlowBlock) => { + if (block.kind === 'columnBreak') { + return { kind: 'columnBreak' } as Measure; + } + if (block.kind === 'table') { + return wideTableMeasure; + } + const textLength = block.kind === 'paragraph' ? (block.runs?.[0]?.text?.length ?? 1) : 1; + const lineHeight = block.id.startsWith('footnote-') ? 10 : 18; + return makeMeasure(lineHeight, textLength); + }); + + const columns = { count: 2, gap: 20 }; + const margins = { top: 60, right: 60, bottom: 60, left: 60 }; + const pageSize = { w: 600, h: 800 }; + + const result = await incrementalLayout( + [], + null, + [paragraphOne, columnBreak, wideTable], + { + pageSize, + margins, + columns, + footnotes: { + refs: [{ id: 'wide', pos: 82 }], + blocksById: new Map([['wide', [footnote]]]), + }, + }, + measureBlock, + ); + + const page = result.layout.pages[0]; + const columnWidth = (pageSize.w - margins.left - margins.right - columns.gap) / columns.count; + const columnTwoX = margins.left + columnWidth + columns.gap; + + const tableFragment = page.fragments.find((fragment) => fragment.blockId === wideTable.id); + const footnoteFragment = page.fragments.find((fragment) => fragment.blockId === footnote.id); + + expect(tableFragment?.kind).toBe('table'); + expect(tableFragment && 'columnIndex' in tableFragment ? tableFragment.columnIndex : undefined).toBe(1); + expect(tableFragment?.x).toBeLessThan(columnTwoX); + expect(footnoteFragment?.x).toBeCloseTo(columnTwoX, 2); + }); }); diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 1cb5e155ae..d164ddf5bf 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -2916,5 +2916,5 @@ export { resolvePageNumberTokens } from './resolvePageTokens.js'; export type { NumberingContext, ResolvePageTokensResult } from './resolvePageTokens.js'; // Table utilities consumed by layout-bridge and cross-package sync tests -export { getCellLines, getEmbeddedRowLines } from './layout-table.js'; +export { getCellLines, getEmbeddedRowLines, resolveTableFrame, resolveRenderedTableWidth } from './layout-table.js'; export { describeCellRenderBlocks, computeCellSliceContentHeight } from './table-cell-slice.js'; 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 6e0439aa53..8d9b2e47d5 100644 --- a/packages/layout-engine/layout-engine/src/layout-table.test.ts +++ b/packages/layout-engine/layout-engine/src/layout-table.test.ts @@ -727,6 +727,56 @@ describe('layoutTableBlock', () => { const rightFragment = layoutWithJustification('right'); expect(rightFragment.x).toBe(300); }); + + it('allows centered wide tables to overflow into both margins', () => { + const measure = createMockTableMeasure([300, 300], [20]); + const fragments: TableFragment[] = []; + const mockPage = { fragments }; + const block = createMockTableBlock(1, undefined, { justification: 'center' }); + + layoutTableBlock({ + block, + measure, + columnWidth: 500, + ensurePage: () => ({ + page: mockPage, + columnIndex: 0, + cursorY: 0, + contentBottom: 1000, + }), + advanceColumn: (state) => state, + columnX: () => 0, + }); + + expect(fragments).toHaveLength(1); + expect(fragments[0].width).toBe(600); + expect(fragments[0].x).toBe(-50); + }); + + it('allows right-aligned wide tables to overflow into the left margin', () => { + const measure = createMockTableMeasure([300, 300], [20]); + const fragments: TableFragment[] = []; + const mockPage = { fragments }; + const block = createMockTableBlock(1, undefined, { justification: 'right' }); + + layoutTableBlock({ + block, + measure, + columnWidth: 500, + ensurePage: () => ({ + page: mockPage, + columnIndex: 0, + cursorY: 0, + contentBottom: 1000, + }), + advanceColumn: (state) => state, + columnX: () => 0, + }); + + expect(fragments).toHaveLength(1); + expect(fragments[0].width).toBe(600); + expect(fragments[0].x).toBe(-100); + }); }); describe('table start preflight', () => { @@ -3356,6 +3406,33 @@ describe('layoutTableBlock', () => { expect(fragments[0].width).toBe(240); // 200 - (-40) }); + it('preserves width for wide tables with positive indent', () => { + const block = createMockTableBlock(1); + block.attrs = { tableIndent: { width: 30 } } as TableAttrs; + const measure = createMockTableMeasure([300, 300], [20]); + + const fragments: TableFragment[] = []; + const mockPage = { fragments }; + + layoutTableBlock({ + block, + measure, + columnWidth: 500, + ensurePage: () => ({ + page: mockPage, + columnIndex: 0, + cursorY: 0, + contentBottom: 1000, + }), + advanceColumn: (state) => state, + columnX: () => 0, + }); + + expect(fragments).toHaveLength(1); + expect(fragments[0].x).toBe(30); + expect(fragments[0].width).toBe(600); + }); + it('should clamp width to 0 when indent exceeds width', () => { const block = createMockTableBlock(1); block.attrs = { tableIndent: { width: 250 } } as TableAttrs; @@ -4167,7 +4244,9 @@ describe('layoutTableBlock', () => { 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 block = createMockTableBlock(2, undefined, { + tableWidth: { value: 5000, type: 'pct' }, + }); const measure = createMockTableMeasure([250, 200, 250], [30, 30]); // measure.totalWidth = 700 @@ -4236,7 +4315,9 @@ describe('layoutTableBlock', () => { 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 block = createMockTableBlock(4, undefined, { + tableWidth: { value: 5000, type: 'pct' }, + }); const measure = createMockTableMeasure([300, 300], [200, 200, 200, 200]); // totalWidth = 600, each row = 200px @@ -4276,7 +4357,9 @@ describe('layoutTableBlock', () => { }); it('should generate metadata boundaries from rescaled column widths when table is clamped', () => { - const block = createMockTableBlock(2); + const block = createMockTableBlock(2, undefined, { + tableWidth: { value: 5000, type: 'pct' }, + }); const measure = createMockTableMeasure([250, 200, 250], [30, 30]); const fragments: TableFragment[] = []; diff --git a/packages/layout-engine/layout-engine/src/layout-table.ts b/packages/layout-engine/layout-engine/src/layout-table.ts index 7f37f86d40..6f6d71d051 100644 --- a/packages/layout-engine/layout-engine/src/layout-table.ts +++ b/packages/layout-engine/layout-engine/src/layout-table.ts @@ -11,6 +11,7 @@ import type { ParagraphMeasure, ParagraphBlock, } from '@superdoc/contracts'; +import { OOXML_PCT_DIVISOR, rescaleColumnWidths } from '@superdoc/contracts'; import type { PageState } from './paginator.js'; import { computeFragmentPmRange, extractBlockPmRange } from './layout-utils.js'; import { describeCellRenderBlocks, createCellSliceCursor, computeFullCellContentHeight } from './table-cell-slice.js'; @@ -122,6 +123,13 @@ function applyTableIndent(x: number, width: number, indent: number, columnWidth: }; } + if (width > columnWidth) { + return { + x: shiftedX, + width, + }; + } + const maxWidthWithinColumn = Math.max(0, columnWidth - indent); return { x: shiftedX, @@ -129,6 +137,43 @@ function applyTableIndent(x: number, width: number, indent: number, columnWidth: }; } +function resolveTableWidthValue(attrs: TableBlock['attrs']): { width: number; type?: string } | null { + const tableWidth = attrs?.tableWidth; + if (!tableWidth || typeof tableWidth !== 'object') { + return null; + } + + const width = (tableWidth as Record).width ?? (tableWidth as Record).value; + if (typeof width !== 'number' || !Number.isFinite(width) || width <= 0) { + return null; + } + + const type = (tableWidth as Record).type; + return { + width, + type: typeof type === 'string' ? type : undefined, + }; +} + +export function resolveRenderedTableWidth( + columnWidth: number, + measuredWidth: number, + attrs: TableBlock['attrs'], +): number { + const safeMeasuredWidth = + Number.isFinite(measuredWidth) && measuredWidth > 0 ? measuredWidth : Math.max(0, columnWidth); + const configuredWidth = resolveTableWidthValue(attrs); + if (!configuredWidth) { + return safeMeasuredWidth; + } + + if (configuredWidth.type === 'pct') { + return Math.max(0, Math.round(columnWidth * (configuredWidth.width / OOXML_PCT_DIVISOR))); + } + + return safeMeasuredWidth; +} + /** * Resolve the table fragment frame within a column based on justification. * @@ -142,20 +187,20 @@ function applyTableIndent(x: number, width: number, indent: number, columnWidth: * @param attrs - Table attributes * @returns Resolved x and width for the table fragment */ -function resolveTableFrame( +export function resolveTableFrame( baseX: number, columnWidth: number, tableWidth: number, attrs: TableBlock['attrs'], ): { x: number; width: number } { - const width = Math.min(columnWidth, tableWidth); + const width = resolveRenderedTableWidth(columnWidth, tableWidth, attrs); const justification = typeof attrs?.justification === 'string' ? attrs.justification : undefined; if (justification === 'center') { - return { x: baseX + Math.max(0, (columnWidth - width) / 2), width }; + return { x: baseX + (columnWidth - width) / 2, width }; } if (justification === 'right' || justification === 'end') { - return { x: baseX + Math.max(0, columnWidth - width), width }; + return { x: baseX + (columnWidth - width), width }; } const tableIndent = getTableIndentWidth(attrs); @@ -172,9 +217,6 @@ function resolveTableFrame( * * @returns Rescaled column widths if clamping occurred, undefined otherwise. */ -// Canonical implementation lives in @superdoc/contracts; imported for local use. -import { rescaleColumnWidths } from '@superdoc/contracts'; - const COLUMN_MIN_WIDTH_PX = 25; const COLUMN_MAX_WIDTH_PX = 200; const ROW_MIN_HEIGHT_PX = 10; @@ -1211,7 +1253,7 @@ function layoutMonolithicTable(context: TableLayoutContext): void { const height = Math.min(context.measure.totalHeight, state.contentBottom - state.cursorY); const baseX = context.columnX(state.columnIndex); - const baseWidth = Math.min(context.columnWidth, context.measure.totalWidth || context.columnWidth); + const baseWidth = Math.max(0, context.measure.totalWidth || context.columnWidth); const { x, width } = resolveTableFrame(baseX, context.columnWidth, baseWidth, context.block.attrs); const columnWidths = rescaleColumnWidths(context.measure.columnWidths, context.measure.totalWidth, width); @@ -1227,6 +1269,7 @@ function layoutMonolithicTable(context: TableLayoutContext): void { const fragment: TableFragment = { kind: 'table', blockId: context.block.id, + columnIndex: state.columnIndex, fromRow: 0, toRow: context.block.rows.length, x, @@ -1368,7 +1411,7 @@ export function layoutTableBlock({ const height = Math.min(measure.totalHeight, state.contentBottom - state.cursorY); const baseX = columnX(state.columnIndex); - const baseWidth = Math.min(columnWidth, measure.totalWidth || columnWidth); + const baseWidth = Math.max(0, measure.totalWidth || columnWidth); const { x, width } = resolveTableFrame(baseX, columnWidth, baseWidth, block.attrs); const columnWidths = rescaleColumnWidths(measure.columnWidths, measure.totalWidth, width); @@ -1377,6 +1420,7 @@ export function layoutTableBlock({ const fragment: TableFragment = { kind: 'table', blockId: block.id, + columnIndex: state.columnIndex, fromRow: 0, toRow: 0, x, @@ -1522,13 +1566,14 @@ export function layoutTableBlock({ // Don't create empty fragments with just padding if (fragmentHeight > 0 && madeProgress) { const baseX = columnX(state.columnIndex); - const baseWidth = Math.min(columnWidth, measure.totalWidth || columnWidth); + const baseWidth = Math.max(0, measure.totalWidth || columnWidth); const { x, width } = resolveTableFrame(baseX, columnWidth, baseWidth, block.attrs); const scaledWidths = rescaleColumnWidths(measure.columnWidths, measure.totalWidth, width); const fragment: TableFragment = { kind: 'table', blockId: block.id, + columnIndex: state.columnIndex, fromRow: rowIndex, toRow: rowIndex + 1, x, @@ -1636,13 +1681,14 @@ export function layoutTableBlock({ ); const baseX = columnX(state.columnIndex); - const baseWidth = Math.min(columnWidth, measure.totalWidth || columnWidth); + const baseWidth = Math.max(0, measure.totalWidth || columnWidth); const { x, width } = resolveTableFrame(baseX, columnWidth, baseWidth, block.attrs); const scaledWidths = rescaleColumnWidths(measure.columnWidths, measure.totalWidth, width); const fragment: TableFragment = { kind: 'table', blockId: block.id, + columnIndex: state.columnIndex, fromRow: bodyStartRow, toRow: forcedEndRow, x, @@ -1685,13 +1731,14 @@ export function layoutTableBlock({ ); const baseX = columnX(state.columnIndex); - const baseWidth = Math.min(columnWidth, measure.totalWidth || columnWidth); + const baseWidth = Math.max(0, measure.totalWidth || columnWidth); const { x, width } = resolveTableFrame(baseX, columnWidth, baseWidth, block.attrs); const scaledWidths = rescaleColumnWidths(measure.columnWidths, measure.totalWidth, width); const fragment: TableFragment = { kind: 'table', blockId: block.id, + columnIndex: state.columnIndex, fromRow: bodyStartRow, toRow: endRow, x, diff --git a/packages/layout-engine/measuring/dom/src/index.test.ts b/packages/layout-engine/measuring/dom/src/index.test.ts index c4d26ea236..5591296d54 100644 --- a/packages/layout-engine/measuring/dom/src/index.test.ts +++ b/packages/layout-engine/measuring/dom/src/index.test.ts @@ -3243,6 +3243,9 @@ describe('measureBlock', () => { const block: FlowBlock = { kind: 'table', id: 'table-1', + attrs: { + tableWidth: { value: 5000, type: 'pct' }, + }, rows: [ { id: 'row-0', @@ -3280,14 +3283,14 @@ describe('measureBlock', () => { ], }, ], - columnWidths: [400, 400], // Total 800px + columnWidths: [400, 400], // Total 800px, but pct width should rescale to current column }; const measure = await measureBlock(block, { maxWidth: 600 }); expect(measure.kind).toBe('table'); if (measure.kind !== 'table') throw new Error('expected table measure'); - // Should scale: 400 * (600/800) = 300 + // Percentage width should rescale to the current column width: 400 * (600/800) = 300 expect(measure.columnWidths[0]).toBe(300); expect(measure.columnWidths[1]).toBe(300); expect(measure.totalWidth).toBe(600); @@ -3851,6 +3854,9 @@ describe('measureBlock', () => { const block: FlowBlock = { kind: 'table', id: 'scale-test-1', + attrs: { + tableWidth: { value: 5000, type: 'pct' }, + }, rows: [ { id: 'row-0', @@ -3896,7 +3902,7 @@ describe('measureBlock', () => { expect(measure.kind).toBe('table'); if (measure.kind !== 'table') throw new Error('expected table measure'); - // Should scale from 400px to 300px maintaining 1:2:1 ratio + // Percentage width should scale from 400px to 300px maintaining 1:2:1 ratio // 100 * (300/400) = 75, 200 * (300/400) = 150 expect(measure.columnWidths[0]).toBe(75); expect(measure.columnWidths[1]).toBe(150); @@ -3952,6 +3958,9 @@ describe('measureBlock', () => { const block: FlowBlock = { kind: 'table', id: 'scale-test-3', + attrs: { + tableWidth: { value: 5000, type: 'pct' }, + }, rows: [ { id: 'row-0', @@ -4040,6 +4049,9 @@ describe('measureBlock', () => { const block: FlowBlock = { kind: 'table', id: 'scale-test-5', + attrs: { + tableWidth: { value: 5000, type: 'pct' }, + }, rows: [ { id: 'row-0', @@ -4310,6 +4322,53 @@ describe('measureBlock', () => { expect(measure.columnWidths[1]).toBe(300); }); + it('preserves explicit widths wider than the content column', async () => { + const block: FlowBlock = { + kind: 'table', + id: 'explicit-wide-table', + attrs: { + tableWidth: { width: 700, type: 'px' }, + }, + rows: [ + { + id: 'row-0', + cells: [ + { + id: 'cell-0-0', + blocks: [ + { + kind: 'paragraph', + id: 'para-0', + runs: [{ text: 'A', fontFamily: 'Arial', fontSize: 12 }], + }, + ], + }, + { + id: 'cell-0-1', + blocks: [ + { + kind: 'paragraph', + id: 'para-1', + runs: [{ text: 'B', fontFamily: 'Arial', fontSize: 12 }], + }, + ], + }, + ], + }, + ], + columnWidths: [200, 200], + }; + + const measure = await measureBlock(block, { maxWidth: 500 }); + + expect(measure.kind).toBe('table'); + if (measure.kind !== 'table') throw new Error('expected table measure'); + + expect(measure.totalWidth).toBe(700); + expect(measure.columnWidths[0]).toBe(350); + expect(measure.columnWidths[1]).toBe(350); + }); + it('scales column widths to 50% of available width when tableWidth type is pct with value 2500', async () => { const block: FlowBlock = { kind: 'table', @@ -4359,6 +4418,49 @@ describe('measureBlock', () => { expect(measure.columnWidths[1]).toBe(150); }); + it('preserves imported grid widths that exceed the content column', async () => { + const block: FlowBlock = { + kind: 'table', + id: 'grid-wide-table', + rows: [ + { + id: 'row-0', + cells: [ + { + id: 'cell-0-0', + blocks: [ + { + kind: 'paragraph', + id: 'para-0', + runs: [{ text: 'A', fontFamily: 'Arial', fontSize: 12 }], + }, + ], + }, + { + id: 'cell-0-1', + blocks: [ + { + kind: 'paragraph', + id: 'para-1', + runs: [{ text: 'B', fontFamily: 'Arial', fontSize: 12 }], + }, + ], + }, + ], + }, + ], + columnWidths: [320, 320], + }; + + const measure = await measureBlock(block, { maxWidth: 500 }); + + expect(measure.kind).toBe('table'); + if (measure.kind !== 'table') throw new Error('expected table measure'); + + expect(measure.totalWidth).toBe(640); + expect(measure.columnWidths).toEqual([320, 320]); + }); + it('handles percentage width with width property instead of value', async () => { const block: FlowBlock = { kind: 'table', diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index ded7439f9e..0046a28614 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -2600,9 +2600,10 @@ async function measureTableBlock(block: TableBlock, constraints: MeasureConstrai 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) - const effectiveTargetWidth = resolvedTableWidth != null ? Math.min(resolvedTableWidth, maxWidth) : maxWidth; + // Effective target width: explicit OOXML widths are authoritative and may exceed the + // content column when Word would overflow into the margins. Auto-sized fallback tables + // still default to the available column width. + const effectiveTargetWidth = resolvedTableWidth != null ? resolvedTableWidth : maxWidth; // Use provided column widths from OOXML w:tblGrid if available if (block.columnWidths && block.columnWidths.length > 0) { @@ -2613,26 +2614,29 @@ async function measureTableBlock(block: TableBlock, constraints: MeasureConstrai const hasExplicitWidth = resolvedTableWidth != null; const hasFixedLayout = block.attrs?.tableLayout === 'fixed'; - // For tables with explicit/percentage width or fixed layout, scale to target width + // For tables with explicit/percentage width or fixed layout, scale to the imported + // target width when one exists. Wide explicit tables are allowed to exceed the + // current content column so they can later render into the margins. if (hasExplicitWidth || hasFixedLayout) { const totalWidth = columnWidths.reduce((a, b) => a + b, 0); const tableWidthType = (block.attrs?.tableWidth as TableWidthAttr | undefined)?.type; - const shouldScaleDown = totalWidth > effectiveTargetWidth; + const targetWidth = hasExplicitWidth ? effectiveTargetWidth : totalWidth; + const shouldScaleDown = totalWidth > targetWidth; const shouldScaleUp = - totalWidth < effectiveTargetWidth && - effectiveTargetWidth > 0 && + totalWidth < targetWidth && + targetWidth > 0 && (tableWidthType === 'pct' || (hasExplicitWidth && !hasFixedLayout)); - // Scale to effectiveTargetWidth (resolved percentage or explicit width) + // Scale to the resolved percentage or explicit width. // - Always scale down if too wide // - Only scale up for percentage widths or auto-layout tables - if ((shouldScaleDown || shouldScaleUp) && effectiveTargetWidth > 0 && totalWidth > 0) { - const scale = effectiveTargetWidth / totalWidth; + if ((shouldScaleDown || shouldScaleUp) && targetWidth > 0 && totalWidth > 0) { + const scale = targetWidth / totalWidth; columnWidths = columnWidths.map((w) => Math.max(1, Math.round(w * scale))); // Normalize to exact target width (handle rounding errors) const scaledSum = columnWidths.reduce((a, b) => a + b, 0); - if (scaledSum !== effectiveTargetWidth && columnWidths.length > 0) { - const diff = effectiveTargetWidth - scaledSum; + if (scaledSum !== targetWidth && columnWidths.length > 0) { + const diff = targetWidth - scaledSum; columnWidths[columnWidths.length - 1] = Math.max(1, columnWidths[columnWidths.length - 1] + diff); } } @@ -2650,20 +2654,10 @@ async function measureTableBlock(block: TableBlock, constraints: MeasureConstrai columnWidths = columnWidths.slice(0, maxCellCount); } - // Auto-layout: only scale DOWN if columns exceed available width. - // Do NOT scale up — explicit w:tblGrid column widths are authoritative. + // Auto-layout: keep explicit w:tblGrid column widths authoritative, even when they + // exceed the current content column. Narrower-section fallback happens in layout. // Tables without w:tblGrid already arrive with page-width columns via // the fallback grid builder in tableFallbackHelpers. - const totalWidth = columnWidths.reduce((a, b) => a + b, 0); - if (totalWidth > effectiveTargetWidth && effectiveTargetWidth > 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 { // Fallback: Equal distribution based on max cells in any row