# Clásico anotado con glosas al margen

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

- Versión HTML: https://postext.dev/es/cookbook/annotated-classic-glosses
- Receta N.º 032 · Recuadros y notas · Nivel 3 (Avanzado) · Salidas: Canvas
- Géneros: Narrativa, teatro y prosa literaria
- Requiere postext ≥ 1.4.1 · probada con 1.4.1 el 2026-09-26
- Páginas: [81](https://postext.dev/cookbook/annotated-classic-glosses/en/p01.webp?v=dc7bd3ad), [82](https://postext.dev/cookbook/annotated-classic-glosses/en/p02.webp?v=dc7bd3ad), [83](https://postext.dev/cookbook/annotated-classic-glosses/en/p03.webp?v=dc7bd3ad)
- Última actualización: 2026-09-26
- Otros idiomas: [en](https://postext.dev/en/cookbook/annotated-classic-glosses.md)

## Lo que vas a componer

El comienzo del capítulo VII de *Alice’s Adventures in Wonderland*, la merienda de locos, en tres páginas de una edición anotada de 156 × 234 mm. El texto de Carroll va en una columna justificada de Unna, de unos 66 caracteres por línea. Un canal de 33 mm junto al corte exterior recoge las ocho glosas del editor, cada una al lado del párrafo que lleva su llamada, a la derecha en las páginas impares y a la izquierda en las pares. Las de palabras y chistes van en verde y las de historia en rojo, igual que las letras que las llaman. El capítulo se abre bajo un dibujo de la mesa del té vista desde arriba, con el número, el título en Rozha One y una entradilla en cursiva con capitular verde, y en el margen, junto a la entradilla, va la nota sobre esta edición.

**Esta receta responde a:**

- ¿Cómo pongo glosas en el margen, junto al párrafo que explican, en lugar de notas al pie?
- ¿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 a una apertura una línea de autor, una entradilla o un primer párrafo con capitular?

## La respuesta corta

```js
// script.js, líneas 38–63
const layout = {
  layoutType: 'oneAndHalf', // one text column and a narrower side column
  sideColumnPercent: 26, // of the 127 mm between the margins: a 33 mm channel
  sideColumnRole: 'floats', // no text runs in it: it holds side boxes and figures
  sideColumnSide: 'outer', // at the fore-edge, which mirrored margins move page by page
  gutterWidth: mm(4.5), // the text column keeps 127 − 33 − 4.5 = 89.5 mm
};
// A gloss is :::callout{type="note" span="side" title="a · …"} in the Markdown.
// span="side" moves it out of the flow into the channel, level with the block after
// the fence, so the fence goes just before the paragraph that carries its letter.
// Glosses never float. One that meets the gloss above stacks under it. One that would
// run past the column's foot slides up, as far as the gloss above allows, until its
// foot sits on the foot; if it still does not fit, it waits for the next page
// (gotcha: side-box-starts-at-fence).
const gloss = (id, hue) => ({ id,
  backgroundEnabled: false, // one device: a hairline over the gloss, in its colour
  stripe: { enabled: true, side: 'top', width: pt(0.5), color: col(hue) },
  padding: { top: mm(1.5), right: pt(0), bottom: pt(0), left: pt(0) },
  // Cormorant SC draws lower case as small capitals: the title needs no textTransform.
  titleStyle: { fontFamily: LABEL, fontWeight: 600, fontSize: pt(8.5),
    letterSpacing: pt(0.3), color: col(hue), gap: mm(0.8) },
  // Face and colours come from bodyText. A box body also inherits its 4 mm first-line
  // indent, which a gloss sets back to 0.
  body: { fontSize: pt(7.8), lineHeight: pt(10), textAlign: 'left',
    firstLineIndent: pt(0) } });
const calloutStyles = [gloss('note', 'lawn'), gloss('context', 'jam')]; // words, history
```

## 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.
- [Columna al margen para flotantes](https://postext.dev/es/docs/configuration.md#disposición): Una página en columna y media cuya columna lateral no recibe texto, solo las figuras, tablas y recuadros que se colocan en ella, como el margen de muchos libros de texto.
- [Columna y media](https://postext.dev/es/docs/configuration.md#tipos-de-disposición): Una disposición asimétrica: una columna principal ancha y una lateral estrecha, a un lado fijo o en el borde exterior de cada página.

**También usa**

- [Márgenes simétricos](https://postext.dev/es/docs/configuration.md#márgenes-simétricos-espejo)
- [Recuadros](https://postext.dev/es/docs/configuration.md#estilos-de-aviso)
- [Superíndices y subíndices](https://postext.dev/es/docs/document-format.md#formato-en-línea)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Banda de capítulo a todo el ancho](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)
- [Anclaje de elementos de diseño](https://postext.dev/es/docs/configuration.md#posicionamiento-de-elementos)
- [Capitulares en las aperturas](https://postext.dev/es/docs/configuration.md#elementos-de-texto)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [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)
- [Figuras y tablas como recursos](https://postext.dev/es/docs/document-format.md#recursos)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration.md#paleta-de-colores)
- [Corte de líneas óptimo (Knuth–Plass)](https://postext.dev/es/docs/justification.md#knuth-plass-ver-el-párrafo-completo)
- [Chips en línea](https://postext.dev/es/docs/configuration.md#estilos-de-chip)
- [Color del papel](https://postext.dev/es/docs/configuration.md#página)

**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), [`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**

- Unna (OFL-1.1), Rozha One (OFL-1.1), Cormorant SC (OFL-1.1)

## Elaboración

### 1 · Deja el corte exterior para las glosas

El código es [la respuesta corta](#la-respuesta-corta) de arriba. En una [disposición en columna y media](/es/docs/configuration#tipos-de-disposición), `sideColumnPercent: 26` da a la columna lateral 33 de los 127 mm que hay entre márgenes, y al texto le quedan 89,5 mm después del medianil de 4,5 mm. Con `sideColumnRole: 'floats'` el texto de Carroll no entra en la columna lateral, que solo recibe lo que se coloca en ella con `span: 'side'`. Postext 1.4.1 no compone notas al pie ([qué no se soporta](/es/docs/document-format#qué-no-se-soporta)); en el margen, cada glosa queda a la altura de la línea que explica y las notas no le quitan líneas al pie de la columna de texto. Los dos estilos de glosa solo se diferencian en el color del filete y del título, así que el lector distingue una glosa sobre una palabra de otra sobre historia antes de leerla.

### 2 · Refleja la página para que el canal siga al corte exterior

```js
// script.js, líneas 77–88
const PT = 25.4 / 72; // mm in a point
const [TRIM_W, TRIM_H] = [156, 234]; // mm
const [TOP, BOTTOM, INNER, OUTER] = [22, 22, 15, 14]; // mm: 38 lines of text
const page = { width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150,
  backgroundColor: col('paper'),
  // left is a recto's inner margin; mirror swaps the sides on a verso, and 'outer' moves
  // the channel with them: right on a recto, left on a verso.
  margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER),
    mirror: true } };
const CONTENT_W = TRIM_W - INNER - OUTER; // 127 mm
const SIDE_W = CONTENT_W * layout.sideColumnPercent / 100; // 33 mm: the glosses' measure
const TEXT_W = CONTENT_W - SIDE_W - layout.gutterWidth.value;
```

Con `mirror: true` los márgenes se intercambian en cada página par, y `sideColumnSide: 'outer'` lleva el canal con ellos, así que las glosas bajan por la derecha de las [páginas 81](https://postext.dev/cookbook/annotated-classic-glosses/en/p01.webp?v=dc7bd3ad) y [83](https://postext.dev/cookbook/annotated-classic-glosses/en/p03.webp?v=dc7bd3ad) y por la izquierda de la [82](https://postext.dev/cookbook/annotated-classic-glosses/en/p02.webp?v=dc7bd3ad). Los márgenes de 22 mm arriba y abajo dejan 190 mm, sitio para 38 líneas de 14 pt, y cada glosa empieza en una línea de esa misma rejilla.

### 3 · Pon cada glosa antes del párrafo que explica

```js
// script.js, líneas 92–107
const bodyText = { // justified and hyphenated (en-us) by default
  // Copy-fitted to the 89.5 mm column: at 9.5 pt '“Have some wine,” the March Hare said in
  // an encouraging tone.' fits one line. At 10.4 pt every measure from 77 to 93 mm left
  // it a short last line: 'couraging tone.', 'aging tone.', 'ing tone.' or 'tone.'.
  fontFamily: TEXT, fontSize: pt(9.5), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: mm(4),
  // Unna's space is narrow, 0.22 em. At the default 0.6 the line breaker set 'Alice felt
  // dreadfully puzzled. The Hatter’s remark seemed to have' with its spaces at 0.69.
  minWordSpacing: 0.75 };
const paragraphStyles = [
  // The chapter's first paragraph, set flush. indentAfterHeading: false would do it
  // without glosses, but the gloss fences between the heading and this paragraph
  // count as the block after the heading, so the default stays and the paragraph takes
  // this style (gotcha: side-box-after-heading).
  { id: 'opening', firstLineIndent: pt(0) },
];
```

Un recuadro con `span="side"` en su [valla `:::callout`](/es/docs/configuration#el-contenedor-callout) sale del flujo y se coloca en el canal a la altura del bloque que sigue a la valla. Por eso la valla de cada glosa va justo antes del párrafo que lleva su llamada, como en el ejemplo de abajo. Las glosas a y b acompañan al primer párrafo, con lo que sus vallas quedan entre el título y ese párrafo. En 1.4.1 los dos recuadros cuentan entonces como el bloque que sigue al título, y `indentAfterHeading: false` no llegaría al párrafo. La configuración deja esa opción en su valor por defecto y es el estilo `opening` el que quita la sangría al párrafo. El cuerpo está ajustado a la columna: a 9,5 pt, el párrafo que empieza «“Have some wine,”» cabe en una línea de 89,5 mm, y a 10,4 pt todas las medidas entre 77 y 93 mm le dejaban una última línea corta, como «ing tone.» o «tone.». El espacio entre palabras de Unna es estrecho, 0,22 em, y con el `minWordSpacing` por defecto, 0,6, una línea de la página 83 lo reducía al 0,69 de su anchura; con `minWordSpacing: 0.75` esa línea ya no es posible.

```markdown
:::callout{type="context" span="side" title="c · Hair"}
Tenniel drew Alice with long, loose hair. …
:::

“Your hair wants cutting,”:chip[^c^]{style="context"} said the Hatter. …
```

### 4 · Da a cada letra el color de su glosa

```js
// script.js, líneas 67–73
// A letter in the text is a chip named after its gloss's type, :chip[^a^]{style="note"}:
// the Markdown has no mark for coloured text, and a chip style sets a colour. With no
// fill, outline, padding or gap the chip adds no width, so the lines break as they would
// with a plain ^a^.
const letter = (id, hue) => ({ id, backgroundEnabled: false, borderWidth: pt(0),
  paddingX: pt(0), gap: pt(0), color: col(hue), bold: true });
const chipStyles = [letter('note', 'lawn'), letter('context', 'jam')];
```

El Markdown no tiene marca para el texto en color, pero un estilo de [chip](/es/docs/document-format#chips-en-línea) sí fija un color de texto, así que cada llamada es un chip cuyo estilo se llama igual que el tipo de su glosa. `:chip[^c^]{style="context"}` imprime una c roja, el mismo rojo del filete y del título de la glosa c. Sin fondo, contorno, relleno ni separación, el chip no añade anchura y cada línea se corta donde se cortaba con un `^c^` sin más.

### 5 · Abre el capítulo bajo la mesa del té

```js
// script.js, líneas 111–167
const HEADPIECE = 72; // mm: the depth of the drawing at the head of the page
const HEADNOTE = { size: 9.8, lead: LEAD }; // pt: the headnote keeps the body's leading
const CAPS = { text: 0.597, initial: 0.56 }; // cap heights, em: Unna and Rozha One
// The initial's size: its capital runs from the first line's cap height down to the
// last baseline it spans. The default size is as tall as both line boxes, so its top
// rises above the first line's capitals.
const dropSize = (lines) => pt(((lines - 1) * HEADNOTE.lead + CAPS.text * HEADNOTE.size)
  / CAPS.initial);
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { y: mm(y) }, size: { width } });
// The opener reserves the height of its lowest text, rounded up to the grid, and the
// heading's default bottom margin adds one blank line: with a four-line headnote the
// text starts on line 23. The drawing reserves nothing
// (gotcha: opener-image-no-reserve), so the kicker, the title and the headnote hang
// below it and carry the reserve past it.
const opener = { enabled: true, slot: { elements: [
  // An image element draws a resource without number or caption. It hangs from the
  // trim's corner over the margin, which 1.4.1 paints only when level 1 spans the page.
  { kind: 'image', id: 'headpiece', resourceId: 'tea-table',
    placement: { anchor: { to: 'page', edge: 'top-left' },
      size: { width: mm(TRIM_W) } } },
  // The numeral comes from the heading line,
  // # A Mad Tea-Party {num="VII" headnote="…" …}: the excerpt stands alone, so
  // {chapterNumber} would print 1 (gotcha: heading-number-placeholders).
  { kind: 'text', id: 'kicker', content: 'Chapter {attr.num}', fontFamily: LABEL,
    fontWeight: 600, fontSize: pt(9), letterSpacing: pt(2), textTransform: 'uppercase',
    color: col('jam'), align: 'left',
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { y: mm(HEADPIECE - TOP + 8) } } }, // 8 mm under the drawing's foot
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY,
    fontSize: pt(40), lineHeight: 1.05, color: col('ink'), align: 'left',
    // A longer title wraps instead of ending in '…' (gotcha: overflow-ellipsis-default).
    overflow: 'wrap',
    placement: below('kicker', 2, 'fill') },
  // Drop caps exist only in design text, which is set ragged
  // (gotcha: design-text-ragged): the headnote is the editor's voice, italic and ragged;
  // Carroll's text opens in the flow.
  { kind: 'text', id: 'headnote', content: '{attr.headnote}', fontFamily: TEXT,
    italic: true, fontSize: pt(HEADNOTE.size),
    // A design text's lineHeight multiplies its size
    // (gotcha: design-lineheight-multiple).
    lineHeight: HEADNOTE.lead / HEADNOTE.size, color: col('ink'),
    align: 'left', overflow: 'wrap', // a drop cap needs wrapping text
    dropCap: { lines: 2, fontFamily: DISPLAY, fontSize: dropSize(2), color: col('lawn'),
      gap: mm(1.5) },
    placement: below('title', 5, mm(TEXT_W)) },
  // The note on this edition stands in the channel, level with the headnote's top.
  { kind: 'text', id: 'edition-label', content: 'This edition', fontFamily: LABEL,
    fontWeight: 600, fontSize: pt(8.5), letterSpacing: pt(0.3), color: col('muted'),
    align: 'left', placement: { anchor: { to: '#headnote', edge: 'right-of' },
      offset: { x: layout.gutterWidth }, size: { width: mm(SIDE_W) } } },
  { kind: 'text', id: 'edition', content: '{attr.source}', fontFamily: TEXT,
    fontSize: pt(7.5), lineHeight: 10 / 7.5, color: col('muted'), align: 'left',
    overflow: 'wrap',
    placement: { anchor: { to: '#edition-label', edge: 'below' }, offset: { y: mm(0.8) },
      size: { width: mm(SIDE_W) } } },
] } };
```

La [ranura de diseño](/es/docs/configuration#span-y-diseño-avanzado) del título ocupa toda la página para que el dibujo pueda llegar al corte. Un elemento de imagen no reserva altura, así que el antetítulo, el título y la entradilla cuelgan bajo el dibujo y son ellos los que fijan la reserva, que acaba en el texto más bajo, redondeada a la rejilla; el margen inferior que el título lleva por defecto añade una línea en blanco. Con una entradilla de cuatro líneas el texto empieza en la línea 23, y una quinta lo bajaría a la 24. El número, la entradilla y la nota sobre esta edición salen de [atributos del título](/es/docs/document-format#atributos-de-encabezado), `{num="VII" headnote="…" source="…"}`. El número se toma de ahí porque `{chapterNumber}` imprimiría 1 en un fragmento que es un documento aparte. El texto de diseño nunca se justifica, así que la capitular abre la entradilla del editor, en cursiva y en bandera, y el texto de Carroll empieza justificado debajo, ya en el flujo. `dropSize()` calcula el cuerpo de la capitular para que su parte superior quede a la altura de las mayúsculas de la primera línea de la entradilla.

### 6 · Da a cada glosa el color de lo que explica

```js
// script.js, líneas 16–32
const palette = {
  ink: '#1f1b1a', // text: a warm near-black
  lawn: '#1e6f6b', // glosses on words and jokes, the drop cap; the lawn in the headpiece
  jam: '#b23a48', // glosses on history, the kicker; the headpiece's teapot and arm-chair
  biscuit: '#cdbfae', // the headpiece's chairs, saucers, bread and butter, March Hare
  muted: '#6e645b', // running heads, the note on this edition, the Dormouse
  paper: '#f8f3e6', // the page, and the tablecloth
};
// col(id): a colour linked to its entry. It carries the hex too, because 1.4.1 paints
// design elements from the hex alone (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const entry = (id, hex, name = id) => ({ id, name, value: { hex, model: 'hex' } });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => entry(id, hex)),
  // The engine's defaults link to 'main-color': aimed at the green, nothing prints blue.
  entry('main-color', palette.lawn, 'lawn (defaults)'),
];
```

El verde césped marca las glosas sobre palabras, dichos y chistes, y el rojo mermelada las de personas, fechas e historia del texto. El dibujo toma sus colores del mismo objeto `palette`, así que basta un valor nuevo en `jam` para recolorear a la vez las glosas de historia, sus llamadas, el antetítulo, la tetera y el sillón. Cada color enlazado lleva además su hex, porque 1.4.1 pinta la apertura y las cabeceras con el hex (lo explica «Errores frecuentes»).

## 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/annotated-classic-glosses

### script.js

```js
// ═══ Postext Cookbook · Nº 032 · Annotated classic with margin glosses ════════════
// https://postext.dev/en/cookbook/annotated-classic-glosses
// Code: MIT · Text: Lewis Carroll (public domain), glosses (CC BY 4.0) · Headpiece: in code
// Fonts: Unna, Rozha One, Cormorant SC (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// The mad tea-party as an annotated edition: the text keeps to one column, and its glosses stand
// in the outer margin beside the lines they explain, changing sides with the spread.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en')
const RECIPE = 'annotated-classic-glosses';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: a lawn green and a jam red on cream paper
const palette = {
  ink: '#1f1b1a', // text: a warm near-black
  lawn: '#1e6f6b', // glosses on words and jokes, the drop cap; the lawn in the headpiece
  jam: '#b23a48', // glosses on history, the kicker; the headpiece's teapot and arm-chair
  biscuit: '#cdbfae', // the headpiece's chairs, saucers, bread and butter, March Hare
  muted: '#6e645b', // running heads, the note on this edition, the Dormouse
  paper: '#f8f3e6', // the page, and the tablecloth
};
// col(id): a colour linked to its entry. It carries the hex too, because 1.4.1 paints
// design elements from the hex alone (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const entry = (id, hex, name = id) => ({ id, name, value: { hex, model: 'hex' } });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => entry(id, hex)),
  // The engine's defaults link to 'main-color': aimed at the green, nothing prints blue.
  entry('main-color', palette.lawn, 'lawn (defaults)'),
];
// #endregion
const [TEXT, DISPLAY, LABEL] = ['Unna', 'Rozha One', 'Cormorant SC'];
const LEAD = 14; // body leading in pt: the grid of every page

// #region answer: a float-only channel at the fore-edge, and two gloss styles for it
const layout = {
  layoutType: 'oneAndHalf', // one text column and a narrower side column
  sideColumnPercent: 26, // of the 127 mm between the margins: a 33 mm channel
  sideColumnRole: 'floats', // no text runs in it: it holds side boxes and figures
  sideColumnSide: 'outer', // at the fore-edge, which mirrored margins move page by page
  gutterWidth: mm(4.5), // the text column keeps 127 − 33 − 4.5 = 89.5 mm
};
// A gloss is :::callout{type="note" span="side" title="a · …"} in the Markdown.
// span="side" moves it out of the flow into the channel, level with the block after
// the fence, so the fence goes just before the paragraph that carries its letter.
// Glosses never float. One that meets the gloss above stacks under it. One that would
// run past the column's foot slides up, as far as the gloss above allows, until its
// foot sits on the foot; if it still does not fit, it waits for the next page
// (gotcha: side-box-starts-at-fence).
const gloss = (id, hue) => ({ id,
  backgroundEnabled: false, // one device: a hairline over the gloss, in its colour
  stripe: { enabled: true, side: 'top', width: pt(0.5), color: col(hue) },
  padding: { top: mm(1.5), right: pt(0), bottom: pt(0), left: pt(0) },
  // Cormorant SC draws lower case as small capitals: the title needs no textTransform.
  titleStyle: { fontFamily: LABEL, fontWeight: 600, fontSize: pt(8.5),
    letterSpacing: pt(0.3), color: col(hue), gap: mm(0.8) },
  // Face and colours come from bodyText. A box body also inherits its 4 mm first-line
  // indent, which a gloss sets back to 0.
  body: { fontSize: pt(7.8), lineHeight: pt(10), textAlign: 'left',
    firstLineIndent: pt(0) } });
const calloutStyles = [gloss('note', 'lawn'), gloss('context', 'jam')]; // words, history
// #endregion

// #region letters: the letter that calls each gloss, in the gloss's colour
// A letter in the text is a chip named after its gloss's type, :chip[^a^]{style="note"}:
// the Markdown has no mark for coloured text, and a chip style sets a colour. With no
// fill, outline, padding or gap the chip adds no width, so the lines break as they would
// with a plain ^a^.
const letter = (id, hue) => ({ id, backgroundEnabled: false, borderWidth: pt(0),
  paddingX: pt(0), gap: pt(0), color: col(hue), bold: true });
const chipStyles = [letter('note', 'lawn'), letter('context', 'jam')];
// #endregion

// #region page: a trade page with mirrored margins, so the channel is always at the fore-edge
const PT = 25.4 / 72; // mm in a point
const [TRIM_W, TRIM_H] = [156, 234]; // mm
const [TOP, BOTTOM, INNER, OUTER] = [22, 22, 15, 14]; // mm: 38 lines of text
const page = { width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150,
  backgroundColor: col('paper'),
  // left is a recto's inner margin; mirror swaps the sides on a verso, and 'outer' moves
  // the channel with them: right on a recto, left on a verso.
  margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER),
    mirror: true } };
const CONTENT_W = TRIM_W - INNER - OUTER; // 127 mm
const SIDE_W = CONTENT_W * layout.sideColumnPercent / 100; // 33 mm: the glosses' measure
const TEXT_W = CONTENT_W - SIDE_W - layout.gutterWidth.value;
// #endregion

// #region text: book texture and a flush opening paragraph
const bodyText = { // justified and hyphenated (en-us) by default
  // Copy-fitted to the 89.5 mm column: at 9.5 pt '“Have some wine,” the March Hare said in
  // an encouraging tone.' fits one line. At 10.4 pt every measure from 77 to 93 mm left
  // it a short last line: 'couraging tone.', 'aging tone.', 'ing tone.' or 'tone.'.
  fontFamily: TEXT, fontSize: pt(9.5), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: mm(4),
  // Unna's space is narrow, 0.22 em. At the default 0.6 the line breaker set 'Alice felt
  // dreadfully puzzled. The Hatter’s remark seemed to have' with its spaces at 0.69.
  minWordSpacing: 0.75 };
const paragraphStyles = [
  // The chapter's first paragraph, set flush. indentAfterHeading: false would do it
  // without glosses, but the gloss fences between the heading and this paragraph
  // count as the block after the heading, so the default stays and the paragraph takes
  // this style (gotcha: side-box-after-heading).
  { id: 'opening', firstLineIndent: pt(0) },
];
// #endregion

// #region opener: a headpiece, the chapter's numeral and title, a headnote with a drop cap
const HEADPIECE = 72; // mm: the depth of the drawing at the head of the page
const HEADNOTE = { size: 9.8, lead: LEAD }; // pt: the headnote keeps the body's leading
const CAPS = { text: 0.597, initial: 0.56 }; // cap heights, em: Unna and Rozha One
// The initial's size: its capital runs from the first line's cap height down to the
// last baseline it spans. The default size is as tall as both line boxes, so its top
// rises above the first line's capitals.
const dropSize = (lines) => pt(((lines - 1) * HEADNOTE.lead + CAPS.text * HEADNOTE.size)
  / CAPS.initial);
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { y: mm(y) }, size: { width } });
// The opener reserves the height of its lowest text, rounded up to the grid, and the
// heading's default bottom margin adds one blank line: with a four-line headnote the
// text starts on line 23. The drawing reserves nothing
// (gotcha: opener-image-no-reserve), so the kicker, the title and the headnote hang
// below it and carry the reserve past it.
const opener = { enabled: true, slot: { elements: [
  // An image element draws a resource without number or caption. It hangs from the
  // trim's corner over the margin, which 1.4.1 paints only when level 1 spans the page.
  { kind: 'image', id: 'headpiece', resourceId: 'tea-table',
    placement: { anchor: { to: 'page', edge: 'top-left' },
      size: { width: mm(TRIM_W) } } },
  // The numeral comes from the heading line,
  // # A Mad Tea-Party {num="VII" headnote="…" …}: the excerpt stands alone, so
  // {chapterNumber} would print 1 (gotcha: heading-number-placeholders).
  { kind: 'text', id: 'kicker', content: 'Chapter {attr.num}', fontFamily: LABEL,
    fontWeight: 600, fontSize: pt(9), letterSpacing: pt(2), textTransform: 'uppercase',
    color: col('jam'), align: 'left',
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { y: mm(HEADPIECE - TOP + 8) } } }, // 8 mm under the drawing's foot
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY,
    fontSize: pt(40), lineHeight: 1.05, color: col('ink'), align: 'left',
    // A longer title wraps instead of ending in '…' (gotcha: overflow-ellipsis-default).
    overflow: 'wrap',
    placement: below('kicker', 2, 'fill') },
  // Drop caps exist only in design text, which is set ragged
  // (gotcha: design-text-ragged): the headnote is the editor's voice, italic and ragged;
  // Carroll's text opens in the flow.
  { kind: 'text', id: 'headnote', content: '{attr.headnote}', fontFamily: TEXT,
    italic: true, fontSize: pt(HEADNOTE.size),
    // A design text's lineHeight multiplies its size
    // (gotcha: design-lineheight-multiple).
    lineHeight: HEADNOTE.lead / HEADNOTE.size, color: col('ink'),
    align: 'left', overflow: 'wrap', // a drop cap needs wrapping text
    dropCap: { lines: 2, fontFamily: DISPLAY, fontSize: dropSize(2), color: col('lawn'),
      gap: mm(1.5) },
    placement: below('title', 5, mm(TEXT_W)) },
  // The note on this edition stands in the channel, level with the headnote's top.
  { kind: 'text', id: 'edition-label', content: 'This edition', fontFamily: LABEL,
    fontWeight: 600, fontSize: pt(8.5), letterSpacing: pt(0.3), color: col('muted'),
    align: 'left', placement: { anchor: { to: '#headnote', edge: 'right-of' },
      offset: { x: layout.gutterWidth }, size: { width: mm(SIDE_W) } } },
  { kind: 'text', id: 'edition', content: '{attr.source}', fontFamily: TEXT,
    fontSize: pt(7.5), lineHeight: 10 / 7.5, color: col('muted'), align: 'left',
    overflow: 'wrap',
    placement: { anchor: { to: '#edition-label', edge: 'below' }, offset: { y: mm(0.8) },
      size: { width: mm(SIDE_W) } } },
] } };
// #endregion

