Signal drop!
Relay (operand.online) is unreachable.
Usually, a dropped signal means an upgrade is happening. Hold on!
Sorry, no connección.
Hang in there while we get back on track
gram: docs
> ./packages/core/src/layout-engine/index.ts
/**
* Layout Engine - Main Entry Point
*
* Converts blocks + measures into positioned fragments on pages.
*
* @experimental Stable enough for the first-party React adapter, but the
* API may change in minor releases until a third-party adapter validates
* it. Pin a version range if you depend on this directly.
* @packageDocumentation
* @public
*/
import type {
FlowBlock,
Measure,
Layout,
LayoutOptions,
PageMargins,
ColumnLayout,
ParagraphBlock,
ParagraphMeasure,
ParagraphFragment,
TableBlock,
TableMeasure,
TableFragment,
ImageBlock,
ImageMeasure,
ImageFragment,
TextBoxBlock,
TextBoxMeasure,
TextBoxFragment,
SectionBreakBlock,
} from './types';
import { assertExhaustiveFlowBlock } from './types';
import { createPaginator } from './paginator';
import {
computeKeepNextChains,
calculateChainHeight,
getMidChainIndices,
hasPageBreakBefore,
} from './keep-together';
import { isFloatingTextBoxBlock } from './textBoxFlow';
import { buildTableRowBreakInfo, snapRowBreak } from './tableRowBreak';
import { MIN_WRAP_SEGMENT_WIDTH } from '../layout-bridge/measuring/floatingZones';
import { getParagraphFragmentPmRange } from './paragraphFragmentRange';
import { balanceTerminalContinuousTextColumns } from './columnBalancing';
import { getSpacingAfter, getSpacingBefore } from './paragraphSpacing';
// Default page size (US Letter in pixels at 96 DPI)
const DEFAULT_PAGE_SIZE = { w: 816, h: 1056 };
// Default margins (1 inch = 96 pixels)
const DEFAULT_MARGINS: PageMargins = {
top: 96,
right: 96,
bottom: 96,
left: 96,
};
/**
* Page-flow geometry resolved from a single section's properties.
* Exported so the React paged editor can reuse the same shape when
* measuring blocks per section width — keeping pagination and
* measurement consistent.
*/
export type SectionLayoutConfig = {
pageSize: { w: number; h: number };
margins: PageMargins;
/** Optional. Sections without explicit columns inherit `{ count: 1 }`. */
columns?: ColumnLayout;
};
const DEFAULT_COLUMNS: ColumnLayout = { count: 1, gap: 0 };
/**
* Walk `blocks` once and collect per-section geometry. `configs` has one
* entry per section break plus a trailing `finalConfig`. `breakIndices` is
* 1-to-1 with the inner break entries (same length as `configs.length - 1`).
* Callers that need the break `type` can read it from
* `(blocks[breakIndices[i]] as SectionBreakBlock).type`.
*
* @internal
*/
export function collectSectionConfigs(
blocks: FlowBlock[],
initialConfig: SectionLayoutConfig,
finalConfig: SectionLayoutConfig
): {
configs: SectionLayoutConfig[];
breakIndices: number[];
} {
const configs: SectionLayoutConfig[] = [];
const breakIndices: number[] = [];
let previousConfig = initialConfig;
for (let i = 0; i < blocks.length; i++) {
if (blocks[i].kind !== 'sectionBreak') continue;
const sb = blocks[i] as SectionBreakBlock;
const config: SectionLayoutConfig = {
pageSize: sb.pageSize ?? previousConfig.pageSize,
margins: sb.margins ?? previousConfig.margins,
columns: sb.columns,
};
configs.push(config);
breakIndices.push(i);
previousConfig = config;
}
configs.push(finalConfig);
return { configs, breakIndices };
}
/**
* Apply contextual spacing suppression (OOXML §17.3.1.9).
*
* When two consecutive paragraph blocks both have `contextualSpacing: true`
* and share the same `styleId`, the spaceAfter of the first paragraph and
* the spaceBefore of the second paragraph are suppressed (set to 0).
*
* This mutates the block attrs in-place before layout runs.
*/
function applyContextualSpacing(blocks: FlowBlock[]): void {
for (let i = 0; i < blocks.length - 1; i++) {
const curr = blocks[i];
const next = blocks[i + 1];
if (curr.kind !== 'paragraph' || next.kind !== 'paragraph') continue;
const currAttrs = curr.attrs;
const nextAttrs = next.attrs;
if (
currAttrs?.contextualSpacing &&
nextAttrs?.contextualSpacing &&
currAttrs.styleId &&
currAttrs.styleId === nextAttrs.styleId
) {
// Suppress spaceAfter on current paragraph
if (currAttrs.spacing) {
currAttrs.spacing = { ...currAttrs.spacing, after: 0 };
}
// Suppress spaceBefore on next paragraph
if (nextAttrs.spacing) {
nextAttrs.spacing = { ...nextAttrs.spacing, before: 0 };
}
}
}
// Recurse into table cells so contextual spacing is suppressed there too —
// measure, row-break, and the painter all read the (mutated) cell paragraph
// spacing, so they stay consistent.
for (const block of blocks) {
if (block.kind !== 'table') continue;
for (const row of block.rows) {
for (const cell of row.cells) {
applyContextualSpacing(cell.blocks);
}
}
}
}
/**
* Layout a document: convert blocks + measures into pages with positioned fragments.
*
* Algorithm:
* 1. Walk blocks in order with their corresponding measures
* 2. For each block, create appropriate fragment(s)
* 3. Use paginator to manage page/column state
* 4. Handle page breaks, section breaks, and keepNext chains
*/
export function layoutDocument(
blocks: FlowBlock[],
measures: Measure[],
options: LayoutOptions = {} as LayoutOptions
): Layout {
// Validate input
if (blocks.length !== measures.length) {
throw new Error(
`layoutDocument: expected one measure per block (blocks=${blocks.length}, measures=${measures.length})`
);
}
// Set up options with defaults
const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE;
const baseMargins = {
top: options.margins?.top ?? DEFAULT_MARGINS.top,
right: options.margins?.right ?? DEFAULT_MARGINS.right,
bottom: options.margins?.bottom ?? DEFAULT_MARGINS.bottom,
left: options.margins?.left ?? DEFAULT_MARGINS.left,
header: options.margins?.header ?? options.margins?.top ?? DEFAULT_MARGINS.top,
footer: options.margins?.footer ?? options.margins?.bottom ?? DEFAULT_MARGINS.bottom,
};
// Use document margins directly for WYSIWYG fidelity
// Word uses fixed margins from the document - body content always starts at marginTop
// If header content extends below marginTop, it overlaps (this matches Word behavior)
// Note: headerContentHeights are still available for future use (e.g., warnings)
void options.headerContentHeights;
void options.footerContentHeights;
void options.titlePage;
void options.evenAndOddHeaders;
const margins = { ...baseMargins };
const finalPageSize = options.finalPageSize ?? pageSize;
const finalMargins = options.finalMargins ?? margins;
// Calculate content width
const contentWidth = pageSize.w - margins.left - margins.right;
if (contentWidth <= 0) {
throw new Error('layoutDocument: page size and margins yield no content area');
}
// ECMA-376 §17.6.22: each section break carries the CURRENT section's
// properties; `w:type` describes how that section starts relative to the
// previous one.
const bodyConfig: SectionLayoutConfig = { pageSize, margins, columns: options.columns };
const finalConfig: SectionLayoutConfig = {
pageSize: finalPageSize,
margins: finalMargins,
columns: options.columns,
};
const { configs: sectionConfigs, breakIndices } = collectSectionConfigs(
blocks,
bodyConfig,
finalConfig
);
const sectionBreakTypes = [
...breakIndices.map((i) => (blocks[i] as SectionBreakBlock).type),
options.bodyBreakType,
];
const initialConfig = sectionConfigs[0] ?? bodyConfig;
// Create paginator with first section geometry
const paginator = createPaginator({
pageSize: initialConfig.pageSize,
margins: initialConfig.margins,
columns: initialConfig.columns ?? DEFAULT_COLUMNS,
footnoteReservedHeights: options.footnoteReservedHeights,
});
// Apply contextual spacing: suppress spaceBefore/spaceAfter between
// consecutive paragraphs that both have contextualSpacing=true and share
// the same styleId (OOXML spec 17.3.1.9 / ECMA-376 §17.3.1.9).
applyContextualSpacing(blocks);
// Pre-compute keepNext chains for pagination decisions
const keepNextChains = computeKeepNextChains(blocks);
const midChainIndices = getMidChainIndices(keepNextChains);
// Process each block, tracking section break index with a counter (O(1) per break)
let sectionIdx = 0;
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i];
const measure = measures[i];
// Handle pageBreakBefore on paragraphs
if (hasPageBreakBefore(block)) {
paginator.forcePageBreak();
}
// Handle keepNext chains - if this is a chain start, check if chain fits
const chain = keepNextChains.get(i);
if (chain && !midChainIndices.has(i)) {
const chainHeight = calculateChainHeight(chain, blocks, measures);
const state = paginator.getCurrentState();
const availableHeight = paginator.getAvailableHeight();
const pageContentHeight = state.contentBottom - state.topMargin;
// Only move to new page if:
// 1. Chain fits on a blank page (avoid infinite loop for oversized chains)
// 2. Chain doesn't fit in current available space
// 3. Current page already has content
if (
chainHeight <= pageContentHeight &&
chainHeight > availableHeight &&
state.page.fragments.length > 0
) {
paginator.forcePageBreak();
}
}
switch (block.kind) {
case 'paragraph':
layoutParagraph(block, measure as ParagraphMeasure, paginator, paginator.getContentWidth());
break;
case 'table':
if (block.floating) {
layoutFloatingTable(
block,
measure as TableMeasure,
paginator,
paginator.getContentWidth()
);
} else {
layoutTable(block, measure as TableMeasure, paginator);
}
break;
case 'image':
layoutImage(block, measure as ImageMeasure, paginator);
break;
case 'textBox':
layoutTextBox(block as TextBoxBlock, measure as TextBoxMeasure, paginator);
break;
case 'pageBreak':
paginator.forcePageBreak();
break;
case 'columnBreak':
paginator.forceColumnBreak();
break;
case 'sectionBreak': {
// Use the NEXT section's columns; for break type, prefer next section's
// type but fall back to current break's type (preserves explicit 'continuous')
const nextType = sectionBreakTypes[sectionIdx + 1] ?? sectionBreakTypes[sectionIdx];
const nextSectionConfig = sectionConfigs[sectionIdx + 1] ?? initialConfig;
handleSectionBreak(block as SectionBreakBlock, paginator, nextSectionConfig, nextType);
const nextBreakIndex = breakIndices[sectionIdx + 1];
const isTerminalSection = nextBreakIndex === undefined;
if (
isTerminalSection &&
(nextType ?? 'nextPage') === 'continuous' &&
(nextSectionConfig.columns?.count ?? 1) > 1
) {
balanceTerminalContinuousTextColumns({
blocks,
measures,
paginator,
start: i + 1,
end: blocks.length,
});
}
sectionIdx++;
break;
}
default:
// Exhaustiveness guard — see FlowBlock in types.ts.
assertExhaustiveFlowBlock(block, 'runLayoutPipeline');
}
}
// Ensure at least one page exists
if (paginator.pages.length === 0) {
paginator.getCurrentState();
}
return {
pageSize,
pages: paginator.pages,
columns: options.columns,
pageGap: options.pageGap,
};
}
/**
* Layout a paragraph block onto pages.
*/
function layoutParagraph(
block: ParagraphBlock,
measure: ParagraphMeasure,
paginator: ReturnType<typeof createPaginator>,
contentWidth: number
): void {
if (measure.kind !== 'paragraph') {
throw new Error(`layoutParagraph: expected paragraph measure`);
}
const lines = measure.lines;
if (lines.length === 0) {
// Empty paragraph - still takes up space based on spacing
const spaceBefore = getSpacingBefore(block);
const spaceAfter = getSpacingAfter(block);
const state = paginator.getCurrentState();
// Create minimal fragment
const fragment: ParagraphFragment = {
kind: 'paragraph',
blockId: block.id,
x: paginator.getColumnX(state.columnIndex),
y: state.cursorY + spaceBefore,
width: contentWidth,
height: 0,
fromLine: 0,
toLine: 0,
pmStart: block.pmStart,
pmEnd: block.pmEnd,
};
paginator.addFragment(fragment, 0, spaceBefore, spaceAfter);
return;
}
const spaceBefore = getSpacingBefore(block);
const spaceAfter = getSpacingAfter(block);
// Try to fit all lines on current page/column
let currentLineIndex = 0;
while (currentLineIndex < lines.length) {
const state = paginator.getCurrentState();
// Reserve the space `addFragment` will consume before this fragment's first
// line: `max(spaceBefore, trailingSpacing)` for the first fragment (the
// margin collapsed with the previous block's `spacing.after`), nothing for a
// continuation fragment (a fresh page/column resets trailing spacing). The
// fit loop must budget against the space that actually remains for lines —
// otherwise it counts lines that fit WITHOUT the heading's trailing space but
// don't fit once `addFragment` adds it, so `ensureFits` punts the WHOLE first
// fragment to the next page (a long paragraph after a keepNext heading jumps
// wholesale, stranding the heading above a near-full-page gap).
const reservedBefore =
currentLineIndex === 0 ? Math.max(spaceBefore, state.trailingSpacing) : 0;
const availableForLines = paginator.getAvailableHeight() - reservedBefore;
// Calculate how many lines fit in the space remaining after the reserve.
let linesHeight = 0;
let fittingLines = 0;
for (let j = currentLineIndex; j < lines.length; j++) {
// floatSkipBefore reserves vertical space above a line that was pushed past
// floats with no horizontal room — it counts toward the fragment height
// so subsequent blocks flow below the float, not over it.
const lineHeight = lines[j].lineHeight + (lines[j].floatSkipBefore ?? 0);
const totalWithLine = linesHeight + lineHeight;
if (totalWithLine <= availableForLines || fittingLines === 0) {
linesHeight = totalWithLine;
fittingLines++;
} else {
break;
}
}
// Create fragment for these lines
const isFirstFragment = currentLineIndex === 0;
const isLastFragment = currentLineIndex + fittingLines >= lines.length;
const effectiveSpaceBefore = isFirstFragment ? spaceBefore : 0;
const effectiveSpaceAfter = isLastFragment ? spaceAfter : 0;
const pmRange = getParagraphFragmentPmRange(
block,
measure,
currentLineIndex,
currentLineIndex + fittingLines
);
const fragment: ParagraphFragment = {
kind: 'paragraph',
blockId: block.id,
x: paginator.getColumnX(state.columnIndex),
y: 0, // Will be set by addFragment
width: contentWidth,
height: linesHeight,
fromLine: currentLineIndex,
toLine: currentLineIndex + fittingLines,
pmStart: pmRange.pmStart,
pmEnd: pmRange.pmEnd,
continuesFromPrev: !isFirstFragment,
continuesOnNext: !isLastFragment,
};
const result = paginator.addFragment(
fragment,
linesHeight,
effectiveSpaceBefore,
effectiveSpaceAfter
);
fragment.y = result.y;
currentLineIndex += fittingLines;
// If more lines remain, advance to next column/page
if (currentLineIndex < lines.length) {
paginator.ensureFits(lines[currentLineIndex].lineHeight);
}
}
}
/**
* Count consecutive header rows at the start of a table.
* Header rows are marked with isHeader: true in the block data.
*/
function countHeaderRows(block: TableBlock): number {
let count = 0;
for (const row of block.rows) {
if (row.isHeader) {
count++;
} else {
break;
}
}
return count;
}
/**
* Calculate total height of header rows from their measures.
*/
export function getHeaderRowsHeight(measure: TableMeasure, headerRowCount: number): number {
let height = 0;
for (let i = 0; i < headerRowCount && i < measure.rows.length; i++) {
height += measure.rows[i].height;
}
return height;
}
/**
* Layout a table block onto pages.
*
* Rows are placed in order. A row that doesn't fit in the remaining space is
* broken across the page boundary (Word's "allow row to break across pages")
* at the deepest whole line that fits — the leftover continues on the next
* page. The cursor into the table is `(rowIndex, consumed)` where `consumed`
* is how many px of `rowIndex` were already placed on a previous fragment.
*/
function layoutTable(
block: TableBlock,
measure: TableMeasure,
paginator: ReturnType<typeof createPaginator>
): void {
if (measure.kind !== 'table') {
throw new Error(`layoutTable: expected table measure`);
}
const rows = measure.rows;
if (rows.length === 0) {
return;
}
// Detect header rows (consecutive rows at start with isHeader: true)
const headerRowCount = countHeaderRows(block);
const headerRowsHeight = getHeaderRowsHeight(measure, headerRowCount);
const breakInfo = buildTableRowBreakInfo(block, measure);
let rowIndex = 0;
let consumed = 0; // px of rows[rowIndex] already placed on a previous fragment
while (rowIndex < rows.length) {
const state = paginator.getCurrentState();
const isFirstFragment = rowIndex === 0 && consumed === 0;
// Account for trailing spacing from the previous block that addFragment
// will consume (only the first fragment butts against prior content).
const pendingSpacing = isFirstFragment ? state.trailingSpacing : 0;
const headerOverhead = !isFirstFragment && headerRowCount > 0 ? headerRowsHeight : 0;
const availableHeight = paginator.getAvailableHeight() - pendingSpacing - headerOverhead;
const startRow = rowIndex;
const topClip = consumed;
let used = 0;
let cur = rowIndex;
// Px of `cur` already placed on a previous fragment. Only the first row of
// this fragment can carry one (the rest start at 0); `cur === toRow` holds
// at the top of every iteration.
const firstRowOffset = consumed;
let toRow = rowIndex; // exclusive
let bottomClip: number | undefined;
let lastRowPartial = false;
while (cur < rows.length) {
const rowHeight = rows[cur].height;
const startOff = cur === startRow ? firstRowOffset : 0;
const remaining = rowHeight - startOff;
if (used + remaining <= availableHeight) {
// The rest of this row fits whole.
used += remaining;
cur += 1;
toRow = cur;
continue;
}
// This row does not fully fit in the remaining space. Break it mid-content
// at the deepest whole line that fits (Word's "allow row to break across
// pages") — this keeps the row's other columns on the page where they
// start and flows a tall vertically-merged cell across the boundary.
// `w:cantSplit` rows (§17.4.6) never break.
const budget = availableHeight - used;
const placeable = block.rows[cur]?.cantSplit
? 0
: snapRowBreak(breakInfo, cur, startOff, budget);
if (placeable > 0) {
// Break this row mid-content at a whole-line boundary.
used += placeable;
toRow = cur + 1;
bottomClip = startOff + placeable;
lastRowPartial = true;
} else if (toRow > startRow) {
// Nothing of this row fits, but earlier rows did — end before it.
} else {
// Fresh fragment and not even one line fits: place the rest of the row
// with overflow rather than loop forever (oversized-row guard).
used += remaining;
toRow = cur + 1;
}
break;
}
// Compute fragment geometry. `used` is the visible window height.
const fragmentHeight = headerOverhead + used;
const isLastFragment = toRow === rows.length && !lastRowPartial;
let desiredX = paginator.getColumnX(state.columnIndex);
if (block.justification === 'center') {
desiredX = desiredX + (paginator.columnWidth - measure.totalWidth) / 2;
} else if (block.justification === 'right') {
desiredX = desiredX + paginator.columnWidth - measure.totalWidth;
} else if (block.indent) {
desiredX += block.indent;
}
const fragment: TableFragment = {
kind: 'table',
blockId: block.id,
x: desiredX,
y: 0, // Will be set by addFragment
width: measure.totalWidth,
height: fragmentHeight,
fromRow: startRow,
toRow,
pmStart: block.pmStart,
pmEnd: block.pmEnd,
continuesFromPrev: !isFirstFragment,
continuesOnNext: !isLastFragment,
headerRowCount: !isFirstFragment && headerRowCount > 0 ? headerRowCount : undefined,
topClip: topClip > 0 ? topClip : undefined,
bottomClip,
};
const result = paginator.addFragment(fragment, fragmentHeight, 0, 0);
fragment.y = result.y;
fragment.x = desiredX;
// Advance the cursor. A partial last row resumes at its break point
// (`bottomClip`); otherwise we move past the rows just placed.
if (lastRowPartial) {
rowIndex = toRow - 1;
consumed = bottomClip ?? 0;
} else {
rowIndex = toRow;
consumed = 0;
}
// If content remains, advance to the next column/page so the next
// iteration sees fresh space (the current page is exhausted).
if (rowIndex < rows.length) {
const nextNeeded =
(headerRowCount > 0 ? headerRowsHeight : 0) + (rows[rowIndex].height - consumed);
paginator.ensureFits(nextNeeded);
}
}
}
/**
* Layout a floating table (anchored) without advancing the cursor.
*/
function layoutFloatingTable(
block: TableBlock,
measure: TableMeasure,
paginator: ReturnType<typeof createPaginator>,
contentWidth: number
): void {
if (measure.kind !== 'table') {
throw new Error(`layoutFloatingTable: expected table measure`);
}
const state = paginator.getCurrentState();
const floating = block.floating;
const page = state.page;
const margins = page.margins;
const tableWidth = measure.totalWidth;
const tableHeight = measure.totalHeight;
const contentHeight = page.size.h - margins.top - margins.bottom;
// Default anchor base (content area)
let baseX = margins.left;
let baseY = margins.top;
if (floating?.horzAnchor === 'page') baseX = 0;
if (floating?.vertAnchor === 'page') baseY = 0;
// ECMA-376 §17.4.39: vertAnchor="text" positions the table relative to the
// line where the table would otherwise appear in flow. We use the current
// cursor as that anchor — without this, tables with w:tblpPr w:vertAnchor="text"
// jump to the top of the content area.
if (floating?.vertAnchor === 'text') baseY = state.cursorY;
// Determine X position
let x = paginator.getColumnX(state.columnIndex);
if (floating?.tblpX !== undefined) {
x = baseX + floating.tblpX;
} else if (floating?.tblpXSpec) {
const spec = floating.tblpXSpec;
if (spec === 'left' || spec === 'inside') {
x = baseX;
} else if (spec === 'right' || spec === 'outside') {
x = baseX + contentWidth - tableWidth;
} else if (spec === 'center') {
x = baseX + (contentWidth - tableWidth) / 2;
}
} else if (block.justification === 'center') {
x = baseX + (contentWidth - tableWidth) / 2;
} else if (block.justification === 'right') {
x = baseX + contentWidth - tableWidth;
}
// Determine Y position
let y = state.cursorY;
let usedExplicitY = false;
if (floating?.tblpY !== undefined) {
y = baseY + floating.tblpY;
usedExplicitY = true;
} else if (floating?.tblpYSpec) {
usedExplicitY = true;
const spec = floating.tblpYSpec;
if (spec === 'top') {
y = baseY;
} else if (spec === 'bottom') {
y = baseY + contentHeight - tableHeight;
} else if (spec === 'center') {
y = baseY + (contentHeight - tableHeight) / 2;
}
}
// If not explicitly positioned, ensure it fits on the current page
if (!usedExplicitY) {
const fitState = paginator.ensureFits(tableHeight);
y = fitState.cursorY;
}
// Clamp within content area to avoid negative positions
const minX = margins.left;
const maxX = margins.left + contentWidth - tableWidth;
if (Number.isFinite(maxX)) {
x = Math.max(minX, Math.min(x, maxX));
}
const fragment: TableFragment = {
kind: 'table',
blockId: block.id,
x,
y,
width: tableWidth,
height: tableHeight,
fromRow: 0,
toRow: block.rows.length,
pmStart: block.pmStart,
pmEnd: block.pmEnd,
isFloating: true,
};
// Add directly without advancing cursor — narrow floating tables let text
// wrap on either side (the painter's float zones handle that).
state.page.fragments.push(fragment);
// When a floating table leaves no horizontal room for text on either side
// (i.e. effectively block-like), reserve vertical space so subsequent
// paragraphs flow below it. Without this, the cursor stays at the table's
// top edge and the next paragraph paints over the table rows.
const leftFromText = floating?.leftFromText ?? 0;
const rightFromText = floating?.rightFromText ?? 0;
const leftSpace = x - margins.left - leftFromText;
const rightSpace = margins.left + contentWidth - (x + tableWidth) - rightFromText;
if (leftSpace < MIN_WRAP_SEGMENT_WIDTH && rightSpace < MIN_WRAP_SEGMENT_WIDTH) {
const advanceTo = y + tableHeight + (floating?.bottomFromText ?? 0);
if (advanceTo > state.cursorY) {
state.cursorY = advanceTo;
}
}
}
/**
* Layout an image block onto pages.
*/
function layoutImage(
block: ImageBlock,
measure: ImageMeasure,
paginator: ReturnType<typeof createPaginator>
): void {
if (measure.kind !== 'image') {
throw new Error(`layoutImage: expected image measure`);
}
// Handle anchored images differently
if (block.anchor?.isAnchored) {
layoutAnchoredImage(block, measure, paginator);
return;
}
// Inline image - ensure it fits
const state = paginator.ensureFits(measure.height);
const fragment: ImageFragment = {
kind: 'image',
blockId: block.id,
x: paginator.getColumnX(state.columnIndex),
y: 0, // Will be set by addFragment
width: measure.width,
height: measure.height,
pmStart: block.pmStart,
pmEnd: block.pmEnd,
};
const result = paginator.addFragment(fragment, measure.height, 0, 0);
fragment.y = result.y;
}
/**
* Layout an anchored (floating) image.
*/
function layoutAnchoredImage(
block: ImageBlock,
measure: ImageMeasure,
paginator: ReturnType<typeof createPaginator>
): void {
const state = paginator.getCurrentState();
const anchor = block.anchor!;
// Position based on anchor offsets
const x = anchor.offsetH ?? paginator.getColumnX(state.columnIndex);
const y = anchor.offsetV ?? state.cursorY;
const fragment: ImageFragment = {
kind: 'image',
blockId: block.id,
x,
y,
width: measure.width,
height: measure.height,
pmStart: block.pmStart,
pmEnd: block.pmEnd,
isAnchored: true,
zIndex: anchor.behindDoc ? -1 : 1,
};
// Add directly to page without affecting cursor
state.page.fragments.push(fragment);
}
/**
* Layout a text box block onto pages.
*/
function layoutTextBox(
block: TextBoxBlock,
measure: TextBoxMeasure,
paginator: ReturnType<typeof createPaginator>
): void {
if (measure.kind !== 'textBox') {
throw new Error(`layoutTextBox: expected textBox measure`);
}
if (isFloatingTextBoxBlock(block)) {
const state = paginator.getCurrentState();
const fragment: TextBoxFragment = {
kind: 'textBox',
blockId: block.id,
x: paginator.getColumnX(state.columnIndex),
y: state.cursorY,
width: measure.width,
height: measure.height,
pmStart: block.pmStart,
pmEnd: block.pmEnd,
isFloating: true,
zIndex: block.wrapType === 'behind' ? -1 : 1,
};
state.page.fragments.push(fragment);
return;
}
const state = paginator.ensureFits(measure.height);
const fragment: TextBoxFragment = {
kind: 'textBox',
blockId: block.id,
x: paginator.getColumnX(state.columnIndex),
y: 0,
width: measure.width,
height: measure.height,
pmStart: block.pmStart,
pmEnd: block.pmEnd,
};
const result = paginator.addFragment(fragment, measure.height, 0, 0);
fragment.y = result.y;
}
/**
* Handle a section break block.
* @param block - The section break block (current section's properties)
* @param paginator - The paginator instance
* @param nextSectionConfig - Page layout for the NEXT section
* @param nextSectionType - Break type of the NEXT section (how it starts relative to current)
*/
function handleSectionBreak(
_block: SectionBreakBlock,
paginator: ReturnType<typeof createPaginator>,
nextSectionConfig: SectionLayoutConfig,
nextSectionType?: SectionBreakBlock['type']
): void {
// ECMA-376 §17.6.22: w:type specifies how the NEXT section starts relative to this one.
// Default is 'nextPage' when w:type is absent.
const breakType = nextSectionType ?? 'nextPage';
switch (breakType) {
case 'nextPage':
paginator.updatePageLayout(nextSectionConfig.pageSize, nextSectionConfig.margins);
paginator.forcePageBreak();
break;
case 'evenPage': {
paginator.updatePageLayout(nextSectionConfig.pageSize, nextSectionConfig.margins);
const state = paginator.forcePageBreak();
// If landed on odd page, add another page
if (state.page.number % 2 !== 0) {
paginator.forcePageBreak();
}
break;
}
case 'oddPage': {
paginator.updatePageLayout(nextSectionConfig.pageSize, nextSectionConfig.margins);
const state = paginator.forcePageBreak();
// If landed on even page, add another page
if (state.page.number % 2 === 0) {
paginator.forcePageBreak();
}
break;
}
case 'continuous':
// ECMA-376 §17.6.22: keep current page geometry; defer new size/margins
// until the next natural page break. Columns apply immediately below.
paginator.updatePageLayout(
nextSectionConfig.pageSize,
nextSectionConfig.margins,
/* applyImmediately */ false
);
break;
}
// Update column layout for the next section
paginator.updateColumns(nextSectionConfig.columns ?? DEFAULT_COLUMNS);
}
// Re-export types
export * from './types';
export { createPaginator } from './paginator';
export type { PageState, PaginatorOptions, Paginator } from './paginator';
export {
computeKeepNextChains,
calculateChainHeight,
getMidChainIndices,
hasKeepLines,
hasPageBreakBefore,
} from './keep-together';
export type { KeepNextChain } from './keep-together';
export {
scheduleSectionBreak,
applyPendingToActive,
createInitialSectionState,
getEffectiveMargins,
getEffectivePageSize,
getEffectiveColumns,
} from './section-breaks';
export type { SectionState, BreakDecision } from './section-breaks';
export type { FootnoteContent } from './types';
export { findPageIndexContainingPmPos } from './findPageIndexContainingPmPos';
export {
isFloatingTextBoxBlock,
floatingTextBoxWrapsText,
floatingTextBoxReservesBand,
type TextBoxFlowAttrs,
} from './textBoxFlow';
export { isFloatingWrapType, isWrapNone, wrapsAroundText } from '../docx/wrapTypes';