Layout pipeline
layoutDocument(doc, options) turns a document into pages of positioned fragments. It is DOM-free and deterministic: given the same document, theme and measurer it returns the same pages in Node, a worker or a browser.
const layout = layoutDocument(doc, {
measurer, // required: TextMeasurer (HarfBuzz in production)
theme, // page size, margins, typography (defaults to defaultTheme)
math, // MathMeasurer for LaTeX (e.g. KaTeX)
mediaSize, // intrinsic media sizes
renderers, // custom block layouts, by node type
headerFooter, variables, firstPageNumber, totalPages,
hints, // formatter output: Map<blockIndex, { pageBreakBefore?, keepWithNext? }>
cache, previous, // incremental layout (see next page)
});
// → { pages: PageLayout[], warnings: LayoutWarning[] }Three stages
- Measure. Each top-level block is measured into a Flow at the content width. Containers (quotes, lists, cells) measure their children recursively.
- Arrange. The top-level flows are laid end to end with collapsed margins.
- Paginate. One linear walk over all break opportunities picks where each page ends. Page fragments are then built lazily, per page, on first access.
Flows and breaks
A Flow is a measured block: fragments at y relative to its top, plus the places a page may end inside it.
interface Flow {
height: number;
fragments: Fragment[];
breaks: Break[]; // sorted by `end`, strictly inside (0, height)
marginTop: number;
marginBottom: number;
keepWithNext?: boolean;
pageBreakBefore?: boolean;
}
interface Break {
end: number; // content up to here stays on this page
resume: number; // the next page starts here (a collapsed gap is skipped)
penalty: number; // 0 = fine, 1 = avoid, Infinity = never
forced?: boolean; // must break here
carry?: { height: number; fragments: Fragment[] }; // repeated at the top of the next page
}end and resume differ when a break falls in a gap: the space between two paragraphs ends one page and is dropped at the top of the next, the way Word drops space-before at the top of a page.
Every pagination rule is a break
| Rule | Expressed as |
|---|---|
| Line boundaries in a paragraph | break, penalty 0 |
Widows / orphans (theme.widows, theme.orphans, default 2) | the offending line breaks get penalty 1 |
keepLinesTogether | every line break inside the paragraph gets penalty 1 |
keepWithNext (and headings, via theme.headingsKeepWithNext) | the boundary break after the block gets penalty 1 |
pageBreakBefore | the boundary break before the block is forced |
spaceBefore / spaceAfter | the block's top/bottom margin; with an explicit spaceBefore the margin is kept at the document start and after a forced break (keepMarginAtBreak, CSS Fragmentation §5.2), else a block starting a page loses it |
| Table header rows | breaks after the header rows carry the header fragments |
| Rows crossing a rowspan, rows inside the header | penalty 1 |
| Media, math, rules | no inner breaks (unsplittable) |
| Inside a float's vertical span | breaks get penalty 2 (FLOAT_SPAN), the boundary after a float too |
Because all of this is data, the paginator itself knows nothing about paragraphs, headings or tables.
Choosing page ends
For each page, the paginator considers the breaks between the page start and the page's capacity (content height minus header/footer bands and any carried header) and picks:
- the first forced break in range, else
- the furthest penalty-0 break that fits, else
- the furthest penalty-1 break, else
- the furthest penalty-2 break (inside a float's span), else
- nothing fits: the unsplittable content overflows its own page (clipped in print, as Word does). The page gets
forcedCut: trueand a warning is emitted.
Each page only walks the flows it overlaps, without allocating per break, so pagination is linear in the number of breaks. Keep rules yield gracefully: a keepLinesTogether paragraph taller than a page still splits (penalty 1 is "avoid", not "never").
Sections with their own page setup and columns
Sections (chapters and section breaks, see plugin-header-footer) may set their own page size, margins and newspaper columns (SectionSettings.page, .columns; missing fields inherit from the previous section, Word's sections without the duplication). docGeometry resolves them once per document; when every section uses the theme's page in one column it returns null and nothing below runs (the uniform fast path).
Otherwise each block is measured at its section's column width (sections that match the theme share its cache key) and the paginator fills frames instead of pages. A frame is one column of a region; a page stacks regions. The same paginate picks every frame's end with every rule above; only its capacity callback changes: a FramePlanner decides, frame by frame and in order, where the next one goes:
- the next column of the region, when the previous frame wasn't its last;
- a new page after the last column, at a page break (
pageBreakBefore, next-page section starts) or when the next section has another page size. The page's size and content height come from the section of its first block (bands included); - a new region below (continuous section start,
sectionBreakwithoutpageBreakBefore): it starts under the previous region's longest column, with the new section's columns and side margins. If not even its first line fits, the new page.
A column break (columnBreakBefore, Word's w:br w:type="column") and a continuous section start are forced breaks (Flow.breakBefore); the planner reads what started a frame from the block at its start. Before a continuous section, a section's columns are balanced (Word): when the rest of the section fits in the region, a binary search over real pagination (so widows, keeps and table headers all apply) finds the smallest region height that still holds it. Columns of unequal width (custom) each lay their text out at their own width: blocks are measured at the widest column, and once pagination knows which column a block lands in, lines in a narrower column are narrowed per line (an exclusion beyond the column's width; a paragraph continuing into a wider column widens there), blocks that can't wrap (tables) and floats are measured at their column's width, and objects positioned from the margin or page (ctx.area) are placed from the text area, not their column. This shares the page objects' fixed point (below).
An odd/even page section start (pageBreakBefore: 'odd' | 'even', Flow.pageParity) inserts a blank page (PageSlice.blank) when the page after the break would have the wrong number; the blank page belongs to the section before it. A converged tail is reused only when it shifts pages by an even count.
The planner is memoryless given the previous frame, so incremental layout works as before: pages whose frames all end before the edit are kept, and pagination stops once a new page starts in the unchanged tail exactly where, and as, an old page started (same section geometry, height and column). A randomized test checks that the result equals a full layout. Pages carry width/height per section and columns (every column box, page coordinates); column rules are rect fragments with role column-rule. The React view positions pages by prefix sums of their heights only when sizes differ.
Stacking and margin collapsing
stack(children, opts) stacks flows vertically with CSS-style margin collapsing between siblings (gap = max(prev.marginBottom, next.marginTop)), adds a break at every sibling boundary (penalty 1 after a keepWithNext child, forced before a pageBreakBefore child), and shifts child breaks into place. Options: dx (indent children), indexPaths (prefix fragment paths with the child index, default true) and contain (keep the first/last margins inside, for boxed containers such as callouts and table cells).
lineBreaks(bottoms, widows, orphans, keepTogether) builds the line breaks of a textblock with widow/orphan control.
Fragments
Positioned output, in CSS px, consumed by painters, the PDF writer and the editor's position index:
| Kind | What | Notable fields |
|---|---|---|
line | a laid-out line | baseline, dir, items (text items with x, width, font, marks, rtl, wordSpacing, shift, and inline atoms), from/to (ProseMirror-compatible offsets), code, decorative |
rect | box or rule | role (quote-border, code-box, code-header, cell, rule, or any host string), fill, stroke, strokeWidth, radius |
media | image/video/audio box | attrs |
math | display equation | latex, depth |
marker | list bullet or number | text, font, baseline, dir, color (a level's own), image (picture bullet: { src }, fills the box) |
custom | plugin-defined box | type, attrs (layout positions it; the plugin's painter draws it) |
Every fragment has x, y, width, height and path (from the document root once placed; path[0] is the top-level block index). Copies repeated on a continuation page (table headers) have repeated: true and never hold the caret.
Rects are clipped at page edges (a quote border or table cell continues on the next page); everything else lands on the page that holds its top edge.
Built-in block layouts
- Paragraphs and headings: UAX #14 line breaking, UAX #9 bidi reordering, justification with per-space word spacing, first-line indent (
textIndent), inline padding,lineHeight, sub/superscript baseline shifts, inline math and hard breaks. All caps, small caps (OpenTypesmcp/c2scwhen the font has them, else capitals at 80% in their own text items) and letter spacing are resolved while collecting inline pieces, so breaking and justification see the drawn widths (details). Empty paragraphs and paragraphs inside lists have no bottom margin (as the canonical book CSS). - Tabs and tab stops: a tab is a literal
\tin a text node (one PM offset, no extra node type: copy/paste, plain text and collaboration need nothing special, and header/footer tabs already used it). Paragraphs with a tab are filled by a tab-aware filler: each tab advances to the paragraph's nexttabStopsentry past the pen ({ pos, align, leader },posfrom the paragraph box's start edge, as Word measures from the margin), else to the next multiple oftheme.tabInterval(default 48px = 0.5in; default stops only after the last custom stop), with Word's implicit stop at the start indent of a hanging paragraph. Right, centre and decimal stops pull back by the text that follows (up to the next tab; decimal up to its.). A tab is aTextItemwith text\t,width= its advance andtab: { align, leader? }; the leader (dots, hyphens, underscores, middle dots) is precomputed text that painters draw throughdrawnItem(item). Lines with tabs are start-aligned and never justified. Paragraphs without a tab take the old path (no cost). Beside a float, tabs fall back to default stops. - Negative indents:
paddingInlineStart/paddingInlineEndmay be negative and a hangingtextIndentmay pass the start edge: text reaches into the page margin, clamped at the paper edge (MeasureCtx.margin, set from the page margins at the top level; without it indents stay >= 0). - Lists: items inset by
theme.list.indent, markers on the first line's baseline, alllistStyleTypes, nested bullet lists step disc → circle → square as browsers do, RTL puts markers on the right. A list's ownlistLevel(Word's numbering level) overrides that: any bullet character in its own font, colour, size and weight, picture bullets (drawn on the first baseline), every Word number format (lower-letteraa/bb,ordinal,arabic-indic…) and templates such as%1.%2.,Chapter %1:or(%1).%kis the counter of the k-th enclosing list (outermost first), each in its own list's format: measuring passes the item counters down throughMeasureCtx.listChain, so nested numbering needs nothing outside the top-level block and the identity-keyed cache stays exact.indentreplaces the theme indent; withhangingthe marker startshangingbefore the text (Word), else it endstheme.list.markerGapbefore it; a marker wider than the hanging indent pushes the first line to the next 0.5in tab stop, as Word's tab after the number does. - Blockquotes: inset children plus a start-side border rect.
- Code blocks: monospace pre-wrap lines in a box with a language header; splits between lines. Syntax colours are a painter concern: monospace tokens never move layout.
- Tables: HTML grid model with
rowspan/colspan,colwidthhonoured and the remaining columns sharing what's left, leading all-header rows repeated on every continuation page, rows taller than a page split between the lines of their cells. The table'swidth(px,"NN%", or"auto"= thecolwidthsum, as Word's grid) andalign/indentplace it inside the text area (default: full width). Cell padding comes from the cell'spadding, the table'scellPadding, then the theme;verticalAlignshifts a cell's content inside its row. Borders: tables without border info keep Folio's grid (every cell box stroked). With tableborders(top,bottom,start,end,insideH,insideV), cellbordersor a built-intableStyle(TABLE_STYLES: header fill, banded rows/columns, first column, last row, perlook), borders resolve collapsed: each shared edge is drawn once, by the cell below / after it (outer bottom and end edges by the last cells); cell borders beat table borders, and two cells' borders on one edge go to the wider, then the heavier style (pickBorder). Cell rects carry the result asborders(visual sides, drawn inside the box);borderStripsturns them into the filled strips canvas and PDF paint (double = two strips, dashed/dotted = dashes), the DOM view uses CSS borders. When such a table splits across pages, the last row on a page gets the border at the cut (the edge row below it would draw), as Word repeats it: the break carries acap(fragments drawn at the bottom of the page that ends there, markedrepeated). - Math: sized by the injected
MathMeasurer, centered, unsplittable. - Media: width from attrs (
%or px), height from attrs or the intrinsic aspect ratio (mediaSize), clamped to the page so it never needs splitting. Audio is a fixed-height bar. Placement (inline, square float, top and bottom, behind/in front) isplaceBox's job (see below). - Any other node with inline content is laid out as a paragraph; with block content, its children are stacked. Unknown leaves render empty with a warning.
Host renderers (options.renderers[type]) win over all of these, including built-in types. See the custom block tutorial.
Floats and text wrap
Objects with Word's layout options (wrap: square, topBottom, behind, front; see placementOf(attrs)) are placed by placeBox(box, placement, ctx), which host renderers call too (plugin-media's file cards do):
- inline / topBottom: an aligned block, spaced by
distT/distB. - square (top level only): a flow of height 0 whose fragment has
layer: 'float'and whoseflow.floatis the excluded box (the object plus its distances), relative to the flow. Nested floats (list items, quotes, cells) are laid out as aligned blocks (v1). - behind / front: height 0, the fragment has
layer: 'behind' | 'front'(withpageYwhen anchored to the page margin). Pages list behind-text fragments first and in-front ones last, so painting in order layers them correctly; text ignores them.
The float pass. Blocks are measured on their own first (cached by node, as always). Then applyFloats walks only from float to float (their indices are kept incrementally) and, while a float is active, tracks block positions in a frame starting at that float:
- A float stacks beside the active floats on its side, or moves below them when the band is too narrow (
settle). - A following block that overlaps the floats' span and can wrap (paragraphs, headings, lists, quotes, any inline container) is re-measured with
ctx.exclusions: each line gets the free band at its own y (wrapLines), lines are moved below a float when the band is narrower than 3 em, and a taller line (big font, inline math) is refilled with its real height. Lists move the whole item, marker included; containers stack their children one by one so each sees the exclusions at its own offset. RTL lines start at the right edge of their band. - A block that can't wrap (table, code, math, rule, media, host renderers, a forced page break) starts below the floats: the block before it grows down to their bottom, so the page may end right there.
- Breaks inside a float's span get penalty 2, so a float that doesn't fit on its page moves to the next page with the text that follows it (Word), but a chain of floats taller than a page still splits instead of overflowing.
Tight wrap. An exclusion may carry the object's outline (poly, a closed polygon, with the distance from text as pad): band() then uses the outline's horizontal extent within each line's own span, so lines follow an ellipse, a star or a rotated box. both lets a line take the larger free side of an object in the middle of the column (Word's "largest").
Page objects. Objects anchored to pages (LayoutOptions.pageObjects, or a renderer's pageObjects, which is how @nextgensoftwares/folio-plugin-layers provides masters and page objects) give each page zones (page coordinates) and fragments. The text a page holds depends on the text wrapped before it, so layout iterates (zonePass): paginate, map each page's zones into flow coordinates per frame (page, or column of a region), re-wrap the blocks in them (memoized, so the same wrap keeps identity), move blocks that can't wrap below the objects in their way, paginate again, until the flows stop changing (at most MAX_ZONE_PASSES, then a warning). Page fragments merge the objects into the behind / in-front stacks by z. Such documents paginate in full on each edit; without page objects or columns nothing of this runs.
Caching and incrementality. Exclusions are page-local: they only touch the blocks after a float until its bottom. Measurements beside a float are memoized per LayoutCache by node identity plus the exclusion geometry (a separate memo, so the plain measurement stays cached), and every derived flow (moved float, grown block, guarded breaks) is memoized by identity, so unchanged pages keep reusing their objects. The incremental diff keeps old base flows for the unchanged prefix/suffix and narrows them to the flows the float pass really left unchanged, walking only the runs it touched. A document without floats skips the pass entirely and lays out exactly as before (pinned by a golden test); incremental results equal full layouts after hundreds of seeded random edits around floats (floats-incremental.test.ts).
Headers and footers
layoutDocument(doc, {
measurer,
headerFooter: {
header: { enabled: true, leftContent: '{{bookTitle}}', rightContent: '{{title}}', differentFirstPage: true },
footer: { enabled: true, showPageNumber: true, pageNumberPosition: 'center' },
},
variables: { bookTitle: 'Physics I', title: 'Kinematics' },
firstPageNumber: 41, // global numbering across a multi-part book
});Each zone has up to three single-line slots (left, center, right). {{name}} placeholders are filled from variables, plus {{page}} and {{total}}. showPageNumber appends "N of M" to the chosen slot. A zone's band height (theme.page.headerHeight / footerHeight) is reserved only on pages where the zone is active. Header/footer lines are decorative: no caret, no hit-testing.
Document headers and footers
Documents can carry their own, Word-grade headers and footers (doc.attrs.headerFooter, edited with @nextgensoftwares/folio-plugin-header-footer). When present (and headerFooter is passed, even as {}) they replace the zones above:
const doc = {
type: 'doc',
attrs: { headerFooter: {
differentOddEven: true, pageNumbers: { format: 'i' },
header: { default: [p('\t\t', field('title'))], even: [p(field('heading'))] },
footer: { default: [p('\t', field('page'))] },
sections: { main: { pageNumbers: { start: 1, format: '1' } } }, // a chapter with hfSection: 'main'
} },
content: [/* ... */],
};
layoutDocument(doc, { measurer, theme, headerFooter: {}, now: Date.now() });What changes in the pipeline:
- Sections. One O(blocks) scan finds section starts (chapters of the chapter level when
doc.attrs.chaptersis on, and blocks withsectionBreak) and resolves "link to previous" for every part. - Exact page capacity.
paginatepasses each new page's start offset tocapacity(i, start): the start gives the page's first block, hence its section, printed number and kind (first/even/default), hence its header and footer band heights. A band ismax(theme band, distance − margin + content height), so a tall header pushes the body down on the pages that show it. - Plan. After pagination every page gets its number (restarts, formats →
page.label), kind, section page count and running heading. Kept and converged pages are checked against what pagination assumed; a difference (e.g. a parity shift under different odd/even heights) re-runs the layout once, non-incrementally. - Chrome. Each page's header/footer content is laid out lazily (fields replaced by the page's values, tabs on start/centre/end stops), measured flows shared by every page showing the same values.
- Reuse. A page's chrome inputs (variant content, the field values it uses, distances, label) are part of its reuse check, separately from its body: a header edit that keeps band heights re-wraps pages around the same body fragments; a total that changes only rebuilds pages that show it.
The env signature includes band-relevant settings (part heights, first-page and restart flags, distances), so changing them is a full layout; typing in a header is not.
Formatter hints
@nextgensoftwares/folio-formatter reads the document and proposes hints instead of editing it. Rules see one top-level block and its immediate neighbours, and results are cached by those three nodes' identities, so a keystroke re-evaluates three blocks, not the book.
import { format, h1StartsPage, introKeepsWithNext } from '@nextgensoftwares/folio-formatter';
const hints = format(doc, [introKeepsWithNext, h1StartsPage]);
layoutDocument(doc, { measurer, hints });Built-in rules: introKeepsWithNext (a paragraph ending in ":" stays with the list/table/code/media/math it introduces; the only one in defaultRules) and h1StartsPage (every H1 after the first starts a page). Hints are keyed by top-level block index and merged over the block's own attributes.
Warnings
layout.warnings collects non-fatal problems: blocks with no layout, and pages whose content overflows (cut in print). Layout never throws on odd content.