# Poemas compuestos verso a verso

> Cada verso es un párrafo, y el que no cabe sigue con 4 em de sangría francesa. Los espacios eme guardan las sangrías de 1918; :::space separa las estrofas.

- Versión HTML: https://postext.dev/es/cookbook/poetry-collection
- Receta N.º 015 · Texto y tipografía · Nivel 2 (Intermedio) · Salidas: Canvas
- Géneros: Poesía
- Requiere postext ≥ 1.4.1 · probada con 1.4.1 el 2026-09-26
- Páginas: [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)
- Última actualización: 2026-09-25
- Otros idiomas: [en](https://postext.dev/en/cookbook/poetry-collection.md)

## Lo que vas a componer

Un poemario de 140 × 216 mm con cuatro poemas de Gerard Manley Hopkins sobre la naturaleza, según la primera edición (1918). Tras la anteportada, un fotograma de helecho y serbal hace de frontispicio frente a la portada, compuesta en Italiana. El índice da el número romano y el título en cursiva de cada poema, con puntos espaciados hasta la página. Cada poema abre página bajo su número. Los versos son párrafos y conservan las sangrías de 1918: en *Pied Beauty* se escalonan según la rima, y los largos de *The Windhover* siguen en la línea de abajo con 4 em de sangría, el doble que sus sangrías. Entre estrofas queda una línea en blanco, y ninguna palabra se parte salvo donde la partió Hopkins. Debajo de cada poema va la fecha, a la derecha, y tras el último, una ramita de serbal. Los poemas llevan el folio centrado abajo.

**Esta receta responde a:**

- ¿Cómo compongo poemas verso a verso, con sangrías, estrofas separadas, sangría francesa y sin partir palabras?
- ¿Cómo añado espacio vertical entre dos bloques, si las líneas en blanco no hacen nada?
- ¿Cómo añado un índice que se actualice solo (líneas de puntos, números de página, autores, filas de parte)?
- ¿Cómo compongo un epígrafe, una dedicatoria, una firma o una cita destacada con una comilla grande?
- ¿Cómo compongo ilustraciones sin numerar: adornos, viñetas, logotipos?

## La respuesta corta

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

## Ingredientes

**Enseña**

- [Estilos de párrafo](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo): Estilos con nombre para grupos de párrafos que se apartan del texto general: versos, epígrafes, dedicatorias, firmas, letra pequeña.
- [Espacio vertical explícito](https://postext.dev/es/docs/document-format.md#space): Añade líneas enteras o fraccionarias de espacio entre dos bloques, donde las líneas en blanco no añaden nada; se descarta al principio de una columna.
- [Índice de contenidos](https://postext.dev/es/docs/configuration.md#índice-de-contenidos): Un :::toc generado a partir de los títulos: líneas de puntos, números de página tal como se imprimen, líneas de autor, filas de parte en color y entradas con enlace en el PDF.

**También usa**

- [Sangrías, alineación y separación de párrafos](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo)
- [Escapes y caracteres literales](https://postext.dev/es/docs/document-format.md#normas-de-redacción)
- [Títulos numerados](https://postext.dev/es/docs/configuration.md#configuración-por-nivel)
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Estilos de título](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Capítulos sin número](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Imágenes en los diseños de página](https://postext.dev/es/docs/configuration.md#elementos-de-imagen)
- [Cubiertas, portadas y colofones](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Metadatos del documento](https://postext.dev/es/docs/document-format.md#frontmatter)
- [Cabeceras por sección](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Cabeceras y folios](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Tipos de recurso propios](https://postext.dev/es/docs/configuration.md#tipos-de-recurso)
- [Figuras justo aquí](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration.md#paleta-de-colores)
- [Cabeceras según el tipo de página](https://postext.dev/es/docs/configuration.md#elementos-de-texto)
- [Bibliografías y glosarios](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)
- [Color del papel](https://postext.dev/es/docs/configuration.md#página)
- [Figuras y tablas como recursos](https://postext.dev/es/docs/document-format.md#recursos)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo), [`colorPalette`](https://postext.dev/es/docs/configuration.md#paleta-de-colores), [`footer`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`header`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`headingStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado), [`headings`](https://postext.dev/es/docs/configuration.md#encabezados), [`layout`](https://postext.dev/es/docs/configuration.md#disposición), [`page`](https://postext.dev/es/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo), [`resourceTypes`](https://postext.dev/es/docs/configuration.md#tipos-de-recurso), [`toc`](https://postext.dev/es/docs/configuration.md#índice-de-contenidos)

**API**

- [`buildDocument`](https://postext.dev/es/docs/configuration.md#construir-un-documento), [`clearMeasurementCache`](https://postext.dev/es/docs/configuration.md#caché-de-medidas), [`registerResourceImage`](https://postext.dev/es/docs/architecture.md#superficie-de-api), [`renderPageToCanvas`](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap)

**Tipografías**

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

## Elaboración

### 1 · Cada verso es un párrafo

El código es [la respuesta corta](#la-respuesta-corta) de arriba. Postext junta en una sola las líneas de un párrafo de Markdown, de ahí que cada verso tenga que ser un párrafo. El estilo del verso los compone en bandera, porque justificados estirarían hasta la medida la primera parte de un verso partido. Lo que pasa a la línea siguiente lleva 4 em de sangría francesa, más que los 1 o 2 em de los versos sangrados por la rima, para que nadie lo tome por otro verso ([estilos de párrafo](/es/docs/configuration#estilos-de-párrafo)). Un párrafo pierde los espacios con que empieza, también los espacios eme, y un estilo con sangría francesa no aplica la de primera línea. Por eso `indentVerse` convierte cada par de espacios iniciales en un espacio eme precedido de un unidor de palabras, un carácter de ancho cero donde se detiene el recorte. Los versos que riman entre sí quedan a la misma distancia del margen, como en 1918. Las líneas en blanco no añaden espacio: cada separación entre estrofas es una línea `:::space` dentro del contenedor, que deja una línea de la rejilla base y se descarta si cae en lo alto de una página ([`:::space`](/es/docs/document-format#space)).

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

*The Windhover, página 6: los versos que riman en -iding entran 2 em, y los cuatro que no caben en la página siguen en la línea de abajo, con 4 em de sangría francesa.*

### 2 · Las líneas que rodean un poema también tienen estilo

```js
// script.js, líneas 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 dedicatoria de *The Windhover*, encima del poema, y el lugar y la fecha, debajo de cada uno, acompañan a los versos sin formar parte de ellos. Cada una de esas líneas es un contenedor `:::paragraphs` con un solo párrafo, y por eso admite un estilo propio. Ninguno de los dos estilos fija el interlineado y sus márgenes miden una línea (15 pt), de modo que el poema no se sale de la rejilla del cuerpo. La fecha queda una línea por debajo del último verso, alineada a la derecha, en Marcellus SC de 8 pt, la misma letra de los folios.

### 3 · Cada poema abre página bajo su número

```js
// script.js, líneas 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 lugar de su texto, el título imprime un diseño en la columna. Ahí, `{number}` es el contador con el formato de `numberingTemplate: '{1:I}'`, de I a IV, y `{titleText}` es el título ([span y diseño avanzado](/es/docs/configuration#span-y-diseño-avanzado)). El nivel no tiene margen inferior, y `minHeight` hace que el título de cada poema ocupe seis líneas de la rejilla (31,75 mm). El número y un título de una línea llenan cuatro, y un título que pasa a dos líneas llena cinco; así, todos los poemas empiezan en la misma línea de la página. En la [página 6](https://postext.dev/cookbook/poetry-collection/en/p06.webp?v=d99a3d85), esa línea es la de la dedicatoria. La paridad `'any'` salta a la página siguiente, par o impar, y entre los poemas no queda ninguna página en blanco.

### 4 · Los preliminares también son títulos

```js
// script.js, líneas 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 estilo de título puede saltar a una página nueva y dibujar en ella un diseño, así que la anteportada, el frontispicio y la portada son un título cada uno, con su estilo. Con `numbered: false` no entran en la cuenta, y el primer poema sigue siendo el I. `toc: false` los deja fuera del índice, y como su pie de página está vacío, sus páginas no llevan folio ([estilos de encabezado](/es/docs/configuration#estilos-de-encabezado)). Los estilos ocupan la página entera porque un diseño en la columna se recorta a la caja de texto: la lámina, que va a sangre, perdería los bordes y se quedaría sin pie. El pie de la lámina sale del atributo `caption` del título; el autor, el título y el subtítulo de la portada, del frontmatter.

### 5 · El índice se rellena solo

```js
// script.js, líneas 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` recoge cada título cuyo estilo lo admite en el índice, con el número de la misma plantilla y la etiqueta de la página donde cae. El libro se compone de nuevo hasta que esas etiquetas dejen de cambiar ([índice de contenidos](/es/docs/configuration#índice-de-contenidos)). El título de la propia página, Contents, tiene un estilo con `numbered: false`; sin él, Contents sería el poema I. Con `'. '` como carácter de relleno, cada punto va seguido de un espacio. Los puntos se alinean a la derecha, para que coincidan de una entrada a otra, y salen con la letra y el color de los números de página, porque `toc.leader` no tiene ajuste de color.

### 6 · Un adorno es un tipo de recurso sin rótulo

```js
// script.js, líneas 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 imagen en el texto es un recurso, y todo recurso tiene un tipo. El tipo del adorno tiene vacío el prefijo del pie y la ramita no lleva pie, de modo que debajo no aparece ningún rótulo como «Figure 1» ([tipos de recurso](/es/docs/configuration#tipos-de-recurso)). El `defaultPlacement` del tipo coloca la ramita donde está `::resource{id="sprig"}`, centrada y con un quinto de la medida; para el remate bajo *Inversnaid* basta esa línea del Markdown. `resourceTypes` sustituye a la lista predeterminada, por lo que este libro no tiene tipos de figura ni de tabla. Si los necesitas, pon `...defaultResourceTypes()` en la lista, delante del adorno.

## La receta completa

Un solo archivo, compuesto a partir de la carpeta de la receta con el texto de ejemplo y el kit común del Recetario ya incluidos; construye su propia página. Para ejecutarlo, ponlo en un `<script type="module">` de una página vacía o pégalo en el panel JS de un pen nuevo de CodePen (como módulo). Importa postext desde esm.sh, así que no hay nada que instalar ni compilar.

- Carpeta de la receta: 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 ───────────────────────────────────────────────────────────────────────
```

## Variantes

### Sangra más los versos partidos

Con una sangría francesa mayor, el resto de cada verso partido queda más lejos del margen. Sirve para poemas en los que muchos versos no caben en la línea.

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

### Empieza todos los versos en el margen

Sin `indentVerse`, Postext recorta los espacios iniciales y todos los versos empiezan en el margen; solo los versos partidos siguen sangrados.

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

### Abre cada poema en página impar

Con la paridad `'odd'`, cada poema salta a la siguiente página impar. El libro pasa de ocho páginas a once, y las tres pares que quedan en blanco no llevan folio, porque los folios se filtran por el 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 los poemas con cifras

El estilo del contador en la plantilla cambia a la vez el número de la página del poema y el del índice.

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

## Errores frecuentes

- **En el texto en bandera no hay separación silábica.** La separación silábica solo se aplica al texto justificado; el texto en bandera corta entre palabras, así que una columna estrecha en bandera queda muy desigual. Justifica el pasaje o ensancha la medida.
- **«1998. » o «- » al principio de un párrafo abren una lista.** Un párrafo que empieza por un número, un punto y un espacio, o por un guion y un espacio, se convierte en un elemento de lista. Pon un unidor de palabras (U+2060) antes del número y escribe los diálogos con raya.
- **Los números del índice quedan algo por encima de la línea base de la entrada.** En postext 1.4.1, :::toc pinta el número de cada entrada centrado en la línea y no sobre la línea base del texto, así que los números de capítulo quedan algo altos junto a sus títulos, unos 0,7 mm al lado de un título de 16 pt, sea cual sea su fuente o su tamaño. Todavía no hay opción de toc que los mueva, así que revisa el índice a tamaño real antes de imprimir.
- **{number}/{chapterNumber} dan el número del H1; {numberRoman} solo en partes.** {number} y {chapterNumber} imprimen el número ya formateado del título, pero {numberRoman}, {numberDecimal} y las demás variantes numéricas solo se rellenan en las páginas de parte. Da formato al número de capítulo en su numberingTemplate ({1:I}) o pásalo como atributo.
- **Un estilo de título hereda el salto de página de su nivel.** Una entrada de headingStyles toma de su nivel de título todo lo que no fija, también breakBefore. Un índice o un colofón con estilo sobre un H1 tras un :::pagebreak hereda la paridad 'odd' y cae detrás de una página en blanco. Dale a ese estilo breakBefore: { enabled: false }.
- **Cualquier objeto headings desactiva el salto de página del H1.** Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración.
- **::resource{id="…"} solo admite comillas dobles.** Una inserción de bloque solo se reconoce como ::resource{id="…"} con comillas dobles; cualquier otra forma se queda en el texto como una línea visible.
- **Una paleta cambiada no llega a los elementos de diseño ni al color de las remisiones.** postext 1.4.1 aplica colorPalette a los estilos de texto (cuerpo, títulos, listas, pies, tablas, recuadros), pero no a los elementos de cabeceras, pies de página, aperturas y portadillas, ni a bodyText.referenceColor: conservan el hex escrito junto a su paletteId. Si cambias la paleta, para una edición de pantalla oscura o para recolorear, reescribe cada color enlazado a partir de colorPalette antes de componer.
- **El lineHeight de un texto de diseño es un múltiplo, nunca una medida.** En una ranura de diseño, el lineHeight de un elemento de texto multiplica su cuerpo (lineHeight: 1.05). En postext 1.4.1 una medida como pt(15) no da error: la altura de la apertura sale NaN, el espacio que reserva, minHeight incluido, se pierde sin aviso y el texto se superpone al título.
- **El desbordamiento del texto de diseño es 'ellipsis-end' por defecto.** Un elemento de texto de diseño que no cabe en su ancho termina en puntos suspensivos por defecto. Pon overflow: 'wrap' en los títulos que deban pasar a más líneas.
- **Entrecomilla cada valor del frontmatter.** YAML lee title: 1984 como un número y una fecha como un objeto Date, y los valores que no son cadenas se imprimen vacíos en los marcadores y dejan el PDF sin título. Entrecomilla cada valor: title: "1984".
- **Carga todas las fuentes antes de componer.** La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde.

- Deja una línea en blanco después de cada verso. Dos versos sin una línea en blanco entre ellos forman un solo párrafo, y el poema se recompone como prosa.
- Da al estilo del verso su propio `textAlign`. Un estilo de párrafo hereda el del cuerpo, y el verso justificado estira hasta la medida la primera parte de cada verso partido.
- Un verso que empieza por un año y un punto, como «1877. », o por un guion y un espacio se convierte en un elemento de lista, porque cada verso es un párrafo. Pon delante un unidor de palabras (U+2060), como hace `indentVerse` antes de una sangría.
- Para que dos palabras pasen juntas a la línea de abajo, únelas con un espacio de no separación (U+00A0), como *the soil* y *bright wings.* en la página 5. Se mantiene en un verso de texto llano; en uno con cursiva o negrita el motor sigue cortando ahí.
- Un elemento del pie de página con `pages` en `'all'` se imprime también en las páginas en blanco. Aquí el folio se compone de dos elementos, uno para las aperturas y otro para las páginas de texto, así que una página par en blanco se queda en blanco.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texto: «God’s Grandeur», «The Windhover», «Pied Beauty» e «Inversnaid», con los lugares y las fechas de las notas del editor, en la primera edición de Poems of Gerard Manley Hopkins (1918); los acentos y las sangrías, cotejados con la transcripción revisada de esa edición en Wikisource: Gerard Manley Hopkins; Robert Bridges (editor) ([fuente](https://www.gutenberg.org/ebooks/22403)), dominio público
- Texto: La nota sobre el texto, el pie de la lámina y el colofón: Ignacio Ferro, CC-BY-4.0
- Imágenes: El fotograma del frontispicio, con helecho y serbal, y el adorno de serbal, dibujados en código con la paleta de la página: Ignacio Ferro, CC-BY-4.0
- Tipografías: Sorts Mill Goudy (OFL-1.1), Italiana (OFL-1.1), Marcellus SC (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 044 · Edición crítica: numeración de versos y notas por verso](https://postext.dev/es/cookbook/critical-edition-line-numbers.md): El Lycidas de Milton, texto de 1645: una función cuenta los versos, pone el número cada cinco en un recuadro lateral y abre cada nota con el número del verso. · Nivel 3 (Avanzado) · Poesía
- [N.º 043 · Traducción enfrentada, estrofa a estrofa](https://postext.dev/es/cookbook/bilingual-facing-verse.md): Cada poema es un recuadro sin marco con dos columnas: un corte fijo abre la inglesa en su título y :::space deja cada estrofa a la altura de su original. · Nivel 2 (Intermedio) · Poesía
- [N.º 062 · Poemas con forma: Alas de Pascua, con cada verso centrado](https://postext.dev/es/cookbook/shaped-verse.md): Alas de Pascua y El altar, de George Herbert, compuestos en texto centrado, un párrafo por verso, para que la longitud de los versos dibuje las dos figuras. · Nivel 1 (Básico) · Poesía