// The running heads: the book on the verso, the chapter on the recto, folios at the fore-edge.
const HEAD = 14; // mm from the top trim to the heads' baseline
const INSET = 8; // mm from the folio's outer edge to the running head's
const DROP = 13; // mm from the bottom trim up to the foot of the drop folio's box
// In 1.4.1 a design text's first baseline sits 0.96 em below the top of its box: the default
// lineHeight, 1.2, times 0.8, where the baseline falls in the line box.
const BASELINE = 1.2 * 0.8;
const baseline = (size) => mm(HEAD - BASELINE * size * PT);
const head = (id, parity, content, x, extra = {}) => ({ kind: 'text', id, parity, content,
  pages: 'body', fontFamily: LABEL, fontWeight: 600, fontSize: pt(8.5), letterSpacing: pt(1.2),
  textTransform: 'uppercase', color: col('muted'), ...extra,
  placement: { anchor: { to: 'page', edge: parity === 'even' ? 'top-left' : 'top-right' },
    offset: { x: mm(x), y: baseline(extra.fontSize?.value ?? 8.5) } } });
const folio = { fontFamily: TEXT, fontWeight: 700, fontSize: pt(9), letterSpacing: pt(0),
  color: col('ink') };
const header = { elements: [
  head('verso-folio', 'even', '{pageNumber}', OUTER, folio),
  head('verso-title', 'even', '{title}', OUTER + INSET),
  head('recto-title', 'odd', '{chapterTitle}', -(OUTER + INSET)),
  head('recto-folio', 'odd', '{pageNumber}', -OUTER, folio),
] };
// The opener, a recto, carries a drop folio at the foot of its channel instead.
const footer = { elements: [{ ...head('drop-folio', 'odd', '{pageNumber}', -OUTER, folio),
  pages: 'opener', placement: { anchor: { to: 'page', edge: 'bottom-right' },
    offset: { x: mm(-OUTER), y: mm(-DROP) } } }] };

