Every layout decision in Postext is driven by a single configuration object.
PostextConfig controls page dimensions, column layout, body text typography, heading styles, and more. Every property is optional — Postext ships with sensible defaults inspired by traditional book typography. You only need to specify what you want to change.
import { buildDocument } from 'postext';
const document = buildDocument(content, {
page: { sizePreset: '21x28', dpi: 300 },
layout: { layoutType: 'double', gutterWidth: { value: 0.5, unit: 'cm' } },
bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 9, unit: 'pt' } },
headings: { fontFamily: 'Open Sans' },
});For an overview of how the engine processes this configuration, see the Architecture page.
#Index
This reference is long. These are the main blocks:
- Page — size, margins, baseline grid, cut lines.
- Layout — column count, gutters, rules.
- Body text — typography, hyphenation, orphans and widows.
- Headings — shared defaults and H1–H6 overrides.
- Unordered lists and Ordered lists — bullets, numbering, nesting.
- Math — LaTeX rendering, scale, colour, margins.
- Units and colors + Color palette —
Dimension,ColorValue, named colors. - Custom fonts — declare user-uploaded font families alongside Google Fonts.
- HTML viewer — target column width and line-breaking for the HTML backend.
- PDF generation — outlines, forced colour space for the PDF backend.
- Debug — visual overlays and authoring warnings for the editor.
- Advanced configuration — declared types, future support.
- Programmatic use —
buildDocument, resolvers, caches. - Running layout in a Web Worker — off-main-thread builds with cancellation.
- Integrating the HTML viewer and PDF generation — end-to-end recipes.
#Page
The page property controls the physical dimensions and appearance of the page.
| Property | Type | Default | Description |
|---|---|---|---|
sizePreset | PageSizePreset | '17x24' | Predefined page size. Set to 'custom' to use explicit width/height. |
width | Dimension | 17 cm | Page width. Only used when sizePreset is 'custom'. |
height | Dimension | 24 cm | Page height. Only used when sizePreset is 'custom'. |
margins | PageMargins | 2 cm all sides | Space between the page edge and the content area. Each side (top, bottom, left, right) is set independently. |
backgroundColor | ColorValue | transparent | Page background color. |
dpi | number | 300 | Dots per inch. Affects how physical units (cm, mm, in) are converted to pixels. |
cutLines | CutLinesConfig | disabled | Show trim marks at page corners for print cutting. When enabled, the canvas expands to include bleed area and crop marks. See below. |
baselineGrid | BaselineGridConfig | disabled | Overlay a horizontal baseline grid for vertical rhythm alignment. See below. |
#Page size presets
| Preset | Width | Height | Common use |
|---|---|---|---|
'11x17' | 11 cm | 17 cm | Pocket books |
'12x19' | 12 cm | 19 cm | Standard paperback |
'17x24' | 17 cm | 24 cm | Technical books, textbooks |
'21x28' | 21 cm | 28 cm | Magazines, reports (near A4) |
#Baseline grid
The baseline grid draws horizontal lines at intervals matching the body text line height. It is a visual aid for ensuring vertical rhythm — when enabled, the engine snaps heading blocks to the grid so that body text in adjacent columns stays aligned.
| Property | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Whether to draw the baseline grid overlay. |
color | ColorValue | #cccccc | Color of the grid lines. |
lineWidth | Dimension | 0.5 pt | Thickness of the grid lines. |
page: {
baselineGrid: { enabled: true, color: { hex: '#e0e0e0', model: 'hex' } }
}#Cut lines
When enabled, the canvas expands to include a bleed area and the engine draws crop marks at each corner for print production.
| Property | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Whether to expand the canvas with bleed and draw crop marks. |
bleed | Dimension | 3 mm | Extra area around the page used for print bleed. |
markLength | Dimension | 5 mm | Length of each crop mark. |
markOffset | Dimension | 3 mm | Gap between the page corner and the start of the crop mark. |
markWidth | Dimension | 0.25 pt | Thickness of the crop marks. |
color | ColorValue | #000000 | Color of the crop marks. |
#Layout
The layout property controls how columns are arranged within the content area.
| Property | Type | Default | Description |
|---|---|---|---|
layoutType | 'single' | 'double' | 'oneAndHalf' | 'double' | Column arrangement. See below for details on each type. |
gutterWidth | Dimension | 0.75 cm | Horizontal space between columns. Only applies to multi-column layouts. |
sideColumnPercent | number | 33 | Width of the side column as a percentage of the content area. Only applies to 'oneAndHalf' layout. |
columnRule | ColumnRuleConfig | disabled | Optional visual rule drawn between columns. See below. |
#Column rule
Draws a thin vertical line in the gutter to visually separate columns.
| Property | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Whether to draw the column rule. |
color | ColorValue | #cccccc | Color of the rule line. |
lineWidth | Dimension | 0.5 pt | Thickness of the rule line. |
#Layout types
-
'single'— One column spanning the full content width. Best for narrow pages or text-heavy content with long paragraphs. -
'double'— Two equal-width columns. The classic editorial layout — keeps line measure within the optimal 40–50 character range for comfortable reading. -
'oneAndHalf'— An asymmetric layout with a main column and a narrower side column. The side column (controlled bysideColumnPercent) is ideal for margin notes, small figures, or supporting content. Values between 25–40% work well.
#Body Text
The bodyText property controls the typography of all paragraph text.
| Property | Type | Default | Description |
|---|---|---|---|
fontFamily | string | 'EB Garamond' | Font family for body text. Any Google Font, system font, or custom family declared in customFonts. |
fontSize | Dimension | 8 pt | Base font size for body text. |
lineHeight | Dimension | 1.5 em | Vertical spacing between lines. Relative units (em, rem) scale with font size. |
paragraphSpacing | boolean | false | When enabled, inserts a blank line (equal to lineHeight) between consecutive paragraphs for publisher-style separation. |
color | ColorValue | #000000 | Text color. |
boldColor | ColorValue | Main Color (#517538) | Color applied to bold/strong spans. Resolved against the default palette's main-color entry, so changing the palette colour retints all bold runs across the document. |
italicColor | ColorValue | Main Color (#517538) | Color applied to italic/emphasis spans. Same palette-linked default as boldColor. |
textAlign | 'left' | 'justify' | 'justify' | Text alignment. Justified text distributes spacing across each line for even edges. |
fontWeight | number | 400 | Weight for normal text (100–900). |
boldFontWeight | number | 700 | Weight for bold/strong text (100–900). |
hyphenation | HyphenationConfig | enabled, 'en-us' | Automatic hyphenation settings. See below. |
firstLineIndent | Dimension | 1.5em | Indent applied to the first line of each paragraph (or to all lines except the first when hanging indent is enabled). |
hangingIndent | boolean | false | When enabled, the indent is applied to all lines except the first (French/hanging indent). |
maxWordSpacing | number | 2 | Upper bound for word spacing in justified text, expressed as a multiplier of the normal space width. Lines exceeding this ratio are considered "loose". |
minWordSpacing | number | 0.6 | Lower bound for word spacing in justified text, as a multiplier of the normal space width. |
optimalLineBreaking | boolean | true | Use Knuth-Plass optimal line breaking instead of greedy first-fit. Produces more even word spacing across the paragraph. See Hyphenation & Justification. |
#Hyphenation
When text alignment is set to 'justify', hyphenation prevents excessive word spacing by breaking long words at syllable boundaries. The engine uses TeX/Liang patterns to find natural break points at syllable boundaries. See Hyphenation & Justification for a detailed explanation.
| Property | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Whether to allow hyphenation. |
locale | HyphenationLocale | 'en-us' | Language rules for syllable boundaries. |
Supported locales: 'en-us' (English), 'es' (Spanish), 'fr' (French), 'de' (German), 'it' (Italian), 'pt' (Portuguese), 'ca' (Catalan), 'nl' (Dutch).
Words shorter than 5 characters are never hyphenated. The engine requires at least 2 characters before and 3 characters after a break point.
#Orphans, widows, runts, and keep-together rules
See Hyphenation & Justification for the mechanics behind these demerits. This section is the reference for the bodyText keys that drive them.
Beyond hyphenation and spacing bounds, the body-text configuration exposes the soft rules that prevent structurally awkward paragraph breaks. All of these are fed into the Knuth-Plass line-breaking algorithm as demerits — they bias the layout toward clean breaks without ever forcing a hard rule. Set the *Penalty values to 0 to effectively disable any one of them.
| Property | Type | Default | Description |
|---|---|---|---|
avoidOrphans | boolean | true | Discourage a paragraph from ending with fewer than orphanMinLines lines at the top of the next column. |
orphanMinLines | number | 2 | Minimum lines required at the top of the next column when a paragraph is split. Only active when avoidOrphans is true. |
orphanPenalty | number | 1000 | Demerit added when an orphan constraint is violated. Higher values bias the algorithm more strongly against orphans; 0 disables the penalty. |
avoidOrphansInLists | boolean | true | When true, list items also receive orphan protection (not just paragraphs). Only effective when avoidOrphans is true. |
avoidWidows | boolean | true | Discourage a paragraph from starting with fewer than widowMinLines lines at the bottom of the current column. |
widowMinLines | number | 2 | Minimum lines required at the bottom of the current column when a paragraph is split. Only active when avoidWidows is true. |
widowPenalty | number | 1000 | Demerit added when a widow constraint is violated. 0 disables the penalty. |
avoidWidowsInLists | boolean | true | When true, list items also receive widow protection. Only effective when avoidWidows is true. |
avoidRunts | boolean | true | Discourage paragraphs from ending with a very short last line — a runt, e.g. a single short word alone. |
runtMinCharacters | number | 20 | Approximate minimum character count for the last line of a paragraph. Interpreted internally as runtMinCharacters × normalSpaceWidth pixels: the test is "is the last line visually shorter than N characters' worth of space-width content". |
runtPenalty | number | 1000 | Equivalent-badness injected into the Knuth–Plass squared demerit formula (same scale as line badness, which saturates at 10000). At 1000 it dominates alternatives requiring up to roughly r≈2.15 word-spacing stretch. 0 disables the penalty. |
avoidRuntsInLists | boolean | true | When true, list items also receive the runt penalty. Only effective when avoidRunts is true. |
slackWeight | number | 10 | Weight applied to the squared "unused column space" cost. Higher values make the layout prefer filling columns tightly; 0 disables the slack pressure entirely. |
keepColonWithList | boolean | true | When a paragraph ends with a colon that directly introduces a list, keep the colon-bearing last line joined to the list: if placing the paragraph would leave no room for the first list item in the same column/page, the last line (or the whole paragraph, if it is a single line) is moved to the next column together with the list. When this rule would push the whole paragraph and a run of headings immediately precedes it in the column, those headings are pulled forward too so headings.keepWithNext keeps holding. |
About runts. A runt is a paragraph whose last line is too short to feel like a proper line of text — typically one or two short words marooned at the end of a paragraph. Because the check is based on the pixel length of the line relative to the normal space width, runtMinCharacters adapts automatically to the current font size. A short word that is visually wider than runtMinCharacters × spaceWidth is fine; a word that is narrower than that (or truly alone) attracts the runt penalty.
Soft, not hard. None of these rules can prevent a break — the engine will always produce a layout. They are demerits: the algorithm trades off badness, hyphenation cost, fitness-class smoothness, and these structural penalties in a single global optimisation and picks the break set with the lowest total cost. If you need a harder guarantee, raise the penalty; if a given document reads better with the penalty relaxed, lower it.
#Headings
The headings property controls typography for all heading levels (H1–H6). You can set general defaults that apply to all levels, then override specific properties per level.
#General defaults
| Property | Type | Default | Description |
|---|---|---|---|
fontFamily | string | 'Open Sans' | Font family for all headings. |
lineHeight | Dimension | 1.2 em | Line height for headings. Tighter than body text. |
color | ColorValue | Main Color (#517538) | Heading text color. Bound to the default palette's main-color entry, so swapping the palette colour retints every heading. |
textAlign | 'left' | 'justify' | 'left' | Heading text alignment. |
fontWeight | number | 700 | Font weight for headings (100–900). |
marginTop | Dimension | 1.5 em | Space above headings. |
marginBottom | Dimension | 0.5 em | Space below headings. |
keepWithNext | boolean | true | When true, a heading is never placed as the last element of a column or page. If the following block would not have at least bodyText.widowMinLines lines of room after the heading (or one line when bodyText.avoidWidows is false), the heading is pushed forward so it stays joined to its text. Interacts with bodyText.keepColonWithList: if that rule has to push a colon-paragraph whole, any trailing heading(s) in the column travel with it rather than being left stranded. |
#Per-level overrides
Each heading level can override the general defaults via the levels array. Only the fontSize differs by default — all other properties inherit from the general heading settings.
| Level | Default font size |
|---|---|
| H1 | 18 pt |
| H2 | 15 pt |
| H3 | 12 pt |
| H4 | 10 pt |
| H5 | 9 pt |
| H6 | 8 pt |
Per-level overrides support the same properties as the general defaults — fontSize, lineHeight, fontFamily, color, fontWeight, marginTop, marginBottom — plus two level-only fields:
| Property | Type | Default | Description |
|---|---|---|---|
italic | boolean | false | Render the heading in italic. Applied on top of fontWeight. |
numberingTemplate | string | '' | Reserved for automatic heading numbering (e.g. 'Chapter . '). Currently stored on the resolved config and carried through the pipeline; rendering will honour it in an upcoming release. |
headings: {
fontFamily: 'Merriweather',
levels: [
{ level: 1, fontSize: { value: 24, unit: 'pt' }, color: { hex: '#1a1a2e', model: 'hex' } },
{ level: 2, fontSize: { value: 18, unit: 'pt' }, italic: true },
]
}#Unordered Lists
The unorderedLists property controls how bullet lists (-, *, +) and GFM task lists (- [ ], - [x]) are rendered. Up to five levels of nesting are supported.
#Unordered list defaults
| Property | Type | Default | Description |
|---|---|---|---|
fontFamily | string | inherits bodyText.fontFamily | Font used for the item text. |
color | ColorValue | Main Color (#517538) | Text and bullet color for items. Bound to the default palette's main-color entry. |
fontWeight | number | 700 | Weight for item text (100–900). Bullets inherit this weight unless overridden per-level. |
italic | boolean | false | Render item text in italic. |
bulletChar | string | '•' | Glyph used as the bullet marker. |
bulletFontSize | Dimension | 1 em | Size of the bullet glyph. Relative units scale with the body font size. |
gap | Dimension | 0.5 em | Horizontal space between the bullet and the item text. |
indent | Dimension | 0 em | Base indent for level 1. Deeper levels cascade from the parent's text-start unless overridden (see below). |
bulletVerticalOffset | Dimension | 0 em | Fine-tune bullet vertical position. Negative values move the bullet up, positive values move it down. |
marginTop / marginBottom | Dimension | 1.5 em | Space before and after the list as a whole. |
itemSpacing | Dimension | 0 em | Extra vertical space inserted between items on top of the line height. |
hangingIndent | boolean | true | When enabled, wrapped lines align with the first text character rather than under the bullet. |
levels | UnorderedListLevelConfig[] | — | Per-depth overrides for levels 1–5. See below. |
#Task list extensions
GFM task items (- [ ] …, - [x] …) are rendered as unordered items with a checkbox glyph replacing the bullet. The following fields only apply to task items:
| Property | Type | Default | Description |
|---|---|---|---|
taskCheckboxChar | string | '☐' | Glyph used for unchecked tasks. |
taskCheckedChar | string | '☑' | Glyph used for completed tasks. |
taskCompletedStrikethrough | boolean | true | Draw a strikethrough line across the text of completed tasks. |
taskCompletedColor | ColorValue | inherits item color | Optional color applied to completed task text. When omitted, the regular item color is used. |
#Unordered per-level overrides
Each entry in levels targets one depth (1–5) and can override any of the following:
| Property | Type | Description |
|---|---|---|
bulletChar | string | Bullet glyph for this depth. |
fontFamily | string | Item font family for this depth. |
fontSize | Dimension | Bullet glyph size for this depth. |
color | ColorValue | Item color. |
fontWeight | number | Item weight. |
italic | boolean | Italic toggle. |
indent | Dimension | Explicit indent for the bullet at this depth. See the cascade rule below. |
verticalOffset | Dimension | Vertical fine-tune for the bullet at this depth. |
Indent cascade. Level 1 always starts at the general indent value (by default 0 em — bullets are pinned to the column edge). For levels 2–5, if you leave indent undefined the engine places the bullet at the previous level's text-start (parent indent + bullet width + gap). Set an explicit indent on a level to break the cascade and pin that depth anywhere you like.
unorderedLists: {
bulletChar: '—',
gap: { value: 0.4, unit: 'em' },
hangingIndent: true,
levels: [
{ level: 2, bulletChar: '·' },
{ level: 3, bulletChar: '◦', color: { hex: '#666666', model: 'hex' } },
],
}#Ordered Lists
The orderedLists property controls numbered lists (1., 2), etc.). Up to five levels of nesting are supported and each depth can use a different number format.
#Ordered list defaults
| Property | Type | Default | Description |
|---|---|---|---|
fontFamily | string | inherits bodyText.fontFamily | Font used for the item text and the number marker. |
color | ColorValue | Main Color (#517538) | Text and number-marker color for items. Bound to the default palette's main-color entry. |
fontWeight | number | 700 | Weight for item text and number markers (100–900). |
italic | boolean | false | Render item text in italic. |
numberFormat | OrderedListNumberFormat | 'arabic' | Number style: 'arabic', 'lower-alpha', 'upper-alpha', 'lower-roman', 'upper-roman'. |
separator | string | '.' | Character placed between the number and the text — typically '.' or ')'. |
numberFontSize | Dimension | 1 em | Size of the number marker. |
gap | Dimension | 0.5 em | Horizontal space between the number and the item text. |
indent | Dimension | 0 em | Base indent for level 1; deeper levels cascade from the parent's text-start unless overridden. |
numberVerticalOffset | Dimension | 0 em | Fine-tune the vertical position of the number marker. |
marginTop / marginBottom | Dimension | 1.5 em | Space before and after the list as a whole. |
itemSpacing | Dimension | 0 em | Extra vertical space between items. |
hangingIndent | boolean | true | Wrapped lines align with the first text character rather than under the number. |
levels | OrderedListLevelConfig[] | — | Per-depth overrides for levels 1–5. |
#Ordered per-level overrides
Each entry in levels can override numberFormat, separator, fontFamily, fontSize, color, fontWeight, italic, indent, and verticalOffset — the same indent cascade as unordered lists applies.
Right alignment. The pipeline measures the widest formatted number within a run and indents all items in that run so the number markers line up on their right edge. A list of ten items rendered as 1. – 10. has the single-digit numbers right-padded so the separator stays in the same column.
orderedLists: {
numberFormat: 'arabic',
separator: '.',
levels: [
{ level: 2, numberFormat: 'lower-alpha' },
{ level: 3, numberFormat: 'lower-roman', separator: ')' },
],
}This yields the classic nested mix:
1. First item
a. Sub-item
i) Deep note
b. Sub-item
2. Second item
#Math
The math property controls how LaTeX formulas inside $...$ (inline) and $$...$$ (display) delimiters are parsed and rendered. The underlying engine is KaTeX, rasterised to the canvas and embedded as scalable glyphs in the PDF.
interface MathConfig {
enabled?: boolean; // Render LaTeX. When false, spans pass through as literal TeX.
fontSizeScale?: number; // Multiplier applied to the body font size.
color?: ColorValue; // Formula colour; inherits body colour if omitted.
marginTop?: Dimension; // Space above display math blocks.
marginBottom?: Dimension; // Minimum space below; baseline grid snap may enlarge it.
}| Property | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | When false, $...$ and $$...$$ spans are still parsed (so unclosed-delimiter warnings still fire) but are rendered as their literal TeX source. Useful when the content intentionally contains dollar signs or when you want to disable math rendering entirely. |
fontSizeScale | number | 1.0 | Multiplier applied to bodyText.fontSize before rendering. 1.0 matches body text; values in the 0.9–1.1 range are typical when the math font looks slightly larger or smaller than the prose font. |
color | ColorValue | inherits body colour | Colour of the rendered formula. Omit to inherit bodyText.color. Set explicitly when you want formulas tinted differently from prose — e.g. matching a heading accent. |
marginTop | Dimension | 0.8em | Space above a display math block. Ignored for inline math. |
marginBottom | Dimension | 0.8em | Space below a display math block. When the baseline grid is enabled this is treated as a minimum — the grid snap may extend it so the next baseline falls on a grid line. |
math: {
enabled: true,
fontSizeScale: 1.0,
color: { hex: '#517538', model: 'hex' },
marginTop: { value: 1, unit: 'em' },
marginBottom: { value: 1, unit: 'em' },
}Resolver and stripper match the other sections:
import {
DEFAULT_MATH_CONFIG,
resolveMathConfig,
stripMathDefaults,
} from 'postext';
const resolved = resolveMathConfig(config.math);
const minimal = stripMathDefaults(config.math);For the document-side grammar ($...$, $$...$$, escaping a literal dollar), see Document format.
#Units and Colors
#Dimensions
All physical measurements in Postext use the Dimension type — a value paired with a unit:
interface Dimension {
value: number;
unit: DimensionUnit; // 'cm' | 'mm' | 'in' | 'pt' | 'px' | 'em' | 'rem'
}Absolute units — cm, mm, in, pt, px — are converted to pixels using the configured DPI. At 300 DPI, 1 cm equals approximately 118 px.
Relative units — em, rem — scale with the current font size. An em is relative to the element's own font size; rem is relative to the body text font size.
#Colors
Colors are stored with both a hex representation and a target color model:
interface ColorValue {
hex: string; // '#ff0000', 'transparent', etc.
model: ColorModel; // 'hex' | 'rgb' | 'cmyk' | 'hsl'
}The model field indicates the intended color space. For web rendering, 'hex' or 'rgb' are typical. For print workflows, 'cmyk' preserves the intent that the color should be specified in CMYK when exported to PDF.
Because Postext targets publication-grade output, the default body text color ships with model: 'cmyk' (#000000). Heading, bold, italic, and list colors default to the palette-linked Main Color (#517538, model: 'hex'). Page background and UI overlays (baseline grid, cut marks, debug indicators) default to model: 'hex'. Override color.model on any field if you need different export semantics.
#Custom fonts
Postext resolves every fontFamily string against both the Google Fonts catalogue and the document's customFonts list. Custom fonts take precedence on name collision — if you declare customFonts: [{ name: 'Roboto', … }], Postext uses your uploaded file instead of the Google Fonts "Roboto".
Use custom fonts when:
- The document needs a brand or licensed typeface that is not on Google Fonts.
- The environment can't reach the Google Fonts CDN (offline, intranet, privacy-sensitive).
- You must keep the font binary private and not upload it to a third party.
#Configuration schema
type CustomFontFormat = 'woff2' | 'woff' | 'ttf' | 'otf';
type CustomFontStyle = 'normal' | 'italic';
interface CustomFontVariant {
weight: number; // CSS font-weight, 100..900
style: CustomFontStyle;
fileId: string; // opaque id of the binary in out-of-band storage
format: CustomFontFormat;
fileName?: string; // original upload filename (optional, shown in UI)
}
interface CustomFontFamily {
name: string; // used anywhere a Google Font family name fits
variants: CustomFontVariant[];
}
interface PostextConfig {
// ...
customFonts?: CustomFontFamily[];
}Each variant's binary is not embedded in the config itself. The config only holds fileId pointers; the bytes live out-of-band. In the sandbox, that means IndexedDB (key-value store, browser-only, private to the document). An integrator embedding Postext in another host is free to resolve fileId however they like — a server endpoint, a service worker cache, anything — as long as the bytes reach the main thread before buildDocument runs.
#Managing custom fonts in the sandbox
Open the Fonts panel from the left activity bar (between Resources and Configuration). For each family:
- Add font family — creates an empty family; rename it inline.
- Upload variant(s) — pick a weight (100–900) and style (normal / italic), then choose one or many
.woff2,.woff,.ttf, or.otffiles. Each file becomes its own variant bound to the currently-selected (weight, style); the uploaded filename is remembered and shown in the row so you can tell variants apart. Re-tune a variant's weight or style from its dropdowns at any time. - Duplicate variants are allowed. If two files land on the same (weight, style) slot, both are kept and a Duplicate font variant warning appears so you know to disambiguate the settings of the extras.
- Delete variant or Delete family — removes the entry from the config and the stored bytes from IndexedDB.
Once a family is declared, every FontPicker groups it under Custom, above the Google Fonts list. Selecting it wires the family into every font-family field you apply it to.
#Rendering behaviour
Under the hood:
- When
customFontschanges, every declared family is automatically registered asFontFaceentries ondocument.fonts— so the HTML viewer, the Canvas viewport (which measures throughdocument.fonts), and any direct CSS reference all pick up the custom face without requiring the user to first open the Font Picker. - The layout worker receives the same ArrayBuffers through the existing font payload transfer path, so measurement (
buildFontString, pretext) produces identical metrics to Google Fonts. - Changing or removing a variant drops the worker's cached face for that family and re-registers on the next build, so previews stay in sync with the current variant set.
- PDF export: uploaded binaries flow through the same
PdfFontProviderpipeline..woff2files are decompressed;.ttfand.otfare passed through directly..woffis rejected with a clear error (pdf-lib can't embed raw WOFF — re-upload as.woff2/.ttf/.otf). CFF-flavored OpenType (.otfwithOTTOmagic) is embedded without subsetting, because pdf-lib's CFF subsetter walks every glyph atsave()time and can hang for minutes on real fonts; skipping subset trades a somewhat larger PDF for consistent render times.
#Missing-font warnings
The warnings panel recognises three new failure modes specific to custom fonts (all enabled by the same debug.warnings.missingFont toggle that already guards the generic "not loaded" warning):
- Unknown font family — a
fontFamilyreferences a name that is neither a known Google Font nor a currently-declared custom family. This also fires immediately when you delete a custom family that somefontFamilyfield still references, instead of waiting for the DOM to notice. - Missing font variant — the family exists but at least one of the standard weight/style slots (400 / 700, normal / italic) has no uploaded file. The warning lists the specific combinations that are missing.
- Duplicate font variant — two or more uploaded files share the same (weight, style) slot within one family. Only one file is actually used at render time; the warning nudges you to retune the remaining entries.
Clicking any of these warnings opens the Fonts panel so you can upload the missing variant, re-add the family, or disambiguate the duplicates.
#Color Palette
The colorPalette property on PostextConfig lets you define a reusable set of named colors and reference them from any ColorValue in the configuration. It is the Postext equivalent of CSS custom properties or an InDesign swatches panel: change the palette entry once, and every color that points to it updates across the document.
interface ColorPaletteEntry {
id: string; // stable identifier — referenced by ColorValue.paletteId
name: string; // human label shown in sandbox UIs
value: ColorValue;
}#The default palette
Postext ships with a single-entry default palette called Main Color (id: 'main-color', hex #517538). Several defaults — heading color, bold/italic body color, bullet and number-marker colors — reference this entry via paletteId: 'main-color', so changing that one swatch retints every part of the document that uses it.
You can inspect, clone, or compare against the default palette via three exports:
import {
DEFAULT_COLOR_PALETTE,
cloneDefaultColorPalette,
isDefaultColorPalette,
} from 'postext';
// Read-only snapshot of the shipped palette.
DEFAULT_COLOR_PALETTE;
// => [{ id: 'main-color', name: 'Main Color', value: { hex: '#517538', model: 'hex' } }]
// Independent copy — mutate this, not DEFAULT_COLOR_PALETTE.
const palette = cloneDefaultColorPalette();
// Detect whether a user has customised the palette at all.
isDefaultColorPalette(palette); // trueA palette lives at the top level of the config:
const config: PostextConfig = {
colorPalette: [
{ id: 'ink', name: 'Ink', value: { hex: '#0a0a0a', model: 'cmyk' } },
{ id: 'accent', name: 'Accent', value: { hex: '#b8860b', model: 'hex' } },
],
bodyText: { color: { hex: '#000000', model: 'cmyk', paletteId: 'ink' } },
headings: { color: { hex: '#000000', model: 'hex', paletteId: 'accent' } },
};#Referencing a palette entry
Any ColorValue in the configuration — page background, body text color, heading colors, column rules, list colors, task-completed color, cut-mark color, baseline-grid color, debug indicators — can carry an optional paletteId field pointing at an entry in colorPalette. When present, the palette entry's hex / model win over the fallback hex / model stored alongside. The inline fallback is only used if the palette is missing, empty, or doesn't contain that id — useful when shipping a config that will be read by a tool that doesn't understand palettes.
#How palettes are applied
buildDocument runs the palette in two places so referenced colors work both for overrides you spelled out and for defaults that are filled in later:
applyPaletteToConfig(config)— flattens everyColorValuein the raw user config that carries apaletteId. Useful when you want to inspect what the engine will actually see.applyPaletteToResolvedConfig(resolved, palette)— runs after defaults are resolved and rewrites the palette-linked defaults (heading color, bold/italic body color, list colors) to match the active palette.
You rarely need to call these yourself, but both are exported so you can inspect or reuse them:
import {
applyPaletteToConfig,
applyPaletteToResolvedConfig,
resolveColorValue,
} from 'postext';
const flat = applyPaletteToConfig(config);
// Every ColorValue with a paletteId in the raw config has been replaced
// with the palette entry's hex/model.
// `applyPaletteToResolvedConfig` is typically handled by buildDocument; use it
// directly if you build a ResolvedConfig yourself and want the palette applied.resolveColorValue(value, palette, fallback) is the single-value variant, handy when you are composing configs imperatively and need to resolve one color at a time.
#Editing the palette
Removing a palette entry should go through unlinkPaletteRefs (exported from postext-sandbox) so that any ColorValue still pointing at the removed id gets rewritten with the plain hex / model fallback. The sandbox's "Color Palette" section does this automatically.
#HTML Viewer
The htmlViewer property controls how the HTML backend lays out pages on screen. It only applies when you render with renderToHtml / renderToHtmlIndexed; the canvas and PDF paths ignore it entirely — they consume the configured page.width, page.height, and page.dpi directly.
interface HtmlViewerConfig {
maxCharsPerLine?: number; // Target column width, in characters of the body font.
columnGap?: number; // Horizontal gap between columns in multi-column mode (px).
optimalLineBreaking?: boolean; // Use Knuth–Plass inside the HTML viewer instead of greedy.
}| Property | Type | Default | Description |
|---|---|---|---|
maxCharsPerLine | number | 70 | Target measure for each rendered column, expressed in characters of the body font. The viewport samples a representative prose string at that length to derive the actual pixel width — so the result adapts to any proportional font and font-size combination. |
columnGap | number | 50 | Horizontal gap, in CSS pixels, between columns when the viewer is in multi-column mode. Ignored in single-column mode. |
optimalLineBreaking | boolean | false | Enable Knuth–Plass line breaking in the HTML viewer. Off by default because the viewer reruns layout on every resize and font-size change — the greedy first-fit algorithm is fast enough to feel instantaneous. Turn it on when you want the same optimal breaks the canvas backend uses. |
Resolver and stripper follow the same pattern as the other sections:
import {
DEFAULT_HTML_VIEWER_CONFIG,
resolveHtmlViewerConfig,
stripHtmlViewerDefaults,
} from 'postext';
const resolved = resolveHtmlViewerConfig(config.htmlViewer);
// => { maxCharsPerLine: 70, columnGap: 50, optimalLineBreaking: false }
const minimal = stripHtmlViewerDefaults(config.htmlViewer);
// => undefined when everything matches the defaultsSee Integrating the HTML viewer below for an end-to-end example.
#PDF Generation
The pdfGeneration property controls how the PDF backend emits the final document. These settings are consumed by the postext-pdf package at export time; the canvas and HTML viewers ignore them.
type PdfColorSpace = 'rgb' | 'cmyk' | 'grayscale';
interface PdfGenerationConfig {
outlines?: boolean; // Emit PDF bookmarks from the heading tree.
forceColorSpace?: boolean; // Convert every colour to `colorSpace`.
colorSpace?: PdfColorSpace; // Target space used when `forceColorSpace` is true.
}| Property | Type | Default | Description |
|---|---|---|---|
outlines | boolean | true | Emit PDF outlines (bookmarks) from the heading hierarchy so readers can jump directly to any heading from the sidebar of a PDF viewer. Turn off for documents where the heading tree is meaningless (e.g. single-page posters). |
forceColorSpace | boolean | false | When true, every colour in the rendered PDF is converted to colorSpace at export time. Leave off for screen-first PDFs where input colours are already in the desired space; turn on to guarantee a single colour space across mixed sources. |
colorSpace | 'rgb' | 'cmyk' | 'grayscale' | 'cmyk' | Target colour space used when forceColorSpace is on. Use 'cmyk' for offset printing, 'rgb' for screen-only PDFs, and 'grayscale' for black-and-white print proofs. Has no effect when forceColorSpace is false. |
pdfGeneration: {
outlines: true,
forceColorSpace: true,
colorSpace: 'cmyk',
}Resolver and stripper match the other sections:
import {
DEFAULT_PDF_GENERATION_CONFIG,
resolvePdfGenerationConfig,
stripPdfGenerationDefaults,
} from 'postext';
const resolved = resolvePdfGenerationConfig(config.pdfGeneration);
// => { outlines: true, forceColorSpace: false, colorSpace: 'cmyk' }
const minimal = stripPdfGenerationDefaults(config.pdfGeneration);
// => undefined when everything matches the defaultsSee Generating PDFs below for the end-to-end export recipe.
#Debug
The debug property groups two kinds of authoring aids: visual overlays that keep the source text and the rendered layout in sync, and a set of warnings that surface typographic or structural problems in the editor's warnings panel. Neither affects exported output.
#Visual overlays
| Property | Type | Default | Description |
|---|---|---|---|
cursorSync | SyncIndicatorConfig | enabled, #2563eb | Shows a caret in the rendered layout mirroring the source cursor position. |
selectionSync | SyncIndicatorConfig | enabled, #fde04780 | Highlights the rendered range matching the source selection. |
looseLineHighlight | LooseLineHighlightConfig | disabled, #ff000040, 3 | Paints an overlay on justified lines whose word spacing exceeds the given multiplier of the normal space width. |
pageNegative | | disabled | Renders a high-contrast negative overlay over the page — useful for visually auditing the overall shape of a spread (text density, column balance, whitespace) at a glance, without being distracted by glyph detail. |
Each SyncIndicatorConfig is { enabled: boolean; color?: ColorValue }. LooseLineHighlightConfig is { enabled: boolean; color?: ColorValue; threshold?: number }. pageNegative is a minimal { enabled: boolean } toggle.
debug: {
cursorSync: { enabled: true, color: { hex: '#ff0066', model: 'hex' } },
selectionSync: { enabled: false, color: { hex: '#fde04780', model: 'hex' } },
looseLineHighlight: { enabled: true, color: { hex: '#ff000040', model: 'hex' }, threshold: 3 },
pageNegative: { enabled: true },
}#Warnings
debug.warnings controls which authoring issues appear in the editor's warnings panel. Each key is an independent boolean toggle; set one to false to silence that specific warning without disabling the others.
interface WarningsToggleConfig {
missingFont?: boolean;
looseLines?: boolean;
headingHierarchy?: boolean;
consecutiveHeadings?: boolean;
listAfterHeading?: boolean;
}| Property | Type | Default | Description |
|---|---|---|---|
missingFont | boolean | true | Report when a font referenced by the configuration failed to load in the browser. Catches typos in fontFamily and missing @fontsource/... packages early, before they show up as silent fallback-font substitutions in the rendered output. |
looseLines | boolean | true | Report justified lines whose word spacing exceeds debug.looseLineHighlight.threshold. Pairs with the overlay: the warning enumerates them in the panel, the overlay shows them in place. |
headingHierarchy | boolean | true | Report heading levels that skip a rank — e.g. an H1 followed directly by an H3. Structural heading gaps usually indicate either a typo in the heading depth or a misunderstanding of the document's outline. |
consecutiveHeadings | boolean | false | Report when a heading is immediately followed by another heading with no paragraph or list between them. Off by default because stacked headings are legitimate in many templates (title + subtitle, chapter + epigraph); turn on for manuscripts where every heading is supposed to introduce prose. |
listAfterHeading | boolean | false | Report when a list starts immediately after a heading without an introductory paragraph. Off by default because reference material routinely does this; turn on for narrative writing where every list should be framed by prose. |
debug: {
warnings: {
missingFont: true,
looseLines: true,
headingHierarchy: true,
consecutiveHeadings: true,
listAfterHeading: false,
},
}#Advanced Configuration
The following configuration sections are defined in the type system and will be supported by the layout engine as it evolves. They are included here for reference.
#Column Config
Fine-grained column control: explicit column count, gutter size, column rules (visual separators), and column balancing.
#Resource Placement
Controls how images, tables, figures, and pull quotes are positioned: 'topOfColumn', 'inline', 'floatLeft', 'floatRight', 'fullWidthBreak', or 'margin'. Options for deferred placement and aspect ratio preservation.
#Typography
Advanced typographic controls: orphan/widow minimum line counts, rag optimization, spacing around headings and figures, and keep-together rules for headings with paragraphs and figures with captions.
#References
Footnote placement ('columnBottom', 'pageBottom', 'endOfSection'), marker style ('number', 'symbol', 'custom'), automatic figure/table numbering, and margin notes.
#Section Overrides
Apply different column, typography, or resource placement settings to specific sections of the document, matched by CSS-like selectors.
#Renderer
Target output format: 'web' for HTML/canvas rendering, 'pdf' for print output.
#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' } },
};
// 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`);#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));#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: 9, unit: 'pt' }, lineHeight: { value: 1.5, unit: 'em' }, ... }Available resolvers: resolvePageConfig, resolveLayoutConfig, resolveBodyTextConfig, resolveHeadingsConfig, resolveUnorderedListsConfig, resolveOrderedListsConfig, resolveDebugConfig, resolveHtmlViewerConfig. Color palettes are applied separately through applyPaletteToConfig(config), applyPaletteToResolvedConfig(resolved, palette), and resolveColorValue(value, palette, fallback) — see Color Palette.
resolveUnorderedListsConfig and resolveOrderedListsConfig are the only resolvers that take a second argument — the already-resolved ResolvedBodyTextConfig — because list defaults for fontFamily and color cascade from body text:
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, PAGE_SIZE_PRESETS, DEFAULT_LAYOUT_CONFIG, DEFAULT_COLUMN_RULE, DEFAULT_BODY_TEXT_CONFIG, DEFAULT_HYPHENATION_CONFIG, DEFAULT_HEADINGS_CONFIG, DEFAULT_UNORDERED_LISTS_STATIC, DEFAULT_ORDERED_LISTS_STATIC, DEFAULT_DEBUG_CONFIG, DEFAULT_HTML_VIEWER_CONFIG, DEFAULT_COLOR_PALETTE, DEFAULT_MAIN_COLOR, DEFAULT_MAIN_COLOR_ID, DEFAULT_MAIN_COLOR_NAME, DEFAULT_MAIN_COLOR_HEX.
#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: stripPageDefaults, stripLayoutDefaults, stripBodyTextDefaults, stripHeadingsDefaults, stripUnorderedListsDefaults, stripOrderedListsDefaults, stripDebugDefaults, stripHtmlViewerDefaults.
#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.
#Measurement cache
Text measurement is the expensive step in layout. To avoid re-measuring the same block across convergence iterations — or across re-layouts when only the configuration changed — Postext ships a pluggable measurement cache:
import {
createMeasurementCache,
cachedMeasureBlock,
cachedMeasureRichBlock,
clearMeasurementCache,
} from 'postext';
import type { MeasurementCache } from 'postext';
const cache: MeasurementCache = createMeasurementCache();
// Same signature as measureBlock / measureRichBlock, plus a cache argument.
const measured = cachedMeasureBlock(cache, block, options);
const richMeasured = cachedMeasureRichBlock(cache, richBlock, options);
// Drop all cached entries (e.g. when the font family changes):
clearMeasurementCache(cache);buildDocument maintains its own cache internally across the convergence loop, so for typical usage you do not need to touch these. They are exposed for applications that drive the pipeline piece-by-piece — for example an editor that re-runs layout on every keystroke and wants to reuse measurements from the previous frame.
#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) and returns a typed handle.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 }. 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.
#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.
#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.
#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.
#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,
} 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 onFontsDone = () => relayout();
document.fonts?.addEventListener?.('loadingdone', onFontsDone);
return () => {
ro.disconnect();
document.fonts?.removeEventListener?.('loadingdone', onFontsDone);
};
}, [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.
document.fonts.loadingdonefires when a newly-requested web font has arrived. 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.
The sandbox's HtmlPreview component (packages/postext-sandbox/src/viewport/HtmlPreview.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.
#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-pdf#Public API
The package exposes a single entry point and a handful of types:
renderToPdf(doc, options): Promise<Uint8Array>— takes aVDTDocumentand returns the raw PDF bytes.PdfFontProvider— the callback signature(family, weight, style) => Promise<Uint8Array>thatrenderToPdfuses to request font bytes when it needs to embed a new family/weight/style combination.RenderToPdfOptions—{ fontProvider: PdfFontProvider, pageNegative?: boolean }.decompressWoff2(bytes): Uint8Array— helper that turns a WOFF2 file into TTF bytes, which is the formatpdf-libcan embed directly.
#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' } },
},
);
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 VDT for every fontString it encounters (one per family|weight|style combination, including bold, italic, and bold-italic variants) and calls your provider once per unique combination. The provider returns a Uint8Array of TTF or OTF bytes; pdf-lib subsets and embeds them.
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.
#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.
#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);
};#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 (PdfViewport.tsx:51).
#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. See Cut lines.page.dpi: 300(or higher) — PDF points are fixed at 72/inch, but Postext's layout math runs in pixels; a higher DPI gives finer subdivision for elements measured inmmorcm.colors.model: 'cmyk'— preserves the intent that colours were authored in CMYK space. Thehexfallback is still used to actually draw to the PDF today;modelis documented here because it rides along into the VDT for downstream tooling.{ 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).