# Poems set line by line

> Each line of verse is a paragraph whose turnovers hang 4 em in. Em spaces hold the 1918 indents, and :::space puts one line between stanzas.

- HTML version: https://postext.dev/en/cookbook/poetry-collection
- Recipe Nº 015 · Type & text · Level 2 (Intermediate) · Outputs: Canvas
- Genres: Poetry
- Requires postext ≥ 1.4.1 · tested with 1.4.1 on 2026-09-26
- Pages: [1](https://postext.dev/cookbook/poetry-collection/en/p01.webp?v=d99a3d85), [2](https://postext.dev/cookbook/poetry-collection/en/p02.webp?v=d99a3d85), [3](https://postext.dev/cookbook/poetry-collection/en/p03.webp?v=d99a3d85), [4](https://postext.dev/cookbook/poetry-collection/en/p04.webp?v=d99a3d85), [5](https://postext.dev/cookbook/poetry-collection/en/p05.webp?v=d99a3d85), [6](https://postext.dev/cookbook/poetry-collection/en/p06.webp?v=d99a3d85), [7](https://postext.dev/cookbook/poetry-collection/en/p07.webp?v=d99a3d85), [8](https://postext.dev/cookbook/poetry-collection/en/p08.webp?v=d99a3d85)
- Last updated: 2026-09-25
- Other languages: [es](https://postext.dev/es/cookbook/poetry-collection.md)

## What you'll build

A chapbook of four nature poems by Gerard Manley Hopkins, 140 × 216 mm, in the text of the 1918 first edition. After the half-title, a frontispiece photogram of fern and rowan faces a title page set in Italiana. The contents list each poem by numeral and italic title, with spaced dots to the page number. Each poem opens a page under a sage numeral and its italic title. Every line of verse is a paragraph, indented as in 1918: the lines of *Pied Beauty* step in by rhyme, and the long lines of *The Windhover* turn over and hang 4 em in, twice as deep as its indents. Stanzas are one line apart, and no word is broken except where Hopkins broke it. The date goes under each poem, flush right, and a rowan sprig follows the last one. The poem pages carry a centred folio at the foot.

**This recipe answers:**

- How do I set poems line by line, with their indents, stanza breaks, hanging turnovers and no hyphenation?
- How do I add extra vertical space between two blocks, when blank lines do nothing?
- How do I add a table of contents that updates itself (leaders, page numbers, authors, part rows)?
- How do I set an epigraph, a dedication, a signature, or a pull quote with a big quote mark?
- How do I set unnumbered artwork: ornaments, vignettes, logos?

## The short answer

Verse: a paragraph per line, indents kept, turnovers that hang, stanza space.

```js
// script.js, lines 31–54
// A poem is one :::paragraphs{style="verse"} container with a paragraph per line, so no line
// runs on into the next. An indented line starts with spaces, two to an em, and a :::space
// line leaves one line of the grid between stanzas (blank lines only separate paragraphs):
//   :::paragraphs{style="verse"}
//   The world is charged with the grandeur of God.
//
//       It will flame out, like shining from shook foil;
//
//   :::space
//
//   And for all this, nature is never spent;
//   :::
const verse = {
  id: 'verse',
  textAlign: 'left', // ragged: a line that turns over is not stretched to the measure
  hangingIndent: em(4), // a turned line hangs past the 1 and 2 em indents
  // Verse is never hyphenated; ragged text is not in 1.4.1 either (gotcha: ragged-no-hyphenation).
  hyphenation: false,
};
// A paragraph loses its leading spaces, and hangingIndent overrides firstLineIndent, so in verse
// each pair of leading spaces becomes an em space behind a word joiner, where the trim stops.
const indentVerse = (md) => md.replace(/:::paragraphs\{style="verse"\}\n[\s\S]*?\n:::\n/g,
  (poem) => poem.replace(/^((?: {2})+)(?=\S)/gm, (s) => `\u2060${'\u2003'.repeat(s.length / 2)}`));
// Hook-up: paragraphStyles: [verse, …] and buildDocument({ markdown: indentVerse(markdown) }).
```

## Ingredients

**Teaches**

- [Paragraph styles](https://postext.dev/en/docs/configuration.md#paragraph-styles): Named styles for runs of paragraphs set apart from the body: verse, epigraphs, dedications, signatures, small print.
- [Explicit vertical space](https://postext.dev/en/docs/document-format.md#space): Adds whole or fractional lines of space between two blocks, where blank lines add nothing; dropped at a column top.
- [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.

**Also uses**

- [Indents, alignment and paragraph spacing](https://postext.dev/en/docs/configuration.md#body-text)
- [Escapes and literal characters](https://postext.dev/en/docs/document-format.md#authoring-conventions)
- [Numbered headings](https://postext.dev/en/docs/configuration.md#per-level-overrides)
- [Designed openers](https://postext.dev/en/docs/configuration.md#span-and-advanced-design)
- [Heading styles](https://postext.dev/en/docs/configuration.md#heading-styles)
- [Unnumbered chapters](https://postext.dev/en/docs/configuration.md#heading-styles)
- [Heading attributes](https://postext.dev/en/docs/document-format.md#heading-attributes)
- [Pictures in page designs](https://postext.dev/en/docs/configuration.md#image-elements)
- [Covers, title pages and colophons](https://postext.dev/en/docs/configuration.md#heading-styles)
- [Document metadata](https://postext.dev/en/docs/document-format.md#frontmatter)
- [Running heads per section](https://postext.dev/en/docs/configuration.md#heading-styles)
- [Running heads and folios](https://postext.dev/en/docs/configuration.md#headers--footers)
- [Custom resource types](https://postext.dev/en/docs/configuration.md#resource-types)
- [Figures exactly here](https://postext.dev/en/docs/document-format.md#block-embed-optional-explicit-inline-placement)
- [Semantic colour palette](https://postext.dev/en/docs/configuration.md#color-palette)
- [Heads by page role](https://postext.dev/en/docs/configuration.md#text-elements)
- [Bibliographies and glossaries](https://postext.dev/en/docs/configuration.md#paragraph-styles)
- [Paper colour](https://postext.dev/en/docs/configuration.md#page)
- [Figures and tables as resources](https://postext.dev/en/docs/document-format.md#resources)

**Config at a glance**

- [`bodyText`](https://postext.dev/en/docs/configuration.md#body-text), [`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), [`resourceTypes`](https://postext.dev/en/docs/configuration.md#resource-types), [`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**

- Sorts Mill Goudy (OFL-1.1), Italiana (OFL-1.1), Marcellus SC (OFL-1.1)

## Method

### 1 · A line of verse is a paragraph

The code is [the short answer](#the-short-answer) above. Postext joins the lines of a Markdown paragraph into one, so a poem needs a paragraph per line. The verse style sets those paragraphs ragged, because justifying them would stretch the first part of a turned line across the measure. It hangs the rest of the line 4 em in, past the 1 and 2 em indents of the rhyming lines, so a turnover does not read as a line of its own ([paragraph styles](/en/docs/configuration#paragraph-styles)). A paragraph loses its leading spaces, em spaces included, and a style with a hanging indent ignores its first-line indent. `indentVerse` therefore writes each pair of leading spaces as an em space behind a word joiner, a zero-width character where the trim stops. Lines that rhyme with one another keep the same indent, as in 1918. Blank lines add no space. Each stanza break is a `:::space` line inside the container, which leaves one line of the grid and is dropped at the top of a page ([`:::space`](/en/docs/document-format#space)).

![Opening page 6: II The Windhover. The Windhover: its dedication stands above the first line, the lines that rhyme in -iding and -illion start 2 em in, and four long lines turn over, hanging 4 em in.](https://postext.dev/cookbook/poetry-collection/en/p06.webp?v=d99a3d85)

*The Windhover, page 6: the lines that rhyme in -iding start 2 em in, and the four lines too long for the page turn over and hang 4 em in.*

### 2 · The lines around a poem have styles too

```js
// script.js, lines 58–62
const lineStyles = [
  { id: 'dedication', fontSize: pt(9.5), marginBottom: pt(LEAD) }, // under The Windhover
  { id: 'date', fontFamily: 'Marcellus SC', fontSize: pt(8), color: col('muted'),
    textAlign: 'right', marginTop: pt(LEAD) }, // a line of space above, flush right
];
```

The dedication above *The Windhover* and the place and date under each poem are not lines of verse, so each one is a `:::paragraphs` container of one paragraph, in a style of its own. Neither style sets a leading, and their margins are one line (15 pt), so the verse stays on the body's grid. The date is set one line below the poem, flush right, in 8 pt Marcellus SC, the face of the folios.

### 3 · Every poem opens a page under its number

```js
// script.js, lines 66–81
// '{1:I}' numbers the poems I to IV and {number} prints it (gotcha: heading-number-placeholders).
// The head is HEAD_LINES grid lines (minHeight, no bottom margin), so each poem starts on the same
// line; numeral and title fill 4, or 5 if the title wraps (gotcha: overflow-ellipsis-default).
const HEAD_LINES = 6;
const poemHead = { enabled: true, minHeight: pt(LEAD * HEAD_LINES), slot: { elements: [
  { kind: 'text', id: 'numeral', content: '{number}', fontFamily: 'Italiana', fontSize: pt(22),
    color: col('sage'), align: 'left',
    placement: { anchor: { to: 'container', edge: 'top-left' } } },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Sorts Mill Goudy',
    italic: true, fontSize: pt(17), color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { anchor: { to: '#numeral', edge: 'below' }, offset: { y: mm(2) },
      size: { width: 'fill' } } },
] } };
// Parity 'any': the next page, recto or verso (restated: gotcha headings-drop-h1-break).
const poems = { level: 1, numberingTemplate: '{1:I}', advancedDesign: poemHead,
  marginBottom: pt(0), breakBefore: { enabled: true, parity: 'any' } };
```

Instead of its text, the heading prints a design in the column. In that design, `{number}` is the count as `numberingTemplate: '{1:I}'` formats it, I to IV, and `{titleText}` is the title ([span and advanced design](/en/docs/configuration#span-and-advanced-design)). The level has no bottom margin, and `minHeight` makes every head six grid lines deep (31.75 mm). The numeral and a one-line title fill four of them, and a title that wraps fills five, so every poem starts on the same line of its page. On [page 6](https://postext.dev/cookbook/poetry-collection/en/p06.webp?v=d99a3d85) that line holds the dedication. Parity `'any'` breaks to the next page, recto or verso, so there are no blank pages between the poems.

### 4 · The front matter is made of headings

```js
// script.js, lines 85–115
// Headings too, each on a page of its own, with no number, contents entry or folio.
// A style restates the break, or inherits the poems' (gotcha: style-inherits-break).
const leaf = { numbered: false, toc: false, span: 'page', footer: { elements: [] },
  breakBefore: { enabled: true, parity: 'any' } };
const onPage = (y, width) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) },
  ...(width && { size: { width: mm(width) } }) }); // centred, y mm below the trim
const face = (id, content, font, size, y, extra = {}) => ({ kind: 'text', id, content,
  fontFamily: font, fontSize: pt(size), color: col('ink'), align: 'center',
  placement: onPage(y), ...extra });
const image = (id, placement) => ({ kind: 'image', id, resourceId: id, placement });
const design = (...elements) => ({ enabled: true, slot: { elements } });
const front = [
  { id: 'half-title', ...leaf,
    advancedDesign: design(face('title', '{titleText}', 'Italiana', 20, 60)) },
  { id: 'plate', ...leaf, advancedDesign: design( // span 'page': a column clips its design
    image('plate', { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } }),
    face('caption', '{attr.caption}', 'Sorts Mill Goudy', 8.5, 203,
      { italic: true, color: col('muted') })) },
  { id: 'title-page', ...leaf, advancedDesign: design(
    face('author', '{author}', 'Marcellus SC', 10, 46, // {author}, {title}: the frontmatter
      { letterSpacing: pt(2.4), textTransform: 'uppercase' }),
    // A multiple of the size, never pt() (gotcha: design-lineheight-multiple).
    face('title', '{title}', 'Italiana', 54, 56, { lineHeight: 1 }),
    face('subtitle', '{subtitle}', 'Sorts Mill Goudy', 13, 80,
      { italic: true, color: col('sage') }),
    image('sprig', onPage(96, 30)),
    face('press', 'The Herbarium Press', 'Marcellus SC', 8.5, 182,
      { letterSpacing: pt(1.8), color: col('muted') })) },
  { id: 'contents', ...leaf, span: 'column', advancedDesign: { enabled: false },
    fontFamily: 'Italiana', fontSize: pt(24), lineHeight: pt(LEAD * 2), marginBottom: pt(LEAD) },
];
```

A heading style can break to a new page and draw a design there, so the half-title, the frontispiece and the title page are one heading each, with a style of its own. `numbered: false` keeps them out of the count, and the first poem is still I. `toc: false` keeps them out of the contents, and an empty footer leaves their pages without a folio ([heading styles](/en/docs/configuration#heading-styles)). The styles span the page because a design in the column is clipped to the text block, which would crop the full-bleed plate to the margins and cut off its caption. The caption comes from the heading's `caption` attribute; the author, title and subtitle on the title page come from the frontmatter.

### 5 · The contents fill themselves in

```js
// script.js, lines 119–126
// The toc centres each numeral on its line, not on the baseline (gotcha: toc-number-baseline);
// left at the titles' size, these Italiana numerals land on it, where 9 pt ones rode high.
const contents = {
  levels: [{ level: 1, italic: true, marginBottom: pt(LEAD), numberFontFamily: 'Italiana',
    numberFontWeight: 400, numberColor: col('sage'), numberWidth: mm(6), numberGap: mm(3) }],
  pageNumber: { fontFamily: 'Marcellus SC', fontSize: pt(9), color: col('muted'), width: mm(6) },
  leader: { char: '. ', gap: mm(2) }, // spaced dots, right-aligned so they line up
};
```

`:::toc` lists every heading whose style lets it in, with the numeral from the same template and the label of the page where the heading lands. The build lays the book out again until those page labels stop changing ([table of contents](/en/docs/configuration#table-of-contents)). The page's own heading, Contents, has a style with `numbered: false`; without it, Contents would be poem I. The leader `'. '` puts a space after each dot. The dots are right-aligned, so they line up from one entry to the next, and they print in the page numbers' face and colour because `toc.leader` has no colour setting.

### 6 · An ornament is a resource type with no label

```js
// script.js, lines 130–134
// No caption prefix and no caption: the sprig prints bare, where ::resource{id="sprig"} puts
// it (double quotes: gotcha resource-double-quotes), a fifth of the measure wide, centred.
const ornament = { id: 'ornament', name: 'Ornament', shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  defaultPlacement: { position: 'here', width: 0.2, align: 'center' } };
```

A picture in the text is a resource, and every resource has a type. The ornament type has an empty caption prefix and the sprig has no caption, so no label such as "Figure 1" prints under it ([resource types](/en/docs/configuration#resource-types)). The type's `defaultPlacement` puts the sprig where `::resource{id="sprig"}` stands, centred and a fifth of the measure wide, so the tailpiece under *Inversnaid* takes one line of Markdown. `resourceTypes` replaces the default list, so this book has no Figure or Table type. To keep them, spread `defaultResourceTypes()` in front of the ornament.

## 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/poetry-collection

### script.js

```js
// ═══ Postext Cookbook · Nº 015 · Poems set line by line ═════════════════════════════
// https://postext.dev/en/cookbook/poetry-collection
// Code: MIT · Text: G. M. Hopkins, Poems, 1918 (PD) · Plate and ornament: drawn in code
// Fonts: Sorts Mill Goudy, Italiana, Marcellus SC (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 = 'poetry-collection';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // every colour in the config links to one of these
  ink: '#26221f', // the text: a warm near-black
  sage: '#56673f', // the one accent: numerals, the plate's ground, the ornament's leaves
  sepia: '#8c5f3a', // used for the rowan berries only
  muted: '#746a60', // dates, folios, leaders and the colophon
  paper: '#fbf8f2', // the page
};
// The hex travels with the id: designs do not read the palette (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 accent, so nothing prints blue.
  { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.sage, model: 'hex' } },
];
const TRIM_W = 140, TRIM_H = 216; // mm: a poetry trim, tall enough for a sonnet's turnovers
const LEAD = 15; // pt: the body's leading, the grid every line of verse sits on

// #region answer: verse: a paragraph per line, indents kept, turnovers that hang, stanza space
// A poem is one :::paragraphs{style="verse"} container with a paragraph per line, so no line
// runs on into the next. An indented line starts with spaces, two to an em, and a :::space
// line leaves one line of the grid between stanzas (blank lines only separate paragraphs):
//   :::paragraphs{style="verse"}
//   The world is charged with the grandeur of God.
//
//       It will flame out, like shining from shook foil;
//
//   :::space
//
//   And for all this, nature is never spent;
//   :::
const verse = {
  id: 'verse',
  textAlign: 'left', // ragged: a line that turns over is not stretched to the measure
  hangingIndent: em(4), // a turned line hangs past the 1 and 2 em indents
  // Verse is never hyphenated; ragged text is not in 1.4.1 either (gotcha: ragged-no-hyphenation).
  hyphenation: false,
};
// A paragraph loses its leading spaces, and hangingIndent overrides firstLineIndent, so in verse
// each pair of leading spaces becomes an em space behind a word joiner, where the trim stops.
const indentVerse = (md) => md.replace(/:::paragraphs\{style="verse"\}\n[\s\S]*?\n:::\n/g,
  (poem) => poem.replace(/^((?: {2})+)(?=\S)/gm, (s) => `\u2060${'\u2003'.repeat(s.length / 2)}`));
// Hook-up: paragraphStyles: [verse, …] and buildDocument({ markdown: indentVerse(markdown) }).
// #endregion

// #region lines: the lines around a poem: a dedication above it, a place and a date below
const lineStyles = [
  { id: 'dedication', fontSize: pt(9.5), marginBottom: pt(LEAD) }, // under The Windhover
  { id: 'date', fontFamily: 'Marcellus SC', fontSize: pt(8), color: col('muted'),
    textAlign: 'right', marginTop: pt(LEAD) }, // a line of space above, flush right
];
// #endregion

// #region poem-head: every poem opens a page under its numeral and its title
// '{1:I}' numbers the poems I to IV and {number} prints it (gotcha: heading-number-placeholders).
// The head is HEAD_LINES grid lines (minHeight, no bottom margin), so each poem starts on the same
// line; numeral and title fill 4, or 5 if the title wraps (gotcha: overflow-ellipsis-default).
const HEAD_LINES = 6;
const poemHead = { enabled: true, minHeight: pt(LEAD * HEAD_LINES), slot: { elements: [
  { kind: 'text', id: 'numeral', content: '{number}', fontFamily: 'Italiana', fontSize: pt(22),
    color: col('sage'), align: 'left',
    placement: { anchor: { to: 'container', edge: 'top-left' } } },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Sorts Mill Goudy',
    italic: true, fontSize: pt(17), color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { anchor: { to: '#numeral', edge: 'below' }, offset: { y: mm(2) },
      size: { width: 'fill' } } },
] } };
// Parity 'any': the next page, recto or verso (restated: gotcha headings-drop-h1-break).
const poems = { level: 1, numberingTemplate: '{1:I}', advancedDesign: poemHead,
  marginBottom: pt(0), breakBefore: { enabled: true, parity: 'any' } };
// #endregion

// #region front: the half-title, the frontispiece, the title page and the contents
// Headings too, each on a page of its own, with no number, contents entry or folio.
// A style restates the break, or inherits the poems' (gotcha: style-inherits-break).
const leaf = { numbered: false, toc: false, span: 'page', footer: { elements: [] },
  breakBefore: { enabled: true, parity: 'any' } };
const onPage = (y, width) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) },
  ...(width && { size: { width: mm(width) } }) }); // centred, y mm below the trim
const face = (id, content, font, size, y, extra = {}) => ({ kind: 'text', id, content,
  fontFamily: font, fontSize: pt(size), color: col('ink'), align: 'center',
  placement: onPage(y), ...extra });
const image = (id, placement) => ({ kind: 'image', id, resourceId: id, placement });
const design = (...elements) => ({ enabled: true, slot: { elements } });
const front = [
  { id: 'half-title', ...leaf,
    advancedDesign: design(face('title', '{titleText}', 'Italiana', 20, 60)) },
  { id: 'plate', ...leaf, advancedDesign: design( // span 'page': a column clips its design
    image('plate', { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } }),
    face('caption', '{attr.caption}', 'Sorts Mill Goudy', 8.5, 203,
      { italic: true, color: col('muted') })) },
  { id: 'title-page', ...leaf, advancedDesign: design(
    face('author', '{author}', 'Marcellus SC', 10, 46, // {author}, {title}: the frontmatter
      { letterSpacing: pt(2.4), textTransform: 'uppercase' }),
    // A multiple of the size, never pt() (gotcha: design-lineheight-multiple).
    face('title', '{title}', 'Italiana', 54, 56, { lineHeight: 1 }),
    face('subtitle', '{subtitle}', 'Sorts Mill Goudy', 13, 80,
      { italic: true, color: col('sage') }),
    image('sprig', onPage(96, 30)),
    face('press', 'The Herbarium Press', 'Marcellus SC', 8.5, 182,
      { letterSpacing: pt(1.8), color: col('muted') })) },
  { id: 'contents', ...leaf, span: 'column', advancedDesign: { enabled: false },
    fontFamily: 'Italiana', fontSize: pt(24), lineHeight: pt(LEAD * 2), marginBottom: pt(LEAD) },
];
// #endregion

// #region contents: what :::toc prints for each poem: numeral, italic title, leader, page
// The toc centres each numeral on its line, not on the baseline (gotcha: toc-number-baseline);
// left at the titles' size, these Italiana numerals land on it, where 9 pt ones rode high.
const contents = {
  levels: [{ level: 1, italic: true, marginBottom: pt(LEAD), numberFontFamily: 'Italiana',
    numberFontWeight: 400, numberColor: col('sage'), numberWidth: mm(6), numberGap: mm(3) }],
  pageNumber: { fontFamily: 'Marcellus SC', fontSize: pt(9), color: col('muted'), width: mm(6) },
  leader: { char: '. ', gap: mm(2) }, // spaced dots, right-aligned so they line up
};
// #endregion

// #region ornament: a resource type for artwork that prints no caption and no label
// No caption prefix and no caption: the sprig prints bare, where ::resource{id="sprig"} puts
// it (double quotes: gotcha resource-double-quotes), a fifth of the measure wide, centred.
const ornament = { id: 'ornament', name: 'Ornament', shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  defaultPlacement: { position: 'here', width: 0.2, align: 'center' } };
// #endregion

const proseStyles = [ // the note on the text and the colophon, under the contents
  { id: 'note', fontSize: pt(9.5), lineHeight: pt(13) },
  { id: 'colophon', fontSize: pt(8), lineHeight: pt(11), color: col('muted'), textAlign: 'left',
    firstLineIndent: pt(0), marginTop: pt(LEAD) },
];
const folio = (pages) => ({ kind: 'text', id: `folio-${pages}`, content: '{pageNumber}', pages,
  fontFamily: 'Marcellus SC', fontSize: pt(9), color: col('muted'), align: 'center',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(10) } } });

const config = () => ({ // a factory: the engine caches resolved configs per object
  colorPalette,
  // The list replaces Figure and Table; a book with figures spreads defaultResourceTypes() in.
  resourceTypes: [ornament],
  page: { // mirror: left is the inner margin; 150 dpi is for the screen
    sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150,
    backgroundColor: col('paper'),
    margins: { top: mm(22), bottom: mm(24), left: mm(22), right: mm(18), mirror: true },
  },
  layout: { layoutType: 'single' },
  bodyText: { // the note's prose: justified, hyphenated (en-us), spaces 0.8–1.6 of normal
    fontFamily: 'Sorts Mill Goudy', fontSize: pt(11), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: mm(4),
    indentAfterHeading: false, minWordSpacing: 0.8, maxWordSpacing: 1.6,
  },
  headings: {
    fontFamily: 'Sorts Mill Goudy', fontWeight: 400, color: col('ink'),
    levels: [poems, { level: 2, fontFamily: 'Marcellus SC', fontSize: pt(9), lineHeight: pt(LEAD),
      color: col('sage'), marginTop: pt(0), marginBottom: pt(0) }],
  },
  headingStyles: front,
  toc: contents,
  paragraphStyles: [verse, ...lineStyles, ...proseStyles],
  header: { elements: [] }, // no running heads: each poem starts a page under its own title
  footer: { elements: [folio('opener'), folio('body')] }, // centred; never on a blank page
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Pied Beauty"
subtitle: "Four poems of the natural world"
author: "Gerard Manley Hopkins"
---

# Pied Beauty {style="half-title"}

# Fern and Rowan {style="plate" caption="Fern and rowan: the ‘flitches of fern’ and the ‘beadbonny ash’ of Inversnaid"}

# Pied Beauty {style="title-page"}

# Contents {style="contents"}

:::toc

:::space{lines=2}

## A note on the text

:::paragraphs{style="note"}
This selection follows the first edition of the poems, edited by Robert Bridges in 1918, nearly thirty years after Hopkins’s death. Its spelling, accents and indents are kept, and lines that rhyme with one another start the same distance in. A line too long for the page is turned over, and the turnover is set deeper than the lines around it, so that it reads as part of the line above. No word is broken unless Hopkins broke it, as he did at the end of the first line of *The Windhover*. Under each poem is the date from Bridges’s notes, with the place when he gives one.
:::

:::paragraphs{style="colophon"}
Set in Sorts Mill Goudy, Italiana and Marcellus SC (SIL Open Font License). Text from Project Gutenberg eBook 22403, accents and indents from Wikisource. Plate and ornament drawn for this edition.
:::

# God’s Grandeur

:::paragraphs{style="verse"}
The world is charged with the grandeur of God.

    It will flame out, like shining from shook foil;

    It gathers to a greatness, like the ooze of oil

Crushed. Why do men then now not reck his rod?

Generations have trod, have trod, have trod;

    And all is seared with trade; bleared, smeared with toil;

    And wears man’s smudge and shares man’s smell: the soil

Is bare now, nor can foot feel, being shod.

:::space

And for all this, nature is never spent;

    There lives the dearest freshness deep down things;

And though the last lights off the black West went

    Oh, morning, at the brown brink eastward, springs—

Because the Holy Ghost over the bent

    World broods with warm breast and with ah! bright wings.
:::

:::paragraphs{style="date"}
23 February 1877
:::

# The Windhover

:::paragraphs{style="dedication"}
*To Christ our Lord*
:::

:::paragraphs{style="verse"}
I caught this morning morning’s minion, king-

    dom of daylight’s dauphin, dapple-dawn-drawn Falcon, in his riding

    Of the rolling level underneath him steady air, and striding

High there, how he rung upon the rein of a wimpling wing

In his ecstacy! then off, off forth on swing,

    As a skate’s heel sweeps smooth on a bow-bend: the hurl and gliding

    Rebuffed the big wind. My heart in hiding

Stirred for a bird,—the achieve of, the mastery of the thing!

:::space

Brute beauty and valour and act, oh, air, pride, plume, here

    Buckle! AND the fire that breaks from thee then, a billion

Times told lovelier, more dangerous, O my chevalier!

:::space

    No wonder of it: shéer plód makes plough down sillion

Shine, and blue-bleak embers, ah my dear,

    Fall, gall themselves, and gash gold-vermillion.
:::

:::paragraphs{style="date"}
St Beuno’s, 30 May 1877
:::

# Pied Beauty

:::paragraphs{style="verse"}
Glory be to God for dappled things—

  For skies of couple-colour as a brinded cow;

    For rose-moles all in stipple upon trout that swim:

Fresh-firecoal chestnut-falls; finches’ wings;

  Landscape plotted and pieced—fold, fallow, and plough;

    And áll trádes, their gear and tackle and trim.

:::space

All things counter, original, spare, strange;

  Whatever is fickle, freckled (who knows how?)

    With swift, slow; sweet, sour; adazzle, dim;

He fathers-forth whose beauty is past change:

                Praise him.
:::

:::paragraphs{style="date"}
St Beuno’s, Tremeirchion, summer 1877
:::

# Inversnaid

:::paragraphs{style="verse"}
This darksome burn, horseback brown,

His rollrock highroad roaring down,

In coop and in comb the fleece of his foam

Flutes and low to the lake falls home.

:::space

A windpuff-bonnet of fáwn-fróth

Turns and twindles over the broth

Of a pool so pitchblack, féll-frówning,

It rounds and rounds Despair to drowning.

:::space

Degged with dew, dappled with dew

Are the groins of the braes that the brook treads through,

Wiry heathpacks, flitches of fern,

And the beadbonny ash that sits over the burn.

:::space

What would the world be, once bereft

Of wet and of wildness? Let them be left,

O let them be left, wildness and wet;

Long live the weeds and the wilderness yet.
:::

:::paragraphs{style="date"}
28 September 1881
:::

::resource{id="sprig"}
`; // content.<lang>.md, inlined by the Cookbook

// #region art: a photogram of fern and rowan, and a rowan sprig for the ornament
let seed = 1877; // 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 mix = (a, b, k) => `#${[1, 3, 5].map((i) => Math.round(parseInt(palette[a].slice(i, i + 2),
  16) * (1 - k) + parseInt(palette[b].slice(i, i + 2), 16) * k).toString(16).padStart(2, '0'))
  .join('')}`;
const f = (n) => n.toFixed(1);
const path = (d, fill, a = 1) => `<path d="${d}" fill="${fill}" fill-opacity="${a}"/>`;
const ring = (pts) => `M${pts.map(([x, y]) => `${f(x)} ${f(y)}`).join('L')}Z`;
const disk = (x, y, r, fill, a = 1) => `<circle cx="${f(x)}" cy="${f(y)}" r="${f(r)}" `
  + `fill="${fill}" fill-opacity="${a}"/>`;
const PX = 10; // a drawing w × h mm has a viewBox in tenths of a millimetre, w·PX × h·PX
const svgOf = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * PX}" `
  + `height="${h * PX}" viewBox="0 0 ${w * PX} ${h * PX}">${body}</svg>`;
// A cubic Bézier and its unit normal at t.
const bez = ([p0, p1, p2, p3], t) => {
  const u = 1 - t;
  const coord = (i) => u * u * u * p0[i] + 3 * u * u * t * p1[i] + 3 * u * t * t * p2[i]
    + t * t * t * p3[i];
  const dt = (i) => 3 * u * u * (p1[i] - p0[i]) + 6 * u * t * (p2[i] - p1[i])
    + 3 * t * t * (p3[i] - p2[i]);
  const [dx, dy] = [dt(0), dt(1)];
  const len = Math.hypot(dx, dy) || 1;
  return { x: coord(0), y: coord(1), a: Math.atan2(dy, dx), nx: -dy / len, ny: dx / len };
};
// A stalk: the curve drawn as a filled band tapering from w0 to w1.
const stalk = (curve, w0, w1) => {
  const left = [];
  const right = [];
  for (let i = 0; i <= 40; i++) {
    const p = bez(curve, i / 40);
    const w = (w0 + (w1 - w0) * (i / 40)) / 2;
    left.push([p.x + p.nx * w, p.y + p.ny * w]);
    right.unshift([p.x - p.nx * w, p.y - p.ny * w]);
  }
  return ring([...left, ...right]);
};
// A leaf blade from its base (x, y) along angle a: length l, half-width w, `teeth` serrations.
const blade = (x, y, a, l, w, teeth = 0) => {
  const [c, s] = [Math.cos(a), Math.sin(a)];
  const at = (u, v) => [x + u * c - v * s, y + u * s + v * c];
  const side = (sign) => Array.from({ length: 25 }, (_, i) => {
    const t = i / 24;
    let h = w * Math.sin(Math.PI * t ** 0.85) ** 0.9;
    if (teeth && t > 0.25) h *= 1 - 0.18 * ((t * teeth) % 1);
    return at(l * t, sign * h);
  });
  return ring([...side(1), ...side(-1).reverse()]);
};
// A fern frond: a curving rachis, alternate pinnae longest near the base, each a comb of lobes.
function fern(curve, reach, lobe) {
  const d = [stalk(curve, 20, 3)];
  const n = 30;
  for (let i = 3; i < n; i++) {
    const t = i / n;
    const p = bez(curve, t);
    const side = i % 2 ? 1 : -1;
    const len = reach * Math.sin(Math.PI * Math.min(1, (1 - t) * 1.25) / 2) ** 1.2;
    const pa = p.a + side * (1.05 - 0.35 * t); // pinnae lean towards the tip
    const [cx, cy] = [Math.cos(pa), Math.sin(pa)];
    const sweep = side * 0.18; // each pinna arches a little towards the tip
    const axis = [[p.x, p.y], [p.x + cx * len / 3, p.y + cy * len / 3],
      [p.x + Math.cos(pa - sweep) * len * 0.68, p.y + Math.sin(pa - sweep) * len * 0.68],
      [p.x + Math.cos(pa - sweep * 2) * len, p.y + Math.sin(pa - sweep * 2) * len]];
    d.push(stalk(axis, 5, 1.2));
    const k = Math.max(2, Math.round(len / (lobe * 0.82)));
    for (let j = 0; j < k; j++) {
      const s = (j + 0.5) / (k + 0.3);
      const q = bez(axis, s);
      const size = lobe * (1.05 - s * 0.6) * (0.92 + rand() * 0.16);
      for (const sgn of [1, -1]) d.push(blade(q.x, q.y, q.a + sgn * 0.95, size * 1.6, size * 0.5));
    }
    const tip = bez(axis, 1);
    d.push(blade(tip.x, tip.y, tip.a, lobe * 1.2, lobe * 0.35));
  }
  return d.join('');
}
// Rowan: a woody stem, pinnate leaves of serrated leaflets and a dome of berries.
function rowan(curve, leaves, cluster, scale = 1) {
  const d = [stalk(curve, 22 * scale, 8 * scale)];
  const dots = [];
  for (const [t, side, len] of leaves) {
    const p = bez(curve, t);
    const a = p.a + side * 0.9;
    const l = len * scale;
    const rachis = [[p.x, p.y], [p.x + Math.cos(a) * l / 3, p.y + Math.sin(a) * l / 3],
      [p.x + Math.cos(a + side * 0.12) * l * 2 / 3, p.y + Math.sin(a + side * 0.12) * l * 2 / 3],
      [p.x + Math.cos(a + side * 0.25) * l, p.y + Math.sin(a + side * 0.25) * l]];
    d.push(stalk(rachis, 5 * scale, 2 * scale));
    for (let j = 0; j < 6; j++) {
      const q = bez(rachis, 0.18 + j * 0.15);
      const size = (130 - j * 6) * scale;
      for (const sgn of [1, -1]) d.push(blade(q.x, q.y, q.a + sgn * 1.25, size, size * 0.22, 7));
    }
    const tip = bez(rachis, 1);
    d.push(blade(tip.x, tip.y, tip.a, 125 * scale, 27 * scale, 7));
  }
  const [cx, cy, r] = cluster; // berries on short stalks, heaped into a dome
  for (let i = 0; i < 34; i++) {
    const a = -Math.PI * (0.08 + rand() * 0.84);
    const dist = r * Math.sqrt(rand());
    const [bx, by] = [cx + Math.cos(a) * dist * 1.25, cy + Math.sin(a) * dist * 0.8];
    d.push(stalk([[cx, cy + r * 0.5], [cx, cy], [bx, by + 20 * scale], [bx, by]], 3 * scale,
      2 * scale));
    dots.push([bx, by, (17 + rand() * 5) * scale]);
  }
  return { d: d.join(''), dots };
}

// The frontispiece: a photogram, the specimens left in paper white on a brushed field of
// sage, the way Anna Atkins printed her ferns in cyanotype.
function photogram(w, h) {
  const [W, H] = [w * PX, h * PX];
  const [x0, y0, x1, y1] = [70, 70, W - 70, H - 170]; // the brushed-on coating
  const phase = [rand(), rand(), rand()].map((r) => r * 6.3);
  const wob = (v, amp) => amp * (Math.sin(v / 37 + phase[0]) * 0.5 + Math.sin(v / 13 + phase[1])
    * 0.3 + Math.sin(v / 5.3 + phase[2]) * 0.2) + (rand() - 0.5) * amp * 0.4;
  const edge = [];
  for (let x = x0; x <= x1; x += 10) edge.push([x, y0 + wob(x, 7)]);
  for (let y = y0; y <= y1; y += 10) edge.push([x1 + wob(y, 16), y]);
  for (let x = x1; x >= x0; x -= 10) edge.push([x, y1 + wob(x + 99, 7)]);
  for (let y = y1; y >= y0; y -= 10) edge.push([x0 + wob(y + 55, 16), y]);
  const out = [`<defs><radialGradient id="g" cx="0.42" cy="0.38" r="0.85">`
    + `<stop offset="0" stop-color="${mix('sage', 'paper', 0.06)}"/>`
    + `<stop offset="1" stop-color="${mix('sage', 'ink', 0.4)}"/></radialGradient></defs>`,
  path(ring(edge), 'url(#g)')]; // a single path with a gradient fill; only its outline is ragged
  const frond = fern([[520, 1960], [380, 1350], [640, 700], [930, 190]], 400, 26);
  const tree = rowan([[1090, 1960], [1160, 1450], [960, 1060], [1000, 640]],
    [[0.2, -1, 300], [0.4, 1, 280], [0.58, -1, 290], [0.76, 1, 250]], [1000, 600, 150], 0.85);
  // Opaque, as a photogram is: where the two specimens overlap they print one white.
  out.push(path(frond, palette.paper), path(tree.d, palette.paper));
  for (const [x, y, r] of tree.dots) out.push(disk(x, y, r, palette.paper), disk(x, y - r * 0.2,
    r * 0.22, mix('sage', 'paper', 0.5)));
  return svgOf(w, h, out.join(''));
}

// The ornament: a rowan leaf laid flat, with a bunch of berries at its tip.
function sprig() {
  const [w, h] = [36, 12];
  const rib = [[16, 66], [90, 56], [170, 56], [236, 60]];
  const d = [stalk(rib, 6, 3)];
  for (let j = 0; j < 5; j++) {
    const q = bez(rib, 0.12 + j * 0.19);
    const size = 50 - j * 3;
    for (const sgn of [1, -1]) d.push(blade(q.x, q.y, q.a + sgn * 1.1, size, size * 0.22, 8));
  }
  const tip = bez(rib, 1);
  d.push(blade(tip.x, tip.y, tip.a, 44, 10.5, 8));
  const out = [path(d.join(''), palette.sage)];
  for (const [dx, dy, r] of [[34, -16, 9], [44, 2, 10], [30, 14, 9], [52, -12, 8.5],
    [58, 10, 8], [22, -2, 8]]) {
    const [bx, by] = [262 + dx, 60 + dy];
    out.push(path(stalk([[240, 64], [252, 66], [bx - 10, by + 2], [bx, by]], 2.6, 1.8),
      palette.sage), disk(bx, by, r, palette.sepia));
  }
  return svgOf(w, h, out.join(''));
}

const art = { plate: photogram(TRIM_W, TRIM_H), sprig: sprig() };
for (const [id, svg] of Object.entries(art)) await loadSvg(`${id}.svg`, svg);
// #endregion

// Nothing cites them: designs show the plate and the sprig, ::resource sets the tailpiece.
const svgResource = (id, w, h, altText) => ({ id, typeId: 'ornament', kind: 'svg', altText,
  createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: w * PX, height: h * PX } });
const resources = [
  svgResource('plate', TRIM_W, TRIM_H, 'A photogram of a fern frond and a sprig of rowan in '
    + 'berry, left white on a brushed sage ground.'),
  svgResource('sprig', 36, 12, 'Ornament: a rowan leaf with a bunch of berries.'),
];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Loaded before the first build (gotcha: fonts-first). None of the three ships a bold.
const FONTS = { 'Sorts Mill Goudy': ['400', '400i'], Italiana: ['400'], 'Marcellus SC': ['400'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
// :::toc lists the page each poem lands on: the build lays out again until those settle.
const verses = indentVerse(markdown); // the leading spaces of verse, as em spaces
const doc = await buildWithFonts(() => buildDocument({ markdown: verses, resources }, config()),
  markdown);
showPages(doc, { title: 'Pied Beauty · four poems by Gerard Manley Hopkins' });

// ─── 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

### Hang the turnovers deeper

A deeper hanging indent sets the rest of a turned line further from the margin, for poems whose lines turn over often.

```diff
-  hangingIndent: em(4), // a turned line hangs past the 1 and 2 em indents
+  hangingIndent: em(6), // a turned line hangs past the 1 and 2 em indents
```

### Set every line at the margin

Without `indentVerse`, Postext trims the leading spaces and every line of verse starts flush left; only the turnovers still hang.

```diff
-const verses = indentVerse(markdown); // the leading spaces of verse, as em spaces
+const verses = markdown; // leading spaces left to the trim
```

### Open every poem on a right-hand page

With parity `'odd'` each poem opens on the next recto. The book grows from eight pages to eleven, and the three blank versos print no folio, because the folios are filtered by page role.

```diff
 const poems = { level: 1, numberingTemplate: '{1:I}', advancedDesign: poemHead,
-  marginBottom: pt(0), breakBefore: { enabled: true, parity: 'any' } };
+  marginBottom: pt(0), breakBefore: { enabled: true, parity: 'odd' } };
```

### Number the poems in figures

The template's counter style changes the numeral on the poem's page and in the contents at once.

```diff
-const poems = { level: 1, numberingTemplate: '{1:I}', advancedDesign: poemHead,
+const poems = { level: 1, numberingTemplate: '{1}', advancedDesign: poemHead,
```

## Pitfalls

- **Ragged text is never hyphenated.** Hyphenation applies to justified text only; ragged-right text breaks between words, so a narrow ragged column gets a deep rag. Justify the passage or widen the measure.
- **'1998. ' or '- ' at a paragraph start opens a list.** A paragraph that starts with a number, a period and a space, or with a hyphen and a space, becomes a list item. Put a word joiner (U+2060) before the number, and write dialogue with an em dash.
- **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 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 }.
- **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.
- **::resource{id="…"} takes double quotes only.** A block embed is recognised only as ::resource{id="…"} with double quotes; any other form stays in the text as a visible line.
- **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 design text's lineHeight is a multiple, never a dimension.** In a design slot, a text element's lineHeight multiplies its font size (lineHeight: 1.05). In postext 1.4.1 a dimension such as pt(15) is not rejected: the opener's height measures as NaN, the room it reserves, minHeight included, is dropped without a warning and the text runs under the title.
- **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.
- **Quote every frontmatter value.** YAML reads title: 1984 as a number and a date as a Date object, and non-string values print empty in placeholders and leave the PDF without a title. Quote every value: title: "1984".
- **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.

- Leave a blank line after every line of verse. Two lines with no blank line between them are one paragraph, and the poem reflows as prose.
- Give the verse style its own `textAlign`. A paragraph style inherits the body's, and justified verse stretches the first part of every turned line across the measure.
- A line of verse that starts with a year and a full stop, such as "1877. ", or with a hyphen and a space becomes a list item, because every line is a paragraph. Put a word joiner (U+2060) in front of it, as `indentVerse` does before an indent.
- To turn two words over together, tie them with a no-break space (U+00A0), as *the soil* and *bright wings.* are on page 5. It holds in a line of plain text; in a line with italic or bold the engine still breaks there.
- A footer element with `pages` left at `'all'` prints on blank pages too. The folio here is two elements, one for openers and one for body pages, so a blank verso stays blank.

## Credits

- Recipe: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: “God’s Grandeur”, “The Windhover”, “Pied Beauty” and “Inversnaid”, with the places and dates from the editor’s notes, in the first edition of Poems of Gerard Manley Hopkins (1918); accents and indents checked against the proofread Wikisource transcription of that edition: Gerard Manley Hopkins; Robert Bridges (editor) ([source](https://www.gutenberg.org/ebooks/22403)), public domain
- Text: The note on the text, the plate’s caption and the colophon: Ignacio Ferro, CC-BY-4.0
- Images: The frontispiece photogram of fern and rowan and the rowan ornament, drawn in code in the page’s palette: Ignacio Ferro, CC-BY-4.0
- Type: Sorts Mill Goudy (OFL-1.1), Italiana (OFL-1.1), Marcellus SC (OFL-1.1)
- Code: MIT · Sample content: CC-BY-4.0

## Related

- [Nº 044 · Critical edition: line numbers and line-keyed notes](https://postext.dev/en/cookbook/critical-edition-line-numbers.md): Milton’s Lycidas in the 1645 text: a script counts the lines and adds a side box after every fifth, and each note opens on its line number, set as a chip. · Level 3 (Advanced) · Poetry
- [Nº 043 · Facing translation, stanza by stanza](https://postext.dev/en/cookbook/bilingual-facing-verse.md): Each poem is a frameless box with two columns: a fixed column break opens the English at its title, and :::space keeps each stanza level with its original. · Level 2 (Intermediate) · Poetry
- [Nº 062 · Shaped verse: Herbert’s Easter Wings, centred line by line](https://postext.dev/en/cookbook/shaped-verse.md): George Herbert’s Easter Wings and The Altar set as centred body text, a paragraph to each line of verse, so the lengths of the lines draw the two figures. · Level 1 (Basic) · Poetry