const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity)
  colorPalette, page, layout, bodyText, paragraphStyles, calloutStyles, chipStyles, header,
  footer,
  headings: {
    fontFamily: DISPLAY, fontWeight: 400, color: col('ink'), // Rozha One has one weight
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      // 'odd' puts the opener on a recto; span 'page' lets its design cross the channel.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
        advancedDesign: opener },
    ],
  },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Alice’s Adventures in Wonderland"
author: "Lewis Carroll"
---

# A Mad Tea-Party {num="VII" headnote="The tea-party is not in the book Carroll wrote out by hand for Alice Liddell in 1864; he added it, with the Cheshire Cat, for the book of 1865. Letters in the text point to the glosses beside it: green for words and jokes, red for history." source="Text after Project Gutenberg eBook 11; notes CC BY 4.0. Set in Unna, Rozha One and Cormorant SC (SIL OFL)."}

:::callout{type="note" span="side" title="a · The March Hare"}
‘Mad as a March hare’ is an old saying. Hares are shy, but in early spring they race about the fields and rear up on their hind legs to box.
:::

:::callout{type="context" span="side" title="b · The Hatter"}
‘Mad as a hatter’ was a saying before Carroll used it. Hatters made felt from fur treated with mercury, and the fumes gave many of them tremors and fits of shyness and temper. Carroll never calls him the Mad Hatter.
:::

