# Poemes compostos vers a vers

> Cada vers és un paràgraf, i el que no hi cap continua amb 4 em de sagnat francès. Els espais eme guarden els sagnats de 1918; :::space separa les estrofes.

- Versió HTML: https://postext.dev/ca/cookbook/poetry-collection
- Recepta Núm. 015 · Text i tipografia · Nivell 2 (Intermedi) · Sortides: Canvas
- Gèneres: Poesia
- Requereix postext ≥ 1.4.1 · provada amb 1.9.2 el 2026-10-01
- Pàgines: [1](https://postext.dev/cookbook/poetry-collection/en/p01.webp?v=a486ccb9), [2](https://postext.dev/cookbook/poetry-collection/en/p02.webp?v=a486ccb9), [3](https://postext.dev/cookbook/poetry-collection/en/p03.webp?v=a486ccb9), [4](https://postext.dev/cookbook/poetry-collection/en/p04.webp?v=a486ccb9), [5](https://postext.dev/cookbook/poetry-collection/en/p05.webp?v=a486ccb9), [6](https://postext.dev/cookbook/poetry-collection/en/p06.webp?v=a486ccb9), [7](https://postext.dev/cookbook/poetry-collection/en/p07.webp?v=a486ccb9), [8](https://postext.dev/cookbook/poetry-collection/en/p08.webp?v=a486ccb9)
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=poetry-collection&lang=en (.postext: https://postext.dev/cookbook/poetry-collection/en/poetry-collection.postext)
- Última actualització: 2026-09-25
- Altres idiomes: [en](https://postext.dev/en/cookbook/poetry-collection.md), [es](https://postext.dev/es/cookbook/poetry-collection.md), [zh](https://postext.dev/zh/cookbook/poetry-collection.md), [ar](https://postext.dev/ar/cookbook/poetry-collection.md)

## En poques paraules

Un llibret amb quatre poemes sobre la natura de Gerard Manley Hopkins. Ensenya a respectar cada vers per separat, amb els sagnats originals del poeta i una línia en blanc entre estrofes.

## Què compondràs

Un poemari de 140 × 216 mm amb quatre poemes de Gerard Manley Hopkins sobre la natura, segons la primera edició (1918). Després de l'avantportada, un fotograma de falguera i server fa de frontispici davant de la portada, composta en Italiana. L'índex dona el número romà i el títol en cursiva de cada poema, amb punts espaiats fins a la pàgina. Cada poema obre pàgina sota el seu número. Els versos són paràgrafs i conserven els sagnats de 1918: a *Pied Beauty* s'esglaonen segons la rima, i els llargs de *The Windhover* continuen a la línia de sota amb 4 em de sagnat, el doble que els seus sagnats. Entre estrofes queda una línia en blanc, i cap paraula no es parteix tret d'on la va partir Hopkins. Sota cada poema va la data, a la dreta, i després de l'últim, una branqueta de server. Els poemes porten el foli centrat a baix.

**Aquesta recepta respon a:**

- Com componc poemes vers a vers, amb sagnats, estrofes separades, sagnat francès i sense partir paraules?
- Com afegeixo espai vertical entre dos blocs, si les línies en blanc no fan res?
- Com afegeixo un índex que s'actualitzi sol (línies de punts, números de pàgina, autors, files de part)?
- Com componc un epígraf, una dedicatòria, una signatura o una cita destacada amb una cometa gran?
- Com componc il·lustracions sense numerar: ornaments, vinyetes, logotips?

## La resposta curta

```js
// script.js, línies 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

**Ensenya**

- [Estils de paràgraf](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf): Estils amb nom per a grups de paràgrafs que s'aparten del text general: versos, epígrafs, dedicatòries, signatures, lletra petita.
- [Espai vertical explícit](https://postext.dev/ca/docs/document-format.md#space): Afegeix línies senceres o fraccionàries d'espai entre dos blocs, allà on les línies en blanc no afegeixen res; es descarta al principi d'una columna.
- [Índex de continguts](https://postext.dev/ca/docs/configuration.md#índex-de-continguts): Un :::toc generat a partir dels títols: línies de punts, números de pàgina tal com s'imprimeixen, línies d'autor, files de part en color i entrades amb enllaç al PDF.

**També fa servir**

- [Sagnats, alineació i separació de paràgrafs](https://postext.dev/ca/docs/configuration.md#text-de-cos)
- [Escapaments i caràcters literals](https://postext.dev/ca/docs/document-format.md#normes-de-redacció)
- [Títols numerats](https://postext.dev/ca/docs/configuration.md#configuració-per-nivell)
- [Obertures dissenyades](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Estils de títol](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Capítols sense número](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Imatges als dissenys de pàgina](https://postext.dev/ca/docs/configuration.md#elements-dimatge)
- [Cobertes, portades i colofons](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Metadades del document](https://postext.dev/ca/docs/document-format.md#frontmatter)
- [Capçaleres per secció](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Tipus de recurs propis](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs)
- [Figures just aquí](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita)
- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration.md#paleta-de-colors)
- [Capçaleres segons el tipus de pàgina](https://postext.dev/ca/docs/configuration.md#elements-de-text)
- [Bibliografies i glossaris](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf)
- [Color del paper](https://postext.dev/ca/docs/configuration.md#pàgina)
- [Figures i taules com a recursos](https://postext.dev/ca/docs/document-format.md#recursos)

**La configuració d'un cop d'ull**

- [`bodyText`](https://postext.dev/ca/docs/configuration.md#text-de-cos), [`colorPalette`](https://postext.dev/ca/docs/configuration.md#paleta-de-colors), [`footer`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`header`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`headingStyles`](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament), [`headings`](https://postext.dev/ca/docs/configuration.md#encapçalaments), [`layout`](https://postext.dev/ca/docs/configuration.md#disposició), [`page`](https://postext.dev/ca/docs/configuration.md#pàgina), [`paragraphStyles`](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf), [`resourceTypes`](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs), [`toc`](https://postext.dev/ca/docs/configuration.md#índex-de-continguts)

**API**

- [`buildDocument`](https://postext.dev/ca/docs/configuration.md#construir-un-document), [`clearMeasurementCache`](https://postext.dev/ca/docs/configuration.md#memòria-cau-de-mesures), [`registerResourceImage`](https://postext.dev/ca/docs/architecture.md#superfície-dapi), [`renderPageToCanvas`](https://postext.dev/ca/docs/configuration.md#renderitzar-una-pàgina-a-un-bitmap)

**Tipus de lletra**

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

## Elaboració

### 1 · Cada vers és un paràgraf

El codi és [la resposta curta](#la-resposta-curta) de més amunt. Postext ajunta en una sola les línies d'un paràgraf de Markdown, i per això cada vers ha de ser un paràgraf. L'estil del vers els compon en bandera, perquè justificats estirarien fins a la mesura la primera part d'un vers partit. El que passa a la línia següent porta 4 em de sagnat francès, més que els 1 o 2 em dels versos sagnats per la rima, perquè ningú no ho prengui per un altre vers ([estils de paràgraf](/ca/docs/configuration#estils-de-paràgraf)). Un paràgraf perd els espais amb què comença, també els espais eme, i un estil amb sagnat francès no aplica el de primera línia. Per això `indentVerse` converteix cada parell d'espais inicials en un espai eme precedit d'un unidor de paraules, un caràcter d'amplada zero on s'atura el retall. Els versos que rimen entre si queden a la mateixa distància del marge, com el 1918. Les línies en blanc no afegeixen espai: cada separació entre estrofes és una línia `:::space` dins del contenidor, que deixa una línia de la retícula de base i es descarta si cau al capdamunt d'una pàgina ([`:::space`](/ca/docs/document-format#space)).

![Opening page 6: II The Windhover.](https://postext.dev/cookbook/poetry-collection/en/p06.webp?v=a486ccb9)

*The Windhover, pàgina 6: els versos que rimen en -iding entren 2 em, i els quatre que no hi caben continuen a la línia de sota, amb 4 em de sagnat francès.*

### 2 · Les línies que envolten un poema també tenen estil

```js
// script.js, línies 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
];
```

La dedicatòria de *The Windhover*, damunt del poema, i el lloc i la data, sota cadascun, acompanyen els versos sense formar-ne part. Cadascuna d'aquestes línies és un contenidor `:::paragraphs` amb un sol paràgraf, i per això admet un estil propi. Cap dels dos estils no fixa l'interlineat i els seus marges fan una línia (15 pt), de manera que el poema no surt de la retícula del cos. La data queda una línia per sota de l'últim vers, alineada a la dreta, en Marcellus SC de 8 pt, la mateixa lletra dels folis.

### 3 · Cada poema obre pàgina sota el seu número

```js
// script.js, línies 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' } };
```

En lloc del seu text, el títol imprimeix un disseny a la columna. Allà, `{number}` és el comptador amb el format de `numberingTemplate: '{1:I}'`, de I a IV, i `{titleText}` és el títol ([span i disseny avançat](/ca/docs/configuration#span-i-disseny-avançat)). El nivell no té marge inferior, i `minHeight` fa que el títol de cada poema ocupi sis línies de la retícula (31,75 mm). El número i un títol d'una línia n'omplen quatre, i un títol que passa a dues línies n'omple cinc; així, tots els poemes comencen a la mateixa línia de la pàgina. A la [pàgina 6](https://postext.dev/cookbook/poetry-collection/en/p06.webp?v=a486ccb9), aquesta línia és la de la dedicatòria. La paritat `'any'` salta a la pàgina següent, parella o senar, i entre els poemes no queda cap pàgina en blanc.

### 4 · Els preliminars també són títols

```js
// script.js, línies 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) },
];
```

Un estil de títol pot saltar a una pàgina nova i dibuixar-hi un disseny, així que l'avantportada, el frontispici i la portada són un títol cadascun, amb el seu estil. Amb `numbered: false` no entren al compte, i el primer poema continua sent el I. `toc: false` els deixa fora de l'índex, i com que el seu peu de pàgina és buit, les seves pàgines no porten foli ([estils de títol](/ca/docs/configuration#estils-dencapçalament)). Els estils ocupen la pàgina sencera perquè un disseny a la columna es retalla a la caixa de text: la làmina, que va a sang, perdria les vores i es quedaria sense peu. El peu de la làmina surt de l'atribut `caption` del títol; l'autor, el títol i el subtítol de la portada, del frontmatter.

### 5 · L'índex s'omple sol

```js
// script.js, línies 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` recull cada títol l'estil del qual l'admet a l'índex, amb el número de la mateixa plantilla i l'etiqueta de la pàgina on cau. El llibre es torna a compondre fins que aquestes etiquetes deixen de canviar ([índex de continguts](/ca/docs/configuration#índex-de-continguts)). El títol de la mateixa pàgina, Contents, té un estil amb `numbered: false`; sense aquest estil, Contents seria el poema I. Amb `'. '` com a caràcter de farciment, cada punt va seguit d'un espai. Els punts s'alineen a la dreta, perquè coincideixin d'una entrada a l'altra, i surten amb la lletra i el color dels números de pàgina, perquè `toc.leader` no té ajust de color.

### 6 · Un ornament és un tipus de recurs sense rètol

```js
// script.js, línies 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' } };
```

Una imatge al text és un recurs, i tot recurs té un tipus. El tipus de l'ornament té buit el prefix del peu i la branqueta no porta peu, de manera que a sota no apareix cap rètol com «Figure 1» ([tipus de recurs](/ca/docs/configuration#tipus-de-recurs)). El `defaultPlacement` del tipus col·loca la branqueta on és `::resource{id="sprig"}`, centrada i amb una cinquena part de la mesura; per al cul-de-llàntia sota *Inversnaid* n'hi ha prou amb aquesta línia del Markdown. `resourceTypes` substitueix la llista predeterminada, per la qual cosa aquest llibre no té tipus de figura ni de taula. Si els necessites, posa `...defaultResourceTypes()` a la llista, davant de l'ornament.

## La recepta completa

Un sol fitxer, compost a partir de la carpeta de la recepta amb el text d'exemple i el kit comú del Receptari ja inclosos; construeix la seva pròpia pàgina. Per executar-lo, posa'l en un `<script type="module">` d'una pàgina buida o enganxa'l al tauler JS d'un pen nou de CodePen (com a mòdul). Importa postext des d'esm.sh, així que no cal instal·lar ni compilar res.

- Carpeta de la recepta: 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: diffusion models · Sprig: 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 subtitle, 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 made with diffusion models; 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 rowan sprig for the ornament, drawn in code; the plate is a JPEG in assets/
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()]);
};
// 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(''));
}

