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, 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.
#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
Here is the journey your content takes through the engine:
- 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
- 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
- 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
- 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
- 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.
// 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.
// 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.
#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 Configuration 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 | Pass 2, Pass 5 |
tableStyle | Cell typography, borders, header/body fills for kind: 'table' resources — see Configuration → Table style | Pass 4 |
captionStyle | Typography of resource captions: the numbered label and the description text — see 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 | Backends |
resourceTypes | Typed resource numbering: templates, counter formats, reset scopes, default float placement — see 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
#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:
// 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
}
// 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:
isLastLinedrives 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/sourceEndandplainStart/plainEndare 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 itsVDTResourceTableCellentries) 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[]andnotes[]by ID, create initialVDTBlocknodes - 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 measuredVDTLine[]and total height in each block - Key detail: Measurement is cached.
cachedMeasureBlock/cachedMeasureRichBlock(inmeasure/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.
// 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
VDTPageandVDTColumnnodes. TrackavailableHeightper 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, andbboxassigned
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.
#Pass 4: Resource Placement
- Input: VDT with blocks placed in columns
- Action: Float each referenced resource to a band at the top or bottom of a page near its first reference (
packages/postext/src/pipeline/floatPlacement.tsplans the floats; the build pipeline reserves the bands) - Placement resolution: Per resource, the engine resolves
resource.placement→ the type'sresourceType.defaultPlacement→ the built-in default{ position: 'top', span: 'column' }
| Placement field | Behavior |
|---|---|
position: 'top' | The resource floats to a band at the top of the page, pushing the column content below it |
position: 'bottom' | The resource floats to a band at the bottom of the page, shortening the columns 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 (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 does not fit on the current page defers to the next page — it is never shrunk or split
- 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. When a page opens, pending floats claim their bands first, and the columns shrink to fit between them. The text then flows through the narrowed columns uninterrupted; the reader sees the figure at the top or bottom of the page 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 on the next freshly opened page after its first reference in the text, never before. The reader encounters the reference first, then sees the resource. If a float does not fit, it defers forward to a later page, never backward.
- First-reference order. Floats are placed in the order their first references appear in the text. When one defers, the floats queued behind it wait too, so figures never reorder relative to each other.
- Minimum text room. 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.
- 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
orphanMinLineslines at the top of the next column, addorphanPenalty(default 1000) to the node's demerits. - If choosing this break would leave fewer than
widowMinLineslines at the bottom of the current column, addwidowPenalty(default 1000). - If the final line produced from this break would have a content width below
runtMinCharacters × normalSpaceWidth(defaultruntMinCharacters20 — roughly twenty characters' worth of space-width content), injectruntPenalty(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. 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(defaulttrue): if there is not room for the heading plus the body's widow minimum (bodyText.widowMinLines, default2) of the next block — or just one line whenavoidWidowsis 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(defaulttrue): 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 sokeepWithNextis 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.balancingflag 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 for the full algorithm
#Convergence Loop
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.
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
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:
// 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 fileIds its own way. The canvas backend keeps a registry of decoded CanvasImageSources — 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 |
| 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 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
-
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.
-
Dirty tracking. Blocks carry a
dirtyflag. Passes skip clean subtrees. The convergence loop only re-runs from the earliest dirty point. -
Bounded convergence. Maximum 5 iterations is a hard guarantee. Worst-case performance is predictable and measurable.
-
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.
-
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 ownprepare()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. -
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.
-
Off-main-thread builds. The
postext/workerentry point runs the entire pipeline inside a dedicated Web Worker. The main thread posts{ content, config }and anAbortSignal; the worker registers fonts (transferred asArrayBuffers), runs the convergence loop, and posts back the finishedVDTDocument. A newerbuild()call cancels the previous one cooperatively — the worker checks a per-block cancellation hook insidebuildDocumentand throwsBuildCancelledError, 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-clonedMathRenderobjects survive across rebuilds without re-rasterising. -
Flat numeric fields. Bounding boxes are stored as flat
x, y, width, heightfields on each node, not as nested objects. This avoids pointer chasing and is more cache-friendly. -
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 flatblocks[]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:
// Example benchmark
bench('layout 50-page document', () => {
const vdt = createVDT(fiftyPageContent, config);
runPipeline(vdt);
}, { time: 100 }); // sample for 100ms and report ops/secA 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
#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-canvasmay 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 are exported directly from postext (canvas, HTML) and postext-pdf (PDF).