:::paragraphs{style="opening"}
There was a table set out under a tree in front of the house, and the March Hare:chip[^a^]{style="note"} and the Hatter:chip[^b^]{style="context"} were having tea at it: a Dormouse was sitting between them, fast asleep, and the other two were using it as a cushion, resting their elbows on it, and talking over its head. “Very uncomfortable for the Dormouse,” thought Alice; “only, as it’s asleep, I suppose it doesn’t mind.”
:::

The table was a large one, but the three were all crowded together at one corner of it: “No room! No room!” they cried out when they saw Alice coming. “There’s *plenty* of room!” said Alice indignantly, and she sat down in a large arm-chair at one end of the table.

“Have some wine,” the March Hare said in an encouraging tone.

Alice looked all round the table, but there was nothing on it but tea. “I don’t see any wine,” she remarked.

“There isn’t any,” said the March Hare.

“Then it wasn’t very civil of you to offer it,” said Alice angrily.

“It wasn’t very civil of you to sit down without being invited,” said the March Hare.

“I didn’t know it was *your* table,” said Alice; “it’s laid for a great many more than three.”

:::callout{type="context" span="side" title="c · Hair"}
Tenniel drew Alice with long, loose hair. Alice Liddell, in Carroll’s photographs of her, wears hers short and dark, with a fringe.
:::

