Repository navigation
Architecture
Technical architecture of the Postext layout engine: its concepts, the document tree, rhythm, backends and performance
Generated from docs/architecture-en.mdx (updated 2026-10-16). Read it on postext.dev, where it is also available in Spanish, Catalan, Portuguese, French, German, Italian, Chinese, Japanese and Arabic. To change this page, edit the MDX and run pnpm wiki in apps/web.
This page explains how Postext works inside, and it is written for programmers. First, Postext measures every word without drawing anything on screen, which makes it very fast. Then it works out where every line, picture and column goes, and keeps that plan in memory. Last, it draws the finished pages as a web page, a canvas image or a PDF file. When you change a word, it lays the pages out again and reuses the measurements of the paragraphs that did not change. The next page explains, step by step, how the lines and pictures are placed.
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.
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.
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, a table or a video (Resource, with kind: 'bitmap' | 'svg' | 'table' | 'video'). 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 or a chapter-end note, written in the markdown as a [^id] marker and a [^id]: definition (see Footnotes). Margin notes, and the PostextNote list the content model accepts, are not implemented: the engine does not read notes. |
| 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, a runt last line, fitness-class mismatch). The algorithm picks the break set with the lowest total demerits. The split of a paragraph between two columns is chosen the same way, by the lowest sum of widow, orphan and slack costs. |
| 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 | Lines left empty at the bottom of a column when a paragraph is split there or moved on whole. A squared slack cost (weighted by slackWeight) is weighed against the widow and orphan penalties when the engine chooses the split. |
| Backend | A rendering target that consumes the finished VDT. Four are shipping today: canvas (bitmap preview), HTML (DOM-based screen reading), PDF (print-ready output via postext-pdf) and EPUB (postext-epub, the book as an e-book). All four render from the same measurement (the measure module over Pretext). |
| Placement Pass | One walk of the content, from the first block to the last, that builds a whole VDT: it parses, measures through the cache, places text, floats and boxes, and applies the break and keep rules. |
| Round | A re-run of the placement pass with hints from the previous one: a band cap, balancing levers, settled page labels or note numbers. Each kind of round has its own limit (see The Pass Driver). |
Figure: Postext system architecture Enriched markdown and PostextConfig enter the parser. The placement pass measures text through Pretext and fills the Virtual Document Tree; buildDocument runs the pass again with hints until the layout settles. A backend renders the finished VDT to HTML or PDF.
Parser → placement pass ↔ Pretext → VDT → backend → output.
Here is the journey your content takes through the engine:
- The parser reads your enriched markdown and turns it into a flat list of typed content blocks: content and structure, no positions yet
- A placement pass walks that list once and builds the VDT. It measures each block through Pretext at the width of the column the block is about to enter, places text, floats and boxes into pages and columns, and applies the break and keep rules as it goes
- The driver (
buildDocument) runs the pass again when a decision needs the result of a whole layout: a table of contents that prints page numbers, a page-span box that has to cut the columns above it level, a column that ended short. Each re-run takes hints from the pass before it, and each kind of re-run has its own limit - 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, a PDF document with embedded fonts, or an EPUB. The same VDT feeds all four; picking a backend is purely an output decision
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.
// 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, videos — referenced by id
notes?: PostextNote[]; // not read: footnotes are written as [^id] in the markdown
}
interface Resource {
id: string; // stable id, referenced by inline :refs
typeId: string; // ResourceType this resource belongs to ('figure', 'table', …)
kind: 'bitmap' | 'svg' | 'table' | 'video';
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 };
video?: ResourceVideo; // where it plays from; drawn on the page as its poster
placement?: ResourcePlacement; // optional per-resource float override
safeArea?: ResourceSafeArea; // bitmap / svg: { x, y, width, height } in fractions, the part always shown
}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 are written in the markdown: a [^id] marker where the note is cited and a [^id]: definition, a paragraph of its own anywhere in the chapter. The engine numbers the notes in the order they are first cited and sets each one at the foot of the column that holds the line citing it, under a short rule, or after the chapter's last block with footnotes.placement: 'chapterEnd' (see Footnotes and Configuration → Footnotes). The content model still accepts a notes array, but no stage of the pipeline reads it.
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 cited and what it says. The resources array owns the visual data — image dimensions, caption text, table cells. 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.
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.
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 | Measurement, breaks and keep rules, balancing |
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
|
Resource placement |
captionStyle |
Typography of resource captions: the numbered label and the description text — see Configuration → Caption style | Resource placement |
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 | Content structuring, resource placement |
Parsing is deliberately the simplest step in the pipeline. Markdown goes in and comes out as a flat list of typed content blocks (ContentBlock, in packages/postext/src/parse/): a heading, a paragraph with its inline spans, a list item, a directive. The result is memoized (parseMarkdownMemo), so the passes of one build parse once, and so does a later build of the same text. Resource references are resolved against the resources[] array by ID at the start of each placement pass. Footnote definitions (paragraphs that open with [^id]:) leave the flow there too and are kept by id for the markers that cite them; the notes[] array is not read.
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. A VDTBlock exists only once the placement pass has measured a block and given it a place.
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 buildDocument returns, the VDT is the answer. You can query "what is on page 72, column 2?" without rendering a single pixel.
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.
A placement pass fills the tree in place. It opens a page, appends columns to it, takes a block's height off the availableHeight of the column the block lands in, and closes the columns of a band where a page-span box cuts it. A book has thousands of blocks, and an immutable tree (like React's virtual DOM) would allocate a new object for every one of those steps. So the nodes are plain objects and the pass writes to them.
A finished pass is not patched. When buildDocument needs another attempt (a band cap, a balancing lever), it runs the pass again from the content and gets a new tree. The tree of the pass before is left as it was, so the driver can compare the two, keep the better one, or take some pages from each. The measurement cache makes the re-run affordable: a paragraph whose text, fonts and width did not change is read from the cache instead of being measured again.
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 block carries a bounding box, its type, and page/column indices.
Every block carries its bbox, its type, and page/column indices.
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; // every balancing segment ended flush or out of levers
iterationCount: number; // placement passes behind this layout (see The Pass Driver)
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' | 'side'; // 'span' = full-width column holding a page-span block,
// 'side' = the float-only side column of a one-and-a-half layout
}
// A content block (paragraph, heading, resource, etc.)
type VDTBlockType =
| 'paragraph' | 'heading' | 'resource' | 'blockquote'
| 'listItem' | 'footnoteRef' | 'mathDisplay' | 'callout' | 'code';
interface VDTBlock {
id: string;
type: VDTBlockType;
bbox: BoundingBox;
lines: VDTLine[]; // for text blocks: the measured lines
resourceBlock?: ResolvedResourceBlock; // for resource blocks
pageIndex: number;
columnIndex: number;
dirty: boolean; // not used: cleared when the block is placed, read by nothing
snappedToGrid: boolean; // the flow went back to the baseline grid under this block
balancing?: VDTBalancing; // the column-balancing levers applied to this block
}
// A measured line of text
interface VDTLine {
text: string;
bbox: BoundingBox; // natural width: a justified line is painted to its block's right edge
baseline: number; // y-position of the text baseline
hyphenated: boolean; // line ends inside a word, or after a closed dash ("say—" | "that’s")
hardHyphen?: boolean; // …after a hyphen the text carries ("well-" | "known"): nothing added
repeatedHyphen?: boolean; // opens with that hyphen repeated ("vencer-" | "-se"), not in the source
segments?: VDTLineSegment[]; // word/space/math runs for justified rendering
isLastLine?: boolean; // last line of its paragraph
justifiedSpaceRatio?: number; // width every space is painted at ÷ the regular face's space
justifiedSpaceLimitRatio?: number; // the spacing the limits bound, when the line's own spaces are not regular (lineWordSpacing reads both)
sourceStart?: number; // markdown source map (char offsets; a line opening with `\$` starts at the backslash)
sourceEnd?: number;
plainStart?: number; // plain-text source map
plainEnd?: number;
}
// A measured, placement-ready resource embed (bitmap / svg / table / video)
interface ResolvedResourceBlock {
resource: Resource;
kind: 'bitmap' | 'svg' | 'table' | 'video';
number: string; // computed number, e.g. "1.7"
captionPrefix: string; // e.g. "Figure"
bodyRect: BoundingBox; // the image / table area
bodySource?: ResourceSafeArea; // the part of the picture shown in bodyRect when cropped to its safe area
bodyFlex?: { shrink: number; grow: number; delta: number }; // px the body can still shrink / grow, px applied
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. -
dirtyandsnappedToGridare written when a block is placed and read by no stage and no backend.dirtystartstrueon a new block and is set tofalseas the block is placed; the engine does not track changes on the tree, it runs the placement pass again (see The Pass Driver).snappedToGridistrueon the first part of a text block that the flow returns to the grid after (see Vertical Rhythm System). -
balancingsays which column-balancing levers moved or stretched the block; a block the balancing left alone has none (see Column Balancing). -
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.
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.
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.
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 areas (the notes at a column's foot and their rule: the column's text area ends above them)
Figure: Vertical rhythm alignment Column 1 contains a heading that breaks the baseline grid by 12 pixels. As the heading is placed, the engine rounds the height used in the column up to the next grid line, which adds 12 pixels under the heading, so the next body line falls back on the grid. Column 2 remains aligned throughout.
The space under a grid-breaking element is rounded up to the grid as the element is placed, so baselines across columns stay in sync.
The grid is kept at placement, block by block. No later pass walks the columns looking for drift. Body lines are one grid line tall, so a run of body text that starts on the grid stays on it. When the placement pass has set a block that can leave the grid, it rounds the height used in the column up to the next grid line before the next block starts. The blocks that snap this way are:
- the last heading of a run of headings (consecutive headings follow each other without a snap, and the last one realigns the text after them);
- the last item of a list, whatever the spacing between items;
- a display formula;
- a callout box, a code listing, and a figure or table embedded inline;
- the last paragraph of a
:::paragraphscontainer and a:::versepoem, whose leading is their own.
The block's space below (marginBottom) is counted before the rounding, so the margin is a minimum and the grid decides the rest. In the figure, column 1 holds a heading 36px tall on a 24px grid, with no margin below it. Its foot falls 12px past a grid line, the snap adds 12px, and the next body line starts on the grid, level with the line beside it in column 2. With a marginBottom of 16px the foot would be at 52px, and the snap would round that up to 72px.
Float bands keep the grid in their own way, described under Stage 4: a top band is rounded up to whole grid lines, and a bottom float is anchored so the last baseline of its caption sits on the grid. Column balancing adds its space in whole grid lines, so it keeps the grid as well.
Nothing compares one column with another. Every column starts on the grid and every snap returns to it, so the lines of adjacent columns, and of facing pages, meet without a second pass.
Opting out. snapToGrid: false keeps the exact space under a block instead. It can be set on headings (for every level, for one level or for one heading style), on a paragraph style, on a callout style and on codeStyle. The text after such a block may sit off the grid until the next block that snaps. The text inside a callout box follows the box's own leading and has no grid.
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 finished 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 a finished 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.
The backends are plain functions over VDTDocument, not a class hierarchy. There is no PostextBackend interface — just the rendering entry points of each output and the helpers it needs:
// Canvas (from 'postext')
renderToCanvas(doc, options?): HTMLCanvasElement[]; // one canvas per page
renderPage(page, doc, options?): 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, options?): void; // options: { vector?, singleInk? }
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>;
// EPUB (from 'postext-epub'), a book's chapters in order
renderToEpub(docs, 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; the EPUB writer takes one too and stores each picture once as a file of the book. A video resource is drawn as its poster, a bitmap like any other; the HTML backend also takes a resourceVideoUrl(fileId) resolver for the video files it plays, and the EPUB writer stores those files under media/. The PDF backend draws the play mark and the QR code as vector paths over the poster and, when video.linkPoster is on, puts a URI link annotation over it that opens video.link; in a tagged PDF the link joins the poster's Figure, whose alternate text starts with the type's name (Video: …). 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.
| 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 | |
| EPUB | Consumes the VDT already measured with Pretext | EPUB 3 file: one XHTML document per printed page (fixed layout) or per chapter (reflowable), fonts embedded (renderToEpub in postext-epub) |
Shipping |
| Command line | Pretext over Skia (@napi-rs/canvas), in place of the browser's canvas |
The PDF, HTML and EPUB writers, page images and Word, run from a terminal with no browser (postext-cli) |
Shipping |
All four 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. postext-epub is a package of its own for the same reason, and is needed only for e-books. The command line is not a fifth backend: postext-cli runs these same writers where there is no browser.
Where layout runs: The engine measures text on a canvas, so it runs wherever it is given one. In a browser the pipeline runs 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 placement passes 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. Outside a browser, the command line runs the same pipeline with Skia standing in for the browser's canvas, so a book breaks into the same pages in a terminal, a script or a build server.
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 change a word. 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, laid out again on every edit, and several times over when the layout needs its rounds. Game engines face the same class of problem — processing thousands of objects (geometry, physics, lighting, AI) 60 times per second — and they solve it by not repeating work they have already done. Postext applies that to what costs it most, which is measuring text.
-
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.
-
Whole passes over cached measurements. A re-run of the placement pass does not look for what changed: it places the document again from the first block. That is affordable because measuring, the expensive part, is cached per paragraph (principle 5). A pass that changes nothing about a paragraph's text, fonts or width reads its lines from the cache.
-
Bounded rounds. Every kind of re-run has a fixed limit (see The Pass Driver), and column balancing has one per document as well as one per segment, so a book of many chapters cannot spend passes without end. The cost of a build can be measured pass by pass:
BuildDocumentOptions.onPassreports each pass and its wall time. -
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. When a family's faces change, the engine drops that family's widths and Pretext's cache, and a measurement cache drops the blocks set in it at its next lookup (it carries the generation it was filled under), so glyph widths are correct after fonts finish loading with no call from the host;clearMeasurementCache()drops every family. -
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 placement passes, and posts back the finishedVDTDocument. A newerbuild()call cancels the previous one cooperatively — the worker runsbuildDocumentAsync, which polls a cancellation hook once per block and between passes and 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. -
Dual-access VDT. The tree (
pages > columns > blocks) gives hierarchical access for work that goes page by page or column by column (column balancing measures the gap at the foot of each column). A parallel flatblocks[]array gives direct access to every block wherever it sits (the note-number round reads the marker each note printed from it). Both views reference the same block objects (there is no duplication, just two ways to traverse the same data).
There is no incremental entry point: no call takes a finished VDT and updates it. When the content or the configuration changes (a keystroke, another page size), the host calls buildDocument again and gets a new document.
What carries over from one build to the next is the measuring. Create a MeasurementCache once with createMeasurementCache() and pass it as the third argument of every buildDocument call. A paragraph whose text, fonts, width and breaking options did not change is read from it, so an editor that lays the document out on every keystroke measures only the paragraph being typed in. A change of page size or of column count changes the widths, and those paragraphs are measured again; Pretext's prepare() results still hold, since they depend on the font and the text and not on the width, so only the cheap layout() arithmetic runs. The parse is memoized too.
For a live editor, createLayoutWorker (from postext/worker) does all of this off the main thread: the worker owns a measurement cache that lives as long as it does, and a newer build() cancels the one in flight. See Configuration → Measurement cache and Running layout in a Web Worker.
Two benchmarks live in packages/postext/src/__bench__/. pnpm test skips them; pnpm bench, run in packages/postext, runs them. chapter.bench.test.ts lays out one textbook-sized chapter (forty sections of headings, paragraphs and lists) five times against an empty measurement cache and five times against a warm one, and reports both medians and the number of passes. cjkChapter.bench.test.ts lays out chapter 1 of 红楼梦 and counts the characters sent to measureText, to hold the CJK composer to a cost linear in the length of the paragraph.
// From packages/postext/src/__bench__/chapter.bench.test.ts
const cold: number[] = [];
for (let i = 0; i < ROUNDS; i++) cold.push(time(() => buildDocument({ markdown }, config, createMeasurementCache())));
const warmCache = createMeasurementCache();
buildDocument({ markdown }, config, warmCache);
const warm: number[] = [];
for (let i = 0; i < ROUNDS; i++) warm.push(time(() => buildDocument({ markdown }, config, warmCache)));
const passes: BuildPassInfo[] = [];
const doc = buildDocument({ markdown }, config, createMeasurementCache(), { onPass: (p) => passes.push(p) });
// [bench] chapter: … pages, … passes (… ms), iterationCount=…; cold median … ms, warm median … ms (5 rounds)A caveat for honesty: there is no canvas in Node, so both benchmarks stub text measurement and their absolute times are not the browser's. The ratio of cold to warm and the number of passes are, and a regression in either shows here before it shows in the Sandbox. The chapter benchmark reports numbers and does not break the build; the Chinese one asserts that the characters measured stay under three times the length of the longest paragraph. Neither runs in CI: regressions are caught by comparing the numbers across runs when a hot path changes (as was done for the Knuth-Plass active-node rewrite). Performance is a feature, not a hope — but the enforcement today is measurement and review, not a failing pipeline.
Figure: Data flow through the engine Content and config enter the placement pass, which structures, measures and places every block, floats the resources and applies the break and keep rules. buildDocument runs the pass again with hints until every round settles. The finished VDT is handed to the backend, which renders HTML or PDF.
End-to-end data flow: one placement pass, run again with hints, then render.
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.
- 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 files. The
postextpackage loads fonts; it does not carry them.prepareFontsloads every face a configuration and its text ask for before the first build, and a face the font set could not give is listed in the document's warnings asfontFallback. The files themselves come from the host: an@font-facerule on the page, or aresolvefunction that says where each face lives. See Configuration → Loading fonts before layout.
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 (content structuring) |
PostextConfig |
Controls every stage of the placement pass and the rounds around it |
Resource |
A typed bitmap/SVG/table/video resource. Becomes a ResolvedResourceBlock during measurement and either an inline VDTBlock of type 'resource' (placement 'here') or a page-band float (resource placement) |
ResourceType |
Drives typed numbering, caption prefixes, reference labels, and default float placement (content structuring, resource placement) |
ResourcePlacement |
Per-resource float override: position ('auto' / 'top' / 'bottom' / 'here') and span ('column' / 'page' / 'side'), plus rotate, width, align, columns, captionSide, shrink, minScale, captionMeasure, wrap, wrapGap and citingPage; each field falls back to the type's defaultPlacement, resolved during resource placement |
PostextNote |
Not read by the engine. Footnotes are written in the markdown ([^id]) and set at the foot of the citing column or after the chapter; margin notes are not implemented |
PostextResource |
Deprecated. The legacy content-model resource, retained only until the last renderer reference (VDTBlock.resource) migrates to the Resource model |
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), postext-pdf (PDF) and postext-epub (EPUB).
Postext is MIT-licensed. Maintained by Ignacio Ferro and community contributors. See the live documentation for the most up-to-date reference.
Overview
Writing
- Document Format
- Hyphenation and Justification
- Chinese Layout
- Arabic Layout
- Japanese Layout
- Comics
- Composed Pages
- Sandbox
- Agent Skill
- Command Line
- WebMCP
Configuration
- Overview and index
- Page and layout
- Headers and footers
- Body text
- Hyphenation and language
- Headings
- Widows, orphans and balancing
- Lists and maths
- Notes and references
- East Asian typography
- Resources
- Styles and parts
- Callout boxes
- Comics
- Composed pages
- Fonts, colours, viewers
Code
Internals
External