Repository navigation
Programmatic Usage
Part of the Configuration reference.
Generated from docs/configuration-programmatic-usage-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.
Recommended path: use the Web Worker. In a browser, the overwhelming majority of integrations should drive the layout pipeline through
createLayoutWorker()frompostext/worker, not by callingbuildDocumentdirectly on the main thread. The worker keeps the UI responsive during builds, caches text measurements across incremental rebuilds, and wires up last-wins cancellation so a new keystroke aborts any stale build already in flight. Jump straight to Running layout in a Web Worker for the canonical recipe. Everything in the rest of this section (directbuildDocument, resolvers, strippers, caches) is still useful — the worker exposes the exact same inputs and outputs — but for UI code the worker wrapper is the correct starting point. Only fall back to callingbuildDocumenton the main thread for one-shot exports, server-side rendering (Node), or tests.
The buildDocument function runs the full layout pipeline and returns a Virtual Document Tree (VDT) with precise coordinates for every element. This is the lowest-level entry point; UI code should prefer the Web Worker wrapper, which calls buildDocument inside a dedicated worker thread with the same arguments.
import { buildDocument } from 'postext';
const content = {
markdown: '# Chapter One\n\nThe story begins here...',
};
const config = {
page: { sizePreset: '17x24' },
layout: { layoutType: 'double' },
bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 9, unit: 'pt' } }, // 9 pt overrides the 8 pt default
};
// Build the layout — produces a VDT with one entry per page in `vdt.pages`
const vdt = buildDocument(content, config);
console.log(`Document has ${vdt.pages.length} pages`);buildDocument does not stop at a wrong reference or an unknown style: it sets a fallback and records what it did in doc.contentWarnings. The boxes the layout had to force are in doc.warnings, which keeps the shape it had in postext 1.4: every entry there is a calloutOverflow with its pageIndex, columnIndex and overflowPx. Each field is absent when there is nothing to report. Every entry has a kind. The content kinds carry the source range of the construct — sourceStart / sourceEnd, offsets in the markdown you passed, frontmatter included — and, when the construct landed on a page, its pageIndex.
| Kind | Raised when | What the output does |
|---|---|---|
calloutOverflow |
A :::callout box fits no column and no cut can split it. A span: 'side' box taller than an empty side column is one too (since postext 1.25). |
It is placed anyway, overflowing its column by overflowPx (on pageIndex / columnIndex). The one kind listed in doc.warnings; the kinds below are in doc.contentWarnings. |
invalidFrontmatter |
The front matter is not valid YAML (an unclosed quote, text after a quoted value). message is the parser's reason with its line and column. |
The document is set without its metadata; the body after the closing --- is laid out as usual. |
unknownResourceId |
A ::resource embed (usage: 'embed'), an inline :ref ('ref') or a table cell's image ('cellImage') names an id no resource has. |
The embed is left out, the reference prints ? (or its text= label) with no number or link, the cell stays text-only. inResource names the resource whose caption, note or cell holds the reference. |
unknownDirective |
A :::name line whose name is neither a directive nor a container. |
The line is set as text. |
malformedEmbed |
A ::name line that is not a well-formed embed standing alone: ::resource with an unquoted or single-quoted id or another attribute, or a line glued under a paragraph without a blank line. |
The line is set as text. |
fullwidthMarkup |
A line holds markup typed with a Chinese or Japanese input method: a ::: fence, a # heading, a [^…] footnote marker, {…} attributes after a fence or heading, or **…** bold. typed is the markup as written, ascii the form to type. One per line. |
The line is set as text; nothing is converted. |
attributeKeyInvalid |
An attribute key holds letters outside ASCII (作者=曹雪芹); points at the key. |
The attribute is ignored. |
unknownParagraphStyle |
:::paragraphs{style} names no paragraph style. |
The paragraphs are set as body text. |
unknownCalloutType |
:::callout{type} names none of the calloutStyles — only raised once some are configured. |
The box takes the first callout style. |
columnsFlowUnknown |
:::columns{flow} is neither snake nor parallel; value is what it says. |
The group takes the default: parallel with breaks, snake without. |
templateUnknown |
A composed page (::::page{template}) names no pageTemplates entry, or no template at all; template is the id as written and fenceIndex the page's place among the chapter's composed pages. |
Its slots are not checked. Until templates are laid out, its content is set in the running text on pages of its own. |
slotUnknown |
Content of a composed page names a slot that is no frame of the page's template (slot, template, fenceIndex). |
The content goes to the template's flow frame. |
slotEmpty |
A frame of the template receives nothing on the page (frame, template, fenceIndex). Not raised for an optional frame, nor for one that only receives what another frame continues into it. |
The frame stays empty. postext check lists it as a note. |
pageFenceLength |
A three-colon :::page carries template=. |
It is read as a comic page; a composed page needs four colons. |
frameOverflow |
The content laid into a frame of a composed page does not fit it, and the frame has no next frame left (template, frame, fenceIndex; overflowMm, the height cut; configPath, the JSON pointer of the template frame, such as /pageTemplates/0/frames/2). Raised by the layout, with the pageIndex of the page. |
Paged output cuts what does not fit at the frame's foot; it stays in the document, the source map and the reflowable EPUB. |
spreadMismatch |
A composed page's fence and its template disagree about spreads (template, fenceIndex; fence, the fence's spread; templateSpread, the template's). |
The template decides: a spread template is laid over two facing pages, any other on one page. |
unknownChipStyle |
:chip[…]{style} names no chip style. |
The chip takes the first chip style. |
unknownMarkStyle |
:mark[…]{style} names no highlight style. |
The mark takes the first highlight style. |
undefinedFootnote |
A footnote marker [^id] that no [^id]: paragraph defines (id is the note's). |
The number prints; the note is empty. |
unusedFootnote |
A footnote definition [^id]: that no marker cites. |
The note is not set. |
indexMarkInvalid |
An index mark with no term: :index{}, or attributes without term on a mark with no bracketed text. |
The mark indexes nothing. |
indexSeeUnknown |
A see or seealso target (target) that is no entry of its index (index, '' for the main one). Points at the :::index line. |
The cross-reference prints anyway. |
indexRangeUnclosed |
A range="start" mark with no matching range="end", or the reverse (missing says which end is missing; term names the entry). Points at the :::index line. |
The range prints its one page. |
unknownHeadingStyle |
A heading's {style="…"} names no heading style (level is the heading's). |
The heading and its section keep the level's own settings. |
unknownTableStyle |
A table resource's table.styleId names no tableStyles entry. |
The table is set in tableStyle. |
raggedTableGrid |
A table's grid is not rectangular once its merges are counted (see Building table models). | Cells shift onto a merge or leave a hole. reason ('spanOverlap' / 'missingCells'), row and col locate the first issue; count says how many there are. |
lineNumberOverlap |
With lineNumbers.position: 'side', a line number overlaps a side box, a side caption or a figure in the side column. Points at the numbered line; number is the number as printed. |
The number is painted anyway, and neither moves. |
dropCap |
A paragraph a drop cap opens that cannot take it as configured. reason: 'shortParagraph' (fewer lines than the initial sinks; handling is what shortParagraph did, lines the lines a shrunk initial spans), 'split' (it breaks before the initial's last line, alone in a column too short), 'joiningScript' (its first letter joins the next), 'verticalText' or 'noLetter' (it opens with a reference, a formula or a note mark). text is its first line. |
The room is kept, the initial shrinks or is left out, as the warning says. |
codeOverflow |
A code listing has lines wider than its box. mode is what codeStyle.overflow did ('wrap', 'shrink', 'clip'), lines how many source lines were too wide, scale the size a shrunk listing was set at (a share of fontSize), lang the fence's language. Points at the listing. |
The lines are turned over, set smaller or cut, as mode says. |
floatShrunk |
A floated picture was set smaller than its size to fit the room of its slot (placement.shrink). resourceId names it, scale is the share of its width it keeps, and overflowPx, when present, how far it still runs past the foot of the text block at its smallest scale (placement.minScale), on a fresh page where it had nowhere else to go. Points at the paragraph that first cites it. |
The picture is printed at that scale; past the text block only when overflowPx says so. |
textWrap |
A resource or a box set to wrap the text round it (placement.wrap, a box's wrap) that does not as asked. reason: 'tooNarrow' (the text beside it would be narrower than layout.wrap.minTextWidth), 'fewLines' (it is shorter than layout.wrap.minLinesBeside lines), 'moved' (an inline one too tall for the room left in its column moved on to the next, its anchor with it) or 'verticalText'. resourceId names a resource, box a box's style. Points at the embed or the box. |
The item takes its band whole, or stands in the next column, as the reason says. |
columnsTooNarrow |
The sub-columns of a :::columns group are narrower than six ems of their text: columns of them, widthPx each. Points at the group's fence. |
The group is set as asked, with few words to a line. |
afterText |
A span: 'side' box, or a figure or table of the side column (resourceId), set on a page that holds no text: the text of its chapter, or of the document, ended while it still waited for room in the side column. One per box or float; points at the box (at the block citing the float), with its page. Since postext 1.25. |
It stands in the side column of a page opened after the text, the boxes in the order of their fences. |
unplaced |
A box or a floated resource (resourceId) still waiting for a slot when the layout ended: the pages opened for it could not take it (a side box where those pages have no side column, say). Points at the box or at the block citing the resource; no page. Since postext 1.25. |
It is on no page. Up to postext 1.24 it dropped out without a warning. |
shortListSplit |
A list of items items, short by bodyText.shortListMaxItems, cut between two columns or pages. Under bodyText.shortListSplit: 'warn' every such list is reported; under 'avoid', only one no column could hold whole. One per list; points at the list, with the page of its first item. Since postext 1.28. |
The list is cut where the column ends. |
tocGroupSplit |
A block of the contents kept together (toc.groups.keepTogether, :::toc{keep="…"}), headed by title, set in columns columns: it is taller than a full column, or the column it starts in is short (under a page-span box, beside a float). One per block; points at the :::toc directive, with the page the block starts on. To clear it, list fewer levels (levels="1"), head the blocks with a lower level (toc.groups.head: 2), or make the entries smaller or tighter. Since postext 1.34. |
The block is cut; with toc.groups.continuation each column it goes on in opens with its head row and the continuation mark. |
openerFloat |
A float set on an opener page under layout.openerFloats: 'warn': a figure, a table or a strip (resourceId), or a floated box. One per float; points at the block citing it (at the box), with its page. Since postext 1.28. |
The float stands on the opener page; 'avoid' moves it to the first slot after it. |
fontFallback |
A face the text was set in (family, weight, style) that the font set could not give when the build ran: reason: 'missing', no face of the family was loaded nor installed, or the one that answers for that weight and slant had not loaded yet; 'synthesized', the family has no face of that weight or slant and the browser takes it from another one, as it is or drawn bolder or slanted (a 600 set from the 700, a 700 italic asked of a family with only a 400 regular). Checked where there is a font set (document.fonts, a worker's self.fonts, or BuildDocumentOptions.fontSet), behind debug.warnings.missingFont. No page and no source range. |
The text is measured and drawn with the fallback face, or with another face of the family, as it is or made bolder or slanted; its line breaks change once the face arrives. See Loading fonts before layout. |
The warnings about a resource — its table style, its grid, a reference inside its caption, note or cells — point at the resource's first embed or reference in the text, and each is listed once per resource. Only the resources the document uses are checked: a chapter of a book reports the tables it cites, not every table the book holds.
import { buildDocument, formatWarning } from 'postext';
const doc = buildDocument({ markdown: 'See :ref{id="fig-map"}.\n\n:::sidebar\nNotes.' }, config);
for (const w of [...(doc.warnings ?? []), ...(doc.contentWarnings ?? []), ...(doc.configWarnings ?? [])]) console.warn(formatWarning(w));
// Unknown resource id "fig-map" in :ref — it prints "?" (or its text= label), with no number or link (page 1, offset 4)
// Unknown directive ":::sidebar" — the line is set as text (page 1, offset 25)
// Narrow on `kind` to read the fields of a kind.
const missing = (doc.contentWarnings ?? []).flatMap((w) => (w.kind === 'unknownResourceId' ? [w.resourceId] : []));formatWarning(w) returns a one-line English description. A host that localises its messages switches on kind instead — and keeps a default branch, since minor releases may add kinds. collectContentWarnings(markdown, config, resources) returns the content warnings without laying anything out (the list the build adds, without pageIndex), for an editor that checks the text as it is typed. collectHeadingDesignCuts(doc) checks a finished layout for heading designs whose text runs past the foot of their page or column (kind: 'headingDesignCut'; see Reserved height), which the layout itself does not report, and formatWarning describes its results as well. The Sandbox's Checks panel lists them all.
The renderers report what they cannot paint as asked through an onWarning option: renderPageToCanvas, renderPage and renderToCanvas (RenderPageOptions), renderToHtml and renderToHtmlIndexed (RenderHtmlOptions), and renderToPdf (RenderToPdfOptions, also through the PDF worker). The main render kind is missingImage: an image — a figure, a table cell's image, a callout icon, a design image — with nothing to draw is painted as a neutral placeholder and reported, once per fileId and render call, with its pageIndex, the resourceId when the painter knows it (figures and cell images), and in the PDF the documentIndex of a multi-document render. Nothing to draw means no registerResourceImage for the fileId on the canvas, no URL from resourceImageUrl in HTML, and no bytes from resourceBytes — or bytes that do not decode — in the PDF. A bitmap or SVG resource that names no fileId at all has nothing to ask for: it is drawn as a placeholder without a report. Two more kinds come from the hosts that embed fonts in SVG pictures (registerSvgImage, registerBundleImages, bundleImageUrl, renderToHtml with inlineSvgFonts, postext-epub; see Fonts in SVG text), with the picture's fileId and resourceId: svgFontUnavailable (family, weight, style), a family its text names with no face to embed, so the image sets that text in a fallback face; and svgFontsTooLarge (bytes, maxBytes), faces over the size cap, none embedded. Render warnings are not stored in the VDT: what a host can supply changes after the layout.
import { buildDocument, renderPage, type RenderWarning, type Resource } from 'postext';
const map: Resource = {
id: 'fig-map', typeId: 'figure', kind: 'bitmap', caption: 'The route.', createdAt: 0, updatedAt: 0,
bitmap: { fileId: 'map-file', format: 'png', width: 1200, height: 800 },
};
const doc = buildDocument({ markdown: 'See :ref{id="fig-map"}.', resources: [map] }, config);
const warnings: RenderWarning[] = [];
const canvas = renderPage(doc.pages[0], doc, { onWarning: (w) => warnings.push(w) });
// Until 'map-file' is registered with registerResourceImage:
// [{ kind: 'missingImage', fileId: 'map-file', resourceId: 'fig-map', pageIndex: 0 }]Each page can be rasterized independently. Use renderPage(page, doc) to obtain an HTMLCanvasElement for a given page number — the canvas is a bitmap sized exactly to the page dimensions in pixels (at the configured DPI), so you can display it, export it, or feed it into any image pipeline:
import { buildDocument, renderPage } from 'postext';
const vdt = buildDocument(content, config);
// Render page 3 (zero-indexed) to a bitmap canvas
const pageNumber = 2;
const page = vdt.pages[pageNumber];
if (!page) throw new Error(`Page ${pageNumber} does not exist`);
const canvas = renderPage(page, vdt);
// canvas.width / canvas.height are the page bitmap size in pixels
// Show it in the DOM
document.body.appendChild(canvas);
// …or export it as a PNG data URL
const pngDataUrl = canvas.toDataURL('image/png');
// …or get a Blob for download / upload
canvas.toBlob((blob) => {
if (blob) saveAs(blob, `page-${pageNumber + 1}.png`);
}, 'image/png');
// …or grab raw RGBA pixels
const ctx = canvas.getContext('2d')!;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);If you prefer to draw into a canvas you already own (for example one attached to the DOM with a specific layout), use renderPageToCanvas(page, doc, canvas) — it resizes and paints into the canvas you pass in, instead of creating a new one.
To render every page, iterate over vdt.pages:
const bitmaps = vdt.pages.map((page) => renderPage(page, vdt));Everything above, running in the browser. The pen imports the latest postext release from a CDN, waits for the web fonts, lays out a short two-column document, paints its first page to a canvas and offers that bitmap as a PNG. Press Run on CodePen to load the editor and change the markdown or the configuration; the page repaints on every edit.
Runnable example: Postext · render a page to an image — Lay out a markdown document with postext and rasterise its first page to a canvas / PNG. (source)
postext/react exports createLayout(content, config?): a component that lays the document out once, when it mounts, and shows each page as a <canvas> inside a <div>.
import { createLayout } from 'postext/react';
const Article = createLayout(
{ markdown: '# Hello\n\nThe first paragraph of the article.' },
{ page: { sizePreset: '17x24' } },
);
export function ArticlePage() {
return <Article className="pages" style={{ maxWidth: 480 }} />;
}-
Main thread, once. The pages are painted at the document's resolution and scaled to the container's width.
contentandconfigare fixed when you callcreateLayout; create another component to show something else. For a live preview, build in the Web Worker and paint withrenderPageToCanvas, as in the React example there. -
Fonts and images first. Load the document's web fonts before the component mounts, and register its images with
registerResourceImage. When the markdown contains a$, the component starts the math engine itself. -
React stays out of the main entry.
postextnever imports React; onlypostext/reactdoes.createLayoutis still exported frompostextso existing code keeps working, but it is deprecated: it loadspostext/reactwhen you call it, and the component suspends until that has arrived (React renders it again by itself). Import it frompostext/react. -
The deprecated component suspends. Until
postext/reacthas arrived,createLayoutfrompostextneeds a concurrent root (createRoot) or a<Suspense>boundary above it. In a legacyReactDOM.renderroot, or inrenderToString, with no boundary, React reports an error instead.reactstays a required peer dependency, so that bundlers can resolve that lazy import.
Resolver functions fill in default values for partial configuration objects. This is useful when you need a complete configuration for inspection or comparison:
import { resolvePageConfig, resolveBodyTextConfig } from 'postext';
const fullPage = resolvePageConfig({ sizePreset: '21x28' });
// => { sizePreset: '21x28', width: { value: 21, unit: 'cm' }, height: { value: 28, unit: 'cm' },
// margins: { top: { value: 2, unit: 'cm' }, ... }, dpi: 300, cutLines: { enabled: false, ... }, ... }
const fullBody = resolveBodyTextConfig({ fontFamily: 'Inter' });
// => { fontFamily: 'Inter', fontSize: { value: 8, unit: 'pt' }, lineHeight: { value: 1.5, unit: 'em' }, ... }Available resolvers, one per top-level section: resolvePageConfig, resolveLayoutConfig, resolveBodyTextConfig, resolveHeadingsConfig, resolveHeadingStylesConfig, resolveTocConfig, resolvePartsConfig, resolveUnorderedListsConfig, resolveOrderedListsConfig, resolveMathConfig, resolveTableStyleConfig, resolveCaptionStyleConfig, resolveDiagramStyleConfig, resolveParagraphStylesConfig, resolveCalloutStylesConfig, resolveHeaderFooterConfig, resolveDebugConfig, resolveHtmlViewerConfig, resolvePdfGenerationConfig — plus resolveDesignSlot for a single design slot. Color palettes are applied separately through applyPaletteToConfig(config), applyPaletteToResolvedConfig(resolved, palette), and resolveColorValue(value, palette, fallback) — see Color Palette.
Resolvers whose defaults cascade from another section take that section, already resolved, as a further argument. resolveUnorderedListsConfig and resolveOrderedListsConfig take the resolved body text, because list defaults for fontFamily and color cascade from it; resolveCalloutStylesConfig takes the resolved body text, headings and unordered lists (see the example in Callout styles), and resolveHeadingStylesConfig the resolved page, body text and both list sections. Check the package's type declarations for the exact signature of each:
import { resolveBodyTextConfig, resolveUnorderedListsConfig } from 'postext';
const body = resolveBodyTextConfig({ fontFamily: 'Inter' });
const lists = resolveUnorderedListsConfig({ bulletChar: '—' }, body);
// => lists.fontFamily === 'Inter' (inherited)Static default bundles — the values used when no cascade is involved — are exported too: DEFAULT_PAGE_CONFIG, DEFAULT_CUT_LINES, DEFAULT_PAGE_NUMBERING, PAGE_SIZE_PRESETS, DEFAULT_LAYOUT_CONFIG, DEFAULT_COLUMN_RULE, DEFAULT_COLUMN_BALANCING, DEFAULT_BODY_TEXT_CONFIG, DEFAULT_HYPHENATION_CONFIG, DEFAULT_HEADINGS_CONFIG, DEFAULT_UNORDERED_LISTS_STATIC, DEFAULT_ORDERED_LISTS_STATIC, DEFAULT_PARAGRAPH_STYLES, DEFAULT_CALLOUT_STYLES, DEFAULT_CALLOUT_STYLE_STATIC, DEFAULT_PARTS_CONFIG, DEFAULT_HEADING_STYLES, DEFAULT_TOC_CONFIG, DEFAULT_MATH_CONFIG, DEFAULT_DIAGRAM_STYLE_CONFIG, DEFAULT_DEBUG_CONFIG, DEFAULT_HTML_VIEWER_CONFIG, DEFAULT_PDF_GENERATION_CONFIG, DEFAULT_COLOR_PALETTE, DEFAULT_MAIN_COLOR, DEFAULT_MAIN_COLOR_ID, DEFAULT_MAIN_COLOR_NAME, DEFAULT_MAIN_COLOR_HEX, plus the header/footer element defaults (DEFAULT_HEADER_FOOTER_SLOT, DEFAULT_HEADER_SLOT, DEFAULT_FOOTER_SLOT, DEFAULT_TEXT_ELEMENT, DEFAULT_RULE_ELEMENT, DEFAULT_BOX_ELEMENT) and the locale-aware defaultResourceTypes(locale) (see Resource types).
When persisting configuration (e.g., to localStorage or a file), use stripConfigDefaults to remove values that match the defaults. This keeps stored configurations minimal — only the intentional overrides are saved:
import { stripConfigDefaults } from 'postext';
const minimal = stripConfigDefaults(fullConfig);
// Only properties that differ from defaults remainIndividual strippers are also available, one per resolver: stripPageDefaults, stripLayoutDefaults, stripBodyTextDefaults, stripHeadingsDefaults, stripHeadingStylesDefaults, stripTocDefaults, stripPartsDefaults, stripUnorderedListsDefaults, stripOrderedListsDefaults, stripMathDefaults, stripTableStyleDefaults, stripCaptionStyleDefaults, stripDiagramStyleDefaults, stripParagraphStylesDefaults, stripCalloutStylesDefaults, stripHeaderFooterDefaults, stripDesignSlotDefaults, stripDebugDefaults, stripHtmlViewerDefaults, stripPdfGenerationDefaults.
Some defaults depend on the rest of the configuration: column balancing is off on a character grid and in vertical text, bodyText.avoidWidows is off on a character grid (and bodyText.avoidOrphans too in the japan region), and the footnotes, the captions and the index follow the document language. stripConfigDefaults compares each value with the default of the configuration it is given, so headings.balancing.enabled: true stays where balancing is off by default, and false is dropped there. bodyText.avoidWidows: true stays on a character grid in the same way. A stripper called on its own takes that context as arguments: stripHeadingsDefaults(headings, balancingOnByDefault(config)), stripIndexDefaults(index, locale), stripCaptionStyleDefaults(captionStyle, locale), stripFootnotesDefaults(footnotes, locale, writingMode), stripBodyTextDefaults(bodyText, locale, avoidWidowsByDefault(config), avoidOrphansByDefault(config)).
A value that says "nothing" is kept wherever nothing is not the default. A heading style takes the document's header and footer when it sets none, so a style that empties them (footer: { elements: [] } on a cover) keeps the empty slot, and its margins, layout and bodyStyle are kept even when they are empty. A heading level set back to its default under headings-wide values that differ keeps the value. calloutStyles: [] and chipStyles: [] stay empty lists: left out, the built-in style would come back. In every case resolveAllConfig(stripConfigDefaults(config)) resolves like resolveAllConfig(config).
The engine exposes its markdown tokenizer and frontmatter reader. Use them to inspect a document before building it, or to feed other tooling with the same block structure Postext sees:
import { parseMarkdown, extractFrontmatter } from 'postext';
const source = '---\ntitle: Chapter One\n---\n\n# Opening\n\nThe story begins here.';
const { metadata, content } = extractFrontmatter(source);
// metadata.title === 'Chapter One'
const blocks = parseMarkdown(content);
// => [ { type: 'heading', level: 1, text: 'Opening', … },
// { type: 'paragraph', text: 'The story begins here.', … } ]See the Document Format page for the full list of markdown constructs Postext recognises.
Layout measures text with the faces the font set has when it runs. prepareFonts loads every face a configuration and its text ask for before the first build: the body, headings, lists, callout titles and bodies, tables, design texts, running heads, contents, code and comic lettering, in each weight and slant the configuration sets (the four of the body family, since ** and * set bold and italic in it). Each face is loaded for the characters the document sets, so a family served as unicode-range slices (Latin Extended, Greek, Arabic, the CJK slices of Google Fonts and Fontsource) brings the files the text needs.
import { prepareFonts, buildDocument, buildDocumentWithFonts } from 'postext';
// The host's files: the PDF font provider's contract, so one function serves both.
async function resolve(family: string, weight: number, style: 'normal' | 'italic') {
const id = family.toLowerCase().replace(/\s+/g, '-');
return `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${weight}-${style}.woff2`;
}
const report = await prepareFonts(content, config, { resolve });
// report.loaded, report.missing, report.synthesized: { family, weight, style }[]
const doc = buildDocument(content, config);
// Or in one call: prepare, build, load any face the pages used that was not there, build again.
const same = await buildDocumentWithFonts(content, config, { resolve });-
Faces the page declares (an
@font-facerule, aFontFacealready added) are loaded through the font set (document.fonts.load, orself.fontsin a worker). Faces it does not declare are asked ofresolve(family, weight, style, { text, codePoints }), which answers with a file (bytes, or a URL), several files (the slices of a face),{ source, unicodeRange, weight, style }for a slice or a variable range, ornull. The engine adds them asFontFaces once all have loaded, in the order of the configuration and of each answer (latin, then latin-ext, then greek when the resolver answers so), so the font set is the same whatever order the files arrive in. It also registers them with the font registry that SVG pictures and layout workers read. A resolver may declare the face itself (add a style sheet) and answernull. -
The report lists the faces a loaded face answers for (or an installed family), the ones still
missing, and the ones the browser wouldsynthesizefrom another weight or slant.timeoutMs(10 000 by default) bounds the wait; a face still loading then counts as missing. Where there is no font set (Node),prepareFontsdoes nothing and reports every face loaded. -
buildDocumentWithFonts(content, config, options)prepares, builds withbuildDocumentAsync, reads the faces the pages actually set text in, loads any the font set could not give (a weight only the pages reveal is asked ofresolveeven when another weight of the family would answer for it), and builds again (at most two extra builds).withLoadedFonts(build, options)does the same around any build function, for a book built chapter by chapter or a bundle (buildBundle) that returns several documents.options.onFontsreceives the final report. -
After the build, every face the text was set in that the font set could not give is listed in
doc.contentWarningsasfontFallback(see Warnings in the document).
Faces that arrive later are picked up on their own. The engine keeps, per font set, which faces of each family were loaded when it last looked; a build looks again at its start (only when the set grew or shrank, is loading, or finished loading a face) and drops what was measured in the families whose faces changed. watchFonts(fontSet) does the same as faces load, at most once per animation frame however many slices a burst brings, and onFontsChanged(listener) tells the host which families changed, so it can lay the pages out again:
import { watchFonts, onFontsChanged } from 'postext';
const stop = watchFonts(); // document.fonts by default
const off = onFontsChanged((families) => relayout());prepareFonts starts the watch on the set it loads into (watch: false leaves it off).
Text measurement is the expensive step in layout. Two kinds of cache keep it cheap:
-
A block cache you own.
createMeasurementCache()returns aMeasurementCachethat remembers every measured paragraph, keyed by its text, fonts, width, line-breaking options and the active hyphenation dictionary. Pass it as the third argument ofbuildDocument(orbuildDocumentAsync) to reuse measurements across the placement passes of a build and across builds: an editor that lays the document out on every keystroke then measures only the paragraphs that changed. Without one, every pass measures every block again. A paragraph read from the cache is the same as one measured afresh, so a build with a cache sets every line as a build without one does; in postext 1.4.1 a cached paragraph lost the mark of a runt last line, and runt tightening and column balancing could then break it differently. The cache carries the measurement generation it was filled under: when faces of a family arrive or leave, its next lookup drops the blocks set in that family, so a cache kept across font loads never serves fallback-measured lines. -
Global width caches. Word widths are cached per font string in module state that every build in the page shares, and pretext keeps a cache of its own. The engine drops the widths of a family when its faces change (at the start of a build, from
watchFonts, fromprepareFontsandloadBundleFonts); pretext's cache has no family index and is cleared whole.
import { buildDocument, createMeasurementCache, evictFontFamilies, clearMeasurementCache } from 'postext';
const cache = createMeasurementCache();
let doc = buildDocument(content, config, cache);
// A face of "EB Garamond" was added to document.fonts: the next build sees it,
// with the same cache, and measures that family again.
doc = buildDocument(content, config, cache);
// A host that changes faces the engine cannot see (a font set of its own) says so:
evictFontFamilies(['EB Garamond']); // that family's widths and cached blocks
clearMeasurementCache(); // every familyFor applications that measure text piece by piece, cachedMeasureBlock(text, font, maxWidthPx, lineHeightPx, options, cache) and cachedMeasureRichBlock(spans, normalFont, boldFont, italicFont, boldItalicFont, maxWidthPx, lineHeightPx, options, cache) take the arguments of measureBlock and measureRichBlock plus the cache, last.
Paragraphs are broken into lines by Knuth–Plass. Since postext 1.33 the engine runs it in a line breaker written in Rust and compiled to WebAssembly (30 KB, inside the package). It chooses the same breaks as the TypeScript breaker, with the same arithmetic in the same order, and a whole book lays out about 15 % faster. Nothing has to be set up:
- In a worker, in Node and in Bun, the module is compiled on the first paragraph.
- On a page's main thread, where a browser compiles a module this size only asynchronously, it compiles in the background and the TypeScript breaker answers until then.
await initLineBreaker()before the first layout puts it to work from the first paragraph; it resolvesfalsewhere WebAssembly is unavailable. - Where WebAssembly is missing, or a Content Security Policy refuses it (
script-srcwithout'wasm-unsafe-eval'), the TypeScript breaker answers every paragraph.
Both choose the same breaks, so which one answered never changes a layout. useWasmLineBreaker(false) keeps the TypeScript breaker, to compare the two.
import { initLineBreaker, buildDocument } from 'postext';
await initLineBreaker(); // optional: the first layout already uses it
const doc = buildDocument({ markdown }, config);Some of Postext's state lives in module-level variables. Everything that imports postext in the same JavaScript realm shares it: a page and its scripts share one copy, while each iframe and each worker has its own. One document per page never notices. Several documents in one page — two live previews, a gallery of examples — do:
-
Resource images.
registerResourceImage(fileId, image)fills one registry keyed byfileId, whichrenderPageandrenderPageToCanvasread. Two documents that both registerfigure.svgshare that entry: the last registration wins, for both. Give file ids a per-document prefix, and callunregisterResourceImage(fileId)orclearResourceImages()when a document goes away. The rasters the canvas backend caches are keyed the same way and dropped with the image. -
Text measurements. Measured widths are cached per font string and text for the whole realm. When faces of a family arrive or leave, the engine drops that family's widths for every document (see Loading fonts before layout);
clearMeasurementCache()drops them all. -
Resolved configurations. Each configuration object is resolved once and the result is cached against that object. Every build first compares the object with the text it had when it was resolved (one
JSON.stringify, about 0.1 ms for a 55 KB book configuration and 1.5 ms for a 240 KB one), so a configuration changed in place, at any depth (config.bodyText.fontSize = …, a palette colour), is resolved again.invalidateConfig(config)drops the resolution by hand.stableStringifyandhashStringgive a content key that does not depend on key order, for hosts that cache layouts by configuration. -
Hyphenation language. Every build sets the process-wide hyphenation language to its document's
bodyText.hyphenation.locale. The exportedhyphenateText(text)andlayoutDesignSlotuse the language of the last build unless you pass one: callhyphenateText(text, 'es'). -
Math engine. There is one MathJax engine and one cache of rendered formulas for the realm;
initMathEngine()starts it for everyone. -
Line breaker. There is one WebAssembly line breaker for the realm, compiled once;
useWasmLineBreaker(false)turns it off for everyone.
The simplest isolation is a realm per document: an iframe per live example (a CodePen embed is one), or a layout worker per document for the measurements and the hyphenation (images are still registered on the page).
This is the recommended way to use Postext in the browser. If you are building anything interactive — a live preview, an editor, a resize-aware viewer, or a sandbox-style playground — drive the pipeline through createLayoutWorker() from postext/worker. Do not call buildDocument directly on the main thread for UI code.
Calling buildDocument on the main thread runs the full pipeline — parse, measure, place, and every re-run of the placement pass the layout needs — on whichever thread invoked it. For a one-shot export that is fine. For an interactive UI it is the wrong thread: a 150 ms layout blocks input events, keystrokes queue up, and scroll stutters. The worker moves every one of those milliseconds to a background thread.
Postext ships a dedicated Web Worker entry point — postext/worker — that takes the pipeline off the main thread. It is the path we expect the majority of integrations to use: the sandbox's Canvas, HTML and PDF viewports all share the same createLayoutWorker() handle through a single useLayoutWorker hook (packages/postext-sandbox/src/worker/useLayoutWorker.ts) and drive it with last-wins cancellation — a new keystroke aborts the in-flight build before it even finishes.
At a glance, the canonical integration is:
-
Create a worker once per viewport with
createLayoutWorker(). -
Register fonts once per family by posting transferable
ArrayBuffers viaregisterFonts(payloads). -
Build with
build(content, config, { signal }), passing a freshAbortSignalevery call so stale builds can be cancelled. - Supersede any previous build by aborting its signal before starting the next one — this is the last-wins pattern.
- Dispose the worker when the component that owns it unmounts.
The same VDTDocument that comes back from build(...) feeds every downstream renderer: renderPage/renderPageToCanvas for canvas, renderToHtmlIndexed for HTML, and renderToPdf (from postext-pdf) for PDF. You build once in the worker and rasterise as many times as the UI needs on the main thread.
-
Main thread stays free. Parsing, measurement, and every placement pass all run inside the worker. The main thread is only touched when the finished
VDTDocumentis posted back. -
Last-wins cancellation.
build(content, config, { signal })threads anAbortSignalinto the worker. Aborting before completion raises anAbortErroron the main side; inside the worker the pipeline throws aBuildCancelledErrorat the next per-block cancellation checkpoint and stops immediately. -
Per-worker measurement cache. The worker keeps a single
MeasurementCachefor its lifetime. Subsequent builds that share font, text, and width reuse the cached line measurements — typing a single character into a long document only re-measures blocks whose input actually changed. -
Identical metrics to the main thread. Fonts are shipped into the worker as transferable
ArrayBuffers and registered vianew FontFace(...)on the worker's ownFontFaceSet. The worker measures with the same canvas font metrics the main thread would use, so line breaks and column heights are byte-for-byte identical. -
Math raster cache survives worker builds. The math renderer ships a content-keyed raster cache alongside the identity-keyed one — structured-cloning a
MathRenderacross the worker boundary would otherwise miss the identity cache on every rebuild. -
The same lines as on the page. A canvas made in a worker has no page to take a language from, and a font answers with other glyphs under a language. The engine sets the document's language (
locale) on every context it measures with, in the worker as on the page, sobuild()returns the linesbuildDocumentgives on a page in any language (bodyText.shapingLanguage: 'document', the default since postext 1.30). With'host', which a configuration stored before 1.30 keeps, a worker measures with no language and a page with its own, as up to postext 1.28.
The worker client lives at the postext/worker subpath and is a handful of names:
-
createLayoutWorker(opts?): LayoutWorkerHandle— spawns a dedicated worker (or wraps one you pass in viaopts.worker, or starts the worker entry atopts.url) and returns a typed handle. See Loading the worker from a CDN. -
LayoutWorkerHandle.registerFonts(faces: FontPayload[]): Promise<void>— ship font bytes into the worker. Buffers are transferred, so keep a fresh copy on the main thread if you need to re-send later. -
LayoutWorkerHandle.build(content, config?, { signal? }): Promise<VDTDocument>— run the pipeline. Aborting the signal cancels the in-flight build. -
LayoutWorkerHandle.dispose(): void— terminate the worker and reject any pending builds withAbortError. -
FontPayload—{ family, weight, style, unicodeRange?, buffer: ArrayBuffer }.weightis a CSS weight string ('700','bold');registerFontsalso takes it as a number (700). Thebufferis transferred to the worker when you callregisterFonts. -
BuildCancelledError(re-exported frompostext) — whatbuildDocumentthrows internally whenoptions.shouldCancelreturnstrue. You do not usually see this on the main thread: the worker protocol converts it to anAbortErrorbefore it reaches your code.
The package also publishes a postext/worker/entry path pointing at the compiled worker script. createLayoutWorker() resolves this URL automatically; you only need to reference it explicitly when your bundler requires a hand-constructed new Worker(new URL(...), { type: 'module' }) call, or when you serve the entry yourself (opts.url).
import { createLayoutWorker } from 'postext/worker';
import type { FontPayload, LayoutWorkerHandle } from 'postext/worker';
import type { PostextConfig, VDTDocument } from 'postext';
// 1. Create the worker once and keep the handle for the lifetime of your viewport.
const layout: LayoutWorkerHandle = createLayoutWorker();
// 2. Register fonts once per family (transferable ArrayBuffers).
// getConfigFontFamilies(config) is a helper that lists the families your config will render.
const payloads: FontPayload[] = await collectFontPayloadsForFamilies([
'EB Garamond',
'Open Sans',
]);
await layout.registerFonts(payloads);
// 3. Drive builds with last-wins cancellation: abort the previous signal
// before starting a new build. A stale build is thrown away inside the worker.
let pending: AbortController | null = null;
async function rebuild(
markdown: string,
config: PostextConfig,
): Promise<VDTDocument | null> {
pending?.abort();
pending = new AbortController();
try {
return await layout.build({ markdown }, config, { signal: pending.signal });
} catch (err) {
if ((err as { name?: string } | null)?.name === 'AbortError') return null;
throw err;
}
}
// 4. Dispose when the component that owns the worker unmounts.
// Pending builds reject with AbortError.
layout.dispose();Wrapped in a React component the shape is:
import { useEffect, useRef } from 'react';
import { createLayoutWorker } from 'postext/worker';
import type { LayoutWorkerHandle } from 'postext/worker';
import { renderPageToCanvas } from 'postext';
import type { PostextConfig } from 'postext';
export function CanvasPreview({
markdown,
config,
}: {
markdown: string;
config: PostextConfig;
}) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const workerRef = useRef<LayoutWorkerHandle | null>(null);
const pendingRef = useRef<AbortController | null>(null);
// Mount: spin up the worker and ship the fonts once.
useEffect(() => {
const handle = createLayoutWorker();
workerRef.current = handle;
(async () => {
const payloads = await collectFontPayloadsForFamilies(
getConfigFontFamilies(config),
);
await handle.registerFonts(payloads);
})();
return () => {
pendingRef.current?.abort();
handle.dispose();
};
}, []); // fonts registered once; re-register only when the family set changes
// Every keystroke or config change: supersede the in-flight build and kick a new one.
useEffect(() => {
const handle = workerRef.current;
if (!handle) return;
pendingRef.current?.abort();
const ac = new AbortController();
pendingRef.current = ac;
(async () => {
try {
const vdt = await handle.build({ markdown }, config, { signal: ac.signal });
const canvas = canvasRef.current;
if (!canvas || !vdt.pages[0]) return;
renderPageToCanvas(vdt.pages[0], vdt, canvas); // rasterise on the main thread
} catch (err) {
if ((err as { name?: string } | null)?.name !== 'AbortError') throw err;
}
})();
}, [markdown, config]);
return <canvas ref={canvasRef} />;
}The pattern is always the same: create once, register fonts once, build-with-AbortSignal many times, dispose on unmount.
A worker script must come from the page's own origin, so a copy of postext/worker served by a CDN cannot start the layout.worker.js file next to it. createLayoutWorker() handles that:
-
esm.sh, with no options. When
postext/workerwas itself loaded from esm.sh (its module URL looks likehttps://esm.sh/postext@1.5.0/es2022/worker.mjs), the client starts the matchinghttps://esm.sh/postext@1.5.0/worker/entrythrough a one-line, same-origin blob module that imports it. The same holds for imports that add?deps=,?external=or?alias=, and for thehttps://esm.sh/*postext@1.5.0/workerform. The worker always gets the plain build of that version, because a worker has no import map to resolve external dependencies with. -
Any other server, with
url. Other CDNs, such as jsDelivr (/+esm) or unpkg, are not detected.createLayoutWorker({ url })starts the worker entry module aturl: a same-origin URL directly, a URL on another origin through the same blob wrapper. That server must allow cross-origin requests (CORS). -
Bundlers change nothing. With Vite, webpack or Next.js, keep calling
createLayoutWorker()with no options: they emit the worker as a chunk of your app.
import { createLayoutWorker } from 'https://esm.sh/postext/worker';
const layout = createLayoutWorker();
const face = async (weight, style) => ({
family: 'EB Garamond',
weight,
style,
buffer: await (await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/eb-garamond@5/files/eb-garamond-latin-${weight}-${style}.woff2`)).arrayBuffer(),
});
await layout.registerFonts(await Promise.all([face(400, 'normal'), face(700, 'normal'), face(400, 'italic')]));
const doc = await layout.build({ markdown }, { bodyText: { fontFamily: 'EB Garamond' } });A worker does not see the page's fonts. It has a font set of its own, holding only the faces sent with registerFonts and the fonts installed on the system. When a build sets text in a family the worker cannot find, that text is measured with a fallback font, so its line breaks will not match the page. The client then prints one console warning per family ("EB Garamond" is not available inside the layout worker…), and lists the families in BuildStats.missingFonts, which the onStats callback of build receives. The document itself carries the same faces as fontFallback content warnings.
handle.prepareFonts(content, config, options) runs prepareFonts on the page and then sends the worker the files of every face it found that the font registry holds (the resolver's files, a bundle's faces, the page's readable @font-face rules), only the slices that hold the document's characters. When faces reach the worker, it drops the measurements of those families only and its cache of finished documents.
Faces may also reach the worker after a build: a weight the pages turn out to need, another unicode-range slice of a cut, a file uploaded again under the same family name. The next build measures with them. A worker's canvas would not do so on its own: in Chrome it resolves a font the first time a font shorthand is set and does not look at the font set again for that shorthand, so the text would keep the widths of the face that answered first. The worker therefore names a family once more in the shorthands it sets once a face of that family has arrived late (Geist, Geist): another family list for the canvas, the same font and the same widths. The documents it returns keep the font strings as the engine writes them, and a worker that has its faces before its first build sets every shorthand as written.
registerFonts takes raw font bytes. The main thread is the right place to fetch them, because a central cache lets multiple worker instances share the same bytes.
The sandbox's collectFontPayloadsForFamilies (packages/postext-sandbox/src/controls/fontLoader.ts) is a reference implementation. It:
- Queries
https://api.fontsource.org/v1/fonts/{family-id}for the weights and styles the family has. - Reads the family's subsets and their
unicode-rangefrom its package on jsDelivr (@fontsource/{family-id}@5/400.css), once per family. The files are then asked of the exact version that style sheet was answered from (jsDelivr'sx-jsd-version), so a family never arrives from two versions of its package. - Downloads every file of the regular, the bold and their italics of each family (
files/{family-id}-{subset}-{weight}-{style}.woff2);collectFontPayloadsForConfig(config, markdown)takes the weights and styles a configuration asks for instead. They are static files, the ones the PDF embeds, so the worker measures with what the PDF draws. A variable file gives other advances between two of its masters, and a line a fraction of a pixel from its measure breaks elsewhere. - Returns a
FontPayload[]wherebufferis a freshArrayBufferper call — important, becauseregisterFontstransfers the buffer and leaves the sender-side copy detached.
The files of a cut go in the order a pen declares them, latin first and the script's own files last (arabic, the numbered slices of a Chinese or Japanese family), since the file added last answers for a character two files hold. A family Fontsource has no files for is read from Google's CSS2 style sheet instead, which only returns WOFF2 to browser-like User-Agent strings.
Pair it with getConfigFontFamilies(config) to get the list of families a given PostextConfig will actually render (body, headings, list bullets, ordered-list numbers).
If you are driving buildDocument yourself — for example inside a custom worker — the pipeline exposes a shouldCancel hook you can use directly:
import { buildDocument, BuildCancelledError } from 'postext';
let superseded = false;
try {
const vdt = buildDocument(content, config, cache, {
shouldCancel: () => superseded,
});
} catch (err) {
if (err instanceof BuildCancelledError) return; // a newer build took over
throw err;
}shouldCancel is called once per top-level block during placement. The hook is intentionally cooperative — it cannot stop pretext's own layout call mid-line, but it keeps the cancellation granularity small enough (milliseconds) that a fast-typing user never waits on a stale build.
The PDF backend takes a ready VDTDocument and turns it into PDF bytes. It does not re-run layout. That means the canonical browser PDF flow pairs cleanly with the worker: build the VDT in the worker (off the main thread, cancellable, cache-reusing), then call renderToPdf on the main thread against the same VDT.
import type { LayoutWorkerHandle } from 'postext/worker';
import { renderToPdf } from 'postext-pdf';
import type { PostextConfig } from 'postext';
import { createPdfFontProvider } from './pdfFontProvider';
const fontProvider = createPdfFontProvider();
export async function exportPdf(
layout: LayoutWorkerHandle,
markdown: string,
config: PostextConfig,
): Promise<Uint8Array> {
// 1. Build the VDT in the worker — UI stays responsive during the layout passes.
const vdt = await layout.build({ markdown }, config);
// 2. Rasterise to PDF on the main thread. renderToPdf is fast once the VDT exists
// because it is walking precomputed coordinates, not remeasuring text.
return renderToPdf(vdt, {
fontProvider,
// pdfGeneration config on `vdt.config` is honoured automatically.
});
}If you already maintain a worker handle for the live preview, reuse it for export instead of spinning up a second worker — the measurement cache inside the worker makes a PDF export that follows an on-screen preview essentially free.
For a long book, writing the PDF itself takes seconds too; postext-pdf/worker runs that step on a worker of its own (see Rendering the PDF on a worker).
Use the worker for:
- Live previews, editors, and playgrounds. Anything where the document is rebuilt in response to user input.
-
Resize-aware HTML viewers that re-run layout on every
ResizeObservertick. - In-browser PDF export triggered from a UI that already has a live preview — reuse the existing worker handle so the export piggybacks on the measurement cache.
- Multiple output tabs that all need the same VDT (the sandbox's Canvas / HTML / PDF viewports share one worker handle per viewport mount).
Skip the worker for:
-
Server-side generation — Node does not have a browser
FontFaceSet, and you control the thread anyway. -
Isolated one-shot exports (a CLI, a headless export script, a Cloud Function) where no interactive UI exists to block. Calling
buildDocumentdirectly is simpler and avoids the cost of the initial font transfer.
← Configuration: units, fonts, colours, viewers and debugging · Programmatic usage: PDF files →
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