From 04a50be8a4b3ad767984163661e1cb1d339f49f0 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Wed, 11 Feb 2026 14:42:07 +0200 Subject: [PATCH 1/2] fix: anchor table overlaps text --- .../layout-engine/src/index.test.ts | 207 ++++++++++++++++++ .../layout-engine/layout-engine/src/index.ts | 62 ++++-- .../layout-engine/src/layout-table.ts | 15 +- 3 files changed, 260 insertions(+), 24 deletions(-) diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index 849d5a1492..3390f22819 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -35,6 +35,49 @@ const makeMeasure = (heights: number[]): ParagraphMeasure => ({ totalHeight: heights.reduce((sum, h) => sum + h, 0), }); +const makeTableBlock = ( + id: string, + rowCount: number, + options?: { anchor?: TableBlock['anchor']; wrap?: TableBlock['wrap'] }, +): TableBlock => { + const rows = Array.from({ length: rowCount }, (_, rowIndex) => ({ + id: `${id}-row-${rowIndex}`, + cells: [ + { + id: `${id}-cell-${rowIndex}-0`, + paragraph: { + kind: 'paragraph' as const, + id: `${id}-cell-${rowIndex}-p0`, + runs: [], + }, + }, + ], + })); + + return { + kind: 'table', + id, + rows, + anchor: options?.anchor, + wrap: options?.wrap, + }; +}; + +const makeTableMeasure = (columnWidths: number[], rowHeights: number[]): TableMeasure => ({ + kind: 'table', + rows: rowHeights.map((height) => ({ + height, + cells: columnWidths.map((width) => ({ + paragraph: makeMeasure([height]), + width, + height, + })), + })), + columnWidths, + totalWidth: columnWidths.reduce((sum, width) => sum + width, 0), + totalHeight: rowHeights.reduce((sum, height) => sum + height, 0), +}); + const block: FlowBlock = { kind: 'paragraph', id: 'block-1', @@ -508,6 +551,170 @@ describe('layoutDocument', () => { expect(paraFragment.width).toBe(contentWidth); }); + it('does not push anchor paragraph below anchored tables', () => { + const tableBlock = makeTableBlock('table-1', 1, { + anchor: { + isAnchored: true, + hRelativeFrom: 'column', + vRelativeFrom: 'paragraph', + offsetH: 0, + offsetV: 0, + }, + wrap: { + type: 'Square', + wrapText: 'right', // Table on left, text wraps to right + distLeft: 5, + distRight: 10, + }, + }); + + const tableMeasure = makeTableMeasure([200], [60]); + + const paragraphBlock: FlowBlock = { + kind: 'paragraph', + id: 'para-1', + runs: [], + }; + + const paragraphMeasure = makeMeasure([20, 20, 20]); + + const layout = layoutDocument([paragraphBlock, tableBlock], [paragraphMeasure, tableMeasure], DEFAULT_OPTIONS); + + const fragments = layout.pages[0].fragments; + const paraFragment = fragments.find( + (fragment) => fragment.kind === 'para' && fragment.blockId === 'para-1', + ) as ParaFragment; + + expect(paraFragment).toBeTruthy(); + + const contentWidth = DEFAULT_OPTIONS.pageSize!.w - DEFAULT_OPTIONS.margins!.left - DEFAULT_OPTIONS.margins!.right; + + expect(paraFragment.x).toBe(DEFAULT_OPTIONS.margins!.left); + expect(paraFragment.width).toBe(contentWidth); + }); + + it('anchors tables after the paragraph even when the paragraph spans pages', () => { + const options: LayoutOptions = { + pageSize: { w: 300, h: 120 }, + margins: { top: 20, right: 20, bottom: 20, left: 20 }, + }; + + const paragraphBlock: FlowBlock = { + kind: 'paragraph', + id: 'para-1', + runs: [], + }; + const paragraphMeasure = makeMeasure([40, 40, 40]); + + const tableBlock = makeTableBlock('table-1', 1, { + anchor: { + isAnchored: true, + hRelativeFrom: 'column', + vRelativeFrom: 'paragraph', + offsetH: 0, + offsetV: 10, + }, + wrap: { type: 'Square' }, + }); + const tableMeasure = makeTableMeasure([100], [30]); + + const layout = layoutDocument([paragraphBlock, tableBlock], [paragraphMeasure, tableMeasure], options); + + expect(layout.pages.length).toBeGreaterThanOrEqual(2); + + const firstPageTable = layout.pages[0].fragments.find( + (fragment) => fragment.kind === 'table' && fragment.blockId === 'table-1', + ) as { y: number } | undefined; + const secondPageTable = layout.pages[1].fragments.find( + (fragment) => fragment.kind === 'table' && fragment.blockId === 'table-1', + ) as { y: number } | undefined; + + expect(firstPageTable).toBeUndefined(); + expect(secondPageTable).toBeTruthy(); + expect(secondPageTable?.y).toBe(options.margins!.top + 40 + 10); + }); + + it('pushes subsequent paragraphs below anchored tables', () => { + const paragraph1: FlowBlock = { kind: 'paragraph', id: 'para-1', runs: [] }; + const paragraph2: FlowBlock = { kind: 'paragraph', id: 'para-2', runs: [] }; + + const paragraph1Measure = makeMeasure([20]); + const paragraph2Measure = makeMeasure([20, 20]); + + const tableBlock = makeTableBlock('table-1', 1, { + anchor: { + isAnchored: true, + hRelativeFrom: 'column', + vRelativeFrom: 'paragraph', + offsetH: 0, + offsetV: 0, + }, + wrap: { + type: 'Square', + wrapText: 'right', + }, + }); + const tableMeasure = makeTableMeasure([200], [100]); + + const layout = layoutDocument( + [paragraph1, tableBlock, paragraph2], + [paragraph1Measure, tableMeasure, paragraph2Measure], + DEFAULT_OPTIONS, + ); + + const para2Fragment = layout.pages[0].fragments.find( + (fragment) => fragment.kind === 'para' && fragment.blockId === 'para-2', + ) as ParaFragment; + + const contentWidth = DEFAULT_OPTIONS.pageSize!.w - DEFAULT_OPTIONS.margins!.left - DEFAULT_OPTIONS.margins!.right; + + expect(para2Fragment.x).toBe(DEFAULT_OPTIONS.margins!.left); + expect(para2Fragment.width).toBe(contentWidth); + }); + + it('treats 99% width floating tables as inline but anchors narrower tables', () => { + const paragraphBlock: FlowBlock = { kind: 'paragraph', id: 'para-1', runs: [] }; + const paragraphMeasure = makeMeasure([20]); + + const inlineTableBlock = makeTableBlock('table-99', 1, { + anchor: { isAnchored: true, hRelativeFrom: 'column', vRelativeFrom: 'paragraph', offsetH: 0, offsetV: 0 }, + wrap: { type: 'Square' }, + }); + const inlineTableMeasure = makeTableMeasure([495], [40]); + + const inlineLayout = layoutDocument( + [paragraphBlock, inlineTableBlock], + [paragraphMeasure, inlineTableMeasure], + DEFAULT_OPTIONS, + ); + + const inlineTableFragment = inlineLayout.pages[0].fragments.find( + (fragment) => fragment.kind === 'table' && fragment.blockId === 'table-99', + ) as { y: number } | undefined; + + expect(inlineTableFragment).toBeTruthy(); + expect(inlineTableFragment?.y).toBe(DEFAULT_OPTIONS.margins!.top + paragraphMeasure.totalHeight); + + const anchoredTableBlock = makeTableBlock('table-98', 1, { + anchor: { isAnchored: true, hRelativeFrom: 'column', vRelativeFrom: 'paragraph', offsetH: 0, offsetV: 0 }, + wrap: { type: 'Square' }, + }); + const anchoredTableMeasure = makeTableMeasure([490], [40]); + + const anchoredLayout = layoutDocument( + [{ kind: 'paragraph', id: 'para-1', runs: [] }, anchoredTableBlock], + [paragraphMeasure, anchoredTableMeasure], + DEFAULT_OPTIONS, + ); + + const anchoredTableFragment = anchoredLayout.pages[0].fragments.find( + (fragment) => fragment.kind === 'table' && fragment.blockId === 'table-98', + ) as { y: number } | undefined; + + expect(anchoredTableFragment).toBeTruthy(); + expect(anchoredTableFragment?.y).toBe(DEFAULT_OPTIONS.margins!.top + paragraphMeasure.totalHeight); + }); + it('propagates pm ranges onto fragments', () => { const blockWithRuns: FlowBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index e7629254ac..21f07cd531 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -128,6 +128,21 @@ function getMeasureHeight(block: FlowBlock, measure: Measure): number { } } +/** + * Compute the base Y coordinate for an anchored table based on vRelativeFrom. + * Ignores tblpYSpec (alignV) by design. + */ +function getTableAnchorBaseY(block: TableBlock, state: PageState): number { + const vRelativeFrom = block.anchor?.vRelativeFrom ?? 'paragraph'; + if (vRelativeFrom === 'page') { + return 0; + } + if (vRelativeFrom === 'margin') { + return state.topMargin; + } + return state.cursorY; +} + // ConstraintBoundary and PageState now come from paginator /** @@ -1718,27 +1733,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options } const anchorsForPara = anchoredByParagraph.get(index); - - // Register anchored tables for this paragraph before layout - // so the float manager knows about them when laying out text const tablesForPara = anchoredTablesByParagraph.get(index); - if (tablesForPara) { - const state = paginator.ensurePage(); - for (const { block: tableBlock, measure: tableMeasure } of tablesForPara) { - if (placedAnchoredTableIds.has(tableBlock.id)) continue; - - // Register the table with the float manager for text wrapping - floatManager.registerTable(tableBlock, tableMeasure, state.cursorY, state.columnIndex, state.page.number); - - // Create and place the table fragment at its anchored position - const anchorX = tableBlock.anchor?.offsetH ?? columnX(state.columnIndex); - const anchorY = state.cursorY + (tableBlock.anchor?.offsetV ?? 0); - - const tableFragment = createAnchoredTableFragment(tableBlock, tableMeasure, anchorX, anchorY); - state.page.fragments.push(tableFragment); - placedAnchoredTableIds.add(tableBlock.id); - } - } /** * keepNext Chain-Aware Page Break Logic @@ -1902,6 +1897,33 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options } : undefined, ); + + // Register and place anchored tables after the paragraph so the paragraph appears above the table. + // Full-width floating tables are treated as inline and laid out when we hit the table block. + if (tablesForPara) { + const state = paginator.ensurePage(); + const columnWidthForTable = getCurrentColumns().width; + let tableBottomY = state.cursorY; + for (const { block: tableBlock, measure: tableMeasure } of tablesForPara) { + if (placedAnchoredTableIds.has(tableBlock.id)) continue; + const totalWidth = tableMeasure.totalWidth ?? 0; + if (columnWidthForTable > 0 && totalWidth >= columnWidthForTable * 0.99) continue; + + const anchorBaseY = getTableAnchorBaseY(tableBlock, state); + floatManager.registerTable(tableBlock, tableMeasure, anchorBaseY, state.columnIndex, state.page.number); + + const anchorX = tableBlock.anchor?.offsetH ?? columnX(state.columnIndex); + const anchorY = anchorBaseY + (tableBlock.anchor?.offsetV ?? 0); + + const tableFragment = createAnchoredTableFragment(tableBlock, tableMeasure, anchorX, anchorY); + state.page.fragments.push(tableFragment); + placedAnchoredTableIds.add(tableBlock.id); + + const bottom = anchorY + (tableMeasure.totalHeight ?? 0); + if (bottom > tableBottomY) tableBottomY = bottom; + } + state.cursorY = tableBottomY; + } continue; } if (block.kind === 'image') { diff --git a/packages/layout-engine/layout-engine/src/layout-table.ts b/packages/layout-engine/layout-engine/src/layout-table.ts index 4fe6a0233a..96da381395 100644 --- a/packages/layout-engine/layout-engine/src/layout-table.ts +++ b/packages/layout-engine/layout-engine/src/layout-table.ts @@ -1017,15 +1017,22 @@ export function layoutTableBlock({ advanceColumn, columnX, }: TableLayoutContext): void { - // Skip anchored/floating tables handled by the float manager + // Anchored/floating tables are normally placed by the float manager when we layout their anchor + // paragraph. Treat full-width floating tables as inline so they flow like normal tables and + // don't create overlap or extra pages. + let treatAsInline = false; if (block.anchor?.isAnchored) { - return; + const totalWidth = measure.totalWidth ?? 0; + treatAsInline = columnWidth > 0 && totalWidth >= columnWidth * 0.99; + if (!treatAsInline) { + return; + } } - // 1. Detect floating tables - use monolithic layout + // 1. Detect floating tables - use monolithic layout (unless we're treating as inline) const tableProps = block.attrs?.tableProperties as Record | undefined; const floatingProps = tableProps?.floatingTableProperties as Record | undefined; - if (floatingProps && Object.keys(floatingProps).length > 0) { + if (floatingProps && Object.keys(floatingProps).length > 0 && !treatAsInline) { layoutMonolithicTable({ block, measure, columnWidth, ensurePage, advanceColumn, columnX }); return; } From f794f41a053b91c078ca0bea3bfbdce70bb4c88f Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Thu, 12 Feb 2026 22:32:54 +0200 Subject: [PATCH 2/2] fix: review comments --- .../layout-engine/layout-engine/src/index.ts | 43 +++++++++---------- .../layout-engine/src/layout-table.d.ts | 5 +++ .../layout-engine/src/layout-table.ts | 13 ++++-- 3 files changed, 35 insertions(+), 26 deletions(-) diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 21f07cd531..11f54aa8e3 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -35,7 +35,7 @@ import { import { layoutParagraphBlock } from './layout-paragraph.js'; import { layoutImageBlock } from './layout-image.js'; import { layoutDrawingBlock } from './layout-drawing.js'; -import { layoutTableBlock, createAnchoredTableFragment } from './layout-table.js'; +import { layoutTableBlock, createAnchoredTableFragment, ANCHORED_TABLE_FULL_WIDTH_RATIO } from './layout-table.js'; import { collectAnchoredDrawings, collectAnchoredTables, collectPreRegisteredAnchors } from './anchors.js'; import { createPaginator, type PageState, type ConstraintBoundary } from './paginator.js'; import { formatPageNumber } from './pageNumbering.js'; @@ -128,21 +128,6 @@ function getMeasureHeight(block: FlowBlock, measure: Measure): number { } } -/** - * Compute the base Y coordinate for an anchored table based on vRelativeFrom. - * Ignores tblpYSpec (alignV) by design. - */ -function getTableAnchorBaseY(block: TableBlock, state: PageState): number { - const vRelativeFrom = block.anchor?.vRelativeFrom ?? 'paragraph'; - if (vRelativeFrom === 'page') { - return 0; - } - if (vRelativeFrom === 'margin') { - return state.topMargin; - } - return state.cursorY; -} - // ConstraintBoundary and PageState now come from paginator /** @@ -1870,6 +1855,10 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options } } + // Paragraph start Y (OOXML: anchor for vertAnchor="text"). Captured before layout so + // paragraph-anchored tables use it as base; offsetV (tblpY) positions below start to avoid overlap. + const paragraphStartY = paginator.ensurePage().cursorY; + layoutParagraphBlock( { block, @@ -1898,8 +1887,9 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options : undefined, ); - // Register and place anchored tables after the paragraph so the paragraph appears above the table. + // Register and place anchored tables after the paragraph. Anchor base is paragraph start (OOXML-style). // Full-width floating tables are treated as inline and laid out when we hit the table block. + // Only vRelativeFrom=paragraph is supported. Position = max(paragraphStartY + offsetV, paragraphBottom) so the table never overlaps the paragraph. if (tablesForPara) { const state = paginator.ensurePage(); const columnWidthForTable = getCurrentColumns().width; @@ -1907,20 +1897,27 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options for (const { block: tableBlock, measure: tableMeasure } of tablesForPara) { if (placedAnchoredTableIds.has(tableBlock.id)) continue; const totalWidth = tableMeasure.totalWidth ?? 0; - if (columnWidthForTable > 0 && totalWidth >= columnWidthForTable * 0.99) continue; + if (columnWidthForTable > 0 && totalWidth >= columnWidthForTable * ANCHORED_TABLE_FULL_WIDTH_RATIO) continue; - const anchorBaseY = getTableAnchorBaseY(tableBlock, state); - floatManager.registerTable(tableBlock, tableMeasure, anchorBaseY, state.columnIndex, state.page.number); + // OOXML: position = paragraph start + tblpY (offsetV). Clamp so table top is never above paragraph + // bottom, ensuring no overlap when offsetV is 0 or small. + const offsetV = tableBlock.anchor?.offsetV ?? 0; + const anchorY = Math.max(paragraphStartY + offsetV, state.cursorY); + floatManager.registerTable(tableBlock, tableMeasure, anchorY, state.columnIndex, state.page.number); const anchorX = tableBlock.anchor?.offsetH ?? columnX(state.columnIndex); - const anchorY = anchorBaseY + (tableBlock.anchor?.offsetV ?? 0); const tableFragment = createAnchoredTableFragment(tableBlock, tableMeasure, anchorX, anchorY); state.page.fragments.push(tableFragment); placedAnchoredTableIds.add(tableBlock.id); - const bottom = anchorY + (tableMeasure.totalHeight ?? 0); - if (bottom > tableBottomY) tableBottomY = bottom; + // Only advance cursor for tables that affect flow (wrap type other than 'None'). + // wrap.type === 'None' is absolute overlay with no exclusion zone; pushing cursor would add unwanted whitespace. + const wrapType = tableBlock.wrap?.type ?? 'None'; + if (wrapType !== 'None') { + const bottom = anchorY + (tableMeasure.totalHeight ?? 0); + if (bottom > tableBottomY) tableBottomY = bottom; + } } state.cursorY = tableBottomY; } diff --git a/packages/layout-engine/layout-engine/src/layout-table.d.ts b/packages/layout-engine/layout-engine/src/layout-table.d.ts index a68b6c93ba..590294697b 100644 --- a/packages/layout-engine/layout-engine/src/layout-table.d.ts +++ b/packages/layout-engine/layout-engine/src/layout-table.d.ts @@ -7,6 +7,11 @@ export type PageState = { contentBottom: number; }; +/** + * Ratio of column width (0..1). An anchored table with totalWidth >= columnWidth * this value + * is treated as full-width and laid out inline instead of as a floating fragment. + */ +export declare const ANCHORED_TABLE_FULL_WIDTH_RATIO: number; export type TableLayoutContext = { block: TableBlock; measure: TableMeasure; diff --git a/packages/layout-engine/layout-engine/src/layout-table.ts b/packages/layout-engine/layout-engine/src/layout-table.ts index 96da381395..b02c5d9fbe 100644 --- a/packages/layout-engine/layout-engine/src/layout-table.ts +++ b/packages/layout-engine/layout-engine/src/layout-table.ts @@ -13,6 +13,12 @@ import type { import type { PageState } from './paginator.js'; import { computeFragmentPmRange, extractBlockPmRange } from './layout-utils.js'; +/** + * Ratio of column width (0..1). An anchored table with totalWidth >= columnWidth * this value + * is treated as full-width and laid out inline instead of as a floating fragment. + */ +export const ANCHORED_TABLE_FULL_WIDTH_RATIO = 0.99; + export type TableLayoutContext = { block: TableBlock; measure: TableMeasure; @@ -1023,16 +1029,17 @@ export function layoutTableBlock({ let treatAsInline = false; if (block.anchor?.isAnchored) { const totalWidth = measure.totalWidth ?? 0; - treatAsInline = columnWidth > 0 && totalWidth >= columnWidth * 0.99; + treatAsInline = columnWidth > 0 && totalWidth >= columnWidth * ANCHORED_TABLE_FULL_WIDTH_RATIO; if (!treatAsInline) { return; } } - // 1. Detect floating tables - use monolithic layout (unless we're treating as inline) + // 1. Detect floating tables - use monolithic layout so the table stays one unit (no split across pages). + // This applies even when treatAsInline (full-width anchored): we still flow the table here but render it as one fragment. const tableProps = block.attrs?.tableProperties as Record | undefined; const floatingProps = tableProps?.floatingTableProperties as Record | undefined; - if (floatingProps && Object.keys(floatingProps).length > 0 && !treatAsInline) { + if (floatingProps && Object.keys(floatingProps).length > 0) { layoutMonolithicTable({ block, measure, columnWidth, ensurePage, advanceColumn, columnX }); return; }