“Your hair wants cutting,”:chip[^c^]{style="context"} said the Hatter. He had been looking at Alice for some time with great curiosity, and this was his first speech.

“You should learn not to make personal remarks,” Alice said with some severity; “it’s very rude.”

:::callout{type="context" span="side" title="d · The riddle"}
Carroll made up the riddle without an answer. So many readers asked for one that in a preface of 1896 he offered this: ‘Because it can produce a few notes, tho they are very flat; and it is nevar put with the wrong end in front!’ Later printings changed *nevar*, raven spelt backwards, to *never*, and the joke was lost.
:::

The Hatter opened his eyes very wide on hearing this; but all he *said* was, “Why is a raven like a writing-desk?”:chip[^d^]{style="context"}

“Come, we shall have some fun now!” thought Alice. “I’m glad they’ve begun asking riddles.—I believe I can guess that,” she added aloud.

“Do you mean that you think you can find out the answer to it?” said the March Hare.

“Exactly so,” said Alice.

“Then you should say what you mean,” the March Hare went on.

“I do,” Alice hastily replied; “at least—at least I mean what I say—that’s the same thing, you know.”

:::callout{type="note" span="side" title="e · I see what I eat"}
The Hatter has the logic right. Turn a statement round and you get its converse, which can be false when the statement is true. Carroll, as Charles Dodgson, taught mathematics at Christ Church, Oxford, and wrote two books on logic.
:::

