# Anthology with bylines

> Three essays on walking whose headings carry author, year and magazine: the opener prints all three; the verso running heads and the contents print the author.

- HTML version: https://postext.dev/en/cookbook/anthology-with-bylines
- Recipe Nº 030 · Book structure · Level 2 (Intermediate) · Outputs: Canvas
- Genres: Fiction, drama & literary prose
- Requires postext ≥ 1.4.1 · tested with 1.4.1 on 2026-09-26
- Pages: [1](https://postext.dev/cookbook/anthology-with-bylines/en/p01.webp?v=de61dbd9), [2](https://postext.dev/cookbook/anthology-with-bylines/en/p02.webp?v=de61dbd9), [3](https://postext.dev/cookbook/anthology-with-bylines/en/p03.webp?v=de61dbd9), [4](https://postext.dev/cookbook/anthology-with-bylines/en/p04.webp?v=de61dbd9), [5](https://postext.dev/cookbook/anthology-with-bylines/en/p05.webp?v=de61dbd9), [6](https://postext.dev/cookbook/anthology-with-bylines/en/p06.webp?v=de61dbd9), [7](https://postext.dev/cookbook/anthology-with-bylines/en/p07.webp?v=de61dbd9), [8](https://postext.dev/cookbook/anthology-with-bylines/en/p08.webp?v=de61dbd9), [9](https://postext.dev/cookbook/anthology-with-bylines/en/p09.webp?v=de61dbd9)
- Last updated: 2026-09-26
- Other languages: [es](https://postext.dev/es/cookbook/anthology-with-bylines.md)

## What you'll build

Nine pages of *Afoot*, a pocket anthology of three essays on walking, by Hazlitt (1822), Thoreau (1862) and Stevenson (1876). A dusk landscape drawn in code fills the cover. The contents give each essay a rust numeral, a title in Gloock, spaced leaders to its page and the author’s name in heather italic underneath. Each essay opens with its numeral and title, a byline in spaced capitals and a dateline with the magazine and year of its first printing. Versos carry the author’s name, rectos the essay’s title. Every essay’s heading carries its author, year and source as attributes, and the opener, the running heads and the contents print them from there, so a fourth essay needs no change to the configuration.

**This recipe answers:**

- How do I build a contents page that fills itself in, with leaders, page numbers and each essay’s author?
- How do I add an author line, a standfirst or a lead with a drop cap to an opener?
- How do I set running heads: book title on the left page, chapter title on the right, page number outside?

## The short answer

One set of heading attributes, read in three places.

```js
// script.js, lines 43–63
// Each essay's heading carries its credits, and every {attr.…} below reads them:
//   # Walking {author="Henry David Thoreau" year="1862" source="The Atlantic Monthly"}
// (a value cannot hold { or }, and one with " goes in single quotes; gotcha: attr-values)
// 1 · The opener: inside a heading's design, {attr.author} is that heading's attribute.
const byline = [
  text('byline', '{attr.author}', { ...label, fontSize: pt(8), letterSpacing: pt(1.6),
    color: col('heather') }, below('title', 5)),
  text('dateline', '{attr.source}, {attr.year}', { fontFamily: 'Spectral', italic: true,
    fontSize: pt(9.5), color: col('muted'), align: 'left' }, below('byline', 1.2)),
];
// 2 · The verso running head (head() is in the running-heads region): in the page header,
//     {attr.author} is the attribute of the essay the page belongs to.
const versoAuthor = head('verso-author', '{attr.author}', 'even',
  at('page', 'top-left', MARGIN.outer + HEAD.gap, HEAD.y));
// 3 · The contents: toc.subtitle prints the attribute it names as a line under each title.
//     'author' and italic are the defaults; attr is written out so it can become 'source'.
const authorLine = { enabled: true, attr: 'author', fontFamily: 'Spectral', fontSize: pt(10),
  color: col('heather') };
// Hooked up below: byline → the opener, versoAuthor → header, authorLine → toc.subtitle.
// A heading without author="…" gets an empty byline and running head and no line in the
// contents, and the build gives no warning: check every heading.
```

## Ingredients

**Teaches**

- [Table of contents](https://postext.dev/en/docs/configuration.md#table-of-contents): A :::toc built from the headings: dot leaders, page labels as printed, author lines, coloured part rows and clickable PDF entries.
- [Heading attributes](https://postext.dev/en/docs/document-format.md#heading-attributes): Key–value pairs on a heading (author, kicker, lead) that openers, running heads and the contents print through {attr.name}.
- [Running heads and folios](https://postext.dev/en/docs/configuration.md#headers--footers): Header and footer slots with placeholders ({pageNumber}, {chapterTitle}, {title}…) set per recto or verso, with long titles truncated or wrapped.

**Also uses**

- [Designed openers](https://postext.dev/en/docs/configuration.md#span-and-advanced-design)
- [Numbered headings](https://postext.dev/en/docs/configuration.md#per-level-overrides)
- [Heading styles](https://postext.dev/en/docs/configuration.md#heading-styles)
- [Unnumbered chapters](https://postext.dev/en/docs/configuration.md#heading-styles)
- [Covers, title pages and colophons](https://postext.dev/en/docs/configuration.md#heading-styles)
- [Pictures in page designs](https://postext.dev/en/docs/configuration.md#image-elements)
- [Anchoring design elements](https://postext.dev/en/docs/configuration.md#element-placement)
- [Heads by page role](https://postext.dev/en/docs/configuration.md#text-elements)
- [Mirrored margins](https://postext.dev/en/docs/configuration.md#mirrored-margins)
- [Paragraph styles](https://postext.dev/en/docs/configuration.md#paragraph-styles)
- [Pinned boxes and badges](https://postext.dev/en/docs/configuration.md#the-callout-container)
- [Callout boxes](https://postext.dev/en/docs/configuration.md#callout-styles)
- [Explicit vertical space](https://postext.dev/en/docs/document-format.md#space)
- [Document metadata](https://postext.dev/en/docs/document-format.md#frontmatter)
- [Semantic colour palette](https://postext.dev/en/docs/configuration.md#color-palette)
- [Figures and tables as resources](https://postext.dev/en/docs/document-format.md#resources)
- [Pages on a canvas](https://postext.dev/en/docs/configuration.md#rendering-a-page-to-a-bitmap)
- [Paper colour](https://postext.dev/en/docs/configuration.md#page)
- [Running heads per section](https://postext.dev/en/docs/configuration.md#heading-styles)

**Config at a glance**

- [`bodyText`](https://postext.dev/en/docs/configuration.md#body-text), [`calloutStyles`](https://postext.dev/en/docs/configuration.md#callout-styles), [`colorPalette`](https://postext.dev/en/docs/configuration.md#color-palette), [`footer`](https://postext.dev/en/docs/configuration.md#headers--footers), [`header`](https://postext.dev/en/docs/configuration.md#headers--footers), [`headingStyles`](https://postext.dev/en/docs/configuration.md#heading-styles), [`headings`](https://postext.dev/en/docs/configuration.md#headings), [`layout`](https://postext.dev/en/docs/configuration.md#layout), [`page`](https://postext.dev/en/docs/configuration.md#page), [`paragraphStyles`](https://postext.dev/en/docs/configuration.md#paragraph-styles), [`toc`](https://postext.dev/en/docs/configuration.md#table-of-contents)

**APIs**

- [`buildDocument`](https://postext.dev/en/docs/configuration.md#building-a-document), [`clearMeasurementCache`](https://postext.dev/en/docs/configuration.md#measurement-cache), [`registerResourceImage`](https://postext.dev/en/docs/architecture.md#api-surface), [`renderPageToCanvas`](https://postext.dev/en/docs/configuration.md#rendering-a-page-to-a-bitmap)

**Typefaces**

- Spectral (OFL-1.1), Gloock (OFL-1.1), Hanken Grotesk (OFL-1.1)

## Method

### 1 · Number the essays and sink their text

```js
// script.js, lines 67–85
const opener = { enabled: true,
  // The hairline ends 7 mm above the foot of this reserve, so the text starts on the same grid
  // line under every opener whose title fits on one line (SINK = 14 holds a two-line title).
  minHeight: pt(SINK * LEAD),
  slot: { elements: [
    // {number} prints numberingTemplate '{1:I}': I, II, III. {numberRoman} would print
    // nothing here: it is filled on part pages only (gotcha: heading-number-placeholders).
    text('number', '{number}', { ...gloock, fontSize: pt(34), lineHeight: 1, color: col('rust') },
      at('container', 'top-left', 0, 8)),
    text('title', '{titleText}', { ...gloock, fontSize: pt(26), lineHeight: 1.08,
      color: col('ink') }, below('number', 3, { width: 'fill' })),
    ...byline,
    { kind: 'rule', id: 'rule', thickness: pt(0.5), color: col('rule'),
      placement: below('dateline', 6) }, // a horizontal rule runs to the column's edge
  ] } };
const essays = { level: 1, numberingTemplate: '{1:I}', advancedDesign: opener,
  marginBottom: pt(0), // the heading's default margin would add to minHeight
  // Restated (gotcha: headings-drop-h1-break); the cover and contents styles inherit it too.
  breakBefore: { enabled: true, parity: 'any' } }; // 'any': each piece opens on the next page
```

The level’s `numberingTemplate: '{1:I}'` numbers the essays I, II and III, and `{number}` prints the numeral above the title ([span and advanced design](/en/docs/configuration#span-and-advanced-design)). `minHeight` reserves 12 lines of 14 pt and the hairline ends 7 mm above that, so under a one-line title the text starts 79 mm from the top edge on every opener. A second line of 26 pt title adds 9.9 mm and pushes the text one grid line down; with `SINK = 14`, one-line and two-line titles start their text on the same line. `marginBottom: pt(0)` keeps the heading’s default margin from adding to the reserve.

### 2 · Let the contents fill themselves in

```js
// script.js, lines 89–101
const ENTRY = 15; // pt: the essay titles in the contents
const MIDDLE = 0.3125; // em: how far Chrome's textBaseline 'middle' sits above Gloock's baseline
const contents = { // passed to the config as `toc`
  // 1.4.1 centres an entry's number 0.3 × the entry size above its baseline (gotcha:
  // toc-number-baseline): at 0.3 × 15 ÷ 0.3125 = 14.4 pt a Gloock numeral stands on it.
  levels: [{ level: 1, fontFamily: 'Gloock', fontSize: pt(ENTRY), color: col('ink'),
    numberFontSize: pt((0.3 * ENTRY) / MIDDLE), numberFontWeight: 400, // Gloock has one weight
    numberColor: col('rust'), numberWidth: mm(8), numberGap: mm(3), marginBottom: pt(LEAD) }],
  pageNumber: { fontFamily: 'Hanken Grotesk', fontSize: pt(8.5), fontWeight: 600,
    color: col('muted'), width: mm(6) }, // the leader dots take this face and colour too
  leader: { char: '. ', gap: mm(2) }, // spaced dots, right-aligned so they line up
  subtitle: authorLine,
};
```

`:::toc` in the Markdown prints one entry per listed heading: number, title, leaders, page label and, through `subtitle`, the author. `buildDocument` lays the book out again until the page labels stop changing, so a renamed or lengthened essay updates the contents ([table of contents](/en/docs/configuration#table-of-contents)). Postext 1.4.1 puts the middle of an entry’s number 0.3 × the entry size above the baseline, and Chrome puts Gloock’s middle 0.3125 em above its baseline, so a numeral of 0.3 × 15 ÷ 0.3125 = 14.4 pt stands on the 15 pt title’s baseline; a 12 pt one rides 0.3 mm high. A book with parts also gets a row for each `:::part`, drawn by `toc.parts.design`, as in [Parts in colour](https://postext.dev/en/cookbook/parts-in-colour.md).

### 3 · Author on the verso, essay on the recto

```js
// script.js, lines 105–121
// Each head: the label face, the pages of its parity, never an opener (pages: 'body'), where
// the byline names the author. A function declaration, so the answer above can call it.
function head(id, content, parity, placement, look = {}) {
  return { ...text(id, content, { ...label, fontSize: pt(7.5), letterSpacing: pt(1.3),
    color: col('muted'), ...look }, placement), parity, pages: 'body' };
}
const folio = { color: col('ink') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', at('page', 'top-left', MARGIN.outer, HEAD.y), folio),
  versoAuthor,
  head('recto-title', '{chapterTitle}', 'odd',
    at('page', 'top-right', -(MARGIN.outer + HEAD.gap), HEAD.y), { align: 'right' }),
  head('recto-folio', '{pageNumber}', 'odd', at('page', 'top-right', -MARGIN.outer, HEAD.y),
    { ...folio, align: 'right' }),
] };
const footer = { elements: [{ ...head('drop-folio', '{pageNumber}', 'all', // an opener's folio
  at('container', 'top', 0, 9), { ...folio, align: 'center' }), pages: 'opener' }] };
```

Each head is anchored to the page, 11 mm from the top edge, and `parity` picks the side: the folio lines up with the outer edge of the text and the name or title sits 7 mm further in. The `pages: 'body'` in `head()` keeps the heads off the openers, where the byline already names the author, and the footer’s `pages: 'opener'` prints a drop folio there instead ([text elements](/en/docs/configuration#text-elements)). `{chapterTitle}` prints the essay’s title without its numeral.

### 4 · Keep the cover and the contents out of the count

```js
// script.js, lines 125–148
// Unnumbered, so the first essay is I; unlisted; and with no running heads or folio. They
// inherit the level's page break, 'any' (gotcha: style-inherits-break).
const bare = { numbered: false, toc: false, header: { elements: [] }, footer: { elements: [] } };
const cover = { enabled: true, slot: { elements: [
  { kind: 'image', id: 'art', resourceId: 'cover',
    placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } } },
  text('title', '{titleText}', { ...gloock, fontSize: pt(80), lineHeight: 1, color: col('ink') },
    at('page', 'top-left', MARGIN.inner, 22)), // page 1 is a recto: the inner margin is left
  text('subtitle', '{subtitle}', { fontFamily: 'Spectral', italic: true, fontSize: pt(14),
    color: col('ink'), align: 'left' }, below('title', 1)),
  text('authors', '{attr.authors}', { ...label, fontSize: pt(8.5), letterSpacing: pt(2),
    color: col('heather') }, below('subtitle', 5)),
  text('imprint', '{attr.imprint}', { ...label, fontSize: pt(7.5), letterSpacing: pt(1.8),
    color: col('paper') }, at('page', 'bottom-left', MARGIN.inner, -12)),
] } };
// span: 'page' in a one-column book: a design kept in the column is clipped at the column's top
// and bottom edges, which would leave bands of paper above and below the dusk.
const coverStyle = { id: 'cover', ...bare, span: 'page', advancedDesign: cover };
const contentsOpener = { enabled: true, slot: { elements: [ // {title}, {subtitle}: frontmatter
  text('kicker', '{title} · {subtitle}', { ...label, fontSize: pt(8), letterSpacing: pt(1.6),
    color: col('heather') }, at('container', 'top-left', 0, 8)),
  text('title', '{titleText}', { ...gloock, fontSize: pt(26), color: col('ink') },
    below('kicker', 2.5)),
] } };
```

With `numbered: false` the cover and the contents do not advance the counter, so Hazlitt is I, and `toc: false` keeps them off the list ([heading styles](/en/docs/configuration#heading-styles)). Both styles inherit the level’s `breakBefore`, parity `'any'`, which is why the contents follows the cover on page 2 without a `:::pagebreak`. The cover style spans the page in a one-column book because a design kept in the column is clipped at the column’s top and bottom edges, which would leave 20 mm of paper above the landscape.

### 5 · Write every colour out with its palette link

```js
// script.js, lines 13–27
const palette = {
  ink: '#241f26', // text: a plum-tinted near-black
  paper: '#f7f2e8', // the page
  heather: '#6b4468', // the bylines and the authors in the contents
  rust: '#a4502a', // the essay numbers, and the cover's sun
  rule: '#d5cabd', // hairlines
  muted: '#6d6570', // running heads, datelines, page numbers in the contents
};
// A design element paints the hex written beside its paletteId (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to 'main-color': point it at the ink, so nothing prints blue.
  { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];
```

In 1.4.1 `colorPalette` reaches the text styles but not the design elements, so `col()` writes each colour’s hex beside its `paletteId`: the bylines, the running heads and the cover paint that hex. Pointing `main-color` at the ink covers every default the configuration does not restate, which would otherwise print in the engine’s blue, `#295AA3`.

## The whole recipe

One file, composed from the recipe's folder with the sample text and the Cookbook's shared kit inlined; it builds its own page. To run it, put it in a `<script type="module">` on an empty page, or paste it into the JS panel of a new CodePen (as a module). It imports postext from esm.sh, so there is nothing to install or build.

- Source folder: https://github.com/drnachio/postext/tree/main/cookbook/anthology-with-bylines

### script.js

```js
// ═══ Postext Cookbook · Nº 030 · Anthology with bylines ═══════════════════════════════
// https://postext.dev/en/cookbook/anthology-with-bylines
// Code: MIT · Text: Hazlitt, Thoreau, Stevenson (public domain) · Cover: generated (CC BY 4.0)
// Fonts: Spectral, Gloock, Hanken Grotesk (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage }
  from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'anthology-with-bylines';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: heather for the credits, rust for the numbers, plum ink on warm paper
const palette = {
  ink: '#241f26', // text: a plum-tinted near-black
  paper: '#f7f2e8', // the page
  heather: '#6b4468', // the bylines and the authors in the contents
  rust: '#a4502a', // the essay numbers, and the cover's sun
  rule: '#d5cabd', // hairlines
  muted: '#6d6570', // running heads, datelines, page numbers in the contents
};
// A design element paints the hex written beside its paletteId (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to 'main-color': point it at the ink, so nothing prints blue.
  { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];
// #endregion
const TRIM = { width: 135, height: 180 }; // a pocket book, 3 : 4
const MARGIN = { top: 20, bottom: 22, inner: 19, outer: 15 }; // mirrored
const LEAD = 14; // body leading in pt: the baseline grid
const SINK = 12; // grid lines an essay opener reserves above its first line of text
const HEAD = { y: 11, gap: 7 }; // running heads: mm from the top edge, folio to words
const label = { fontFamily: 'Hanken Grotesk', fontWeight: 600, textTransform: 'uppercase',
  align: 'left' }; // design text is centred by default
// One weight, no italic; 'wrap' breaks a long title (gotcha: overflow-ellipsis-default).
const gloock = { fontFamily: 'Gloock', overflow: 'wrap', align: 'left' };
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const below = (id, y, size) => ({ ...at(`#${id}`, 'below', 0, y), ...(size && { size }) });
const text = (id, content, look, placement) => ({ kind: 'text', id, content, ...look, placement });

// #region answer: one set of heading attributes, read in three places
// Each essay's heading carries its credits, and every {attr.…} below reads them:
//   # Walking {author="Henry David Thoreau" year="1862" source="The Atlantic Monthly"}
// (a value cannot hold { or }, and one with " goes in single quotes; gotcha: attr-values)
// 1 · The opener: inside a heading's design, {attr.author} is that heading's attribute.
const byline = [
  text('byline', '{attr.author}', { ...label, fontSize: pt(8), letterSpacing: pt(1.6),
    color: col('heather') }, below('title', 5)),
  text('dateline', '{attr.source}, {attr.year}', { fontFamily: 'Spectral', italic: true,
    fontSize: pt(9.5), color: col('muted'), align: 'left' }, below('byline', 1.2)),
];
// 2 · The verso running head (head() is in the running-heads region): in the page header,
//     {attr.author} is the attribute of the essay the page belongs to.
const versoAuthor = head('verso-author', '{attr.author}', 'even',
  at('page', 'top-left', MARGIN.outer + HEAD.gap, HEAD.y));
// 3 · The contents: toc.subtitle prints the attribute it names as a line under each title.
//     'author' and italic are the defaults; attr is written out so it can become 'source'.
const authorLine = { enabled: true, attr: 'author', fontFamily: 'Spectral', fontSize: pt(10),
  color: col('heather') };
// Hooked up below: byline → the opener, versoAuthor → header, authorLine → toc.subtitle.
// A heading without author="…" gets an empty byline and running head and no line in the
// contents, and the build gives no warning: check every heading.
// #endregion

// #region opener: the essay's number and title, then the byline, over a sunk first line
const opener = { enabled: true,
  // The hairline ends 7 mm above the foot of this reserve, so the text starts on the same grid
  // line under every opener whose title fits on one line (SINK = 14 holds a two-line title).
  minHeight: pt(SINK * LEAD),
  slot: { elements: [
    // {number} prints numberingTemplate '{1:I}': I, II, III. {numberRoman} would print
    // nothing here: it is filled on part pages only (gotcha: heading-number-placeholders).
    text('number', '{number}', { ...gloock, fontSize: pt(34), lineHeight: 1, color: col('rust') },
      at('container', 'top-left', 0, 8)),
    text('title', '{titleText}', { ...gloock, fontSize: pt(26), lineHeight: 1.08,
      color: col('ink') }, below('number', 3, { width: 'fill' })),
    ...byline,
    { kind: 'rule', id: 'rule', thickness: pt(0.5), color: col('rule'),
      placement: below('dateline', 6) }, // a horizontal rule runs to the column's edge
  ] } };
const essays = { level: 1, numberingTemplate: '{1:I}', advancedDesign: opener,
  marginBottom: pt(0), // the heading's default margin would add to minHeight
  // Restated (gotcha: headings-drop-h1-break); the cover and contents styles inherit it too.
  breakBefore: { enabled: true, parity: 'any' } }; // 'any': each piece opens on the next page
// #endregion

// #region contents: the essays' numbers, titles, leaders and page labels, from the headings
const ENTRY = 15; // pt: the essay titles in the contents
const MIDDLE = 0.3125; // em: how far Chrome's textBaseline 'middle' sits above Gloock's baseline
const contents = { // passed to the config as `toc`
  // 1.4.1 centres an entry's number 0.3 × the entry size above its baseline (gotcha:
  // toc-number-baseline): at 0.3 × 15 ÷ 0.3125 = 14.4 pt a Gloock numeral stands on it.
  levels: [{ level: 1, fontFamily: 'Gloock', fontSize: pt(ENTRY), color: col('ink'),
    numberFontSize: pt((0.3 * ENTRY) / MIDDLE), numberFontWeight: 400, // Gloock has one weight
    numberColor: col('rust'), numberWidth: mm(8), numberGap: mm(3), marginBottom: pt(LEAD) }],
  pageNumber: { fontFamily: 'Hanken Grotesk', fontSize: pt(8.5), fontWeight: 600,
    color: col('muted'), width: mm(6) }, // the leader dots take this face and colour too
  leader: { char: '. ', gap: mm(2) }, // spaced dots, right-aligned so they line up
  subtitle: authorLine,
};
// #endregion

// #region running-heads: author on the verso, essay title on the recto, folios outside
// Each head: the label face, the pages of its parity, never an opener (pages: 'body'), where
// the byline names the author. A function declaration, so the answer above can call it.
function head(id, content, parity, placement, look = {}) {
  return { ...text(id, content, { ...label, fontSize: pt(7.5), letterSpacing: pt(1.3),
    color: col('muted'), ...look }, placement), parity, pages: 'body' };
}
const folio = { color: col('ink') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', at('page', 'top-left', MARGIN.outer, HEAD.y), folio),
  versoAuthor,
  head('recto-title', '{chapterTitle}', 'odd',
    at('page', 'top-right', -(MARGIN.outer + HEAD.gap), HEAD.y), { align: 'right' }),
  head('recto-folio', '{pageNumber}', 'odd', at('page', 'top-right', -MARGIN.outer, HEAD.y),
    { ...folio, align: 'right' }),
] };
const footer = { elements: [{ ...head('drop-folio', '{pageNumber}', 'all', // an opener's folio
  at('container', 'top', 0, 9), { ...folio, align: 'center' }), pages: 'opener' }] };
// #endregion

// #region front: the cover and the contents page, two headings kept out of the count
// Unnumbered, so the first essay is I; unlisted; and with no running heads or folio. They
// inherit the level's page break, 'any' (gotcha: style-inherits-break).
const bare = { numbered: false, toc: false, header: { elements: [] }, footer: { elements: [] } };
const cover = { enabled: true, slot: { elements: [
  { kind: 'image', id: 'art', resourceId: 'cover',
    placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } } },
  text('title', '{titleText}', { ...gloock, fontSize: pt(80), lineHeight: 1, color: col('ink') },
    at('page', 'top-left', MARGIN.inner, 22)), // page 1 is a recto: the inner margin is left
  text('subtitle', '{subtitle}', { fontFamily: 'Spectral', italic: true, fontSize: pt(14),
    color: col('ink'), align: 'left' }, below('title', 1)),
  text('authors', '{attr.authors}', { ...label, fontSize: pt(8.5), letterSpacing: pt(2),
    color: col('heather') }, below('subtitle', 5)),
  text('imprint', '{attr.imprint}', { ...label, fontSize: pt(7.5), letterSpacing: pt(1.8),
    color: col('paper') }, at('page', 'bottom-left', MARGIN.inner, -12)),
] } };
// span: 'page' in a one-column book: a design kept in the column is clipped at the column's top
// and bottom edges, which would leave bands of paper above and below the dusk.
const coverStyle = { id: 'cover', ...bare, span: 'page', advancedDesign: cover };
const contentsOpener = { enabled: true, slot: { elements: [ // {title}, {subtitle}: frontmatter
  text('kicker', '{title} · {subtitle}', { ...label, fontSize: pt(8), letterSpacing: pt(1.6),
    color: col('heather') }, at('container', 'top-left', 0, 8)),
  text('title', '{titleText}', { ...gloock, fontSize: pt(26), color: col('ink') },
    below('kicker', 2.5)),
] } };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  colorPalette,
  page: { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
    backgroundColor: col('paper'), margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom),
      left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true } }, // left = inner
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: 'Spectral', fontSize: pt(10), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    firstLineIndent: mm(4), indentAfterHeading: false,
    minWordSpacing: 0.7, maxWordSpacing: 1.6, // tighter than the 0.6–2 defaults
    maxRuntTracking: 0 }, // tracking 1.4.1 never paints (gotcha: runt-tracking-unpainted)
  // The hidden heading line is still measured, in this face; otherwise the build needs Open Sans.
  headings: { fontFamily: 'Gloock', fontWeight: 400, levels: [essays] },
  headingStyles: [coverStyle, { id: 'contents', ...bare, advancedDesign: contentsOpener }],
  toc: contents,
  // Quoted verse, one paragraph per line (a paragraph keeps no line breaks, and 1.4.1 prints a
  // Markdown blockquote in a fixed #666666 grey); 'runon' resumes the sentence after it.
  paragraphStyles: [{ id: 'verse', firstLineIndent: mm(8), textAlign: 'left' },
    { id: 'runon', firstLineIndent: pt(0) },
    // In the note, under a :::space: a style's margins do not count inside a box (gotcha:
    // box-paragraph-margins). It takes the note body's indent, 0.
    { id: 'colophon', fontSize: pt(7.5), lineHeight: pt(10.5), color: col('muted') }],
  calloutStyles: [{ id: 'note', placement: 'fixed', backgroundEnabled: false, // the page foot
    stripe: { enabled: true, side: 'top', width: pt(0.5), color: col('rule') },
    padding: { top: mm(3), right: pt(0), bottom: pt(0), left: pt(0) },
    titleStyle: { ...label, fontSize: pt(7.5), letterSpacing: pt(1.5), color: col('heather') },
    body: { fontSize: pt(9), lineHeight: pt(12.5), firstLineIndent: pt(0) } }],
  header, footer,
});

// #region art: the cover, drawn in code and seeded: the same dusk on every run
let seed = 1822; // Mulberry32, a tiny seeded PRNG: never Math.random() in a recipe
const rand = () => {
  let r = Math.imul((seed = (seed + 0x6d2b79f5) | 0) ^ (seed >>> 15), 1 | seed);
  r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
  return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
const n = (v) => v.toFixed(2);
const channel = (hex, i) => parseInt(hex.slice(i, i + 2), 16);
const mix = (a, b, k) => `#${[1, 3, 5].map((i) => Math.round(channel(a, i) * (1 - k)
  + channel(b, i) * k).toString(16).padStart(2, '0')).join('')}`; // a towards b by k
const SKY = '#f2d6ae'; // apricot dusk
const RISE = 10; // mm the whole landscape is lifted, so the card's crop takes in more of it
const W = TRIM.width;
const H = TRIM.height;

// A ridge line: a few slow waves with seeded phases, sampled every millimetre.
function ridge(base, waves) {
  const phases = waves.map(() => rand() * Math.PI * 2);
  return (x) => base - RISE
    + waves.reduce((y, [amp, len], i) => y + amp * Math.sin(x / len + phases[i]), 0);
}
const fillUnder = (f, colour) => {
  let d = `M-1 ${n(f(-1))}`;
  for (let x = 0; x <= W + 1; x += 1) d += ` L${x} ${n(f(x))}`;
  return `<path d="${d} L${W + 1} ${H + 1} L-1 ${H + 1} Z" fill="${colour}"/>`;
};

// The footpath: a ribbon from the foot of the page to a fold of the near hill, narrowing
// with distance. s runs from 0 at the far end to 1 at the foot of the page.
const [NEAR, FAR] = [[98, H + 2], [60, 128 - RISE]];
const pathAt = (s) => [ // x, y and width in mm: the bends and the width shrink with distance
  FAR[0] + (NEAR[0] - FAR[0]) * s + 13 * s * Math.sin(Math.PI * (1 - s) * 2.1),
  FAR[1] + (NEAR[1] - FAR[1]) * s ** 1.5, 0.5 + 15 * s ** 1.7];
function footpath(from = 0) { // the part nearer than `from`
  const left = [];
  const right = [];
  for (let i = 0; i <= 80; i++) {
    const s = from + (1 - from) * (i / 80);
    const [x, y, w] = pathAt(s);
    left.push(`${n(x - w / 2)} ${n(y)}`);
    right.unshift(`${n(x + w / 2)} ${n(y)}`);
  }
  const fill = mix(SKY, palette.paper, 0.35);
  return `<path d="M${left.join(' L')} L${right.join(' L')} Z" fill="${fill}"/>`;
}

function coverSvg() {
  const layers = [ // far to near: base line, [amplitude, wavelength] waves, colour
    [98, [[3, 14], [2, 6]], mix(palette.heather, SKY, 0.72)],
    [108, [[4, 18], [1.5, 7]], mix(palette.heather, SKY, 0.55)],
    [119, [[5, 22], [2, 9]], mix(palette.heather, SKY, 0.36)],
    [133, [[6, 26], [2, 11]], mix(palette.heather, palette.ink, 0.12)],
    [152, [[7, 30], [2.5, 12]], mix(palette.heather, palette.ink, 0.62)],
  ].map(([base, waves, colour]) => ({ f: ridge(base, waves), colour }));
  const sky = '<linearGradient id="dusk" x1="0" y1="0" x2="0" y2="1">' // paler at the ridge
    + `<stop offset="0" stop-color="${mix(SKY, palette.rust, 0.1)}"/>`
    + `<stop offset="0.55" stop-color="${mix(SKY, palette.paper, 0.5)}"/></linearGradient>`
    + `<rect width="${W}" height="${H}" fill="url(#dusk)"/>`;
  const sun = `<circle cx="101" cy="${96 - RISE}" r="13" fill="${mix(palette.rust, SKY, 0.12)}"/>`;
  const birds = [[113, 66, 1.6], [119, 62, 1.2], [108, 71, 1]].map(([x, y, w]) => '<path '
    + `d="M${n(x - w)} ${n(y - 0.4)} Q${n(x - w / 2)} ${n(y - 1)} ${x} ${y} `
    + `Q${n(x + w / 2)} ${n(y - 1)} ${n(x + w)} ${n(y - 0.4)}" fill="none" `
    + `stroke="${palette.ink}" stroke-width="0.35" stroke-linecap="round"/>`).join('');
  const [far1, far2, mid, near, fore] = layers;
  // Three trees on the middle ridge, and hedgerows across the near hill as rows of shrubs.
  const dark = mix(palette.heather, palette.ink, 0.45);
  const copse = [[106, 2.4, 3.2], [111.5, 1.8, 2.6], [116, 2.9, 3.6]].map(([x, r, trunk]) => {
    const foot = mid.f(x) + 0.6;
    return `<path d="M${x} ${n(foot)} V${n(foot - trunk)}" stroke="${dark}" stroke-width="0.7"/>`
      + `<ellipse cx="${x}" cy="${n(foot - trunk - r * 0.8)}" rx="${n(r * 0.85)}" ry="${n(r)}" `
      + `fill="${dark}"/>`;
  }).join('');
  let hedges = '';
  for (const [dy, x0, x1, s] of [[5, -1, 52, 0.8], [11, 70, W + 1, 1], [17, -1, 40, 1.25]]) {
    for (let x = x0; x < x1; x += (2 + rand() * 0.8) * s) { // s: nearer rows, bigger shrubs
      if (rand() < 0.1) continue; // a gap in the hedge
      const y = near.f(x) + dy + Math.sin(x / 9) * 1.5 + rand() * 0.4 * s;
      hedges += `<circle cx="${n(x)}" cy="${n(y)}" r="${n((0.7 + rand() * 0.4) * s)}" `
        + `fill="${mix(palette.heather, palette.ink, 0.6)}"/>`;
    }
  }
  // The near stretch of the path starts just behind the crest it comes over.
  let crest = 0;
  while (crest < 1 && pathAt(crest)[1] < fore.f(pathAt(crest)[0]) - 1) crest += 0.005;
  const body = sky + sun + birds + fillUnder(far1.f, far1.colour) + fillUnder(far2.f, far2.colour)
    + fillUnder(mid.f, mid.colour) + copse + fillUnder(near.f, near.colour) + hedges + footpath()
    + fillUnder(fore.f, fore.colour) + footpath(crest);
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W * 10}" height="${H * 10}" `
    + `viewBox="0 0 ${W} ${H}">${body}</svg>`;
}
// The cover's resource: the design's image element names it by id, and loadSvg() below
// registers the drawing under its fileId.
const resources = [{ id: 'cover', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'cover.svg', width: TRIM.width * 10, height: TRIM.height * 10 },
  altText: 'Hills at dusk in five layers, from dusty rose to deep heather, a low rust sun, '
    + 'three trees on a ridge, hedgerows across the near hill and a pale footpath winding up '
    + 'from the foot of the page.' }];
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Afoot"
subtitle: "Three essays on walking"
---

# Afoot {style="cover" authors="Hazlitt · Thoreau · Stevenson" imprint="The Fieldpath Library"}

# Contents {style="contents"}

:::toc

:::callout{type="note" title="A note on the texts"}
Each of these essays was first printed in a magazine, two in London and the third in Boston. Hazlitt’s appeared in *The New Monthly Magazine* in January 1822. Thoreau worked his up from a lecture he first gave in 1851, and *The Atlantic Monthly* printed it in June 1862, a month after his death. Stevenson’s came out in *The Cornhill Magazine* in 1876, and in its second paragraph he quotes Hazlitt by name. Each essay starts at its first line and stops well short of its last, in its author’s own spelling; […] marks a cut within a paragraph.

:::space{lines=1}

:::paragraphs{style="colophon"}
Set in Spectral, Gloock and Hanken Grotesk (SIL Open Font License). The essays are in the public domain, from Project Gutenberg eBooks #3020, #1022 and #386; this note and the cover are CC BY 4.0.
:::
:::

# On Going a Journey {author="William Hazlitt" year="1822" source="The New Monthly Magazine"}

One of the pleasantest things in the world is going a journey; but I like to go by myself. I can enjoy society in a room; but out of doors, nature is company enough for me. I am then never less alone than when alone.

:::paragraphs{style="verse"}
*The fields his study, nature was his book.*
:::

I cannot see the wit of walking and talking at the same time. When I am in the country I wish to vegetate like the country. I am not for criticising hedge-rows and black cattle. I go out of town in order to forget the town and all that is in it. There are those who for this purpose go to watering-places, and carry the metropolis with them. I like more elbow-room and fewer encumbrances. I like solitude, when I give myself up to it, for the sake of solitude; nor do I ask for

:::paragraphs{style="verse"}
*A friend in my retreat,*

*Whom I may whisper solitude is sweet.*
:::

The soul of a journey is liberty, perfect liberty, to think, feel, do, just as one pleases. We go a journey chiefly to be free of all impediments and of all inconveniences; to leave ourselves behind much more to get rid of others. It is because I want a little breathing-space to muse on indifferent matters, where Contemplation

:::paragraphs{style="verse"}
*May plume her feathers and let grow her wings,*

*That in the various bustle of resort*

*Were all too ruffled, and sometimes impair’d,*
:::

:::paragraphs{style="runon"}
that I absent myself from the town for a while, without feeling at a loss the moment I am left by myself. Instead of a friend in a postchaise or in a Tilbury, to exchange good things with, and vary the same stale topics over again, for once let me have a truce with impertinence. Give me the clear blue sky over my head, and the green turf beneath my feet, a winding road before me, and a three hours’ march to dinner—and then to thinking! It is hard if I cannot start some game on these lone heaths. I laugh, I run, I leap, I sing for joy. […]
:::

# Walking {author="Henry David Thoreau" year="1862" source="The Atlantic Monthly"}

I wish to speak a word for Nature, for absolute Freedom and Wildness, as contrasted with a freedom and culture merely civil,—to regard man as an inhabitant, or a part and parcel of Nature, rather than a member of society. I wish to make an extreme statement, if so I may make an emphatic one, for there are enough champions of civilization: the minister and the school committee and every one of you will take care of that.

I have met with but one or two persons in the course of my life who understood the art of Walking, that is, of taking walks—who had a genius, so to speak, for sauntering, which word is beautifully derived “from idle people who roved about the country, in the Middle Ages, and asked charity, under pretense of going à la Sainte Terre,” to the Holy Land, till the children exclaimed, “There goes a Sainte-Terrer,” a Saunterer, a Holy-Lander. They who never go to the Holy Land in their walks, as they pretend, are indeed mere idlers and vagabonds; but they who do go there are saunterers in the good sense, such as I mean. Some, however, would derive the word from sans terre without land or a home, which, therefore, in the good sense, will mean, having no particular home, but equally at home everywhere. For this is the secret of successful sauntering. He who sits still in a house all the time may be the greatest vagrant of all; but the saunterer, in the good sense, is no more vagrant than the meandering river, which is all the while sedulously seeking the shortest course to the sea. But I prefer the first, which, indeed, is the most probable derivation. For every walk is a sort of crusade, preached by some Peter the Hermit in us, to go forth and reconquer this Holy Land from the hands of the Infidels.

It is true, we are but faint-hearted crusaders, even the walkers, nowadays, who undertake no persevering, never-ending enterprises. Our expeditions are but tours, and come round again at evening to the old hearth-side from which we set out. Half the walk is but retracing our steps. We should go forth on the shortest walk, perchance, in the spirit of undying adventure, never to return,—prepared to send back our embalmed hearts only as relics to our desolate kingdoms. If you are ready to leave father and mother, and brother and sister, and wife and child and friends, and never see them again,—if you have paid your debts, and made your will, and settled all your affairs, and are a free man; then you are ready for a walk.

To come down to my own experience, my companion and I, for I sometimes have a companion, take pleasure in fancying ourselves knights of a new, or rather an old, order—not Equestrians or Chevaliers, not Ritters or Riders, but Walkers, a still more ancient and honorable class, I trust. The chivalric and heroic spirit which once belonged to the Rider seems now to reside in, or perchance to have subsided into, the Walker—not the Knight, but Walker Errant. He is a sort of fourth estate, outside of Church and State and People.

We have felt that we almost alone hereabouts practiced this noble art; though, to tell the truth, at least if their own assertions are to be received, most of my townsmen would fain walk sometimes, as I do, but they cannot. No wealth can buy the requisite leisure, freedom, and independence which are the capital in this profession. It comes only by the grace of God. It requires a direct dispensation from Heaven to become a walker. You must be born into the family of the Walkers. Ambulator nascitur, non fit. Some of my townsmen, it is true, can remember and have described to me some walks which they took ten years ago, in which they were so blessed as to lose themselves for half an hour in the woods; but I know very well that they have confined themselves to the highway ever since, whatever pretensions they may make to belong to this select class. No doubt they were elevated for a moment as by the reminiscence of a previous state of existence, when even they were foresters and outlaws.

# Walking Tours {author="Robert Louis Stevenson" year="1876" source="The Cornhill Magazine"}

It must not be imagined that a walking tour, as some would have us fancy, is merely a better or worse way of seeing the country. There are many ways of seeing landscape quite as good; and none more vivid, in spite of canting dilettantes, than from a railway train. But landscape on a walking tour is quite accessory. He who is indeed of the brotherhood does not voyage in quest of the picturesque, but of certain jolly humours—of the hope and spirit with which the march begins at morning, and the peace and spiritual repletion of the evening’s rest. He cannot tell whether he puts his knapsack on, or takes it off, with more delight. […]

Now, to be properly enjoyed, a walking tour should be gone upon alone. If you go in a company, or even in pairs, it is no longer a walking tour in anything but name; it is something else and more in the nature of a picnic. A walking tour should be gone upon alone, because freedom is of the essence; because you should be able to stop and go on, and follow this way or that, as the freak takes you; and because you must have your own pace, and neither trot alongside a champion walker, nor mince in time with a girl. And then you must be open to all impressions and let your thoughts take colour from what you see. You should be as a pipe for any wind to play upon. “I cannot see the wit,” says Hazlitt, “of walking and talking at the same time. When I am in the country I wish to vegetate like the country,”—which is the gist of all that can be said upon the matter. There should be no cackle of voices at your elbow, to jar on the meditative silence of the morning. And so long as a man is reasoning he cannot surrender himself to that fine intoxication that comes of much motion in the open air, that begins in a sort of dazzle and sluggishness of the brain, and ends in a peace that passes comprehension.
`; // content.<lang>.md, inlined by the Cookbook

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // text, display and label faces (gotcha: fonts-first)
  Spectral: ['400', '400i'], Gloock: ['400'], 'Hanken Grotesk': ['600'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadSvg('cover.svg', coverSvg());
await loadFonts(FONTS, markdown);
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: t({ en: 'Anthology with bylines', es: 'Antología con firmas de autor' }) });

// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ─────

// ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ─────────
function mm(value) { return { value, unit: 'mm' }; }
function pt(value) { return { value, unit: 'pt' }; }
function em(value) { return { value, unit: 'em' }; }
/** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */
function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; }
/** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */
function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; }

// ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ────────
// Postext measures text with the faces the browser has loaded, and caches the
// widths, so every face must be ready before the first build. Faces come from
// Fontsource: the same static files the PDF embeds, so screen and PDF agree.

/** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
  };
  const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin'];
  const jobs = [];
  let added = 0;
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const meta = optional ? await fontsourceMeta(family) : null;
    for (const spec of new Set(specs)) {
      const weight = parseInt(spec, 10);
      const style = spec.endsWith('i') ? 'italic' : 'normal';
      if (hasFace(family, weight, style)) continue;
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`;
        const face = new FontFace(family, `url(${url}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: ranges[subset] });
        jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return added;
}

/** Runs `build` (a buildDocument or buildBundle call) and checks the faces
 *  the pages use. A regular face missing from FONTS is loaded with a warning;
 *  bold and italic variants are loaded when the family ships them. Then the
 *  measurement caches are cleared and the build runs again. */
async function buildWithFonts(build, text = '') {
  const tried = new Set();
  for (let round = 0; round < 3; round++) {
    kitStatus('Laying out…');
    await new Promise(requestAnimationFrame);          // let the status paint first
    const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; });
    const wanted = { base: {}, variants: {} };
    for (const { font, base } of [result].flat().flatMap(fontStringsOf)) {
      const { family, weight, style } = parseFont(font);
      const key = `${family}|${weight}|${style}`;
      if (tried.has(key) || hasFace(family, weight, style)) continue;
      tried.add(key);
      (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`);
    }
    if (Object.keys(wanted.base).length) {
      console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`);
    }
    const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true });
    if (added === 0) return result;
    clearMeasurementCache();
  }
  throw new Error('The fonts did not settle after three builds.');
}

/** Every font string of the layout. `base` marks a block's own face; its
 *  bold, italic and bold-italic variants are listed whether or not used. */
function fontStringsOf(doc) {
  const found = new Map();
  const walk = (node) => {
    if (!node || typeof node !== 'object') return;
    if (Array.isArray(node)) { node.forEach(walk); return; }
    for (const [key, value] of Object.entries(node)) {
      if (typeof value === 'string' && /fontString$/i.test(key)) {
        found.set(value, found.get(value) || key === 'fontString');
      } else if (value && typeof value === 'object') walk(value);
    }
  };
  walk(doc.pages);
  walk(doc.blocks);
  return [...found].map(([font, base]) => ({ font, base }));
}

/** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }.
 *  A string with no weight ('95.8px Young Serif', from a design text) is 400. */
function parseFont(font) {
  const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim());
  if (!m) throw new Error(`Unexpected font string: ${font}`);
  const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]);
  return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' };
}

/** True when a loaded FontFace covers exactly this family, weight and style
 *  (document.fonts.check() is also true for families nobody declared). */
function hasFace(family, weight, style) {
  for (const face of document.fonts) {
    if (face.status !== 'loaded' || face.style !== style) continue;
    if (face.family.replace(/^["']|["']$/g, '') !== family) continue;
    const [low, high = low] = face.weight.split(' ').map(Number);
    if (weight >= low && weight <= high) return true;
  }
  return false;
}

/** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */
function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); }

/** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */
function fontsourceMeta(family) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

// ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ───────
/** Shows the pages as facing spreads on a dark desk: the first page is a
 *  recto on its own, then verso | recto pairs, as in a bound book. Pages
 *  are painted when they scroll near the screen. */
function showPages(docs, { title, width = 460 } = {}) {
  const root = viewer(title);
  const pages = [docs].flat().flatMap((doc) =>
    doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index })));
  const spreads = [];
  let verso = null;
  for (const p of pages) {
    if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; }
    else { spreads.push([verso, p]); verso = null; }
  }
  if (verso) spreads.push([verso, null]);
  const density = Math.min(window.devicePixelRatio || 1, 2);
  showPages.painter?.disconnect();
  const painter = new IntersectionObserver((entries) => {
    for (const { isIntersecting, target } of entries) {
      if (!isIntersecting) continue;
      painter.unobserve(target);
      const { doc, page } = target.postext;
      renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width });
    }
  }, { rootMargin: '800px' });
  showPages.painter = painter;
  root.replaceChildren(...spreads.map((pair) => {
    const spread = document.createElement('div');
    spread.className = 'pt-spread';
    for (const p of pair) {
      const figure = document.createElement('figure');
      if (p) {
        const label = p.page.pageLabel || String(p.n + 1);
        const canvas = document.createElement('canvas');
        canvas.postext = p;
        canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`;
        canvas.setAttribute('role', 'img');
        canvas.setAttribute('aria-label', `Page ${label}`);
        const folio = document.createElement('figcaption');
        folio.textContent = label;
        figure.append(canvas, folio);
        painter.observe(canvas);
      } else figure.className = 'pt-blank';
      spread.append(figure);
    }
    return spread;
  }));
  kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`);
  document.documentElement.dataset.postext = 'ready';
  return pages.length;
}

/** The desk, the bar and the error reporting, created once. */
function viewer(title) {
  if (!document.getElementById('pt-kit')) {
    document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit">
      :root { color-scheme: dark; }
      body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; }
      #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center;
        gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px);
        border-bottom: 1px solid #23262d; }
      #pt-bar strong { color: #f4f1ea; font-weight: 600; }
      #pt-actions { display: flex; gap: 12px; margin-left: auto; }
      #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
      #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; }
      .pt-spread { display: flex; }
      .pt-spread figure { margin: 0; width: min(460px, 44vw); }
      .pt-spread canvas { display: block; width: 100%; background: #fff;
        box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif;
        letter-spacing: .18em; text-transform: uppercase; color: #6c7079; }
      .pt-blank { visibility: hidden; }
      @media (max-width: 760px) {
        .pt-spread { flex-direction: column; gap: 32px; }
        .pt-spread figure { width: min(460px, 92vw); }
        .pt-blank { display: none; }
      }
    </style>`);
    document.body.insertAdjacentHTML('afterbegin',
      '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>');
    document.getElementById('pt-title').textContent = document.title || 'Postext';
    addEventListener('error', (event) => kitFail(event.error ?? event.message));
    addEventListener('unhandledrejection', (event) => kitFail(event.reason));
  }
  if (title) document.getElementById('pt-title').textContent = title;
  return document.getElementById('pages')
    ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' }));
}

function kitStatus(text) {
  viewer();
  document.getElementById('pt-status').textContent = text;
}

function kitFail(error) {
  document.documentElement.dataset.postext = 'error';
  kitStatus(`Error: ${error?.message ?? error}`);
}

// ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ──────────
/** Registers a photo or PNG for the canvas and keeps its bytes for the PDF.
 *  fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */
async function loadImage(fileId, url) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`);
  const bytes = new Uint8Array(await res.arrayBuffer());
  registerResourceImage(fileId, await createImageBitmap(new Blob([bytes])));
  (loadImage.bytes ??= new Map()).set(fileId, bytes);
}

/** Registers SVG markup (drawn in code, or fetched) as a vector image. */
async function loadSvg(fileId, svg) {
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** renderToPdf({ resourceBytes: imageBytes }) */
function imageBytes(fileId) { return loadImage.bytes?.get(fileId); }

/** renderToHtml({ resourceImageUrl: imageUrl }) */
function imageUrl(fileId) {
  const bytes = imageBytes(fileId);
  if (!bytes) return undefined;
  imageUrl.urls ??= new Map();
  if (!imageUrl.urls.has(fileId)) {
    const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg';
    imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type })));
  }
  return imageUrl.urls.get(fileId);
}

// ─── /Kit ───────────────────────────────────────────────────────────────────────
```

## Variations

### List where each essay first appeared

`toc.subtitle` prints any attribute of the heading, so the same Markdown can list each essay’s first magazine under its title.

```diff
-const authorLine = { enabled: true, attr: 'author', fontFamily: 'Spectral', fontSize: pt(10),
+const authorLine = { enabled: true, attr: 'source', fontFamily: 'Spectral', fontSize: pt(10),
```

### Put the book’s title on the verso

Many books set their own title on the verso and the chapter’s on the recto; `{title}` reads it from the Markdown’s frontmatter and prints AFOOT.

```diff
-const versoAuthor = head('verso-author', '{attr.author}', 'even',
+const versoAuthor = head('verso-author', '{title}', 'even',
```

### Open every essay on a recto

With parity `'odd'`, Stevenson moves to page 9 behind a blank page 8. The two front-matter styles then need an `'any'` of their own, or the contents inherit `'odd'` and open on page 3 behind a blank verso.

```diff
-  breakBefore: { enabled: true, parity: 'any' } }; // 'any': each piece opens on the next page
+  breakBefore: { enabled: true, parity: 'odd' } };
-const bare = { numbered: false, toc: false, header: { elements: [] }, footer: { elements: [] } };
+const bare = { numbered: false, toc: false, breakBefore: { enabled: true, parity: 'any' },
+  header: { elements: [] }, footer: { elements: [] } };
```

## Pitfalls

- **Attribute values: no { or }; single-quote a value with ".** An attribute value ends at the closing brace, so it cannot hold { or }. A value that contains a double quote goes in single quotes; a dollar sign is fine.
- **Any headings object switches off the H1 page break.** By default an H1 breaks to a recto (always-odd), but passing any headings object resets that default, so chapters run on and span: 'page' does nothing. Restate headings.levels[0].breakBefore: { enabled: true, parity } in every config.
- **A heading style inherits its level's page break.** A headingStyles entry takes every field it leaves out from its heading level, breakBefore included. A contents page or a colophon styled on an H1 after a :::pagebreak inherits parity 'odd' and lands behind a blank page. Give such a style breakBefore: { enabled: false }.
- **Contents numbers sit a little above the entry's baseline.** In postext 1.4.1 :::toc paints each entry's number centred on the line instead of on the text's baseline, so the chapter numbers ride slightly high beside their titles, about 0.7 mm next to a 16 pt title, whatever face or size you give them. No toc option moves them yet, so look at the contents page at full size before you print.
- **{number}/{chapterNumber} print the H1 number; {numberRoman} is parts-only.** {number} and {chapterNumber} print the heading's formatted number, but {numberRoman}, {numberDecimal} and the other numeric variants are filled only on part pages. Format a chapter number in its numberingTemplate ({1:I}) or pass it as an attribute.
- **A swapped palette misses design elements and the reference colour.** postext 1.4.1 reads colorPalette into the text styles (body, headings, lists, captions, tables, boxes) but not into the elements of headers, footers, openers and part pages, nor into bodyText.referenceColor: they keep the hex written beside their paletteId. When you swap the palette, for a dark screen edition or a retint, rewrite every linked colour from colorPalette before the build.
- **A paragraph style's margins do not count inside a box.** In postext 1.4.1 a :::paragraphs container nested in a :::callout ignores its style's marginTop and marginBottom, so a small-print line set under a note's text sits right against it. Give the style a taller lineHeight, which puts air above its first line, or keep the line out of the box.
- **A runt fix can tighten tracking that is never painted.** In postext 1.4.1, when a paragraph ends on a runt, the layout sets it one line shorter: first with tighter word spacing, then with up to maxRuntTracking thousandths of an em of negative tracking. The canvas and PDF renderers paint tracking only above zero, so a tracked paragraph prints untracked: its justified lines lose the difference from their word spaces and look crushed, and its last line can run past the measure and be clipped at the column edge. Set bodyText.maxRuntTracking: 0, which keeps the word-spacing fix, and reword any runt that comes back.
- **Load every face before layout.** Layout measures text with the faces the browser has loaded and caches the widths, so a face that arrives after the first build leaves wrong line breaks and a PDF that no longer matches the screen. Load every weight and style first, and call clearMeasurementCache() before rebuilding when one arrives late.
- **Design text overflow defaults to 'ellipsis-end'.** A design text element that does not fit its width ends in an ellipsis by default. Set overflow: 'wrap' for titles that should break onto more lines.

- Hazlitt quotes verse in the middle of his sentences. A paragraph keeps no line breaks, so each line of verse is a paragraph of its own in a `:::paragraphs{style="verse"}` container, and the sentence that resumes after it goes in a `runon` style with no indent. A Markdown blockquote would print in a fixed `#666666` grey italic, with the body’s first-line indent and no side indent, and no setting changes that in 1.4.1.
- An attribute that a heading lacks prints nothing and raises no warning. Without `author="…"`, an essay’s byline and verso head come out empty and its line under the title disappears from the contents.
- The 14.4 pt of step 2 holds for Gloock only. For another face, find how far above the baseline Chrome puts its middle: `measureText('I').alphabeticBaseline` with `textBaseline = 'middle'` returns it, negated, in pixels. Then set `numberFontSize` to 0.3 × the entry size divided by that distance in em (Hanken Grotesk’s is 0.2675 em).

## Credits

- Recipe: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: “On Going a Journey” (1822), from its first line to “I sing for joy”, in Table Talk: Essays on Men and Manners: William Hazlitt ([source](https://www.gutenberg.org/ebooks/3020)), public domain
- Text: “Walking” (1862), its first five paragraphs: Henry David Thoreau ([source](https://www.gutenberg.org/ebooks/1022)), public domain
- Text: “Walking Tours” (1876), the opening of its first paragraph and the whole of its second, in Virginibus Puerisque: Robert Louis Stevenson ([source](https://www.gutenberg.org/ebooks/386)), public domain
- Text: The note on the texts and the colophon: Ignacio Ferro, CC-BY-4.0
- Images: The cover: hills at dusk, drawn in code: Ignacio Ferro, CC-BY-4.0
- Type: Spectral (OFL-1.1), Gloock (OFL-1.1), Hanken Grotesk (OFL-1.1)
- Code: MIT · Sample content: CC-BY-4.0

## Related

- [Nº 006 · Front matter in roman folios, then page 1](https://postext.dev/en/cookbook/front-matter-roman-to-arabic.md): The cover and prelims are unnumbered headings counted in lower-case roman; :::numbering restarts the count at 1 on the recto where the novel opens. · Level 3 (Advanced) · Fiction, drama & literary prose
- [Nº 064 · Letters edition: datelines and signatures](https://postext.dev/en/cookbook/letters-edition.md): Letters run on without page breaks; each dateline comes from heading attributes, and paragraph styles set the salutation, the signature and a postscript. · Level 2 (Intermediate) · Fiction, drama & literary prose
- [Nº 005 · Running heads by parity in a book of essays](https://postext.dev/en/cookbook/running-heads-by-parity.md): Header elements filtered by parity and page role: book title on versos, essay title cut short on rectos, folio tabs in the margin, a drop folio on openers. · Level 2 (Intermediate) · Fiction, drama & literary prose
