# Postext Configuration

> Complete reference for all Postext layout configuration options

- HTML version: https://postext.dev/en/docs/configuration
- Last updated: 2026-09-22
- Reading time: 30 min
- Other languages: [es](https://postext.dev/es/docs/configuration.md)

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

```ts
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](/en/docs/architecture) page.

## Index

This reference is long. These are the main blocks:

- [Page](#page) — size, margins, baseline grid, cut lines.
- [Layout](#layout) — column count, gutters, rules.
- [Headers & footers](#headers--footers) — per-page text and rule elements with placeholders, parity, and alignment.
- [Body text](#body-text) — typography, hyphenation, orphans and widows.
- [Document language](#document-language) — the top-level `locale`: hyphenation fallback, table continuation strings, the language tagged into an accessible PDF.
- [Headings](#headings) — shared defaults and H1–H6 overrides.
- [Unordered lists](#unordered-lists) and [Ordered lists](#ordered-lists) — bullets, numbering, nesting.
- [Math](#math) — LaTeX rendering, scale, colour, margins.
- [Resource types](#resource-types) — typed numbering for figures, tables, and custom kinds.
- [Table style](#table-style) (with [named table styles](#named-table-styles)) and [Caption style](#caption-style) — typography and decoration for table resources and their captions.
- [Diagram style](#diagram-style) — single-ink recolouring of embedded SVG diagrams for spot-colour printing.
- [Paragraph styles](#paragraph-styles) — named styles for `:::paragraphs` containers: bibliographies, glossaries, notes.
- [Callout styles](#callout-styles) — boxed notes, tips and objectives for `:::callout` containers.
- [Parts](#parts) — part-divider pages for `:::part` containers: parity, body area, opener design, body typography.
- [Heading styles](#heading-styles) — named styles for `{style="…"}` headings: unnumbered chapters, front matter with its own running heads, geometry and palette.
- [Table of contents](#table-of-contents) — what `:::toc` prints: entry typography, leaders, page numbers, author lines, part rows.
- [Units and colors](#units-and-colors) + [Color palette](#color-palette) — `Dimension`, `ColorValue`, named colors.
- [Custom fonts](#custom-fonts) — declare user-uploaded font families alongside Google Fonts.
- [HTML viewer](#html-viewer) — target column width and line-breaking for the HTML backend.
- [PDF generation (config)](#pdf-generation-config) — outlines, accessible (tagged) output, forced colour space for the PDF backend.
- [Debug](#debug) — visual overlays and authoring warnings for the editor.
- [Programmatic use](#programmatic-usage) — `buildDocument`, resolvers, caches.
- [Running layout in a Web Worker](#running-layout-in-a-web-worker) — off-main-thread builds with cancellation.
- [Integrating the HTML viewer](#integrating-the-html-viewer) and [Generating PDFs](#generating-pdfs) — end-to-end recipes.

## Page

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

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `sizePreset` | `PageSizePreset` | `'17x24'` | Predefined page size. Set to `'custom'` to use explicit width/height. |
| `width` | `Dimension` | `17 cm` | Page width. Taken from `sizePreset` when omitted; an explicit value always wins (use `sizePreset: 'custom'` for fully custom sizes). |
| `height` | `Dimension` | `24 cm` | Page height. Taken from `sizePreset` when omitted; an explicit value always wins. |
| `margins` | `PageMargins` | `2 cm` all sides | Space 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. |
| `backgroundColor` | `ColorValue` | `transparent` | Page background color. |
| `dpi` | `number` | `300` | Dots per inch. Affects how physical units (cm, mm, in) are converted to pixels. |
| `cutLines` | `CutLinesConfig` | disabled | Show trim marks at page corners for print cutting. When enabled, the canvas expands to include bleed area and crop marks. See below. |
| `baselineGrid` | `BaselineGridConfig` | disabled | Overlay a horizontal baseline grid for vertical rhythm alignment. See below. |

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

```json
{
  "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

| Preset | Width | Height | Common use |
| --- | --- | --- | --- |
| `'11x17'` | 11 cm | 17 cm | Pocket books |
| `'12x19'` | 12 cm | 19 cm | Standard paperback |
| `'17x24'` | 17 cm | 24 cm | Technical books, textbooks |
| `'21x28'` | 21 cm | 28 cm | Magazines, reports (near A4) |

> **Figure: Page size presets**
> Four built-in page size presets drawn to proportional scale: pocket 11x17, paperback 12x19, technical 17x24, and near-A4 21x28 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.

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `enabled` | `boolean` | `false` | Whether to draw the baseline grid overlay. |
| `color` | `ColorValue` | `#cccccc` | Color of the grid lines. |
| `lineWidth` | `Dimension` | `0.5 pt` | Thickness of the grid lines. |

```ts
page: {
  baselineGrid: { enabled: true, color: { hex: '#e0e0e0', model: 'hex' } }
}
```

### Cut lines

When enabled, the canvas expands to include a bleed area and the engine draws crop marks at each corner for print production.

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `enabled` | `boolean` | `false` | Whether to expand the canvas with bleed and draw crop marks. |
| `bleed` | `Dimension` | `3 mm` | Extra area around the page used for print bleed. |
| `markLength` | `Dimension` | `5 mm` | Length of each crop mark. |
| `markOffset` | `Dimension` | `3 mm` | Gap between the page corner and the start of the crop mark. |
| `markWidth` | `Dimension` | `0.25 pt` | Thickness of the crop marks. |
| `color` | `ColorValue` | `#000000` | Color of the crop marks. |

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

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `format` | `'decimal' \| 'lower-roman' \| 'upper-roman' \| 'lower-alpha' \| 'upper-alpha'` | `'decimal'` | Numeric style used to render page labels. |
| `startAt` | `number` | `1` | Numeric 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 <code>{pageNumber}</code> header/footer placeholder resolves to. PDFs emit a <code>/PageLabels</code> 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.

> **Figure: Columns, gutter, and margin**
> A 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.
>
> *Columns hold text. Gutters separate them. Margins frame the content.*

> **Figure: Margin system**
> A page with independent top, right, bottom, and left margins surrounding the content area.
>
> *Each side of the page can have its own margin.*

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `layoutType` | `'single' \| 'double' \| 'oneAndHalf'` | `'double'` | Column arrangement. See below for details on each type. |
| `gutterWidth` | `Dimension` | `0.75 cm` | Horizontal space between columns. Only applies to multi-column layouts. |
| `sideColumnPercent` | `number` | `33` | Width of the side column as a percentage of the content area. Only applies to `'oneAndHalf'` layout. |
| `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. |
| `columnRule` | `ColumnRuleConfig` | disabled | Optional visual rule drawn between columns. See below. |
| `fitFiguresToPage` | `boolean` | `false` | Shrink 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.

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `enabled` | `boolean` | `false` | Whether to draw the column rule. |
| `color` | `ColorValue` | `#cccccc` | Color of the rule line. |
| `lineWidth` | `Dimension` | `0.5 pt` | Thickness of the rule line. |

### Layout types

- **`'single'`** — One column spanning the full content width. Best for narrow pages or text-heavy content with long paragraphs.

- **`'double'`** — Two equal-width columns. The classic editorial layout — keeps line measure within the optimal 40–50 character range for comfortable reading.

- **`'oneAndHalf'`** — An asymmetric layout with a main column and a narrower side column. The side column (controlled 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.

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `elements` | `HeaderFooterElement[]` | built-in defaults when `undefined`; `[]` disables | Ordered 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.

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `kind` | `'text'` | — | Discriminator. |
| `id` | `string` | — | Stable id, unique within the slot. Other elements anchor to it with `anchor.to: '#id'`. The sandbox assigns one on creation. |
| `content` | `string` | `''` | Template string. Supports placeholders listed below, plus `{attr.<key>}` — an attribute written on the current chapter's H1 line (`# Title {author="I. Zango"}`). 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. |
| `fontFamily` | `string` | `'EB Garamond'` | Font family. |
| `fontSize` | `Dimension` | `8 pt` | Font size. |
| `fontWeight` | `number` | `400` | Font weight (100–900). |
| `italic` | `boolean` | `false` | Whether to render in italic. |
| `color` | `ColorValue` | `#000000` | Text 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. |
| `lineHeight` | `number` | `1.2` | Leading of wrapped lines, as a multiple of `fontSize`. |
| `letterSpacing` | `Dimension` | `0` | Tracking: 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. |
| `box` | `ElementBoxStyle` | — | Optional 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](https://postext.dev/en/docs/configuration#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. |
| `paragraphIndent` | `Dimension` | `0` | First-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. |
| `hyphenate` | `boolean` | `false` | When `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. |
| `marginFromBody` | `Dimension` | `6 pt` | Absolute distance between the element's body-facing edge and the body edge. Independent of other elements. Migrated to `placement.offset.y`. |
| `marginFromEdge` | `Dimension` | `0 pt` | Horizontal inset from the aligned content edge. Only applies when `align` is `'left'` or `'right'`. Migrated to `placement.offset.x`. |
| `placement` | `ElementPlacement` | derived from `align` + `marginFromBody` + `marginFromEdge` | Advanced 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.

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `kind` | `'rule'` | — | Discriminator. |
| `id` | `string` | — | Stable 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. |
| `color` | `ColorValue` | `#000000` | Stroke colour. |
| `thickness` | `Dimension` | `0.5 pt` | Line thickness. |
| `width` | `Dimension \| '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'`. |
| `marginFromBody` | `Dimension` | `6 pt` | Absolute distance between the rule's body-facing edge and the body edge. Independent of other elements. |
| `marginFromEdge` | `Dimension` | `0 pt` | Horizontal 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). |
| `placement` | `ElementPlacement` | derived from `align` + `marginFromBody` + `marginFromEdge` | Advanced placement (see [Element placement](https://postext.dev/en/docs/configuration#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".

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `kind` | `'box'` | — | Discriminator. |
| `id` | `string` | — | Stable id, unique within the slot. Sibling elements anchor to it with `anchor.to: '#id'`. The sandbox assigns one on creation. |
| `style.backgroundColor` | `ColorValue` | `transparent` | Fill colour. Set to `transparent` for an outline-only box. |
| `style.borderColor` | `ColorValue` | `transparent` | Stroke colour. |
| `style.borderWidth` | `Dimension` | `0 pt` | Stroke width. Strokes are painted on the inside of the box's bounding rectangle so the outer dimensions stay constant. |
| `style.borderRadius` | `Dimension` | `0 pt` | Corner radius. Clamped to half the smaller side at render time. |
| `placement` | `ElementPlacement` | — | Required. 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.

```ts
{
  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' } },
}
```

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` | — | Stable identifier; other elements may anchor to it as `#id`. |
| `resourceId` | `string` | — | Id of a bitmap or SVG `Resource` of the document. |
| `placement` | `ElementPlacement` | — | Anchor, offset and size (see [Element placement](https://postext.dev/en/docs/configuration#element-placement)). A `'fill'` side runs to the container edge. |
| `parity`, `pages` | as 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 <code>resourceImageUrl</code>.

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

```ts
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:

```json
{ "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.

> **Figure: Type scale**
> Typographic hierarchy from H1 down to small body copy, showing relative sizes for headings, body text, and captions.
>
> *A consistent scale keeps hierarchy readable at a glance.*

> **Figure: Spacing scale**
> A step-based spacing scale showing increasing values used across margins, paddings, and gaps.
>
> *Spacing steps build up predictable rhythm through the layout.*

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `fontFamily` | `string` | `'EB Garamond'` | Font family for body text. Any Google Font, system font, or custom family declared in [`customFonts`](#custom-fonts). |
| `fontSize` | `Dimension` | `8 pt` | Base font size for body text. |
| `lineHeight` | `Dimension` | `1.5 em` | Vertical spacing between lines. Relative units (em, rem) scale with font size. |
| `paragraphSpacing` | `boolean` | `false` | When enabled, inserts a blank line (equal to `lineHeight`) between consecutive paragraphs for publisher-style separation. |
| `color` | `ColorValue` | `#000000` | Text color. |
| `boldColor` | `ColorValue` | Main Color (`#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. |
| `italicColor` | `ColorValue` | Main Color (`#295AA3`) | Color applied to italic/emphasis spans. Same palette-linked default as `boldColor`. |
| `referenceColor` | `ColorValue` | Main Color (`#295AA3`) | Color applied to inline `:ref` labels (resource references). Same palette-linked default as `boldColor`. |
| `referenceBold` | `boolean` | `true` | Render inline `:ref` labels with the bold font. |
| `referenceItalic` | `boolean` | `false` | Render 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). |
| `fontWeight` | `number` | `400` | Weight for normal text (100–900). |
| `boldFontWeight` | `number` | `700` | Weight for bold/strong text (100–900). |
| `hyphenation` | `HyphenationConfig` | enabled, `'en-us'` | Automatic hyphenation settings. See below. |
| `firstLineIndent` | `Dimension` | `1.5em` | Indent applied to the first line of each paragraph (or to all lines except the first when hanging indent is enabled). |
| `hangingIndent` | `boolean` | `false` | When enabled, the indent is applied to all lines except the first (French/hanging indent). |
| `indentAfterHeading` | `boolean` | `true` | When 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. |
| `maxWordSpacing` | `number` | `2` | Upper bound for word spacing in justified text, expressed as a multiplier of the normal space width. Lines exceeding this ratio are considered "loose". |
| `minWordSpacing` | `number` | `0.6` | Lower bound for word spacing in justified text, as a multiplier of the normal space width. |
| `optimalLineBreaking` | `boolean` | `true` | Use Knuth-Plass optimal line breaking instead of greedy first-fit. Produces more even word spacing across the paragraph. See [Hyphenation & Justification](/en/docs/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](/en/docs/justification) for a detailed explanation.

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `enabled` | `boolean` | `true` | Whether to allow hyphenation. |
| `locale` | `HyphenationLocale` | `'en-us'` | Language rules for syllable boundaries. |

**Supported locales:** `'en-us'` (English), `'es'` (Spanish), `'fr'` (French), `'de'` (German), `'it'` (Italian), `'pt'` (Portuguese), `'ca'` (Catalan), `'nl'` (Dutch).

Words shorter than 5 characters are never hyphenated. The engine requires at least 2 characters before and 3 characters after a break point.

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

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

### Orphans, widows, runts, and keep-together rules

See [Hyphenation & Justification](/en/docs/justification) for the mechanics behind these demerits. This section is the reference for the `bodyText` keys that drive them.

Beyond hyphenation and spacing bounds, the body-text configuration exposes the soft rules that prevent structurally awkward paragraph breaks. All of these are fed into the Knuth-Plass line-breaking algorithm as demerits — they bias the layout toward clean breaks without ever forcing a hard rule. Set the `*Penalty` values to `0` to effectively disable any one of them.

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `avoidOrphans` | `boolean` | `true` | Discourage a paragraph from ending with fewer than `orphanMinLines` lines at the top of the next column. |
| `orphanMinLines` | `number` | `2` | Minimum lines required at the top of the next column when a paragraph is split. Only active when `avoidOrphans` is `true`. |
| `orphanPenalty` | `number` | `1000` | Demerit added when an orphan constraint is violated. Higher values bias the algorithm more strongly against orphans; `0` disables the penalty. |
| `avoidOrphansInLists` | `boolean` | `true` | When `true`, list items also receive orphan protection (not just paragraphs). Only effective when `avoidOrphans` is `true`. |
| `avoidWidows` | `boolean` | `true` | Discourage a paragraph from starting with fewer than `widowMinLines` lines at the bottom of the current column. |
| `widowMinLines` | `number` | `2` | Minimum lines required at the bottom of the current column when a paragraph is split. Only active when `avoidWidows` is `true`. |
| `widowPenalty` | `number` | `1000` | Demerit added when a widow constraint is violated. `0` disables the penalty. |
| `avoidWidowsInLists` | `boolean` | `true` | When `true`, list items also receive widow protection. Only effective when `avoidWidows` is `true`. |
| `avoidRunts` | `boolean` | `true` | Discourage paragraphs from ending with a very short last line — a *runt*, e.g. a single short word alone. |
| `runtMinCharacters` | `number` | `20` | Approximate minimum character count for the last line of a paragraph. Interpreted internally as `runtMinCharacters × normalSpaceWidth` pixels: the test is "is the last line visually shorter than N characters' worth of space-width content". |
| `runtPenalty` | `number` | `1000` | Equivalent-badness injected into the Knuth–Plass squared demerit formula (same scale as line *badness*, which saturates at 10000). `0` disables the penalty. |
| `avoidRuntsInLists` | `boolean` | `true` | When `true`, list items also receive the runt penalty. Only effective when `avoidRunts` is `true`. |
| `tightenRunts` | `boolean` | `true` | When 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`. |
| `maxRuntTracking` | `number` | `10` | Most 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. |
| `slackWeight` | `number` | `10` | Weight applied to the squared "unused column space" cost. Higher values make the layout prefer filling columns tightly; `0` disables the slack pressure entirely. |
| `keepColonWithList` | `boolean` | `true` | When a paragraph ends with a colon that directly introduces a list, keep the colon-bearing last line joined to the list: if placing the paragraph would leave no room for the first list item in the same column/page, the last line (or the whole paragraph, if it is a single line) is moved to the next column together with the list. When this rule would push the whole paragraph and a run of headings immediately precedes it in the column, those headings are pulled forward too so `headings.keepWithNext` keeps holding. |

**About runts.** A runt is a paragraph whose last line is too short to feel like a proper line of text — typically one or two short words marooned at the end of a paragraph. Because the check is based on the pixel length of the line relative to the normal space width, `runtMinCharacters` adapts automatically to the current font size. A short word that is visually wider than `runtMinCharacters × spaceWidth` is fine; a word that is narrower than that (or truly alone) attracts the runt penalty. 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

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `fontFamily` | `string` | `'Open Sans'` | Font family for all headings. |
| `lineHeight` | `Dimension` | `1.2 em` | Line height for headings. Tighter than body text. |
| `color` | `ColorValue` | Main Color (`#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. |
| `fontWeight` | `number` | `700` | Font weight for headings (100–900). |
| `marginTop` | `Dimension` | `1.5 em` | Space above headings. |
| `marginBottom` | `Dimension` | `0.5 em` | Space below headings. |
| `keepWithNext` | `boolean` | `true` | When `true`, a heading is never placed as the last element of a column or page. If the following block would not have at least `bodyText.widowMinLines` lines of room after the heading (or one line when `bodyText.avoidWidows` is `false`), the heading is pushed forward so it stays joined to its text. Interacts with `bodyText.keepColonWithList`: if that rule has to push a colon-paragraph whole, any trailing heading(s) in the column travel with it rather than being left stranded. |
| `snapToGrid` | `boolean` | `true` | Whether 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. |
| `balancing` | `ColumnBalancingConfig` | enabled | Vertical 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:

0. **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.
1. **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.
2. **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.
3. **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.

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `enabled` | `boolean` | `true` | Whether to balance column bottoms. |
| `maxLinesPerHeading` | `number` | `4` | Maximum extra grid lines that may be added above a single heading. |
| `stretchAfterLists` | `boolean` | `true` | Allow extra grid lines where a list ends, when headings cannot absorb the whole gap. |
| `maxLinesAfterList` | `number` | `1` | Maximum extra grid lines after a single list end. |
| `stretchAfterFloats` | `boolean` | `true` | Allow 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. |
| `maxLinesAfterFloat` | `number` | `1` | Maximum extra grid lines under a single top float. |
| `looseParagraphs` | `boolean` | `true` | Last resort: re-break paragraphs of a short column one line looser (one extra line each), within `bodyText.maxWordSpacing`. |
| `maxLooseParagraphs` | `number` | `2` | How many paragraphs of one short column may run a line long, longest first. |
| `trackParagraphs` | `boolean` | `true` | When word spacing alone cannot gain the line, a loose paragraph may also take the smallest positive tracking (letter spacing) that does. |
| `maxTracking` | `number` | `10` | Upper limit for that tracking, in thousandths of an em per character (10 = 0.01 em). |
| `trailing` | `boolean` | `true` | Level 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. |
| `beforeSpan` | `boolean` | `true` | Level 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.

| Level | Default font size | Default `breakBefore` |
| --- | --- | --- |
| H1 | `18 pt` | `{ enabled: true, parity: 'always-odd' }` |
| H2 | `15 pt` | `{ enabled: false, parity: 'any' }` |
| H3 | `12 pt` | `{ enabled: false, parity: 'any' }` |
| H4 | `10 pt` | `{ enabled: false, parity: 'any' }` |
| H5 | `9 pt` | `{ enabled: false, parity: 'any' }` |
| H6 | `8 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:

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `italic` | `boolean` | `false` | Render 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. |
| `numberingTemplate` | `string` | `''` | Template of the level's automatic number. A token `{1}` … `{6}` prints the running counter of that heading level, optionally formatted by a suffix — `{1:I}` upper roman, `{1:i}` lower roman, `{1:A}` / `{1:a}` alphabetic, `{1:01}` zero-padded — and any other text is literal (`'Chapter {1}. '`, `'{1}.{2}'`; 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 `{number}` placeholder of an advanced design slot (where the prefix itself is not prepended) and is printed in the table of contents. |
| `breakBefore` | `HeadingBreakBeforeConfig` | H1: `{ 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. |

```ts
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

| Value | Behavior |
| --- | --- |
| `'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.

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `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. |
| `advancedDesign` | `HeadingAdvancedDesignConfig` | `{ enabled: false, slot: { elements: [] } }` | Free-composition design slot for this level. When `enabled`, the slot's elements compose the opener. Use `{titleText}` inside a text element to render the heading's title text; use `{number}`, `{numberRoman}`, etc. to insert the formatted heading number. |
| `advancedDesign.minHeight` | `Dimension` | — | Minimum 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:

```json
{
  "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

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `fontFamily` | `string` | inherits `bodyText.fontFamily` | Font used for the item text. |
| `color` | `ColorValue` | Main Color (`#295AA3`) | Text and bullet color for items. Bound to the default palette's `main-color` entry. |
| `fontWeight` | `number` | `700` | Weight for item text (100–900). Bullets inherit this weight unless overridden per-level. |
| `italic` | `boolean` | `false` | Render item text in italic. |
| `bulletChar` | `string` | `'•'` | Glyph used as the bullet marker. |
| `bulletFontSize` | `Dimension` | `1 em` | Size of the bullet glyph. Relative units scale with the body font size. |
| `gap` | `Dimension` | `0.5 em` | Horizontal space between the bullet and the item text. |
| `indent` | `Dimension` | `0 em` | Base indent for level 1. Deeper levels cascade from the parent's text-start unless overridden (see below). |
| `bulletVerticalOffset` | `Dimension` | `0 em` | Fine-tune bullet vertical position. Negative values move the bullet up, positive values move it down. |
| `marginTop` / `marginBottom` | `Dimension` | `1.5 em` | Space before and after the list as a whole. |
| `itemSpacing` | `Dimension` | `0 em` | Extra vertical space inserted between items on top of the line height. |
| `hangingIndent` | `boolean` | `true` | When enabled, wrapped lines align with the first text character rather than under the bullet. |
| `levels` | `UnorderedListLevelConfig[]` | — | Per-depth overrides for levels 1–5. See below. |

### Task list extensions

GFM task items (`- [ ] …`, `- [x] …`) are rendered as unordered items with a checkbox glyph replacing the bullet. The following fields only apply to task items:

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `taskCheckboxChar` | `string` | `'☐'` | Glyph used for unchecked tasks. |
| `taskCheckedChar` | `string` | `'☑'` | Glyph used for completed tasks. |
| `taskCompletedStrikethrough` | `boolean` | `true` | Draw a strikethrough line across the text of completed tasks. |
| `taskCompletedColor` | `ColorValue` | inherits item color | Optional color applied to completed task text. When omitted, the regular item color is used. |

### Unordered per-level overrides

Each entry in `levels` targets one depth (1–5) and can override any of the following:

| Property | Type | Description |
| --- | --- | --- |
| `bulletChar` | `string` | Bullet glyph for this depth. |
| `fontFamily` | `string` | Item font family for this depth. |
| `fontSize` | `Dimension` | Bullet glyph size for this depth. |
| `color` | `ColorValue` | Item color. |
| `fontWeight` | `number` | Item weight. |
| `italic` | `boolean` | Italic toggle. |
| `indent` | `Dimension` | Explicit indent for the bullet at this depth. See the cascade rule below. |
| `verticalOffset` | `Dimension` | Vertical fine-tune for the bullet at this depth. |

**Indent cascade.** Level 1 always starts at the general `indent` value (by default `0 em` — bullets are pinned to the column edge). For levels 2–5, if you leave `indent` undefined the engine places the bullet at the *previous* level's text-start (parent indent + bullet width + `gap`). Set an explicit `indent` on a level to break the cascade and pin that depth anywhere you like.

```ts
unorderedLists: {
  bulletChar: '—',
  gap: { value: 0.4, unit: 'em' },
  hangingIndent: true,
  levels: [
    { level: 2, bulletChar: '·' },
    { level: 3, bulletChar: '◦', color: { hex: '#666666', model: 'hex' } },
  ],
}
```

## Ordered Lists

The `orderedLists` property controls numbered lists (`1.`, `2)`, etc.). Up to five levels of nesting are supported and each depth can use a different number format.

### Ordered list defaults

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `fontFamily` | `string` | inherits `bodyText.fontFamily` | Font used for the item text and the number marker. |
| `color` | `ColorValue` | Main Color (`#295AA3`) | Text and number-marker color for items. Bound to the default palette's `main-color` entry. |
| `fontWeight` | `number` | `700` | Weight for item text and number markers (100–900). |
| `italic` | `boolean` | `false` | Render item text in italic. |
| `numberFormat` | `OrderedListNumberFormat` | `'arabic'` | Number style: `'arabic'`, `'lower-alpha'`, `'upper-alpha'`, `'lower-roman'`, `'upper-roman'`. |
| `separator` | `string` | `'.'` | Character placed between the number and the text — typically `'.'` or `')'`. |
| `separatorFontFamily` | `string` | inherits `fontFamily` | Font 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. |
| `separatorFontWeight` | `number` | inherits `fontWeight` | Weight of the separator (100–900). |
| `separatorItalic` | `boolean` | inherits `italic` | Render the separator in italic. |
| `separatorColor` | `ColorValue` | inherits `color` | Colour of the separator. Palette references are honoured. |
| `separatorGap` | `Dimension` | `0 em` | Space between the number and the separator. The item text still starts `gap` after the separator. |
| `numberFontSize` | `Dimension` | `1 em` | Size of the number marker. |
| `gap` | `Dimension` | `0.5 em` | Horizontal space between the number and the item text. |
| `indent` | `Dimension` | `0 em` | Base indent for level 1; deeper levels cascade from the parent's text-start unless overridden. |
| `numberVerticalOffset` | `Dimension` | `0 em` | Fine-tune the vertical position of the number marker. |
| `marginTop` / `marginBottom` | `Dimension` | `1.5 em` | Space before and after the list as a whole. |
| `itemSpacing` | `Dimension` | `0 em` | Extra vertical space between items. |
| `hangingIndent` | `boolean` | `true` | Wrapped lines align with the first text character rather than under the number. |
| `levels` | `OrderedListLevelConfig[]` | — | Per-depth overrides for levels 1–5. |

### Ordered per-level overrides

Each entry in `levels` can override `numberFormat`, `separator`, `fontFamily`, `fontSize`, `color`, `fontWeight`, `italic`, `indent`, `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.

```ts
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.

```ts
interface MathConfig {
  enabled?: boolean;        // Render LaTeX. When false, spans pass through as literal TeX.
  fontSizeScale?: number;   // Multiplier applied to the body font size.
  color?: ColorValue;       // Formula colour; inherits body colour if omitted.
  marginTop?: Dimension;    // Space above display math blocks.
  marginBottom?: Dimension; // Minimum space below; baseline grid snap may enlarge it.
}
```

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `enabled` | `boolean` | `true` | When false, `$...$` and `$$...$$` spans are still parsed (so unclosed-delimiter warnings still fire) but are rendered as their literal TeX source. Useful when the content intentionally contains dollar signs or when you want to disable math rendering entirely. |
| `fontSizeScale` | `number` | `1.0` | Multiplier applied to `bodyText.fontSize` before rendering. 1.0 matches body text; values in the 0.9–1.1 range are typical when the math font looks slightly larger or smaller than the prose font. |
| `color` | `ColorValue` | inherits body colour | Colour of the rendered formula. Omit to inherit `bodyText.color`. Set explicitly when you want formulas tinted differently from prose — e.g. matching a heading accent. |
| `marginTop` | `Dimension` | `0.8em` | Space above a display math block. Ignored for inline math. |
| `marginBottom` | `Dimension` | `0.8em` | Space below a display math block. When the baseline grid is enabled this is treated as a **minimum** — the grid snap may extend it so the next baseline falls on a grid line. |

```ts
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:

```ts
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](/en/docs/document-format#mathematical-formulas).

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

```ts
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', … }]
```

```ts
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`.

| Property | Type | Description |
| --- | --- | --- |
| `id` | `string` | Stable 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. |
| `name` | `string` | Singular display name. Used by the `style="full"` inline reference (e.g. `Figure 1.7`). |
| `namePlural` | `string` (optional) | Plural display name, for UI labels and lists of resources. |
| `shortLabel` | `string` | Compact abbreviation used by the default inline reference style (e.g. `Fig. 1.7`). |
| `numberingTemplate` | `string` | Template for the computed number. See **Template tokens** below. Common forms are `{h1}.{n}` (chapter-scoped, e.g. `2.3`) and `{n}` (a single running count). |
| `resetOn` | `ResourceCounterReset` | `'never'` gives one document-wide running count; `'h1'`..`'h6'` reset the `{n}` 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. `{h1}.{n}` with `resetOn: 'h1'`. |
| `counterFormat` | `ResourceCounterFormat` | How the `{n}` counter renders: decimal (`1, 2, 3`), lower/upper roman (`i, ii` / `I, II`), or lower/upper alpha (`a, b` / `A, B`). Heading tokens (`{h1}`…) always render as decimals. |
| `captionPrefix` | `string` | Text prepended to the figure/table caption. The computed number follows the prefix — a caption renders as `{captionPrefix} {number}. {caption text}`, e.g. **Figure 1.7. The original plan.** |
| `defaultPlacement` | `ResourcePlacement` (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](https://postext.dev/en/docs/configuration#numbering-and-references) below for the resolution chain and [Document format › Resources](/en/docs/document-format#resources) for what each value does, including turned resources. |
| `captionStyle` | `CaptionStyleConfig` (optional) | Partial [caption style](https://postext.dev/en/docs/configuration#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](#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.

| Template | With `h1 = 2`, counter = 3 | Notes |
| --- | --- | --- |
| `{n}` | `3` | A single running count. Pair with `resetOn: 'never'`. |
| `{h1}.{n}` | `2.3` | Chapter-scoped. Pair with `resetOn: 'h1'`. |
| `{h1}.{h2}.{n}` | `2.0.3` | Section-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](/en/docs/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](#named-table-styles). 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.

```ts
const config: PostextConfig = {
  tableStyle: {
    headerBold: true,
    headerBackground: { hex: '#f0f0f0', model: 'hex' },
    borders: true,
    borderWidth: { value: 0.75, unit: 'pt' },
  },
};
```

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `bodyFontFamily` | `string` | body text font | Font family for body cells. |
| `bodyFontSize` | `Dimension` | body text size | Font size for body cells. |
| `bodyColor` | `ColorValue` | body text color | Text color for body cells. |
| `headerFontFamily` | `string` | body text font | Font family for header cells. |
| `headerFontSize` | `Dimension` | body text size | Font size for header cells. |
| `headerColor` | `ColorValue` | body text color | Text color for header cells. |
| `headerBold` | `boolean` | `true` | Render header cells in bold. |
| `headerItalic` | `boolean` | `false` | Render header cells in italics. |
| `headerBackgroundEnabled` | `boolean` | `true` | Paint a fill behind the header row. |
| `headerBackground` | `ColorValue` | `#f0f0f0` | Header row fill color. |
| `bodyBackgroundEnabled` | `boolean` | `false` | Paint a fill behind body rows. |
| `bodyBackground` | `ColorValue` | `#ffffff` | Body row fill color (only painted when enabled). |
| `borders` | `boolean` | `true` | Draw cell borders. |
| `borderColor` | `ColorValue` | body text color | Border stroke color. |
| `borderWidth` | `Dimension` | `0.75pt` | Border stroke width (≈1px at 96 DPI; scales with page DPI). |
| `cellPadding` | `Dimension` | `0.375em` | Inner 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. |
| `borderRadius` | `Dimension` | `0` | Corner 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. |
| `continuedSuffix` | `string` | `'(cont.)'` | Appended, in italics, to the caption of every continued part of a split table. |
| `continuesMarkerEnabled` | `boolean` | `true` | Set a marker under every part that continues on the next page. |
| `continuesMarker` | `string` | `'Continued'` / `'Continúa'` | Text of that marker, set flush right under the part in the note typeface (see [caption style](https://postext.dev/en/docs/configuration#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.

```ts
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](#resource-types)).

```ts
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' },
  },
};
```

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `fontFamily` | `string` | body text font | Caption font family (label and description). |
| `fontSize` | `Dimension` | body text size | Caption font size (label and description). |
| `color` | `ColorValue` | body text color | Description text color. |
| `align` | `'left' \| 'center' \| 'right'` | `'left'` | Horizontal alignment of the caption under the resource. |
| `gap` | `Dimension` | `0.75em` | Vertical gap between the resource and its caption. |
| `labelBold` | `boolean` | `true` | Render the numbered label (e.g. `Figure 1`) in bold. |
| `labelItalic` | `boolean` | `false` | Render the numbered label in italics. |
| `labelColor` | `ColorValue` | caption `color` | Color of the numbered label. |
| `descriptionItalic` | `boolean` | `false` | Render 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. |
| `backgroundEnabled` | `boolean` | `false` | Paint a bar behind the caption. The bar spans the full block width and encloses the caption lines plus `padding` on every side. |
| `background` | `ColorValue` | main palette color | Bar fill color (only painted when enabled). |
| `padding` | `Dimension` | `0.35em` | Inner padding between the bar edge and the caption text. Ignored when the bar is off. |
| `note` | `object` | — | Styling 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.

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `note.fontSize` | `Dimension` | 0.85 × caption size | Note font size. |
| `note.color` | `ColorValue` | caption `color` | Note text color. |
| `note.italic` | `boolean` | `false` | Render the note in italics. |
| `note.gap` | `Dimension` | `0.35em` | Gap 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.

```ts
const config: PostextConfig = {
  diagramStyle: {
    singleInk: true,
    inkColor: { hex: '#295AA3', model: 'hex' },
  },
};
```

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `singleInk` | `boolean` | `false` | Recolour every embedded SVG diagram to tints of a single ink. |
| `inkColor` | `ColorValue` | Main 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.

```ts
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:

```ts
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.

```ts
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' },
    },
  ],
};
```

```md
## References

:::paragraphs{style="bibliography"}
Knuth, D. E. (1984). *The TeXbook*. Addison-Wesley.

Bringhurst, R. (2004). *The Elements of Typographic Style*. Hartley & Marks.
:::
```

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` | required | Identifier referenced from `:::paragraphs{style="…"}`. |
| `name` | `string` | `id` | Human-readable name, for editor UIs only. |
| `fontFamily` | `string` | body text font | Font family. Weights (regular and bold) follow the body text. |
| `fontSize` | `Dimension` | body text size | Font size. |
| `lineHeight` | `Dimension` | body line height | Leading. `em`/`rem` are relative to the style's own font size, so an inherited `1.5em` tightens along with a smaller size. |
| `color` | `ColorValue` | body text color | Text color. Bold and italic runs keep the body emphasis colors. |
| `textAlign` | `'left' \| 'justify' \| 'center' \| 'right'` | body alignment | Horizontal alignment. `'center'` and `'right'` set every line ragged from the other side — a dedication, a signature block. |
| `boldColor` | `ColorValue` | `bodyText.boldColor` | Colour of bold runs (an authors list with the names in the house colour). |
| `hyphenation` | `boolean` | body hyphenation | Hyphenate when justified (uses the document locale). |
| `firstLineIndent` | `Dimension` | body first-line indent | Indent of the first line. Ignored when `hangingIndent` is non-zero. |
| `hangingIndent` | `Dimension` | `0` | Indent applied to every line except the first — the classic bibliography or glossary shape. |
| `spaceBetween` | `Dimension` | `0` | Vertical gap between consecutive paragraphs inside the container. `0` makes entries abut. |
| `marginTop` | `Dimension` | `0` | Space above the container's first paragraph. Collapses with the spacing already pending and vanishes at the top of a column, like any other margin. |
| `marginBottom` | `Dimension` | `0` | Minimum 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.

```ts
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.

```ts
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,
    },
  ],
};
```

```md
Classify: :chip[battery] :chip[cable] :chip[switch]

Press :chip[Ctrl]{style="key"} + :chip[C]{style="key"}.
```

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` | required | Identifier referenced from `:chip[…]{style="…"}`. |
| `name` | `string` | `id` | Human-readable name, for editor UIs only. |
| `backgroundEnabled` | `boolean` | `true` | Paint the box fill. |
| `background` | `ColorValue` | `#e8eef7` | Box fill (palette-linkable). |
| `borderColor` | `ColorValue` | main palette colour | Outline colour. |
| `borderWidth` | `Dimension` | `0.5pt` | Outline width; `0` draws none. The outline is stroked inside the box edge. |
| `borderRadius` | `Dimension` | `0.3em` | Corner radius, clamped to half the box height (a large value gives a pill). |
| `paddingX` | `Dimension` | `0.3em` | Room between the outline and the text, left and right. Part of the chip's advance. |
| `paddingY` | `Dimension` | `0.1em` | Room above and below the text band. Paints outside the line box: it never changes the line height. |
| `fontFamily` | `string` | surrounding text | Family of the chip text. Weights follow the text around it. |
| `fontSize` | `Dimension` | surrounding text | Size of the chip text; `em` is relative to the surrounding text. |
| `color` | `ColorValue` | surrounding text | Colour of the chip text. Unset, bold and italic runs keep the emphasis colours. |
| `bold` | `boolean` | `false` | Set the chip text bold, on top of its own markup. |
| `italic` | `boolean` | `false` | Set the chip text italic, on top of its own markup. |
| `gap` | `Dimension` | `0.25em` | Least 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.

```ts
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.

```ts
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' } },
    },
  ],
};
```

```md
:::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.
:::
```

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` | — | Identifier selected by `:::callout{type="…"}`. A fence with an unknown or missing `type` uses the first configured style (the sandbox flags unknown types). |
| `name` | `string` | `id` | Human-readable name (editor UI only). |
| `title` | `string` | `''` | 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. |
| `fixed` | `{ anchor?, offset? }` | `{ anchor: { to: 'container', edge: 'bottom-left' } }` | Position 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). |
| `floatBarrier` | `boolean` | `false` | Make 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` / `background` | `boolean` / `ColorValue` | `true` / `#f4f4f4` | Box fill. |
| `border` | `{ enabled, color, width }` | `false`, `#cccccc`, `0.5pt` | Box outline. |
| `borderRadius` | `Dimension` | `0` | Corner radius of the background / border. |
| `padding` | `{ top, right, bottom, left }` | `0.75em` each | Inset 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 colour | Solid 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`, `0` | Title 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 `bodyText` | Typography 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 `unorderedLists` | List 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. |
| `columnGap` | `Dimension` | `1.5em` | Gap between the columns of a `:::columns` group inside the box (see the container section below). |
| `marginTop` / `marginBottom` | `Dimension` | `0.75em` / `0.75em` | Space above the box (collapses with the previous block's margin) and minimum space below it (the exact space with `snapToGrid: false`). |
| `snapToGrid` | `boolean` | `true` | When `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. |
| `keepTogether` | `boolean` | `true` | When `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`. |
| `splitMinLines` | `number` | `2` | Fewest 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.

```ts
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.

```ts
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' } },
  },
};
```

```md
:::part{number="I" title="Foundations"}
1. The lantern and its parts
2. Trimming the wick
3. Reading the weather
:::

