Skip to main content

Postext Configuration

Updated: 2026-09-22|30 min|enes

Every layout decision in Postext is driven by a single configuration object.

PostextConfig controls page dimensions, column layout, body text typography, heading styles, the document language (locale), 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' } }, // 9 pt overrides the 8 pt default
  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.
  • Headers & footers — per-page text and rule elements with placeholders, parity, and alignment.
  • Body text — typography, hyphenation, orphans and widows.
  • Document language — the top-level locale: hyphenation fallback, table continuation strings, the language tagged into an accessible PDF.
  • Headings — shared defaults and H1–H6 overrides.
  • Unordered lists and Ordered lists — bullets, numbering, nesting.
  • Math — LaTeX rendering, scale, colour, margins.
  • Resource types — typed numbering for figures, tables, and custom kinds.
  • Table style (with named table styles) and Caption style — typography and decoration for table resources and their captions.
  • Diagram style — single-ink recolouring of embedded SVG diagrams for spot-colour printing.
  • Paragraph styles — named styles for :::paragraphs containers: bibliographies, glossaries, notes.
  • Callout styles — boxed notes, tips and objectives for :::callout containers.
  • Parts — part-divider pages for :::part containers: parity, body area, opener design, body typography.
  • Heading styles — named styles for {style="…"} headings: unnumbered chapters, front matter with its own running heads, geometry and palette.
  • Table of contents — what :::toc prints: entry typography, leaders, page numbers, author lines, part rows.
  • Units and colors + Color paletteDimension, 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 (config) — outlines, accessible (tagged) output, forced colour space for the PDF backend.
  • Debug — visual overlays and authoring warnings for the editor.
  • Programmatic usebuildDocument, resolvers, caches.
  • Running layout in a Web Worker — off-main-thread builds with cancellation.
  • Integrating the HTML viewer and Generating PDFs — end-to-end recipes.

#Page

The page property controls the physical dimensions and appearance of the page.

PropertyTypeDefaultDescription
sizePresetPageSizePreset'17x24'Predefined page size. Set to 'custom' to use explicit width/height.
widthDimension17 cmPage width. Taken from sizePreset when omitted; an explicit value always wins (use sizePreset: 'custom' for fully custom sizes).
heightDimension24 cmPage height. Taken from sizePreset when omitted; an explicit value always wins.
marginsPageMargins2 cm all sidesSpace between the page edge and the content area. Each side (top, bottom, left, right) is set independently. With mirror: true the margins are facing-page margins: left is the inner (spine-side) margin and right the outer one; odd pages (page 1 is odd) keep them as written and even pages swap them, so the content area — and with it the columns, float bands, header/footer containers and opener bands — moves across the spread. Default false. See below.
backgroundColorColorValuetransparentPage background color.
dpinumber300Dots per inch. Affects how physical units (cm, mm, in) are converted to pixels.
cutLinesCutLinesConfigdisabledShow trim marks at page corners for print cutting. When enabled, the canvas expands to include bleed area and crop marks. See below.
baselineGridBaselineGridConfigdisabledOverlay a horizontal baseline grid for vertical rhythm alignment. See below.

#Mirrored margins

Books are read as spreads, and the inner margin usually differs from the outer one. margins.mirror turns the four margins into facing-page margins:

{
  "page": {
    "margins": {
      "top": { "value": 2, "unit": "cm" },
      "bottom": { "value": 2.5, "unit": "cm" },
      "left": { "value": 2.2, "unit": "cm" },
      "right": { "value": 1.4, "unit": "cm" },
      "mirror": true
    }
  }
}

With this configuration every odd page has a 2.2 cm margin on the left (the spine) and 1.4 cm on the right (the fore-edge); every even page has 1.4 cm on the left (the fore-edge) and 2.2 cm on the right (the spine). Each laid-out page carries its own contentArea on the VDTPage, so everything derived from it — columns, full-width float bands, header and footer containers, and span: 'page' opener bands — follows the mirrored geometry automatically. Page and bleed frames used by design elements anchored to 'page' / 'bleed' are not affected: they describe the physical sheet, not the margins.

#Page size presets

PresetWidthHeightCommon use
'11x17'11 cm17 cmPocket books
'12x19'12 cm19 cmStandard paperback
'17x24'17 cm24 cmTechnical books, textbooks
'21x28'21 cm28 cmMagazines, reports (near A4)
Page size presetsFour built-in page size presets drawn to proportional scale: pocket 11x17, paperback 12x19, technical 17x24, and near-A4 21x28 cm.21×28 · ~A417×24 · Textbook12×19 · Paperback11×17 · Pocket21 cm28 cm
Presets drawn to proportional scale.

#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. The lines span only the page's actual text — from the first text line to the last — so float bands, blank parity pages, and unused tail space show no grid.

PropertyTypeDefaultDescription
enabledbooleanfalseWhether to draw the baseline grid overlay.
colorColorValue#ccccccColor of the grid lines.
lineWidthDimension0.5 ptThickness 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.

PropertyTypeDefaultDescription
enabledbooleanfalseWhether to expand the canvas with bleed and draw crop marks.
bleedDimension3 mmExtra area around the page used for print bleed.
markLengthDimension5 mmLength of each crop mark.
markOffsetDimension3 mmGap between the page corner and the start of the crop mark.
markWidthDimension0.25 ptThickness of the crop marks.
colorColorValue#000000Color of the crop marks.

#Numbering

The page.pageNumbering block controls how page labels are formatted and where the counter starts. It only defines the document-wide default — to restart numbering mid-document (e.g. roman-numeral front matter switching to decimal chapters from 1), use the :::numbering directive (see Document format → Directives).

PropertyTypeDefaultDescription
format'decimal' | 'lower-roman' | 'upper-roman' | 'lower-alpha' | 'upper-alpha''decimal'Numeric style used to render page labels.
startAtnumber1Numeric value assigned to the first page regardless of format. format: 'lower-roman', startAt: 1 yields i, ii, iii, …; format: 'decimal', startAt: 17 yields 17, 18, 19, ….

The computed label is stored on every VDTPage as pageLabel and is what the {pageNumber} header/footer placeholder resolves to. PDFs emit a /PageLabels number tree so Preview / Acrobat's page indicator and "Go to page" navigation match the printed labels exactly.

#Layout

The layout property controls how columns are arranged within the content area.

Columns, gutter, and marginA page divided into three columns: each column is the content area for text, gutters are the vertical gaps between columns, and the margin is the blank edge between the page boundary and the first column.PageMarginColumnGutter
Columns hold text. Gutters separate them. Margins frame the content.
Margin systemA page with independent top, right, bottom, and left margins surrounding the content area.PageContent areatop1cmbottom2cmleft2.5cmright1.5cm
Each side of the page can have its own margin.
PropertyTypeDefaultDescription
layoutType'single' | 'double' | 'oneAndHalf''double'Column arrangement. See below for details on each type.
gutterWidthDimension0.75 cmHorizontal space between columns. Only applies to multi-column layouts.
sideColumnPercentnumber33Width of the side column as a percentage of the content area. Only applies to 'oneAndHalf' layout.
sideColumnRole'text' | 'floats''text'What the side column carries: body text (it flows there after the main column), or only the resources and callouts placed with span: 'side' — a float-only margin column. 'oneAndHalf' only.
sideColumnSide'right' | 'left' | 'outer' | 'inner''right'Edge of the content area the side column sits at. 'outer' / 'inner' follow the page parity when the margins are mirrored (a recto's outer edge is its right edge, a verso's its left). 'oneAndHalf' only.
columnRuleColumnRuleConfigdisabledOptional visual rule drawn between columns. See below.
fitFiguresToPagebooleanfalseShrink a figure (bitmap or SVG) whose image, caption and note would stand taller than the content area until they fit it, and set an inline figure a little too tall for the room left in its column smaller (down to half its width, the caption keeping the column's measure) so it stays with its text. The HTML viewer turns it on, since its pages are only as tall as the screen; printed pages are sized for their figures.

#Column rule

Draws a thin vertical line in the gutter to visually separate columns.

PropertyTypeDefaultDescription
enabledbooleanfalseWhether to draw the column rule.
colorColorValue#ccccccColor of the rule line.
lineWidthDimension0.5 ptThickness 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 by sideColumnPercent) is ideal for margin notes, small figures, or supporting content. Values between 25–40% work well. With sideColumnRole: 'floats' the body text never enters the side column: it becomes a channel for the figures, tables and callouts placed with span: 'side'. A side figure or table stacks from the head of the channel on the page that first cites it — the marginal figure of a textbook sits at the top of its page even when the text cites it further down; one that cannot fit the rest of the channel waits for the next page's. A side box stacks beside the text it interrupts, and when the rest of the channel cannot hold it there it slides up to the lowest position that still fits (its foot on the channel's foot), or waits for the next page. span: 'page' floats and boxes still cross both columns, and a column float with placement.captionSide puts its caption in the channel, level with the figure. Combined with mirrored margins and sideColumnSide: 'outer', the channel sits at the outer edge of every page — the marginal column of a textbook.

#Headers & footers

The header and footer properties control per-page header and footer slots. Headers and footers render inside the existing page margins — they do not reserve additional space and do not shrink the content area.

Container frame. An element anchored to 'container' is placed in the margin band between the body and the trim edge, spanning the content-area width. The header container runs from the trim top down to the top of the body; the footer container from the bottom of the body down to the trim bottom. So top-* header anchors and bottom-* footer anchors measure from the trim edge, while bottom-* header anchors and top-* footer anchors measure from the body edge. The container never includes the bleed or the crop-marks band, so a header or footer lands at the same position on the trimmed page whether page.cutLines is on or off. Anchor to 'page' (the trim box) or 'bleed' to reach beyond the content-area width or into the bleed.

