# Cabeceras según la paridad en un libro de ensayos

> Cabeceras por paridad y tipo de página: el título del libro en las pares, el del ensayo cortado en las impares y solo un folio al pie en las aperturas.

- Versión HTML: https://postext.dev/es/cookbook/running-heads-by-parity
- Receta N.º 005 · Cabeceras y folios · Nivel 2 (Intermedio) · 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: [29](https://postext.dev/cookbook/running-heads-by-parity/es/p01.webp?v=5c2949b1), [30](https://postext.dev/cookbook/running-heads-by-parity/es/p02.webp?v=5c2949b1), [31](https://postext.dev/cookbook/running-heads-by-parity/es/p03.webp?v=5c2949b1), [32](https://postext.dev/cookbook/running-heads-by-parity/es/p04.webp?v=5c2949b1), [33](https://postext.dev/cookbook/running-heads-by-parity/es/p05.webp?v=5c2949b1)
- Última actualización: 2026-09-25
- Otros idiomas: [en](https://postext.dev/en/cookbook/running-heads-by-parity.md)

## Lo que vas a componer

Las páginas 29 a 33 de los *Ensayos* de Montaigne en edición de bolsillo y traducción nueva: el capítulo IV entero y el arranque del V. Las pares llevan el título del libro en versales espaciadas, y las impares, el del ensayo en cursiva, cortado con puntos suspensivos porque el del capítulo IV tiene 87 caracteres. Cada folio va en una pestaña sepia en el margen exterior, a 3 mm de un filete de 0,5 pt en el borde de la caja. Las aperturas solo tienen un folio al pie, y la página en blanco anterior al capítulo V no lleva cabecera ni folio. El texto va en Baskervville sobre papel ahuesado; los títulos, en Libre Caslon Display, y los folios y el título del libro, en Alegreya SC. La configuración define todas las cabeceras, así que la del motor (Open Sans de 8 pt, en azul) no se imprime.

**Esta receta responde a:**

- ¿Cómo pongo el título del libro en las páginas pares, el del capítulo en las impares y el folio por fuera?
- ¿Cómo oculto las cabeceras en las aperturas y las páginas en blanco, o pinto una página par en blanco con el color de la parte?
- ¿Cómo evito que los títulos, las negritas y las viñetas salgan en azul?

## La respuesta corta

```js
// script.js, líneas 68–96
const GAP = 3; // mm from the tab to the divider, which lands on the text block's edge
const HEAD = 12; // mm from the top edge of the page to the top of the tab
const CAP = t({ en: 60.5, es: 66.8 }); // mm: fitted so essay IV's head ends on a word
// (gotcha: ellipsis-mid-word); a short running title (see Variations) suits any title
const page = (edge) => ({ to: 'page', edge }); // the trim box (gotcha: negative-offsets)
const header = { elements: [
  // Verso (even): tab | divider | THE BOOK'S TITLE from the frontmatter (gotcha: quote-frontmatter)
  folio({ id: 'folio-even', parity: 'even', pages: 'body', anchor: page('top-left'),
    x: OUTER - GAP - TAB, y: HEAD }),
  divider({ id: 'rule-even', parity: 'even', from: 'folio-even', edge: 'right-of', x: GAP }),
  { kind: 'text', id: 'book', content: '{title}', parity: 'even', pages: 'body',
    fontFamily: 'Alegreya SC', fontSize: pt(FOLIO_PT), lineHeight: LINE / FOLIO_PT,
    color: col('muted'), textTransform: 'uppercase', letterSpacing: pt(1.3),
    placement: { anchor: { to: '#rule-even', edge: 'right-of' }, // top: the tab's top
      offset: { x: mm(2.5), y: pt(PAD.top) } } }, // PAD.top down: on the folio's baseline
  // Recto (odd): the essay's title, cut short with an ellipsis | divider | tab.
  folio({ id: 'folio-odd', parity: 'odd', pages: 'body', anchor: page('top-right'),
    x: -(OUTER - GAP - TAB), y: HEAD }), // from the right edge, a negative x runs inwards
  divider({ id: 'rule-odd', parity: 'odd', from: 'folio-odd', edge: 'left-of', x: -GAP }),
  { kind: 'text', id: 'essay', content: '{chapterTitle}', parity: 'odd', pages: 'body',
    fontFamily: 'Baskervville', italic: true, fontSize: pt(TITLE_PT),
    lineHeight: LINE / TITLE_PT, color: col('muted'),
    overflow: 'ellipsis-end', // stated, though default (gotcha: overflow-ellipsis-default)
    placement: { anchor: { to: '#rule-odd', edge: 'left-of' },
      offset: { x: mm(-2.5), y: pt(PAD.top) }, size: { maxWidth: mm(CAP) } } },
] };
// Openers: no head, only a drop folio HEAD above the page's foot, centred under the text block.
const footer = { elements: [folio({ id: 'drop-folio', parity: 'all', pages: 'opener',
  anchor: { to: 'container', edge: 'bottom' }, y: -HEAD })] };
```

## Ingredientes

**Enseña**

- [Cabeceras y folios](https://postext.dev/es/docs/configuration.md#encabezados-y-pies): Ranuras de cabecera y pie con marcadores ({pageNumber}, {chapterTitle}, {title}…) distintas en recto y verso, con los títulos largos recortados o partidos.
- [Cabeceras según el tipo de página](https://postext.dev/es/docs/configuration.md#elementos-de-texto): Muestra un elemento de diseño solo en páginas de texto, de apertura, de parte o en blanco, y en páginas pares o impares.

**También usa**

- [Anclaje de elementos de diseño](https://postext.dev/es/docs/configuration.md#posicionamiento-de-elementos)
- [Textos, filetes y cajas en los diseños de página](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Saltos de línea en los títulos](https://postext.dev/es/docs/document-format.md#saltos-de-línea-en-los-títulos)
- [Capítulos que abren en página impar](https://postext.dev/es/docs/configuration.md#saltar-antes)
- [Márgenes simétricos](https://postext.dev/es/docs/configuration.md#márgenes-simétricos-espejo)
- [Formato de página](https://postext.dev/es/docs/configuration.md#tamaños-de-página-predefinidos)
- [Color del papel](https://postext.dev/es/docs/configuration.md#página)
- [Metadatos del documento](https://postext.dev/es/docs/document-format.md#frontmatter)
- [Negrita, cursiva y sus colores](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration.md#paleta-de-colores)
- [Recuadros](https://postext.dev/es/docs/configuration.md#estilos-de-aviso)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)
- [Viudas, huérfanas y líneas cortas](https://postext.dev/es/docs/configuration.md#huérfanas-viudas-runts-y-reglas-de-cohesión)
- [Páginas en un canvas](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap)
- [Libros construidos capítulo a capítulo](https://postext.dev/es/docs/configuration.md#componer-y-renderizar-un-paquete)
- [Banda de capítulo a todo el ancho](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)

**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), [`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), [`locale`](https://postext.dev/es/docs/configuration.md#separación-silábica), [`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), [`renderPageToCanvas`](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap)

**Tipografías**

- Baskervville (OFL-1.1), Libre Caslon Display (OFL-1.1), Alegreya SC (OFL-1.1)

## Elaboración

### 1 · Sustituye la cabecera azul de serie

```js
// script.js, líneas 14–27
const palette = {
  ink: '#1f1b16', // text: a warm near-black, never #000
  sepia: '#8a5a2b', // the one accent: folio tabs, dividers, opener plates (5.3:1 on paper)
  muted: '#6f6558', // running heads, verse glosses, the colophon (5.2:1 on paper)
  paper: '#f8f4ec', // a warm off-white page; also the type reversed out of the sepia
};
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' } })),
  // Text-style defaults (headings, bold, italic, lists, boxes) link to 'main-color'. The
  // built-in header and footer keep #295AA3, and design slots never read the palette
  // (gotcha: palette-skips-designs), so every element colour here carries its hex.
  { id: 'main-color', name: 'sepia (defaults)', value: { hex: palette.sepia, model: 'hex' } },
];
```

Cada color de la configuración enlaza con una de estas entradas. Si dejas `header` sin definir, el motor dibuja su cabecera de serie: `{title}` en las impares y `{chapterTitle}` en las pares sobre un filete de 1 pt, en Open Sans 600 de 8 pt y en azul `#295AA3` ([encabezados y pies](/es/docs/configuration#encabezados-y-pies)). Si `main-color` toma el valor del sepia, cambian los colores por defecto del texto, pero esa cabecera sigue en azul, porque las ranuras de diseño no leen la paleta. Por eso aquí cada cabecera se escribe entera, con sus colores en hexadecimal. La negrita, la cursiva y las referencias también toman `main-color` por defecto, y `bodyText` les devuelve el color de la tinta.

### 2 · Todas las cabeceras salen de una pestaña

```js
// script.js, líneas 32–49
const FOLIO_PT = 7.5; // pt: the folio, and the book's title in capitals beside it
const TITLE_PT = 8.8; // pt: the essay's title, whose italic lowercase reads small at 7.5
const LINE = FOLIO_PT * 1.2; // pt: the folio and both titles share this line box and baseline
const PAD = { top: 1, bottom: 1.4 }; // pt: 0.4 pt more below for the descending 3, 5, 7, 9
const TAB = 7.5; // mm: the tab's width
const TAB_H = LINE + PAD.top + PAD.bottom; // pt: the tab's height, which the divider matches
const folio = ({ id, parity, pages, anchor, x = 0, y }) => ({
  kind: 'text', id, content: '{pageNumber}', parity, pages, fontFamily: 'Alegreya SC',
  fontSize: pt(FOLIO_PT), fontWeight: 700, lineHeight: LINE / FOLIO_PT, color: col('paper'),
  box: { backgroundColor: col('sepia'), borderRadius: mm(2), // a pill; the number is centred
    padding: { top: pt(PAD.top), bottom: pt(PAD.bottom) } }, // by default in both directions
  placement: { anchor, offset: { x: mm(x), y: mm(y) }, size: { width: mm(TAB) } },
});
const divider = ({ id, parity, from, edge, x }) => ({
  kind: 'rule', id, parity, pages: 'body', direction: 'vertical', thickness: pt(0.5),
  color: col('sepia'), placement: { anchor: { to: `#${from}`, edge }, offset: { x: mm(x) },
    size: { height: pt(TAB_H) } },
});
```

La respuesta corta ancla a `'page'` la pestaña de cada cabecera, así que sus desplazamientos se miden desde el corte. Desde la esquina `top-right`, una `x` positiva saca la pestaña de la página; por eso la de la impar la lleva negativa. De la pestaña cuelga el filete, y del filete, el título ([posicionamiento de elementos](/es/docs/configuration#posicionamiento-de-elementos)). Las medidas de la pestaña van en constantes porque las demás piezas se miden a partir de ellas. El filete mide `TAB_H` de alto. El folio y los dos títulos comparten una caja de línea de 9 pt, y la línea de base de un texto de diseño cae al 80 % de la altura de esa caja, así que los tres quedan sobre la misma línea de base con cualquier cuerpo.

![Página 30.](https://postext.dev/cookbook/running-heads-by-parity/es/p02.webp?v=5c2949b1)

*Página 30, par: pestaña, filete y título del libro, hacia dentro desde el margen exterior.*

![Página 31. La cabecera de la página impar corta con puntos suspensivos el título del ensayo, de 87 caracteres, antes de un filete sepia de 0,5 pt y de la pestaña del folio en el margen exterior.](https://postext.dev/cookbook/running-heads-by-parity/es/p03.webp?v=5c2949b1)

*Página 31, impar: el título del ensayo, recortado antes del filete y de la pestaña del margen exterior.*

### 3 · Márgenes en espejo y líneas enteras

```js
// script.js, líneas 53–64
const TRIM = { width: 132, height: 198 }; // mm
const TOP = 22; // mm: the top margin, which holds the heads (gotcha: header-paints-over-text)
const INNER = 15; // mm
const OUTER = 21; // mm: the outer margin, where the folio tabs hang
const LINES = 31; // whole lines of LEAD in the text block, so full pages end level
const MEASURE = TRIM.width - INNER - OUTER; // the text block's width: 96 mm
const geometry = { // left is the inner margin on a recto; mirror swaps it on the versos
  width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, // 150 dpi is for the screen
  backgroundColor: col('paper'),
  margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - (LINES * LEAD * 25.4) / 72),
    left: mm(INNER), right: mm(OUTER), mirror: true },
};
```

Con `mirror: true`, el margen `right` es el exterior en la página impar y pasa a la izquierda en la par, así que `OUTER` es siempre el del corte delantero, donde cuelgan las pestañas. El margen inferior se calcula a partir del formato, del margen superior y de `LINES`, de modo que la caja de texto tiene exactamente `LINES` líneas de `LEAD` (aquí, 31 de 14 pt), tenga el formato o el margen superior que tenga. Toda página llena acaba en la línea 31, y por eso dos páginas enfrentadas, como la [30 y la 31](https://postext.dev/cookbook/running-heads-by-parity/es/p02.webp?v=5c2949b1), terminan a la misma altura.

### 4 · La apertura muestra el título entero

```js
// script.js, líneas 100–122
const PLATE = 26; // mm: a square plate at the head of the text block
const opener = {
  enabled: true,
  minHeight: mm(58), // the text of every essay starts on the same line
  slot: { elements: [
    { kind: 'box', id: 'plate', style: { backgroundColor: col('sepia') },
      placement: { anchor: { to: 'container', edge: 'top-left' },
        size: { width: mm(PLATE), height: mm(PLATE) } } },
    { kind: 'text', id: 'numeral', content: '{attr.num}', fontFamily: 'Libre Caslon Display',
      fontSize: pt(48), lineHeight: 1, color: col('paper'),
      placement: { anchor: { to: '#plate', edge: 'align-top' }, // the plate's own box, where
        size: { width: mm(PLATE), height: mm(PLATE) } } }, // the text centres by default
    { kind: 'text', id: 'book', content: '{subtitle}', fontFamily: 'Alegreya SC',
      fontSize: pt(8.5), letterSpacing: pt(1.4), color: col('sepia'), align: 'left',
      placement: { anchor: { to: 'container', edge: 'top-left' },
        offset: { x: mm(PLATE + 4.5), y: mm(PLATE - 2.9) } } }, // its baseline on the plate's foot
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Libre Caslon Display',
      fontSize: pt(20), lineHeight: 1.12, color: col('ink'), align: 'left',
      overflow: 'wrap', // an opener shows the whole title; the running head cuts it
      placement: { anchor: { to: '#plate', edge: 'below' }, offset: { y: mm(6) },
        size: { width: mm(MEASURE) } } },
  ] },
};
```

La apertura compone el título completo con `overflow: 'wrap'`, partido en cada `\\` de la línea del título. Solo una apertura a toda la página respeta esos saltos, y de ahí el `span: 'page'` del nivel ([saltos de línea en los títulos](/es/docs/document-format#saltos-de-línea-en-los-títulos)). En la cabecera, `{chapterTitle}` da siempre el título en una sola línea, y los puntos suspensivos cortan esa línea. El número romano sale del atributo `{num="IV"}` del título, y las versalitas junto a la placa, del `subtitle` del frontmatter.

### 5 · Cada ensayo abre en página impar

```js
// script.js, líneas 140–149
  headings: { // 400, the face's only weight: the default 700 would ask for one that is missing
    fontFamily: 'Libre Caslon Display', fontWeight: 400, color: col('ink'),
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      // 'odd' puts every opener on a recto, adding a blank verso when one is needed;
      // span: 'page' makes the design an opener, where a \\ in the title breaks the line.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
        marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener },
    ],
  },
```

Cualquier objeto `headings` desactiva el salto de página del H1; por eso aquí se vuelve a declarar. Con `'odd'`, a un ensayo que termina en impar le sigue una página par en blanco. Aquí el capítulo IV acaba en [una impar](https://postext.dev/cookbook/running-heads-by-parity/es/p03.webp?v=5c2949b1), de modo que la 32 queda en blanco y el V abre en la 33. El `pages: 'body'` de las cabeceras las deja fuera de las páginas en blanco y de las aperturas (las páginas cuyo primer bloque es este título). El `pages: 'opener'` del pie imprime el folio solo en las aperturas ([elementos de texto](/es/docs/configuration#elementos-de-texto)).

### 6 · Los folios empiezan en 29

```js
// script.js, líneas 256–261
// 28 pages come before this one, so recto and verso, the mirrored margins and the odd/even
// heads follow the book page (gotcha: parity-page1-recto); the folios start at 29.
const continuation = { pageIndexOffset: 28, pageNumbering: { startAt: 29 } };
const doc = await buildWithFonts(
  () => buildDocument({ markdown, continuation }, config()), markdown);
showPages(doc, { title: t({ en: 'Running heads by parity', es: 'Cabeceras según la paridad' }) });
```

`parity` se calcula a partir del lugar que ocupa la página en el libro, y el folio no interviene. `pageIndexOffset: 28` suma 28 al índice de cada página, así que las impares y las pares, los márgenes en espejo y las cabeceras de cada lado siguen la página del libro. Después, `pageNumbering.startAt` numera estas páginas del 29 al 33. Sin ese desplazamiento, la primera página compuesta es impar, sea cual sea su folio. Con el folio 29 coincide por casualidad, pero con `startAt: 30` el título del libro caería en la 31 y el del ensayo en la 32, cada uno en el lado equivocado del pliego.

## 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/running-heads-by-parity

### script.js

```js
// ═══ Postext Cookbook · Nº 005 · Running heads by parity ═══════════════════════════════
// https://postext.dev/en/cookbook/running-heads-by-parity
// Code: MIT · Text: Montaigne, tr. Cotton (PD, Gutenberg #3600); es: new translation (MIT)
// Fonts: Baskervville, Libre Caslon Display, Alegreya SC (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// Pages 29 to 33 of a pocket Montaigne. Versos carry the book's title and rectos the essay's,
// the folios sit in tabs in the outer margin, and an opener prints only a folio at the foot.
import { buildDocument, renderPageToCanvas, clearMeasurementCache } from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'running-heads-by-parity';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: four colours with semantic ids; main-color points at the sepia
const palette = {
  ink: '#1f1b16', // text: a warm near-black, never #000
  sepia: '#8a5a2b', // the one accent: folio tabs, dividers, opener plates (5.3:1 on paper)
  muted: '#6f6558', // running heads, verse glosses, the colophon (5.2:1 on paper)
  paper: '#f8f4ec', // a warm off-white page; also the type reversed out of the sepia
};
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' } })),
  // Text-style defaults (headings, bold, italic, lists, boxes) link to 'main-color'. The
  // built-in header and footer keep #295AA3, and design slots never read the palette
  // (gotcha: palette-skips-designs), so every element colour here carries its hex.
  { id: 'main-color', name: 'sepia (defaults)', value: { hex: palette.sepia, model: 'hex' } },
];
// #endregion
const LEAD = 14; // pt: the body leading, the pitch of the baseline grid

// #region tab: the folio tab and its divider, the two pieces every head is built from
const FOLIO_PT = 7.5; // pt: the folio, and the book's title in capitals beside it
const TITLE_PT = 8.8; // pt: the essay's title, whose italic lowercase reads small at 7.5
const LINE = FOLIO_PT * 1.2; // pt: the folio and both titles share this line box and baseline
const PAD = { top: 1, bottom: 1.4 }; // pt: 0.4 pt more below for the descending 3, 5, 7, 9
const TAB = 7.5; // mm: the tab's width
const TAB_H = LINE + PAD.top + PAD.bottom; // pt: the tab's height, which the divider matches
const folio = ({ id, parity, pages, anchor, x = 0, y }) => ({
  kind: 'text', id, content: '{pageNumber}', parity, pages, fontFamily: 'Alegreya SC',
  fontSize: pt(FOLIO_PT), fontWeight: 700, lineHeight: LINE / FOLIO_PT, color: col('paper'),
  box: { backgroundColor: col('sepia'), borderRadius: mm(2), // a pill; the number is centred
    padding: { top: pt(PAD.top), bottom: pt(PAD.bottom) } }, // by default in both directions
  placement: { anchor, offset: { x: mm(x), y: mm(y) }, size: { width: mm(TAB) } },
});
const divider = ({ id, parity, from, edge, x }) => ({
  kind: 'rule', id, parity, pages: 'body', direction: 'vertical', thickness: pt(0.5),
  color: col('sepia'), placement: { anchor: { to: `#${from}`, edge }, offset: { x: mm(x) },
    size: { height: pt(TAB_H) } },
});
// #endregion

// #region page: a pocket trim; the margins mirror, the text block holds 31 whole lines
const TRIM = { width: 132, height: 198 }; // mm
const TOP = 22; // mm: the top margin, which holds the heads (gotcha: header-paints-over-text)
const INNER = 15; // mm
const OUTER = 21; // mm: the outer margin, where the folio tabs hang
const LINES = 31; // whole lines of LEAD in the text block, so full pages end level
const MEASURE = TRIM.width - INNER - OUTER; // the text block's width: 96 mm
const geometry = { // left is the inner margin on a recto; mirror swaps it on the versos
  width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, // 150 dpi is for the screen
  backgroundColor: col('paper'),
  margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - (LINES * LEAD * 25.4) / 72),
    left: mm(INNER), right: mm(OUTER), mirror: true },
};
// #endregion

// #region answer: running heads by parity: book on the verso, essay on the recto, folios outside
const GAP = 3; // mm from the tab to the divider, which lands on the text block's edge
const HEAD = 12; // mm from the top edge of the page to the top of the tab
const CAP = t({ en: 60.5, es: 66.8 }); // mm: fitted so essay IV's head ends on a word
// (gotcha: ellipsis-mid-word); a short running title (see Variations) suits any title
const page = (edge) => ({ to: 'page', edge }); // the trim box (gotcha: negative-offsets)
const header = { elements: [
  // Verso (even): tab | divider | THE BOOK'S TITLE from the frontmatter (gotcha: quote-frontmatter)
  folio({ id: 'folio-even', parity: 'even', pages: 'body', anchor: page('top-left'),
    x: OUTER - GAP - TAB, y: HEAD }),
  divider({ id: 'rule-even', parity: 'even', from: 'folio-even', edge: 'right-of', x: GAP }),
  { kind: 'text', id: 'book', content: '{title}', parity: 'even', pages: 'body',
    fontFamily: 'Alegreya SC', fontSize: pt(FOLIO_PT), lineHeight: LINE / FOLIO_PT,
    color: col('muted'), textTransform: 'uppercase', letterSpacing: pt(1.3),
    placement: { anchor: { to: '#rule-even', edge: 'right-of' }, // top: the tab's top
      offset: { x: mm(2.5), y: pt(PAD.top) } } }, // PAD.top down: on the folio's baseline
  // Recto (odd): the essay's title, cut short with an ellipsis | divider | tab.
  folio({ id: 'folio-odd', parity: 'odd', pages: 'body', anchor: page('top-right'),
    x: -(OUTER - GAP - TAB), y: HEAD }), // from the right edge, a negative x runs inwards
  divider({ id: 'rule-odd', parity: 'odd', from: 'folio-odd', edge: 'left-of', x: -GAP }),
  { kind: 'text', id: 'essay', content: '{chapterTitle}', parity: 'odd', pages: 'body',
    fontFamily: 'Baskervville', italic: true, fontSize: pt(TITLE_PT),
    lineHeight: LINE / TITLE_PT, color: col('muted'),
    overflow: 'ellipsis-end', // stated, though default (gotcha: overflow-ellipsis-default)
    placement: { anchor: { to: '#rule-odd', edge: 'left-of' },
      offset: { x: mm(-2.5), y: pt(PAD.top) }, size: { maxWidth: mm(CAP) } } },
] };
// Openers: no head, only a drop folio HEAD above the page's foot, centred under the text block.
const footer = { elements: [folio({ id: 'drop-folio', parity: 'all', pages: 'opener',
  anchor: { to: 'container', edge: 'bottom' }, y: -HEAD })] };
// #endregion

// #region opener: the essay's numeral on a sepia plate; the title below it, set in full
const PLATE = 26; // mm: a square plate at the head of the text block
const opener = {
  enabled: true,
  minHeight: mm(58), // the text of every essay starts on the same line
  slot: { elements: [
    { kind: 'box', id: 'plate', style: { backgroundColor: col('sepia') },
      placement: { anchor: { to: 'container', edge: 'top-left' },
        size: { width: mm(PLATE), height: mm(PLATE) } } },
    { kind: 'text', id: 'numeral', content: '{attr.num}', fontFamily: 'Libre Caslon Display',
      fontSize: pt(48), lineHeight: 1, color: col('paper'),
      placement: { anchor: { to: '#plate', edge: 'align-top' }, // the plate's own box, where
        size: { width: mm(PLATE), height: mm(PLATE) } } }, // the text centres by default
    { kind: 'text', id: 'book', content: '{subtitle}', fontFamily: 'Alegreya SC',
      fontSize: pt(8.5), letterSpacing: pt(1.4), color: col('sepia'), align: 'left',
      placement: { anchor: { to: 'container', edge: 'top-left' },
        offset: { x: mm(PLATE + 4.5), y: mm(PLATE - 2.9) } } }, // its baseline on the plate's foot
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Libre Caslon Display',
      fontSize: pt(20), lineHeight: 1.12, color: col('ink'), align: 'left',
      overflow: 'wrap', // an opener shows the whole title; the running head cuts it
      placement: { anchor: { to: '#plate', edge: 'below' }, offset: { y: mm(6) },
        size: { width: mm(MEASURE) } } },
  ] },
};
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-us', es: 'es' }), // hyphenation by exact code (gotcha: hyphenation-locales)
  colorPalette,
  page: geometry,
  layout: { layoutType: 'single' },
  bodyText: { // bold, italic and references default to main-color (the sepia): set to the ink
    fontFamily: 'Baskervville', fontSize: pt(10.2), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false,
    // Hyphenation, optimal breaking and widow, orphan and runt control are on by default; a band
    // of 0.7–1.65 (default 0.6–2) evens the grey from line to line. At the default runt length,
    // 20 space widths, English essay IV ends on 'minds.' alone; at 16 it ends on 'our minds.'.
    minWordSpacing: 0.7, maxWordSpacing: 1.65, runtMinCharacters: 16,
  },
  // #region levels: every essay opens on a recto; the heading draws the opener
  headings: { // 400, the face's only weight: the default 700 would ask for one that is missing
    fontFamily: 'Libre Caslon Display', fontWeight: 400, color: col('ink'),
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      // 'odd' puts every opener on a recto, adding a blank verso when one is needed;
      // span: 'page' makes the design an opener, where a \\ in the title breaks the line.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
        marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener },
    ],
  },
  // #endregion
  // Montaigne's quotations: a box with no fill keeps verse and gloss together. Its two 10.5 pt
  // margins and a two-line gloss (2 × 10.5 pt) make three whole lines, so the grid adds no space.
  calloutStyles: [{ id: 'quote', backgroundEnabled: false,
    padding: { top: pt(0), right: mm(8), bottom: pt(0), left: mm(8) },
    marginTop: pt(LEAD * 0.75), marginBottom: pt(LEAD * 0.75) }],
  paragraphStyles: [
    { id: 'verse', fontSize: pt(9.6), textAlign: 'center', firstLineIndent: pt(0) },
    { id: 'gloss', fontSize: pt(7.6), lineHeight: pt(LEAD * 0.75), color: col('muted'),
      textAlign: 'center', firstLineIndent: pt(0) },
    // Markdown has no horizontal rule (--- prints as text; gap: markdown-extras), so the rule
    // over the colophon is eight em dashes of Alegreya SC that overlap into a 0.5 pt line.
    { id: 'end', fontFamily: 'Alegreya SC', fontSize: pt(8), color: col('sepia'),
      textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
    // No italics in the colophon: a style's italic runs take bodyText.italicColor, the ink,
    // and would print darker than the muted words around them (gotcha: style-italic-colour).
    { id: 'colophon', fontSize: pt(7), lineHeight: pt(9.5), color: col('muted'),
      textAlign: 'left', firstLineIndent: pt(0) },
  ],
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Ensayos de Montaigne"
subtitle: "Libro primero"
author: "Michel de Montaigne"
---

# Cómo el alma descarga sus pasiones \\ sobre objetos falsos \\ cuando le faltan los verdaderos {num="IV"}

Un gentilhombre de mi tierra, muy aquejado de gota, a quien los médicos apremiaban para que dejara del todo las carnes saladas, solía responder con gracia que, en lo más recio del mal, necesitaba tener con quién tomarla, y que, maldiciendo ya la morcilla, ya la lengua de buey y el jamón, sentía con ello algún alivio. Y hablando en serio: así como al brazo alzado para golpear le duele que el golpe no encuentre nada y se vaya al aire, así la vista, para que el paisaje resulte grato, no ha de perderse en el vacío, sino tener un término que la sostenga a razonable distancia.

:::callout{type="quote"}
:::paragraphs{style="verse"}
*Ventus ut amittit vires, nisi robore densae*

*Occurrant sylvae, spatio diffusus inani.*
:::

:::paragraphs{style="gloss"}
Como el viento, disperso en el espacio vacío, pierde su fuerza si no le salen al paso los troncos de un bosque espeso. — Lucano, III, 362
:::
:::

Parece que el alma, sacudida y conmovida, se pierde en sí misma si no se le da asidero, y hay que darle siempre un objeto en que apoyarse y obrar. Plutarco dice, a propósito de quienes se encariñan con monas y perrillos, que la parte amorosa que hay en nosotros, a falta de un objeto legítimo, antes que quedarse ociosa se forja uno falso y frívolo. Y vemos que el alma, en sus pasiones, prefiere engañarse a sí misma, forjándose un objeto falso y fantástico contra su propia creencia, antes que no obrar contra nada. Así arrastra la rabia a las bestias a atacar la piedra o el hierro que las ha herido, y a vengarse a dentelladas en sí mismas del mal que sienten:

:::callout{type="quote"}
:::paragraphs{style="verse"}
*Pannonis haud aliter, post ictum saevior ursa,*

*Cui jaculum parva Lybis amentavit habena,*

*Se rotat in vulnus, telumque irata receptum*

*Impetit, et secum fugientem circuit hastam.*
:::

:::paragraphs{style="gloss"}
Así la osa de Panonia, herida por el dardo libio, se revuelve contra la lanza y gira tras el asta que huye. — Lucano, VI, 220
:::
:::

¿Qué causas no inventamos para las desgracias que nos suceden? ¿Con qué no la tomamos, con razón o sin ella, por tener contra qué batirnos? No son esas trenzas rubias que desgarras, ni la blancura de ese pecho que, despechada, golpeas con tanta crueldad, las que mataron de un desdichado balazo a ese hermano tan querido: tómala con otra cosa. Tito Livio dice del ejército romano en España que, tras la pérdida de los dos hermanos, sus grandes capitanes, *flere omnes repente, et offensare capita*, «todos a una lloraron y se golpearon la cabeza».

Es costumbre común. Y tuvo gracia el filósofo Bión cuando dijo de aquel rey que de duelo se arrancaba el pelo: «¿Pensará este que la calvicie alivia la pena?». ¿Quién no ha visto a un jugador mascar y tragarse los naipes, o atiborrarse de dados, por tener en qué vengarse de la pérdida de su dinero? Jerjes azotó el mar y escribió un cartel de desafío al monte Atos; Ciro entretuvo a todo un ejército varios días en vengarse del río Gindes por el miedo que había pasado al cruzarlo; y Calígula arruinó una casa bellísima por el placer que su madre había tenido en ella.

Decía la gente en mi juventud que un rey de nuestros vecinos, habiendo recibido de Dios un bastonazo, juró vengarse y ordenó que durante diez años nadie le rezara ni hablara de él, ni, hasta donde alcanzaba su autoridad, creyera en él. Con ello se quería pintar no tanto la necedad como la vanagloria natural de la nación de la que se contaba. Son vicios que van siempre juntos, pero tales acciones tienen, a decir verdad, algo más de soberbia que de estupidez. Augusto César, zarandeado en el mar por la tempestad, se puso a desafiar al dios Neptuno y, en la pompa de los juegos circenses, hizo quitar su imagen del lugar que ocupaba entre los demás dioses, para vengarse de él. En lo cual es aún menos excusable que los anteriores, y menos de lo que lo fue después, cuando, habiendo perdido una batalla bajo Quintilio Varo en Germania, iba de cólera y desesperación dándose con la cabeza contra la pared y gritando: «¡Varo, devuélveme mis soldados!». Porque estos sobrepasan toda locura, pues a ella se une la impiedad, ya que la toman con Dios mismo, o con la fortuna, como si esta tuviera oídos al alcance de nuestra artillería; como los tracios, que, cuando truena o relampaguea, se ponen a disparar contra el cielo con una venganza de titanes, para meter a Dios en razón a flechazos. Ahora bien, como dice aquel antiguo poeta en Plutarco:

:::callout{type="quote"}
:::paragraphs{style="verse"}
*Point ne se faut couroucer aux affaires,*

*Il ne leur chault de toutes nos choleres.*
:::

:::paragraphs{style="gloss"}
No hay que enojarse con las cosas del mundo, que a ellas nada les importan todas nuestras cóleras. — Plutarco
:::
:::

Pero nunca diremos bastantes injurias contra el desarreglo de nuestro espíritu.

# Si el jefe de una plaza sitiada \\ debe salir a parlamentar {num="V"}

Quinto Marcio, legado de los romanos en la guerra contra Perseo, rey de Macedonia, queriendo ganar el tiempo que aún le faltaba para poner a punto su ejército, sembró propuestas de acuerdo con las que el rey, adormecido, concedió una tregua de algunos días, dando así a su enemigo ocasión y tiempo para armarse, de donde le vino al rey su ruina final. Sin embargo, los ancianos del Senado, que guardaban memoria de las costumbres de sus padres, condenaron aquella práctica como contraria a su antiguo estilo, que era, decían, combatir con valor y no con astucia, ni con sorpresas y encuentros nocturnos, ni con fugas fingidas y acometidas inesperadas, y no emprender una guerra sino después de haberla declarado, y a menudo tras haber fijado la hora y el lugar de la batalla.

:::paragraphs{style="end"}
————————
:::

:::paragraphs{style="colophon"}
Libro primero, capítulos IV y V, de los ensayos de Montaigne, en traducción nueva del texto francés de 1595 (Project Gutenberg, libro electrónico n.º 48529). Compuesto en Baskervville, Libre Caslon Display y Alegreya SC (SIL Open Font License).
:::
`; // content.<lang>.md, inlined by the Cookbook

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the design uses, loaded before the first build (gotcha: fonts-first).
const FONTS = { // text, display and label faces (Libre Caslon Display has no italic)
  Baskervville: ['400', '400i'], 'Libre Caslon Display': ['400'], 'Alegreya SC': ['400', '700'],
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
// #region build: pages 29 to 33 of the book
// 28 pages come before this one, so recto and verso, the mirrored margins and the odd/even
// heads follow the book page (gotcha: parity-page1-recto); the folios start at 29.
const continuation = { pageIndexOffset: 28, pageNumbering: { startAt: 29 } };
const doc = await buildWithFonts(
  () => buildDocument({ markdown, continuation }, config()), markdown);
showPages(doc, { title: t({ en: 'Running heads by parity', es: 'Cabeceras según la paridad' }) });
// #endregion

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

## Variantes

### Pon un titulillo corto a los títulos largos

Escribe el titulillo en la línea del título, `{num="IV" short="Los objetos falsos"}`, e imprime el atributo en lugar del título entero; un capítulo sin `short` no imprime nada ahí.

```diff
-  { kind: 'text', id: 'essay', content: '{chapterTitle}', parity: 'odd', pages: 'body',
+  { kind: 'text', id: 'essay', content: '{attr.short}', parity: 'odd', pages: 'body',
```

### Deja que los ensayos abran en cualquier página

Con `'any'`, cada ensayo empieza en la página siguiente, par o impar, y nunca se añade una página en blanco. La placa cuelga de la esquina superior izquierda de la caja de texto y el folio del pie va centrado bajo ella, así que los dos quedan igual en par que en impar.

```diff
-      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
+      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
```

### Pinta de sepia la página en blanco

Un elemento `box` con `pages: 'blank'` solo se imprime en las páginas que añade el salto a impar: en esta edición llena de sepia la página 32, y la edición inglesa, que no tiene página en blanco, no cambia.

```diff
 const footer = { elements: [folio({ id: 'drop-folio', parity: 'all', pages: 'opener',
-  anchor: { to: 'container', edge: 'bottom' }, y: -HEAD })] };
+  anchor: { to: 'container', edge: 'bottom' }, y: -HEAD }),
+  { kind: 'box', id: 'blank-leaf', pages: 'blank', style: { backgroundColor: col('sepia') },
+    placement: { anchor: page('top-left'),
+      size: { width: mm(TRIM.width), height: mm(TRIM.height) } } }] };
```

## Errores frecuentes

- **Los puntos suspensivos cortan a mitad de palabra y conservan el espacio anterior.** En postext 1.4.1, overflow: 'ellipsis-end' corta tras el último carácter que cabe, no en un límite de palabra, y conserva el espacio si cae justo detrás de uno, así que una cabecera puede acabar en «fals…» o «false …». Ajusta maxWidth al final de una palabra si conoces el título, o da a los títulos largos un título corto de cabecera en un atributo.
- **El desbordamiento del texto de diseño es 'ellipsis-end' por defecto.** Un elemento de texto de diseño que no cabe en su ancho termina en puntos suspensivos por defecto. Pon overflow: 'wrap' en los títulos que deban pasar a más líneas.
- **Los desplazamientos negativos relativos al contenedor no pintan nada.** El texto de diseño de ancho automático se limita a su contenedor, así que un desplazamiento negativo respecto al contenedor lo saca fuera y no se pinta nada. Ancla esos elementos a la página o a la sangre con desplazamientos explícitos en mm, o dales un ancho fijo.
- **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.
- **Los elementos de cabecera y pie se pintan encima del texto.** Los elementos de cabecera y pie se pintan sobre la página y el área de texto no les deja sitio. Mantenlos dentro de los márgenes, que son los que les reservan el espacio.
- **La página 1 es impar: planifica con números físicos.** La página 1 queda a la derecha y la 2 es la primera página par, así que planifica los pliegos con números de página físicos: una apertura en página par queda frente a la impar que la sigue.
- **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.
- **Entrecomilla cada valor del frontmatter.** YAML lee title: 1984 como un número y una fecha como un objeto Date, y los valores que no son cadenas se imprimen vacíos en los marcadores y dejan el PDF sin título. Entrecomilla cada valor: title: "1984".
- **Carga todas las fuentes antes de componer.** La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde.
- **Un estilo de párrafo no tiene color de cursiva.** En postext 1.4.1 un estilo de párrafo fija color y boldColor, pero no italicColor: sus cursivas toman bodyText.italicColor. Un estilo atenuado (la letra pequeña, la línea «Fuente:» de una tabla) imprime sus títulos en cursiva más oscuros que el texto que los rodea. Deja esos estilos en el color del texto o evita en ellos las cursivas.
- **Solo 8 idiomas tienen separación silábica, con el código exacto.** La separación silábica existe para en-us, es, fr, de, it, pt, ca y nl, con el código exacto: 'es-ES' o cualquier otro idioma pasa sin aviso al inglés americano.
- **Aviso de maquetación: Marcador desconocido** (`headerFooterUnknownPlaceholder`). Un texto de diseño usa un marcador {nombre} que no existe, así que se imprime tal cual. Solución: Usa un marcador admitido ({pageNumber}, {chapterTitle}, {title}, {attr.x}…), o escribe {{ y }} para llaves literales. ([Documentación](https://postext.dev/es/docs/configuration.md#elementos-de-texto))
- **Aviso de maquetación: Falta el metadato del marcador** (`headerFooterMetadataMissing`). Un marcador como {author} o {title} no tiene valor en los metadatos del documento, así que se imprime vacío. Solución: Añade el valor al frontmatter, entre comillas, o quita el marcador. ([Documentación](https://postext.dev/es/docs/configuration.md#elementos-de-texto))

- `CAP` está ajustado al título del ensayo IV en cada edición, el único que muestra aquí una cabecera. La del ensayo V quedaría cortada a mitad de palabra («Si el jefe de una plaza sitiada debe salir a parlam…»), así que un libro de títulos largos necesita el titulillo corto de las variantes.
- En la edición inglesa, subir `minWordSpacing` de 0,7 a 0,75 deja la primera línea del ensayo V («Quintus Marcius, the Roman legate…») sin ningún corte dentro de la horquilla, y sus espacios entre palabras se abren hasta 2,4 veces su ancho normal. En la española no pasa. Después de cualquier cambio, revisa las líneas más abiertas que lista la captura.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texto: Ensayos, libro I, capítulos IV y V, en la traducción inglesa de Charles Cotton editada por William Carew Hazlitt (1877): Michel de Montaigne; Charles Cotton; William Carew Hazlitt ([fuente](https://www.gutenberg.org/ebooks/3600)), dominio público
- Texto: Essais, livre I, chapitres IV et V: el texto francés de 1595 en la edición del general Michaud (Firmin-Didot, 1907): Michel de Montaigne ([fuente](https://www.gutenberg.org/ebooks/48529)), dominio público
- Texto: Traducción al español, con sus glosas de los versos: Postext Cookbook, original
- Tipografías: Baskervville (OFL-1.1), Libre Caslon Display (OFL-1.1), Alegreya SC (OFL-1.1)
- Código: MIT · Contenido de ejemplo: MIT

## Relacionadas

- [N.º 047 · Guía de paseos con pestañas de índice](https://postext.dev/es/cookbook/walking-guide-thumb-tabs.md): Guía de bolsillo con un estilo de título por paseo, cuya paleta cambia la pestaña del corte, las paradas, el tono del recuadro y la página en blanco anterior. · Nivel 3 (Avanzado) · Manuales, guías y obras de consulta
- [N.º 007 · Un libro hecho de capítulos sueltos](https://postext.dev/es/cookbook/book-from-chapters.md): buildBundle compone cinco archivos Markdown como un solo libro: cada capítulo abre en página impar y páginas, capítulos y figuras se numeran de corrido. · Nivel 3 (Avanzado) · Manuales, guías y obras de consulta
- [N.º 003 · Apertura de capítulo sobre banda a sangre](https://postext.dev/es/cookbook/chapter-opener-bleed-band.md): Apertura advancedDesign del título de nivel 1: banda a sangre con el número de capítulo sobre su filete, y antetítulo y entradilla tomados de sus atributos. · Nivel 3 (Avanzado) · Libros de texto