# The lantern and its parts
```

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `page` | `boolean` | `true` | Whether 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.parity` | `HeadingBreakParity` | `'odd'` | Parity of the page the part opens on. Same values and blank-page ownership rules as the heading [breakBefore](https://postext.dev/en/docs/configuration#break-before): a blank inserted to reach the parity belongs to the part (its `{partTitle}` already resolves to the new part); the mandatory separator of `'always-*'` belongs to the previous content. |
| `breakAfter.enabled` | `boolean` | `true` | Move the content after the closing fence to a fresh page. When `false` it continues in the part page's single column. |
| `breakAfter.parity` | `HeadingBreakParity` | `'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. |
| `margins` | `PageMargins` | page margins | Body 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. |
| `design` | `DesignSlot` | empty | Opener 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 `{number} {titleText}` text in the H1 typography is synthesised at the top-left of the body area. |
| `versoDesign` | `DesignSlot` | empty | Design 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`, `textAlign` | as in `bodyText` | inherit `bodyText` | Typography of the paragraphs, blockquotes and list items inside the fence. Weights, emphasis colours and hyphenation come from the body text. |
| `bodyStyle.bulletColor` | `ColorValue` | `unorderedLists.color` | Bullet colour of unordered lists inside the part. |
| `bodyStyle.numberColor` | `ColorValue` | `orderedLists.color` | Number 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.unorderedLists` | `UnorderedListsConfig` | — | Partial 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.orderedLists` | `OrderedListsConfig` | — | Partial 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.

```ts
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.

```ts
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' },
    },
  ],
};
```

```md
# Preface {style="front-matter"}
```

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` | — | Identifier referenced from `{style="…"}` on a heading line. An unknown id leaves the heading as it is. |
| `name` | `string` | `id` | Human-readable name (editor UI only). |
| `numbered` | `boolean` | `true` | Whether the heading counts: advances its level's counter (the `numberingTemplate` numbers, the `{h1}` of resource numbers), the chapter ordinal behind `{chapterNumber}` 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 `{chapterNumber}` is empty on their pages. |
| `toc` | `boolean` | `true` | Whether `:::toc` lists the heading. A heading overrides it with `{toc="false"}` / `{toc="true"}`. |
| level fields | as in `headings.levels[]` | the level's values | `fontFamily`, `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`, `footer` | `DesignSlot` | the document's | Running heads of the section's pages, replacing `header` / `footer` there (element `parity` and `pages` filters still apply). An empty slot removes them. |
| `margins` | `PageMargins` | page margins | Body 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`. |
| `layout` | `LayoutConfig` | `layout` | Column layout of the section's pages (`layoutType`, `gutterWidth`…): a single wide column for a preface set in a two-column book. |
| `bodyStyle` | `PartsBodyStyleConfig` | inherit `bodyText` | Typography of the paragraphs, blockquotes and lists in the section — the same fields as [parts.bodyStyle](https://postext.dev/en/docs/configuration#parts). |
| `palette` | `Record<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.

