# Edición crítica: numeración de versos y notas por verso

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

- Versión HTML: https://postext.dev/es/cookbook/critical-edition-line-numbers
- Receta N.º 044 · Texto y tipografía · Nivel 3 (Avanzado) · 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/critical-edition-line-numbers/en/p01.webp?v=3142c41b), [2](https://postext.dev/cookbook/critical-edition-line-numbers/en/p02.webp?v=3142c41b), [3](https://postext.dev/cookbook/critical-edition-line-numbers/en/p03.webp?v=3142c41b), [6](https://postext.dev/cookbook/critical-edition-line-numbers/en/p06.webp?v=3142c41b), [7](https://postext.dev/cookbook/critical-edition-line-numbers/en/p07.webp?v=3142c41b), [8](https://postext.dev/cookbook/critical-edition-line-numbers/en/p08.webp?v=3142c41b), [9](https://postext.dev/cookbook/critical-edition-line-numbers/en/p09.webp?v=3142c41b)
- Última actualización: 2026-09-26
- Otros idiomas: [en](https://postext.dev/en/cookbook/critical-edition-line-numbers.md)

## Lo que vas a componer

*Lycidas*, la elegía de Milton a la muerte de Edward King, en una edición crítica de 138 × 216 mm que sigue el texto y la ortografía de los *Poems* de 1645. La primera página se abre bajo una rama de laurel, con el título en versales altas de Imbue sobre la nota preliminar que Milton añadió en 1645. Siguen los 193 versos, con una línea en blanco entre estrofas y los versos cortos sangrados. La numeración va de cinco en cinco, en verde laurel, en una columna de 6 mm del lado exterior: a la derecha del poema en las páginas impares y a la izquierda en las pares. El poema no lleva llamadas de nota. Las notas ocupan las dos páginas siguientes, y cada una empieza por el número de su verso, en la misma Libre Franklin verde de la numeración.

**Esta receta responde a:**

- ¿Cómo numero al margen los versos de un poema de cinco en cinco y remito las notas a esos números?
- ¿Cómo compongo poesía: un verso por línea, espacio entre estrofas, sangría francesa en los versos que no caben y sin separación silábica?
- ¿Cómo hago notas al pie?
- ¿Cómo compongo en columna y media, con una columna de texto ancha y una lateral estrecha?
- ¿Cómo añado espacio vertical entre dos bloques, si las líneas en blanco no hacen nada?

## La respuesta corta

```js
// script.js, líneas 33–63
// The poem is written one line of verse to a line of Markdown, with a blank line between
// verse paragraphs and two spaces before a short line. numberVerse() gives each line a
// paragraph of its own and, after every fifth, a side box that holds its number. A side box
// stands where the text has reached at its fence, under the line it follows; a top padding
// of minus one line lifts the number back onto that line. Fenced before its line instead,
// the number of a line that opens a page slides up beside the last line of the page before
// (gotcha: side-box-starts-at-fence).
const EVERY = 5;
const rows = (...lines) => lines.join('\n');
function numberVerse(markdown) {
  return markdown.replace(/^:::paragraphs\{style="verse"\}\n([\s\S]*?)\n:::$/gm, (_, poem) => {
    let n = 0;
    return poem.split('\n').map((line) => {
      if (!line.trim()) return ':::space{lines=1}'; // one blank line of the grid
      n += 1;
      const style = line.startsWith('  ') ? 'short' : 'verse';
      const verse = rows(`:::paragraphs{style="${style}"}`, line.trim(), ':::');
      if (n % EVERY) return verse;
      return rows(verse, '', ':::callout{type="lineno" span="side"}',
        ':::paragraphs{style="number"}', n, ':::', ':::');
    }).join('\n\n');
  });
}
const lineno = { id: 'lineno', backgroundEnabled: false, // no box: only the number shows
  padding: { top: pt(-LEAD), right: pt(0), bottom: pt(0), left: pt(0) } };
// The number: right-aligned, so the numbers share a right edge, and at the verse's leading,
// so it sits on the baseline of its line.
const number = { id: 'number', fontFamily: LABEL, fontSize: pt(7.5), lineHeight: pt(LEAD),
  color: col('laurel'), textAlign: 'right' };
// Hook-up: calloutStyles: [lineno], paragraphStyles: [number, …] and
// buildDocument({ markdown: numberVerse(markdown) }, config()).
```

## Ingredientes

**Enseña**

- [Notas al margen](https://postext.dev/es/docs/configuration.md#estilos-de-aviso): Cajas en la columna lateral a la altura del párrafo que glosan, en columna y media con canal de flotantes.
- [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.

**También usa**

- [Columna al margen para flotantes](https://postext.dev/es/docs/configuration.md#disposición)
- [Columna y media](https://postext.dev/es/docs/configuration.md#tipos-de-disposición)
- [Chips en línea](https://postext.dev/es/docs/configuration.md#estilos-de-chip)
- [Recuadros](https://postext.dev/es/docs/configuration.md#estilos-de-aviso)
- [Márgenes simétricos](https://postext.dev/es/docs/configuration.md#márgenes-simétricos-espejo)
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Textos, filetes y cajas en los diseños de página](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Imágenes en los diseños de página](https://postext.dev/es/docs/configuration.md#elementos-de-imagen)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Estilos de título](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Metadatos del documento](https://postext.dev/es/docs/document-format.md#frontmatter)
- [Cabeceras y folios](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Cabeceras según el tipo de página](https://postext.dev/es/docs/configuration.md#elementos-de-texto)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration.md#paleta-de-colores)
- [Bibliografías y glosarios](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)
- [Banda de capítulo a todo el ancho](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [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), [`calloutStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-aviso), [`chipStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-chip), [`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)

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

- Linden Hill (OFL-1.1), Imbue (OFL-1.1), Libre Franklin (OFL-1.1)

## Elaboración

### 1 · Cuenta los versos antes de componer

El código de este paso es [la respuesta corta](#la-respuesta-corta), más arriba. Postext no numera líneas, así que `numberVerse()` cuenta los versos en el Markdown antes de componer. Da a cada verso un párrafo propio y, cada cinco versos, añade un `:::callout{type="lineno" span="side"}` con el número. Un recuadro lateral se coloca en su columna a la altura a la que ha llegado el texto en su valla, así que este empieza debajo del verso al que sigue. El relleno superior del estilo, de menos una línea (−14,5 pt), sube el número al nivel de ese verso y deja el recuadro sin altura ([estilos de aviso](/es/docs/configuration#estilos-de-aviso)). Con la valla antes del verso, el recuadro quedaría a su altura en casi todas las páginas, pero cuando el verso numerado abre página, el recuadro sube junto a la última línea de la página anterior. Dentro del recuadro, el estilo de párrafo `number` alinea las cifras a la derecha y les da el interlineado del verso, 14,5 pt, con lo que cada número cae en la línea base de su verso.

### 2 · Una columna de seis milímetros

```js
// script.js, líneas 67–83
const PT = 25.4 / 72; // mm in a point
const [TRIM_W, TRIM_H] = [138, 216]; // mm
const [TOP, INNER] = [21, 20]; // mm
const LINES = 34; // lines of verse to a page
const BOTTOM = TRIM_H - TOP - LINES * LEAD * PT; // 21.08 mm
const [MEASURE, GUTTER, CHANNEL] = [80, 4, 6]; // mm: the longest line of Lycidas is 78.1 mm
const OUTER = TRIM_W - INNER - MEASURE - GUTTER - CHANNEL; // 28 mm beyond the numbers
const layout = {
  layoutType: 'oneAndHalf',
  sideColumnRole: 'floats', // the side column takes side boxes, never text
  sideColumnSide: 'outer', // right of the verse on a recto, left of it on a verso
  // 6 of 90 mm. The zero is Libre Franklin's widest figure, so '100' (4.9 mm) is the widest number.
  sideColumnPercent: (CHANNEL / (MEASURE + GUTTER + CHANNEL)) * 100,
  gutterWidth: mm(GUTTER),
};
const page = { sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150,
  margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: true } };
```

Con `sideColumnRole: 'floats'`, la columna estrecha de una disposición `oneAndHalf` recibe recuadros laterales y nunca texto ([tipos de disposición](/es/docs/configuration#tipos-de-disposición)). `sideColumnPercent` es un porcentaje de la caja de texto, y el código lo calcula a partir de milímetros: 6 de 90 mm, suficientes para el número más ancho. Las cifras de Libre Franklin son proporcionales, y a 7,5 pt el cero es la más ancha (1,8 mm, frente a 1,2 mm del uno), así que el número más ancho del poema es el 100, con 4,9 mm; el 180 y el 190 miden 4,8 mm. `'outer'` sigue a los márgenes simétricos, de modo que los números quedan a la derecha del verso en las páginas impares y a la izquierda en las pares; en las impares acaban en el mismo borde que el folio de encima. La columna del verso mide 80 mm, 1,9 mm más que el verso 31, el más largo, así que ningún verso pasa a la línea siguiente.

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

```js
// script.js, líneas 87–93
const verseStyles = [
  // Ragged, like all the text here (bodyText). A line too long for the measure would turn
  // over and hang 2 em in; none does at 80 mm.
  { id: 'verse', hangingIndent: em(2) },
  // Milton's short lines: a one-line paragraph, so the first-line indent moves all of it.
  { id: 'short', firstLineIndent: em(2) },
];
```

Postext une las líneas de un párrafo de Markdown, así que cada verso necesita un párrafo propio, y `numberVerse()` pone cada uno en un contenedor `:::paragraphs` con el estilo del verso ([estilos de párrafo](/es/docs/configuration#estilos-de-párrafo)). Un mismo estilo no puede sangrar un verso y dar a la vez sangría francesa a lo que no le quepa en la línea, así que los catorce versos cortos de Milton, el 4 entre ellos, llevan un estilo propio: en un párrafo de una sola línea, una sangría de primera línea de 2 em desplaza todo el verso. Cada línea en blanco entre estrofas se convierte en `:::space{lines=1}`, una línea de la rejilla. Al principio de una página se descarta ([`:::space`](/es/docs/document-format#space)), así que la página 2 empieza con el verso 15 en lo alto de la caja de texto y el cambio de estrofa entre los versos 14 y 15 no se ve (mira «Errores frecuentes»).

### 4 · El laurel, el título y la nota preliminar

```js
// script.js, líneas 97–123
const OPENER_LINES = 20; // of the page's 34: the first verse paragraph, 14 lines, takes the rest
const at = (id, edge, y) => ({ anchor: { to: id, edge }, offset: { x: mm(0), y: mm(y) } });
const title = (size, tracking, placement) => ({ kind: 'text', id: 'title', content: '{titleText}',
  // lineHeight is a multiple of the size (gotcha: design-lineheight-multiple).
  fontFamily: DISPLAY, fontWeight: 300, fontSize: pt(size), lineHeight: 1,
  letterSpacing: pt(tracking), textTransform: 'uppercase', color: col('ink'), placement });
const opener = { enabled: true, minHeight: pt(OPENER_LINES * LEAD), slot: { elements: [
  // An image element reserves no height (gotcha: opener-image-no-reserve): the kicker, title
  // and headnote under it reach down 20 lines. minHeight is a floor at the same depth, so a
  // shorter headnote leaves the verse on line 21.
  { kind: 'image', id: 'laurel', resourceId: 'laurel',
    placement: { anchor: { to: 'page', edge: 'top-right' }, size: { width: mm(104) } } },
  { kind: 'text', id: 'kicker', content: '{author}', fontFamily: LABEL, fontWeight: 500,
    fontSize: pt(8), letterSpacing: pt(1.6), textTransform: 'uppercase', color: col('laurel'),
    placement: at('container', 'top-left', 50) },
  title(66, 2, at('#kicker', 'below', 1)),
  // # Lycidas {headnote="In this Monody …"}. Design text wraps ragged and has no inline
  // italics (gotcha: design-text-no-inline-marks); at 64 mm no word stands alone.
  { kind: 'text', id: 'headnote', content: '{attr.headnote}', fontFamily: TEXT, italic: true,
    fontSize: pt(9.5), lineHeight: 13 / 9.5, color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { ...at('#title', 'below', 3), size: { width: mm(64) } } },
] } };
// Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). span
// 'page' paints the laurel above the text block, where a column clips its design. With the
// default marginBottom the verse would start on line 22 and send line 14 to page 2.
const poem = { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
  advancedDesign: opener, marginBottom: pt(0) };
```

El título de primer nivel imprime un diseño en lugar de su propio texto ([span y diseño avanzado](/es/docs/configuration#span-y-diseño-avanzado)). `{author}` sale del frontmatter, y la nota preliminar, de un atributo en la línea del título, `# Lycidas {headnote="In this Monody …"}` ([atributos de encabezado](/es/docs/document-format#atributos-de-encabezado)). El laurel es un elemento de imagen y no reserva altura, así que la altura de la apertura la dan los textos que tiene debajo: el antetítulo, a 50 mm de lo alto de la caja de texto, el título y las cuatro líneas de la nota preliminar llegan hasta la línea 20 de las 34 de la página, y la primera estrofa, de 14 versos, ocupa el resto. `minHeight` fija un mínimo a esa misma profundidad: si recortas la nota preliminar a tres líneas, el poema sigue empezando en la línea 21, mientras que sin ese mínimo sube a la 20. `marginBottom: pt(0)` deja fuera del hueco el margen que el título lleva por defecto; con ese margen, el poema empezaría en la línea 22 y el verso 14 pasaría a la página 2. El nivel lleva `span: 'page'` porque un diseño que se queda dentro de la columna se recorta por su borde superior, 21 mm por debajo del corte, y el laurel arranca del corte mismo.

### 5 · Notas remitidas al número de verso

```js
// script.js, líneas 127–146
const NOTE = 8.6; // pt: the notes, and the note on the text
const noteStyles = [
  { id: 'textnote', fontSize: pt(NOTE), lineHeight: pt(NOTE * 1.33) },
  // The note on the text ends on the grid, 2.4 mm below its last line; half a line more
  // leaves one blank line before the first note.
  { id: 'note', fontSize: pt(NOTE), lineHeight: pt(NOTE * 1.33), hangingIndent: em(1.6),
    marginTop: pt(LEAD / 2) },
  { id: 'colophon', fontFamily: LABEL, fontSize: pt(7), lineHeight: pt(9.5), color: col('muted'),
    marginTop: pt(LEAD) },
];
// :chip[8]{style="line"}: Linden Hill has no bold, so the number changes face and colour.
// The chip has no fill, outline or side padding, so nothing is drawn around the number.
const chipStyles = [{ id: 'line', backgroundEnabled: false, borderWidth: pt(0), paddingX: pt(0),
  fontFamily: LABEL, fontSize: em(0.9), color: col('laurel') }];
// # Notes {style="notes"} opens the next page under the title's capitals, smaller. A heading
// style keeps the level's break unless it sets its own (gotcha: style-inherits-break). Its
// design stays in the column, so the title lines up with the notes on either page.
const notesHead = { id: 'notes', span: 'column', breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: { enabled: true,
    slot: { elements: [title(30, 1, at('container', 'top-left', 0))] } } };
```

Postext 1.4.1 no compone notas al pie, así que el aparato va detrás del poema y remite a los números de verso. Cada nota empieza por el número del verso que comenta, escrito `:chip[8]{style="line"}`, seguido del lema en cursiva (las palabras comentadas) y de un corchete de cierre. El chip no tiene fondo, contorno ni relleno lateral, así que solo cambian la letra y el color: Libre Franklin de 7,7 pt en verde laurel ([estilos de chip](/es/docs/configuration#estilos-de-chip)). Linden Hill no tiene negrita, y por eso los números se distinguen por el cambio de letra. El estilo de nota sangra 1,6 em todas las líneas menos la primera, con lo que los números sobresalen a la izquierda, y las notas van en bandera, igual que el verso. `# Notes {style="notes"}` salta a la página 8 y compone su título con las versales de la apertura, a 30 pt.

![Opening page 8: Notes.](https://postext.dev/cookbook/critical-edition-line-numbers/en/p08.webp?v=3142c41b)

*Página 8: cada nota empieza por el número de su verso, en verde; el poema, en las páginas 1 a 7, no lleva llamadas.*

### 6 · Cabeceras según la paridad

```js
// script.js, líneas 150–171
const HEAD = 13; // mm from the trim to the running heads' baseline
// A design text's first baseline sits 0.8 of a line below the top of its box: 0.96 em at the
// default lineHeight of 1.2, which the running heads keep.
const BASE = 1.2 * 0.8;
const head = (id, parity, content, x, size = 7.5, extra = {}) => ({ kind: 'text', id, parity,
  content, pages: 'body', fontFamily: LABEL, fontWeight: 500, fontSize: pt(size),
  letterSpacing: pt(1.3), textTransform: 'uppercase', color: col('muted'), ...extra,
  placement: { anchor: { to: 'page', edge: parity === 'even' ? 'top-left' : 'top-right' },
    offset: { x: mm(x), y: mm(HEAD - BASE * size * PT) } } });
const folio = { fontFamily: TEXT, fontWeight: 400, letterSpacing: pt(0), color: col('ink') };
const header = { elements: [
  head('verso-folio', 'even', '{pageNumber}', OUTER, 10, folio),
  head('verso-head', 'even', '{author}', OUTER + 9),
  head('recto-head', 'odd', '{chapterTitle}', -(OUTER + 9)), // LYCIDAS, then NOTES
  head('recto-folio', 'odd', '{pageNumber}', -OUTER, 10, folio),
] };
// The two openers carry their folio at the foot instead, at the outer edge of the text block.
const drop = (parity, edge, x) => ({ ...head(`drop-${parity}`, parity, '{pageNumber}', 0, 10,
  folio), pages: 'opener', placement: { anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(-12) } } });
const footer = { elements: [drop('odd', 'bottom-right', -OUTER),
  drop('even', 'bottom-left', OUTER)] };
```

Cada elemento se ancla a la página y se filtra con `parity` y `pages: 'body'` ([elementos de texto](/es/docs/configuration#elementos-de-texto)). La página par lleva el autor, y la impar, `{chapterTitle}`, que dice LYCIDAS sobre el poema y NOTES sobre las notas, porque las notas se abren con un título propio. El folio va en el borde exterior de la caja de texto; las dos páginas de apertura, la 1 y la 8, lo llevan al pie.

## 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/critical-edition-line-numbers

### script.js

```js
// ═══ Postext Cookbook · Nº 044 · Critical edition: line numbers and line-keyed notes ═══
// https://postext.dev/en/cookbook/critical-edition-line-numbers
// Code: MIT · Text: Milton, Poems (1645) (PD) · Notes and laurel: CC BY 4.0
// Fonts: Linden Hill, Imbue, Libre Franklin (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// Lycidas in the spelling of 1645, with a number beside every fifth line and two pages of
// notes keyed to those numbers, so the verse carries no note markers.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en')
const RECIPE = 'critical-edition-line-numbers';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// Black text on white, and one laurel green for the apparatus.
const palette = {
  ink: '#1b1b1b', // the text
  laurel: '#3c5a3e', // line numbers, note numbers, the kicker; the laurel's leaves
  leaf: '#6d8a5f', // the leaves behind, in the drawing
  berry: '#a4a653', // unripe berries: 'harsh and crude' (line 3)
  muted: '#6a706a', // running heads, the colophon
  paper: '#ffffff',
};
// col(id) carries the hex beside the id, because design slots paint the hex
// (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' } }));
const [TEXT, DISPLAY, LABEL] = ['Linden Hill', 'Imbue', 'Libre Franklin'];
const LEAD = 14.5; // pt: the leading of the verse, and the grid every page keeps

// #region answer: count the lines, and after every fifth set its number in the margin
// The poem is written one line of verse to a line of Markdown, with a blank line between
// verse paragraphs and two spaces before a short line. numberVerse() gives each line a
// paragraph of its own and, after every fifth, a side box that holds its number. A side box
// stands where the text has reached at its fence, under the line it follows; a top padding
// of minus one line lifts the number back onto that line. Fenced before its line instead,
// the number of a line that opens a page slides up beside the last line of the page before
// (gotcha: side-box-starts-at-fence).
const EVERY = 5;
const rows = (...lines) => lines.join('\n');
function numberVerse(markdown) {
  return markdown.replace(/^:::paragraphs\{style="verse"\}\n([\s\S]*?)\n:::$/gm, (_, poem) => {
    let n = 0;
    return poem.split('\n').map((line) => {
      if (!line.trim()) return ':::space{lines=1}'; // one blank line of the grid
      n += 1;
      const style = line.startsWith('  ') ? 'short' : 'verse';
      const verse = rows(`:::paragraphs{style="${style}"}`, line.trim(), ':::');
      if (n % EVERY) return verse;
      return rows(verse, '', ':::callout{type="lineno" span="side"}',
        ':::paragraphs{style="number"}', n, ':::', ':::');
    }).join('\n\n');
  });
}
const lineno = { id: 'lineno', backgroundEnabled: false, // no box: only the number shows
  padding: { top: pt(-LEAD), right: pt(0), bottom: pt(0), left: pt(0) } };
// The number: right-aligned, so the numbers share a right edge, and at the verse's leading,
// so it sits on the baseline of its line.
const number = { id: 'number', fontFamily: LABEL, fontSize: pt(7.5), lineHeight: pt(LEAD),
  color: col('laurel'), textAlign: 'right' };
// Hook-up: calloutStyles: [lineno], paragraphStyles: [number, …] and
// buildDocument({ markdown: numberVerse(markdown) }, config()).
// #endregion

// #region page: a poetry trim, and a channel for the numbers at the fore-edge
const PT = 25.4 / 72; // mm in a point
const [TRIM_W, TRIM_H] = [138, 216]; // mm
const [TOP, INNER] = [21, 20]; // mm
const LINES = 34; // lines of verse to a page
const BOTTOM = TRIM_H - TOP - LINES * LEAD * PT; // 21.08 mm
const [MEASURE, GUTTER, CHANNEL] = [80, 4, 6]; // mm: the longest line of Lycidas is 78.1 mm
const OUTER = TRIM_W - INNER - MEASURE - GUTTER - CHANNEL; // 28 mm beyond the numbers
const layout = {
  layoutType: 'oneAndHalf',
  sideColumnRole: 'floats', // the side column takes side boxes, never text
  sideColumnSide: 'outer', // right of the verse on a recto, left of it on a verso
  // 6 of 90 mm. The zero is Libre Franklin's widest figure, so '100' (4.9 mm) is the widest number.
  sideColumnPercent: (CHANNEL / (MEASURE + GUTTER + CHANNEL)) * 100,
  gutterWidth: mm(GUTTER),
};
const page = { sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150,
  margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: true } };
// #endregion

// #region verse: a paragraph per line, ragged, and the short lines set in
const verseStyles = [
  // Ragged, like all the text here (bodyText). A line too long for the measure would turn
  // over and hang 2 em in; none does at 80 mm.
  { id: 'verse', hangingIndent: em(2) },
  // Milton's short lines: a one-line paragraph, so the first-line indent moves all of it.
  { id: 'short', firstLineIndent: em(2) },
];
// #endregion

// #region opener: the laurel, the title in tall capitals, and the headnote of 1645
const OPENER_LINES = 20; // of the page's 34: the first verse paragraph, 14 lines, takes the rest
const at = (id, edge, y) => ({ anchor: { to: id, edge }, offset: { x: mm(0), y: mm(y) } });
const title = (size, tracking, placement) => ({ kind: 'text', id: 'title', content: '{titleText}',
  // lineHeight is a multiple of the size (gotcha: design-lineheight-multiple).
  fontFamily: DISPLAY, fontWeight: 300, fontSize: pt(size), lineHeight: 1,
  letterSpacing: pt(tracking), textTransform: 'uppercase', color: col('ink'), placement });
const opener = { enabled: true, minHeight: pt(OPENER_LINES * LEAD), slot: { elements: [
  // An image element reserves no height (gotcha: opener-image-no-reserve): the kicker, title
  // and headnote under it reach down 20 lines. minHeight is a floor at the same depth, so a
  // shorter headnote leaves the verse on line 21.
  { kind: 'image', id: 'laurel', resourceId: 'laurel',
    placement: { anchor: { to: 'page', edge: 'top-right' }, size: { width: mm(104) } } },
  { kind: 'text', id: 'kicker', content: '{author}', fontFamily: LABEL, fontWeight: 500,
    fontSize: pt(8), letterSpacing: pt(1.6), textTransform: 'uppercase', color: col('laurel'),
    placement: at('container', 'top-left', 50) },
  title(66, 2, at('#kicker', 'below', 1)),
  // # Lycidas {headnote="In this Monody …"}. Design text wraps ragged and has no inline
  // italics (gotcha: design-text-no-inline-marks); at 64 mm no word stands alone.
  { kind: 'text', id: 'headnote', content: '{attr.headnote}', fontFamily: TEXT, italic: true,
    fontSize: pt(9.5), lineHeight: 13 / 9.5, color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { ...at('#title', 'below', 3), size: { width: mm(64) } } },
] } };
// Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). span
// 'page' paints the laurel above the text block, where a column clips its design. With the
// default marginBottom the verse would start on line 22 and send line 14 to page 2.
const poem = { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
  advancedDesign: opener, marginBottom: pt(0) };
// #endregion

// #region notes: each note opens on its line number, a chip in the label face
const NOTE = 8.6; // pt: the notes, and the note on the text
const noteStyles = [
  { id: 'textnote', fontSize: pt(NOTE), lineHeight: pt(NOTE * 1.33) },
  // The note on the text ends on the grid, 2.4 mm below its last line; half a line more
  // leaves one blank line before the first note.
  { id: 'note', fontSize: pt(NOTE), lineHeight: pt(NOTE * 1.33), hangingIndent: em(1.6),
    marginTop: pt(LEAD / 2) },
  { id: 'colophon', fontFamily: LABEL, fontSize: pt(7), lineHeight: pt(9.5), color: col('muted'),
    marginTop: pt(LEAD) },
];
// :chip[8]{style="line"}: Linden Hill has no bold, so the number changes face and colour.
// The chip has no fill, outline or side padding, so nothing is drawn around the number.
const chipStyles = [{ id: 'line', backgroundEnabled: false, borderWidth: pt(0), paddingX: pt(0),
  fontFamily: LABEL, fontSize: em(0.9), color: col('laurel') }];
// # Notes {style="notes"} opens the next page under the title's capitals, smaller. A heading
// style keeps the level's break unless it sets its own (gotcha: style-inherits-break). Its
// design stays in the column, so the title lines up with the notes on either page.
const notesHead = { id: 'notes', span: 'column', breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: { enabled: true,
    slot: { elements: [title(30, 1, at('container', 'top-left', 0))] } } };
// #endregion

// #region heads: the author on the verso, the section on the recto, folios at the fore-edge
const HEAD = 13; // mm from the trim to the running heads' baseline
// A design text's first baseline sits 0.8 of a line below the top of its box: 0.96 em at the
// default lineHeight of 1.2, which the running heads keep.
const BASE = 1.2 * 0.8;
const head = (id, parity, content, x, size = 7.5, extra = {}) => ({ kind: 'text', id, parity,
  content, pages: 'body', fontFamily: LABEL, fontWeight: 500, fontSize: pt(size),
  letterSpacing: pt(1.3), textTransform: 'uppercase', color: col('muted'), ...extra,
  placement: { anchor: { to: 'page', edge: parity === 'even' ? 'top-left' : 'top-right' },
    offset: { x: mm(x), y: mm(HEAD - BASE * size * PT) } } });
const folio = { fontFamily: TEXT, fontWeight: 400, letterSpacing: pt(0), color: col('ink') };
const header = { elements: [
  head('verso-folio', 'even', '{pageNumber}', OUTER, 10, folio),
  head('verso-head', 'even', '{author}', OUTER + 9),
  head('recto-head', 'odd', '{chapterTitle}', -(OUTER + 9)), // LYCIDAS, then NOTES
  head('recto-folio', 'odd', '{pageNumber}', -OUTER, 10, folio),
] };
// The two openers carry their folio at the foot instead, at the outer edge of the text block.
const drop = (parity, edge, x) => ({ ...head(`drop-${parity}`, parity, '{pageNumber}', 0, 10,
  folio), pages: 'opener', placement: { anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(-12) } } });
const footer = { elements: [drop('odd', 'bottom-right', -OUTER),
  drop('even', 'bottom-left', OUTER)] };
// #endregion

const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity)
  colorPalette, page, layout, header, footer,
  bodyText: { // every paragraph sits in a styled container and takes these as defaults
    fontFamily: TEXT, fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    // Ragged throughout, verse and notes alike, so nothing is hyphenated
    // (gotcha: ragged-no-hyphenation).
    textAlign: 'left', firstLineIndent: pt(0),
  },
  // The designs print the titles, but each heading's own text is still measured, in this face.
  // Left at the default, the page would fetch Open Sans 700 for text it never paints.
  headings: { fontFamily: DISPLAY, fontWeight: 300, levels: [poem] },
  headingStyles: [notesHead],
  paragraphStyles: [...verseStyles, number, ...noteStyles],
  calloutStyles: [lineno],
  chipStyles,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Lycidas"
author: "John Milton"
---

# Lycidas {headnote="In this Monody the Author bewails a learned Friend, unfortunatly drown’d in his Passage from Chester on the Irish Seas, 1637. And by occasion foretels the ruine of our corrupted Clergy then in their height."}

:::paragraphs{style="verse"}
Yet once more, O ye Laurels, and once more
Ye Myrtles brown, with Ivy never-sear,
I com to pluck your Berries harsh and crude,
  And with forc’d fingers rude,
Shatter your leaves before the mellowing year.
Bitter constraint, and sad occasion dear,
Compels me to disturb your season due:
For *Lycidas* is dead, dead ere his prime,
Young *Lycidas*, and hath not left his peer:
Who would not sing for *Lycidas?* he knew
Himself to sing, and build the lofty rhyme.
He must not flote upon his watry bear
Unwept, and welter to the parching wind,
Without the meed of som melodious tear.

Begin then, Sisters of the sacred well,
That from beneath the seat of *Jove* doth spring,
Begin, and somwhat loudly sweep the string.
Hence with denial vain, and coy excuse,
  So may som gentle Muse
With lucky words favour my destin’d Urn,
  And as he passes turn,
And bid fair peace be to my sable shrowd.
For we were nurst upon the self-same hill,
Fed the same flock, by fountain, shade, and rill.

Together both, ere the high Lawns appear’d
Under the opening eye-lids of the morn,
We drove a field, and both together heard
What time the Gray-fly winds her sultry horn,
Batt’ning our flocks with the fresh dews of night,
Oft till the Star that rose, at Ev’ning, bright
Toward Heav’ns descent had slop’d his westering wheel.
Mean while the Rural ditties were not mute,
  Temper’d to th’ Oaten Flute,
Rough *Satyrs* danc’d, and *Fauns* with clov’n heel,
From the glad sound would not be absent long,
And old *Damœtas* lov’d to hear our song.

But O the heavy change, now thou art gon,
Now thou art gon, and never must return!
Thee Shepherd, thee the Woods, and desert Caves,
With wilde Thyme and the gadding Vine o’regrown,
  And all their echoes mourn.
The Willows, and the Hazle Copses green,
  Shall now no more be seen,
Fanning their joyous Leaves to thy soft layes.
As killing as the Canker to the Rose,
Or Taint-worm to the weanling Herds that graze,
Or Frost to Flowers, that their gay wardrop wear,
  When first the White thorn blows;
Such, *Lycidas*, thy loss to Shepherds ear.

Where were ye Nymphs when the remorseless deep
Clos’d o’re the head of your lov’d *Lycidas*?
For neither were ye playing on the steep,
Where your old *Bards*, the famous *Druids* ly,
Nor on the shaggy top of *Mona* high,
Nor yet where Deva spreads her wisard stream:
  Ay me, I fondly dream!
Had ye bin there—for what could that have don?
What could the Muse her self that *Orpheus* bore,
The Muse her self, for her inchanting son
Whom Universal nature did lament,
When by the rout that made the hideous roar,
His goary visage down the stream was sent,
Down the swift *Hebrus* to the *Lesbian* shore.

Alas! What boots it with uncessant care
To tend the homely slighted Shepherds trade,
And strictly meditate the thankles Muse,
Were it not better don as others use,
To sport with *Amaryllis* in the shade,
Or with the tangles of *Neæra*’s hair?
Fame is the spur that the clear spirit doth raise
(That last infirmity of Noble mind)
To scorn delights, and live laborious dayes;
But the fair Guerdon when we hope to find,
And think to burst out into sudden blaze,
Comes the blind *Fury* with th’ abhorred shears,
And slits the thin spun life. But not the praise,
*Phœbus* repli’d, and touch’d my trembling ears;
*Fame* is no plant that grows on mortal soil,
  Nor in the glistering foil
Set off to th’ world, nor in broad rumour lies,
But lives and spreds aloft by those pure eyes,
And perfet witnes of all judging *Jove*;
As he pronounces lastly on each deed,
Of so much fame in Heav’n expect thy meed.

O Fountain *Arethuse*, and thou honour’d flood,
Smooth-sliding *Mincius*, crown’d with vocall reeds,
That strain I heard was of a higher mood:
  But now my Oate proceeds,
And listens to the Herald of the Sea
  That came in *Neptune*’s plea,
He ask’d the Waves, and ask’d the Fellon winds,
What hard mishap hath doom’d this gentle swain?
And question’d every gust of rugged wings
That blows from off each beaked Promontory,
  They knew not of his story,
And sage *Hippotades* their answer brings,
That not a blast was from his dungeon stray’d,
The Ayr was calm, and on the level brine,
Sleek *Panope* with all her sisters play’d.
It was that fatall and perfidious Bark
Built in th’ eclipse, and rigg’d with curses dark,
That sunk so low that sacred head of thine.

Next *Camus*, reverend Sire, went footing slow,
His Mantle hairy, and his Bonnet sedge,
Inwrought with figures dim, and on the edge
Like to that sanguine flower inscrib’d with woe.
Ah! Who hath reft (quoth he) my dearest pledge?
  Last came, and last did go,
The Pilot of the *Galilean* lake,
Two massy Keyes he bore of metals twain,
(The Golden opes, the Iron shuts amain)
He shook his Miter’d locks, and stern bespake,
How well could I have spar’d for thee young swain.
Anow of such as for their bellies sake,
Creep and intrude, and climb into the fold?
Of other care they little reck’ning make,
Then how to scramble at the shearers feast,
And shove away the worthy bidden guest.
Blind mouthes! that scarce themselves know how to hold
A Sheep-hook, or have learn’d ought els the least
That to the faithfull Herdmans art belongs!
What recks it them? What need they? They are sped;
And when they list, their lean and flashy songs
Grate on their scrannel Pipes of wretched straw,
The hungry Sheep look up, and are not fed,
But swoln with wind, and the rank mist they draw,
Rot inwardly, and foul contagion spread:
Besides what the grim Woolf with privy paw
Daily devours apace, and nothing sed,
But that two-handed engine at the door,
Stands ready to smite once, and smite no more.

Return *Alpheus*, the dread voice is past,
That shrunk thy streams; Return *Sicilian* Muse,
And call the Vales, and bid them hither cast
Their Bels, and Flourets of a thousand hues.
Ye valleys low where the milde whispers use,
Of shades and wanton winds, and gushing brooks,
On whose fresh lap the swart Star sparely looks,
Throw hither all your quaint enameld eyes,
That on the green terf suck the honied showres,
And purple all the ground with vernal flowres.
Bring the rathe Primrose that forsaken dies.
The tufted Crow-toe, and pale Gessamine,
The white Pink, and the Pansie freakt with jeat,
  The glowing Violet.
The Musk-rose, and the well attir’d Woodbine,
With Cowslips wan that hang the pensive hed,
And every flower that sad embroidery wears:
Bid *Amaranthus* all his beauty shed,
And Daffadillies fill their cups with tears,
To strew the Laureat Herse where *Lycid* lies.
For so to interpose a little ease,
Let our frail thoughts dally with false surmise.
Ay me! Whilst thee the shores and sounding Seas
Wash far away, where ere thy bones are hurld,
Whether beyond the stormy *Hebrides*,
Where thou perhaps under the whelming tide
Visit’st the bottom of the monstrous world;
Or whether thou to our moist vows deny’d,
Sleep’st by the fable of *Bellerus* old,
Where the great vision of the guarded Mount
Looks toward *Namancos* and *Bayona*’s hold;
Look homeward Angel now, and melt with ruth.
And, O ye *Dolphins*, waft the haples youth.

Weep no more, woful Shepherds weep no more,
For *Lycidas* your sorrow is not dead,
Sunk though he be beneath the watry floar,
So sinks the day-star in the Ocean bed,
And yet anon repairs his drooping head,
And tricks his beams, and with new-spangled Ore,
Flames in the forehead of the morning sky:
So *Lycidas* sunk low, but mounted high,
Through the dear might of him that walk’d the waves;
Where other groves, and other streams along,
With *Nectar* pure his oozy Lock’s he laves,
And hears the unexpressive nuptiall Song,
In the blest Kingdoms meek of joy and love.
There entertain him all the Saints above,
In solemn troops, and sweet Societies
That sing, and singing in their glory move,
And wipe the tears for ever from his eyes.
Now *Lycidas* the Shepherds weep no more;
Hence forth thou art the Genius of the shore,
In thy large recompense, and shalt be good
To all that wander in that perilous flood.

Thus sang the uncouth Swain to th’ Okes and rills,
While the still morn went out with Sandals gray,
He touch’d the tender stops of various Quills,
With eager thought warbling his Dorick lay:
And now the Sun had stretch’d out all the hills,
And now was dropt into the Western bay;
At last he rose, and twitch’d his Mantle blew:
To morrow to fresh Woods, and Pastures new.
:::
`; // content.<lang>.md, inlined by the Cookbook: the poem
const notes = String.raw`# Notes {style="notes"}

:::paragraphs{style="textnote"}
*The text* is that of *Poems of Mr. John Milton* (London, 1645), pages 57 to 65. Lycidas had first been printed, without the headnote, in *Justa Edovardo King naufrago* (Cambridge, 1638), the volume of elegies for King. Spelling and capitals follow 1645, and so does the italic of names in the poem. A verse paragraph that 1645 marks by indenting its first line is marked here by a blank line; the short lines are indented. The notes are keyed to the line numbers in the margin.
:::

:::paragraphs{style="note"}
:chip[1]{style="line"} *Yet once more*] Hebrews 12.26, ‘Yet once more I shake not the earth only, but also heaven.’

:chip[1–2]{style="line"} *Laurels … Myrtles … Ivy*] evergreens and poets’ crowns: the laurel is Apollo’s, the myrtle Venus’s, the ivy Bacchus’s.

:chip[3]{style="line"} *crude*] unripe (Latin *crudus*). The berries are picked before their season, as King died before his.

:chip[8]{style="line"} *Lycidas*] Edward King (1612–1637), fellow of Christ’s College, Cambridge, drowned on 10 August 1637 when his ship, bound from Chester for Dublin, struck a rock off the Welsh coast. Lycidas is a herdsman in Theocritus, *Idyll* 7, and in Virgil, *Eclogue* 9.

:chip[12]{style="line"} *flote … bear*] float … bier.

:chip[15]{style="line"} *Sisters of the sacred well*] the Muses, who dance round the spring and the altar of Zeus on Helicon in the first lines of Hesiod’s *Theogony*.

:chip[23]{style="line"} *the self-same hill*] Christ’s College, Cambridge, where Milton studied from 1625 to 1632 and King from 1626.

:chip[36]{style="line"} *Damœtas*] a herdsman in Theocritus and Virgil. Some commentators see in him a tutor of Christ’s, William Chappell or Joseph Mede; neither identification is certain.

:chip[53–55]{style="line"} *Druids … Mona … Deva*] the Druids’ island is Anglesey, *Mona* in Tacitus, *Annals* 14.30; the Dee (*Deva*) reaches the sea below Chester, where King sailed. Its shifting course was read as an omen for England and Wales, hence *wisard*.

:chip[58]{style="line"} *the Muse her self that Orpheus bore*] Calliope. The women of Thrace tore Orpheus apart, and his head, still singing, went down the Hebrus and over the sea to Lesbos (Ovid, *Metamorphoses* 11.1–55).

:chip[64]{style="line"} *uncessant*] unceasing; so 1638, 1645 and 1673.

:chip[70–71]{style="line"} *That last infirmity of Noble mind*] Tacitus, *Histories* 4.6: the desire for glory is the last thing even the wise put off.

:chip[75]{style="line"} *the blind Fury*] Atropos, the Fate who cuts the thread of life; Milton makes her a Fury, and blind.

:chip[77]{style="line"} *touch’d my trembling ears*] Apollo plucks the poet’s ear in Virgil, *Eclogue* 6.3–4, to call him back from kings and battles to pastoral.

:chip[85–86]{style="line"} *Arethuse … Mincius*] the fountain of Syracuse and the river of Mantua: the country of Theocritus and the country of Virgil.

:chip[96]{style="line"} *Hippotades*] Aeolus, son of Hippotes, keeper of the winds.

:chip[103]{style="line"} *Camus*] the god of the Cam, who stands for Cambridge; he walks as slowly as his river flows.

:chip[106]{style="line"} *that sanguine flower*] the hyacinth, sprung from the blood of Hyacinthus, whose petals were said to carry AI, a cry of grief (Ovid, *Metamorphoses* 10.215).

:chip[109]{style="line"} *The Pilot of the Galilean lake*] St Peter, the fisherman given the keys of heaven (Matthew 16.19), mitred here as the first bishop.

:chip[114–117]{style="line"} *Anow … Then*] enough … than.

:chip[119]{style="line"} *Blind mouthes*] Ruskin, in *Sesame and Lilies* (1865): a bishop is one who sees and a pastor one who feeds, so a blind mouth is a clergyman who does neither.

:chip[128]{style="line"} *the grim Woolf*] usually read as the Church of Rome, which was making converts at the court of Charles I.

:chip[130]{style="line"} *that two-handed engine*] no reading has settled it. Readers have proposed the sword of the archangel Michael, the axe laid to the root of the trees in Matthew 3.10, the two Houses of Parliament and St Peter’s two keys.

:chip[132]{style="line"} *Alpheus*] the river said to run under the sea from Greece and rise in Arethusa’s fountain (line 85). Called on here, it brings the poem back to pastoral after St Peter’s speech.

:chip[138]{style="line"} *the swart Star*] Sirius, the Dog Star of the hottest weeks, which scorches what it looks on.

:chip[156]{style="line"} *Hebrides*] King’s body was never found.

:chip[160–162]{style="line"} *Bellerus … the guarded Mount … Namancos*] Bellerus is made from Bellerium, the Roman name of Land’s End. From St Michael’s Mount the archangel looks out over the sea to Galicia, where Mercator’s atlas marks Namancos, near the castle of Bayona.

:chip[164]{style="line"} *Dolphins*] like the dolphin that carried the singer Arion to shore at Taenarum (Herodotus 1.24).

:chip[176]{style="line"} *unexpressive nuptiall Song*] the song past expressing at the marriage of the Lamb (Revelation 19.7–9).

:chip[183]{style="line"} *Genius of the shore*] the guardian spirit of a place: King will keep those who cross the sea he drowned in.

:chip[193]{style="line"} *fresh Woods*] in the spring of 1638 Milton left England for Italy.
:::

:::paragraphs{style="colophon"}
Set in Linden Hill, Imbue and Libre Franklin (SIL Open Font License). Text of 1645, public domain. Notes and laurel drawing, CC BY 4.0.
:::
`; // content.notes.<lang>.md: the notes

// #region art: a sprig of bay laurel with its unripe berries, in the page's greens
let seed = 1645; // Mulberry32: a seeded generator, 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 f1 = (v) => v.toFixed(1);
const ring = (pts) => `M${pts.map(([x, y]) => `${f1(x)} ${f1(y)}`).join('L')}Z`;
const line = (pts) => `M${pts.map(([x, y]) => `${f1(x)} ${f1(y)}`).join('L')}`;
const fill = (d, hex) => `<path d="${d}" fill="${hex}"/>`;
const stroke = (d, hex, w) => `<path d="${d}" fill="none" stroke="${hex}" stroke-width="${w}" `
  + 'stroke-linecap="round"/>';
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('')}`;
// A point on a cubic Bézier, with its direction.
const bez = ([p0, p1, p2, p3], t) => {
  const u = 1 - t;
  const pos = (i) => u * u * u * p0[i] + 3 * u * u * t * p1[i] + 3 * u * t * t * p2[i]
    + t * t * t * p3[i];
  const d = (i) => 3 * u * u * (p1[i] - p0[i]) + 6 * u * t * (p2[i] - p1[i])
    + 3 * t * t * (p3[i] - p2[i]);
  return { x: pos(0), y: pos(1), a: Math.atan2(d(1), d(0)) };
};
// The stem: the curve as a band tapering from w0 to w1.
const band = (curve, w0, w1) => {
  const [left, right] = [[], []];
  for (let i = 0; i <= 48; i++) {
    const p = bez(curve, i / 48);
    const w = (w0 + (w1 - w0) * (i / 48)) / 2;
    left.push([p.x - Math.sin(p.a) * w, p.y + Math.cos(p.a) * w]);
    right.unshift([p.x + Math.sin(p.a) * w, p.y - Math.cos(p.a) * w]);
  }
  return ring([...left, ...right]);
};
// A bay leaf from its base (x, y) along angle a: narrow at the stalk, widest a third of the
// way up, drawn out to a point, with its midrib bowed by `bend`. Returns the blade, the midrib
// and four pairs of side veins.
function bayLeaf(x, y, a, len, wide, bend) {
  const [c, s] = [Math.cos(a), Math.sin(a)];
  const to = (u, v) => [x + u * c - v * s, y + u * s + v * c];
  const mid = (t) => bend * len * Math.sin(Math.PI * t);
  const half = (t) => wide * Math.sin(Math.PI * t ** 0.72) ** 1.1;
  const edge = (sign) => Array.from({ length: 33 }, (_, i) => {
    const t = i / 32;
    return to(len * t, mid(t) + sign * half(t) * (1 + 0.035 * Math.sin(t * 23 + sign)));
  });
  const veins = [];
  for (const t of [0.24, 0.4, 0.56, 0.7]) {
    for (const sign of [1, -1]) {
      veins.push(line([to(len * t, mid(t)),
        to(len * (t + 0.13), mid(t + 0.13) + sign * half(t + 0.13) * 0.72)]));
    }
  }
  return { blade: ring([...edge(1), ...edge(-1).reverse()]),
    rib: line(Array.from({ length: 17 }, (_, i) => to(len * i / 18, mid(i / 18)))),
    veins: veins.join('') };
}
function laurel(w, h) {
  const stem = [[w + 40, -60], [w * 0.8, h * 0.12], [w * 0.62, h * 0.62], [w * 0.14, h * 0.7]];
  const back = [];
  const front = [];
  const berries = [];
  const N = 15;
  for (let i = 0; i < N; i++) {
    const t = 0.03 + (i / (N - 1)) * 0.9;
    const p = bez(stem, t);
    const side = i % 2 ? 1 : -1;
    const len = (300 - 150 * t) * (0.88 + rand() * 0.24);
    const turned = i % 4 === 2; // seen edge-on, its paler underside up
    const a = p.a + side * (0.42 + rand() * 0.5);
    const stalk = [p.x + Math.cos(a) * 14, p.y + Math.sin(a) * 14];
    const blade = bayLeaf(stalk[0], stalk[1], a, len, len * (turned ? 0.12 : 0.2),
      side * (0.04 + rand() * 0.05));
    (turned ? back : front).push({ ...blade, stalk: line([[p.x, p.y], stalk]) });
    if (i % 4 === 1 && t < 0.8) { // a small umbel of berries in the leaf's axil
      const b = p.a - side * 0.9;
      const hub = [p.x + Math.cos(b) * 26, p.y + Math.sin(b) * 26];
      for (let k = 0; k < 4; k++) {
        const ba = b + (k - 1.5) * 0.42;
        const r = 40 + rand() * 12;
        berries.push({ stalk: line([[p.x, p.y], hub, [hub[0] + Math.cos(ba) * r * 0.6,
          hub[1] + Math.sin(ba) * r * 0.6]]), x: hub[0] + Math.cos(ba) * r,
        y: hub[1] + Math.sin(ba) * r, a: ba });
      }
    }
  }
  const [end, bud] = [bez(stem, 1), bez(stem, 0.97)]; // the shoot ends in two young leaves
  front.push({ ...bayLeaf(end.x, end.y, end.a - 0.08, 120, 21, 0.05), stalk: '' },
    { ...bayLeaf(bud.x, bud.y, bud.a + 0.55, 72, 13, -0.06), stalk: '' });
  const [wood, pale, vein] = [mix('laurel', 'ink', 0.35), mix('leaf', 'paper', 0.25),
    mix('laurel', 'paper', 0.28)];
  const out = [];
  for (const b of back) {
    out.push(stroke(b.stalk, wood, 5), fill(b.blade, palette.leaf), stroke(b.rib, pale, 3));
  }
  out.push(fill(band(stem, 17, 6), wood));
  for (const b of berries) {
    out.push(stroke(b.stalk, wood, 3.5), `<ellipse cx="${f1(b.x)}" cy="${f1(b.y)}" rx="21" `
      + `ry="16.5" transform="rotate(${f1(b.a * 180 / Math.PI)} ${f1(b.x)} ${f1(b.y)})" `
      + `fill="${palette.berry}"/>`);
  }
  for (const b of front) {
    out.push(stroke(b.stalk, wood, 5), fill(b.blade, palette.laurel), stroke(b.rib, vein, 3.2),
      stroke(b.veins, vein, 1.6));
  }
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" `
    + `viewBox="0 0 ${w} ${h}">${out.join('')}</svg>`;
}
await loadSvg('laurel.svg', laurel(1040, 740)); // tenths of a millimetre: 104 × 74 mm
// #endregion
const resources = [{ id: 'laurel', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'laurel.svg', width: 1040, height: 740 },
  altText: 'A sprig of bay laurel with a cluster of unripe berries, entering from the corner.' }];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Loaded before the first build (gotcha: fonts-first). Linden Hill has no bold, Imbue no italic.
const FONTS = { 'Linden Hill': ['400', '400i'], Imbue: ['300'], 'Libre Franklin': ['400', '500'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown + notes);
const source = `${numberVerse(markdown)}\n\n${notes}`;
const doc = await buildWithFonts(() => buildDocument({ markdown: source, resources }, config()),
  source);
showPages(doc, { title: 'Lycidas · with line numbers and notes' });

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

### Numera los versos de diez en diez

Los poemas largos suelen numerarse de diez en diez; la columna de 6 mm se queda como está.

```diff
-const EVERY = 5;
+const EVERY = 10;
```

### Deja todos los números a la derecha del verso

Con la columna lateral fija a la derecha, los números quedan a la derecha del verso en todas las páginas, y en las pares, junto al lomo.

```diff
-  sideColumnSide: 'outer', // right of the verse on a recto, left of it on a verso
+  sideColumnSide: 'right', // right of the verse on every page
```

## Errores frecuentes

- **Un recuadro lateral empieza a la altura del bloque que sigue a su valla.** En postext 1.4.1, un recuadro con span: 'side' se coloca en la columna lateral a la altura a la que ha llegado el texto en su valla, en la siguiente línea de la rejilla y debajo de los recuadros que ya haya. Pon la valla de una glosa justo antes del párrafo que explica: si va después, la glosa empieza junto al párrafo siguiente. Un recuadro que pasaría del pie de la columna sube hasta que su pie coincide con el de la columna, si el recuadro de encima le deja sitio; si aun así no cabe, espera a la columna lateral de la página siguiente.
- **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.
- **En el texto en bandera no se evitan las líneas cortas.** optimalLineBreaking, avoidRunts, runtPenalty y runtMinCharacters actúan sobre el algoritmo de Knuth–Plass, que postext 1.4.1 solo aplica al texto justificado. Un párrafo en bandera se corta línea a línea y puede terminar en una sola palabra corta, digan lo que digan esos ajustes. Revisa las últimas líneas del texto en bandera y reescribe el párrafo que acabe en una línea corta.
- **Valores de atributo: sin { ni }; comillas simples si llevan ".** Un valor de atributo termina en la llave de cierre, así que no puede contener { ni }. Un valor que lleve comillas dobles va entre comillas simples; el signo de dólar no da problemas.
- **El texto de diseño no admite ^sup^ ni **negrita**.** Los elementos de texto de diseño imprimen texto plano, así que ^1^ o **negrita** en un atributo aparecen tal cual. Usa superíndices Unicode (¹ ² ³ están en el subconjunto latin) o un segundo elemento con otro peso.
- **Las imágenes de una apertura no cuentan para la altura que reserva.** En postext 1.4.1, un título con diseño avanzado mide la altura que reserva sin contar sus imágenes: sus textos, filetes y cajas cuentan, aunque estén anclados a la página, pero una imagen, como un dibujo a sangre en la cabeza de la página, no reserva nada, así que el texto puede empezar encima de ella. Fija con minHeight dónde debe empezar el texto.
- **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.
- **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.
- **Una configuración se cachea por identidad: crea un objeto nuevo.** El motor guarda en caché las configuraciones resueltas según la identidad del objeto, así que modificar el mismo objeto y volver a componer reutiliza el resultado anterior. Crea un objeto nuevo en cada composición: por eso la configuración de una receta es una función, config().
- **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.

- Un verso numerado que no cabe en la medida lleva el número junto a su última línea, porque la valla del recuadro lateral va detrás del párrafo entero. A 80 mm ningún verso de *Lycidas* pasa a la línea siguiente; si estrechas la medida, revisa los versos numerados.
- `:::space` se descarta al principio de una página, así que el cambio de estrofa entre los versos 14 y 15, que coincide con el paso de la página 1 a la 2, no se ve. Si tu edición tiene que marcar todos los cambios de estrofa, sangra el primer verso de cada una en lugar de dejar una línea en blanco, como hace la edición de 1645.
- [La tarjeta sobre recuadros laterales](#gotcha-side-box-starts-at-fence) aconseja poner la valla de una glosa antes de su párrafo, para que empiece a la altura de su primera línea. El número de verso, en cambio, va después de su verso y sube con el relleno negativo: con la valla antes, el número de un verso que abre página sube junto a la última línea de la página anterior (paso 1).

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texto: Lycidas, con su nota preliminar, en el texto de Poems of Mr. John Milton (1645), páginas 57–65, según la transcripción revisada de Wikisource del facsímil de 1927; cotejado con el texto de Oxford de H. C. Beeching (Project Gutenberg, libro electrónico 1745): John Milton ([fuente](https://en.wikisource.org/wiki/Poems_of_Mr._John_Milton,_Both_English_and_Latin,_Compos%27d_at_several_times/Lycidas)), dominio público
- Texto: La nota sobre el texto, las notas y el colofón: Ignacio Ferro, CC-BY-4.0
- Imágenes: La rama de laurel de la primera página, dibujada en código con los verdes de la página: Ignacio Ferro, CC-BY-4.0
- Tipografías: Linden Hill (OFL-1.1), Imbue (OFL-1.1), Libre Franklin (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 015 · Poemas compuestos verso a verso](https://postext.dev/es/cookbook/poetry-collection.md): 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. · Nivel 2 (Intermedio) · Poesía
- [N.º 032 · Clásico anotado con glosas al margen](https://postext.dev/es/cookbook/annotated-classic-glosses.md): La merienda de locos de Alicia en edición anotada: glosas verdes y rojas en el margen exterior, junto a lo que explican, con llamadas de su mismo color. · Nivel 3 (Avanzado) · Narrativa, teatro y prosa literaria
- [N.º 020 · Notas finales a dos columnas en lugar de notas al pie](https://postext.dev/es/cookbook/endnotes-instead-of-footnotes.md): Un preprocesador breve pasa las notas al pie de Markdown a llamadas voladas y a una sección que un estilo de título compone en página propia, a dos columnas. · Nivel 2 (Intermedio) · Artículos y trabajos académicos