“Not the same thing a bit!” said the Hatter. “You might just as well say that ‘I see what I eat’ is the same thing as ‘I eat what I see’!”:chip[^e^]{style="note"}

“You might just as well say,” added the March Hare, “that ‘I like what I get’ is the same thing as ‘I get what I like’!”

“You might just as well say,” added the Dormouse, who seemed to be talking in his sleep, “that ‘I breathe when I sleep’ is the same thing as ‘I sleep when I breathe’!”

“It *is* the same thing with you,” said the Hatter, and here the conversation dropped, and the party sat silent for a minute, while Alice thought over all she could remember about ravens and writing-desks, which wasn’t much.

The Hatter was the first to break the silence. “What day of the month is it?” he said, turning to Alice: he had taken his watch out of his pocket, and was looking at it uneasily, shaking it every now and then, and holding it to his ear.

:::callout{type="context" span="side" title="f · The fourth"}
Of May: Alice Liddell was born on 4 May 1852.
:::

Alice considered a little, and then said “The fourth.”:chip[^f^]{style="context"}

“Two days wrong!” sighed the Hatter. “I told you butter wouldn’t suit the works!” he added looking angrily at the March Hare.

:::callout{type="note" span="side" title="g · The best butter"}
Grocers sold butter by grade, and ‘best’ fetched the highest price. The March Hare defends its quality, which was never the trouble.
:::

“It was the *best* butter,”:chip[^g^]{style="note"} the March Hare meekly replied.

“Yes, but some crumbs must have got in as well,” the Hatter grumbled: “you shouldn’t have put it in with the bread-knife.”

The March Hare took the watch and looked at it gloomily: then he dipped it into his cup of tea, and looked at it again: but he could think of nothing better to say than his first remark, “It was the *best* butter, you know.”

Alice had been looking over his shoulder with some curiosity. “What a funny watch!” she remarked. “It tells the day of the month, and doesn’t tell what o’clock it is!”

“Why should it?” muttered the Hatter. “Does *your* watch tell you what year it is?”

“Of course not,” Alice replied very readily: “but that’s because it stays the same year for such a long time together.”

“Which is just the case with *mine*,” said the Hatter.

Alice felt dreadfully puzzled. The Hatter’s remark seemed to have no sort of meaning in it, and yet it was certainly English. “I don’t quite understand you,” she said, as politely as she could.

“The Dormouse is asleep again,” said the Hatter, and he poured a little hot tea upon its nose.

:::callout{type="note" span="side" title="h · The Dormouse"}
Hazel dormice sleep through the day and hibernate for half the year, from autumn to spring. The name is often traced to the French *dormir*, to sleep.
:::

The Dormouse:chip[^h^]{style="note"} shook its head impatiently, and said, without opening its eyes, “Of course, of course; just what I was going to remark myself.”

“Have you guessed the riddle yet?” the Hatter said, turning to Alice again.

“No, I give it up,” Alice replied: “what’s the answer?”

“I haven’t the slightest idea,” said the Hatter.

“Nor I,” said the March Hare.

Alice sighed wearily. “I think you might do something better with the time,” she said, “than waste it in asking riddles that have no answers.”

“If you knew Time as well as I do,” said the Hatter, “you wouldn’t talk about wasting *it*. It’s *him*.”

“I don’t know what you mean,” said Alice.

