diff --git a/packages/layout-engine/layout-engine/src/anchors.ts b/packages/layout-engine/layout-engine/src/anchors.ts index 031dce73d5..9fc0a4b9cf 100644 --- a/packages/layout-engine/layout-engine/src/anchors.ts +++ b/packages/layout-engine/layout-engine/src/anchors.ts @@ -162,9 +162,46 @@ export function collectAnchoredDrawings(blocks: FlowBlock[], measures: Measure[] return map; } +/** + * Check if an anchored table should be pre-registered (before any paragraphs are laid out). + * Tables with vRelativeFrom='margin' or 'page' position themselves relative to the page, + * not relative to their anchor paragraph. These need to be registered first so ALL + * paragraphs can wrap around them. + */ +export function isPageRelativeTableAnchor(block: TableBlock): boolean { + const vRelativeFrom = block.anchor?.vRelativeFrom; + return vRelativeFrom === 'margin' || vRelativeFrom === 'page'; +} + +/** + * Collect anchored tables that should be pre-registered before the layout loop. + * These are tables with vRelativeFrom='margin' or 'page' that affect all paragraphs. + */ +export function collectPreRegisteredAnchoredTables(blocks: FlowBlock[], measures: Measure[]): AnchoredTable[] { + const result: AnchoredTable[] = []; + const len = Math.min(blocks.length, measures.length); + + for (let i = 0; i < len; i += 1) { + const block = blocks[i]; + const measure = measures[i]; + if (block.kind !== 'table' || measure?.kind !== 'table') continue; + const tableBlock = block as TableBlock; + if (!tableBlock.anchor?.isAnchored) continue; + if (isPageRelativeTableAnchor(tableBlock)) { + result.push({ block: tableBlock, measure: measure as TableMeasure }); + } + } + + return result; +} + /** * Collect anchored/floating tables mapped to their anchor paragraph index. * Also returns anchored tables that have no paragraph to attach to. + * + * Tables with vRelativeFrom='margin' or 'page' are excluded here — they are handled + * by collectPreRegisteredAnchoredTables so earlier paragraphs on the page see their + * exclusion zone. */ export function collectAnchoredTables(blocks: FlowBlock[], measures: Measure[]): AnchoredTableCollection { const len = Math.min(blocks.length, measures.length); @@ -184,6 +221,9 @@ export function collectAnchoredTables(blocks: FlowBlock[], measures: Measure[]): // Check if the table is anchored/floating if (!tableBlock.anchor?.isAnchored) continue; + // Page/margin-relative tables are pre-registered — skip them here. + if (isPageRelativeTableAnchor(tableBlock)) continue; + // Heuristic: anchor to explicit paragraph id, else nearest preceding paragraph, else nearest next paragraph const anchorParagraphId = typeof tableBlock.attrs === 'object' && tableBlock.attrs diff --git a/packages/layout-engine/layout-engine/src/floating-objects.test.ts b/packages/layout-engine/layout-engine/src/floating-objects.test.ts index 266a4fec00..71853af1d5 100644 --- a/packages/layout-engine/layout-engine/src/floating-objects.test.ts +++ b/packages/layout-engine/layout-engine/src/floating-objects.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'bun:test'; -import { createFloatingObjectManager } from './floating-objects.js'; -import type { ImageBlock, ImageMeasure } from '@superdoc/contracts'; +import { createFloatingObjectManager, computeTableAnchorX, computeTableAnchorY } from './floating-objects.js'; +import type { ImageBlock, ImageMeasure, TableAnchor } from '@superdoc/contracts'; describe('FloatingObjectManager', () => { const mockColumns = { width: 600, gap: 20, count: 1 }; @@ -897,3 +897,334 @@ describe('FloatingObjectManager', () => { }); }); }); + +// SD-2562 + GH#2800: computeTableAnchorX/Y honor hRelativeFrom/vRelativeFrom/alignH/alignV +// per ECMA-376 §17.4.57 / §17.18.35 / §17.18.100. Regression tests cover the full matrix +// verified against Microsoft Word's computed positions (Word COM API: Table.Rows.HorizontalPosition +// and VerticalPosition). +describe('computeTableAnchorX', () => { + // US Letter, 1" margins, single column. Page is 816px wide; content area 624px. + const columns = { width: 624, gap: 0, count: 1 }; + const margins = { left: 96, right: 96 }; + const pageWidth = 816; + const tableWidth = 192; + + const anchor = (overrides: Partial = {}): TableAnchor => ({ + isAnchored: true, + hRelativeFrom: 'page', + vRelativeFrom: 'paragraph', + offsetH: 0, + offsetV: 0, + ...overrides, + }); + + it('page-anchored tblpX measures from page edge (GH#2800)', () => { + // Reporter: horzAnchor="page" tblpX=7298 twips ≈ 486.53px → render at 486.53 from page left. + expect( + computeTableAnchorX( + anchor({ hRelativeFrom: 'page', offsetH: 486.53 }), + 0, + columns, + tableWidth, + margins, + pageWidth, + ), + ).toBeCloseTo(486.53, 2); + }); + + it('margin-anchored tblpX measures from left margin, not page edge', () => { + // horzAnchor="margin" tblpX=96 → 96 + 96 margin = 192 from page left. + expect( + computeTableAnchorX(anchor({ hRelativeFrom: 'margin', offsetH: 96 }), 0, columns, tableWidth, margins, pageWidth), + ).toBe(192); + }); + + it('column-anchored tblpX measures from column baseline', () => { + expect( + computeTableAnchorX(anchor({ hRelativeFrom: 'column', offsetH: 48 }), 0, columns, tableWidth, margins, pageWidth), + ).toBe(144); // 96 margin + 0 column offset + 48 tblpX + }); + + it('alignH=right (tblpXSpec=right) on page-anchor positions at right edge', () => { + // tblpXSpec=right + horzAnchor=page → pageWidth - tableWidth = 624. + expect( + computeTableAnchorX( + anchor({ hRelativeFrom: 'page', alignH: 'right' }), + 0, + columns, + tableWidth, + margins, + pageWidth, + ), + ).toBe(pageWidth - tableWidth); + }); + + it('alignH=center (tblpXSpec=center) on margin-anchor centers within content area', () => { + // tblpXSpec=center + horzAnchor=margin → marginLeft + (contentWidth - tableWidth)/2. + expect( + computeTableAnchorX( + anchor({ hRelativeFrom: 'margin', alignH: 'center' }), + 0, + columns, + tableWidth, + margins, + pageWidth, + ), + ).toBe(96 + (624 - 192) / 2); + }); + + it('page-anchor treats single-column layouts consistently (no margin-injection)', () => { + // Regression: the previous implementation injected marginLeft when columns.count === 1, + // giving 96 + 486.53 = 582.53 instead of 486.53. GH#2800's symptom. + const result = computeTableAnchorX( + anchor({ hRelativeFrom: 'page', offsetH: 486.53 }), + 0, + columns, + tableWidth, + margins, + pageWidth, + ); + expect(result).not.toBe(582.53); + expect(result).toBeCloseTo(486.53, 2); + }); + + it('alignH="inside" behaves like "left"', () => { + expect( + computeTableAnchorX( + anchor({ hRelativeFrom: 'margin', alignH: 'inside' }), + 0, + columns, + tableWidth, + margins, + pageWidth, + ), + ).toBe(96); + }); + + it('alignH="outside" behaves like "right"', () => { + expect( + computeTableAnchorX( + anchor({ hRelativeFrom: 'margin', alignH: 'outside' }), + 0, + columns, + tableWidth, + margins, + pageWidth, + ), + ).toBe(96 + 624 - tableWidth); + }); + + it('defaults hRelativeFrom to "column" when absent (matches Word defaults)', () => { + const defaulted: TableAnchor = { + isAnchored: true, + vRelativeFrom: 'paragraph', + offsetH: 48, + offsetV: 0, + // hRelativeFrom absent + }; + expect(computeTableAnchorX(defaulted, 0, columns, tableWidth, margins, pageWidth)).toBe(144); + }); + + it('zero tableWidth + alignH="right" still produces a finite coordinate', () => { + const result = computeTableAnchorX( + anchor({ hRelativeFrom: 'page', alignH: 'right' }), + 0, + columns, + 0, + margins, + pageWidth, + ); + expect(Number.isFinite(result)).toBe(true); + expect(result).toBe(pageWidth); // right edge - 0 width + }); +}); + +describe('computeTableAnchorY', () => { + // US Letter at 96dpi: 1056px tall, 1" margins. Content area: y=96..960. + const pageContentTop = 96; + const pageContentBottom = 960; + const pageHeight = 1056; + + const anchor = (overrides: Partial = {}): TableAnchor => ({ + isAnchored: true, + hRelativeFrom: 'page', + vRelativeFrom: 'paragraph', + offsetH: 0, + offsetV: 0, + ...overrides, + }); + + it('vRelativeFrom=paragraph uses paragraph baseline + offsetV', () => { + // Paragraph-anchored tables clamp to max(paragraphStartY, paragraphCursorY) + offsetV + // (legacy no-overlap heuristic). For a single-line paragraph where cursor has advanced + // past the start, cursorY wins. + const paragraphStartY = 169.6; + const paragraphCursorY = 188; + expect( + computeTableAnchorY( + anchor({ vRelativeFrom: 'paragraph', offsetV: 0 }), + 92, + paragraphStartY, + paragraphCursorY, + pageContentTop, + pageContentBottom, + pageHeight, + ), + ).toBe(paragraphCursorY); + }); + + it('vRelativeFrom=page uses page TOP + offsetV (SD-2562 core)', () => { + // SD-2562 repro: vertAnchor="page" tblpY=2880 twips = 192px → y=192 from page top, + // regardless of where the anchor paragraph ended. + expect( + computeTableAnchorY( + anchor({ vRelativeFrom: 'page', offsetV: 192 }), + 92, + /* paragraphStartY */ 200, + /* paragraphCursorY */ 400, // must be ignored for page-relative + pageContentTop, + pageContentBottom, + pageHeight, + ), + ).toBe(192); + }); + + it('vRelativeFrom=margin uses margin TOP + offsetV', () => { + expect( + computeTableAnchorY( + anchor({ vRelativeFrom: 'margin', offsetV: 96 }), + 92, + /* paragraphStartY */ 200, + /* paragraphCursorY */ 400, + pageContentTop, + pageContentBottom, + pageHeight, + ), + ).toBe(pageContentTop + 96); + }); + + it('alignV=bottom on page anchor aligns to page bottom edge', () => { + expect( + computeTableAnchorY( + anchor({ vRelativeFrom: 'page', alignV: 'bottom', offsetV: 0 }), + 100, + /* paragraphStartY */ 500, + /* paragraphCursorY */ 500, + pageContentTop, + pageContentBottom, + pageHeight, + ), + ).toBe(pageHeight - 100); // pageHeight - tableHeight + }); + + it('alignV=center on margin anchor centers within content area', () => { + const tableHeight = 100; + const contentHeight = pageContentBottom - pageContentTop; + expect( + computeTableAnchorY( + anchor({ vRelativeFrom: 'margin', alignV: 'center' }), + tableHeight, + 500, + 500, + pageContentTop, + pageContentBottom, + pageHeight, + ), + ).toBe(pageContentTop + (contentHeight - tableHeight) / 2); + }); + + it('paragraph anchor with multi-page paragraph respects cursorY floor', () => { + // Multi-page paragraph: paragraphStartY refers to page 1 (stale). cursorY is the + // current page position after layout. The clamp keeps the table from overlapping + // already-laid content on the current page. + const result = computeTableAnchorY( + anchor({ vRelativeFrom: 'paragraph', offsetV: 10 }), + 30, + /* paragraphStartY */ 20, // page 1 top (stale) + /* paragraphCursorY */ 60, // page 2 current position + pageContentTop, + pageContentBottom, + pageHeight, + ); + expect(result).toBe(70); // max(20, 60) + 10 + }); + + it('vRelativeFrom=paragraph ignores alignV per ECMA-376 (tblpYSpec disallowed with vertAnchor=text)', () => { + const paragraphStartY = 169.6; + const paragraphCursorY = 188; + // Spec §17.4.57: "tblpYSpec … is ignored, unless vertAnchor is set to text, in which case any + // relative positioning is not allowed, and is itself ignored." + expect( + computeTableAnchorY( + anchor({ vRelativeFrom: 'paragraph', alignV: 'center', offsetV: 0 }), + 92, + paragraphStartY, + paragraphCursorY, + pageContentTop, + pageContentBottom, + pageHeight, + ), + ).toBe(paragraphCursorY); + }); + + it('alignV="center" on page anchor centers vertically on page', () => { + const tableHeight = 100; + expect( + computeTableAnchorY( + anchor({ vRelativeFrom: 'page', alignV: 'center' }), + tableHeight, + 0, + 0, + pageContentTop, + pageContentBottom, + pageHeight, + ), + ).toBe((pageHeight - tableHeight) / 2); + }); + + it('alignV="bottom" on margin anchor aligns to bottom margin', () => { + const tableHeight = 80; + expect( + computeTableAnchorY( + anchor({ vRelativeFrom: 'margin', alignV: 'bottom' }), + tableHeight, + 0, + 0, + pageContentTop, + pageContentBottom, + pageHeight, + ), + ).toBe(pageContentBottom - tableHeight); + }); + + it('alignV + offsetV are additive (matches Word behavior)', () => { + // Per Word: alignV chooses the reference edge/center, offsetV shifts from there. + // "center + 20" = centered then shifted 20px down. + const tableHeight = 100; + expect( + computeTableAnchorY( + anchor({ vRelativeFrom: 'page', alignV: 'center', offsetV: 20 }), + tableHeight, + 0, + 0, + pageContentTop, + pageContentBottom, + pageHeight, + ), + ).toBe((pageHeight - tableHeight) / 2 + 20); + }); + + it('defaults vRelativeFrom to "paragraph" when absent', () => { + const defaulted: TableAnchor = { + isAnchored: true, + hRelativeFrom: 'page', + offsetH: 0, + offsetV: 5, + // vRelativeFrom absent + }; + const paragraphCursorY = 188; + expect( + computeTableAnchorY(defaulted, 80, 100, paragraphCursorY, pageContentTop, pageContentBottom, pageHeight), + ).toBe(paragraphCursorY + 5); + }); +}); diff --git a/packages/layout-engine/layout-engine/src/floating-objects.ts b/packages/layout-engine/layout-engine/src/floating-objects.ts index 88e4595d37..87720b2792 100644 --- a/packages/layout-engine/layout-engine/src/floating-objects.ts +++ b/packages/layout-engine/layout-engine/src/floating-objects.ts @@ -171,8 +171,9 @@ export function createFloatingObjectManager( // Compute table X position based on anchor alignment const x = computeTableAnchorX(anchor, columnIndex, currentColumns, tableWidth, currentMargins, currentPageWidth); - // Compute table Y position (anchor Y + vertical offset) - const y = anchorY + (anchor.offsetV ?? 0); + // anchorY is the table's final top edge — already includes offsetV when resolved by + // computeTableAnchorY. Do not apply offsetV a second time here. + const y = anchorY; const zone: ExclusionZone = { imageBlockId: tableBlock.id, // Reusing imageBlockId field for table id @@ -408,7 +409,7 @@ function computeWrapMode(wrap: ImageBlock['wrap'], _anchor: ImageBlock['anchor'] * Compute horizontal position of anchored table based on alignment and offsets. * Similar to computeAnchorX but uses TableAnchor type. */ -function computeTableAnchorX( +export function computeTableAnchorX( anchor: TableAnchor, columnIndex: number, columns: ColumnLayout, @@ -428,17 +429,14 @@ function computeTableAnchorX( const relativeFrom = anchor.hRelativeFrom ?? 'column'; - // Base origin and available width based on relativeFrom + // Base origin and available width based on relativeFrom. + // Word's page-relative origin is always the physical page edge (x=0), + // regardless of column count — anchors can extend into the margin. let baseX: number; let availableWidth: number; if (relativeFrom === 'page') { - if (columns.count === 1) { - baseX = contentLeft; - availableWidth = contentWidth; - } else { - baseX = 0; - availableWidth = pageWidth != null ? pageWidth : contentWidth; - } + baseX = 0; + availableWidth = pageWidth != null ? pageWidth : contentWidth + marginLeft + marginRight; } else if (relativeFrom === 'margin') { baseX = contentLeft; availableWidth = contentWidth; @@ -465,6 +463,58 @@ function computeTableAnchorX( return result; } +/** + * Compute vertical position of an anchored table based on its vRelativeFrom / alignV / offsetV. + * + * Mirrors Word's `w:vertAnchor` semantics per ECMA-376 §17.4.57: + * - `page` : position is relative to the physical page edge (y=0 is page top). + * - `margin` : position is relative to the content area (top margin line). + * - `paragraph` (OOXML `w:vertAnchor="text"`): position is relative to the anchor paragraph. + * + * `alignV` (from `w:tblpYSpec`) chooses the reference edge or center for `page` and `margin` + * anchors; `offsetV` is then applied on top of the chosen baseline (matches Word's behavior — + * e.g. "bottom + 10px" places the table 10px above the bottom edge). `alignV` is ignored for + * `paragraph` anchoring because the OOXML spec disallows `tblpYSpec` with `vertAnchor="text"`. + * + * For `paragraph` anchoring, the baseline is clamped to `max(paragraphStartY, paragraphCursorY)` + * to preserve SuperDoc's "no overlap" legacy behavior: the table never renders above the + * current cursor on the anchor's page (handling multi-page paragraph spans where + * `paragraphStartY` refers to a prior page). Strict spec behavior (table positioned at + * paragraph TOP, allowing overlap) is a separate feature tracked for follow-up. + */ +export function computeTableAnchorY( + anchor: TableAnchor, + tableHeight: number, + paragraphStartY: number, + paragraphCursorY: number, + pageContentTop: number, + pageContentBottom: number, + pageHeight: number, +): number { + const offsetV = anchor.offsetV ?? 0; + const alignV = anchor.alignV; + const vRelativeFrom = anchor.vRelativeFrom ?? 'paragraph'; + + if (vRelativeFrom === 'page') { + if (alignV === 'bottom') return pageHeight - tableHeight + offsetV; + if (alignV === 'center') return (pageHeight - tableHeight) / 2 + offsetV; + // 'top', 'inside', 'outside' → top edge; 'inline' is meaningless for non-paragraph anchors + return offsetV; + } + + if (vRelativeFrom === 'margin') { + const contentHeight = Math.max(0, pageContentBottom - pageContentTop); + if (alignV === 'bottom') return pageContentBottom - tableHeight + offsetV; + if (alignV === 'center') return pageContentTop + (contentHeight - tableHeight) / 2 + offsetV; + return pageContentTop + offsetV; + } + + // 'paragraph' (OOXML "text"): clamp to current cursor so the table never overlaps + // already-laid paragraph content on the current page, then apply offsetV. + // Strict spec behavior (paragraph TOP with overlap permitted) is a follow-up. + return Math.max(paragraphStartY, paragraphCursorY) + offsetV; +} + /** * Map TableWrap.wrapText to ExclusionZone.wrapMode. * Determines which side of the table text should wrap. diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index b73c27ee11..8462bb16fe 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -825,6 +825,73 @@ describe('layoutDocument', () => { expect(anchoredTableFragment?.y).toBe(DEFAULT_OPTIONS.margins!.top + paragraphMeasure.totalHeight); }); + // SD-2562: vRelativeFrom="page" anchored tables must render at a page-relative Y, + // not at the anchor paragraph's Y. Verified against Word COM ground truth (hPos=288, vPos=192 + // for a table with horzAnchor=page tblpX=4320 vertAnchor=page tblpY=2880 on US-Letter at 96dpi). + it('respects vRelativeFrom="page" for anchored tables (SD-2562)', () => { + const paragraphBlock: FlowBlock = { kind: 'paragraph', id: 'para-1', runs: [] }; + const paragraphMeasure = makeMeasure([20]); + + const pageAnchoredTable = makeTableBlock('sd-2562-page', 1, { + anchor: { + isAnchored: true, + hRelativeFrom: 'page', + vRelativeFrom: 'page', + offsetH: 288, + offsetV: 192, + }, + wrap: { type: 'Square' }, + }); + const tableMeasure = makeTableMeasure([192], [92]); + + const layout = layoutDocument( + [paragraphBlock, pageAnchoredTable], + [paragraphMeasure, tableMeasure], + DEFAULT_OPTIONS, + ); + + const tableFragment = layout.pages[0].fragments.find( + (fragment) => fragment.kind === 'table' && fragment.blockId === 'sd-2562-page', + ) as { x: number; y: number } | undefined; + + expect(tableFragment).toBeTruthy(); + // Page-relative: Y is 192 from page top, NOT (marginTop + paragraphHeight). + expect(tableFragment?.y).toBe(192); + expect(tableFragment?.x).toBe(288); + }); + + it('respects vRelativeFrom="margin" and hRelativeFrom="margin" for anchored tables', () => { + const paragraphBlock: FlowBlock = { kind: 'paragraph', id: 'para-1', runs: [] }; + const paragraphMeasure = makeMeasure([20]); + + const marginAnchoredTable = makeTableBlock('sd-2562-margin', 1, { + anchor: { + isAnchored: true, + hRelativeFrom: 'margin', + vRelativeFrom: 'margin', + offsetH: 96, + offsetV: 96, + }, + wrap: { type: 'Square' }, + }); + const tableMeasure = makeTableMeasure([192], [92]); + + const layout = layoutDocument( + [paragraphBlock, marginAnchoredTable], + [paragraphMeasure, tableMeasure], + DEFAULT_OPTIONS, + ); + + const tableFragment = layout.pages[0].fragments.find( + (fragment) => fragment.kind === 'table' && fragment.blockId === 'sd-2562-margin', + ) as { x: number; y: number } | undefined; + + expect(tableFragment).toBeTruthy(); + // Margin-relative: both axes are marginLeft/marginTop + offset. + expect(tableFragment?.x).toBe(DEFAULT_OPTIONS.margins!.left + 96); + expect(tableFragment?.y).toBe(DEFAULT_OPTIONS.margins!.top + 96); + }); + it('renders a floating table when the document has no body paragraphs', () => { const floatingOnlyTable = makeParagraphlessFloatingTable('table-floating-only'); const floatingOnlyMeasure = makeTableMeasure([220], [60]); diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 32a2aea6c1..ffe0a58afe 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -30,7 +30,12 @@ import type { NormalizedColumnLayout, } from '@superdoc/contracts'; import { normalizeColumnLayout, getFragmentZIndex } from '@superdoc/contracts'; -import { createFloatingObjectManager, computeAnchorX } from './floating-objects.js'; +import { + createFloatingObjectManager, + computeAnchorX, + computeTableAnchorX, + computeTableAnchorY, +} from './floating-objects.js'; import { computeNextSectionPropsAtBreak } from './section-props'; import { scheduleSectionBreak as scheduleSectionBreakExport, @@ -46,6 +51,7 @@ import { collectAnchoredDrawings, collectAnchoredTables, collectPreRegisteredAnchors, + collectPreRegisteredAnchoredTables, isPageRelativeAnchor, } from './anchors.js'; import { normalizeFragmentsForRegion } from './normalize-header-footer-fragments.js'; @@ -1511,39 +1517,14 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options const resolveParagraphlessAnchoredTableY = (block: TableBlock, measure: TableMeasure, state: PageState): number => { const contentTop = state.topMargin; const contentBottom = state.contentBottom; - const contentHeight = Math.max(0, contentBottom - contentTop); + const pageHeight = contentBottom + (state.page.margins?.bottom ?? activeBottomMargin); const tableHeight = measure.totalHeight ?? 0; const anchor = block.anchor; - const offsetV = anchor?.offsetV ?? 0; - const vRelativeFrom = anchor?.vRelativeFrom; - const alignV = anchor?.alignV; - - if (vRelativeFrom === 'margin') { - if (alignV === 'bottom') { - return contentBottom - tableHeight + offsetV; - } - if (alignV === 'center') { - return contentTop + (contentHeight - tableHeight) / 2 + offsetV; - } - return contentTop + offsetV; - } - - if (vRelativeFrom === 'page') { - if (alignV === 'bottom') { - const pageHeight = contentBottom + (state.page.margins?.bottom ?? activeBottomMargin); - return pageHeight - tableHeight + offsetV; - } - if (alignV === 'center') { - const pageHeight = contentBottom + (state.page.margins?.bottom ?? activeBottomMargin); - return (pageHeight - tableHeight) / 2 + offsetV; - } - return offsetV; - } + if (!anchor) return contentTop; - // Paragraph-relative floating tables normally anchor to a body paragraph. - // When a document has no body paragraphs at all, fall back to the top of the - // content area so the table can still render on page 1. - return contentTop + offsetV; + // No body paragraph exists — paragraph-relative anchors fall back to content top + // (paragraphStartY and paragraphCursorY both collapse to contentTop). + return computeTableAnchorY(anchor, tableHeight, contentTop, contentTop, contentTop, contentBottom, pageHeight); }; for (const entry of preRegisteredAnchors) { @@ -1614,6 +1595,48 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options preRegisteredPositions.set(entry.block.id, { anchorX, anchorY }); } + // Pre-register page/margin-relative anchored TABLES (SD-2562). + // Mirrors the image pre-registration above so exclusion zones are active for ALL + // paragraphs on the page, not just those laid out after the anchor paragraph. + // Without this, a page-anchored table positioned above its anchor paragraph paints + // over already-laid earlier paragraphs instead of making them wrap around it. + const preRegisteredTables = collectPreRegisteredAnchoredTables(blocks, measures); + const preRegisteredTablePositions = new Map(); + for (const entry of preRegisteredTables) { + const state = paginator.ensurePage(); + const anchor = entry.block.anchor; + if (!anchor) continue; + + const tableWidth = entry.measure.totalWidth ?? 0; + const tableHeight = entry.measure.totalHeight ?? 0; + const pageContentTop = state.topMargin; + const pageContentBottom = state.contentBottom; + const pageHeight = pageContentBottom + (state.page.margins?.bottom ?? activeBottomMargin); + + // For page/margin anchoring, paragraphStartY/paragraphCursorY don't apply — + // pass pageContentTop for both so any accidental paragraph-branch read is harmless. + const anchorY = computeTableAnchorY( + anchor, + tableHeight, + pageContentTop, + pageContentTop, + pageContentTop, + pageContentBottom, + pageHeight, + ); + const anchorX = computeTableAnchorX( + anchor, + state.columnIndex, + getCurrentColumns(), + tableWidth, + { left: activeLeftMargin, right: activeRightMargin }, + activePageSize.w, + ); + + floatManager.registerTable(entry.block, entry.measure, anchorY, state.columnIndex, state.page.number); + preRegisteredTablePositions.set(entry.block.id, { anchorX, anchorY }); + } + // Pre-compute keepNext chains for correct pagination grouping. // Word treats consecutive paragraphs with keepNext=true as indivisible units. const keepNextChains = computeKeepNextChains(blocks); @@ -2098,27 +2121,56 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options : undefined, ); - // Register and place anchored tables after the paragraph. Anchor base is paragraph-relative - // (OOXML-style), clamped to paragraph bottom to avoid overlap, then offsetV is applied. + // Register and place anchored (floating) tables for this paragraph. + // Anchor coordinates are resolved per OOXML w:tblpPr semantics (§17.4.57): + // X = f(horzAnchor/tblpX/tblpXSpec, page/margin/column baseline) + // Y = f(vertAnchor/tblpY/tblpYSpec, page/margin/paragraph baseline) // Full-width floating tables are treated as inline and laid out when we hit the table block. - // Only vRelativeFrom=paragraph is supported. if (tablesForPara) { const state = paginator.ensurePage(); const columnWidthForTable = getCurrentColumnWidth(); + const currentColumns = getCurrentColumns(); + const pageMargins = { + left: activeLeftMargin, + right: activeRightMargin, + top: activeTopMargin, + bottom: activeBottomMargin, + }; let tableBottomY = state.cursorY; for (const { block: tableBlock, measure: tableMeasure } of tablesForPara) { if (placedAnchoredTableIds.has(tableBlock.id)) continue; const totalWidth = tableMeasure.totalWidth ?? 0; + const totalHeight = tableMeasure.totalHeight ?? 0; if (columnWidthForTable > 0 && totalWidth >= columnWidthForTable * ANCHORED_TABLE_FULL_WIDTH_RATIO) continue; - // OOXML anchor base is paragraph-relative. Clamp to paragraph bottom so the table never overlaps - // paragraph text, then apply offsetV from that resolved anchor position. - const offsetV = tableBlock.anchor?.offsetV ?? 0; - const anchorBaseY = Math.max(paragraphStartY, state.cursorY); - const anchorY = anchorBaseY + offsetV; - floatManager.registerTable(tableBlock, tableMeasure, anchorY, state.columnIndex, state.page.number); + const tableAnchor = tableBlock.anchor; + const pageContentTop = state.topMargin; + const pageContentBottom = state.contentBottom; + const pageHeight = pageContentBottom + (state.page.margins?.bottom ?? activeBottomMargin); + + const anchorY = tableAnchor + ? computeTableAnchorY( + tableAnchor, + totalHeight, + paragraphStartY, + state.cursorY, + pageContentTop, + pageContentBottom, + pageHeight, + ) + : paragraphStartY; + const anchorX = tableAnchor + ? computeTableAnchorX( + tableAnchor, + state.columnIndex, + currentColumns, + totalWidth, + pageMargins, + activePageSize.w, + ) + : columnX(state.columnIndex); - const anchorX = tableBlock.anchor?.offsetH ?? columnX(state.columnIndex); + floatManager.registerTable(tableBlock, tableMeasure, anchorY, state.columnIndex, state.page.number); const tableFragment = createAnchoredTableFragment(tableBlock, tableMeasure, anchorX, anchorY); state.page.fragments.push(tableFragment); @@ -2260,6 +2312,25 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options if (measure.kind !== 'table') { throw new Error(`layoutDocument: expected table measure for block ${block.id}`); } + + // Pre-registered page/margin-anchored tables: emit the fragment at the + // already-resolved position (exclusion zone was registered before PASS 2). + const preRegTablePos = preRegisteredTablePositions.get(block.id); + if (preRegTablePos && !placedAnchoredTableIds.has(block.id)) { + const state = paginator.ensurePage(); + const tableBlock = block as TableBlock; + const tableMeasure = measure as TableMeasure; + const fragment = createAnchoredTableFragment( + tableBlock, + tableMeasure, + preRegTablePos.anchorX, + preRegTablePos.anchorY, + ); + state.page.fragments.push(fragment); + placedAnchoredTableIds.add(tableBlock.id); + continue; + } + layoutTableBlock({ block: block as TableBlock, measure: measure as TableMeasure, @@ -2342,7 +2413,16 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options } const anchorY = resolveParagraphlessAnchoredTableY(tableBlock, tableMeasure, state); - const anchorX = tableBlock.anchor?.offsetH ?? columnX(state.columnIndex); + const anchorX = tableBlock.anchor + ? computeTableAnchorX( + tableBlock.anchor, + state.columnIndex, + getCurrentColumns(), + tableMeasure.totalWidth ?? 0, + { left: activeLeftMargin, right: activeRightMargin }, + activePageSize.w, + ) + : columnX(state.columnIndex); floatManager.registerTable(tableBlock, tableMeasure, anchorY, state.columnIndex, state.page.number); state.page.fragments.push(createAnchoredTableFragment(tableBlock, tableMeasure, anchorX, anchorY));