Chapter 12 · Part II · The craft
Configuration: programmatic usage
Calling Postext from code: buildDocument, Web Workers, the HTML viewer, PDF files, the 3D book, EPUB and .postext bundles
In short
This page is for people who write code. It shows how to build a book with one function call and how to read the warnings it reports. It explains how to run the work in the background, so the page stays quick. It shows how to make a web view, a PDF file, a 3D book and an EPUB e-book. The last section explains the file that carries a whole book with its fonts and pictures.
#Programmatic Usage
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.
#Building a document
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`);#Warnings in the document
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 | :::paragraphsstyle names no paragraph style. | The paragraphs are set as body text. |
unknownCalloutType | :::callouttype names none of the calloutStyles — only raised once some are configured. | The box takes the first callout style. |
columnsFlowUnknown | :::columnsflow is neither snake nor parallel; value is what it says. | The group takes the default: parallel with breaks, snake without. |
unknownChipStyle | :chip[…]style names no chip style. | The chip takes the first chip 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. |
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 }]#Rendering a page to a bitmap
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));Live example: a page as an image
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.
import { buildDocument, renderPage } from 'https://esm.sh/postext';
const markdown = `# The Lantern
The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.
## Two columns
Postext lays this text out in **two columns**, breaking each paragraph with the *Knuth–Plass* algorithm and hyphenating with TeX patterns. Widows and orphans are avoided, and the columns are balanced on the last page.
The light it gave was small, but it was enough to find the step. The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.
The light it gave was small, but it was enough to find the step. The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.`;
const config = {
// 150 dpi: crisp enough for a preview, light enough to paint instantly.
page: { sizePreset: '17x24', dpi: 150 },
layout: { layoutType: 'double' },
bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 10, unit: 'pt' } },
};
// Postext measures text with the fonts the browser has loaded,
// so wait for every face the document uses before laying it out.
await Promise.all([
document.fonts.load('16px "EB Garamond"'),
document.fonts.load('bold 16px "EB Garamond"'),
document.fonts.load('italic 16px "EB Garamond"'),
document.fonts.load('bold 16px "Open Sans"'), // the default heading face
]);
// The whole layout: one entry per page in doc.pages, with exact coordinates.
const doc = buildDocument({ markdown }, config);
// Rasterise the first page. The canvas is sized to the page at the configured dpi.
const canvas = renderPage(doc.pages[0], doc);
document.getElementById('page').replaceChildren(canvas);
document.getElementById('status').textContent =
`${doc.pages.length} page(s) · page 1 is ${canvas.width} × ${canvas.height} px`;
// The same bitmap as a PNG file.
canvas.toBlob((blob) => {
const link = document.getElementById('download');
link.href = URL.createObjectURL(blob);
link.hidden = false;
}, 'image/png');index.html
<p id="status">Laying out…</p>
<a id="download" download="page-1.png" hidden>Download page 1 as PNG</a>
<div id="page"></div>style.css
body {
margin: 16px;
font-family: system-ui, sans-serif;
background: #e8e8e8;
}
#page canvas {
display: block;
max-width: 100%;
height: auto;
margin-top: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}Loads an interactive editor from codepen.io. The example imports the latest postext release from a CDN.
#React
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.
#Resolving defaults
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).
#Stripping defaults
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, 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. 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).
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).
#Parsing
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.
#Loading fonts before layout
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).
#Measurement cache
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 convergence passes 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.
#Global state shared in one page
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.
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).
#Running layout in a Web Worker
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, seven passes, up to five convergence iterations — 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.
#What the worker gives you
- Main thread stays free. Parsing, measurement, and the seven-pass convergence loop 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.
#Public API
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).
#Minimal integration
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.
#Loading the worker from a CDN
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.
#Font payload collection (Fontsource / Google Fonts)
registerFonts takes raw font bytes. The main thread is the right place to fetch them, because Google Fonts only returns WOFF2 to browser-like User-Agent strings, and 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 drop-in reference implementation. It:
- Queries
https://api.fontsource.org/v1/fonts/{family-id}to discover the available weights and whether the family ships a variable axis. - Builds a Google Fonts CSS2 URL that covers every weight and style the family advertises.
- Fetches the generated
@font-facestylesheet, scrapes eachsrc: url(...) format('woff2')declaration, and downloads the raw bytes. - Returns a
FontPayload[]wherebufferis a freshArrayBufferper call — important, becauseregisterFontstransfers the buffer and leaves the sender-side copy detached.
Pair it with getConfigFontFamilies(config) to get the list of families a given PostextConfig will actually render (body, headings, list bullets, ordered-list numbers).
#Cooperative cancellation inside the engine
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.
#Driving PDF export from the worker
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).
#When to use the worker, when not to
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.
#Integrating the HTML viewer
The HTML viewer is Postext's screen-first renderer. Instead of rasterising pages to a bitmap it emits absolutely-positioned DOM nodes whose geometry is driven by the same pipeline that produces print output. This makes it the right choice when you want readable, selectable, and resize-aware typography in a browser — a reading app, an in-product preview, or an embedded docs surface — without pulling in a PDF viewer.
The key pieces from the public API:
buildDocument(content, config, cache?)— runs the full layout pipeline and returns aVDTDocument.renderToHtmlIndexed(doc, options)— turns the VDT into a single HTML string plus a per-page / per-block breakdown. The breakdown enables cheap DOM patching when only a few blocks changed between renders.resolveHtmlViewerConfig(partial)— fills in the HTML-viewer defaults (maxCharsPerLine,columnGap,optimalLineBreaking).buildFontString+measureGlyphWidth+dimensionToPx— measurement primitives used to derive an actual pixel column width from a target character count.createMeasurementCache/clearMeasurementCache— pluggable caches so you can reuse measurements across re-layouts.prepareFonts/buildDocumentWithFonts/watchFonts/onFontsChanged— load the document's faces before layout and lay out again when faces arrive (see Loading fonts before layout).
#Live example: an HTML string
The whole round trip in plain JavaScript, before the React integration below: build the document, hand the VDTDocument to renderToHtml, and drop the string into a container. mode: 'single' stacks the pages vertically; background gives them a colour, since pages are transparent by default. The pen also prints the generated markup, so you can see the absolutely positioned lines the renderer emits — the browser paints them but never reflows them.
import { buildDocument, renderToHtml } from 'https://esm.sh/postext';
const markdown = `# The Lantern
The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.
## Two columns
Postext lays this text out in **two columns**, breaking each paragraph with the *Knuth–Plass* algorithm and hyphenating with TeX patterns. Widows and orphans are avoided, and the columns are balanced on the last page.
The light it gave was small, but it was enough to find the step. The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.
The light it gave was small, but it was enough to find the step. The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.`;
const config = {
// 96 dpi: page pixels are CSS pixels, so the HTML shows at its real size.
page: { sizePreset: '17x24', dpi: 96 },
layout: { layoutType: 'double' },
bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 10, unit: 'pt' } },
};
// Postext measures text with the fonts the browser has loaded,
// so wait for every face the document uses before laying it out.
await Promise.all([
document.fonts.load('16px "EB Garamond"'),
document.fonts.load('bold 16px "EB Garamond"'),
document.fonts.load('italic 16px "EB Garamond"'),
document.fonts.load('bold 16px "Open Sans"'), // the default heading face
]);
const doc = buildDocument({ markdown }, config);
// One HTML string for the whole document. Every line is an absolutely
// positioned element, so the browser never reflows the text.
const html = renderToHtml(doc, { mode: 'single', background: '#ffffff' });
document.getElementById('viewer').innerHTML = html;
document.getElementById('source').textContent = html;
document.getElementById('status').textContent =
`${doc.pages.length} page(s) · ${(html.length / 1024).toFixed(1)} KB of HTML`;index.html
<p id="status">Laying out…</p>
<div id="viewer"></div>
<details>
<summary>Generated HTML</summary>
<pre id="source"></pre>
</details>style.css
body {
margin: 16px;
font-family: system-ui, sans-serif;
background: #e8e8e8;
}
/* The page is wider than this pane: let it scroll instead of clipping it.
The renderer centres pages with an inline style, hence the !important. */
#viewer {
overflow: auto;
}
#viewer .pt-doc {
align-items: flex-start !important;
}
/* Each page is a .pt-page block; the renderer positions every line inside it. */
#viewer .pt-page {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
details {
margin-top: 16px;
}
#source {
max-height: 240px;
overflow: auto;
padding: 8px;
background: #fff;
font-size: 11px;
white-space: pre-wrap;
word-break: break-all;
}Loads an interactive editor from codepen.io. The example imports the latest postext release from a CDN.
#Minimal integration
The snippet below is the shortest useful integration: build the document at the current viewport size, render it into a container, and re-run on resize.
import { useEffect, useRef } from 'react';
import {
buildDocument,
renderToHtmlIndexed,
resolveHtmlViewerConfig,
buildFontString,
measureGlyphWidth,
dimensionToPx,
createMeasurementCache,
watchFonts,
onFontsChanged,
} from 'postext';
import type { PostextConfig, MeasurementCache } from 'postext';
// Screen-friendly DPI: at 144 DPI, an 8pt body size resolves to 16 px.
const HTML_DPI = 144;
const PADDING_PX = 24;
// Prose sample used to measure the target column width. Proportional fonts
// make "N × average width" unreliable, so we measure a representative string.
const SAMPLE =
'The quick brown fox jumps over the lazy dog. Sphinx of black quartz, judge my vow.';
function sampleForChars(n: number): string {
let s = SAMPLE;
while (s.length < n) s += ' ' + SAMPLE;
return s.slice(0, n);
}
export function PostextHtmlViewer({
markdown,
config,
mode = 'multi',
}: {
markdown: string;
config: PostextConfig;
mode?: 'single' | 'multi';
}) {
const hostRef = useRef<HTMLDivElement | null>(null);
const cacheRef = useRef<MeasurementCache>(createMeasurementCache());
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const relayout = () => {
const rect = host.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const viewer = resolveHtmlViewerConfig(config.htmlViewer);
const fontFamily = config.bodyText?.fontFamily ?? 'EB Garamond';
const fontWeight = config.bodyText?.fontWeight ?? 400;
const fontSize = config.bodyText?.fontSize ?? { value: 8, unit: 'pt' as const };
const fontSizePx = dimensionToPx(fontSize, HTML_DPI);
// Measure the *actual* column width for N characters of body prose.
const targetColumnPx = measureGlyphWidth(
sampleForChars(viewer.maxCharsPerLine),
buildFontString(fontFamily, fontSizePx, String(fontWeight), 'normal'),
);
const inner = Math.max(rect.width - PADDING_PX * 2, 100);
let columnWidthPx: number;
if (mode === 'single') {
columnWidthPx = Math.min(targetColumnPx, inner);
} else {
// Fit as many columns as we can at the target width.
const count = Math.max(
1,
Math.floor((inner + viewer.columnGap) / (targetColumnPx + viewer.columnGap)),
);
columnWidthPx = (inner - viewer.columnGap * (count - 1)) / count;
}
columnWidthPx = Math.max(Math.floor(columnWidthPx), 80);
// Single mode uses one very tall page; multi mode uses the viewport
// height so each VDT "page" becomes one column.
const pageHeightPx =
mode === 'single' ? Math.max(rect.height * 20, 200_000) : Math.max(rect.height - PADDING_PX * 2, 400);
const override: PostextConfig = {
...config,
page: {
...config.page,
dpi: HTML_DPI,
width: { value: columnWidthPx, unit: 'px' },
height: { value: pageHeightPx, unit: 'px' },
margins: {
top: { value: 0, unit: 'px' },
bottom: { value: 0, unit: 'px' },
left: { value: 0, unit: 'px' },
right: { value: 0, unit: 'px' },
},
},
layout: { ...config.layout, layoutType: 'single' },
bodyText: {
...config.bodyText,
optimalLineBreaking: viewer.optimalLineBreaking,
},
};
const doc = buildDocument({ markdown }, override, cacheRef.current);
const { html } = renderToHtmlIndexed(doc, {
mode,
columnGap: viewer.columnGap,
padding: PADDING_PX,
background: 'transparent',
});
host.innerHTML = html;
};
relayout();
const ro = new ResizeObserver(() => relayout());
ro.observe(host);
// Re-measure when web fonts land so glyph widths aren't taken from fallbacks.
const stopWatching = watchFonts();
const off = onFontsChanged(() => relayout());
return () => {
ro.disconnect();
off();
stopWatching();
};
}, [markdown, config, mode]);
return <div ref={hostRef} style={{ width: '100%', height: '100%', overflow: 'auto' }} />;
}A few notes on what this example is doing:
- Measuring the column, not approximating it. Because
maxCharsPerLineis a target expressed in characters, the actual pixel width depends on the body font.measureGlyphWidthgives a real measurement against the chosen font, which keeps the measure consistent across font swaps. - Rewriting the page. The HTML viewer treats each VDT "page" as one on-screen column. The example overrides
page.widthwith the measured column width, sets margins to zero (the padding lives outside the page in the wrapping.pt-docdiv), and usesHTML_DPI = 144so8ptbody text resolves to16px. - Font-loading awareness.
watchFontslistens todocument.fontsand, once a frame, drops what was measured in the families whose faces arrived;onFontsChangedthen lays the column out again. Without the relayout, the first render uses a fallback font's metrics and jumps when the real font lands. - Reusing the measurement cache. Creating the cache once per component means resizes and font-scale changes reuse measurements from the previous render instead of re-measuring every paragraph.
#Going further
The example above is intentionally flat. Production integrations usually add:
- Shadow DOM isolation — render into
host.attachShadow({ mode: 'open' })so nothing in the outer page can bleed CSS into the viewer. - Incremental patching —
renderToHtmlIndexedreturnspages[i].blocks, each with a stableidand the block's outer HTML. When only a few blocks differ between two renders you can replace those block wrappers in place instead of rebuildinginnerHTML. - Overlays — layering an absolutely-positioned SVG on top of each
.pt-pagefor cursors, selections, or baseline grids. - Links — the words of a Markdown link are wrapped in
<a href="…" rel="noopener noreferrer">, which takes the text colour and has no underline; see Document format › Links. In an editor-like viewer, intercept clicks ona[href]that do not start with#and open them in a new tab (:refanchors link within the document). - Single-ink pictures — with
diagramStyle.singleInkon, SVG<img>s get a CSS filter unless you passsingleInk: falsefor URLs that are already recoloured; see Single ink on canvas and in HTML.
The sandbox's HtmlPreview component (packages/postext-sandbox/src/viewport/HtmlPreview/index.tsx) implements all of these on top of the same API shown here and can be used as a reference. It also routes every build through a shared layout worker (see Running layout in a Web Worker) so that live edits and resizes never block the main thread — swap the direct buildDocument(...) call in the snippet above for layoutWorker.build(...) when you are ready to move layout off the main thread.
#How the HTML output differs from canvas and PDF
renderToHtml places every line, figure and design element exactly where the canvas and the PDF do, but it paints less around them:
| Feature | Canvas (renderPage) | HTML (renderToHtml) | PDF (renderToPdf) |
|---|---|---|---|
| Page background | White, with page.backgroundColor over the trim and bleed. | Transparent, unless you pass background or set page.backgroundColor (which then fills the whole page box, slug included). | White, with page.backgroundColor over the trim and bleed. |
Baseline grid (page.baselineGrid) | Drawn | Not drawn | Drawn |
Column rule (layout.columnRule) | Drawn | Not drawn | Drawn |
Cut marks (page.cutLines) | Drawn | Not drawn; the page box still includes the slug around the trim. | Drawn |
| Page negative | pageNegative option | Not available | pageNegative option |
| Text | Pixels | Selectable text in absolutely positioned elements, set in the CSS font families: the page must load the same faces. | Embedded fonts from your fontProvider; selectable, searchable and tagged. |
Vertical text (layout.writingMode: 'vertical-rl') | Characters painted one cell at a time, turned back upright; vertical forms through a twin face (loadVerticalAlternates). | The flow in one box turned a quarter turn; each line turned back upright and set with writing-mode: vertical-rl, so the browser takes the vertical forms and stands the characters; short numbers in text-combine-upright: all; a dash, an ellipsis, an interpunct or a wave dash in a box of its cell (the browser would advance it by its horizontal width), a dash stretched to fill it by flow.dashAdvances. | Upright characters through an Identity-V twin of each font; see Vertical text in the PDF. |
| Images | registerResourceImage | The resourceImageUrl(fileId) option; a grey placeholder box without it. | The resourceBytes(fileId) option. |
| Formulas | Vector paths | Inline <svg> | Vector paths |
| Links | None | :ref citations link to their resource; contents rows do not. | :ref citations and contents rows, plus the outline (bookmarks). |
The transparent page matters on a dark site: a preview without background shows black text on the site's dark background. Pass renderToHtml(doc, { background: '#ffffff' }), or give the document a page.backgroundColor.
The host page's text styles stay out. Every line is set at the widths the engine measured, so a letter-spacing, word-spacing, text-transform or font-variant that the output inherited from the page around it would widen the glyph runs and make the lines overprint. The .pt-doc root therefore resets the inherited text properties — letter and word spacing, case, indent, white space, font style, variant, weight, stretch, features and kerning, line height, alignment, text shadow and emphasis, hyphens, direction, writing mode, text stroke and fill, and mobile text inflation — before its own layout declarations, so the output looks the same inside a shadow root or under a styled element. The list is exported as HTML_TEXT_RESET, a string of CSS declarations: a host that mounts the pages' innerHtml (from renderToHtmlIndexed) in containers of its own sets it on their root. Up to postext 1.4 the root reset nothing; the workaround was a wrapper with all: initial.
#Generating PDFs
PDF output lives in a separate package, postext-pdf, so that web-only integrations do not pay the cost of pdf-lib and @pdf-lib/fontkit. The PDF backend does not re-measure text: it consumes the exact same VDTDocument you would feed to renderToCanvas or renderToHtml and translates its pixel-space coordinates into PDF points. The three outputs are therefore guaranteed to agree on line breaks, column heights, and resource placement.
In the browser, build the VDT through the Web Worker.
renderToPdfitself is fast once the VDT exists — the expensive part is the layout pipeline that produced it. Running that pipeline on the worker keeps the UI responsive and lets a PDF export reuse the same measurement cache the live preview already warmed up. See Driving PDF export from the worker for the recommended flow. The main-thread examples below are the reference for what the arguments mean — for UI code, build the VDT in the worker first and only callrenderToPdfdirectly.
#Installation
npm install postext postext-pdfpostext is a peer dependency of postext-pdf. Each postext-pdf release needs the postext it was released with, or a later one of the same major (its peer range is ^ that version, ^1.5.0 for 1.5.0), because it imports helpers postext added in that release. Upgrade the two together, and on a CDN pin them to the same version.
#Public API
The package exposes a single entry point and a handful of types:
renderToPdf(doc, options): Promise<Uint8Array>— takes aVDTDocument(or a book's chapters as an array of them) and returns the raw PDF bytes.PdfFontProvider— the callback signature(family, weight, style, request?) => Promise<Uint8Array | Uint8Array[]>thatrenderToPdfuses to request font bytes when it needs to embed a new family/weight/style combination.request.codePointsholds the characters the pages set in that face; the answer is one file, or several that make the face together (see Chinese, Japanese and Korean fonts).RenderToPdfOptions—{ fontProvider, resourceBytes?, outlines?, accessible?, colorSpace?, pageNegative?, characterGrid?, onProgress?, onWarning?, rasterizeSvg?, harfbuzzWasm?, print?, outputProfile?, profileBaseUrl? }.outlines,accessibleandcolorSpacefall back to the document'spdfGenerationwhen left out (see PDF generation (config)).resourceBytesis described in Resource bytes and print masters;onWarningin Which faces the provider is asked for and Warnings in the document.characterGrid: trueprints the gridcjk.grid.showdraws on screen, which the PDF otherwise leaves out (see Character grid).harfbuzzWasmsays where to load HarfBuzz'sharfbuzz.wasmfrom (a URL, relative to the page, or the file's bytes) for a document with right-to-left or joining text; left out, the copy beside postext-pdf's module, then the same harfbuzzjs release from jsDelivr and esm.sh.printtakes the print production settings (PDF/X standard, output profile, black, preflight), falling back to the document'sprint; with a PDF/X standard, orcolorSpace: 'cmyk', every colour is separated through the ICC output profile, whose bytesoutputProfilegives (else it is fetched fromprofileBaseUrl, by default the npm CDN copy of postext'sicc/folder).PdfWarning— a non-fatal problem reported throughonWarning; narrow onkind:'fontFallback'(PdfFontFallbackWarning), a face set in another cut of its family;'missingGlyph'(PdfMissingGlyphWarning), characters no file of a face has a glyph for;'variableFontDefaultInstance'(PdfVariableFontWarning), a variable font asked for at a weight other than its default instance;'cffEmbeddedWhole'(PdfCffEmbeddedWholeWarning), a CFF face over 2 MB embedded whole;'complexShapingUnavailable'(PdfComplexShapingWarning), right-to-left or joining text drawn without HarfBuzz, which could not be loaded (reasonlists where it was looked for), so Arabic marks are misplaced;'outputProfileUnavailable'(PdfPrintWarning), a CMYK render whose output profile could not be loaded, converted with the naive formula;'pageNegativeIgnored'(PdfPrintWarning), the page negative left out of a PDF/X-1a file; or'missingImage', an image with no bytes drawn as a placeholder (reported only to anonWarningyou pass; the font warnings without one go toconsole.warn).decompressWoff2(bytes): Uint8Array— helper that turns a WOFF2 file into TTF bytes, which is the formatpdf-libcan embed directly.createPdfWorker(options?), frompostext-pdf/worker— the same render on a Web Worker; see Rendering the PDF on a worker.
#Minimal example
import { buildDocument } from 'postext';
import { renderToPdf } from 'postext-pdf';
const vdt = buildDocument(
{ markdown: '# Chapter One\n\nThe story begins here…' },
{
page: { sizePreset: '17x24' },
layout: { layoutType: 'double' },
bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 9, unit: 'pt' } }, // 9 pt overrides the 8 pt default
},
);
const pdfBytes = await renderToPdf(vdt, {
fontProvider: async (family, weight, style) => {
// Return TTF bytes for this family/weight/style.
// See the "Font provider" section below for a real implementation.
const res = await fetch(`/fonts/${family}-${weight}${style === 'italic' ? 'i' : ''}.ttf`);
return new Uint8Array(await res.arrayBuffer());
},
});
// `pdfBytes` is a Uint8Array — save, download, or stream it.
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
window.open(url);#Why a font provider?
pdf-lib embeds real font files into the PDF — the browser's installed fonts are not available at render time, and a font you only loaded for on-screen measurement is not, on its own, enough to produce a self-contained PDF. renderToPdf scans the pages for every face they paint (one per family|weight|style combination — see Which faces the provider is asked for) and calls your provider once per unique combination. The provider returns a Uint8Array of TTF or OTF bytes, or a list of them for a face served as several files (see Chinese, Japanese and Korean fonts); pdf-lib subsets TrueType outlines and embeds CFF (.otf) files whole. Faces the provider answers with the same file, such as a regular cut standing in for the bold a family lacks, share one embedded font. A face no page ends up drawing with, such as the face of an SVG figure's text when the figure is drawn as a picture, is left out of the file.
Use per-weight static fonts, not a single variable font. Google Fonts often serves one variable WOFF2 per family covering the whole weight axis. pdf-lib can only embed the default instance from a variable file, so a bold paragraph would render at regular weight. Fontsource publishes per-weight static WOFF2 files that solve this cleanly — this is the pattern the sandbox uses. A variable file asked for at a weight other than its default instance is reported as a variableFontDefaultInstance warning.
Every word of the text lands where the layout put it. In paragraphs, list items, quotes, boxes and other flowing text, each word starts at the position the VDT measured, so a difference between the browser's widths and the embedded face's never builds up along a line. A line with no inline formatting is painted as one text object that moves the pen between words; justified lines, centred ones and lines with formatting are painted word by word. A character the face has no glyph for, which the browser measured in another font and the PDF paints as the face's missing-glyph box, moves none of the words after it, and is reported once per face as a missingGlyph warning. A space the face lacks, such as a narrow no-break space or a figure space, takes the width the browser gave it, and invisible characters such as the word joiner and the zero-width space are not drawn. A non-breaking hyphen (U+2011) the face lacks is drawn with the face's hyphen (U+2010), or its hyphen-minus when it has no hyphen either, as the browser shows it; Open Sans and Outfit, among others, lack both. Neither case counts as a missing glyph. There are two exceptions. A line with right-to-left letters is still painted as one run (see Languages and scripts). Text placed by a design (running heads and footers, openers, box titles and other design elements) is set with the embedded face's own widths, so a glyph the face lacks there still moves the rest of its line.
#Which faces the provider is asked for
renderToPdf walks the pages the way it paints them and asks the provider only for the faces they draw:
- the regular face of a block that sets any line, and the bold, italic or bold-italic face of each run actually set that way;
- chip runs, list markers, and the text of design slots (running heads, folios, opener and part bands);
- the caption, note and table-cell text of every resource;
- the faces an SVG figure's
<text>names, when the figure is embedded. A family the provider cannot supply at all falls through to the next family in the SVG'sfont-familylist.
So a heading family nobody slants is never asked for its italic, and a figure without a note never needs the note faces.
When the provider rejects a face, the render goes on. Another face of the same family is embedded in its place, and a PdfWarning is reported. The substitute is the first face that loads, trying the nine standard weights (100 to 900) in the order CSS font matching uses, which is also the face the browser shows in the preview:
- the same style first. For a weight from 400 to 500, the weights up to 500 go first, then the lighter ones from the nearest down, then the heavier ones from 600 up. For a weight below 400, the lighter weights go first, from the nearest down, then the heavier ones. For a weight above 500, the heavier weights go first, then the lighter ones;
- then the other style, italic for upright and upright for italic, at the weight asked for and the other weights in the same order.
A family without italics therefore sets its italic runs upright, a family that ships only 400 and 700 takes a 600 as 700, and a family that ships a single cut sets everything in it. The provider is asked one face at a time, in that order, and never twice for the same face, so a face nothing uses is never embedded. A family the provider cannot serve at all is asked for every one of those 18 faces before the render fails.
const bytes = await renderToPdf(doc, {
fontProvider,
onWarning: (w) => {
// { kind: 'fontFallback', family: 'Oswald', weight: 700, style: 'italic',
// fallback: { weight: 700, style: 'normal' }, reason: '…', message: '…' }
console.info(w.message);
},
});Without onWarning, the message goes to console.warn. The text keeps its positions, which come from the VDT and were measured with the faces the browser had, so a substitute with different widths can look tight or loose. Supply the real face to fix it. A render still fails (postext-pdf: failed to load font(s): …) only when the provider cannot supply any face of a family at a standard weight, upright or italic.
#Browser font provider (Fontsource + WOFF2)
The sandbox ships createPdfFontProvider() (packages/postext-sandbox/src/viewport/pdfFontProvider.ts), which you can copy into any browser app. The essentials:
import type { PdfFontProvider } from 'postext-pdf';
import { decompressWoff2 } from 'postext-pdf';
const bytesCache = new Map<string, Promise<Uint8Array>>();
function fontsourceId(family: string): string {
return family.toLowerCase().replace(/\s+/g, '-');
}
function fontsourceWoff2Url(
family: string,
weight: number,
style: 'normal' | 'italic',
): string {
const id = fontsourceId(family);
return `https://cdn.jsdelivr.net/npm/@fontsource/${id}@latest/files/${id}-latin-${weight}-${style}.woff2`;
}
export function createPdfFontProvider(): PdfFontProvider {
return async (family, weight, style) => {
const key = `${family}|${weight}|${style}`;
const cached = bytesCache.get(key);
if (cached) return cached;
const promise = (async (): Promise<Uint8Array> => {
const url = fontsourceWoff2Url(family, weight, style);
const res = await fetch(url, { mode: 'cors' });
if (!res.ok) throw new Error(`font fetch failed: ${res.status} ${url}`);
// pdf-lib needs TTF bytes, so decompress the WOFF2 wrapper client-side.
return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
})();
bytesCache.set(key, promise);
return promise;
};
}A production version should also:
- Query the available weights (via
https://api.fontsource.org/v1/fonts/{id}) and snap the requested weight to the nearest one the family actually ships, so a request forweight: 600on a family that only has{400, 700}still succeeds. - Fall back from italic to normal when a family has no italic cut for the requested weight, rather than failing the whole render.
- Reuse the cache across renders (keep the
bytesCachemodule-scoped, not per-call) so regenerating the PDF after a config change is effectively free.
#Chinese, Japanese and Korean fonts
A CJK face does not come as one small file. Fontsource ships Noto Serif SC as about a hundred files per weight, each holding part of the characters and declared in the family's stylesheet with its unicode-range (@fontsource/noto-serif-sc/400.css); the browser downloads the files a page's text touches. The latin file that the provider above fetches holds no Han at all, and the named subsets are incomplete: chinese-simplified of Noto Serif SC lacks 釵, and chinese-traditional of Noto Serif TC has none of the full-width marks (),!?:;.
So a provider may answer a face with several files. renderToPdf passes it the characters the pages set in that face (request.codePoints), collected from every chapter before anything is drawn, and the provider returns the files that hold them, in the order the browser looks them up. Each file is embedded as a subset of its own and each character is drawn from the first file that has a glyph for it: a chapter that touches 60 slices embeds 60 small subsets. A face asked for again, for the text of an SVG figure say, is asked only for the characters its files lack. A provider that returns one Uint8Array works as before. The sandbox provider reads the Fontsource stylesheet of the weight and style and fetches the files whose ranges hold the text; the core of it:
import type { PdfFontProvider } from 'postext-pdf';
import { decompressWoff2 } from 'postext-pdf';
type Slice = { url: string; ranges: Array<[number, number]> };
async function fontsourceSlices(family: string, weight: number, style: 'normal' | 'italic'): Promise<Slice[]> {
const id = family.toLowerCase().replace(/\s+/g, '-');
const cssUrl = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/${weight}${style === 'italic' ? '-italic' : ''}.css`;
const css = await (await fetch(cssUrl)).text();
return [...css.matchAll(/@font-face\s*{([^}]*)}/g)].map(([, rule]) => ({
url: new URL(/url\(\.?\/?([^)]+\.woff2)\)/.exec(rule)![1], cssUrl).href,
ranges: /unicode-range:\s*([^;]+);/.exec(rule)![1].split(',').map((part) => {
const [lo, hi = lo] = part.trim().slice(2).split('-');
return [parseInt(lo, 16), parseInt(hi, 16)] as [number, number];
}),
}));
}
export const sliceFontProvider: PdfFontProvider = async (family, weight, style, request) => {
// Where ranges overlap, the browser tries the last rule first.
const slices = (await fontsourceSlices(family, weight, style)).reverse();
const picked = new Set<Slice>();
for (const cp of request?.codePoints ?? []) {
const slice = slices.find((s) => s.ranges.some(([lo, hi]) => cp >= lo && cp <= hi));
if (slice) picked.add(slice);
}
if (picked.size === 0) picked.add(slices[0]!);
return Promise.all(slices.filter((s) => picked.has(s)).map(async (s) =>
decompressWoff2(new Uint8Array(await (await fetch(s.url)).arrayBuffer()))));
};A Latin family goes through the same code: English text gets its latin file alone, Czech gets latin and latin-ext.
Characters that no file of the face has a glyph for are drawn as the font's .notdef glyph (an empty box in most fonts), and renderToPdf reports them once per face after the pages are drawn:
// { kind: 'missingGlyph', family: 'Noto Serif TC', weight: 400, style: 'normal',
// characters: [',', '!', '?'], message: '…' }The Sandbox lists these, and the two warnings below, in its Checks panel after each PDF it generates. When the book, its settings or its resources change, they are marked as coming from an earlier PDF until the next one replaces them; opening another book clears them.
- Bold needs a static file per weight. Fontsource serves each weight of Noto Serif SC and TC as separate static files, so bold works in the Sandbox. The Google Fonts files (
NotoSerifSC[wght].ttf, 25 MB) are variable fonts: pdf-lib embeds their default instance, so a 700 face prints at 400, andrenderToPdfreports it asvariableFontDefaultInstance. For a bundle, cut one static instance per weight with fontTools (fonttools varLib.instancer NotoSerifSC[wght].ttf wght=700) and subset it to the book's characters withpyftsubset. - Use TrueType builds. Source Han Serif and the Noto Serif CJK
.otffiles have CFF outlines, which postext-pdf embeds whole, 8 to 25 MB per weight; a CFF face over 2 MB is reported ascffEmbeddedWhole. The TrueType builds (Google Fonts, Fontsource) are subset to the glyphs used. For Japanese, Noto Serif JP and Noto Sans JP (Google Fonts, or Fontsource's numbered slices), Shippori Mincho, Zen Old Mincho and BIZ UDMincho come as TrueType; Source Han Serif JP and theJP.otffiles of Noto Serif CJK are CFF. - Japanese forms of a pan-CJK face. One code point of Han, of the punctuation or of the quotes can be drawn one way in Japan and another in China, and a pan-CJK face (Source Han, Noto CJK) holds both. The PDF shapes a Japanese document (
locale: 'ja'), and an isolate in Japanese (:ltr[…]{lang=ja}) in any document, with the OpenType language systemJAN, so the face'sloclfeature prints the Japanese forms that the canvas and the HTML print throughlang. An isolate in another language inside a Japanese book is shaped with that language's forms (the font's default ones for Chinese) and tagged as aSpanwith its/Lang. Chinese and other documents are shaped with the font's default forms, as before. A face made for Japanese such as Noto Serif JP has Japanese default forms; it still sets the “ ” of Japanese text in theirJANforms.
A character missing from one family is not taken from another: Noto Serif TC does not borrow from Noto Serif SC. Settle coverage when you build the font files; the 红楼梦 showcase copies the glyphs its TC subsets lack from the SC face.
#Server-side font provider (Node, local files)
In Node you can skip the WOFF2 step entirely and read TTF/OTF files from disk:
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { PdfFontProvider } from 'postext-pdf';
const FONT_DIR = '/path/to/fonts';
function filename(family: string, weight: number, style: 'normal' | 'italic'): string {
const slug = family.replace(/\s+/g, '');
const styleSuffix = style === 'italic' ? 'Italic' : '';
const weightName =
weight >= 700 ? 'Bold'
: weight >= 600 ? 'SemiBold'
: weight >= 500 ? 'Medium'
: weight >= 300 ? 'Light'
: 'Regular';
return `${slug}-${weightName}${styleSuffix}.ttf`;
}
export const localFontProvider: PdfFontProvider = async (family, weight, style) => {
const buf = await readFile(join(FONT_DIR, filename(family, weight, style)));
return new Uint8Array(buf);
};#Resource bytes and print masters
resourceBytes(fileId) returns the raw bytes of a picture, and the backend sniffs them:
- PNG, JPEG, GIF and WebP embed as images;
- SVG markup is drawn as vector paths, its
textset as real text in the document's embedded fonts, or rasterised at 600 dpi in the browser when it uses features outside the vector subset. A<style>that holds only@font-facerules (faces an author embedded) is skipped and keeps the figure vector (since postext-pdf 1.25); any other style sheet makes it a raster, made with the faces its text names inlined fromfontProvider(unlessdiagramStyle.inlineFontsor the resource'ssvg.inlineFontsisfalse); - a PDF has its first page embedded verbatim, as a form XObject.
Each picture is stored in the file once, however many times it is drawn. An SVG drawn as vector paths becomes a form XObject that every page paints, so a frame or a logo in the page design of a thirty-page document is written once, not thirty times; each further page adds a few hundred bytes. Up to postext-pdf 1.4 every page carried its own copy of the paths.
An SVG figure can name a print master in svg.pdfFileId: a single-page PDF of the same figure, typically the original the SVG was exported from. renderToPdf asks resourceBytes for the master's id first. It embeds that page in place of the SVG, with its fonts, gradients and colour spaces intact, wherever the SVG is drawn: as a figure, as the picture of a table cell (TableCell.image), as a design image or as a box icon (its marker too). The VDT carries the master's id on each of those uses (svg.pdfFileId on the figure's resource, pdfFileId on a cell image and on a design image block), so in a book every chapter gets the master. The canvas and HTML backends keep drawing the SVG. The SVG's own bytes are used instead in three cases: the master is missing, it is not a PDF, or single ink is on (diagramStyle.singleInk recolours SVG markup only).
const resources: Resource[] = [{
id: 'map', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
svg: { fileId: 'map.svg', width: 800, height: 600, pdfFileId: 'map.pdf' },
}];
const files = new Map([['map.svg', svgBytes], ['map.pdf', masterPdfBytes]]);
const pdf = await renderToPdf(buildDocument({ markdown, resources }, config), {
fontProvider,
resourceBytes: (fileId) => files.get(fileId),
});A host may also return the master's bytes for the SVG's own id, as bundleResourceBytes does; both ways work.
#Vertical text in the PDF
A vertical page (layout.writingMode: 'vertical-rl') is drawn through a quarter-turn frame, as the canvas paints it, and its text is set down the column:
- Upright characters are shown through a second Type0 font of the same embedded file: the same CIDFont, widths and ToUnicode map, with
Encoding /Identity-V(vertical mode). One run of characters is one text object whose glyphs advance one em down the column by themselves (DW2 [880 −1000]), so readers select and extract a column as one line. The glyphs are shaped with OpenTypevertandfwid, which gives brackets, quotes, mainland pause marks, ellipses and dashes their vertical forms; a character that stands upright as it is keeps its horizontal glyph. Nothing of the font is embedded twice. - Latin words and long numbers run sideways with the horizontal font; a number in one cell stands upright, squeezed across to the em when wider; a mark the font has no vertical form for is turned or moved, as on the canvas.
- Tracking between characters is written as
TJnumbers, which in vertical mode move the pen down the column. - Every vertical line is marked with an
/ActualTextof its text, so copying and text extraction read it as written.pdftotextand pdf.js read the columns top to bottom, right to left; pdf.js starts a new line at a number set in one cell. - Links, bookmarks and destinations are mapped onto the sheet: a link over a vertical line is a tall, thin rectangle, and a bookmark opens the page at the top of its heading's column.
- A tagged PDF declares the writing mode on its
Documentelement (the Layout attributeWritingMode /TbRl, which every element inherits); PDF/UA-1 validation (veraPDF) passes on a vertical chapter. - Viewers: Acrobat, Preview, Chrome (PDFium), pdf.js and Poppler render the vertical fonts. A right-bound book (
page.binding) also asks viewers to lay its spreads out right to left (/Direction /R2L,/PageLayout /TwoPageRight); Acrobat and Foxit follow it, Chrome does not.
A 43-page chapter set in Noto Serif TC (the book's characters, TrueType) is about 820 KB, of which the two font subsets are most.
#Links in the PDF
The words of a Markdown link (see Document format › Links) become URI link annotations, one per run of linked words on a line. Each covers the line box and has no border. In an accessible render, each run is a Link element whose /Contents is its text. Only absolute http:, https:, mailto:, tel: and ftp: targets are linked, because a relative URL has no base inside a PDF. Characters outside printable ASCII are percent-encoded. :ref citations and contents rows keep their links inside the document.
#Complete browser example: build, render, download
Putting everything together — build the VDT, render to PDF, and trigger a download from the browser:
import { buildDocument, createMeasurementCache } from 'postext';
import { renderToPdf } from 'postext-pdf';
import { createPdfFontProvider } from './pdfFontProvider';
const fontProvider = createPdfFontProvider();
export async function downloadPdf(markdown: string, config: PostextConfig) {
const cache = createMeasurementCache();
const vdt = buildDocument({ markdown }, config, cache);
const bytes = await renderToPdf(vdt, { fontProvider });
const blob = new Blob([bytes.slice().buffer], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'document.pdf';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}Important: call ensureConfigFontsLoaded(config) (or equivalent) before buildDocument when your config references web fonts. Layout is measured against whatever font metrics the browser currently has for that family — if the real font has not landed yet, the VDT is measured against a fallback and the PDF will not match the canvas or HTML output. The sandbox does this explicitly before every render (see packages/postext-sandbox/src/viewport/PdfViewport.tsx).
#Live example: a PDF in the browser
The complete flow above, running in the browser: the pen imports postext and postext-pdf from a CDN, loads the web fonts, builds the document, embeds the Fontsource cuts through the font provider and hands the bytes to a link that opens the file in a new tab, and to a download link. The PDF you get has the same line breaks as the canvas and HTML output, real embedded fonts, and outline bookmarks.
import { buildDocument } from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';
const markdown = `# The Lantern
The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.
## Two columns
Postext lays this text out in **two columns**, breaking each paragraph with the *Knuth–Plass* algorithm and hyphenating with TeX patterns. Widows and orphans are avoided, and the columns are balanced on the last page.
The light it gave was small, but it was enough to find the step. The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.`;
const config = {
page: { sizePreset: '17x24' },
layout: { layoutType: 'double' },
bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 10, unit: 'pt' } },
};
// The PDF embeds real font files. Fontsource publishes one static WOFF2 per
// weight and style; decompress it to the TTF bytes pdf-lib can embed.
const fontProvider = async (family, weight, style) => {
const id = family.toLowerCase().replace(/\s+/g, '-');
const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@latest/files/${id}-latin-${weight}-${style}.woff2`;
const res = await fetch(url);
if (!res.ok) throw new Error(`font fetch failed: ${res.status} ${url}`);
return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
};
// Layout is measured with the browser's fonts, so load them before building:
// otherwise the PDF would not match the canvas or HTML output.
await Promise.all([
document.fonts.load('16px "EB Garamond"'),
document.fonts.load('bold 16px "EB Garamond"'),
document.fonts.load('italic 16px "EB Garamond"'),
document.fonts.load('bold 16px "Open Sans"'), // the default heading face
]);
const doc = buildDocument({ markdown }, config);
// Same VDT, now translated to PDF points: identical line breaks and placement.
const bytes = await renderToPdf(doc, { fontProvider });
// A PDF viewer cannot run inside this sandboxed result frame,
// so hand the file to a new tab and to a download link.
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
document.getElementById('open').href = url;
document.getElementById('download').href = url;
document.getElementById('links').hidden = false;
document.getElementById('status').textContent =
`${doc.pages.length} page(s) · ${(bytes.length / 1024).toFixed(0)} KB PDF`;index.html
<p id="status">Rendering…</p>
<p id="links" hidden>
<a id="open" target="_blank" rel="noopener">Open lantern.pdf in a new tab</a> ·
<a id="download" download="lantern.pdf">Download it</a>
</p>style.css
body {
margin: 16px;
font-family: system-ui, sans-serif;
}Loads an interactive editor from codepen.io. The example imports the latest postext release from a CDN.
#Rendering the PDF on a worker
postext-pdf/worker moves renderToPdf off the main thread. The worker writes the text, the vector figures, the structure tree and the file itself. Two jobs need the page, so the worker asks the main thread for them: fetching fonts, and rasterising an SVG through an <img>. For a book of hundreds of pages, rendering takes seconds that would otherwise freeze the page; for a few pages, calling renderToPdf directly is simpler.
import { createPdfWorker } from 'postext-pdf/worker';
const pdfWorker = createPdfWorker();
const bytes = await pdfWorker.render(docs, {
fontProvider, // runs on this thread
resourceBytes: new Map([['map.svg', svgBytes]]), // a Map; its buffers move to the worker
onProgress: ({ phase, pages, totalPages }) => showProgress(phase, pages, totalPages),
onWarning: (w) => console.info(w.message),
});
pdfWorker.dispose();render(docs, options)takes one document or a book's array of chapter documents. It takes the options ofrenderToPdf, with two differences.resourceBytesis aMap<string, Uint8Array>whose buffers are transferred, so pass copies of bytes you keep.rasterizeSvg, when given, runs on the main thread; by default the page's ownImageand canvas do the job.- One handle renders one document at a time.
dispose()terminates the worker and rejects any render still pending. createPdfWorker({ worker })takes aWorkeryou create, for build tools that control worker URLs. That worker must runpostext-pdf/worker/entry.
From a CDN. By default the worker script is loaded from the package's own URL (new URL('./pdf.worker.js', import.meta.url)). A page on another origin may not start it: imported from esm.sh, createPdfWorker() throws Failed to construct 'Worker': Script at 'https://esm.sh/postext-pdf@…/pdf.worker.js' cannot be accessed from origin …. Start a same-origin module worker that imports the entry instead (when you pin a version, pin the same one in both URLs):
import { createPdfWorker } from 'https://esm.sh/postext-pdf/worker';
const entry = URL.createObjectURL(new Blob(
["import 'https://esm.sh/postext-pdf/worker/entry';"],
{ type: 'text/javascript' },
));
const pdfWorker = createPdfWorker({ worker: new Worker(entry, { type: 'module' }) });The layout worker from postext/worker needs the same wrapper around postext/worker/entry (see Running layout in a Web Worker).
#Print-ready PDFs
For production print workflows, tune these configuration options before rendering:
page.cutLines.enabled: true— adds bleed area and crop marks around the trim, and gives every page a TrimBox and a BleedBox. See Cut lines.print: { standard: 'pdfx4', outputProfile: 'fogra51' }(or'pdfx1a') — a PDF/X file with the output intent, the identification and the boxes a printer checks; every colour and RGB picture separated through the ICC profile, 100 % K overprinting and large black areas in rich black. See Print production (config).colorSpace: 'cmyk'(orpdfGeneration: { forceColorSpace: true, colorSpace: 'cmyk' }) — the same separation without the PDF/X identification (crop marks are always in registration colour). PDF print masters are embedded as they are.page.dpi: 300— the layout's px per inch: a bitmap with no resolution of its own prints at this resolution at its natural size. The preflight reports pictures under 300 ppi at their printed size.ColorValue.cmyk— a colour authored in CMYK prints with its exact values.{ pageNegative: true }inRenderToPdfOptions— inverts the trim area using a Difference blend mode (cut marks stay un-inverted). Useful for preflight checks on dark-on-light typography.
#Reference implementation
The sandbox's PdfViewport component (packages/postext-sandbox/src/viewport/PdfViewport.tsx) wires the pieces above into a live preview with regenerate, download, and print buttons, and is a good starting point for any in-browser PDF integration. It builds the VDT through the shared layout worker (see Running layout in a Web Worker) so clicking Regenerate doesn't freeze the UI while the pipeline runs — the main thread only handles renderToPdf (which is already fast once the VDT exists).
#A 3D book (postext-folio)
postext-folio sets a laid-out document on the screen as a printed book lying open on a desk: spreads by the recto rule, and leaves the reader turns with the ‹ › buttons, the arrow keys, a swipe, a click on a page, or by taking a page by its edge and dragging it over. Each leaf curls in three.js according to its paper and casts a real shadow on the pages under it. The WebGL canvas draws the book still and turning alike, so a page never changes look when it lands. It is the viewer of the Cookbook's recipes and of the sandbox's Folio tab.
npm install postext postext-folio threeimport { buildDocument } from 'postext';
import { createFolioFromDocument } from 'postext-folio';
const doc = buildDocument({ markdown }, config);
const book = createFolioFromDocument(document.getElementById('book')!, doc, {
onChange: ({ pages }) => console.log('showing pages', pages),
});
// After an edit: the same viewer, on the same page.
book.setDocument(buildDocument({ markdown: edited }, config));- Pages are painted as they are needed.
createFolioFromDocumentpaints each page withrenderPageToCanvasat exactly the device pixels of a page slot (WebGL then shows it texel for pixel, as sharp as the canvas preview), and only the spreads around the open one (window, three either side by default). Pages that fall out of that window are freed, so a book of a thousand pages costs the memory of a few. A jump to a far page paints that spread first. Up to ten pages away the leaves turn one by one; farther, the block of pages in between lifts as one slab, as thick as those pages (their calipers summed), and lands on the other side.setDocumentkeeps the painting of every page that reads the same in the new layout ({ repaint: true }paints them all again, after an image came in). - The document sets the book. Its first page opens alone on the right when it is a recto (
pageIndexOffseteven), a right-bound book (page.binding: 'right', or a vertical document) lies mirrored and turns leftward, blank pages take the page's background colour, and the page's trim width (pageWidthMm) scales the paper's caliper and the boards. A chapter laid out with acontinuationcounts the book's other pages (pageIndexOffsetbefore it,bookPageCountafter it) into the thickness of the two page blocks without drawing them (extraPages). - The document sets the look. The paper, the binding, the desk and the light are the document's
foliosettings (doc.config.folio). A page set inside a:::paperrun carries its own stock (VDTPage.paper), and its leaf is drawn with that paper's colour, surface, thickness and stiffness. Withbinding.cover: 'pages'the first page is the front board and the last, when it is a verso, the back board (covers). A newspaper trim ('broadsheet','berliner','tabloid','compact') whose settings name no stock and no binding lies as folded newsprint, also when the host passes its ownfolio. - The container sets the size. The book fills it, with the buttons and the page count in the margins, so give the container a height; a resize paints the pages again at the new size. Below 560 px wide it shows one page at a time (
mode: 'auto';'single'and'double'force either): the spine runs along the page's inner edge and the leaf turns over it, a drag towards the spine turns forward, a swipe away from it goes back, a tap turns. - What the pointer does.
interaction(andsetInteractionlater) sets what the left button, one finger or a pen does on the book:'hand'(the default) takes and turns the pages,'orbit'turns the view as a right-drag does (for trackpads and tablets),'select'leaves the pointer to the host, to select text, say.pageAt(event)gives the page under a pointer and where on it ({ page, x, y }, fractions of the page from its top left corner), on the book as it is seen, tilted or orbited;pointOnScreen(point)goes the other way, for drawing a caret or a selection over the page.refreshPage(src)shows again a page canvas the host drew anew in place. The sandbox uses all of them to select text and follow the editor's caret on the 3D pages. - A magnifying glass for small print.
interaction: 'magnify'holds a round glass with a black rim over the book where the pointer is (a finger holds it above itself while it touches the screen). It shows the book as the reader's eye sees it, lit and curved as it lies, magnified most at the centre and curving in towards the rim. The wheel,+and−change how much it magnifies (setMagnification(zoom), 1.5 to 10; the default shows the page at about 5.5 CSS px a millimetre) and Esc puts it away;magnifier: { zoom, diameter }sets both from the start.createFolioFromDocumentpaints the pages under the glass again, sharp enough for its centre, so a newspaper's body type reads;createFoliotakes such paintings fromdetail: { paint(index, deviceWidth), release() }. The sandbox puts it on the Magnifying glass button (M). The glass also selects text: over the pages the cursor is a text cursor,pageAtreturns the point under the glass's centre (above the finger on a touch screen), and in the Sandbox a click there places the caret and a drag selects. - The reader can look round it. A right-drag orbits the view round the book (down to 70° from overhead), also while leaves are turning;
resetView()eases it back to the settings'tiltandyaw, andgetView()gives the view as it is seen now ({ tilt, yaw }, degrees) to store as those settings. The sandbox puts them on two buttons, Reset view and Save as default view. - Videos play on the pages. A click on a video's poster plays it on the page, in every
interactionmode, and it keeps playing while its leaf turns; another click pauses it, and it stops when the book comes to rest on a spread that does not show it. A video withplayer.autoplaystarts by itself the first time its spread is shown; one that also plays alongside the others (player.exclusive: false) starts, muted, each time and stops when the spread is turned away, several at once. Optionsvideos,videoUrlandonVideo, andstopVideo()on the viewer; see Document format › Videos on Folio's pages. - Fonts and images first. As for
renderPage, the faces the document uses must be loaded indocument.fontsand its resource images registered withregisterResourceImagebefore pages are painted. - Accessible. The viewer is a focusable group that takes ←/→ (mirrored for a right-bound book), Page Up/Down, Home and End; its buttons and page count are labelled (
labelstranslates them), and each page canvas carriesalttext (alt: (index) => …). - Without WebGL2, or when the reader asks for reduced motion, the spreads simply change. WebGL books are heavy for a phone (a texture per page side): the sandbox offers its Folio tab only where WebGL2 is available and the screen's short side is at least 600 px.
canFlip()says whether the leaves will turn in 3D here: WebGL2, and no reduced motion.
#Appearance
The appearance option, and setAppearance later, override what the document says. Anything left out keeps the document's:
const book = createFolioFromDocument(container, doc, {
appearance: {
folio: {
tilt: 22,
paper: { type: 'bookWove', texture: 'laid' },
binding: { type: 'hardcover', coverColor: { hex: '#5a1f1f', model: 'hex' } },
surface: { type: 'walnut' },
lighting: { environment: 'lamp' },
},
// Photographed desks: a folder laid out as postext.dev's /folio/textures/
// (manifest.json and one folder per desk). Without it, procedural maps.
textureBaseUrl: '/folio/textures',
// The picture for `folio.binding.spineImage`: the resource's URL,
// or a drawn canvas or image.
spineImage: spineUrl,
},
});
// A settings panel: the book redrawn in place, nothing painted again.
book.setAppearance({ folio: { ...folio, lighting: { environment: 'daylight' } } });
book.resetView();| Field | What it does |
|---|---|
folio | The folio settings: tilt, paper, binding, surface, lighting. Replaces the document's when given. |
pageWidthMm | The trim width of a page in mm, against which the caliper and the boards are scaled. From the document: the trimmed page at its dpi. Default 150 for createFolio. |
extraPages | : pages of the book outside the ones given, counted for the thickness of the page blocks and never drawn. |
covers | : the first page given is the front cover, the last the back cover (when it falls on a verso). They turn as boards and no case is drawn. From the document: binding.cover: 'pages' on a book that starts at its first page and ends at its last. |
spineImage | The picture printed on the spine, as a URL, a canvas or an image. createFolioFromDocument does not look resources up: pass the picture of the resource folio.binding.spineImage names. Ignored on a saddle stitch. |
textureBaseUrl | Where the photographed desk textures are served. Until they load, or without it, the desk is drawn with procedural maps. |
createFolio(container, { pages }) is the same viewer over any pages: image URLs, <img> or <canvas> elements, and "" for a blank page; a page can be { src, alt, paper }, with paper a :::paper-style stock for that leaf. PageFlipper is the three.js engine alone, for a host that lays out its own spread DOM; FlatPageFlipper is the flat page-turn that predates the 3D book, kept for the Cookbook's light table. The full option list is in the package README.
#Live example: a book in 3D
The pen imports postext and postext-folio from a CDN, lays a short document out and opens it as a book. Take the right page by its edge and drag it over.
import { buildDocument } from 'https://esm.sh/postext';
import { createFolioFromDocument } from 'https://esm.sh/postext-folio';
const paragraph = `The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved. The light it gave was small, but it was enough to find the step.`;
// Thirty-six short sections: about ten pages to turn.
const markdown = ['# The Lantern']
.concat(Array.from({ length: 36 }, (_, i) => `## Evening ${i + 1}\n\n${paragraph} ${paragraph}\n\n${paragraph}`))
.join('\n\n');
const config = {
page: { sizePreset: '17x24', dpi: 150 },
layout: { layoutType: 'double' },
bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 10, unit: 'pt' } },
};
// Postext measures text with the fonts the browser has loaded,
// so wait for every face the document uses before laying it out.
await Promise.all([
document.fonts.load('16px "EB Garamond"'),
document.fonts.load('bold 16px "EB Garamond"'),
document.fonts.load('italic 16px "EB Garamond"'),
document.fonts.load('bold 16px "Open Sans"'), // the default heading face
]);
const doc = buildDocument({ markdown }, config);
const status = document.getElementById('status');
// The book: drag a page by its edge, click it, or use ← → and the buttons.
// Pages are painted at the size they are shown, around the open spread only.
createFolioFromDocument(document.getElementById('book'), doc, {
onChange: ({ pages }) => {
status.textContent = `${doc.pages.length} pages · open at ${pages.map((i) => i + 1).join('–')}`;
},
});
status.textContent = `${doc.pages.length} pages · drag a page by its edge to turn it`;index.html
<p id="status">Laying out…</p>
<div id="book"></div>style.css
body {
margin: 0;
font-family: system-ui, sans-serif;
color: #eee;
background: radial-gradient(ellipse 70% 75% at 50% 42%, #272b34 0%, #1a1d23 58%, #121418 100%);
min-height: 100vh;
}
#status {
margin: 12px 16px 0;
font-size: 14px;
opacity: 0.8;
}
/* The viewer fits the book into its container: give it a height. */
#book {
height: calc(100vh - 48px);
--postext-folio-accent: #f0b35a;
}Loads an interactive editor from codepen.io. The example imports the latest postext release from a CDN.
#Live example: page images
createFolio with pages drawn on canvases, a blank last page and the paper colour.
import { createFolio } from 'https://esm.sh/postext-folio';
// Any pages will do: image URLs, <img> or <canvas> elements, and "" for a
// blank page. Here, eight pages drawn on canvases.
function drawPage(n) {
const canvas = document.createElement('canvas');
canvas.width = 600;
canvas.height = 840;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fbf8f1';
ctx.fillRect(0, 0, 600, 840);
ctx.fillStyle = `hsl(${n * 45} 45% 45%)`;
ctx.fillRect(60, 80, 480, 320);
ctx.fillStyle = '#222';
ctx.font = 'bold 56px Georgia, serif';
ctx.fillText(`Plate ${n}`, 60, 480);
ctx.font = '22px Georgia, serif';
for (let line = 0; line < 8; line++) ctx.fillRect(60, 530 + line * 30, line === 7 ? 260 : 480, 3);
ctx.textAlign = 'center';
ctx.fillText(String(n), 300, 800);
return { src: canvas, alt: `Plate ${n}` };
}
const pages = Array.from({ length: 8 }, (_, i) => drawPage(i + 1));
// A blank page at the end, drawn as paper.
pages.push('');
const status = document.getElementById('status');
createFolio(document.getElementById('book'), {
pages,
firstPageRecto: true, // page 1 opens alone, on the right
binding: 'left', // 'right' lays a right-to-left book mirrored
paper: '#fbf8f1',
onChange: (state) => {
status.textContent = `Showing ${state.pages.map((i) => i + 1).join('–')} of ${pages.length}`;
},
});
status.textContent = 'Drag a page by its edge, click it, or use ← →';index.html
<p id="status">Drawing pages…</p>
<div id="book"></div>style.css
body {
margin: 0;
font-family: system-ui, sans-serif;
color: #eee;
background: radial-gradient(ellipse 70% 75% at 50% 42%, #272b34 0%, #1a1d23 58%, #121418 100%);
min-height: 100vh;
}
#status {
margin: 12px 16px 0;
font-size: 14px;
opacity: 0.8;
}
/* The viewer fits the book into its container: give it a height. */
#book {
height: calc(100vh - 48px);
--postext-folio-accent: #f0b35a;
}Loads an interactive editor from codepen.io. The example imports the latest postext release from a CDN.
#EPUB books (postext-epub)
postext-epub writes a laid-out book as an EPUB 3.3 file, in the browser or in Node, with no server. It reads the same chapter documents renderToPdf takes for a book, so page numbers, notes, citations, cross-references, the contents and the index arrive resolved, and it returns the file as bytes. It is the writer behind the sandbox's EPUB 3 tab.
npm install postext postext-epubpostext is a peer dependency, as for postext-pdf: upgrade the two together, and on a CDN pin them to the same version.
#Fixed layout and reflowable
EPUB 3 defines two renditions, set by the package's rendition:layout property; layout picks one:
layout: 'fixed' | layout: 'reflowable' | |
|---|---|---|
| EPUB name | pre-paginated (fixed layout, FXL) | reflowable, the EPUB default |
| Content documents | One XHTML document per printed page, at the trimmed page size in CSS px | One XHTML document per chapter (a part opens one of its own) |
| What it keeps | The page: columns, floats, running heads, openers, line breaks and positions, in the embedded fonts. Text stays real text: selectable, searchable, read aloud | The text and its structure: headings, paragraphs rebuilt from the lines, lists, boxes as asides, figures and tables after the text that cites them, notes, links, print page markers. A style sheet derived from the configuration |
| What it gives up | The reader's choice of typeface, size and margins; on a small screen the page is scaled down | Columns, running heads, page design and the exact line breaks |
| Spreads and direction | page-spread-left / page-spread-right from parity and binding; a right-bound book reads right to left | Reading direction from the binding; vertical Chinese keeps vertical-rl, Arabic is dir="rtl" |
| Suited to | Designed pages: illustrated books, textbooks, catalogues, magazines; large screens | Running text: novels, essays, reports; phones and e-ink readers |
Both renditions carry the same navigation: a table of contents from the headings and part pages, a page list with the printed page labels, landmarks (cover, printed contents, start of the body) and an NCX for older reading systems.
#Writing a book
import { openBundle, buildBundle } from 'postext';
import { renderToEpub } from 'postext-epub';
const bundle = await openBundle(fileBytes);
const docs = buildBundle(bundle); // one VDTDocument per chapter, in book order
const bytes = await renderToEpub(docs, {
layout: 'reflowable',
metadata: { title: 'Lantern', creators: ['Ada Lovelace'], language: 'en' },
fonts: bundle.fonts.map((f) => ({ family: f.family, weight: f.weight, style: f.style, bytes: new Uint8Array(f.bytes), format: f.format })),
resourceBytes: (fileId) => {
const data = bundle.files.get(fileId);
return data ? { bytes: data, mediaType: '' } : undefined;
},
onWarning: (w) => console.warn(w),
});A single document is a book of one chapter: renderToEpub([doc], options).
renderToEpub(docs, options): Promise<Uint8Array>writes the file.optionsis{ layout, metadata, fonts?, svgFonts?, resourceBytes?, cover?, onProgress?, onWarning?, signal? }.metadata:titleandlanguage(a BCP 47 tag) are required;subtitle,creators,identifier,date,publisher,rights,descriptionandmodifiedare optional. A bare ISBN becomesurn:isbn:…. Without anidentifierthe book gets aurn:uuid:derived from its title, creators and language, so a new version of the same book keeps its place in a reader's library. Passmodifiedtoo for byte-identical output.fonts: the faces to embed,{ family, weight, style, bytes, format, unicodeRange? }withformatone ofwoff2,woff,ttf,otf. Each face becomes a file and an@font-facerule; several files with theirunicodeRangemake one face (Google Fonts slices). A family, weight or style the pages use with no embedded face is reported asmissingFont, and reading systems substitute their own. Embed only fonts whose licence allows it: a face withredistributable: falseis never written into the file (the book may be set with it, but its file stays out, also of SVG pictures).resourceBytes(fileId): the pictures the pages place, sync or async, as{ bytes, mediaType }; an emptymediaTypeis read from the bytes. Give bitmaps as stored and SVGs as their source, not the PDF print master (svg.pdfFileId). Each picture is stored once. A single-ink book (diagramStyle.singleInk) has its SVGs recoloured in the file. An SVG gets the faces its text names embedded in it, since a reading system shows it as an image that cannot see the book's fonts (see Fonts in SVG text): fromfonts(the slices that hold its characters), then fromsvgFonts.providerfor a family the book's text does not use. Families of faces markedredistributable: false, and thosesvgFonts.withhold(family)names, stay out and are reported once each asfontWithheld; a family with no face is reported assvgFontUnavailable, faces oversvgFonts.maxBytes(2 MiB) assvgFontsTooLarge.svgFonts.inline: false,diagramStyle.inlineFonts: falseand a resource'ssvg.inlineFonts: falsekeep the bytes as given. A picture with no bytes is reported asmissingImageand left as an empty frame.cover:{ bytes, mediaType, alt? }, a JPEG, PNG, WebP or SVG picture. The book then opens on a cover document holding it, and it is the package'scover-image(the thumbnail in a library). Without one, the fixed layout names its first page as the cover and the reflowable book has no cover picture.onProgress({ phase, done, total }):resources(fonts and pictures),documents(pages for a fixed layout, chapters for a reflowable one), thenpackage.signalaborts between steps.readEpub(bytes)reads a file back for a viewer, withoutDOMParser: layout, metadata, reading direction, every file by path, the manifest, the spine, the table of contents, the page list, the fixed-layout viewport and the cover. The sandbox's reader is built on it.
Both renditions carry EPUB Accessibility 1.1 metadata (access modes, features such as the table of contents and print page numbers, hazards and a summary) and make no WCAG conformance claim by default. The full option list and the limitations are in the package README.
#Checking a file with EPUBCheck
W3C EPUBCheck is the reference validator for EPUB; ebook stores check the files they receive with it. With it installed (brew install epubcheck, or the Java release), epubcheck book.epub lists errors, warnings and usage notes; the usage notes Postext output leaves (CSS-028, OBS-001, HTM_062) are informational. In the Postext repository, pnpm --filter postext-epub epubcheck checks the test suite's sample books, node packages/postext-epub/scripts/epubcheck.mjs book.postext --layout both lays out one .postext file or preset folder and checks both renditions, and pnpm --filter postext-epub validate runs the whole matrix of books (the guide, the showcase presets, Chinese, Arabic and Cookbook books), each of which passes with no errors or warnings.
#Bundles (.postext files)
A .postext file is a whole book in one file: a zip archive holding a preset.json manifest, one markdown file per chapter, the resource payloads (bitmaps, SVGs, PDF print masters) and the font files the configuration names. The Sandbox exports and imports it and the agent skill delivers it. The postext package can create and open it too, so a book can move between those tools and your own program without losing anything.
my-book.postext
├── preset.json manifest: name, locale, chapters, config, resources, fonts
├── chapters/01-dusk.md
├── chapters/02-night.md
├── resources/lantern.svg
└── fonts/ebgaramond-400-normal.woff2
The manifest is described field by field in the Sandbox's Preset bundle format appendix. A file may also carry layouts.json, the Sandbox's page counts, so the book opens already paginated there, or one per edition of a multilingual book (layouts.zh-Hant.json, read first). openBundle ignores them.
The API is exported from postext itself and from the postext/bundle subpath, which adds the low-level helpers. Import from postext when you also render. That way the bundle adapters and the renderers share one module instance, which matters on a CDN such as esm.sh, where every entry point is a separate build.
#Opening a bundle
openBundle takes the file's bytes (a Uint8Array, an ArrayBuffer, or a Blob / File from an <input type="file">) and returns everything the engine and its backends need:
import { openBundle } from 'postext';
const bundle = await openBundle(await file.arrayBuffer(), { locale: 'es' });
bundle.chapters; // [{ title, file, markdown }, …] in book order
bundle.config; // PostextConfig, ready for buildDocument
bundle.resources; // Resource[]
bundle.files; // Map<path, Uint8Array>: every file of the bundle| Field | What it holds |
|---|---|
manifest | The validated preset.json. |
id, name, description | From the manifest. |
locale, locales | The locale the content was read in, and every locale a bilingual bundle carries. options.locale picks one: first the exact tag, then the base language, then the bundle's own locale. |
chapters | { title, file, markdown } per chapter. A chapter without a title in the manifest takes the text of its first # heading. |
config | The default colour palette and the resource types in the bundle's language, then the manifest's config, then the locale's overrides. The bundle's language is locale above, so a bundle in one language gets its own labels whatever options.locale asks for. A manifest that names no language takes the one its config sets (locale, then the hyphenation locale), else options.locale. customFonts lists the bundle's font families. This is the same configuration the Sandbox opens the bundle with. |
resources | The resources, with the captions of the chosen locale. A size missing from the manifest is read from the file. |
fonts | One entry per face: { family, weight, style, format, file, bytes }. |
files | Every file in the archive, keyed by its path. |
thumbnail, canvasScope | The cover picture's path, and how the bundle asks to be viewed: the manifest's view, with the served language's localized[…].view over it. |
start | Where the book starts, for a bundle that holds part of a longer one: the manifest's start, or the localized[…].start of the served language. Absent for a book that starts at its beginning. |
warnings | Non-fatal problems: an unsupported font file, a missing print master. |
A payload's fileId is its path inside the bundle. resource.svg.fileId, resource.bitmap.fileId and every customFonts variant's fileId can be looked up directly in bundle.files. openBundle throws when the bytes are not a zip, when there is no valid preset.json (at the root or under one top-level folder), or when a file the manifest names is missing.
Bundles written by postext 1.4 or earlier
Every manifest createBundle and the Sandbox write carries configVersion: 11: the configuration rules its config was written for. A manifest without it was written by postext 1.4 or earlier, which set eighteen things differently:
- Heading breaks (rules 3): up to 1.4, a
headingsobject with no H1 break had none (see Per-level overrides). - The maths size (rules 4): up to 1.4, formulas came out 1.131 times larger than
fontSizeScalesays (see Formula size). - The space under an inline resource (rules 5): up to 1.4, the text after a
placement.position: 'here'figure or table resumed at the next grid line, with no float gap under it (seelayout.inlineResourceGapunder Layout). - The space around an inline resource inside a box (rules 6): up to 1.4, such a resource sat right against the text of the box around it (see
layout.inlineResourceGapInBoxesunder Layout). - Inline marks in headings (rules 6): up to 1.4, a heading printed the words of its
*italic*,**bold**and other marks in its own plain style (seeheadings.inlineMarksunder Headings). - The size of a drop cap (rules 6): up to 1.4, a design text's
dropCapwith nofontSizewas as tall as all the line boxes it spans, its top above the first line (seedropCapunder Text elements). - The room under a colon line (rules 6): up to 1.4,
keepColonWithListtook one line of room under the line ending in a colon as enough for the list, and a two-line first item the orphan and widow rules keep whole went on to the next column without it (seebodyText.colonListRoom). - The lines a box cut leaves (rules 6): up to 1.4, a box that split inside a paragraph or list item could leave one line of it on a side, as long as each side of the box held its
splitMinLineslines in all (seelayout.boxChildSplitMinLinesunder Layout). - Line breaks at a dash (rules 7): up to 1.4, Knuth-Plass never ended a line after an em or en dash set closed between words (
say—that’s), and the line-by-line breaker of formatted text only between two letters (seebodyText.breakAfterDashesunder Body text). - Ragged text (rules 7): up to 1.4, ragged running text was set line by line, each line filled before the next, whatever
optimalLineBreakingsaid (seebodyText.optimalRaggedunder Body text). - The split under a heading (rules 8): up to 1.4, the paragraph under a heading at the foot of a column kept as many lines as fit there, however few went on to the next column (see
headings.keepWithNextSplitunder Headings). - The space under a
:::paragraphscontainer (rules 8): up to 1.4, the style's space was set under the last paragraph before the grid snap, the next block's space above (a heading'smarginTop) was added under it, and the text's paragraph spacing was left out (seebodyText.paragraphContainerSpacingunder Body text). - Line breaks at a compound's hyphen (rules 8): up to 1.4, Knuth-Plass never ended a justified line after a hyphen between two letters (
well-known) in a paragraph without inline formatting, while it did in one with formatting (seebodyText.breakAfterHyphensunder Body text). - Poems with no separator (rules 9): up to 1.22, a
:::versepoem whose lines carried no||was set as single hemistichs, each line centred (seebodyText.verse.layoutunder Verse). - A first-line indent beside a hanging indent (rules 9): up to 1.22, a paragraph style's
hangingIndentreplaced itsfirstLineIndent, and the first line started atindent(see Paragraph styles). - A backslash at the end of a line (rules 9): up to 1.22, a backslash that ended a line of a paragraph, a quotation or a list item, and
\\before a space, printed, and the lines joined with a space (seebodyText.hardLineBreaksunder Body text). - Code fences (rules 9): up to 1.22, a
```or~~~fence and the lines inside it were read as Markdown: lines joined into paragraphs, a#line became a heading, the fences printed (seecodeStyle.blocksunder Code listings). - Verse turnovers (rules 10): in 1.23, a line of a poem set line by line that was wider than the measure turned over at its natural word spacing, however little it overflowed (see
bodyText.verse.tightenunder Verse).
openBundle and readBundle read such a manifest's config, and each locale's localized configuration, through migrateConfig, which writes out the breaks 1.4 laid out and multiplies the maths scale by 1.131 (its em display margins divided by it). A manifest stamped 3 to 7, written by a 1.5 prerelease, only gets the pins of the rules after its stamp. With 3 that is the maths size, the inline gap, the five pins of rules 6, the two of rules 7 and the three of rules 8; with 4, the inline gap and the pins of rules 6, 7 and 8; with 5, the pins of rules 6, 7 and 8; with 6, the pins of rules 7 and 8; with 7, the pins of rules 8 alone. The split under a heading (pinLegacyHeadingSplit) is written as headings.keepWithNextSplit: 'fill' on the headings the layers leave in force, when a chapter read has a heading and the configuration names no value of its own, keeps headings.keepWithNext on and does not turn bodyText.avoidOrphans off. The compound breaks (pinLegacyHyphenBreaks) are written as bodyText.breakAfterHyphens: false on the bodyText in force, when a chapter read sets a hyphen between two letters and the configuration neither sets it already nor turns optimalLineBreaking off. The space under containers (pinLegacyParagraphContainerSpacing) is written as bodyText.paragraphContainerSpacing: 'add' on the bodyText in force, when the configuration declares a paragraph style (in paragraphStyles or the HTML viewer's overrides), a chapter read opens a :::paragraphs container on a line of its own, and the configuration does not set it already. The dash breaks (pinLegacyDashBreaks) are written as bodyText.breakAfterDashes: false on the bodyText in force, when a chapter read sets an em or en dash closed between words (a letter, a digit or closing punctuation before it, a letter, a digit or an opening bracket or quote after it; a quotation mark before it counts when a letter, a digit, closing punctuation or a no-break space stands before the quote, as in "no"—and, not in said "—Hola; an inline mark touching the dash or the quote, such as the ** of **riddles.**—I, counts on either side) and the configuration does not set it already. The ragged breaking (pinLegacyRaggedBreaking) is written as bodyText.optimalRagged: false on the bodyText in force, when the configuration sets some running text ragged (a textAlign other than 'justify' on the body text, a paragraph style, a box body, the body of a part or the body of a section style (headingStyles[].bodyStyle), or in the HTML viewer's overrides), does not set it already and does not turn optimalLineBreaking off. The gap in boxes (pinLegacyBoxResourceGap) is written as layout.inlineResourceGapInBoxes: false on the layout in force, when a resource is embedded on its own line inside a :::callout of the chapters read and the configuration does not set it already. The box cut (pinLegacyBoxChildCut) is written as layout.boxChildSplitMinLines: 1 on the layout in force, when a chapter read opens a :::callout on a line of its own and the configuration does not set it already. The heading marks (pinLegacyHeadingMarks) are written as headings.inlineMarks: false on the headings the layers leave in force, when a heading of the chapters read carries a mark (*, _, ^, ~, :smallcaps[ or a link in its title) and the configuration names no value of its own. The drop caps (pinLegacyDropCapSize) get their 1.4 size written out as dropCap.fontSize, wherever they sit: in the unit of the element's leading when that is a length, else in the unit of its font size. The colon-line room (pinLegacyColonListRoom) is written as bodyText.colonListRoom: 'line' on the bodyText in force, when a list of the chapters read follows a line ending in a colon (blank lines between them allowed) and the configuration neither names a room nor turns keepColonWithList off. The inline gap (pinLegacyInlineGap) is written as layout.inlineResourceGap: 'above' on the layout the layers leave in force, when a line of the chapters read embeds a resource (::resource{id="…"} alone on its line, as the parser reads it: a mention in running text or in a code span does not count) and the configuration names no gap of its own. The size is pinned on the math the layers leave in force (a locale's own math replaces the shared one), and only when the chapters read have a $: a bundle without maths keeps its config as written. When neither the manifest nor the locale names a math, the one in force is readBundle's baseConfig (the reader's own), and it is pinned too, since 1.4 set the bundle's formulas at that size: a base fontSizeScale: 1.5 reads as 1.5 × 1.1312. The base's heading breaks are taken as they are. An old bundle therefore keeps what these rules laid out, and bundle.config shows the breaks, the maths size, the gaps, the heading marks, the drop-cap sizes, the colon-line room, the box cut, the dash breaks, the compound breaks, the ragged breaking, the split under a heading and the space under containers it is laid out with. The layout fixes of 1.5 have no pin and apply to it as to any book, so a page they touch can still move (see Formula size for the list). A preset.json written by hand for today's rules sets "configVersion": 11; stamping an old bundle's manifest with it is also the one-line way to read it with today's rules (an unversioned bundle then loses its heading-break pin too). A manifest stamped 8, written by postext 1.5 to 1.22, gets the four pins of rules 9 alone, as every older one does too: the verse layout (pinLegacyVerseLayout) is written as bodyText.verse.layout: 'bayt' on the bodyText in force, when a chapter read sets a :::verse poem whose fence names no layout and whose lines carry no hemistich separator and the configuration does not set it already; the paired indents (pinLegacyPairedIndents) drop the firstLineIndent of every paragraph style (in paragraphStyles or the HTML viewer's overrides) that also sets a non-zero hangingIndent; the forced line breaks (pinLegacyHardBreaks) are written as bodyText.hardLineBreaks: false on the bodyText in force, when a chapter read ends a line of a paragraph, a quotation or a list item with a backslash and the block goes on below it, or sets \\ before a space and more text (inline code and maths, display formulas, headings and :::verse poems aside), and the configuration does not set it already; and the code fences (pinLegacyCodeBlocks) are written as codeStyle.blocks: false, when a chapter read opens a ``` or ~~~ fence (three or more, up to three spaces in) and the configuration does not set it already. A manifest stamped 9, written by postext 1.23, gets the pin of rules 10 alone, as every older one does too: the verse turnovers (pinLegacyVerseTightening) are written as bodyText.verse.tighten: false on the bodyText in force, when a chapter read sets a poem line by line (a :::verse fence that names layout=lines, or names no layout over lines with no hemistich separator while the configuration does not set such poems as bayts) and the configuration does not set it already. A manifest stamped 10, written by postext 1.24, gets the pin of rules 11 alone, as every older one does too: the balancing of a character grid (pinLegacyGridBalancing) is written as headings.balancing.enabled: true on the headings in force, when the merged configuration sets cjk.grid.enabled in horizontal text and does not set enabled itself, since 1.24 balanced such a page by default. The rules of 11 also keep book titles from one-character breaks, set circled numbers as Chinese characters and set CJK design text with the body's rules (#637): a manifest stamped 10 or older gets cjk.titleMinChars: 1 (pinLegacyTitleBreaks) when a chapter read sets a title (《 〈 or :book[), cjk.circledNumbers: 'western' (pinLegacyCircledNumbers) when one sets a circled number (U+2460–U+24FF, U+2776–U+2793), and cjk.composeDesignText: false (pinLegacyDesignText) when a chapter read or the configuration itself holds CJK text, each on the cjk in force and only when the configuration does not set it already. They also cut inline tables and set :::columns in the running text (#634): a manifest stamped 10 or older gets tableStyle.splitInline: false (pinLegacyInlineTableSplit) on the tableStyle in force when a chapter read embeds a resource, and layout.flowColumns: false (pinLegacyFlowColumns) on the layout in force when a chapter read opens a :::columns fence on a line of its own, each only when the configuration does not set it already. They offer a float the head of a page-span opener's own column as well (#639): a manifest stamped 10 or older gets layout.floatsUnderOpener: false (pinLegacyOpenerHeadFloats) on the layout in force when the merged configuration sets a heading level or style with span: 'page' and a chapter read has a heading, only when the configuration does not set it already.
import { CONFIG_VERSION, migrateConfig } from 'postext/bundle';
migrateConfig({ headings: { fontFamily: 'Georgia' } }, undefined, { content: 'A book with no maths.' });
// => { headings: { fontFamily: 'Georgia', levels: [{ level: 1, breakBefore: { enabled: false } }] } }
migrateConfig({ math: { fontSizeScale: 1.2 } }, 3);
// => { math: { fontSizeScale: 1.35746…, marginTop: { value: 0.7072, unit: 'em' }, marginBottom: { value: 0.7072, unit: 'em' } },
// layout: { inlineResourceGap: 'above', inlineResourceGapInBoxes: false, boxChildSplitMinLines: 1 },
// headings: { inlineMarks: false, keepWithNextSplit: 'fill' }, bodyText: { colonListRoom: 'line', breakAfterDashes: false, breakAfterHyphens: false, verse: { layout: 'bayt', tighten: false }, hardLineBreaks: false }, codeStyle: { blocks: false } }
migrateConfig({ layout: { layoutType: 'single' } }, 4, { content: 'Text.\n\n::resource{id="fig"}' });
// => { layout: { layoutType: 'single', inlineResourceGap: 'above' } }
migrateConfig({ layout: { layoutType: 'single' } }, 5, { content: ':::callout\nText.\n\n::resource{id="fig"}\n:::' });
// => { layout: { layoutType: 'single', inlineResourceGapInBoxes: false, boxChildSplitMinLines: 1 } }
migrateConfig({ bodyText: { textAlign: 'left' } }, 6, { content: 'I say—that is all.' });
// => { bodyText: { textAlign: 'left', breakAfterDashes: false, optimalRagged: false } }
migrateConfig({ paragraphStyles: [{ id: 'verse' }] }, 7, { content: ':::paragraphs{style="verse"}\nA line.\n:::' });
// => { paragraphStyles: [{ id: 'verse' }], bodyText: { paragraphContainerSpacing: 'add' } }
migrateConfig({ bodyText: { fontFamily: 'Georgia' } }, 7, { content: 'A well-known tale.' });
// => { bodyText: { fontFamily: 'Georgia', breakAfterHyphens: false } }
migrateConfig(config, CONFIG_VERSION); // today's rules: `config` itselfcontent is the markdown the configuration lays out (a string or a list of chapters). Without it the maths size is pinned whenever maths is on, the space under containers whenever the configuration declares a paragraph style, and the two gaps, the heading marks, the colon-line room, the box cut, the dash breaks, the split under a heading and the compound breaks always, since the engine cannot tell whether the book has a formula, a :::paragraphs container, an inline figure, a marked heading, a list introduced by a colon, a box, a closed dash, a heading or a compound. The ragged breaking is pinned by the configuration alone, content or not. Migrate a stored configuration once and store it again under CONFIG_VERSION: the maths pin multiplies the scale, so a configuration migrated twice would grow twice. The verse layout and the code fences are pinned without it too, since the book may hold a :::verse poem with no separator or a fence, and the paired indents are pinned by the configuration alone. So are the 1.23 verse turnovers, since the book may set a poem line by line.
#Laying out and rendering a bundle
Four helpers connect an opened bundle to the engine and the backends:
loadBundleFonts(bundle)registers the bundle's faces withdocument.fonts, and their bytes with the engine's font registry for SVG pictures (registerFontBytes). Await it before laying out, because layout measures text with the fonts the browser has. Families the bundle names but does not carry (Google Fonts) still have to be loaded by you, as in any other document.registerBundleImages(bundle)decodes the pictures for the canvas backend (renderPage,renderToCanvas), video posters included.bundleImageUrl(bundle)is theresourceImageUrlresolver forrenderToHtml, andbundleVideoUrl(bundle)itsresourceVideoUrlresolver for the video files a bundle carries. Both recolour SVG figures whendiagramStyle.singleInkis on, once: they recolour the markup and flag the pictures so that no backend tints them again (see Single ink on canvas and in HTML). Both also embed in each SVG the faces its text names, from the bundle's own fonts first, then from the faces registered with the engine (see Fonts in SVG text);registerBundleImages(bundle, { onWarning })andbundleImageUrl(bundle, { onWarning })report a family with no face.buildBundle(bundle)lays the chapters out in order and returns oneVDTDocumentper chapter. Each chapter continues the one before it: heading and resource counters, the open part, page parity and page numbering. A chapter that prints the contents (:::toc) or the index (:::index) receives the whole book's outline. It takes the same options asbuildDocument, plusconfigto override the bundle's configuration,cacheto share a measurement cache andmetadata(see below).bundleResourceBytes(bundle)andbundleFontProvider(bundle, { decodeWoff2, fallback })are theresourceBytesandfontProvideroptions ofpostext-pdf'srenderToPdf. The font provider picks the nearest weight of the requested style from the bundle. For a.woff2face it needsdecompressWoff2, and for a family the bundle does not carry it callsfallbackwith the renderer's arguments,requestincluded, and passes on whatever it returns. A fallback that fetches only a family'slatinfile prints a Chinese family the bundle does not embed as empty boxes; one that answers with slices, such assliceFontProviderin Chinese, Japanese and Korean fonts, prints it whole.
import { openBundle, loadBundleFonts, registerBundleImages, buildBundle, renderPage,
bundleResourceBytes, bundleFontProvider } from 'postext';
import { renderToPdf, decompressWoff2 } from 'postext-pdf';
const bundle = await openBundle(bytes);
await loadBundleFonts(bundle);
await registerBundleImages(bundle);
const docs = buildBundle(bundle); // one VDTDocument per chapter
const firstPage = renderPage(docs[0].pages[0], docs[0]); // a <canvas>
const pdf = await renderToPdf(docs, { // the whole book
fontProvider: bundleFontProvider(bundle, { decodeWoff2: decompressWoff2, fallback: fontsource }),
resourceBytes: bundleResourceBytes(bundle),
});To lay out a single chapter yourself, pass bundle.chapters[i].markdown, bundle.resources and bundle.config to buildDocument, as you would for any document.
The book's metadata. As in the Sandbox, the first chapter's front matter is the book's: buildBundle hands its title, author and the rest to every chapter, so {title} and {author} running heads hold on every page and every chapter's doc.metadata carries them. A front-matter block at the top of a later chapter is ignored: it is found by its --- lines and blanked without being parsed, so YAML the parser would reject does no harm. options.metadata supplies values the front matter does not set (the front matter wins). The book's page count reaches every chapter too: {bookTotalPages} prints it, while {totalPages} counts the chapter (see Book page count).
const docs = buildBundle(bundle, { metadata: { author: 'A. Author' } });
docs[3].metadata.title; // the first chapter's `title:`Where the book starts. A bundle may hold part of a longer publication: pages 58 to 61 of an issue, chapter 4 of a textbook. Its start says what comes before it, in the terms of buildDocument's continuation: the pages before the first one (pageIndexOffset, which decides the side page 1 falls on, and with it the mirrored margins and the odd and even running heads), the page numbering in effect (pageNumbering), the heading counters (headings), the open part (part) and the resource, statement, footnote and line counters. createBundle writes it as preset.json's start, openBundle returns it as bundle.start, and buildBundle lays the first chapter out with it, as buildDocument({ markdown, continuation: start }, config) would, and chains the chapters after it from there; {bookTotalPages} counts the pages before the book too. A manifest without start reads as before, and a reader that does not know the field ignores it. bookPageCount is not part of it: the reader counts the pages. In a bundle with several languages, localized[…].start gives one edition a start of its own.
const { bytes } = await createBundle({
name: 'Field notes, chapter 4',
markdown,
config,
// Page 58, an even page, in chapter 4.
start: { pageIndexOffset: 57, pageNumbering: { startAt: 58 }, headings: { h1: 3 } },
});
const bundle = await openBundle(bytes);
bundle.start; // { pageIndexOffset: 57, pageNumbering: { startAt: 58 }, headings: { h1: 3, h2: 0, … } }
const [doc] = buildBundle(bundle);
doc.pages[0].pageLabel; // '58'#Live example: open a bundle
The pen loads a two-chapter sample book (lantern.postext, with its own typeface, an SVG figure and a table) from the repository. It registers the bundle's fonts and pictures, lays the book out with buildBundle and paints every page. Make the PDF renders the same documents with postext-pdf, embedding the bundle's fonts. Choose a .postext file of your own, exported from the Sandbox for example, to see it the same way.
import {
openBundle,
loadBundleFonts,
registerBundleImages,
buildBundle,
bundleResourceBytes,
bundleFontProvider,
renderPage,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';
// A two-chapter book with its own typeface, an SVG figure and a table.
const SAMPLE = 'https://cdn.jsdelivr.net/gh/drnachio/postext@main/docs/examples/open-bundle/lantern.postext';
const status = document.getElementById('status');
const pdfButton = document.getElementById('pdf');
let current = null;
async function show(data) {
// Chapters, config (fonts wired to the bundle's own files), resources and
// every file, keyed by its path inside the bundle.
const bundle = await openBundle(data);
// Layout measures text with the fonts the browser has: register the
// bundle's faces, and load the Google Fonts it names but does not carry
// (the default running heads use Open Sans; see the pen's CSS).
await loadBundleFonts(bundle);
await document.fonts.load('600 16px "Open Sans"');
await registerBundleImages(bundle);
// One VDTDocument per chapter, each continuing the one before it.
const docs = buildBundle(bundle);
const pages = docs.flatMap((doc) => doc.pages.map((page) => renderPage(page, doc)));
document.getElementById('pages').replaceChildren(...pages);
status.textContent = `${bundle.name} · ${bundle.chapters.length} chapter(s) · ${pages.length} page(s)`
+ (bundle.warnings.length ? ` · ${bundle.warnings.length} warning(s)` : '');
current = { bundle, docs };
pdfButton.disabled = false;
document.getElementById('links').hidden = true;
}
// Fonts the bundle does not carry come from Fontsource.
async function fontsource(family, weight, style) {
const id = family.toLowerCase().replace(/\s+/g, '-');
const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@latest/files/${id}-latin-${weight}-${style}.woff2`);
if (!res.ok) throw new Error(`font fetch failed: ${res.status} ${family}`);
return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
}
pdfButton.addEventListener('click', async () => {
pdfButton.disabled = true;
status.textContent = 'Rendering the PDF…';
const { bundle, docs } = current;
const bytes = await renderToPdf(docs, {
fontProvider: bundleFontProvider(bundle, { decodeWoff2: decompressWoff2, fallback: fontsource }),
resourceBytes: bundleResourceBytes(bundle),
});
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
document.getElementById('open').href = url;
document.getElementById('download').href = url;
document.getElementById('links').hidden = false;
status.textContent = `${bundle.name} · ${(bytes.length / 1024).toFixed(0)} KB PDF`;
pdfButton.disabled = false;
});
document.getElementById('file').addEventListener('change', async (event) => {
const file = event.target.files[0];
if (!file) return;
status.textContent = `Opening ${file.name}…`;
await show(file).catch((err) => { status.textContent = `Could not open ${file.name}: ${err.message}`; });
});
const res = await fetch(SAMPLE);
await show(await res.arrayBuffer());index.html
<p>
<label>Open a .postext file: <input id="file" type="file" accept=".postext,application/zip"></label>
<button id="pdf" disabled>Make the PDF</button>
<span id="links" hidden>
<a id="open" target="_blank" rel="noopener">open it</a> ·
<a id="download" download="book.pdf">download it</a>
</span>
</p>
<p id="status">Loading the sample book…</p>
<div id="pages"></div>style.css
body {
margin: 16px;
font-family: system-ui, sans-serif;
background: #e8e8e8;
}
#pages {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: flex-start;
}
#pages canvas {
display: block;
width: 240px;
height: auto;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}Loads an interactive editor from codepen.io. The example imports the latest postext release from a CDN.
#Creating a bundle
createBundle writes a .postext file from a document: its chapters, configuration, resources and the payloads they reference.
import { createBundle } from 'postext';
const { bytes, manifest, warnings } = await createBundle({
name: 'The Lantern',
locale: 'en',
chapters: [
{ markdown: '# Dusk\n\nIt is drawn in :ref{id="lantern"}.' },
{ title: 'Night', markdown: '# Night\n\n…' },
],
config,
resources: [{
id: 'lantern', typeId: 'figure', kind: 'svg', caption: 'The lantern.',
svg: { fileId: 'lantern.svg', width: 240, height: 150 },
createdAt: 0, updatedAt: 0,
}],
files: { 'lantern.svg': svgMarkup, 'garamond-regular': fontBytes },
});| Input | Meaning |
|---|---|
name, id, description, locale | The manifest's metadata. id defaults to a slug of name. |
chapters or markdown | The book, one { title?, markdown } per chapter, or a single document. |
config | The PostextConfig. Values equal to the defaults are left out of the manifest. |
resources | The resources. A picture names its payload by bitmap.fileId / svg.fileId (and svg.pdfFileId for a print master). |
files | The payloads by fileId (an object or a Map): the pictures the resources reference and the font files the config.customFonts variants reference. Values may be a Uint8Array, an ArrayBuffer, a Blob or a string (SVG markup). |
thumbnail | { data, mime }: a cover picture (PNG, JPEG, WebP, GIF or SVG). |
canvasScope | 'book' asks viewers to lay the whole book out as one canvas. |
start | Where the book starts, for a bundle that holds part of a longer one: the continuation a single document would be built with. Written as the manifest's start. |
mtime | The modification date written on every file of the archive (a Date, a timestamp or a date string). Left out, it is the time of the call, so two calls with the same input give different bytes. Pass a fixed date and the same input gives the same bytes, which you can hash or compare. A zip keeps a date and time with no time zone, in two-second steps, from 1980 to 2099, and the date is written in the machine's local time. For bytes that match on every machine, build the date from local fields, such as new Date(1980, 0, 1): a timestamp or a string ending in Z names an instant, which falls on a different local time in each time zone ('1980-01-01T00:00:00Z' is still 1979 west of UTC). A date outside those years, in local time, throws. |
localized | More languages of the same book, by locale tag: { es: { chapters?, config?, resources? } }. The inputs above are then the content of locale, which is required. See Bilingual bundles. |
It returns the archive's bytes, the manifest written as preset.json, every file as files (path → bytes) and a list of warnings. Files are named after their resource id (resources/lantern.svg) or font file name (fonts/…), and chapters after their order and title (chapters/01-dusk.md). Fonts are declared in the manifest's fonts, never inside config.customFonts. Some things are left out, each with a warning:
- a resource or font face whose payload is not in
files - a
.woffface (the PDF backend cannot embed one) - a family marked
redistributable: false
In the browser, hand bytes to a download link: URL.createObjectURL(new Blob([bytes], { type: 'application/zip' })). In Node, write them with fs.writeFile. createBundle and openBundle do not need a DOM. The dists use extensionless module paths, so under plain Node, without a bundler, they need a resolve hook. The repository's docs/examples/open-bundle/build-sample.mjs shows one in a few lines.
#Bilingual bundles
A .postext file can carry a book in several languages, and openBundle(bytes, { locale }) reads it in any of them. createBundle writes one from localized: one entry per extra locale, each with what differs from the primary content (the locale input):
const { bytes, manifest } = await createBundle({
name: 'The Lantern',
locale: 'en',
chapters: [{ markdown: '# Dusk\n\n…' }, { markdown: '# Night\n\n…' }],
config,
resources: [lanternFigure, hoursTable],
files: { 'lantern.svg': svgEn, 'lantern-es.svg': svgEs },
localized: {
es: {
chapters: [{ markdown: '# Anochecer\n\n…' }, { markdown: '# Noche\n\n…' }],
config: { headings: { levels: [{ level: 1, numberingTemplate: 'Capítulo {1}' }] } },
resources: [
{ id: 'lantern', caption: 'El farol.', svg: { fileId: 'lantern-es.svg', width: 240, height: 150 } },
{ id: 'hours', caption: 'Horas de luz.' },
],
},
},
});
const es = await openBundle(bytes, { locale: 'es' }); // Spanish chapters, config and captionschapters: the book in that language. The chapter files go into one folder per locale (chapters/en/01-dusk.md,chapters/es/01-anochecer.md) and the manifest'schaptersbecomes a locale → chapters map. A locale withoutchaptersreads the primary ones; when no locale has chapters of its own, they stay a single list.config: the configuration for that language. Each top-level key replaces the shared key wholesale when the bundle is read in the locale, soheadingsabove replaces the wholeheadingsobject. Keys left out, or equal to the shared ones, are shared and not written, so passing the locale's full configuration works as well as passing the few keys that change. A key set to its defaults while the shared one is not (layout: {}) is written as given, so it resets the shared value. Fonts are shared: the families of a locale'scustomFontsjoin the bundle'sfonts.resources: the wording of the shared resources, matched byid:caption,note,altTextand a table'stable. A picture with words in it can have its own artwork:bitmap.fileIdorsvg.fileId(andsvg.pdfFileId) name another payload infiles, written asresources/es/lantern.svg. Other fields, such as the type or the placement, are shared. An id that is not amongresourcesis left out with a warning, and a missing locale picture leaves the locale on the shared one, also with a warning.
The manifest lists every language in locales (['en', 'es']), keeps the primary one as locale and stores the rest under localized. openBundle without a locale reads the primary language.
Which language a reader gets. openBundle(bytes, { locale }) serves the exact locale, else its base language (es-MX reads es), else the primary one, and bundle.locale says which it served. Chapters and wording always come from the same language. The primary language keeps the shared wording even when localized carries a regional variant of it: a pt-PT bundle with a pt-BR entry reads the Brazilian captions for pt-BR only, and the shared ones for pt-PT and pt.
#Live example: create a bundle
The pen builds a two-chapter book with an SVG figure and lists the files createBundle wrote along with the manifest. It offers the archive as a download, then opens it again with openBundle and paints its first page: the full round trip in a few lines. Import the downloaded file in the Sandbox to keep working on it there.
import { createBundle, openBundle, registerBundleImages, buildBundle, renderPage } from 'https://esm.sh/postext';
// A picture resource names its payload by fileId; the bytes (here, SVG
// markup) go in `files` under that same id.
const lanternSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 150">
<rect width="240" height="150" fill="#f3efe6"/>
<path d="M100 36 h40 l8 14 h-56 z" fill="#2f3e46"/>
<rect x="98" y="50" width="44" height="58" rx="4" fill="#f6c453" stroke="#2f3e46" stroke-width="4"/>
<circle cx="120" cy="79" r="11" fill="#fff4c2"/>
<path d="M94 108 h52 l-6 12 h-40 z" fill="#2f3e46"/>
</svg>`;
const resources = [{
id: 'lantern',
typeId: 'figure',
kind: 'svg',
caption: 'The lantern by the door.',
svg: { fileId: 'lantern.svg', width: 240, height: 150 },
createdAt: 0,
updatedAt: 0,
}];
const text = 'The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.';
// One entry per chapter; a chapter without a title takes its first # heading.
const chapters = [
{ markdown: `# Dusk\n\n${text} It is drawn in :ref{id="lantern"}.\n\n${text}\n\n${text}` },
{ markdown: `# Night\n\n${text}\n\n${text}` },
];
const config = {
layout: { layoutType: 'double' },
// Two short chapters that run on, with no blank verso between them (an
// H1 otherwise opens on a fresh recto), as in the open-bundle sample.
headings: { levels: [{ level: 1, numberingTemplate: 'Chapter {1}', breakBefore: { enabled: false } }] },
};
// Everything a .postext file holds: manifest, chapters, resources, fonts.
const { bytes, manifest, files, warnings } = await createBundle({
name: 'The Lantern',
locale: 'en',
chapters,
config,
resources,
files: { 'lantern.svg': lanternSvg },
});
if (warnings.length) console.warn(warnings);
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/zip' }));
document.getElementById('download').href = url;
document.getElementById('actions').hidden = false;
document.getElementById('files').replaceChildren(...Object.entries(files).map(([path, data]) => {
const li = document.createElement('li');
li.textContent = `${path} (${data.length} B)`;
return li;
}));
document.getElementById('manifest').textContent = JSON.stringify(manifest, null, 2);
// Round trip: open the file just written, the way any program would.
await Promise.all([
document.fonts.load('16px "EB Garamond"'),
document.fonts.load('bold 16px "EB Garamond"'),
document.fonts.load('bold 16px "Open Sans"'), // the default heading face
]);
const bundle = await openBundle(bytes);
await registerBundleImages(bundle);
const [firstChapter] = buildBundle(bundle);
document.getElementById('page').replaceChildren(renderPage(firstChapter.pages[0], firstChapter));
document.getElementById('status').textContent =
`${bundle.name}: ${bundle.chapters.length} chapters, ${(bytes.length / 1024).toFixed(1)} KB`;index.html
<p id="status">Building the bundle…</p>
<p id="actions" hidden>
<a id="download" download="lantern.postext">Download lantern.postext</a> ·
<a href="https://postext.dev/en/sandbox" target="_blank" rel="noopener">open the Sandbox</a> and import it (Projects → New → Import .postext…)
</p>
<div id="output">
<section>
<h3>Files in the bundle</h3>
<ul id="files"></ul>
<h3>preset.json</h3>
<pre id="manifest"></pre>
</section>
<section>
<h3>Opened again: page 1</h3>
<div id="page"></div>
</section>
</div>style.css
body {
margin: 16px;
font-family: system-ui, sans-serif;
background: #e8e8e8;
}
#output {
display: flex;
flex-wrap: wrap;
gap: 24px;
align-items: flex-start;
}
#output section {
flex: 1 1 280px;
min-width: 0;
}
h3 {
margin: 8px 0;
font-size: 14px;
}
ul {
margin: 0;
padding-left: 20px;
font-family: ui-monospace, monospace;
font-size: 13px;
}
pre {
max-height: 320px;
overflow: auto;
padding: 8px;
background: #fff;
font-size: 12px;
}
#page canvas {
display: block;
max-width: 100%;
height: auto;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}Loads an interactive editor from codepen.io. The example imports the latest postext release from a CDN.
#Working with bundles
Because the Sandbox, the agent skill and the postext package all read and write the same file, a .postext file is a convenient way to hand a book from one tool to another:
- Start from a bundle. Port an existing publication with the agent skill, or design a book in the Sandbox and export it (Download (.postext) in its row's ⋯ menu in the Books panel). Load the file from your program with
openBundleto render it to canvas, HTML or PDF. Keep the file as the book's source: edit the chapters, the configuration or the resources in code and write it back withcreateBundle, or simply reload it whenever it changes. - Debug and fine-tune in the Sandbox. When something in your program's output needs work (a figure that lands on the wrong page, a heading style, the column balance), export what your program lays out with
createBundle. Import that file in the Sandbox (Books → New → Open a .postext file…), fix the text, design or figures with the live preview, the Checks panel and the PDF view, then export it again. Your program then loads the corrected file withopenBundle. Or copy what changed back into your code: the manifest'sconfigholds only the values that differ from the defaults, so it reads as a short diff.
#Low-level API
postext/bundle also exports the building blocks behind openBundle and createBundle, for hosts that store or serve bundles their own way (an unzipped directory over HTTP, records in a database):
openBundleZip(bytes)/zipBundle(files, { mtime }): the archive layer. Opening tolerates a top-level folder and ignores__MACOSXentries and dotfiles. Paths that escape the bundle are refused.mtimedates the files ascreateBundle's input does.readBundle(manifest, readFile, options)reads a manifest plus areadFile(path)callback into chapters, config, resources, pictures and fonts.optionssets the locale, how file ids are named (ids), the base configuration (baseConfig, under the manifest's; by default the palette and the resource types ofbundleBaseConfigin the bundle's language, whichresolveBundleConfigLocale(manifest, locale)returns, and a host that passes its ownbaseConfigshould localise it to that language; under a manifest older thanconfigVersion: 4itsmathis pinned with the bundle's, under one older than 5 itslayout's inline gap, under one older than 6 itslayout's gap in boxes, itsbodyText's colon-line room, itsheadings' inline marks and its drop-cap sizes, under one older than 7 itsbodyText's dash breaks and ragged breaking, and under one older than 8 itsheadings' split under a heading and itsbodyText's compound breaks and space under:::paragraphscontainers, see Bundles written by postext 1.4 or earlier) and how intrinsic sizes are measured.readResolutionreads each bitmap's resolution from its file intobitmap.fileResolution; it defaults to true when the bundle setslayout.bitmapResolution: 'file'.planBundle(meta, content)/resolveBundleFiles(plan, sources): the writing side, split into a pure plan (file names and manifest) and resolving the bytes throughreadBlob/readFontcallbacks.isBundleManifest(value), the locale pickers (pickChapterSpecs,pickLocaleOverrides,pickBundleView,resolveBundleLocale,resolveBundleConfigLocale),svgSize/bitmapSize/bitmapInfo(a bitmap's pixels and the resolution its file states), and the format types (BundleManifest,BundleResourceSpec,BundleFontFamilySpec, …).CONFIG_VERSION,migrateConfig(config, configVersion, { content }),pinLegacyHeadingBreaks(config),pinLegacyMathSize(config),pinLegacyInlineGap(config),pinLegacyBoxResourceGap(config),pinLegacyHeadingMarks(config),pinLegacyDropCapSize(config),pinLegacyColonListRoom(config),pinLegacyBoxChildCut(config),pinLegacyDashBreaks(config),pinLegacyRaggedBreaking(config),pinLegacyHeadingSplit(config),pinLegacyParagraphContainerSpacing(config),pinLegacyHyphenBreaks(config)andLEGACY_MATH_SIZE(0.5 ÷ 0.442): a stored configuration in today's terms (see Bundles written by postext 1.4 or earlier).readBundleapplies it; a host that stores configurations its own way can too, once per stored copy.
The Sandbox is built on these. It adds its own storage ids and the layouts.json page records.