“Of course you don’t!” the Hatter said, tossing his head contemptuously. “I dare say you never even spoke to Time!”
`; // content.<lang>.md, inlined by the Cookbook

// #region art: the tea-table seen from above, drawn in the page's colours
const mulberry32 = (seed) => () => { // a seeded generator: the same leaves on every run
  let t = (seed += 0x6d2b79f5);
  t = Math.imul(t ^ (t >>> 15), t | 1);
  t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
const mix = (a, b, k) => `#${[1, 3, 5].map((i) => Math.round(parseInt(a.slice(i, i + 2), 16)
  * (1 - k) + parseInt(b.slice(i, i + 2), 16) * k).toString(16).padStart(2, '0')).join('')}`;
const P = palette;
const LEAF = mix(P.lawn, P.ink, 0.35);
const f1 = (n) => Math.round(n * 10) / 10;
const circle = (x, y, r, fill, extra = '') => `<circle cx="${f1(x)}" cy="${f1(y)}" r="${f1(r)}" `
  + `fill="${fill}"${extra}/>`;
const ring = (x, y, r, stroke, w) => circle(x, y, r, 'none',
  ` stroke="${stroke}" stroke-width="${w}"`);
const path = (d, fill, extra = '') => `<path d="${d}" fill="${fill}"${extra}/>`;
const at = (x, y, turn, body) => `<g transform="translate(${f1(x)} ${f1(y)}) rotate(${f1(turn)})">`
  + `${body}</g>`;
const rect = (x, y, w, h, r, fill, extra = '') => `<rect x="${x}" y="${y}" width="${w}" `
  + `height="${h}" rx="${r}" fill="${fill}"${extra}/>`;

// A cup on its saucer, from above: the handle points along `turn`.
const cup = (x, y, turn, tea = true) => at(x, y, turn, circle(0, 0, 44, P.paper,
  ` stroke="${P.biscuit}" stroke-width="4"`) + ring(34, 0, 9, P.lawn, 6)
  + circle(0, 0, 27, P.paper, ` stroke="${P.lawn}" stroke-width="6"`)
  + (tea ? circle(0, 0, 19, mix(P.muted, P.biscuit, 0.35)) : ''));
const teapot = (x, y) => at(x, y, 0, path('M54 -14C86 -20 98 -40 112 -52C106 -30 98 -6 60 16Z',
  P.jam) + ring(-68, 0, 24, P.jam, 12) + circle(0, 0, 62, P.jam)
  + circle(0, 0, 36, P.jam, ` stroke="${P.paper}" stroke-width="4"`) + circle(0, 0, 10, P.paper));
// Bread and butter: three slices on a plate.
const plate = (x, y, turn) => at(x, y, turn, circle(0, 0, 50, P.paper,
  ` stroke="${P.lawn}" stroke-width="4"`) + [0, 120, 240].map((a) => at(0, 0, a,
  path('M-6 -8L-30 -34L22 -34Z', P.biscuit, ` stroke="${P.paper}" stroke-width="3"`))).join(''));
// The butter on its dish, with the bread-knife that put the crumbs in the watch.
const butter = (x, y) => at(x, y, -8, rect(-44, -28, 88, 56, 12, P.paper,
  ` stroke="${P.lawn}" stroke-width="4"`) + rect(-24, -16, 48, 32, 4, P.biscuit)
  + path('M-40 44L52 44L58 50L-40 50Z', P.muted));
// The Hatter's watch, on its chain.
const watch = (x, y) => [0, 1, 2, 3, 4, 5, 6, 7].map((i) => circle(x - 52 - i * 15,
  y + 18 * Math.sin(i / 1.5), 5, 'none', ` stroke="${P.muted}" stroke-width="3"`)).join('')
  + circle(x - 40, y, 8, P.muted) + circle(x, y, 36, P.paper, ` stroke="${P.ink}" stroke-width="6"`)
  + path(`M${x} ${y}L${x} ${y - 25}M${x} ${y}L${x + 17} ${y + 8}`, 'none',
    ` stroke="${P.ink}" stroke-width="4" stroke-linecap="round"`) + circle(x, y, 4, P.ink);
// The Hatter at the table's end: his silk hat from above, with a crescent of red band, a sheen
// and the price ticket.
const hatter = (x, y) => circle(x, y, 72, P.ink) + circle(x, y, 50, P.jam)
  + circle(x - 7, y - 9, 47, mix(P.ink, P.muted, 0.25))
  + path(`M${x - 38} ${y - 24}A40 40 0 0 1 ${x + 6} ${y - 50}`, 'none',
    ` stroke="${P.paper}" stroke-opacity=".3" stroke-width="5" stroke-linecap="round"`)
  + at(x + 34, y + 30, 40, rect(-15, -11, 30, 22, 2, P.paper));
// The March Hare from behind, its ears laid back.
const hare = (x, y) => [-16, 16].map((a) => at(x, y, a, path('M-18 20C-22 70 -12 116 0 122C12 116'
  + ' 22 70 18 20Z', P.biscuit) + path('M-8 40C-10 74 -5 100 0 104C5 100 10 74 8 40Z', P.jam,
  ' fill-opacity=".35"'))).join('') + circle(x, y, 40, P.biscuit);
// The Dormouse asleep, curled in its tail.
const dormouse = (x, y) => path(`M${x + 30} ${y + 8}C${x + 60} ${y + 40} ${x + 10} ${y + 64} `
  + `${x - 24} ${y + 44}`, 'none', ` stroke="${P.muted}" stroke-width="7" stroke-linecap="round"`)
  + circle(x, y, 32, P.muted) + circle(x - 20, y - 24, 10, P.muted) + circle(x + 6, y - 30, 10,
    P.muted) + path(`M${x - 16} ${y - 8}q6 5 12 0`, 'none',
    ` stroke="${P.paper}" stroke-width="3" stroke-linecap="round"`);
const chair = (x, y, turn) => at(x, y, turn, rect(-34, -30, 68, 60, 10, P.biscuit)
  + rect(-36, -44, 72, 12, 6, mix(P.biscuit, P.muted, 0.4)));
// Alice's arm-chair at the far end, its back away from the table.
const armchair = (x, y) => rect(x - 80, y - 80, 36, 160, 14, P.jam)
  + [-80, 52].map((dy) => rect(x - 70, y + dy, 140, 28, 12, P.jam)).join('')
  + rect(x - 46, y - 54, 112, 108, 16, mix(P.jam, P.paper, 0.25));

function teaTable() {
  const [W, H] = [TRIM_W * 10, HEADPIECE * 10]; // 0.1 mm units
  const [X0, X1, Y0, Y1] = [220, 1390, 200, 520]; // the tablecloth
  const rand = mulberry32(1865);
  const leaves = [];
  for (let i = 0; i < 70; i++) { // fallen leaves on the lawn, never on the table
    const [x, y] = [rand() * W, rand() * H];
    if (x > X0 - 90 && x < X1 + 170 && y > Y0 - 90 && y < Y1 + 110) continue;
    const s = 0.6 + rand() * 0.7;
    leaves.push(at(x, y, rand() * 360, path(`M0 ${-26 * s}C${16 * s} ${-12 * s} ${16 * s} `
      + `${12 * s} 0 ${26 * s}C${-16 * s} ${12 * s} ${-16 * s} ${-12 * s} 0 ${-26 * s}Z`, LEAF)));
  }
  const scallop = (x, y) => circle(x, y, 15, P.paper);
  const hem = []; // the cloth's scalloped edge
  for (let x = X0; x <= X1; x += 30) hem.push(scallop(x, Y0), scallop(x, Y1));
  for (let y = Y0; y <= Y1; y += 30) hem.push(scallop(X0, y), scallop(X1, y));
  const places = [340, 490, 640, 790, 940, 1090];
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" `
    + `viewBox="0 0 ${W} ${H}">${rect(0, 0, W, H, 0, P.lawn)}${leaves.join('')}`
    + places.map((x) => chair(x, Y0 - 58, 0) + chair(x + 40, Y1 + 58, 180)).join('')
    + armchair(150, (Y0 + Y1) / 2) + hare(1260, Y1 + 72)
    + rect(X0, Y0, X1 - X0, Y1 - Y0, 0, P.paper) + hem.join('')
    + rect(X0 + 28, Y0 + 28, X1 - X0 - 56, Y1 - Y0 - 56, 0, 'none',
      ` stroke="${P.jam}" stroke-width="3"`)
    + places.map((x, i) => cup(x, Y0 + 62, 200 + i * 23, i % 3 !== 1)
      + cup(x + 40, Y1 - 62, 20 - i * 31, i % 2 === 0)).join('')
    + cup(1215, 300, 150) + cup(1290, 420, 60) + cup(1195, 440, 250, false) // crowded at one end
    + plate(470, 360, 10) + butter(595, 350) + teapot(760, 360) + watch(1010, 360)
    + dormouse(1330, 300) + hatter(1470, 360) + '</svg>';
}
// #endregion

