Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 207 additions & 0 deletions packages/layout-engine/layout-engine/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
61 changes: 40 additions & 21 deletions packages/layout-engine/layout-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1718,27 +1718,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
Expand Down Expand Up @@ -1875,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,
Expand Down Expand Up @@ -1902,6 +1886,41 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
}
: undefined,
);

// 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;
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 * ANCHORED_TABLE_FULL_WIDTH_RATIO) continue;

// 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 tableFragment = createAnchoredTableFragment(tableBlock, tableMeasure, anchorX, anchorY);
state.page.fragments.push(tableFragment);
placedAnchoredTableIds.add(tableBlock.id);

// 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;
}
continue;
}
if (block.kind === 'image') {
Expand Down
5 changes: 5 additions & 0 deletions packages/layout-engine/layout-engine/src/layout-table.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
20 changes: 17 additions & 3 deletions packages/layout-engine/layout-engine/src/layout-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1017,12 +1023,20 @@ 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 * ANCHORED_TABLE_FULL_WIDTH_RATIO;
if (!treatAsInline) {
return;
}
}

// 1. Detect floating tables - use monolithic layout
// 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<string, unknown> | undefined;
const floatingProps = tableProps?.floatingTableProperties as Record<string, unknown> | undefined;
if (floatingProps && Object.keys(floatingProps).length > 0) {
Expand Down
Loading