# Postext Architecture

> Technical architecture of the Postext layout engine

- HTML version: https://postext.dev/en/docs/architecture
- Last updated: 2026-06-10
- Reading time: 38 min
- Other languages: [es](https://postext.dev/es/docs/architecture.md)

If you have ever worked with React, you already understand the core trick. React builds a virtual DOM in memory, diffs it, and only then touches the real browser DOM. Postext does the same thing — but instead of UI components, it builds a tree of **pages, columns, text blocks, and bounding boxes**. The entire geometry of a multi-page, multi-column document, computed before a single pixel is rendered. Every paragraph, heading, image, footnote, and pull quote placed at exact coordinates, respecting centuries-old typographic rules that CSS simply cannot express.

All of this is possible thanks to [`@chenglou/pretext`](https://github.com/chenglou/pretext), a DOM-free text measurement library that is 300–600x faster than browser layout reflow. For the story behind the project (a decade of failed attempts, the bottleneck that blocked them all, and the library that finally removed it), see the [Introduction](/en/docs/introduction).

## The Core Idea

Picture a 74-page, two-column document. A company annual report, maybe, or a densely illustrated textbook. You hand it to Postext, and the engine constructs the entire layout in memory — every page, every column, every paragraph's exact position and pixel dimensions. Want to know what sits on page 72, column 2? The answer is already there. No rendering required. The engine has already decided where to break every paragraph, where to place every image, how to avoid widows and orphans, and how to align baselines across adjacent columns.

Now here is why that matters so much.

Typographic rules are deeply, maddeningly interdependent. You fix a widow on page 5 — a lonely last line stranded at the bottom of a column — by pulling it back into the previous column. Great. But that change shortens page 5's column, which shifts content forward, which might just create an orphan on page 6. A single first line pushed onto a new column, disconnected from its paragraph. To even *detect* that you have created a new problem, you need the entire document layout available for inspection. And to *fix* it without creating yet another problem somewhere else, you need to be able to adjust, re-measure, and re-check the whole thing.

That is the "compute everything first, render later" philosophy. It is not a performance trick. It is the only way to apply the dozens of interconnected typographic rules that professional typesetters have used for centuries.

## Core Concepts

A quick glossary. The rest of the document assumes these terms — come back here whenever one
has gone fuzzy.

| Term | Definition |
| --- | --- |
| **VDT** | Virtual Document Tree. The mutable in-place data structure that represents the entire document: pages, columns, blocks, inline segments, and bounding boxes. Analogous to a virtual DOM but for document layout geometry. |
| **Page** | A fixed-size rectangular area. The engine is page-aware from the start. A document is an ordered sequence of pages. |
| **Column** | A vertical subdivision of a page. Columns have a fixed width and maximum height. Text flows from one column to the next, then to the next page. |
| **Block** | A content unit that occupies vertical space in a column: paragraph, heading, image, table, blockquote, pull quote, or footnote area. |
| **Line** | A measured line of text within a block, produced by Pretext. Each line has a bounding box and a baseline position. |
| **Bounding Box** | {  `x, y, width, height`  } in pixels, relative to the page origin. Every node in the VDT carries one. |
| **Resource** | A non-text element connected to the prose by id: a bitmap, an SVG, or a table (`Resource`, with `kind: 'bitmap' \| 'svg' \| 'table'`). Resources are numbered per type (Figure 1, Table 2.1…) at first reference and floated to a band at the top or bottom of a page near that reference. |
| **Note** | A footnote, endnote, or margin note, defined by `PostextNote`. The content model carries notes, but note layout is still an early-stage area of the engine. |
| **Baseline Grid** | The vertical lattice derived from the body line height (e.g. 24px for 16px/1.5). Every body-text baseline should land on a multiple of it, keeping lines aligned across columns and facing pages. |
| **Badness** | How far a justified line's inter-word spaces deviate from their natural width — the squared adjustment ratio, saturating at 10000. The base cost in Knuth-Plass line breaking. |
| **Demerits** | The total cost of a candidate line break in Knuth-Plass: badness plus penalties (hyphenation, widow/orphan/runt, fitness-class mismatch). The algorithm picks the break set with the lowest total demerits. |
| **Fitness Class** | A coarse bucket for how tight or loose a line is. Adjacent lines in very different classes (a tight line next to a very loose one) incur an extra demerit, smoothing the paragraph's texture. |
| **Slack** | Unused vertical space left at the bottom of a column. A squared slack cost (weighted by `slackWeight`) nudges the line breaker toward break sets that fill columns tightly. |
| **Backend** | A rendering target that consumes the converged VDT. Three are shipping today: **canvas** (bitmap preview), **HTML** (DOM-based screen reading) and **PDF** (print-ready output via `postext-pdf`). All three share the same measurement (the measure module over Pretext). |
| **Pass** | One stage of the layout pipeline. Each pass reads and mutates the VDT with a single responsibility. |
| **Convergence Loop** | The outer loop that re-runs layout passes when later passes invalidate earlier decisions. Bounded to a maximum of 5 iterations. |

## System Architecture

> **Figure: Postext system architecture**
> Enriched markdown and PostextConfig enter a parser that builds the Virtual Document Tree. Pretext measures text. Seven layout passes mutate the VDT inside a convergence loop. A backend renders the final VDT to HTML or PDF.
>
> *Parser → VDT ↔ Pretext → seven layout passes → backend → output.*

**Here is the journey your content takes through the engine:**

1. The **Parser** reads your enriched markdown and config, building the initial VDT — a tree of typed blocks with no positions yet, just content and structure
2. **Layout passes** take over, mutating the VDT in sequence: measuring text via Pretext, flowing blocks into pages and columns, refining typography until it meets professional standards
3. The **Convergence loop** watches for trouble — when a later pass (say, fixing a widow) invalidates an earlier decision (say, column heights), the engine loops back and re-runs from the affected point. Up to 5 iterations, until everything settles
4. The **final VDT** is the complete layout geometry: every element knows its page number, column assignment, position, and bounding box. The document is fully "typeset" before any rendering happens
5. A **backend** walks the finished VDT and renders it to the target format — a rasterized canvas bitmap, a DOM tree of positioned HTML elements, or a PDF document with embedded fonts. The same VDT feeds all three; picking a backend is purely an output decision

## Input Layer

### Content Model

The content model is a philosophy as much as it is a data structure. You describe *what* to say, not *how* to lay it out. The engine makes the layout decisions.

> **Figure: Content model: markdown, resources, notes**
> The input to Postext separates markdown (reading order and semantic structure) from resources (bitmaps, SVGs, tables) and notes. The engine resolves inline :ref references and ::resource embeds by ID and produces the VDT.
>
> *Markdown owns the reading order; resources own the visual data.*

```typescript
// packages/postext/src/types.ts

interface PostextContent {
  markdown: string;            // enriched markdown with :ref / ::resource markers
  metadata?: DocumentMetadata; // title, subtitle, author, publishDate, …
  resources?: Resource[];      // bitmaps, SVGs, tables — referenced by id
  notes?: PostextNote[];       // footnotes, endnotes, margin notes
}

interface Resource {
  id: string;                  // stable id, referenced by inline :refs
  typeId: string;              // ResourceType this resource belongs to ('figure', 'table', …)
  kind: 'bitmap' | 'svg' | 'table';
  caption?: string;            // the type prefix + number are computed, not written here
  altText?: string;
  createdAt: number;
  updatedAt: number;
  // Exactly one kind-specific payload:
  bitmap?: { fileId: string; format: string; width: number; height: number };
  svg?: { fileId: string; width?: number; height?: number };
  table?: { model: TableModel };
  placement?: ResourcePlacement; // optional per-resource float override
}
```

**Resources** carry the visual data: captions, alt text, and a kind-specific payload. Binary payloads (bitmaps, SVGs) live *out-of-band* — the resource only stores a `fileId`, and the renderer resolves it at draw time (the sandbox keeps the bytes in IndexedDB). Table resources are the exception: their `TableModel` (a grid of cells with spans and alignment) travels inline, because it is structured data, not bytes.
**Notes** carry content and a marker style, referenced from inline positions in the markdown.

This separation is an opinionated design choice, and it matters more than it looks. The markdown owns the *reading order* and *semantic structure* — what comes first, what is a heading, where a footnote is referenced. The resources and notes arrays own the *visual data* — image dimensions, caption text, note content. By keeping them apart, the same markdown can be laid out in completely different ways just by changing the configuration. A two-column academic layout and a single-column blog post can share the same source content. And the engine can make placement decisions — like deferring an image to the next column because it does not fit here — without ever touching your source content.

```typescript
// Example: a simple article with a referenced figure
const content: PostextContent = {
  markdown: `
# The Art of Typography

The history of typography begins with Gutenberg's
movable type, shown in :ref{id="printing-press"}.
His invention transformed the production of books.

The technique spread rapidly across Europe, reaching
Italy by 1465 and France by 1470.
  `,
  resources: [
    {
      id: 'printing-press',
      typeId: 'figure',
      kind: 'bitmap',
      caption: 'A reconstruction of the original press.',
      altText: "Reconstruction of Gutenberg's printing press",
      createdAt: 1765379100000,
      updatedAt: 1765379100000,
      bitmap: { fileId: 'press-photo', format: 'jpeg', width: 600, height: 400 },
    },
  ],
};
```

Resources are connected to the prose by id, and **referencing one is enough to incorporate it**. The inline `:ref{id="printing-press"}` does two jobs at once: it renders the resource's computed number in the running text ("Fig. 1"), and — at the first reference in reading order — it *floats* the resource onto the page, reserving a band at the top or bottom near that reference, exactly as a print typesetter would. You never place the figure a second time. For the occasional resource that must sit at an exact point in the flow, `::resource{id="…"}` on its own line is an optional block embed: it only renders inline when the resource's resolved `placement.position` is `'here'` (which opts it out of floating); for a floated resource it is treated as just another reference. The author never has to think about placement. The engine does.

> **Figure: Reference resolution**
> A markdown source references a figure inline with :ref{id=printing-press}. The resources array provides the actual content by ID. The engine resolves the reference, renders the computed number in the text, and floats the figure to a band at the top of the page.
>
> *References in the markdown are just names. The engine resolves them against resources, numbers them, and floats the figure near the reference.*

### Configuration

Every aspect of the layout pipeline is controlled by `PostextConfig`. The full survey of sections (page, layout, body text, headings, lists, math, headers and footers…) lives on the <a href="/en/docs/configuration">Configuration</a> page; the ones most relevant to this document are:

| Config | Controls | Used in |
| --- | --- | --- |
| `bodyText` / `headings` | Per-field widow/orphan/runt penalties, keep-together rules, justification limits, hyphenation — see [Configuration → Body Text](/en/docs/configuration#body-text) | Pass 2, Pass 5 |
| `tableStyle` | Cell typography, borders, corner radius, header/body fills for `kind: 'table'` resources, plus the named variants of `tableStyles` a table picks with `table.styleId` — see [Configuration → Table style](/en/docs/configuration#table-style) | Pass 4 |
| `captionStyle` | Typography of resource captions: the numbered label and the description text — see [Configuration → Caption style](/en/docs/configuration#caption-style) | Pass 4 |
| `diagramStyle` | `singleInk` + `inkColor` — recolors SVG diagrams to luminance-mapped tints of one ink, so figures reproduce faithfully when printing with a single spot colour — see [Configuration → Diagram style](/en/docs/configuration#diagram-style) | Backends |
| `resourceTypes` | Typed resource numbering: templates, counter formats, reset scopes, default float placement — see [Configuration → Resource types](/en/docs/configuration#resource-types) | Pass 1, Pass 4 |
| `TypographyConfig`, `ColumnConfig`, `ResourcePlacementConfig`, `ReferenceConfig`, `PostextSectionOverride` | **Legacy.** Declared in `types.ts` but never wired into the pipeline; their responsibilities were absorbed by the sections above. Kept for reference only. | — |

### Parsing Strategy

Parsing is deliberately the simplest step in the pipeline. Markdown goes in, gets parsed into an AST, and each node becomes a `VDTBlock`. Resource and note references are resolved against the `resources[]` and `notes[]` arrays by ID. The output is a flat list of typed, content-filled blocks — but with no page assignment, no column, no position.

Think of it as a manifest: "there is a heading, then a paragraph with 200 words, then a figure reference, then another paragraph." No measurements. No positioning. No layout decisions at all. The heavy work starts in Pass 2.

## Virtual Document Tree (VDT)

Imagine you asked a professional typesetter to lay out an entire book, but instead of handing you printed pages, they handed you a spreadsheet. Every row is an element. Every cell is a precise measurement: "the heading is at (40, 30), the first paragraph starts at (40, 78) and is 144px tall, the image goes at the top of column 2 on page 3..." That spreadsheet is the VDT.

The Virtual Document Tree is the central data structure of Postext — a mutable, in-place tree representing every page, column, block, and line, each carrying a precise bounding box. Once the layout pipeline converges, the VDT *is* the answer. You can query "what is on page 72, column 2?" without rendering a single pixel.

### Why Mutable

This is the same approach used in game engine render pipelines, where a shared mutable world state is updated by successive systems on a tight loop. And for the same reason.

Immutable trees (like React's virtual DOM) allocate new objects on every change. That is fine for a UI with a few hundred components. But in a convergence loop that may run up to 5 iterations across 7 passes, potentially touching thousands of blocks, allocation pressure and garbage collection pauses become very real. The VDT uses in-place mutation with a `dirty` flag pattern instead: passes mark nodes dirty, and subsequent passes know exactly which nodes to re-examine. The engine remembers what changed so it does not redo valid work.

### Structure

> **Figure: Virtual Document Tree structure**
> Hierarchical VDT: document contains pages, each page contains columns, each column contains blocks (heading, paragraph, resource), each text block contains measured lines. Every node carries a bounding box, a dirty flag, and page/column indices.
>
> *Every node carries bbox, dirty flag, and page/column indices.*

### Type Definitions

The shapes below are **simplified for exposition** — the real definitions in `packages/postext/src/vdt.ts` carry many more rendering-oriented fields (font strings, colours, list bullets, math renders, design slots). The structure is what matters here:

```typescript
// Simplified — see packages/postext/src/vdt.ts for the full definitions

// The root of the Virtual Document Tree
interface VDTDocument {
  pages: VDTPage[];
  blocks: VDTBlock[];         // flat view of the same block objects
  config: ResolvedConfig;     // every sub-config resolved to non-optional
  baselineGrid: number;       // baseline increment in px (e.g. 24 for 16px/1.5)
  converged: boolean;
  iterationCount: number;
  metadata: DocumentMetadata;
}

// A physical page
interface VDTPage {
  index: number;
  width: number;
  height: number;
  columns: VDTColumn[];
  header?: VDTDesignSlot;     // running header (design slot)
  footer?: VDTDesignSlot;     // running footer / page number
  floats?: VDTBlock[];        // resource bands floated to the top/bottom of this page
  pageNumberValue: number;
  pageLabel: string;          // rendered label ('iv', '7', 'A', …)
}

// A column within a page
interface VDTColumn {
  index: number;
  bbox: BoundingBox;          // position within the page
  blocks: VDTBlock[];
  availableHeight: number;    // remaining vertical space
  baselineOffset: number;     // current baseline y-position
  band?: number;              // column band (0 unless a span block split the page)
  kind?: 'text' | 'span';     // 'span' = full-width column holding a page-span block
}

// A content block (paragraph, heading, resource, etc.)
type VDTBlockType =
  | 'paragraph' | 'heading' | 'resource' | 'blockquote'
  | 'listItem' | 'footnoteRef' | 'mathDisplay';

interface VDTBlock {
  id: string;
  type: VDTBlockType;
  bbox: BoundingBox;
  lines: VDTLine[];           // for text blocks (populated by Pass 2)
  resourceBlock?: ResolvedResourceBlock; // for resource blocks
  pageIndex: number;
  columnIndex: number;
  dirty: boolean;             // needs re-layout
  snappedToGrid: boolean;     // baseline aligned to grid
}

// A measured line of text
interface VDTLine {
  text: string;
  bbox: BoundingBox;
  baseline: number;             // y-position of the text baseline
  hyphenated: boolean;          // line ends with a hyphen
  segments?: VDTLineSegment[];  // word/space/math runs for justified rendering
  isLastLine?: boolean;         // last line of its paragraph
  justifiedSpaceRatio?: number; // applied space width ÷ normal space width
  sourceStart?: number;         // markdown source map (char offsets)
  sourceEnd?: number;
  plainStart?: number;          // plain-text source map
  plainEnd?: number;
}

// A measured, placement-ready resource embed (bitmap / svg / table)
interface ResolvedResourceBlock {
  resource: Resource;
  kind: 'bitmap' | 'svg' | 'table';
  number: string;             // computed number, e.g. "1.7"
  captionPrefix: string;      // e.g. "Figure"
  bodyRect: BoundingBox;      // the image / table area
  fileId?: string;            // out-of-band binary (bitmap / svg)
  captionLines: VDTLine[];    // measured caption, prefix + number included
  table?: VDTResourceTableLayout; // cell geometry for table resources
}

// Bounding box — all values in px, relative to page origin
interface BoundingBox {
  x: number;
  y: number;
  width: number;
  height: number;
}
```

A few of these fields deserve a note:

- **`isLastLine`** drives justified rendering: final lines render ragged even when the paragraph is justified — except *overfull* final lines, whose inter-word spaces compress to fit the measure (TeX glue-setting semantics).
- **`sourceStart`/`sourceEnd` and `plainStart`/`plainEnd`** are source maps from each line back to the original markdown and to the block's plain text — they power cursor and selection sync in editor integrations.
- **`VDTResourceTableLayout`** (with its `VDTResourceTableCell` entries) carries the full laid-out geometry of a table resource: column x-edges, row y-edges, and per-cell rects with their measured content lines, so every backend draws the same table.
- **`computePageTextExtent(page)`** is a small public helper that returns the vertical extent actually covered by text on a page (including float captions). Debug overlays use it so the baseline-grid lines span only real text, not empty page bottom.

### Dirty Tracking

Dirty tracking is how the engine avoids redoing work it has already done correctly. When a pass moves or resizes a block, it sets `dirty = true` on that block and on every downstream block in the same column — because their positions all depend on the changed block. The convergence loop can then skip unchanged subtrees entirely.

Here is a concrete example. Pass 5 inserts a hyphen into a paragraph on page 12, causing it to lose one line of height. That paragraph gets marked dirty. So do all blocks below it in the same column — they all need to shift up by one line. But the blocks on page 11 and earlier? Untouched. Passes skip them completely on the next iteration.

The `dirty` flag doubles as the convergence signal: if no blocks are dirty after passes 5–7, the layout has converged and the engine stops iterating. Done.

## Layout Pipeline

Seven passes, each with one job. That is the entire layout pipeline.

The design borrows from game engine render pipelines — shadow pass, lighting pass, post-processing pass — where each system reads and mutates a shared world state and trusts that previous systems did their part. This makes individual passes easy to understand, test, and optimize in isolation. You can benchmark Pass 5 without thinking about Pass 3.

The key difference from a game engine is that a game renders each frame once and moves on. Postext cannot. Typographic decisions are deeply interdependent — fixing a widow might change column heights, which affects balancing, which might create a new orphan — so the pipeline may need to loop. Passes 3–7 run inside a convergence loop, iterating up to 5 times until the layout settles into a stable result.

### Pass 1: Content Structuring

- **Input:** Raw `PostextContent`
- **Action:** Parse markdown into an AST, resolve resource and note references against `resources[]` and `notes[]` by ID, create initial `VDTBlock` nodes
- **Output:** Flat `VDTBlock[]` (typed and content-filled, but with no page or column assignment)
- **Runs once** (not part of the convergence loop)

### Pass 2: Text Measurement

- **Input:** `VDTBlock[]` with text content
- **Action:** For each text block, measure lines at the target column width through the dedicated **measure module** (`packages/postext/src/measure/`), which layers hyphenation, justification, rich inline runs, and Knuth-Plass line breaking on top of Pretext. Store measured `VDTLine[]` and total height in each block
- **Key detail:** Measurement is cached. `cachedMeasureBlock` / `cachedMeasureRichBlock` (in `measure/cache.ts`) key on the text, fonts, width, and every layout-affecting option, so re-measuring an unchanged paragraph is a map lookup
- **Output:** Every text block has precise pixel dimensions
- **Re-runs when:** Column widths change or text content changes (e.g., hyphenation inserted)

The module splits cleanly by responsibility: `plain.ts` measures plain runs, `rich.ts` measures mixed bold/italic/math spans, `font.ts` builds font strings and owns cache lifecycle, and `canvas.ts` wraps the raw canvas text-width primitives. One lifecycle detail matters in practice: `clearMeasurementCache()` clears both Pretext's internal caches *and* the engine's own text-width cache, so glyph widths measured against a fallback font are discarded once the real fonts finish loading.

Underneath, this is where Pretext earns its keep. The `prepare()` call is the expensive part — it analyzes the text using the canvas font engine and caches the result. But the `layout()` call? Pure arithmetic, nearly free. That split is everything. Once text is prepared, the engine can re-layout at different widths — trying column configurations, testing what happens if a paragraph gains a hyphen — all with negligible cost. Prepare once, layout as many times as you need.

```typescript
// Simplified: how the measure module uses pretext internally
const prepared = prepare(paragraphText, '16px/1.5 Inter');
const { height } = layout(prepared, columnWidth, 24); // 24px line-height
// => "This paragraph is 168px tall at 320px column width — that's 7 lines."
```

### Pass 3: Page and Column Placement

- **Input:** Measured blocks
- **Action:** Flow blocks into pages and columns sequentially. Create `VDTPage` and `VDTColumn` nodes. Track `availableHeight` per column. When a block does not fit, advance to the next column or page
- **Strategy:** Greedy first-fit placement. Column and page breaks follow the simplest valid assignment
- **Output:** Every block has `pageIndex`, `columnIndex`, and `bbox` assigned

This is the moment the VDT becomes a real document. Before this pass, blocks are just a flat list with dimensions but no address. Pass 3 walks through them and assigns each one to a page and column, like pouring water into a grid of containers: fill column 1 until it overflows, spill into column 2, when the page is full start a new one.

Before any content blocks land, the pass reserves space for structural elements — running headers and footers (laid out as design slots from `config.header` / `config.footer`) and any float bands already pending for the freshly opened page. These reservations reduce the `availableHeight` of each column, so when content blocks start flowing in, the engine already knows exactly how much room is available.

**Column bands and span columns.** `page.columns` is a flat array in reading order, but a page is not always a single row of columns. A page-span inline block — today a `:::callout` with `span: 'page'` in a multi-column layout — cuts the page into stacked *bands*: the text columns of the current band are closed at the cut line (their height clamped, `availableHeight` zero), the block gets its own full-width column with `kind: 'span'`, and a fresh band of text columns (`band + 1`, same x and width, same bottom as the band it replaces) is appended below it. Columns are only ever appended, so `columnIndex` keeps addressing `page.columns[i]`, and renderers need no special drawing: each column clips to its own bbox (widened by `columnClipRect` — 2pt for glyph ink plus however far the column's design overlays, such as a heading tab or a callout badge, hang past it — the same rectangle in the canvas and PDF backends), and the column rule is drawn per band between adjacent text columns. Column balancing ignores span columns and zero-height bands. A span block cuts directly where the band is level (page top, right after an opener heading, another span block or a top float band); arriving in an uneven band it proposes a *band cap* instead (`packages/postext/src/pipeline/bandCaps.ts`) — the columns of the band that opens with a given content block are shortened to `ceil(Σ used / N / grid)` lines — and `buildDocument` re-runs the placement pass with the cap (growing it one line at a time when the capped band overflows, at most a few extra passes, then falling back to the next page), so the text fills the shortened columns under every placement rule, ends level at the cut, and the closed columns keep whatever slack remains as `availableHeight` for balancing to absorb. The same mechanism levels the *closing* band of a chapter and of the document (`headings.balancing.trailing`): a chapter opener, a `:::part`, a chapter-closing `placement: 'fixed'` callout or the end of the document reached with the current band's columns uneven proposes a `kind: 'trailing'` cap, keyed by the boundary block; because a cap is keyed by the block that opens its band — and that block moves whenever an earlier page absorbs extra balancing lines — trailing caps are resolved *after* column balancing has settled, with the balancing hints frozen, and a short polish round then lets the levers fill what the cut left short. `placement: 'fixed'` callouts leave the flow: the box is anchored to the page content area / trim box / bleed box, the text columns it covers give up that zone (cut from the bottom or the top like a float band, moving to the next page on a conflict), and frame plus children go to `page.floats`.

### Pass 4: Resource Placement

- **Input:** VDT with blocks placed in columns
- **Action:** Float each referenced resource into the first free slot after its first reference — the bottom of the referencing column, the top / bottom of the next empty column, or a band of the next page (`packages/postext/src/pipeline/floatPlacement.ts` plans the floats, `pipeline/floatSlots.ts` enumerates and measures the slots; the build pipeline reserves the bands)
- **Placement resolution:** Per resource, the engine resolves `resource.placement` → the type's `resourceType.defaultPlacement` → the built-in default `{ position: 'auto', span: 'column' }`

| Placement field | Behavior |
| --- | --- |
| `position: 'auto'` | The resource takes the first free slot after its reference, top or bottom — the default |
| `position: 'top'` | Only top slots: a band at the top of the next empty column or page, pushing the column content below it |
| `position: 'bottom'` | Only bottom slots: a band at the bottom of a column or page, shortening the column above it |
| `position: 'here'` | Opts out of floating: the resource embeds inline at its `::resource` directive, exactly where it appears in the flow |
| `span: 'column'` | The band occupies a single column (on a freshly opened page the engine picks the column with the most room left) |
| `span: 'page'` | The band spans the full content width across all columns, breaking the column flow — full-width bands are reserved first, so column-span floats nest inside the remaining space |

- **Deferred placement:** A float that fits in no slot of the current page waits for the next page the flow opens — it is never shrunk or split — and, at a chapter boundary, is drained onto pages opened ahead of the boundary
- **Output:** Resources positioned in page bands (`page.floats`), with the affected columns' heights reduced so text flows around the bands

Resource placement is where things get interesting, because floats do not just occupy space — they reshape the space around them. Once the block holding a reference has landed, the pending float is offered the free slots of the current page after it — the bottom of that column, then the top and bottom of the next empty column (a page-span float: the page bottom, when every column still has room); what fits nowhere waits for the next page, where pending floats claim their bands before any text flows in. The columns shrink to fit between the bands, the text flows through the narrowed columns uninterrupted, and the reader sees the figure near (but not exactly at) the point where it is mentioned. This is standard practice in professional typesetting; books do it constantly.

**Placement rules.** Beyond the placement dispatch, floats follow strict editorial constraints:

- **After-reference rule.** A float lands in the first free slot *after* its first reference in the text, never before it. The reader encounters the reference first, then sees the resource. If a float does not fit, it defers forward to a later slot or page, never backward.
- **Reference order within a sequence.** Pending floats are offered each slot in first-reference order, and one that fits nowhere holds up the ones behind it in its numbering sequence: table 3 never lands after table 4, figure 12 never before figure 11. The sequences do not hold each other up — a waiting table lets a later figure through. So that a long table need not wait for a fresh page, one offered the head of an empty column is cut to that column and continues in the next slot (the column beside it, or the next page's bands), with its header rows repeated.
- **Chapter barrier.** Floats never escape their chapter. At a chapter opener (a heading level with `breakBefore` or `span: 'page'`), a `:::part`, a callout style with `floatBarrier: true` and the end of the document, every pending float is placed first — in the page's free slots, then on pages opened ahead of the boundary (each force-placing at least one float) — before the boundary's own page break. Before opening such a page, a figure or table still pending is offered the current page's free slots once more whatever its `position` — a head-of-page float cited on a chapter's closing page takes the foot of that page, under the balanced columns, rather than a page of its own (floated callouts keep their placement). A `:::pagebreak` sends pending floats to the page that follows it, after any parity padding.
- **Minimum text room.** On a freshly opened page a band is only reserved if at least 3 body lines of text still fit in the affected columns — with one exception: an oversized float may be force-placed into a band that is still all-text, so a dominating figure cannot stall the queue forever. A slot on the current page must fit in the column's remaining height; the 3-line rule applies there only next to another float band.
- **Breathing room.** One body-line-height gap separates a band from the text beside it.
- **Baseline-grid alignment.** Top bands are rounded *up* to a multiple of the baseline grid (growing the gap below the float), so every displaced line still lands on the grid. Bottom floats are anchored so the caption's last baseline sits on the grid — the caption shares its baseline with the last text line of the neighbouring columns, and pages end at the same height across columns and facing pages.

**Typed first-reference numbering.** Resources are *not* numbered in the markdown. `pipeline/resourceNumbering.ts` assigns each resource its number the first time it is referenced in reading order, using the resource's `ResourceType`: the `numberingTemplate` combines the per-type counter `{n}` with the heading counters `{h1}`..`{h6}` in effect at the reference (e.g. `'{h1}.{n}'` → "1.7"), `resetOn` controls when the counter restarts (`'never'` or at any heading level), and `counterFormat` picks decimal, roman, or alphabetic numerals. The built-in *Figure* and *Table* types come from `defaultResourceTypes(locale)`, localized to the document's language. Because numbering follows first-reference order, inserting a new figure mid-document renumbers everything after it automatically — no source edits required.

### Pass 5: Typographic Refinement

This is the pass that separates a layout engine from a text dumper. It enforces the editorial quality rules that professional typesetters have applied by hand for centuries — and that naive text rendering completely ignores.

Pass 5 operates at two levels: **penalty-based line breaking** inside each paragraph, and **structural keep-together enforcement** between blocks. They work together, but they are distinct mechanisms.

#### Penalty-based widow/orphan/runt avoidance

**Widows and orphans** are the most visible signs of amateur typesetting:

- A **widow** is a single line of a paragraph left alone at the bottom of a column. The paragraph
  continues in the next column, but that lone line looks stranded (as if the column ended
  prematurely).
- An **orphan** is a single line of a paragraph stranded at the top of a column. The bulk of the
  paragraph is in the previous column, but one line spilled over (it looks disconnected from its
  context).
- A **runt** is a paragraph whose last line is a single short word (or two) — visually far too short to feel like a proper line of text. Less structurally severe than a widow, but just as jarring to a careful reader.

All three are handled by injecting **demerits** into the Knuth-Plass line-breaking algorithm. Rather than laying out a paragraph and then trying to repair a bad break after the fact, the engine teaches the line breaker that certain break sets are more expensive than others. The algorithm then picks the globally optimal break set that naturally avoids widows, orphans, and runts whenever possible.

Concretely, for every candidate break node in a paragraph:

- If choosing this break would leave fewer than `orphanMinLines` lines at the top of the next column, add `orphanPenalty` (default 1000) to the node's demerits.
- If choosing this break would leave fewer than `widowMinLines` lines at the bottom of the current column, add `widowPenalty` (default 1000).
- If the final line produced from this break would have a content width below `runtMinCharacters × normalSpaceWidth` (default `runtMinCharacters` 20 — roughly twenty characters' worth of space-width content), inject `runtPenalty` (default 1000) as equivalent badness into the squared demerit formula — so it competes on the same scale as line badness (which saturates at 10000) rather than being dwarfed by it.

These penalties sit alongside the usual demerits — badness (squared adjustment ratio), hyphenation cost, and fitness-class mismatch — in a single global optimisation. One rendering detail completes the picture: in justified paragraphs, final lines render ragged — except *overfull* final lines, whose inter-word spaces compress to fit the measure per TeX glue-setting semantics, applied identically in the canvas, HTML, and PDF backends. The algorithm is free to accept one of them if the alternative is worse (a paragraph with no legal break that satisfies every rule), but it will almost always find a break set that avoids them. List items opt into the same protection via `avoidOrphansInLists`, `avoidWidowsInLists`, `avoidRuntsInLists` (all `true` by default).

A fourth soft pressure, `slackWeight`, weights a squared "unused column space" cost so the algorithm prefers break sets that fill columns tightly. Together these demerits make Pass 5 a *line-breaking* refinement: most widow/orphan/runt cases are resolved inside the Knuth-Plass solver, not by letter-spacing tweaks after the fact.

All of this is tunable on `BodyTextConfig` — see [Configuration → Orphans, widows, runts, and keep-together rules](/en/docs/configuration#orphans-widows-runts-and-keep-together-rules). Setting any `*Penalty` to `0` effectively disables that rule.

#### Structural keep-together rules

Some groupings are bigger than a single paragraph — they span adjacent blocks and cannot be addressed by line-breaking alone. Pass 5 enforces these at the block-placement level, moving entire groups forward when they would otherwise split across a column or page break:

- **Heading with its first paragraph.** A heading must never appear at the bottom of a column if the paragraph it introduces would start in the next column. Enforced by `headings.keepWithNext` (default `true`): if there is not room for the heading *plus* the body's widow minimum (`bodyText.widowMinLines`, default `2`) of the next block — or just one line when `avoidWidows` is off — the heading is pushed forward to travel with its text.
- **Consecutive headings.** When multiple headings appear in sequence (e.g., an h2 followed by an h3 followed by a paragraph), the entire group must stay together. None of the headings can be left stranded at the bottom of a column without the content they introduce.
- **Colon-introduced lists.** When a paragraph ends with a colon that directly introduces a list, the colon-bearing line must stay with the start of the list. Enforced by `bodyText.keepColonWithList` (default `true`): if placing the paragraph would leave no room for the first list item, the colon-bearing last line (or the whole paragraph, if it is one line) moves forward together with the list. Whenever this rule has to push the whole paragraph and a run of headings immediately precedes it in the column, those headings are pulled forward too so `keepWithNext` is not silently violated; the only exception is when the column contains just the heading(s) that a previous iteration already moved forward, in which case the engine keeps the paragraph with the heading and accepts the softer colon/list separation to avoid looping.
- **Figure with its caption.** A figure and its caption are an inseparable unit. They always move together.

When a keep-together violation is detected, the engine pushes the entire group to the next column or page. The vacated space is handled by the normal column-filling mechanism (the line breaker has already chosen a break set that fits; if the resulting column is a little short, Pass 7 redistributes vertical space around grid-breaking elements to keep the baseline grid honest).

#### Output

Blocks whose measurements or placements changed are marked `dirty` for the next iteration of the convergence loop. In practice, because the heavy lifting is done inside Knuth-Plass rather than by post-hoc adjustments, most documents stabilize quickly — the line breaker picks a good set of breaks the first time and subsequent iterations only have to deal with downstream effects of block movement and column balancing.

These corrections are invisible when done well (a reader should never notice them). But their *absence* is immediately obvious to anyone who reads carefully: that awkward single line at the top of a column, those uneven gaps where the engine gave up trying to make text fit. Professional publishers have entire style guides about preventing exactly these problems. Postext automates them.

### Pass 6: Column Balancing

- **Input:** VDT with refined typography
- **Action:** Equalize column heights on each page by moving blocks between columns to minimize the height difference (the `ColumnConfig.balancing` flag that would gate this is one of the declared-but-unwired legacy options)
- **Constraint:** Must not violate widow/orphan rules established in Pass 5
- **Output:** Blocks may have moved between columns, marked `dirty`

You notice unbalanced columns immediately, especially on the last page of a chapter. A full left column and a nearly empty right column looks unfinished — like the layout gave up halfway through. Balancing redistributes content so both columns land at roughly the same height, giving the spread a polished, intentional appearance.

The algorithm computes the total content height for all blocks on a page, divides by the number of columns to find the target height, and searches for the best column-break point that gets each column closest to that target. But it is not a simple split. This is a constraint satisfaction problem: the algorithm must respect `keepTogether` rules (a heading must stay with its first paragraph), honor minimum line counts, and — crucially — not undo the widow and orphan fixes that Pass 5 just worked so hard to establish.

### Pass 7: Vertical Rhythm Alignment

- **Input:** VDT with balanced columns
- **Action:** Snap baselines to the baseline grid by distributing spacing adjustments around headings, images, and other grid-breaking elements
- **Output:** Adjusted spacing values; baselines aligned across columns
- **See:** [Vertical Rhythm System](#vertical-rhythm-system) for the full algorithm

### Convergence Loop

> **Figure: Convergence loop**
> Pass 1 parses, Pass 2 measures, then passes 3 through 7 run inside a convergence loop. If any block remains dirty and the iteration count is under five, the loop re-runs from Pass 3.
>
> *The engine loops back to Pass 3 until no dirty blocks remain (max 5 iterations).*

Think of the convergence loop as the engine arguing with itself. Pass 5 picks a break set that avoids a widow inside paragraph A — but doing so shortens paragraph A by a line, which leaves a gap at the bottom of column 2. Pass 6 re-balances the columns to compensate, which pushes a heading to a new column, which triggers `keepWithNext` and forces the heading back to the next column entirely. Pass 7 adjusts vertical rhythm, which might just create a new runt where the heading used to sit. So the engine loops back to Pass 3, re-places blocks with the updated measurements, and runs through the whole sequence again. Each iteration resolves more problems than it creates — until eventually, nothing is dirty anymore.

Because most widow/orphan/runt cases are resolved *inside* the Knuth-Plass solver in a single pass of line breaking, typical documents now converge in 1–2 iterations. The loop is still needed when block-level events (a heading pushed forward by `keepWithNext`, a figure deferred by placement, or column balancing equalising heights) shift the column boundaries that Pass 5 measured against. When that happens, Pass 3 re-places, Pass 5 re-breaks with the new constraints, and the loop settles.

After passes 5–7 complete, the engine checks whether any blocks are marked `dirty`. If dirty blocks exist and the iteration count is below 5, the pipeline re-runs from **Pass 3**.

**Convergence criteria:**
- No dirty blocks after passes 5–7, **or**
- Maximum of 5 iterations reached (accept the best result so far)

The engine tracks a **typographic violation score** at each iteration — a weighted sum of remaining problems: widows, orphans, unbalanced columns, baseline grid misalignment. Each type of violation carries a weight reflecting its visual severity (a widow is far more noticeable than a 2px grid drift). If the 5-iteration limit is reached without full convergence, the engine picks the iteration that produced the lowest violation score. Not necessarily the last one — later iterations can sometimes overcorrect, fixing one problem while creating another.

**Balancing converges per segment.** The pages between explicit breaks — a chapter opener, a `:::pagebreak` — are laid out independently of one another: nothing flows across such a break, so a balancing lever inside one run of pages can never move a line of another. The column-balancing loop therefore judges every such *segment* on its own. A pass still places the whole document, but each segment keeps or rejects its share of the levers by its own gap score, blacklists its own cascades, plateaus on its own and spends its own budget of attempts; a segment whose retry regressed gets its pages back from the best pass it had while the others move on. A book of thirty chapters thus balances exactly as its chapters would one by one — the whole-book PDF and the single-chapter PDF of the same chapter are identical — instead of one cascade anywhere costing every page of the book a pass.

The 5-iteration cap is a pragmatic safety valve: perfection is the enemy of done. Some pathological cases — a page where every paragraph is exactly the wrong length to create widows no matter how you balance the columns — will never fully converge. The engine accepts "best effort" and moves on.

## Vertical Rhythm System

Hold a well-typeset book up to the light. The lines on the left page align with the lines on the right. The baseline of line 5 in column 1 sits at exactly the same vertical position as the baseline of line 5 in column 2. That is vertical rhythm, and it is one of the first things a trained eye checks when evaluating typographic quality. It is also one of the key differentiators of Postext.

When both columns contain only body text at the same size, alignment is trivial — every line is the same height, so baselines naturally match. The challenge arrives the moment one column contains a heading with a larger font size, an image with an arbitrary pixel height, or extra spacing around a block quote. These elements "break" the grid: the content below them shifts by an amount that is not a multiple of the baseline increment, and suddenly the baselines in that column fall out of sync with the adjacent column. The visual harmony is gone.

The goal is to get it back: baselines of body text in adjacent columns must align horizontally, even when headings, images, or other non-standard-height elements appear in one column but not the other.

### Baseline Grid

Everything anchors to a single number. The document defines a `baselineGrid` value derived from the body text's line height — for example, body text set at `16px` with a `line-height` of `1.5` produces a baseline grid of `24px`. Every body text baseline should fall on a multiple of this value. That is the contract.

### Grid-Breaking Elements

Some elements inevitably break the grid because their height is not a multiple of `baselineGrid`:

- **Headings** (larger font size, different line-height)
- **Images** (arbitrary pixel height)
- **Tables** (variable height)
- **Block quotes** (may use different font size or padding)
- **Footnote separators** (fixed height rule)

### Spacing Adjustment Algorithm

> **Figure: Vertical rhythm alignment**
> Column 1 contains a heading that breaks the baseline grid by 12 pixels. The engine adds 12 pixels of spacing after the heading so the next body line falls back on the grid. Column 2 remains aligned throughout.
>
> *Spacing is adjusted after grid-breaking elements so baselines across columns stay in sync.*

After adjusting spacing in each column independently, the engine verifies **cross-column alignment**: baselines at the same vertical position across columns should match. If they diverge — because different columns have different grid-breaking elements — a second alignment pass adjusts gaps in both columns to find a common rhythm.

Here is a concrete example. Column 1 has a 36px heading (1.5x the 24px grid). Column 2 has no heading. After the heading, column 1 has drifted 12px off the grid. The algorithm adds 12px of extra space after the heading — bumping "space after heading" from 16px to 28px. Now the next body text line in column 1 falls on a grid line again, and its baseline matches the corresponding line in column 2. Harmony restored.

**Edge cases:**
- A column with more grid-breaking elements than adjustable gaps accepts partial alignment (the
  algorithm does its best but cannot guarantee perfect grid alignment if there are too many
  disruptions and too few places to absorb the error)
- An image taller than the column spans columns or pages (handled separately in Pass 4)
- When the adjustment required would create visibly awkward spacing (e.g., 40px of space after a
  heading when the norm is 16px), the algorithm distributes the error across multiple gaps rather
  than concentrating it in one place

## Backend Interface

There is exactly one source of truth for text measurement — the measure module over Pretext's canvas font metrics — and every backend renders from the same converged VDT it produced.

That is a deliberate choice, and it exists for a critical reason: **the way you measure text must exactly match the way you render it.** Imagine measurement used canvas font metrics, but a rendering backend used a PDF library with slightly different kerning tables. The layout would not match the output. Lines that the engine measured as fitting in 320px might overflow or underflow when rendered. Every pixel of drift is a lie. By measuring once and rendering everywhere from the resulting geometry, the backends cannot disagree: line breaks, column heights, and resource placement are frozen into the VDT before any backend runs.

This is why the PDF backend, for example, does not re-measure text: it consumes an already-converged VDT and translates its pixel coordinates into PDF points. The canvas metrics are the source of truth; PDF is a transport. Users of `renderToPdf` (from the `postext-pdf` package) pass the same VDT they would hand to `renderToCanvas` or `renderToHtml`, and the three outputs are guaranteed to agree.

### API Surface

The backends are plain functions over `VDTDocument`, not a class hierarchy. There is no `PostextBackend` interface — just three rendering entry points and the helpers each output target needs:

```typescript
// Canvas (from 'postext')
renderToCanvas(doc): HTMLCanvasElement[];               // one canvas per page
renderPage(page, doc): HTMLCanvasElement;               // a single page
renderPageToCanvas(page, doc, canvas, options?): void;  // draw into an existing canvas

// Canvas resource-image registry — decoded images keyed by Resource fileId
registerResourceImage(fileId, image): void;
unregisterResourceImage(fileId): void;
clearResourceImages(): void;

// HTML (from 'postext')
renderToHtml(doc, options?): string;
renderToHtmlIndexed(doc, options?): HtmlRenderIndex;    // per-page / per-block breakdown

// PDF (from 'postext-pdf')
renderToPdf(doc, options): Promise<Uint8Array>;
```

Because binary resource payloads live out-of-band, each backend resolves `fileId`s its own way. The canvas backend keeps a registry of decoded `CanvasImageSource`s — the host app registers each bitmap or SVG once with `registerResourceImage(fileId, image)` and the renderer looks it up at draw time. The HTML backend takes a `resourceImageUrl(fileId)` resolver in its options and emits `<img>` tags pointing at whatever URLs (object URLs, data URIs, CDN paths) the host returns. The PDF backend receives a `resourceBytes` provider and embeds the actual bytes. Table resources need none of this — their model is inline, and every backend draws the cells from the VDT's table geometry.

`renderToHtmlIndexed` deserves a note: besides the full HTML string, it returns a per-page, per-block breakdown (`HtmlRenderIndex`) so callers can diff against a previous render and patch only the DOM subtrees whose HTML actually changed — the live-preview path in the sandbox.

### Backends

| Backend | Measurement | Rendering | Status |
| --- | --- | --- | --- |
| **Canvas** | Pretext (canvas font metrics) | Bitmap drawing on an `HTMLCanvasElement` (`renderToCanvas`, `renderPage`, `renderPageToCanvas`) | **Shipping** |
| **HTML** | Pretext (same metrics as canvas) | Absolutely-positioned DOM nodes with editorial CSS (`renderToHtml`, `renderToHtmlIndexed`) | **Shipping** |
| **PDF** | Consumes the VDT already measured with Pretext | PDF page construction via pdf-lib with per-weight font embedding (`renderToPdf` in `postext-pdf`) | **Shipping** |
| **Server-side** | Pretext + node-canvas | Headless rendering for SSR / batch generation | Future |

All three shipping backends consume the same `VDTDocument`. The split between `postext` (which exports the canvas and HTML backends) and `postext-pdf` (which exports the PDF backend) is purely about dependencies: the PDF path pulls in `pdf-lib` and `@pdf-lib/fontkit`, and most web integrations do not need them. Install `postext-pdf` only when you actually want to emit PDF bytes.

**Browser-only constraint:** In Phase 1, all layout computation happens client-side in the browser. The pipeline can run either on the main thread (`buildDocument`) or inside a dedicated Web Worker (`createLayoutWorker` from `postext/worker`) — the worker path is the recommended integration for UI-driven apps because it keeps measurement and the convergence loop off the main thread, supports last-wins cancellation via `AbortSignal`, and owns its own measurement cache and math raster cache so successive rebuilds stay cheap. See [Configuration → Running layout in a Web Worker](/en/docs/configuration#running-layout-in-a-web-worker) for the full integration pattern. Server-side rendering remains a deliberate scope decision for later — nail the browser experience first, expand to other targets later.

## Performance Strategy

The difference between a sluggish tool and a magical one is about 10x. A 500ms layout means the user sees a visible stutter every time they resize the window. A 50ms layout feels instantaneous — like the document was always there. That factor cannot be patched in later. It has to be designed in from day one.

Consider what the engine is up against: thousands of text blocks across hundreds of pages, with the entire layout potentially recomputed on every viewport resize. This is the same class of problem that game engines face — processing thousands of objects (geometry, physics, lighting, AI) 60 times per second. They solve it with a pipeline architecture (multiple passes over shared mutable state, each pass doing one thing fast) and aggressive avoidance of unnecessary work (culling, dirty flags, spatial partitioning). Postext borrows every one of these ideas.

### Principles

1. **In-memory computation.** The entire VDT fits in memory. No DOM reads during layout. The DOM
   is only touched at the very end, during rendering.

2. **Dirty tracking.** Blocks carry a `dirty` flag. Passes skip clean subtrees. The convergence
   loop only re-runs from the earliest dirty point.

3. **Bounded convergence.** Maximum 5 iterations is a hard guarantee. Worst-case performance
   is predictable and measurable.

4. **Pretext speed.** Text measurement at 300–600x DOM speed means the engine can afford to
   re-measure text speculatively (trying different column widths, hyphenation points, tracking
   adjustments) without blocking the main thread.

5. **Layered measurement caching.** The measure module (`packages/postext/src/measure/`) keeps
   an explicit measurement cache keyed on text, fonts, width, and every layout-affecting option,
   on top of Pretext's own `prepare()` cache and a raw text-width cache. Re-measuring an
   unchanged paragraph costs a map lookup. `clearMeasurementCache()` flushes all of it — including
   the text-width cache, so glyph widths are correct after fonts finish loading.

6. **Hot-path line breaking.** The Knuth-Plass active-node handling was rewritten for speed:
   the active set is compacted in place as nodes retire, and candidates are deduplicated
   per (line, fitness class) so only the lowest-demerit node per key survives. The algorithmic
   results are identical — same break sets, just computed faster.

7. **Off-main-thread builds.** The `postext/worker` entry point runs the entire pipeline
   inside a dedicated Web Worker. The main thread posts `{ content, config }` and an
   `AbortSignal`; the worker registers fonts (transferred as `ArrayBuffer`s), runs the
   convergence loop, and posts back the finished `VDTDocument`. A newer `build()` call
   cancels the previous one cooperatively — the worker checks a per-block cancellation hook
   inside `buildDocument` and throws `BuildCancelledError`, so a user typing into an editor
   never waits on a superseded layout. The worker also maintains its own persistent
   measurement cache and a content-keyed math raster cache so that structured-cloned
   `MathRender` objects survive across rebuilds without re-rasterising.

8. **Flat numeric fields.** Bounding boxes are stored as flat `x, y, width, height` fields on
   each node, not as nested objects. This avoids pointer chasing and is more cache-friendly.

9. **Dual-access VDT.** The tree (`pages > columns > blocks`) gives hierarchical access for
   passes that need to work page-by-page or column-by-column (like Pass 6, column balancing).
   A parallel flat `blocks[]` array gives O(1) indexed access for passes that need to iterate
   all blocks regardless of their location (like Pass 5, widow/orphan detection). Both views
   reference the same block objects (there is no duplication, just two ways to traverse the
   same data).

### Resize Handling

When the user resizes the viewport, the engine does not rebuild from scratch. It updates column widths in the VDT, marks all text blocks as dirty, and re-runs the pipeline from Pass 2. Page and column structures are reused.

This is the mutable VDT paying dividends. Instead of discarding the entire layout and starting from zero, the engine reuses as much work as possible. The Pretext `prepare()` results are still valid — they depend on font and text content, not width — so only the cheap `layout()` calls need to re-run. A 50-page document can be fully re-laid-out by re-measuring all text blocks (fast, because `prepare()` is cached) and re-running passes 3–7, without re-parsing the markdown or re-resolving references. The user drags the window edge and the layout follows in real time.

### Benchmarking from Day One

Every pass is independently benchmarkable in isolation, using vitest's `bench` API:

```typescript
// Example benchmark
bench('layout 50-page document', () => {
  const vdt = createVDT(fiftyPageContent, config);
  runPipeline(vdt);
}, { time: 100 }); // sample for 100ms and report ops/sec
```

A caveat for honesty: `{ time: 100 }` is how long vitest *samples* the benchmark, not a pass/fail threshold — benchmarks report numbers, they do not break the build. Regressions are caught by comparing those numbers across runs when a hot path changes (as was done for the Knuth-Plass active-node rewrite), not by an automated CI gate. Performance is a feature, not a hope — but the enforcement today is measurement and review, not a failing pipeline.

## Data Flow

> **Figure: Data flow through the engine**
> Content and config enter Pass 1 (parse) and Pass 2 (measure). Passes 3 through 7 run inside the convergence loop. Once converged, the VDT is handed to the backend, which renders HTML or PDF.
>
> *End-to-end data flow: parse, measure, converge, render.*

## Non-Goals

Each of these limits is a deliberate choice — the engine is complex enough on its own, and
taking on responsibilities that belong elsewhere would be the fastest way to never finish.

- **Server-side rendering.** All layout runs in the browser. The engine depends on canvas font metrics (via Pretext), which require a browser environment. A server-side backend using `node-canvas` may come later, but it is not part of the initial design. Browser first.
- **WYSIWYG editing.** Postext is a layout engine, not an editor. Content in, geometry out. Building an interactive editing surface — cursor management, selection, undo/redo, input handling — is an entirely separate problem. Postext can serve as the rendering backend for an editor, but it does not provide editing capabilities itself.
- **CSS column-count wrapper.** Postext replaces CSS multi-column layout; it does not wrap it. It computes precise positioned geometry from scratch, because the browser's column layout algorithm lacks control over resource placement, widow/orphan prevention, and cross-column typographic rules. Those are the whole point.
- **Responsive breakpoint management.** Postext computes layout at a given page size. The consumer decides when to re-layout (on viewport resize, on orientation change). Postext does not manage breakpoints, media queries, or responsive design decisions. That is your job.
- **Real-time collaborative editing.** Postext is a stateless layout computation — content in, geometry out — not a collaborative document system with conflict resolution, operational transforms, or multi-user awareness.
- **Font loading or management.** Postext assumes fonts are already loaded and available for measurement. Font loading, font fallback chains, and font subsetting are the consumer's responsibility. If a font is not loaded when Postext measures text, the measurements will use the browser's fallback font, and the layout will be wrong once the real font loads. Load your fonts first.

## Appendix: Relationship to Existing Types

Here is how the main types defined in `packages/postext/src/types.ts` map to the architecture described above:

| Type | Architectural Role |
| --- | --- |
| `PostextContent` | Entry point: the input to the engine (Pass 1) |
| `PostextConfig` | Controls all pipeline behavior across every pass |
| `Resource` | A typed bitmap/SVG/table resource. Becomes a `ResolvedResourceBlock` during measurement and either an inline `VDTBlock` of type `'resource'` (placement `'here'`) or a page-band float (Pass 4) |
| `ResourceType` | Drives typed numbering, caption prefixes, reference labels, and default float placement (Pass 1, Pass 4) |
| `ResourcePlacement` | Per-resource float override: `position` (`'top'` / `'bottom'` / `'here'`) and `span` (`'column'` / `'page'`), resolved in Pass 4 |
| `PostextNote` | Becomes a footnote, endnote, or margin note block in Pass 1 |
| `PostextResource` | **Deprecated.** The legacy content-model resource, retained only until the last renderer reference (`VDTBlock.resource`) migrates to the `Resource` model |
| `PlacementStrategy`, `ColumnConfig`, `TypographyConfig`, `ResourcePlacementConfig`, `ReferenceConfig`, `PostextSectionOverride` | **Legacy.** Declared but never wired into the pipeline; superseded by `bodyText`/`headings` (typography), `layout` (columns), and the `Resource` float model (placement) |

### Where the VDT Types Live

The VDT types (`VDTDocument`, `VDTPage`, `VDTColumn`, `VDTBlock`, `VDTLine`, `VDTLineSegment`, `ResolvedResourceBlock`, `VDTResourceTableLayout`, `BoundingBox`, and friends) live in `packages/postext/src/vdt.ts`, alongside the factory helpers (`createVDTDocument`, `createVDTPage`, `createVDTBlock`, …) and `computePageTextExtent`. There is no separate backend interface module — the rendering entry points described in [Backend Interface](#backend-interface) are exported directly from `postext` (canvas, HTML) and `postext-pdf` (PDF).