Slots use the unified design slot model: every element has a placement with an anchor (to the container or to another element by #id), an optional offset, and an optional size. The legacy flat fields align, marginFromBody, marginFromEdge, and width: 'full' are still accepted on input and are migrated to the new shape automatically; the equivalent new-shape description is documented below.

Each slot holds a list of text, rule, and box elements. Array order is paint order (first element paints first, last element paints on top). This holds whatever the anchors say: an element may anchor to one listed after it (anchor.to: '#ttl'), so a background box can come first and still be positioned against the text it sits behind.

Built-in defaults. When header or footer is undefined, postext applies a sensible built-in default rather than an empty slot:

  • Default header: {title} right-aligned on odd pages, {chapterTitle} left-aligned on even pages, and a full-width rule — all in the palette's main color, Open Sans 8pt/600, marginFromBody 16pt (text) / 13pt (rule).
  • Default footer: {pageNumber} centered on every page in the palette's main color, Open Sans 8pt/600, marginFromBody 16pt.

To opt out of the built-in defaults, set header: { elements: [] } (or footer: { elements: [] }). An explicit empty elements array is preserved as "no elements" — only undefined triggers the defaults.

PropertyTypeDefaultDescription
elementsHeaderFooterElement[]built-in defaults when undefined; [] disablesOrdered list of text and rule elements.

#Text elements

Text elements render a template string with placeholder substitution. Placeholders use {name} syntax; {{ and }} emit literal braces.

The defaults in the table below are those of a text element you add yourself. The built-in header and footer described under Built-in defaults above are ready-made elements with their own values (Open Sans 8pt/600 in the palette's main colour), not the element defaults.

PropertyTypeDefaultDescription
kind'text'Discriminator.
idstringStable id, unique within the slot. Other elements anchor to it with anchor.to: '#id'. The sandbox assigns one on creation.
contentstring''Template string. Supports placeholders listed below, plus — an attribute written on the current chapter's H1 line (# Title ). A missing attribute resolves to an empty string without a warning.
align'left' | 'center' | 'right''center'Horizontal alignment inside the slot.
parity'all' | 'odd' | 'even''all'Which pages the element appears on (page number parity: page 1 is odd).
pages'all' | 'body' | 'opener' | 'part' | 'blank''all'Which page roles the element appears on, combined with parity. After placement every page is classified as 'blank' (parity / separator padding, or no content), 'part' (a part-divider page), 'opener' (its first block is a heading whose level spans the page or forces a page break before it — the first page of a chapter) or 'body' (everything else). pages: 'body' hides a running head on chapter openers; pages: 'opener' shows a folio only there.
fontFamilystring'EB Garamond'Font family.
fontSizeDimension8 ptFont size.
fontWeightnumber400Font weight (100–900).
italicbooleanfalseWhether to render in italic.
colorColorValue#000000Text colour.
overflow'wrap' | 'ellipsis-start' | 'ellipsis-middle' | 'ellipsis-end' | 'clip''wrap'How the engine handles text that exceeds the element's available width. 'wrap' breaks the line into multiple lines; the ellipsis variants truncate to a single line and insert at the start, middle, or end; 'clip' hard-clips to the element's bounding box without inserting any character.
verticalAlign'top' | 'middle' | 'bottom''middle'Where the text sits inside a box taller than its lines — a fixed placement.size.height, or a box stretched by an anchored neighbour.
lineHeightnumber1.2Leading of wrapped lines, as a multiple of fontSize.
letterSpacingDimension0Tracking: extra space advanced after every character, spaces included, exactly as CSS letter-spacing does. Measured widths grow with it, so an auto-width box stays tight.
textTransform'none' | 'uppercase''none'Letter-case transform applied to the resolved text, placeholders included — a part title set in capitals in the contents.
boxElementBoxStyleOptional background and border drawn behind the text: backgroundColor, borderColor, borderWidth, borderRadius and a per-side padding that grows the box beyond the text (see Box elements for the fields).
dropCap{ lines, fontFamily, fontWeight, fontSize, color, gap }Drop cap on a wrapping text: the first letter set large beside the first lines lines (default 2), in its own face, weight and colour, gap from the text. fontSize defaults to the size whose cap height spans those lines.
paragraphIndentDimension0First-line indent of every paragraph after the first. A newline in the content — or the two characters \n, for text that comes from an attribute value — separates paragraphs; consecutive newlines count as one.
hyphenatebooleanfalseWhen true and overflow is 'wrap', long words that would still overflow after a regular line break are split at syllable boundaries (using the document's active hyphenation locale) with a soft-hyphen at the break.
marginFromBodyDimension6 ptAbsolute distance between the element's body-facing edge and the body edge. Independent of other elements. Migrated to placement.offset.y.
marginFromEdgeDimension0 ptHorizontal inset from the aligned content edge. Only applies when align is 'left' or 'right'. Migrated to placement.offset.x.
placementElementPlacementderived from align + marginFromBody + marginFromEdgeAdvanced placement (see below). When set, takes precedence over the legacy flat fields.

Available placeholders:

  • {pageNumber} — 1-based page number of the current page.
  • {totalPages} — total page count for the document.
  • {title}, {subtitle}, {author}, {publishDate} — values read from content.metadata. Unknown or empty metadata renders as an empty string (and raises a warning in the sandbox).
  • {chapterTitle} — text of the most recent H1 on or before the current page.
  • {partTitle}, {partNumber} — title and number of the current part (the most recent :::part page on or before the current page; blank parity pages just before a part page already belong to it). Empty before the first part.

Edge-implied alignment

When a text element's placement.anchor.to references another element by #id, the anchor edge implies a default text alignment for wrapped lines:

  • right-of and align-left imply text align: 'left' — wrapped lines flow rightward from the anchor.
  • left-of and align-right imply align: 'right' — wrapped lines hug the side closest to the anchor target.

The sandbox heading editor applies these implied alignments automatically when you change the anchor edge or target. They keep multi-line wrapped text visually anchored to the element it relates to (so e.g. the "P" of a wrapped "Postext" lines up vertically under the "I" of "Introduction").

#Rule elements

Rule elements render a line: horizontal across the slot, or vertical down it.

PropertyTypeDefaultDescription
kind'rule'Discriminator.
idstringStable id, unique within the slot, for anchor.to: '#id' references.
direction'horizontal' | 'vertical''horizontal'A horizontal rule runs along placement.size.width ('fill' = to the container edge) and is thickness tall. A vertical rule runs down placement.size.height ('fill' or unset = to the container edge) and is thickness wide — a divider between a running head and a folio.
colorColorValue#000000Stroke colour.
thicknessDimension0.5 ptLine thickness.
widthDimension | 'full''full''full' spans the content area; a Dimension constrains the line to a fixed length positioned by align.
align'left' | 'center' | 'right''center'Alignment when width is not 'full'.
marginFromBodyDimension6 ptAbsolute distance between the rule's body-facing edge and the body edge. Independent of other elements.
marginFromEdgeDimension0 ptHorizontal inset from the aligned content edge. Only applies when width is a fixed Dimension and align is 'left' or 'right'.
parity'all' | 'odd' | 'even''all'Which pages the rule appears on.
pages'all' | 'body' | 'opener' | 'part' | 'blank''all'Page roles the rule appears on (see the text-element pages field).
placementElementPlacementderived from align + marginFromBody + marginFromEdgeAdvanced placement (see Element placement). size.width / size.height set the rule length; width: 'fill' is the legacy 'full'.

#Box elements

Box elements paint a rounded rectangle inside the slot — useful as a backdrop behind text in chapter openers, sidebars or footers. Box elements are positioned exclusively through the placement field; they have no legacy flat shorthand. Fill, stroke and corner radius live in the nested style object (ElementBoxStyle), as in the JSON example under "Element placement".

PropertyTypeDefaultDescription
kind'box'Discriminator.
idstringStable id, unique within the slot. Sibling elements anchor to it with anchor.to: '#id'. The sandbox assigns one on creation.
style.backgroundColorColorValuetransparentFill colour. Set to transparent for an outline-only box.
style.borderColorColorValuetransparentStroke colour.
style.borderWidthDimension0 ptStroke width. Strokes are painted on the inside of the box's bounding rectangle so the outer dimensions stay constant.
style.borderRadiusDimension0 ptCorner radius. Clamped to half the smaller side at render time.
placementElementPlacementRequired. See "Element placement" below.
parity'all' | 'odd' | 'even''all'Which pages the box appears on.
pages'all' | 'body' | 'opener' | 'part' | 'blank''all'Page roles the box appears on (see the text-element pages field).

#Image elements

An image element draws a bitmap or SVG resource of the document — a publisher's logo on a title page, a mark in a running head. It is sized by its placement.size: with one of width / height left 'auto' (the default) the other side follows the image's aspect ratio; with both set the image is fitted inside the box and centred. A missing or non-image resource draws nothing.

{
  kind: 'image', id: 'logo', resourceId: 'logo-publisher',
  placement: { anchor: { to: 'page', edge: 'top-left' }, offset: { x: { value: 64, unit: 'mm' }, y: { value: 233, unit: 'mm' } }, size: { width: { value: 83, unit: 'mm' }, height: 'auto' } },
}
PropertyTypeDefaultDescription
idstringStable identifier; other elements may anchor to it as #id.
resourceIdstringId of a bitmap or SVG Resource of the document.
placementElementPlacementAnchor, offset and size (see Element placement). A 'fill' side runs to the container edge.
parity, pagesas above'all'Which pages the image appears on.

The PDF backend embeds the resource like a figure (an SVG with a print master uses it); the HTML viewer resolves it through resourceImageUrl.

#Element placement

ElementPlacement is the unified positioning model used by every element type (text, rule, box) inside any design slot — page header, page footer, or heading-level advanced design slot. Three pieces of state describe a placement:

interface ElementPlacement {
  /** What this element anchors to and which edge of that target. */
  anchor: {
    to: 'container' | 'page' | 'bleed' | `#${string}`; // container = the slot; page = trim box; bleed = trim box + bleed; #id = another element
    edge: AnchorEdge;
  };
  /** Distance from the anchor point. */
  offset?: { x?: Dimension; y?: Dimension };
  /** Optional fixed width / height. Width also accepts 'fill' (span the slot).
   *  `maxWidth` caps an 'auto' width (text): the element still shrink-wraps its
   *  content, so elements anchored to it stay attached, but a long text wraps or
   *  ellipsizes there — a running head can reserve room for the label hanging
   *  off it instead of squeezing that label out. */
  size?: { width?: Dimension | 'fill' | 'auto'; height?: Dimension | 'fill' | 'auto'; maxWidth?: Dimension };
}

AnchorEdge accepts:

  • Container edges (when anchor.to is 'container', 'page' or 'bleed'): top, top-left, top-right, bottom, bottom-left, bottom-right, left, right.
  • Element-relative edges (when anchor.to === '#someId'): right-of, left-of, below, above, align-top, align-bottom, align-left, align-right.

anchor.to: 'page' anchors the element to the trim box (the physical page after cutting) and 'bleed' to the trim box grown by cutLines.bleed on every side (identical to the trim box while cut lines are disabled). Both frames also become the reference for size: 'fill' and for the automatic width clamp, so a coloured band can run edge to edge regardless of the page margins:

{ "kind": "box", "id": "band", "placement": { "anchor": { "to": "bleed", "edge": "top-left" }, "size": { "width": "fill", "height": { "value": 6, "unit": "cm" } } }, "style": { "backgroundColor": { "hex": "#1d3557", "model": "hex" } } }

Inside a heading's advanced-design slot, page- and bleed-anchored elements do not grow the height reserved for the heading unless they extend below the heading's top edge (a band across the top of the page sits behind the opener; a band reaching below the heading pushes the body text down). Use advancedDesign.minHeight to reserve a fixed opener height regardless.

Each element has a stable id (auto-assigned by the sandbox; you can also set it by hand). Elements anchored to other elements form a small dependency graph that the engine resolves before measuring, so an element can chain off another without manual coordinates.

The legacy align + marginFromBody + marginFromEdge shape is parsed on input and rewritten into a placement at config-resolution time, so existing configs keep working unchanged.

#Body text

The bodyText property controls the typography of all paragraph text.

Type scaleTypographic hierarchy from H1 down to small body copy, showing relative sizes for headings, body text, and captions.H1Heading 132pxH2Heading 224pxH3Heading 320pxBodyBody text16pxSmallCaption / note13px
A consistent scale keeps hierarchy readable at a glance.
Spacing scaleA step-based spacing scale showing increasing values used across margins, paddings, and gaps.xs4 px×1sm8 px×2md16 px×4lg24 px×6xl40 px×102xl64 px×164 px
Spacing steps build up predictable rhythm through the layout.
PropertyTypeDefaultDescription
fontFamilystring'EB Garamond'Font family for body text. Any Google Font, system font, or custom family declared in customFonts.
fontSizeDimension8 ptBase font size for body text.
lineHeightDimension1.5 emVertical spacing between lines. Relative units (em, rem) scale with font size.
paragraphSpacingbooleanfalseWhen enabled, inserts a blank line (equal to lineHeight) between consecutive paragraphs for publisher-style separation.
colorColorValue#000000Text color.
boldColorColorValueMain Color (#295AA3)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.
italicColorColorValueMain Color (#295AA3)Color applied to italic/emphasis spans. Same palette-linked default as boldColor.
referenceColorColorValueMain Color (#295AA3)Color applied to inline :ref labels (resource references). Same palette-linked default as boldColor.
referenceBoldbooleantrueRender inline :ref labels with the bold font.
referenceItalicbooleanfalseRender inline :ref labels in italics.
textAlign'left' | 'justify''justify'Text alignment. Justified text distributes spacing across each line for even edges. Last lines of justified paragraphs render ragged at their natural width — except when Knuth-Plass accepted an overfull final line relying on glue shrink, in which case the inter-word spaces compress so the line fits the measure exactly (TeX glue-setting semantics, applied identically in the canvas, HTML, and PDF backends).
fontWeightnumber400Weight for normal text (100–900).
boldFontWeightnumber700Weight for bold/strong text (100–900).
hyphenationHyphenationConfigenabled, 'en-us'Automatic hyphenation settings. See below.
firstLineIndentDimension1.5emIndent applied to the first line of each paragraph (or to all lines except the first when hanging indent is enabled).
hangingIndentbooleanfalseWhen enabled, the indent is applied to all lines except the first (French/hanging indent).
indentAfterHeadingbooleantrueWhen set to false, the first paragraph immediately following a heading is rendered without first-line indent — a typographic convention common in scientific publications and many book styles. Has no effect when hangingIndent is enabled.
maxWordSpacingnumber2Upper bound for word spacing in justified text, expressed as a multiplier of the normal space width. Lines exceeding this ratio are considered "loose".
minWordSpacingnumber0.6Lower bound for word spacing in justified text, as a multiplier of the normal space width.
optimalLineBreakingbooleantrueUse 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.

PropertyTypeDefaultDescription
enabledbooleantrueWhether to allow hyphenation.
localeHyphenationLocale'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.

Document language

The top-level locale is the language of the document as a whole. It takes the same values as hyphenation.locale and is the fallback when that field is unset, so a Spanish book only needs locale: 'es' to hyphenate in Spanish. It also picks the language of the built-in table continuation strings ((cont.) / Continued versus Continúa, see Tables taller than the page) and is the language tagged into an accessible PDF. Unset, the engine assumes 'en-us'; the sandbox falls back to the interface language and exposes the field at the top of the Body text section.

const config: PostextConfig = {
  locale: 'es',
  bodyText: { textAlign: 'justify', hyphenation: { enabled: true } }, // hyphenates in Spanish
};

#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.

PropertyTypeDefaultDescription
avoidOrphansbooleantrueDiscourage a paragraph from ending with fewer than orphanMinLines lines at the top of the next column.
orphanMinLinesnumber2Minimum lines required at the top of the next column when a paragraph is split. Only active when avoidOrphans is true.
orphanPenaltynumber1000Demerit added when an orphan constraint is violated. Higher values bias the algorithm more strongly against orphans; 0 disables the penalty.
avoidOrphansInListsbooleantrueWhen true, list items also receive orphan protection (not just paragraphs). Only effective when avoidOrphans is true.
avoidWidowsbooleantrueDiscourage a paragraph from starting with fewer than widowMinLines lines at the bottom of the current column.
widowMinLinesnumber2Minimum lines required at the bottom of the current column when a paragraph is split. Only active when avoidWidows is true.
widowPenaltynumber1000Demerit added when a widow constraint is violated. 0 disables the penalty.
avoidWidowsInListsbooleantrueWhen true, list items also receive widow protection. Only effective when avoidWidows is true.
avoidRuntsbooleantrueDiscourage paragraphs from ending with a very short last line — a runt, e.g. a single short word alone.
runtMinCharactersnumber20Approximate 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".
runtPenaltynumber1000Equivalent-badness injected into the Knuth–Plass squared demerit formula (same scale as line badness, which saturates at 10000). 0 disables the penalty.
avoidRuntsInListsbooleantrueWhen true, list items also receive the runt penalty. Only effective when avoidRunts is true.
tightenRuntsbooleantrueWhen the penalty could not avoid a runt, set the paragraph one line shorter instead: the word spaces tighten (never past minWordSpacing) and, if that alone does not carry the line, a little negative tracking joins in. Needs optimalLineBreaking and avoidRunts.
maxRuntTrackingnumber10Most tracking a runt fix may take, in thousandths of an em (the InDesign unit: 10 = 0.01 em per character), applied as a tightening. 0 leaves the fix to word spacing alone.
slackWeightnumber10Weight applied to the squared "unused column space" cost. Higher values make the layout prefer filling columns tightly; 0 disables the slack pressure entirely.
keepColonWithListbooleantrueWhen 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. For the mathematically curious: at the default runtPenalty of 1000, avoiding a runt dominates any alternative break set requiring up to roughly r≈2.15 word-spacing stretch.

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

PropertyTypeDefaultDescription
fontFamilystring'Open Sans'Font family for all headings.
lineHeightDimension1.2 emLine height for headings. Tighter than body text.
colorColorValueMain Color (#295AA3)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.
fontWeightnumber700Font weight for headings (100–900).
marginTopDimension1.5 emSpace above headings.
marginBottomDimension0.5 emSpace below headings.
keepWithNextbooleantrueWhen 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.
snapToGridbooleantrueWhether the flow snaps back onto the baseline grid under a heading. With true the heading's marginBottom is rounded up to whole grid lines; with false the exact margin is kept and the text under the heading may sit off the grid until the next snap point (the end of a list, a :::paragraphs tail, display math) — the way many books set a line and a half under a heading.
balancingColumnBalancingConfigenabledVertical column balancing — extra space above headings so columns end flush with the page bottom. See below.

#Column balancing

Publishers expect every column to start at the top of the page and end flush with its bottom. Break rules (orphan/widow protection, headings kept with their text, unsplittable figures) naturally leave short columns — one or more empty baseline-grid lines at the bottom. When balancing is enabled, the engine does what a compositor would, applying three levers in editorial priority order:

  1. A box closing the column — a callout that ends a short column is pushed down by the exact room under its foot, so its bottom edge lands on the last grid slot of the page, level with the last line of the column beside it.
  2. Headings — whole grid lines are added to the top margin of the headings inside the short column. When several lines are needed and the column holds several headings, the lines are distributed among them, always giving the largest share to the most important heading (an h2 receives more than an h3). Headings sitting at the very top of a column never receive extra space, so columns keep starting at the page top.
  3. List ends — when the headings cannot absorb the whole gap, a grid line is added where a list/enumeration ends (space after a list reads naturally), capped per list end.
  4. Loose paragraphs — as a last resort, one paragraph of the column is re-broken one line longer (TeX's \looseness=+1), choosing the longest paragraph so the extra word spacing dilutes invisibly. The loose solution is only accepted when every line stays below bodyText.maxWordSpacing — type colour never exceeds the limit you already configured. Requires bodyText.optimalLineBreaking.

The last column of a page is only balanced when the page flows naturally into the next one — a chapter's closing page legitimately ends short.

PropertyTypeDefaultDescription
enabledbooleantrueWhether to balance column bottoms.
maxLinesPerHeadingnumber4Maximum extra grid lines that may be added above a single heading.
stretchAfterListsbooleantrueAllow extra grid lines where a list ends, when headings cannot absorb the whole gap.
maxLinesAfterListnumber1Maximum extra grid lines after a single list end.
stretchAfterFloatsbooleantrueAllow extra grid lines under a figure or table that heads the short column (a top float), after the list-end lever, so the text below it moves down instead of the column ending short.
maxLinesAfterFloatnumber1Maximum extra grid lines under a single top float.
looseParagraphsbooleantrueLast resort: re-break paragraphs of a short column one line looser (one extra line each), within bodyText.maxWordSpacing.
maxLooseParagraphsnumber2How many paragraphs of one short column may run a line long, longest first.
trackParagraphsbooleantrueWhen word spacing alone cannot gain the line, a loose paragraph may also take the smallest positive tracking (letter spacing) that does.
maxTrackingnumber10Upper limit for that tracking, in thousandths of an em per character (10 = 0.01 em).
trailingbooleantrueLevel the closing band of a chapter and of the document: when the flow ends before the page is full (at a chapter opener, a :::part, a chapter-closing placement: 'fixed' box or the end of the document) with its columns uneven, they are cut level — a band cap of ceil(Σ used / N / grid) lines, resolved after the levers above have settled the earlier pages — so a short bibliography ends at the same height in every column instead of filling the first column and leaving the last one half empty. Only active when enabled is true.
beforeSpanbooleantrueLevel the band a page-span block leaves behind: when a span: 'page' callout does not fit under the current columns even after a level cut, and has to move to the next page or split (calloutStyles[].keepTogether: false), the columns it interrupts are cut level — the same trailing cap a closing band gets — instead of the first filling the page and the last ending short. The page is left as an explicit break so the levers above do not stretch its last column back to the page bottom; the box, or the part of it that fits, then sits under the levelled columns. Only active when enabled is true.

#Per-level overrides

Each heading level can override the general defaults via the levels array. Only fontSize (plus H1's breakBefore, noted below) differs by default — all other properties inherit from the general heading settings.

LevelDefault font sizeDefault breakBefore
H118 pt{ enabled: true, parity: 'always-odd' }
H215 pt{ enabled: false, parity: 'any' }
H312 pt{ enabled: false, parity: 'any' }
H410 pt{ enabled: false, parity: 'any' }
H59 pt{ enabled: false, parity: 'any' }
H68 pt{ enabled: false, parity: 'any' }

The H1 default models a book-style chapter layout: every top-level heading opens on a fresh right-hand (odd) page, with a mandatory blank separator page from the previous chapter. Override it on levels[0].breakBefore if your document is flatter than a book.

Per-level overrides support the same properties as the general defaults — fontSize, lineHeight, fontFamily, color, fontWeight, marginTop, marginBottom — plus two level-only fields:

PropertyTypeDefaultDescription
italicbooleanfalseRender the heading in italic. Applied on top of fontWeight.
textTransform'none' | 'uppercase''none'Upper-cases the heading title (a numbering prefix is kept as written). Length-preserving so the editor's source map stays 1:1: characters whose upper-case form expands (ßSS) are left as they are. The transformed title also feeds the {titleText} placeholder of advanced designs and chapter openers.
numberingTemplatestring''Template of the level's automatic number. A token prints the running counter of that heading level, optionally formatted by a suffix — upper roman, lower roman, / alphabetic, zero-padded — and any other text is literal ('Chapter . ', '.'; a backslash escapes a literal brace). A token whose counter is still empty collapses together with its adjacent separator. Empty (the default) means no automatic number. The rendered number is prepended to the title in the flow, feeds the placeholder of an advanced design slot (where the prefix itself is not prepended) and is printed in the table of contents.
breakBeforeHeadingBreakBeforeConfigH1: { enabled: true, parity: 'always-odd' }
H2–H6: { enabled: false, parity: 'any' }
Force a page break before every heading of this level. parity: 'odd' / 'even' further constrains which side of the spread the heading opens on — a blank padding page is inserted when needed (still counted in the page numbering). 'always-odd' / 'always-even' additionally guarantee at least one mandatory blank separator page between the previous content and the new heading (the separator belongs to the previous chapter; any further parity padding belongs to the new one). When the heading is the very first block of the document and the first page is still empty, parity enforcement is skipped — the heading lands on page 1 as written.
headings: {
  fontFamily: 'Merriweather',
  levels: [
    // Canonical book preset: chapters on a right-hand (odd) page.
    { level: 1, fontSize: { value: 24, unit: 'pt' }, breakBefore: { enabled: true, parity: 'odd' } },
    { level: 2, fontSize: { value: 18, unit: 'pt' }, italic: true },
  ]
}

#Break before

breakBefore is orthogonal to the numbering controls: turning it on forces a page break, but the numeric counter only resets when you explicitly insert a :::numbering directive. Blank parity pages count as real pages in the sequence and receive headers/footers according to their normal odd/even rules.

Parity values

ValueBehavior
'any' (default)No parity constraint. The heading just opens on the next page.
'odd'Ensure the heading opens on an odd (right-hand) page. A single blank is inserted only when the natural next page is even.
'even'Same but for an even (left-hand) page.
'always-odd'Guarantee at least one mandatory blank separator page between the previous content and the new heading, then ensure odd parity. Useful when every chapter must start on a fresh spread.
'always-even'Same but for an even page.

Blank-page ownership

Blank pages inserted by breakBefore carry chapter-title headers based on why they were inserted:

  • Pages inserted to satisfy a parity constraint ('odd', 'even', or the parity tail of 'always-*') belong to the upcoming chapter. Their {chapterTitle} header placeholder resolves to the new chapter's title — because the blank exists only to push the new chapter onto the correct parity.
  • The mandatory leading separator inserted by 'always-odd' / 'always-even' belongs to the previous chapter. It's a deliberate end-of-chapter breath, so the {chapterTitle} header still shows the old chapter's title.

Document-start exception

When the very first block of the document is a heading with breakBefore enabled — or the source opens with :::pagebreak — parity enforcement is skipped while the first page is still empty. The heading lands on page 1 as written, regardless of the configured parity, so a document that begins with a # Chapter 1 configured parity: 'odd' doesn't inherit a spurious leading blank. Once any content has been placed, parity enforcement behaves normally.

#Span and advanced design

Each heading level accepts two additional fields that control how the heading renders as a full page-wide chapter opener.

PropertyTypeDefaultDescription
span'column' | 'page''column'When 'page', the heading is treated as a chapter opener and its advanced design (if enabled) is attached to the page as an opener band above the body. Pair with breakBefore.enabled: true so the opener reliably begins a new page.
advancedDesignHeadingAdvancedDesignConfigFree-composition design slot for this level. When enabled, the slot's elements compose the opener. Use inside a text element to render the heading's title text; use , , etc. to insert the formatted heading number.
advancedDesign.minHeightDimensionMinimum height reserved for the heading in the column flow. The reserved height is max(design content bottom, minHeight), so an opener can push the body text down (or claim the whole page) even when its elements are short or anchored to the page/bleed frames above the heading. Applies whenever enabled is true, even with an empty slot.

Example — a minimalist chapter opener that shows "Chapter N" above the title:

{
  "headings": {
    "levels": [
      {
        "level": 1,
        "span": "page",
        "breakBefore": { "enabled": true, "parity": "always-odd" },
        "advancedDesign": {
          "enabled": true,
          "slot": {
            "elements": [
              {
                "kind": "text",
                "id": "chapterLabel",
                "placement": {
                  "anchor": { "to": "container", "edge": "top" },
                  "offset": { "y": { "value": 48, "unit": "pt" } },
                  "size": { "width": "fill" }
                },
                "content": "Chapter {numberRoman}",
                "fontSize": { "value": 10, "unit": "pt" },
                "align": "center",
                "overflow": "ellipsis-end"
              },
              {
                "kind": "text",
                "id": "chapterTitle",
                "placement": {
                  "anchor": { "to": "#chapterLabel", "edge": "below" },
                  "offset": { "y": { "value": 12, "unit": "pt" } },
                  "size": { "width": "fill" }
                },
                "content": "{titleText}",
                "fontSize": { "value": 24, "unit": "pt" },
                "fontWeight": 700,
                "align": "center",
                "overflow": "wrap",
                "hyphenate": true
              }
            ]
          }
        }
      }
    ]
  }
}

Heading placeholders available inside a level's design slot:

  • {titleText} — the heading's plain text (without numbering prefix).
  • {number} — the formatted number per the level's numberingTemplate.
  • {numberDecimal}, {numberRoman}, {numberRomanLower}, {numberAlpha}, {numberAlphaLower} — alternative numeral formats.
  • {chapterNumber}, {chapterTitle}, {pageNumber}, {totalPages}, {title}, {subtitle}, {author}, {publishDate} — shared metadata placeholders.
  • {attr.<key>} — an attribute written on the heading line itself (# Title {author="I. Zango Martín"}), falling back to the current chapter's H1 attribute. Missing attributes resolve to an empty string with no warning.

#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

PropertyTypeDefaultDescription
fontFamilystringinherits bodyText.fontFamilyFont used for the item text.
colorColorValueMain Color (#295AA3)Text and bullet color for items. Bound to the default palette's main-color entry.
fontWeightnumber700Weight for item text (100–900). Bullets inherit this weight unless overridden per-level.
italicbooleanfalseRender item text in italic.
bulletCharstring'•'Glyph used as the bullet marker.
bulletFontSizeDimension1 emSize of the bullet glyph. Relative units scale with the body font size.
gapDimension0.5 emHorizontal space between the bullet and the item text.
indentDimension0 emBase indent for level 1. Deeper levels cascade from the parent's text-start unless overridden (see below).
bulletVerticalOffsetDimension0 emFine-tune bullet vertical position. Negative values move the bullet up, positive values move it down.
marginTop / marginBottomDimension1.5 emSpace before and after the list as a whole.
itemSpacingDimension0 emExtra vertical space inserted between items on top of the line height.
hangingIndentbooleantrueWhen enabled, wrapped lines align with the first text character rather than under the bullet.
levelsUnorderedListLevelConfig[]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:

PropertyTypeDefaultDescription
taskCheckboxCharstring'☐'Glyph used for unchecked tasks.
taskCheckedCharstring'☑'Glyph used for completed tasks.
taskCompletedStrikethroughbooleantrueDraw a strikethrough line across the text of completed tasks.
taskCompletedColorColorValueinherits item colorOptional 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:

PropertyTypeDescription
bulletCharstringBullet glyph for this depth.
fontFamilystringItem font family for this depth.
fontSizeDimensionBullet glyph size for this depth.
colorColorValueItem color.
fontWeightnumberItem weight.
italicbooleanItalic toggle.
indentDimensionExplicit indent for the bullet at this depth. See the cascade rule below.
verticalOffsetDimensionVertical 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

PropertyTypeDefaultDescription
fontFamilystringinherits bodyText.fontFamilyFont used for the item text and the number marker.
colorColorValueMain Color (#295AA3)Text and number-marker color for items. Bound to the default palette's main-color entry.
fontWeightnumber700Weight for item text and number markers (100–900).
italicbooleanfalseRender item text in italic.
numberFormatOrderedListNumberFormat'arabic'Number style: 'arabic', 'lower-alpha', 'upper-alpha', 'lower-roman', 'upper-roman'.
separatorstring'.'Character placed between the number and the text — typically '.' or ')'.
separatorFontFamilystringinherits fontFamilyFont of the separator. When any separator style differs from the number's, the separator is drawn as its own run after the (right-aligned) number — e.g. 1 in Optima Bold black followed by in DIN Pro Bold blue.
separatorFontWeightnumberinherits fontWeightWeight of the separator (100–900).
separatorItalicbooleaninherits italicRender the separator in italic.
separatorColorColorValueinherits colorColour of the separator. Palette references are honoured.
separatorGapDimension0 emSpace between the number and the separator. The item text still starts gap after the separator.
numberFontSizeDimension1 emSize of the number marker.
gapDimension0.5 emHorizontal space between the number and the item text.
indentDimension0 emBase indent for level 1; deeper levels cascade from the parent's text-start unless overridden.
numberVerticalOffsetDimension0 emFine-tune the vertical position of the number marker.
marginTop / marginBottomDimension1.5 emSpace before and after the list as a whole.
itemSpacingDimension0 emExtra vertical space between items.
hangingIndentbooleantrueWrapped lines align with the first text character rather than under the number.
levelsOrderedListLevelConfig[]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, verticalOffset and the separator style (separatorFontFamily, separatorFontWeight, separatorItalic, separatorColor, separatorGap) — the same indent cascade as unordered lists applies. A level's separator style inherits its own number style unless the list-wide separator setting is given.

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 MathJax (the mathjax-full package) in SVG output mode, 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.
}
PropertyTypeDefaultDescription
enabledbooleantrueWhen 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.
fontSizeScalenumber1.0Multiplier 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.
colorColorValueinherits body colourColour 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.
marginTopDimension0.8emSpace above a display math block. Ignored for inline math.
marginBottomDimension0.8emSpace 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: '#295AA3', 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.

#Resource types

A resource type is a user-definable category — Figure, Table, Diagram, Listing… — that drives how the resources of that kind are numbered, captioned, and referenced. The list lives on config.resourceTypes; the sandbox edits it through a dedicated section of the configuration panel.

When config.resourceTypes is unset, Postext ships two built-in defaults: Figure and Table, both numbered {h1}.{n} (resetting on every level-1 heading) with decimal counters.

The built-in defaults are locale-aware. The exported defaultResourceTypes(locale = 'en') localizes the type names, short labels, and caption prefixes to the document's locale — English yields Figure/Fig. and Table/Tab.; Spanish yields Figura/Fig. and Tabla/Tabla. Regional tags such as es-ES resolve by language, and any locale without translations falls back to English. The numbering behaviour (numberingTemplate: '{h1}.{n}', resetOn: 'h1', decimal counters) is language-independent. Each call returns fresh objects, so you can mutate the result freely:

import { defaultResourceTypes } from 'postext';
 
const types = defaultResourceTypes('es');
// => [{ id: 'figure', name: 'Figura', shortLabel: 'Fig.', captionPrefix: 'Figura',
//       numberingTemplate: '{h1}.{n}', resetOn: 'h1', counterFormat: 'decimal', … },
//     { id: 'table',  name: 'Tabla',  shortLabel: 'Tabla', captionPrefix: 'Tabla', … }]
type ResourceCounterFormat =
  | 'decimal'
  | 'roman-lower'
  | 'roman-upper'
  | 'alpha-lower'
  | 'alpha-upper';
 
type ResourceCounterReset = 'never' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
 
interface ResourcePlacement {
  position?: 'auto' | 'top' | 'bottom' | 'here'; // which free slot a float may take; 'here' = inline embed at the ::resource directive
  span?: 'column' | 'page' | 'side';             // one column, the full content width, or the float-only side column
  rotate?: 'ccw' | 'cw';                         // a quarter turn: a landscape table on a page of its own
  width?: number;                                // fraction (0 < width < 1) of the column or page width; default: the whole width
  align?: 'left' | 'center' | 'right';           // where a float narrower than its column sits; default 'left'
  captionSide?: boolean;                         // caption beside the figure, in the side column of a oneAndHalf layout (column floats only)
}
 
interface ResourceType {
  id: string;                          // stable id, referenced by Resource.typeId
  name: string;                        // singular display name, e.g. "Figure"
  namePlural?: string;                 // optional plural, e.g. "Figures"
  shortLabel: string;                  // compact label for inline refs, e.g. "Fig."
  numberingTemplate: string;           // "{h1}.{n}" or "{n}"
  resetOn: ResourceCounterReset;       // when the {n} counter resets
  counterFormat: ResourceCounterFormat;// how {n} is formatted
  captionPrefix: string;               // prepended to the caption, e.g. "Figure"
  defaultPlacement?: ResourcePlacement;// fallback placement for this type's resources
}

ResourcePlacement is the same shape a resource sets on its own placement. position picks the kind of free slot a float may take — auto (the default) takes the first one after the first reference, top / bottom restrict it to that kind of band, here embeds the resource inline. span sets the float's extent: one column, the full content width, or the float-only side column of a column-and-a-half layout. rotate turns the resource a quarter turn and makes it a page-span float on a page of its own. width narrows a float to a fraction of its column (or of the page for a page-span float) — a small table in a wide column, say. align says where such a narrower float sits — left by default, centre or right. captionSide sets the caption beside the figure in the float-only side column of a column-and-a-half layout (layout.sideColumnRole: 'floats'), level with the figure's top (its bottom for a bottom float); it applies to column floats only, and a page without such a column keeps the caption under the figure. When neither the resource nor its type sets a placement, the built-in default is auto / column.

PropertyTypeDescription
idstringStable identifier referenced by each resource's typeId. Set once when the type is created; deleting a type that resources still reference raises a dangling type warning.
namestringSingular display name. Used by the style="full" inline reference (e.g. Figure 1.7).
namePluralstring (optional)Plural display name, for UI labels and lists of resources.
shortLabelstringCompact abbreviation used by the default inline reference style (e.g. Fig. 1.7).
numberingTemplatestringTemplate for the computed number. See Template tokens below. Common forms are (chapter-scoped, e.g. 2.3) and (a single running count).
resetOnResourceCounterReset'never' gives one document-wide running count; 'h1'..'h6' reset the counter each time a heading of that level (or any ancestor) is encountered. Set this to match the heading level that appears in the template — e.g. with resetOn: 'h1'.
counterFormatResourceCounterFormatHow the counter renders: decimal (1, 2, 3), lower/upper roman (i, ii / I, II), or lower/upper alpha (a, b / A, B). Heading tokens (…) always render as decimals.
captionPrefixstringText prepended to the figure/table caption. The computed number follows the prefix — a caption renders as . , e.g. Figure 1.7. The original plan.
defaultPlacementResourcePlacement (optional)Placement used by resources of this type that do not set their own placement: position, span, rotate, width, align and captionSide, each resolved independently. When neither the resource nor the type sets a field, the built-in default applies: auto / column, upright, the whole width, left-aligned, caption under the figure. See Numbering and references below for the resolution chain and Document format › Resources for what each value does, including turned resources.
captionStyleCaptionStyleConfig (optional)Partial caption style override for resources of this type. Only the keys you set replace the global captionStyle; everything else is inherited (an overridden color also drives the label and note colours unless those are set explicitly). Typical use: tables captioned above on a coloured bar while figures keep their caption below. Palette references resolve like any other colour.

#Template tokens

numberingTemplate is rendered with the same engine as heading numbering (see Headings). It recognises two kinds of token:

  • {n} — the per-type counter, formatted per counterFormat. This is the value that increments per resource and resets according to resetOn.
  • {h1}{h6} — the heading numbers in effect at the point of first reference, always rendered as decimals. {h1} is the current level-1 heading number, {h2} the level-2, and so on.

Any other text is literal. A backslash escapes a literal {, }, or \. When a heading token has no value in scope (e.g. {h1} before any level-1 heading), it collapses together with its adjacent separator — so {h1}.{n} degrades gracefully to the bare counter.

TemplateWith h1 = 2, counter = 3Notes
3A single running count. Pair with resetOn: 'never'.
2.3Chapter-scoped. Pair with resetOn: 'h1'.
2.0.3Section-scoped. Pair with resetOn: 'h2'.

#Numbering and references

The number a resource type produces is what :ref prints and what the caption prefix precedes. :ref{id} is the primary form: the first reference in reading order incorporates the resource, which floats to the first free slot after that reference — the bottom of the referencing column, the top or bottom of the next empty column, or a band of the next page (per its resolved placement — position: 'auto' | 'top' | 'bottom' | 'here' and span: 'column' | 'page' | 'side', plus rotate, width, align and captionSide, resolved per resource, then the type's defaultPlacement, then the built-in auto / column; 'top' / 'bottom' restrict the search to that kind of slot). The ::resource{id} block embed is optional and only needed for placement.position: 'here' — an inline, non-floating embed at an exact point in the flow. The full document-side grammar — both forms plus :ref's style and text options — is documented in Document format › Resources, including how first-reference order drives the count.

This mirrors heading numbering: just as a heading level carries a numberingTemplate, a resource type carries one too — but the resource counter ({n}) advances per first-reference rather than per heading, and resetOn ties it back to the heading hierarchy.

#Table style

The tableStyle property controls the typography and decoration of table resources — every table's, unless it picks a named table style. Body and header cells are styled independently. Font family, size, and colours inherit the resolved body text when unset, so a document with no tableStyle renders tables with body-text typography.

const config: PostextConfig = {
  tableStyle: {
    headerBold: true,
    headerBackground: { hex: '#f0f0f0', model: 'hex' },
    borders: true,
    borderWidth: { value: 0.75, unit: 'pt' },
  },
};
PropertyTypeDefaultDescription
bodyFontFamilystringbody text fontFont family for body cells.
bodyFontSizeDimensionbody text sizeFont size for body cells.
bodyColorColorValuebody text colorText color for body cells.
headerFontFamilystringbody text fontFont family for header cells.
headerFontSizeDimensionbody text sizeFont size for header cells.
headerColorColorValuebody text colorText color for header cells.
headerBoldbooleantrueRender header cells in bold.
headerItalicbooleanfalseRender header cells in italics.
headerBackgroundEnabledbooleantruePaint a fill behind the header row.
headerBackgroundColorValue#f0f0f0Header row fill color.
bodyBackgroundEnabledbooleanfalsePaint a fill behind body rows.
bodyBackgroundColorValue#ffffffBody row fill color (only painted when enabled).
bordersbooleantrueDraw cell borders.
borderColorColorValuebody text colorBorder stroke color.
borderWidthDimension0.75ptBorder stroke width (≈1px at 96 DPI; scales with page DPI).
cellPaddingDimension0.375emInner padding of every cell.
rules'grid' | 'horizontal' | 'outer' | 'none''grid'Which rules to stroke when borders is on: the full cell grid, horizontal rules only (top and bottom edge of every row, no verticals), the outer frame only, or none.
borderRadiusDimension0Corner radius of the table's outer frame. The frame is stroked round (with grid or outer rules), the cell fills and the header background are clipped to it — also with rules: 'none' or borders off — and horizontal rules are trimmed to its outer contour; the inner rules stay straight. A table split across pages rounds the top corners of its first part and the bottom corners of its last. Clamped to half the table's width and height.
overflow'split' | 'clip' | 'hide''split'What becomes of a table taller than the page: continue it on the following pages, keep only the rows that fit, or leave it out. See below.
continuedSuffixstring'(cont.)'Appended, in italics, to the caption of every continued part of a split table.
continuesMarkerEnabledbooleantrueSet a marker under every part that continues on the next page.
continuesMarkerstring'Continued' / 'Continúa'Text of that marker, set flush right under the part in the note typeface (see caption style). The default follows the document locale.

Border widths are kept fractional: a 0.5pt rule is stroked as a hairline in the PDF and on screen rather than being rounded up to a full pixel (the floor is 0.25px).

#Named table styles

A document rarely sets every table alike: a checklist in a navy grid with a rounded frame, an option row boxed by its outer frame only, a data table in plain horizontal rules. tableStyles declares named variants, and a table resource picks one with table.styleId. Every field a style leaves unset is read from tableStyle first and from the body text after that, so a style states only what sets its tables apart. A table without a styleId, or with an id no style declares, keeps tableStyle — a document without tableStyles renders exactly as before.

const config: PostextConfig = {
  tableStyle: {
    borderColor: { hex: '#163a76', model: 'hex' },
    borderWidth: { value: 1.3, unit: 'pt' },
    borderRadius: { value: 10, unit: 'pt' },
  },
  tableStyles: [
    {
      id: 'option',
      name: 'Option row',
      rules: 'outer',
      borderColor: { hex: '#7a9cc6', model: 'hex' },
      borderWidth: { value: 1, unit: 'pt' },
      borderRadius: { value: 8, unit: 'pt' },
      headerBackgroundEnabled: false,
    },
  ],
};
 
// In the resources: this table is set in the "option" style.
const resource: Resource = {
  id: 'choices', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
  table: { model: { rows: [/* … */] }, styleId: 'option' },
};

Each entry takes every tableStyle field plus id (what table.styleId references) and an optional name for the editor (defaults to the id). Everything a style can set applies per table — typography, fills, borders, rules, corner radius, padding and the overflow behaviour with its continuation strings. resolveTableStylesConfig(styles, tableStyle, resolvedBodyText, locale?) returns the resolved list, pickTableStyle(resolved, styleId) the style a table is set in, and stripTableStylesDefaults drops the unset fields (it keeps a field equal to its built-in default, which still overrides a different tableStyle value).

#Tables taller than the page

A floated table that does not fit the fresh page it is offered is not squeezed or overflowed: with overflow: 'split' (the default) the engine cuts it between rows at the last edge that fits the page and continues it on the following pages, as many as it takes. Every continued part repeats the table's header rows (TableModel.headerRowCount, or the leading rows made of header cells when it is unset) and carries the caption again with continuedSuffix after the description — "Table 6-4. Title (cont.)". Every part that goes on gets continuesMarker under it, flush right, in the note typeface; the table's note is held back for the last part. A cut never runs through a merged cell (a rowspan moves whole to the next part), and a row that heads the rows below it — a single cell across the whole table — is carried to the next part rather than left stranded at the foot of a page.

'clip' keeps the leading rows that fit the page and drops the rest silently (the note still closes the part); 'hide' leaves the table out altogether. Both apply only when the table is taller than a page: a table that fits is placed whole in any mode. Inline (placement.position: 'here') tables are not split.

The continuation strings default per document language (locale, else the hyphenation locale): English (cont.) / Continued, Spanish (cont.) / Continúa.

Cell content is inline markdown, and a line break inside a cell starts a new paragraph. A paragraph that opens with a bullet or dash (, -, *, ) or a number (1., 1)) followed by a space is set as a list item: the marker is painted as written, the text hangs off it by the document's unorderedLists.gap, wrapped lines align with the text, and two leading spaces nest a level. So a cell written as • Ofrece elección\n• Acomoda a personas diestras y zurdas comes out as a two-item list.

Column widths belong to the table model, not the style: TableModel.columnWidths is an optional array of relative weights, one per column, normalised at layout time — [2, 1, 1] gives the first column half the width. A missing array, a wrong length, or a non-positive weight falls back to an equal split. The table editor keeps the array aligned when columns are added or removed.

#Caption style

The captionStyle property controls resource captions (the Figure 1 — … line under — or above — images, SVGs, and tables). The numbered label and the description share the same typeface and size — an engine constraint — but the label can carry its own weight, italics, and color. Font family, size, and color inherit the body text when unset. The caption can sit above the resource (the usual convention for tables) and be set on a coloured bar spanning the block width; an optional smaller note (source line, credits — Resource.note) is styled through the note sub-object. A resource type can override any of these fields for its own resources via ResourceType.captionStyle (see Resource types).

const config: PostextConfig = {
  captionStyle: {
    align: 'center',
    labelBold: true,
    labelColor: { hex: '#295AA3', model: 'hex' },
    descriptionItalic: true,
    position: 'above',
    backgroundEnabled: true,
    padding: { value: 0.35, unit: 'em' },
    note: { italic: true, align: 'left' },
  },
};
PropertyTypeDefaultDescription
fontFamilystringbody text fontCaption font family (label and description).
fontSizeDimensionbody text sizeCaption font size (label and description).
colorColorValuebody text colorDescription text color.
align'left' | 'center' | 'right''left'Horizontal alignment of the caption under the resource.
gapDimension0.75emVertical gap between the resource and its caption.
labelBoldbooleantrueRender the numbered label (e.g. Figure 1) in bold.
labelItalicbooleanfalseRender the numbered label in italics.
labelColorColorValuecaption colorColor of the numbered label.
descriptionItalicbooleanfalseRender the description text in italics.
position'above' | 'below''below'Where the caption sits. With 'above' the caption (and its bar) comes first and the resource body moves down by the caption height plus gap; the note then goes under the body.
backgroundEnabledbooleanfalsePaint a bar behind the caption. The bar spans the full block width and encloses the caption lines plus padding on every side.
backgroundColorValuemain palette colorBar fill color (only painted when enabled).
paddingDimension0.35emInner padding between the bar edge and the caption text. Ignored when the bar is off.
noteobjectStyling of the resource note — see the sub-table below.

The note sub-object styles Resource.note, a short run (source, credits, a remark) set under the resource in a smaller size. It accepts the same inline formatting and :ref marks as the caption and inherits the caption typeface. It is placed under the caption when the caption is below, and under the resource body when the caption is above; its height counts towards the block, so a resource with a note floats as one unit.

PropertyTypeDefaultDescription
note.fontSizeDimension0.85 × caption sizeNote font size.
note.colorColorValuecaption colorNote text color.
note.italicbooleanfalseRender the note in italics.
note.gapDimension0.35emGap between the note and what precedes it (caption or body).
note.align'left' | 'center''left'Horizontal alignment of the note.

Per-type overrides are merged with mergeCaptionStyle(resolvedCaptionStyle, override, palette?), exported for hosts that need the same resolution outside the pipeline.

#Diagram style

The diagramStyle property controls how embedded SVG diagrams (kind: 'svg' resources) are coloured. Its single feature today is single-ink mode: a recolouring pass that maps every colour in a diagram to a tint of one ink, so figures reproduce faithfully when the document is printed with a single spot colour.

const config: PostextConfig = {
  diagramStyle: {
    singleInk: true,
    inkColor: { hex: '#295AA3', model: 'hex' },
  },
};
PropertyTypeDefaultDescription
singleInkbooleanfalseRecolour every embedded SVG diagram to tints of a single ink.
inkColorColorValueMain Color (#295AA3)The ink. Defaults to the document's main palette colour (palette-linked via paletteId: 'main-color'), so swapping the palette swatch retints the diagrams along with headings and bold runs.

#How single ink works

When singleInk is enabled, every colour in the SVG markup is rewritten to a tint of inkColor whose strength is 1 − relative luminance (Rec. 709 coefficients applied to the gamma-encoded channels — a perceptual approximation that is plenty for tint mapping). The mapping preserves perceived value: white maps to paper white, black maps to the full ink, and light fills stay light regardless of their original hue. A pale yellow background becomes a pale tint of the ink; a dark stroke approaches the full ink.

The recolouring is performed by the exported applySingleInkToSvg(svgText, inkHex), which operates DOM-free on the SVG markup as text:

  • #rgb / #rgba / #rrggbb / #rrggbbaa hex literals and rgb() / rgba() functions are rewritten wherever they appear — presentation attributes, inline style, gradients, <defs>.
  • The white and black keywords are replaced only where they appear as paint values (fill, stroke, stop-color, flood-color, color — as attributes or inline-style properties), never inside text content or labels.
  • none, transparent, and currentColor are left untouched.
  • Alpha channels are preserved (#rgba / #rrggbbaa nibbles and rgba(…) alpha components ride along unchanged).
  • When inkHex cannot be parsed, the input is returned unchanged.
import { applySingleInkToSvg } from 'postext';
 
const recoloured = applySingleInkToSvg(svgText, '#295AA3');

Single ink applies in all three backends: the canvas viewport and the HTML viewer recolour the SVG markup before rasterising it, and the PDF backend re-rasterizes the SVG resource bytes with the ink applied, so the exported PDF matches the on-screen preview.

Resolver and stripper match the other sections, alongside the DiagramStyleConfig / ResolvedDiagramStyleConfig types:

import {
  DEFAULT_DIAGRAM_STYLE_CONFIG,
  resolveDiagramStyleConfig,
  stripDiagramStyleDefaults,
  applySingleInkToSvg,
} from 'postext';
import type { DiagramStyleConfig, ResolvedDiagramStyleConfig } from 'postext';
 
const resolved = resolveDiagramStyleConfig(config.diagramStyle);
// => { singleInk: false, inkColor: { hex: '#295AA3', model: 'hex', paletteId: 'main-color' } }
 
const minimal  = stripDiagramStyleDefaults(config.diagramStyle);
// => undefined when everything matches the defaults

#Paragraph styles

The paragraphStyles property declares named styles that a document applies to a run of paragraphs with a :::paragraphs{style="…"} container — bibliographies, glossaries, notes, any block of entries that wants its own face, size, leading, or a hanging indent. Every typographic field is optional and inherits the body text when unset, so a style only spells out what differs from running text.

const config: PostextConfig = {
  paragraphStyles: [
    {
      id: 'bibliography',
      name: 'Bibliography',
      fontSize: { value: 7, unit: 'pt' },
      lineHeight: { value: 1.2, unit: 'em' },
      hangingIndent: { value: 2, unit: 'em' },
      spaceBetween: { value: 0.25, unit: 'em' },
      marginTop: { value: 1, unit: 'em' },
      marginBottom: { value: 1, unit: 'em' },
    },
  ],
};
## References
 
:::paragraphs{style="bibliography"}
Knuth, D. E. (1984). *The TeXbook*. Addison-Wesley.
 
Bringhurst, R. (2004). *The Elements of Typographic Style*. Hartley & Marks.
:::
PropertyTypeDefaultDescription
idstringrequiredIdentifier referenced from :::paragraphs{style="…"}.
namestringidHuman-readable name, for editor UIs only.
fontFamilystringbody text fontFont family. Weights (regular and bold) follow the body text.
fontSizeDimensionbody text sizeFont size.
lineHeightDimensionbody line heightLeading. em/rem are relative to the style's own font size, so an inherited 1.5em tightens along with a smaller size.
colorColorValuebody text colorText color. Bold and italic runs keep the body emphasis colors.
textAlign'left' | 'justify' | 'center' | 'right'body alignmentHorizontal alignment. 'center' and 'right' set every line ragged from the other side — a dedication, a signature block.
boldColorColorValuebodyText.boldColorColour of bold runs (an authors list with the names in the house colour).
hyphenationbooleanbody hyphenationHyphenate when justified (uses the document locale).
firstLineIndentDimensionbody first-line indentIndent of the first line. Ignored when hangingIndent is non-zero.
hangingIndentDimension0Indent applied to every line except the first — the classic bibliography or glossary shape.
spaceBetweenDimension0Vertical gap between consecutive paragraphs inside the container. 0 makes entries abut.
marginTopDimension0Space above the container's first paragraph. Collapses with the spacing already pending and vanishes at the top of a column, like any other margin.
marginBottomDimension0Minimum space below the container's last paragraph.

#The :::paragraphs container

A :::paragraphs{style="<id>"} line opens the container and a bare ::: line closes it; every paragraph in between takes the named style, while headings, lists, and other blocks inside keep their usual styling. Containers may be nested inside other fenced containers. An unknown style id is not an error: the paragraphs render as plain body text.

Inside the container the flow leaves the baseline grid — a 7pt entry with 1.2em leading cannot sit on an 8pt/1.5em grid — and the last paragraph snaps the flow back onto it, baking in at least marginBottom below the text (the grid wins; the margin is a minimum, the same convention headings follow). Entries split across columns and pages like body paragraphs, with the same orphan and widow protection; a heading immediately before the container keeps with its first paragraph.

const resolved = resolveParagraphStylesConfig(config.paragraphStyles, resolvedBodyText);
// => every unset field filled from the resolved body text
 
const minimal  = stripParagraphStylesDefaults(config.paragraphStyles);
// => undefined when the list is empty; zero margins and `name === id` dropped

#Chip styles

The chipStyles property declares the named styles of the inline :chip[text]{style="…"} — the rounded, tinted boxes of a word bank, a keyboard key, a tag (the syntax and its line-breaking rules are in the document format reference). One style, chip, ships by default (a pale blue fill with a hairline in the main colour, slightly rounded, the text as the words around it), so :chip[…] works without any configuration; declaring chipStyles replaces that default list. A chip without style, or with an id no style declares, takes the first style.

const config: PostextConfig = {
  chipStyles: [
    { id: 'chip', name: 'Word bank' },
    {
      id: 'key',
      name: 'Keyboard key',
      background: { hex: '#fff4d6', model: 'hex' },
      borderColor: { hex: '#8a6d1f', model: 'hex' },
      borderRadius: { value: 2, unit: 'pt' },
      bold: true,
    },
  ],
};
Classify: :chip[battery] :chip[cable] :chip[switch]
 
Press :chip[Ctrl]{style="key"} + :chip[C]{style="key"}.
PropertyTypeDefaultDescription
idstringrequiredIdentifier referenced from :chip[…]{style="…"}.
namestringidHuman-readable name, for editor UIs only.
backgroundEnabledbooleantruePaint the box fill.
backgroundColorValue#e8eef7Box fill (palette-linkable).
borderColorColorValuemain palette colourOutline colour.
borderWidthDimension0.5ptOutline width; 0 draws none. The outline is stroked inside the box edge.
borderRadiusDimension0.3emCorner radius, clamped to half the box height (a large value gives a pill).
paddingXDimension0.3emRoom between the outline and the text, left and right. Part of the chip's advance.
paddingYDimension0.1emRoom above and below the text band. Paints outside the line box: it never changes the line height.
fontFamilystringsurrounding textFamily of the chip text. Weights follow the text around it.
fontSizeDimensionsurrounding textSize of the chip text; em is relative to the surrounding text.
colorColorValuesurrounding textColour of the chip text. Unset, bold and italic runs keep the emphasis colours.
boldbooleanfalseSet the chip text bold, on top of its own markup.
italicbooleanfalseSet the chip text italic, on top of its own markup.
gapDimension0.25emLeast room kept between the box and a neighbouring word or chip across a word space; a narrower space is topped up inside the chip's advance, so justification never eats it. Nothing is added at a line edge or against glued punctuation.

Em lengths of the box (paddingX, paddingY, borderRadius, borderWidth, gap) are relative to the chip's own font size. The box is a band 0.8 em above and 0.25 em below the baseline, grown by paddingY and the outline; it paints outside the line box and never changes the leading, so the baseline grid holds. When the box ends up taller than the line pitch the chips of consecutive lines touch — the sandbox lists a "Chips touch the next line" warning with the excess, so paddingY, the outline or fontSize can be reduced.

In the VDT a chip is a line segment of kind: 'chip' whose chip field carries the text runs (each with its font string and width), the box geometry (boxWidth, ascent, descent, paddingX, borderWidth, borderRadius, the gap margins) and its colours; the segment's text is a one-character placeholder, so plain-text offsets and source maps count a chip as one character.

const resolved = resolveChipStylesConfig(config.chipStyles);
// => the built-in `chip` style when unset; every field filled
 
const minimal  = stripChipStylesDefaults(config.chipStyles);
// => undefined for the built-in default; static defaults dropped
 
const style = pickChipStyle(resolved, 'key');
// => the `key` style, else the first one

#Callout styles

The calloutStyles property declares the named box styles a document applies with a :::callout{type="…"} container — notes, tips, warnings, learning objectives, any content set apart from the running text in a tinted or bordered box. One neutral style, note, ships by default (light grey background, no border, no stripe, no icon, no title), so :::callout works without any configuration; declaring calloutStyles replaces that default list.

const config: PostextConfig = {
  calloutStyles: [
    { id: 'note', name: 'Note' },
    {
      id: 'objectives',
      name: 'Learning objectives',
      title: 'Objectives',
      stripe: { enabled: true, side: 'left' },
      icon: { kind: 'glyph', glyph: '✓' },
      titleStyle: { textTransform: 'uppercase' },
      lists: { bulletChar: '–' },
    },
    {
      id: 'warning',
      title: 'Warning',
      backgroundEnabled: false,
      border: { enabled: true, color: { hex: '#AA0000', model: 'hex' }, width: { value: 1, unit: 'pt' } },
      borderRadius: { value: 1, unit: 'mm' },
      titleStyle: { color: { hex: '#AA0000', model: 'hex' } },
    },
  ],
};
:::callout{type="objectives"}
- Describe the parts of the lantern.
- Trim the wick at dusk.
:::
 
:::callout{type="warning" title="Do not touch the lens"}
The glass stays hot for an hour after the flame is out.
:::
PropertyTypeDefaultDescription
idstringIdentifier selected by :::callout{type="…"}. A fence with an unknown or missing type uses the first configured style (the sandbox flags unknown types).
namestringidHuman-readable name (editor UI only).
titlestring''Default title text; empty means no title. The fence title attribute overrides it per instance.
span'column' | 'page' | 'side''column'Horizontal extent: the column, the full content width, or the float-only side column of a one-and-a-half layout (layout.sideColumnRole: 'floats') — the box then leaves the flow and stacks in that column beside the text it interrupts. Overridable per instance with the span attribute. In multi-column layouts a 'page' box becomes a span block: it splits the page into column bands and sits in its own full-width column (see the container section below).
placement'here' | 'auto' | 'top' | 'bottom' | 'fixed''here'Where the box goes. 'here' sets it inline in the flow; 'top' / 'bottom' float it like a resource ('auto' takes whichever band is free first, head or foot) — it leaves the flow where it occurs and takes the first free band at or after that point (the foot of the current page, or the head / foot of the next page the flow opens), and the text after it fills the page around it; 'fixed' anchors it at page coordinates through fixed below, out of the column flow. See the container section for the details. Overridable per instance with the placement attribute.
fixedPosition of a 'fixed' box: an ElementAnchor (to: 'container' = the page content area, mirrored on even pages; 'page' = the trim box; 'bleed' = the bleed box; edge: one of the nine container edges) plus an optional offset (x / y dimensions).
floatBarrierbooleanfalseMake the box a float barrier: every figure or table referenced before it is placed before it — in the page's free slots, else on pages opened ahead of the box — so no float escapes past a chapter's closing box (a "key points" summary, typically). Chapter openers, :::part and the end of the document are always barriers.
A span: 'page' box on a multi-column page cuts the band under the text it follows; a full-width figure referenced before it takes that cut first — the text is levelled, the figure sits right where it ended, and the box goes on below it (or to the next page when it no longer fits). A figure too tall to follow the levelled text opens the next page instead, the box after it, and the band it left still ends level. A splittable box (keepTogether: false) opens under the text and figure with as many items as fit, the rest continuing on the next page.
width'fill' | 'auto''fill''fill' spans the available width; 'auto' shrink-wraps the title (badge use) and ignores the children.
backgroundEnabled / backgroundboolean / ColorValuetrue / #f4f4f4Box fill.
border{ enabled, color, width }false, #cccccc, 0.5ptBox outline.
borderRadiusDimension0Corner radius of the background / border.
padding{ top, right, bottom, left }0.75em eachInset between the box edge and its content. em values are relative to the callout body size.
stripe{ enabled, side, width, color }false, 'left', 1.5em, main colourSolid band along one edge. A 'left' / 'right' stripe narrows the content; a 'top' stripe pushes it down.
icon{ kind, glyph, resourceId, fontFamily, fontWeight, size, width, color, align, position, cornerSide }'none', headings font, 400, 1.5em, main colour, 'top', 'inline', 'right'A text glyph (kind: 'glyph') or a bitmap / SVG resource (kind: 'resource' + resourceId) beside the content. With a side stripe the icon is centred over the stripe; otherwise it reserves its own column (size + titleStyle.gap). align: 'center' centres it vertically on the content. A resource image is fitted inside the square keeping its aspect ratio — or inside a width × size box when width is set (a wide strip of icons); an icon taller than the content grows the box to fit it (and, with align: 'center', centres the content on it). position: 'corner' hangs the icon on a top corner as a badge, half of it past the border, taking no room from the content; cornerSide picks the corner — 'right' / 'left', or 'outer' / 'inner', which follow the page parity of mirrored margins (outer = right on a recto, left on a verso).
marker{ kind, glyph, resourceId, fontFamily, fontWeight, size, color, align, gap, rule }'none', headings font, 400, 1.5em, main colour, 'center', 0.5em, rule off (0.5pt, main colour, length 0)A second icon drawn outside the box, in a column on its left, with an optional vertical rule between it and the box — the "tap here" hand beside a self-assessment badge. The frame becomes [marker][rule][gap][box] and as tall as the tallest of the three; align centres them on each other or top-aligns them. rule.length is a minimum: the rule always spans at least the box height.
titleStyle{ fontFamily, fontSize, fontWeight, italic, color, textTransform, gap, letterSpacing, indent }headings font, body size, 700, false, main colour, 'none', 0.5em, 0, 0Title typography. gap is the space between the title and the first child (and the icon column gap). textTransform: 'uppercase' is length-preserving. letterSpacing tracks the title (canvas letterSpacing / PDF Tc); indent pushes it right of the box's inner edge. A corner badge hanging on the title's side (the left corner) reserves its own room first — the badge's inner half plus gap — so the title clears it whichever page it lands on; indent only adds beyond that.
body{ fontFamily, fontSize, lineHeight, color, boldColor, italicColor, textAlign, hyphenation, paragraphSpacing, firstLineIndent }inherits bodyTextTypography of the paragraphs inside the box. Every field inherits the body text when unset; italicColor sets the colour of italic runs (a pull quote in italics in the box's colour).
lists{ bulletChar, color, indent, gap, itemSpacing, bulletFontSize, bulletFontWeight }inherits unorderedListsList typography inside the box (color, indent, gap and itemSpacing also apply to ordered lists). bulletFontSize / bulletFontWeight set the bullet glyph in the box's body face at that size and weight (a heavy coloured bullet).
label{ fontFamily, fontSize, fontWeight, color, background, position, height, paddingX, offset, inset, icon, rule }unset (no tab)A tab on the box's top edge that prints the fence's label attribute — the number of a numbered box ("BOX 1-1"). It hugs the position corner ('top-right' / 'top-left'), inset by inset, rises offset above the box top (that room is part of the block, on top of marginTop, so the tab keeps it at a column head too), is height tall with paddingX on each side of the text, and may carry an icon resource beside it ({ resourceId, width, gap }, on the side away from the corner) and a rule ({ enabled, color, width }) along the top edge from the far corner up to it. Defaults: headings font, body size, 700, white on the main colour, 1.4em tall, 0.6em padding.
columnGapDimension1.5emGap between the columns of a :::columns group inside the box (see the container section below).
marginTop / marginBottomDimension0.75em / 0.75emSpace above the box (collapses with the previous block's margin) and minimum space below it (the exact space with snapToGrid: false).
snapToGridbooleantrueWhen true the flow after the box snaps back to the baseline grid, so the space under it is marginBottom rounded up to whole grid lines. When false the box keeps its exact marginBottom, which collapses with the next block's top margin — two consecutive boxes of such a style sit exactly max(marginBottom, marginTop) apart — and the text after it may sit off the grid until the next snap point (a heading, the end of a list), as after a heading with headings.snapToGrid: false. Meant for documents made of stacked boxes (worksheets, forms). It applies to boxes in the flow; page-span boxes in a multi-column layout, floated, fixed and side boxes keep the grid, since column bands and float zones are laid out on it. The column-balancing levers are unchanged: a box closing a column is still pushed down to the column's last grid slot.
keepTogetherbooleantrueWhen true the box is kept whole: a callout that does not fit the remaining space moves whole to the next column or page. Only a box taller than an empty, full column — a whole page for a span: 'page' box — cannot be kept whole: it splits by the false rules below instead of overflowing, starting where it occurs, and a continuation that fits a column then moves on whole; a floated box (placement: 'top' | 'bottom' | 'auto') that tall does not float but stays in the flow where it occurs. When false any box may break between its child blocks or between the lines of a paragraph or list item: the deepest cut that fits closes the current column (or, for a span: 'page' box, the page, flush with the bottom of the columns) and the rest continues at the top of the next one in a box of its own — same frame and stripe, no title or icon — splitting again if it is still too tall. A cut inside a list item leaves its bullet with the head. Every fragment's frame shares the fence's contentIndex / containerId and records callout.part / callout.continued. Use it on a long closing "key points" box together with headings.balancing.beforeSpan, or on a note style whose boxes must never push a figure off the page. A nested box (a :::callout inside another) is one child of its parent: a cut may fall before or after it, and inside it only when its own style allows splitting (keepTogether: false, or taller than a full column), by its own splitMinLines.
splitMinLinesnumber2Fewest text lines a fragment of a split box (keepTogether: false, or a keep-together box taller than a full column) keeps on either side of the cut. It guards text only: a side holding at least one figure, table, display formula or nested box is acceptable whatever its line count, so a box of pictures may leave a single one on a page. A cut inside a paragraph or list item still counts the lines on each side (a figure or formula there as one line). With the default no box breaks leaving a lone text line at the foot of a column or at the head of the next; when no cut satisfies the minimum the box moves whole.

#The :::callout container

A :::callout{type="<id>"} line opens the box and a bare ::: line closes it. The fence accepts four attributes — type (the style id), title (overrides the style's title), span and placement (override the style's values) — and the content in between is laid out inside the box: an optional title, then the paragraphs, lists, blockquotes, formulas or resource embeds, each typeset with the style's body / lists typography (headings keep their usual styles). Margins between children collapse as in the running text; the interior leaves the baseline grid, and the flow snaps back onto it after the box with at least marginBottom below (the grid wins; the margin is a minimum — the same convention resources follow). A style with snapToGrid: false keeps the exact marginBottom instead, and the text after the box stays off the grid until the next heading or list end. A heading immediately before a callout keeps with it.

Limits in this version:

  • A callout is kept whole unless its style sets keepTogether: false. When it does not fit the remaining column space it moves whole to the next column or page — also out of an empty column that float bands or a band cap have cut short, as long as a full column would hold it. A box taller than a full column splits instead, like a splittable one; only one that no cut can split (a figure, table or :::columns group taller than the column, or a splitMinLines no cut satisfies) is placed anyway and overflows; the layout then records a calloutOverflow warning (VDTDocument.warnings), which the sandbox lists. A splittable box leaves the part that fits behind — whole children, or the lines of a paragraph down to splitMinLines on each side (a figure, table or display formula alone is enough for a side) — and continues in a box without title or icon on the next column or page.
  • Floats yield to an unbreakable box. When the block right after a figure's reference is a keepTogether callout, a slot that would leave the box no column of the current band to land in (the referencing column or an empty one after it, which held it before the float) is not taken: the figure moves on to its next slot, usually the next page, and the box stays in flow — as a compositor would set it, rather than pushing the box off the page and leaving the column with the figure alone.
  • span: 'page' in a multi-column layout makes the box a span block: it is laid out at the full content width and splits the page into column bands — the text columns above it are closed at the cut line, the box takes its own full-width column, and a fresh band of text columns opens below it, so the flow continues under the box in every column. Where the columns are level — at the top of a page, right below a span: 'page' opener heading, right below another span block, or right below a top float band — the box simply cuts there. Arriving mid-page, with the columns uneven, it is set the way a compositor would: the text above it is cut level across all columns (the engine re-runs placement with the band's columns shortened to the same number of grid lines, so the text overflows from column to column naturally and every orphan, widow and keep-with-next rule still applies), the box spans the page, and the columns resume below it. The cut takes a couple of extra placement passes; when the cut line would leave no room for the box plus the widow minimum of body lines below it, or no arrangement fits after a few attempts, the box moves to the top of the next page. With headings.balancing.beforeSpan (the default) the band it leaves is cut level behind it — like a chapter's closing band — and, when the style allows splitting (keepTogether: false), the part of the box that fits under the levelled columns closes the page and the rest opens the next one; with beforeSpan: false the page it leaves is simply balanced as usual, without forcing a page break. A heading right before a span block does not travel with it. In single-column layouts span: 'page' is simply inline.
  • placement: 'fixed' takes the box out of the flow: it is laid out (width: 'auto' shrink-wraps the title; 'fill' takes the width of the text column under the anchor point) and pinned to the page where it occurs in the flow at the position fixed.anchor / fixed.offset describe — the bottom-left corner of the content area by default. The text columns it covers give up that zone (cut from the bottom, or from the top when the column is still empty), exactly like a float band; when the zone already holds text, a float or a span block, the box moves to the next page. A fixed box that closes the chapter (the next block is a chapter opener, a :::part, a float-barrier box or the end of the document) first levels the columns above it (headings.balancing.trailing), so a short closing page ends level with the badge beneath. Frame and children live in page.floats and render outside the column clip in every backend.
  • placement: 'top' | 'bottom' floats the box like a resource: it leaves the flow where it occurs and takes the first free band after that point — the foot of the current page ('bottom'), or the head / foot of the next page the flow opens — at the column width (span: 'column') or the full content width (span: 'page'); the text after it fills the page it left. Its frame and children go to page.floats, as a fixed box does. A floated box heading a fresh page is set before the figures waiting for that page, and a figure cited on an earlier page that then fits under it takes the rest of that page even when fewer than three text lines would remain (a gallery page: box plus figure, no text between them). A span: 'side' box never floats: it stacks beside the text whatever its placement.
  • width: 'auto' shrink-wraps the title only; children are ignored.
  • A :::callout nested inside another callout is a box of its own: it is laid out with its own style (background, border, radius, padding, stripe, title, icon, marker, label, typography) at the full inner width of its parent and stacks as one child of it, its marginTop / marginBottom collapsing with its neighbours. Its span and placement (fence or style) are ignored — a nested box always flows inside its parent — and so are floatBarrier and snapToGrid. Boxes nest to any depth and may sit inside a :::columns group (each one whole, in one column). When the parent splits, the cut falls before or after a nested box, or inside it when the nested style allows splitting; every fragment redraws the frames it cuts through, and a nested box continued from the previous fragment drops its title and icon, like a top-level continuation.
  • A :::columns{count=N}::: group among the children lays those children out in N columns of equal width, columnGap apart, inside the box: the run is cut at the block or line boundaries that level the columns best (a paragraph or list item cut mid-way continues at the head of the next column without its bullet), every column starts at the group's top and the group is as tall as its tallest column; the children after it return to the full width. A split box (keepTogether: false) never cuts inside a group. Use it for a two-column summary of key points, or the tables of a wide box set side by side.
  • The fence's fifth attribute, label, prints on the style's label tab (see label above) — :::callout{type="box" label="BOX 1-1" title="The octet rule"}; without a label style the attribute is ignored.

In the VDT the box is a type: 'callout' frame block whose decoration (background, stripe, icon, title) lives on designOverlay, followed by its child blocks in the same column; the frame and every child carry the fence's containerId. A nested box is a type: 'callout' frame of its own among the children, followed by its blocks; they keep the top-level fence's containerId (placement and balancing still see one unit) and add calloutPath, the container ids of the nested fences around them, outermost first (a nested frame's own id is the last entry). The tagged PDF gives each nested box a Div inside its parent's. Icon images resolve like resource images: the canvas image registry, the HTML resourceImageUrl option and the PDF resourceBytes provider.

const resolved = resolveCalloutStylesConfig(config.calloutStyles, resolvedBodyText, resolvedHeadings, resolvedUnorderedLists);
// => every inherited field filled from the resolved sections
 
const minimal  = stripCalloutStylesDefaults(config.calloutStyles);
// => undefined for the built-in `note` default; static defaults dropped

#Parts

The parts property configures the part-divider pages a document opens with a :::part{number="…" title="…"} container — the “Part I — Foundations” page that groups a run of chapters. A part always occupies a page of its own: the container breaks to a fresh page of the configured parity, converts it into a single-column page whose body area comes from parts.margins, lays the opener design over the whole page, and breaks again after the closing fence so the next chapter (with its own breakBefore.parity) starts clean — with the default H1 settings that yields the classic recto part page, blank verso, chapter on the next recto.

const config: PostextConfig = {
  parts: {
    breakBefore: { parity: 'odd' },
    breakAfter: { enabled: true, parity: 'any' },
    margins: { top: { value: 9, unit: 'cm' }, left: { value: 3, unit: 'cm' }, right: { value: 3, unit: 'cm' } },
    design: {
      elements: [
        {
          kind: 'text', id: 'number', content: 'Part {numberRoman}',
          fontSize: { value: 12, unit: 'pt' }, fontWeight: 600, align: 'left',
          placement: { anchor: { to: 'page', edge: 'top-left' }, offset: { x: { value: 3, unit: 'cm' }, y: { value: 5, unit: 'cm' } }, size: { width: 'auto', height: 'auto' } },
        },
        {
          kind: 'text', id: 'title', content: '{titleText}',
          fontSize: { value: 28, unit: 'pt' }, fontWeight: 700, align: 'left', overflow: 'wrap',
          placement: { anchor: { to: '#number', edge: 'below' }, size: { width: { value: 15, unit: 'cm' }, height: 'auto' } },
        },
      ],
    },
    bodyStyle: { fontSize: { value: 11, unit: 'pt' }, numberColor: { hex: '#AA0000', model: 'hex' } },
  },
};
:::part{number="I" title="Foundations"}
1. The lantern and its parts
2. Trimming the wick
3. Reading the weather
:::
 
# The lantern and its parts
PropertyTypeDefaultDescription
pagebooleantrueWhether a :::part opens a divider page. With false no page is opened and the fence's body is not set: the part's number, title and palette take effect from the next content on, with no break of their own. Typical use: htmlViewer.overrides.parts.page: false, a screen edition without section dividers.
breakBefore.parityHeadingBreakParity'odd'Parity of the page the part opens on. Same values and blank-page ownership rules as the heading breakBefore: a blank inserted to reach the parity belongs to the part (its already resolves to the new part); the mandatory separator of 'always-*' belongs to the previous content.
breakAfter.enabledbooleantrueMove the content after the closing fence to a fresh page. When false it continues in the part page's single column.
breakAfter.parityHeadingBreakParity'any'Parity of that fresh page. Leave it at 'any' and let the next chapter's own breakBefore.parity decide whether a blank verso follows. The break is applied when the next block is placed, so a part that closes the document leaves no trailing empty page.
marginsPageMarginspage marginsBody area of the part page — the single column the blocks inside the fence flow in. Each side inherits the page margin when unset; mirror swaps inner/outer on even pages exactly like the page margins.
designDesignSlotemptyOpener design. Its container is the page trim box, so container anchors and 'page' anchors coincide and 'bleed' runs to the bleed when cut lines are on. Purely decorative: it never reserves body space — raise margins.top to keep the body clear of it. When empty, a default text in the H1 typography is synthesised at the top-left of the body area.
versoDesignDesignSlotemptyDesign of the blank verso that follows a part page (the back of the divider leaf): same container and placeholders as design. Leave empty for a plain verso.
bodyStyle.fontFamily, fontSize, lineHeight, color, textAlignas in bodyTextinherit bodyTextTypography of the paragraphs, blockquotes and list items inside the fence. Weights, emphasis colours and hyphenation come from the body text.
bodyStyle.bulletColorColorValueunorderedLists.colorBullet colour of unordered lists inside the part.
bodyStyle.numberColorColorValueorderedLists.colorNumber colour of ordered lists inside the part. Numbers are always set in the body's bold weight, so a chapter list reads as a table of contents.
bodyStyle.unorderedListsUnorderedListsConfigPartial overrides applied on top of the document's unorderedLists inside the part, after bulletColor. List-wide values propagate to the levels that inherited them; levels entries apply to their level only.
bodyStyle.orderedListsOrderedListsConfigPartial overrides applied on top of the document's orderedLists inside the part, after numberColor and the bold weight — e.g. a separator of '•' with its own separatorFontFamily and separatorColor for a part opener's chapter list.

#Part design placeholders

The design slot resolves the heading placeholder set with the part's own values: {titleText} is the fence's title; {number} the number exactly as written; {numberDecimal}, {numberRoman}, {numberRomanLower}, {numberAlpha}, {numberAlphaLower} re-format it — the number is parsed as a decimal or a roman numeral ("IV", "iv" and "4" all give {numberDecimal} = 4), and resolve to '' for anything else. {partTitle} / {partNumber}, {chapterTitle} / {chapterNumber} (the chapter before the part), {pageNumber}, {totalPages} and the metadata placeholders are available too. {attr.<key>} reads the current chapter's H1 attributes.

#The :::part container

A :::part{number="…" title="…"} line opens the part and a bare ::: closes it; both attributes are optional (they default to ''). The blocks in between — typically the chapter list — flow in the part page's single column with bodyStyle, starting at margins.top; a body longer than the page continues on ordinary pages. The page is classified role: 'part' (VDTPage.partInfo carries the number and title), so header and footer elements can target or skip it with pages: 'part' / pages: 'body'; the PDF backend adds the part to the outline above its chapters. An empty body (:::part{…} directly followed by :::) is the common case and still produces the page — consecutive parts never share one. A :::part nested in another part is flattened into the outer one.

A third attribute, palette="<id>=<hex>[, <id>=<hex>…]", gives the part its own colours: on the part page and on every page that follows it — until the next part — each design colour (header, footer, opener band, part and verso designs) linked to one of those palette ids takes the part's value instead of the document palette's. That is how a book's sections recolour the corner tab, the running-head dot and the chapter-opener band without a second design: :::part{number="II" title="…" palette="band=#f6c297"}. The text flow follows too: on the same pages, every flow colour equal to the base value of an overridden palette entry — headings, bold, italic and reference colours, bullets and list numbers, caption labels, table text and rules — takes the part's value, so headings.levels[1].color linked to band sets each section's headings in its own colour. Inline swatches keep the colour written in them. Pairs are separated by commas, semicolons or spaces, = or : joins id and colour, the # is optional. A part stays in effect after its fence closes: {partTitle}, {partNumber} and the palette follow the flow into the chapters after it and — through continuation.part, which continuationAfter() reports — into chapters laid out on their own, so the second chapter of a section shows the section in its running heads exactly as the first does.

const resolved = resolvePartsConfig(config.parts, resolvedPage, resolvedBodyText, resolvedUnorderedLists, resolvedOrderedLists);
// => margins filled from the page, bodyStyle from the body / list configs
 
const minimal = stripPartsDefaults(config.parts);
// => undefined when only static defaults remain

#Heading styles

The headingStyles property declares named styles a document applies to a heading with # Title {style="<id>"}. A style does two things. It overrides the heading's level typography and design — every field of a level entry except level and numberingTemplate (font, size, colour, breakBefore, span, advancedDesign, textTransform…) — and it governs the section the heading opens: its pages, up to the next heading of the same or a higher level, take the style's running heads, page geometry, body typography and palette. That is how a book's front matter (a preface in a single wide column with roman folios and blue bands) sits in a two-column, decimal-numbered manual without a second configuration.

const config: PostextConfig = {
  headingStyles: [
    {
      id: 'front-matter',
      numbered: false,
      breakBefore: { enabled: true, parity: 'odd' },
      span: 'page',
      advancedDesign: { enabled: true, minHeight: { value: 52, unit: 'mm' }, slot: { elements: [/* bands, `{titleText}` */] } },
      header: { elements: [/* folio | rule | `{title}. {subtitle}` */] },
      margins: { left: { value: 50, unit: 'mm' }, right: { value: 17, unit: 'mm' } },
      layout: { layoutType: 'single' },
      bodyStyle: { fontSize: { value: 10.5, unit: 'pt' }, textAlign: 'justify' },
      palette: { band: '#547396' },
    },
  ],
};
# Preface {style="front-matter"}
PropertyTypeDefaultDescription
idstringIdentifier referenced from on a heading line. An unknown id leaves the heading as it is.
namestringidHuman-readable name (editor UI only).
numberedbooleantrueWhether the heading counts: advances its level's counter (the numberingTemplate numbers, the of resource numbers), the chapter ordinal behind and the number printed in the contents. false for a preface, an authors list, an index: the first numbered chapter after them is still chapter 1, and is empty on their pages.
tocbooleantrueWhether :::toc lists the heading. A heading overrides it with / .
level fieldsas in headings.levels[]the level's valuesfontFamily, fontSize, lineHeight, fontWeight, italic, color, marginTop, marginBottom, breakBefore, span, advancedDesign, textTransform: each one set replaces the heading level's value for headings of this style.
header, footerDesignSlotthe document'sRunning heads of the section's pages, replacing header / footer there (element parity and pages filters still apply). An empty slot removes them.
marginsPageMarginspage marginsBody area of the section's pages; each side inherits the page margin when unset, mirror included. Takes effect on the pages the section opens — pair it with breakBefore.
layoutLayoutConfiglayoutColumn layout of the section's pages (layoutType, gutterWidth…): a single wide column for a preface set in a two-column book.
bodyStylePartsBodyStyleConfiginherit bodyTextTypography of the paragraphs, blockquotes and lists in the section — the same fields as parts.bodyStyle.
paletteRecord<string, string>Palette overrides (id → hex) applied to every design slot laid out on the section's pages, on top of the current part's — the same mechanism as a part's palette attribute.

A section closes at the next heading of the same or a higher level: an unstyled # after a styled one returns to the document's running heads and geometry; a styled one opens its own section. Pages the section left blank for parity belong to it, like chapter titles do.

const resolved = resolveHeadingStylesConfig(config.headingStyles, resolvedPage, resolvedBodyText, resolvedUnorderedLists, resolvedOrderedLists);
// => level overrides normalised, margins filled from the page, bodyStyle from the body
 
const minimal = stripHeadingStylesDefaults(config.headingStyles);
// => undefined when no style remains

#Table of contents

The toc property configures what a :::toc directive prints (see Document format). The contents are assembled from the document's outline — every heading with its number and page label, every :::part — so they follow the chapters: rename one, move it to another part, change its authors, and the entries change with it. An entry is the heading's number in a column of its own, the title, a leader and the page label at the right edge, then an optional subtitle line; a part is a row designed by parts.design.

const config: PostextConfig = {
  toc: {
    levels: [{ level: 1, fontWeight: 700, color: { hex: '#00507b', model: 'hex' }, numberWidth: { value: 7.4, unit: 'mm' } }],
    unnumbered: { color: { hex: '#000000', model: 'hex' } },
    pageNumber: { fontWeight: 400, width: { value: 8, unit: 'mm' } },
    leader: { char: '.', gap: { value: 1, unit: 'mm' } },
    subtitle: { enabled: true, attr: 'author', italic: true, fontSize: { value: 8.5, unit: 'pt' } },
    parts: {
      height: { value: 23, unit: 'pt' },
      marginTop: { value: 11.5, unit: 'pt' },
      design: { elements: [/* a band box, 'SECTION {number}', '{titleText}', '{pageNumber}' */] },
    },
  },
};
PropertyTypeDefaultDescription
levelsTocLevelConfig[]level 1Heading levels listed, each with its entry typography: fontFamily, fontSize, lineHeight (defaults to the body leading, so the contents sit on the grid), fontWeight, italic, color, indent (of the whole entry), numberWidth / numberGap (the number column the title starts after; numbers are right-aligned in it), numberFontFamily, numberFontSize, numberFontWeight, numberColor, marginTop, marginBottom. Unset fields inherit the body text.
unnumberedTocEntryStyleConfigOverrides for headings whose style has numbered: false (a preface): they print no number and start flush at the level's indent.
pageNumberobjectlevel-1 face, body weightfontFamily, fontSize, fontWeight, italic, color of the page label, and width (default 2em): the column reserved for it at the right edge, where it is right-aligned.
leaderobjectchar is repeated across the gap between the title and the page number, right-aligned so the dots of consecutive entries line up ('. ' spaces them out); gap is the least room kept between the title and the leader. A title that would leave the label no room wraps a little earlier.
subtitleobjectA second line under the entry taken from a heading attribute (attr) — the chapter authors — with its own fontFamily, fontSize, fontWeight, italic (default true), color and extra indent. The line shares the entry's leading and never separates from its title.
parts.enabledbooleantrueWhether part dividers get a row.
parts.breakBeforebooleanfalseOpen a fresh page before every part row but the first, so each part's chapters are listed on a page of their own.
parts.designDesignSlotemptyRow design; its container is the row (column width × height). Placeholders: , , …, and (the part page's label). Palette-linked colours take the part's own palette, so each section's row comes in its colour. When empty, and the page number are set in the level-1 entry typography.
parts.height, marginTop, marginBottomDimensiontwo body lines, 0, 0Row height and the space around it.

The page labels are the ones the document prints. buildDocument() lays a document with a :::toc out again with the labels of the previous pass until they settle (at most three extra passes); a host laying a book out chapter by chapter supplies the whole book's outline as PostextContent.outline instead, assembled from contentOutline() (headings and parts from the text alone) and outlineFromDoc() (the same entries with the page labels of a layout), and lays the contents chapter out again whenever outlineKey() of that outline changes.

#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 unitscm, mm, in, pt, px — are converted to pixels using the configured DPI. At 300 DPI, 1 cm equals approximately 118 px.

Relative unitsem, 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 (#295AA3, 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:

  1. Add font family — creates an empty family; rename it inline.
  2. Upload variant(s) — pick a weight (100–900) and style (normal / italic), then choose one or many .woff2, .woff, .ttf, or .otf files. 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.
  3. 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.
  4. Delete variant or Delete family — removes the entry from the config and the stored bytes from IndexedDB.

Once a family is declared, every font picker 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 customFonts changes, every declared family is automatically registered as FontFace entries on document.fonts — so the HTML viewer, the Canvas viewport (which measures through document.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 PdfFontProvider pipeline. .woff2 files are decompressed; .ttf and .otf are passed through directly. .woff is rejected with a clear error (pdf-lib can't embed raw WOFF — re-upload as .woff2/.ttf/.otf). CFF-flavored OpenType (.otf with OTTO magic) is embedded without subsetting, because pdf-lib's CFF subsetter walks every glyph at save() 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 fontFamily references 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 some fontFamily field 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 #295AA3). 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: '#295AA3', 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); // true

A 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:

  1. applyPaletteToConfig(config) — flattens every ColorValue in the raw user config that carries a paletteId. Useful when you want to inspect what the engine will actually see.
  2. 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.
  overrides?: HtmlViewerOverrides; // Screen-only partial config merged over the document config.
}
 
type HtmlViewerOverrides = Omit<PostextConfig, 'htmlViewer'>;
PropertyTypeDefaultDescription
maxCharsPerLinenumber70Target 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.
columnGapnumber50Horizontal gap, in CSS pixels, between columns when the viewer is in multi-column mode. Ignored in single-column mode.
optimalLineBreakingbooleanfalseEnable 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.
overridesHtmlViewerOverridesA partial document config that applies on screen only. The HTML viewer merges it over the document config before laying out (applyHtmlViewerOverrides); canvas and PDF ignore it. Objects merge recursively; a levels array (headings, lists, contents) merges entry by entry on level; every other array — a design slot's elements, calloutStyles, colorPalette… — replaces the base array wholesale. Typical use: a chapter opener without the print bands, or a part page whose title wraps against the number instead of a fixed trim-box width. The sandbox edits it as JSON.
const config: PostextConfig = {
  headings: { levels: [{ level: 1, span: 'page', breakBefore: { enabled: true } }] },
  htmlViewer: {
    // On screen, chapters flow on without the page-span opener.
    overrides: { headings: { levels: [{ level: 1, span: 'column', breakBefore: { enabled: false } }] } },
  },
};

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 defaults

See Integrating the HTML viewer below for an end-to-end example.

#PDF generation (config)

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.
  accessible?: boolean;        // Tagged, PDF/UA-oriented output (structure tree, alt text, language).
}
PropertyTypeDefaultDescription
outlinesbooleantrueEmit 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).
forceColorSpacebooleanfalseWhen 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.
accessiblebooleantrueEmit an accessible, tagged PDF oriented to PDF/UA-1: a logical structure tree in reading order (headings that never skip a level, paragraphs, lists, block quotes, callouts, tables with header cells, figures with their alt text and captions, formulas, clickable refs as links), the document title and language (the top-level locale), the PDF/UA identification in the XMP metadata, and every decorative mark (page background, rules, baseline grid, running headers and footers, cut marks, repeated table headers) flagged as an artifact so screen readers skip it. A figure without altText falls back to its caption, then to its label. Turn off only for print masters where the extra structure is unwanted.
pdfGeneration: {
  outlines: true,
  accessible: 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', accessible: true }
 
const minimal  = stripPdfGenerationDefaults(config.pdfGeneration);
// => undefined when everything matches the defaults

See 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.

PropertyTypeDescription
cursorSyncSyncIndicatorConfigCaret mirrored into the rendered layout — see Visual overlays.
selectionSyncSyncIndicatorConfigSource selection highlighted on the page — see Visual overlays.
looseLineHighlightLooseLineHighlightConfigOverlay on loose justified lines — see Visual overlays.
pageNegativeHigh-contrast negative of the page — see Visual overlays.
warningsWarningsToggleConfigOne boolean per kind of authoring warning shown in the editor — see Warnings.

#Visual overlays

PropertyTypeDefaultDescription
cursorSync.enabledbooleantrueShows a caret in the rendered layout mirroring the source cursor position.
cursorSync.colorColorValue#2563ebColour of that caret.
selectionSync.enabledbooleantrueHighlights the rendered range matching the source selection.
selectionSync.colorColorValue#fde04780Colour of the highlight — a translucent yellow by default.
looseLineHighlight.enabledbooleanfalsePaints an overlay on justified lines whose word spacing exceeds threshold times the normal space width.
looseLineHighlight.colorColorValue#ff000040Colour of that overlay.
looseLineHighlight.thresholdnumber3Multiplier of the normal space width above which a justified line counts as loose. The looseLines warning uses the same threshold.
pageNegative.enabledbooleanfalseRenders 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;
  designIssues?: boolean;
}
PropertyTypeDefaultDescription
missingFontbooleantrueReport 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.
looseLinesbooleantrueReport 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.
headingHierarchybooleantrueReport 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.
consecutiveHeadingsbooleanfalseReport 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.
listAfterHeadingbooleanfalseReport 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.
designIssuesbooleantrueReport integrity problems in design slots — page headers, footers, and heading advanced-design slots. Covers cyclic anchor chains and dangling anchor references (an element anchored to a #id that no longer exists), a page-span heading whose breakBefore is disabled, and an enabled advanced design whose elements never render .
debug: {
  warnings: {
    missingFont: true,
    looseLines: true,
    headingHierarchy: true,
    consecutiveHeadings: true,
    listAfterHeading: false,
    designIssues: true,
  },
}

#Programmatic Usage

Recommended path: use the Web Worker. In a browser, the overwhelming majority of integrations should drive the layout pipeline through createLayoutWorker() from postext/worker, not by calling buildDocument directly 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 (direct buildDocument, 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 calling buildDocument on 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`);

#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.

Postext · render a page to an image
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.

#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 remain

Individual 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.

#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:

  1. Create a worker once per viewport with createLayoutWorker().
  2. Register fonts once per family by posting transferable ArrayBuffers via registerFonts(payloads).
  3. Build with build(content, config, { signal }), passing a fresh AbortSignal every call so stale builds can be cancelled.
  4. Supersede any previous build by aborting its signal before starting the next one — this is the last-wins pattern.
  5. 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 VDTDocument is posted back.
  • Last-wins cancellation. build(content, config, { signal }) threads an AbortSignal into the worker. Aborting before completion raises an AbortError on the main side; inside the worker the pipeline throws a BuildCancelledError at the next per-block cancellation checkpoint and stops immediately.
  • Per-worker measurement cache. The worker keeps a single MeasurementCache for 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 via new FontFace(...) on the worker's own FontFaceSet. 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 MathRender across 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 via opts.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 with AbortError.
  • FontPayload{ family, weight, style, unicodeRange?, buffer: ArrayBuffer }. The buffer is transferred to the worker when you call registerFonts.
  • BuildCancelledError (re-exported from postext) — what buildDocument throws internally when options.shouldCancel returns true. You do not usually see this on the main thread: the worker protocol converts it to an AbortError before 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:

  1. Queries https://api.fontsource.org/v1/fonts/{family-id} to discover the available weights and whether the family ships a variable axis.
  2. Builds a Google Fonts CSS2 URL that covers every weight and style the family advertises.
  3. Fetches the generated @font-face stylesheet, scrapes each src: url(...) format('woff2') declaration, and downloads the raw bytes.
  4. Returns a FontPayload[] where buffer is a fresh ArrayBuffer per call — important, because registerFonts transfers 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 ResizeObserver tick.
  • 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 buildDocument directly 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 a VDTDocument.
  • 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.

#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.

Postext · render a document to HTML
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,
} 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 maxCharsPerLine is a target expressed in characters, the actual pixel width depends on the body font. measureGlyphWidth gives 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.width with the measured column width, sets margins to zero (the padding lives outside the page in the wrapping .pt-doc div), and uses HTML_DPI = 144 so 8pt body text resolves to 16px.
  • Font-loading awareness. document.fonts.loadingdone fires 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 patchingrenderToHtmlIndexed returns pages[i].blocks, each with a stable id and the block's outer HTML. When only a few blocks differ between two renders you can replace those block wrappers in place instead of rebuilding innerHTML.
  • Overlays — layering an absolutely-positioned SVG on top of each .pt-page for cursors, selections, or baseline grids.

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.

#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. renderToPdf itself 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 call renderToPdf directly.

#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 a VDTDocument and returns the raw PDF bytes.
  • PdfFontProvider — the callback signature (family, weight, style) => Promise<Uint8Array> that renderToPdf uses 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 format pdf-lib can 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' } }, // 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 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 for weight: 600 on 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 bytesCache module-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 (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.

Postext · generate a PDF in the browser
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.

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 in mm or cm.
  • colors.model: 'cmyk' — preserves the intent that colours were authored in CMYK space. The hex fallback is still used to actually draw to the PDF today; model is documented here because it rides along into the VDT for downstream tooling.
  • { pageNegative: true } in RenderToPdfOptions — 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).

#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. openBundle ignores it.

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
FieldWhat it holds
manifestThe validated preset.json.
id, name, descriptionFrom the manifest.
locale, localesThe 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.
configThe default colour palette and the resource types localised to the bundle's locale, then the manifest's config, then the locale's overrides. customFonts lists the bundle's font families. This is the same configuration the Sandbox opens the bundle with.
resourcesThe resources, with the captions of the chosen locale. A size missing from the manifest is read from the file.
fontsOne entry per face: { family, weight, style, format, file, bytes }.
filesEvery file in the archive, keyed by its path.
thumbnail, canvasScopeThe cover picture's path, and how the bundle asks to be viewed.
warningsNon-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.

#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 with document.fonts. 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). bundleImageUrl(bundle) is the resourceImageUrl resolver for renderToHtml. Both recolour SVG figures when diagramStyle.singleInk is on.
  • buildBundle(bundle) lays the chapters out in order and returns one VDTDocument per 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) receives the whole book's outline. It takes the same options as buildDocument, plus config to override the bundle's configuration and cache to share a measurement cache.
  • bundleResourceBytes(bundle) and bundleFontProvider(bundle, { decodeWoff2, fallback }) are the resourceBytes and fontProvider options of postext-pdf's renderToPdf. The font provider picks the nearest weight of the requested style from the bundle. For a .woff2 face it needs decompressWoff2, and for a family the bundle does not carry it calls fallback.
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.

#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.

Postext · open a .postext bundle
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 },
});
InputMeaning
name, id, description, localeThe manifest's metadata. id defaults to a slug of name.
chapters or markdownThe book, one { title?, markdown } per chapter, or a single document.
configThe PostextConfig. Values equal to the defaults are left out of the manifest.
resourcesThe resources. A picture names its payload by bitmap.fileId / svg.fileId (and svg.pdfFileId for a print master).
filesThe 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.

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 .woff face (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.

#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.

Postext · create a .postext bundle
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' },
  headings: { levels: [{ level: 1, numberingTemplate: 'Chapter {1}' }] },
};
 
// 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 (Export on its row in the Projects panel). Load the file from your program with openBundle to 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 with createBundle, 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 (Projects → New → Import .postext…), fix the markdown, configuration or resources with the live preview, the warnings panel and the PDF view, then export it again. Your program then loads the corrected file with openBundle. Or copy what changed back into your code: the manifest's config holds 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): the archive layer. Opening tolerates a top-level folder and ignores __MACOSX entries and dotfiles. Paths that escape the bundle are refused.
  • readBundle(manifest, readFile, options) reads a manifest plus a readFile(path) callback into chapters, config, resources, pictures and fonts. options sets the locale, how file ids are named (ids), the base configuration and how intrinsic sizes are measured.
  • planBundle(meta, content) / resolveBundleFiles(plan, sources): the writing side, split into a pure plan (file names and manifest) and resolving the bytes through readBlob / readFont callbacks.
  • isBundleManifest(value), the locale pickers (pickChapterSpecs, pickLocaleOverrides, resolveBundleLocale), svgSize / bitmapSize, and the format types (BundleManifest, BundleResourceSpec, BundleFontFamilySpec, …).

The Sandbox is built on these. It adds its own storage ids and the layouts.json page records.