await loadSvg('sprig.svg', sprig());
// The frontispiece is a photogram: the whole 140 × 216 mm page, its torn coating and the paper
// under it in one JPEG, declared at its pixels.
await loadImage('plate-1120.jpg', asset('plate-1120.jpg'));

// 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 = [
  { id: 'plate', typeId: 'ornament', kind: 'bitmap', createdAt: 0, updatedAt: 0,
    bitmap: { fileId: 'plate-1120.jpg', format: 'jpeg', width: 1120, height: 1728 },
    altText: '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.'),
];
// #endregion

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

## Variants

### Sagna més els versos partits

Amb un sagnat francès més gran, la resta de cada vers partit queda més lluny del marge. Serveix per a poemes en què molts versos no caben a la línia.

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

### Comença tots els versos al marge

Sense `indentVerse`, Postext retalla els espais inicials i tots els versos comencen al marge; només els versos partits continuen sagnats.

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

### Obre cada poema en pàgina senar

Amb la paritat `'odd'`, cada poema salta a la pàgina senar següent. El llibre passa de vuit pàgines a onze, i les tres parelles que queden en blanc no porten foli, perquè els folis es filtren pel rol de la pàgina.

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

### Numera els poemes amb xifres

L'estil del comptador a la plantilla canvia alhora el número de la pàgina del poema i el de l'índex.

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