```ts
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](/en/docs/document-format#toc)). 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`.

```ts
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}' */] },
    },
  },
};
```

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `levels` | `TocLevelConfig[]` | level 1 | Heading 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. |
| `unnumbered` | `TocEntryStyleConfig` | — | Overrides for headings whose style has `numbered: false` (a preface): they print no number and start flush at the level's `indent`. |
| `pageNumber` | object | level-1 face, body weight | `fontFamily`, `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. |
| `leader` | object | `{ enabled: true, char: '.', gap: 0.5em }` | `char` 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. |
| `subtitle` | object | `{ enabled: false, attr: 'author' }` | A 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.enabled` | `boolean` | `true` | Whether part dividers get a row. |
| `parts.breakBefore` | `boolean` | `false` | Open a fresh page before every part row but the first, so each part's chapters are listed on a page of their own. |
| `parts.design` | `DesignSlot` | empty | Row design; its container is the row (column width × `height`). Placeholders: `{number}`, `{numberDecimal}`, `{numberRoman}`…, `{titleText}` and `{pageNumber}` (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, `{number} {titleText}` and the page number are set in the level-1 entry typography. |
| `parts.height`, `marginTop`, `marginBottom` | `Dimension` | two body lines, `0`, `0` | Row 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:

```ts
interface Dimension {
  value: number;
  unit: DimensionUnit; // 'cm' | 'mm' | 'in' | 'pt' | 'px' | 'em' | 'rem'
}
```

**Absolute units** — `cm`, `mm`, `in`, `pt`, `px` — are converted to pixels using the configured DPI. At 300 DPI, `1 cm` equals approximately 118 px.

**Relative units** — `em`, `rem` — scale with the current font size. An `em` is relative to the element's own font size; `rem` is relative to the body text font size.

### Colors

Colors are stored with both a hex representation and a target color model:

```ts
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

```ts
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.

```ts
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:

```ts
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:

```ts
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:

```ts
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.

```ts
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'>;
```

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `maxCharsPerLine` | `number` | `70` | Target measure for each rendered column, expressed in characters of the body font. The viewport samples a representative prose string at that length to derive the actual pixel width — so the result adapts to any proportional font and font-size combination. |
| `columnGap` | `number` | `50` | Horizontal gap, in CSS pixels, between columns when the viewer is in multi-column mode. Ignored in single-column mode. |
| `optimalLineBreaking` | `boolean` | `false` | Enable Knuth–Plass line breaking in the HTML viewer. Off by default because the viewer reruns layout on every resize and font-size change — the greedy first-fit algorithm is fast enough to feel instantaneous. Turn it on when you want the same optimal breaks the canvas backend uses. |
| `overrides` | `HtmlViewerOverrides` | — | A 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. |

```ts
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:

```ts
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](#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.

```ts
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).
}
```

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `outlines` | `boolean` | `true` | Emit PDF outlines (bookmarks) from the heading hierarchy so readers can jump directly to any heading from the sidebar of a PDF viewer. Turn off for documents where the heading tree is meaningless (e.g. single-page posters). |
| `forceColorSpace` | `boolean` | `false` | When true, every colour in the rendered PDF is converted to `colorSpace` at export time. Leave off for screen-first PDFs where input colours are already in the desired space; turn on to guarantee a single colour space across mixed sources. |
| `colorSpace` | `'rgb' \| 'cmyk' \| 'grayscale'` | `'cmyk'` | Target colour space used when `forceColorSpace` is on. Use `'cmyk'` for offset printing, `'rgb'` for screen-only PDFs, and `'grayscale'` for black-and-white print proofs. Has no effect when `forceColorSpace` is false. |
| `accessible` | `boolean` | `true` | Emit 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. |

```ts
pdfGeneration: {
  outlines: true,
  accessible: true,
  forceColorSpace: true,
  colorSpace: 'cmyk',
}
```

Resolver and stripper match the other sections:

```ts
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](#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.

| Property | Type | Description |
| --- | --- | --- |
| `cursorSync` | `SyncIndicatorConfig` | Caret mirrored into the rendered layout — see [Visual overlays](https://postext.dev/en/docs/configuration#visual-overlays). |
| `selectionSync` | `SyncIndicatorConfig` | Source selection highlighted on the page — see [Visual overlays](https://postext.dev/en/docs/configuration#visual-overlays). |
| `looseLineHighlight` | `LooseLineHighlightConfig` | Overlay on loose justified lines — see [Visual overlays](https://postext.dev/en/docs/configuration#visual-overlays). |
| `pageNegative` | `{ enabled: boolean }` | High-contrast negative of the page — see [Visual overlays](https://postext.dev/en/docs/configuration#visual-overlays). |
| `warnings` | `WarningsToggleConfig` | One boolean per kind of authoring warning shown in the editor — see [Warnings](https://postext.dev/en/docs/configuration#warnings). |

### Visual overlays

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `cursorSync.enabled` | `boolean` | `true` | Shows a caret in the rendered layout mirroring the source cursor position. |
| `cursorSync.color` | `ColorValue` | `#2563eb` | Colour of that caret. |
| `selectionSync.enabled` | `boolean` | `true` | Highlights the rendered range matching the source selection. |
| `selectionSync.color` | `ColorValue` | `#fde04780` | Colour of the highlight — a translucent yellow by default. |
| `looseLineHighlight.enabled` | `boolean` | `false` | Paints an overlay on justified lines whose word spacing exceeds `threshold` times the normal space width. |
| `looseLineHighlight.color` | `ColorValue` | `#ff000040` | Colour of that overlay. |
| `looseLineHighlight.threshold` | `number` | `3` | Multiplier of the normal space width above which a justified line counts as loose. The `looseLines` warning uses the same threshold. |
| `pageNegative.enabled` | `boolean` | `false` | Renders a high-contrast negative overlay over the page — useful for visually auditing the overall shape of a spread (text density, column balance, whitespace) at a glance, without being distracted by glyph detail. |

Each `SyncIndicatorConfig` is `{ enabled: boolean; color?: ColorValue }`. `LooseLineHighlightConfig` is `{ enabled: boolean; color?: ColorValue; threshold?: number }`. `pageNegative` is a minimal `{ enabled: boolean }` toggle.

```ts
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.

```ts
interface WarningsToggleConfig {
  missingFont?: boolean;
  looseLines?: boolean;
  headingHierarchy?: boolean;
  consecutiveHeadings?: boolean;
  listAfterHeading?: boolean;
  designIssues?: boolean;
}
```

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `missingFont` | `boolean` | `true` | Report when a font referenced by the configuration failed to load in the browser. Catches typos in `fontFamily` and missing `@fontsource/...` packages early, before they show up as silent fallback-font substitutions in the rendered output. |
| `looseLines` | `boolean` | `true` | Report justified lines whose word spacing exceeds `debug.looseLineHighlight.threshold`. Pairs with the overlay: the warning enumerates them in the panel, the overlay shows them in place. |
| `headingHierarchy` | `boolean` | `true` | Report heading levels that skip a rank — e.g. an H1 followed directly by an H3. Structural heading gaps usually indicate either a typo in the heading depth or a misunderstanding of the document's outline. |
| `consecutiveHeadings` | `boolean` | `false` | Report when a heading is immediately followed by another heading with no paragraph or list between them. Off by default because stacked headings are legitimate in many templates (title + subtitle, chapter + epigraph); turn on for manuscripts where every heading is supposed to introduce prose. |
| `listAfterHeading` | `boolean` | `false` | Report when a list starts immediately after a heading without an introductory paragraph. Off by default because reference material routinely does this; turn on for narrative writing where every list should be framed by prose. |
| `designIssues` | `boolean` | `true` | Report 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 `{titleText}`. |

```ts
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](#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](#running-layout-in-a-web-worker), which calls `buildDocument` inside a dedicated worker thread with the same arguments.

```ts
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:

```ts
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`:

```ts
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.

> **Runnable example: Postext · render a page to an image** — Lay out a markdown document with postext and rasterise its first page to a canvas / PNG. ([source](https://github.com/drnachio/postext/tree/main/docs/examples/render-page))

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

```ts
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](#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](#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:

```ts
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](#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:

```ts
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:

```ts
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](/en/docs/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:

```ts
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 `ArrayBuffer`s 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 `ArrayBuffer`s 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

```ts
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:

```tsx
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:

```ts
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.

```ts
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.

> **Runnable example: Postext · render a document to HTML** — Lay out a markdown document with postext and render it to an HTML string. ([source](https://github.com/drnachio/postext/tree/main/docs/examples/render-html))

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

```tsx
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 patching** — `renderToHtmlIndexed` 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](#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](#running-layout-in-a-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](#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

```bash
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

```ts
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:

```ts
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:

```ts
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:

```tsx
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.

> **Runnable example: Postext · generate a PDF in the browser** — Lay out a markdown document with postext and render it to a PDF with postext-pdf. ([source](https://github.com/drnachio/postext/tree/main/docs/examples/render-pdf))

### Print-ready PDFs

For production print workflows, tune these configuration options before rendering:

- **`page.cutLines.enabled: true`** — adds bleed area and crop marks around the trim. See [Cut lines](#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](#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](/en/docs/sandbox#export-and-import) exports and imports it and the [agent skill](/en/docs/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](/en/docs/sandbox#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:

```ts
import { openBundle } from 'postext';

const bundle = await openBundle(await file.arrayBuffer(), { locale: 'es' });

bundle.chapters;   // [{ title, file, markdown }, …] in book order
bundle.config;     // PostextConfig, ready for buildDocument
bundle.resources;  // Resource[]
bundle.files;      // Map<path, Uint8Array>: every file of the bundle
```

| Field | What it holds |
| --- | --- |
| `manifest` | The validated `preset.json`. |
| `id`, `name`, `description` | From the manifest. |
| `locale`, `locales` | The locale the content was read in, and every locale a bilingual bundle carries. `options.locale` picks one: first the exact tag, then the base language, then the bundle's own locale. |
| `chapters` | `{ title, file, markdown }` per chapter. A chapter without a title in the manifest takes the text of its first `#` heading. |
| `config` | The default colour palette and the resource types 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. |
| `resources` | The resources, with the captions of the chosen locale. A size missing from the manifest is read from the file. |
| `fonts` | One entry per face: `{ family, weight, style, format, file, bytes }`. |
| `files` | Every file in the archive, keyed by its path. |
| `thumbnail`, `canvasScope` | The cover picture's path, and how the bundle asks to be viewed. |
| `warnings` | Non-fatal problems: an unsupported font file, a missing print master. |

A payload's **`fileId` is its path inside the bundle**. `resource.svg.fileId`, `resource.bitmap.fileId` and every `customFonts` variant's `fileId` can be looked up directly in `bundle.files`. `openBundle` throws when the bytes are not a zip, when there is no valid `preset.json` (at the root or under one top-level folder), or when a file the manifest names is missing.

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

```ts
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.

> **Runnable example: Postext · open a .postext bundle** — Open a .postext file with postext, lay the book out and render it to canvas and PDF. ([source](https://github.com/drnachio/postext/tree/main/docs/examples/open-bundle))

### Creating a bundle

`createBundle` writes a `.postext` file from a document: its chapters, configuration, resources and the payloads they reference.

```ts
import { createBundle } from 'postext';

const { bytes, manifest, warnings } = await createBundle({
  name: 'The Lantern',
  locale: 'en',
  chapters: [
    { markdown: '# Dusk\n\nIt is drawn in :ref{id="lantern"}.' },
    { title: 'Night', markdown: '# Night\n\n…' },
  ],
  config,
  resources: [{
    id: 'lantern', typeId: 'figure', kind: 'svg', caption: 'The lantern.',
    svg: { fileId: 'lantern.svg', width: 240, height: 150 },
    createdAt: 0, updatedAt: 0,
  }],
  files: { 'lantern.svg': svgMarkup, 'garamond-regular': fontBytes },
});
```

| Input | Meaning |
| --- | --- |
| `name`, `id`, `description`, `locale` | The manifest's metadata. `id` defaults to a slug of `name`. |
| `chapters` or `markdown` | The book, one `{ title?, markdown }` per chapter, or a single document. |
| `config` | The `PostextConfig`. Values equal to the defaults are left out of the manifest. |
| `resources` | The resources. A picture names its payload by `bitmap.fileId` / `svg.fileId` (and `svg.pdfFileId` for a print master). |
| `files` | The payloads by `fileId` (an object or a `Map`): the pictures the resources reference and the font files the `config.customFonts` variants reference. Values may be a `Uint8Array`, an `ArrayBuffer`, a `Blob` or a string (SVG markup). |
| `thumbnail` | `{ data, mime }`: a cover picture (PNG, JPEG, WebP, GIF or SVG). |
| `canvasScope` | `'book'` asks viewers to lay the whole book out as one canvas. |

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.

> **Runnable example: Postext · create a .postext bundle** — Write a .postext file with postext's createBundle, download it and open it again. ([source](https://github.com/drnachio/postext/tree/main/docs/examples/create-bundle))

### 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](/en/docs/skill), or design a book in the [Sandbox](/en/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.