// The headpiece is a resource that only the opener's design draws: never cited, never placed.
const resources = [{ id: 'tea-table', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'tea-table.svg', width: TRIM_W * 10, height: HEADPIECE * 10 },
  altText: 'The tea-table seen from above: a long cloth laid with cups, a red teapot, butter '
    + 'and a watch on its chain; a red arm-chair at one end and, at the other, the Hatter’s hat, '
    + 'the Dormouse asleep and the March Hare’s ears.' }];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first)
  Unna: ['400', '400i', '700'], // 700: the folios and the gloss letters
  'Rozha One': ['400'],
  'Cormorant SC': ['600'],
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await loadSvg('tea-table.svg', teaTable());
const continuation = { pageNumbering: { startAt: 81 } }; // chapter VII of a book: an odd folio
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources, continuation }, config()), markdown);
showPages(doc, { title: 'Annotated classic with margin glosses' });

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

### Deja las glosas a la derecha en todas las páginas

Para una edición que se lee página a página en pantalla, desactiva los márgenes simétricos: `'outer'` pasa a ser el borde derecho de todas las páginas, y las cabeceras no se mueven, porque van ancladas a los bordes de la página.

```diff
-    mirror: true } };
+    mirror: false } };
```

### Lleva las notas largas al final del capítulo

Una nota más larga que el párrafo que explica puede escribirse como nota al pie de Markdown: [Notas finales a dos columnas en lugar de notas al pie](https://postext.dev/es/cookbook/endnotes-instead-of-footnotes.md) convierte cada `[^1]` en un número volado y reúne las notas en una página aparte, tras el capítulo.

## 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.
- **Un recuadro lateral tras un título sangra el párrafo siguiente.** En postext 1.4.1, un recuadro con span: 'side' entre un título y su primer párrafo le da a ese párrafo sangría de primera línea, aunque indentAfterHeading sea false: el recuadro sale del flujo, pero sus bloques siguen contando como el bloque que sigue al título. Coloca el recuadro después del primer párrafo.
- **{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.
- **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.
- **El texto de diseño nunca se justifica, así que la entradilla de una capitular va en bandera.** En postext 1.4.1, un elemento de texto de diseño se alinea a la izquierda, al centro o a la derecha y corta palabra a palabra: no hay alineación justificada, y hyphenate: true solo parte una palabra que no cabe en una línea entera. Las líneas de una entradilla junto a una dropCap acaban, por tanto, en bandera al lado del texto justificado. Limita la entradilla a las líneas que acompañan a la capitular y ajústalas a mano: con lines: 1 (una capitular alzada) la entradilla es una sola línea, que el gap de la dropCap puede dejar a ras; el párrafo sigue en el Markdown.
- **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 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.
- **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.

- Las glosas se apilan en el orden del Markdown, una debajo de otra, así que una glosa larga empuja hacia abajo a la siguiente. En la [página 81](https://postext.dev/cookbook/annotated-classic-glosses/en/p01.webp?v=dc7bd3ad) las dos llamadas caen en la segunda línea del primer párrafo: la glosa a empieza a la altura del párrafo y la b a continuación, cinco líneas por debajo de su llamada. Donde las llamadas queden cerca, escribe glosas breves.
- El error «Un recuadro lateral tras un título sangra el párrafo siguiente», arriba, aconseja poner el recuadro después del primer párrafo. Una glosa con la valla ahí empieza junto al segundo párrafo (lo explica «Un recuadro lateral empieza a la altura del bloque que sigue a su valla»), así que esta edición deja las dos vallas antes del primer párrafo y le quita la sangría con el estilo `opening`.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texto: Alice’s Adventures in Wonderland (1865), comienzo del capítulo VII, «A Mad Tea-Party»: Lewis Carroll ([fuente](https://www.gutenberg.org/ebooks/11)), dominio público
- Texto: La entradilla, las ocho glosas y la nota sobre esta edición: Ignacio Ferro, CC-BY-4.0
- Imágenes: La mesa del té vista desde arriba, dibujada en código con la paleta de la página: Ignacio Ferro, CC-BY-4.0
- Tipografías: Unna (OFL-1.1), Rozha One (OFL-1.1), Cormorant 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.º 001 · Libro de texto con columna al margen](https://postext.dev/es/cookbook/textbook-margin-column.md): Columna y media con la columna exterior solo para flotantes: en ella se apilan figuras y glosas con span 'side', y captionSide lleva allí los demás pies. · Nivel 3 (Avanzado) · Libros de texto
- [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