## Errors freqüents

- **En el text en bandera no hi ha partició de mots.** La partició de mots només s'aplica al text justificat; el text en bandera talla entre paraules, així que una columna estreta en bandera queda molt desigual. Justifica el passatge o eixampla la mesura.
- **«1998. » o «- » al començament d'un paràgraf obren una llista.** Un paràgraf que comença per un número, un punt i un espai, o per un guionet i un espai, es converteix en un element de llista. Posa un unidor de paraules (U+2060) davant del número i escriu els diàlegs amb guió llarg.
- **Els números de l'índex queden una mica per sobre de la línia de base de l'entrada.** A postext 1.4.1, :::toc pinta el número de cada entrada centrat a la línia i no sobre la línia de base del text, de manera que els números de capítol queden una mica alts al costat dels seus títols, uns 0,7 mm al costat d'un títol de 16 pt, sigui quina sigui la font o la mida. Encara no hi ha cap opció de toc que els mogui, així que revisa l'índex a mida real abans d'imprimir.
- **{number}/{chapterNumber} donen el número de l'H1; {numberRoman} només en parts.** {number} i {chapterNumber} imprimeixen el número ja formatat del títol, però {numberRoman}, {numberDecimal} i les altres variants numèriques només s'emplenen a les pàgines de part. Dona format al número de capítol al seu numberingTemplate ({1:I}) o passa'l com a atribut.
- **Un estil de títol hereta el salt de pàgina del seu nivell.** Una entrada de headingStyles pren del seu nivell de títol tot allò que no fixa, també breakBefore. Un índex o un colofó amb estil sobre un H1 després d'un :::pagebreak hereta la paritat 'odd' i cau darrere d'una pàgina en blanc. Dona a aquest estil breakBefore: { enabled: false }.
- **Qualsevol objecte headings desactiva el salt de pàgina de l'H1.** Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració.
- **::resource{id="…"} només admet cometes dobles.** Una inserció de bloc només es reconeix com a ::resource{id="…"} amb cometes dobles; qualsevol altra forma es queda al text com una línia visible.
- **Una paleta canviada no arriba als elements de disseny ni al color de les remissions.** postext 1.4.1 aplica colorPalette als estils de text (cos, títols, llistes, peus, taules, requadres), però no als elements de capçaleres, peus de pàgina, obertures i portadelles, ni a bodyText.referenceColor: conserven l'hex escrit al costat del seu paletteId. Si canvies la paleta, per a una edició de pantalla fosca o per recolorejar, reescriu cada color enllaçat a partir de colorPalette abans de compondre.
- **El lineHeight d'un text de disseny és un múltiple, mai una mesura.** En una ranura de disseny, el lineHeight d'un element de text multiplica el seu cos (lineHeight: 1.05). A postext 1.4.1 una mesura com pt(15) no dona error: l'alçada de l'obertura surt NaN, l'espai que reserva, minHeight inclòs, es perd sense avís i el text se superposa al títol.
- **El desbordament del text de disseny és 'ellipsis-end' per defecte.** Un element de text de disseny que no cap en la seva amplada acaba en punts suspensius per defecte. Posa overflow: 'wrap' als títols que hagin de passar a més línies.
- **Posa entre cometes cada valor del frontmatter.** YAML llegeix title: 1984 com un nombre i una data com un objecte Date, i els valors que no són cadenes s'imprimeixen buits als marcadors i deixen el PDF sense títol. Posa entre cometes cada valor: title: "1984".
- **Carrega totes les fonts abans de compondre.** La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard.

- Deixa una línia en blanc després de cada vers. Dos versos sense una línia en blanc entre ells formen un sol paràgraf, i el poema es recompon com a prosa.
- Dona a l'estil del vers el seu propi `textAlign`. Un estil de paràgraf hereta el del cos, i el vers justificat estira fins a la mesura la primera part de cada vers partit.
- Un vers que comença per un any i un punt, com «1877. », o per un guionet i un espai es converteix en un element de llista, perquè cada vers és un paràgraf. Posa-hi al davant un unidor de paraules (U+2060), com fa `indentVerse` abans d'un sagnat.
- Perquè dues paraules passin juntes a la línia de sota, uneix-les amb un espai de no separació (U+00A0), com *the soil* i *bright wings.* a la pàgina 5. Es manté en un vers de text pla; en un amb cursiva o negreta el motor continua tallant-hi.
- Un element del peu de pàgina amb `pages` a `'all'` s'imprimeix també a les pàgines en blanc. Aquí el foli es compon de dos elements, un per a les obertures i un altre per a les pàgines de text, així que una pàgina parella en blanc queda en blanc.

## Crèdits

- Recepta: 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) ([font](https://www.gutenberg.org/ebooks/22403)), domini públic
- Text: The note on the text, the plate’s caption and the colophon: Ignacio Ferro, CC-BY-4.0
- Imatges: The frontispiece photogram of fern and rowan: Generated With Diffusion Models, original
- Imatges: The rowan ornament, drawn in code in the page’s palette: Ignacio Ferro, CC-BY-4.0
- Tipus de lletra: Sorts Mill Goudy (OFL-1.1), Italiana (OFL-1.1), Marcellus SC (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 044 · Edició crítica: numeració de versos i notes per vers](https://postext.dev/ca/cookbook/critical-edition-line-numbers.md): El Lycidas de Milton, text de 1645: una funció compta els versos, posa el número cada cinc en un requadre lateral i obre cada nota amb el número del vers. · Nivell 3 (Avançat) · Poesia
- [Núm. 043 · Traducció acarada, estrofa a estrofa](https://postext.dev/ca/cookbook/bilingual-facing-verse.md): Cada poema és un requadre sense marc amb dues columnes: un tall fix obre l'anglesa al títol i :::space deixa cada estrofa a l'altura de l'original. · Nivell 2 (Intermedi) · Poesia
- [Núm. 062 · Poemes amb forma: Ales de Pasqua, amb cada vers centrat](https://postext.dev/ca/cookbook/shaped-verse.md): Ales de Pasqua i L'altar, de George Herbert, compostos en text centrat, un paràgraf per vers, perquè la llargada dels versos dibuixi les dues figures. · Nivell 1 (Bàsic) · Poesia
