# Diccionario con índice de pestañas

> Un bucle crea un estilo de título por letra, y la cabecera de cada uno pone la pestaña azul de su letra un paso más abajo en el corte, sobre un índice pálido.

- Versión HTML: https://postext.dev/es/cookbook/dictionary-thumb-index
- Receta N.º 046 · Cabeceras y folios · Nivel 3 (Avanzado) · Salidas: Canvas
- Géneros: Manuales, guías y obras de consulta
- Requiere postext ≥ 1.4.1 · probada con 1.4.1 el 2026-09-26
- Páginas: [1](https://postext.dev/cookbook/dictionary-thumb-index/es/p01.webp?v=139728f8), [2](https://postext.dev/cookbook/dictionary-thumb-index/es/p02.webp?v=139728f8), [3](https://postext.dev/cookbook/dictionary-thumb-index/es/p03.webp?v=139728f8), [4](https://postext.dev/cookbook/dictionary-thumb-index/es/p04.webp?v=139728f8)
- Última actualización: 2026-09-26
- Otros idiomas: [en](https://postext.dev/en/cookbook/dictionary-thumb-index.md)

## Lo que vas a componer

Cuatro páginas de una nueva edición del *Sailor’s Word-Book* del almirante Smyth (1867), de la A a la C, en una página de 150 × 200 mm. Las entradas van en dos columnas justificadas de Alegreya de 8 pt, separadas por un filete, con el lema en negrita azul marino y sangría francesa de 1 em. Cada letra se abre dentro de su columna con una inicial azul de cuatro líneas sobre un filete color latón, y las entradas siguen sin salto de página. En el corte delantero, cada página imprime el índice entero, 22 pestañas en un tono pálido. La de la letra en curso es azul marino, 3,2 mm más ancha que las demás, y baja un paso con cada letra. La edición española conserva los lemas ingleses de Smyth y traduce las entradas.

**Esta receta responde a:**

- ¿Cómo hago que la pestaña de un diccionario baje por el corte delantero de una letra a la siguiente?
- ¿Cómo compongo una bibliografía o un glosario (sangría francesa, cuerpo menor)?
- ¿Cómo doy color a los términos clave (en negrita o cursiva) en el texto o dentro de los recuadros?
- ¿Cómo pongo cabeceras: el título del libro en la página izquierda, el del capítulo en la derecha 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?

## La respuesta corta

```js
// script.js, líneas 36–66
// 22 tabs share the 44 lines of the text block, two lines each; letters with few words
// share a tab, as in most thumb-indexed dictionaries.
const TABS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P',
  'QR', 'S', 'T', 'UV', 'W', 'XYZ'];
const STEP = 2 * LEAD * PT; // mm: 7.34
const EDGE = { odd: 'top-right', even: 'top-left' }; // the fore-edge: right on a recto
// A cell of the index, `width` mm inside the trim and 3 mm past it, so that only its
// inner corners show their rounding; its label is centred on the part inside the trim.
const cell = (id, label, i, parity, width, fill, ink, size) => {
  const y = mm(TOP + i * STEP + 0.3); // a 0.6 mm gap between neighbours
  const at = (x, w) => ({ anchor: { to: 'page', edge: EDGE[parity] }, offset: { x: mm(x), y },
    size: { width: mm(w), height: mm(STEP - 0.6) } });
  return [
    { kind: 'box', id, parity, style: { backgroundColor: col(fill), borderRadius: mm(1.2) },
      placement: at(parity === 'odd' ? 3 : -3, width + 3) },
    { kind: 'text', id: `${id}-label`, parity, content: label, fontFamily: LABEL, fontWeight: 700,
      fontSize: pt(size), lineHeight: 1, color: col(ink), align: 'center',
      verticalAlign: 'middle', placement: at(0, width) },
  ];
};
const sides = (make) => ['odd', 'even'].flatMap(make);
// Every page prints the whole index, pale; the section's own letter stands out of it.
const ladder = sides((p) => TABS.flatMap((label, i) =>
  cell(`index-${i}-${p}`, label, i, p, 5.8, 'tint', 'slate', 7.5)));
const letterStyles = () => TABS.flatMap((label, i) => [...label].map((letter) => ({
  id: letter,
  // '# B {style="B"}' opens the section of B: its pages take this header, and a page
  // where A ends and B begins takes B's (gotcha: section-last-wins).
  header: { elements: [...runningHeads, ...ladder,
    ...sides((p) => cell(`tab-${p}`, label, i, p, 9, 'navy', 'paper', 8.5))] },
})));
```

## Ingredientes

**Enseña**

- [Bandas a sangre y pestañas](https://postext.dev/es/docs/configuration.md#posicionamiento-de-elementos): Campos de color anclados a la página o a la sangre, de borde a borde, incluidas pestañas de índice en el corte delantero que bajan con cada capítulo.
- [Cabeceras por sección](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado): Un estilo de título lleva su propia cabecera y su propio pie, así que los preliminares, las secciones o las letras de un diccionario tienen los suyos.
- [Negrita, cursiva y sus colores](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo): Los fragmentos en negrita y cursiva y el color con que se imprimen; por defecto toman el azul de acento, así que en un libro a una tinta hay que devolverlos al color del texto.

**También usa**

- [Estilos de título](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Anclaje de elementos de diseño](https://postext.dev/es/docs/configuration.md#posicionamiento-de-elementos)
- [Imágenes en los diseños de página](https://postext.dev/es/docs/configuration.md#elementos-de-imagen)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)
- [Bibliografías y glosarios](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)
- [Cabeceras según el tipo de página](https://postext.dev/es/docs/configuration.md#elementos-de-texto)
- [Cabeceras y folios](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Filete de columna](https://postext.dev/es/docs/configuration.md#filete-de-columna)
- [Equilibrado de columnas](https://postext.dev/es/docs/configuration.md#equilibrado-de-columnas)
- [Viudas, huérfanas y líneas cortas](https://postext.dev/es/docs/configuration.md#huérfanas-viudas-runts-y-reglas-de-cohesión)
- [Separación silábica e idioma del documento](https://postext.dev/es/docs/justification.md#idiomas-soportados)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration.md#paleta-de-colores)
- [Color del papel](https://postext.dev/es/docs/configuration.md#página)
- [Márgenes simétricos](https://postext.dev/es/docs/configuration.md#márgenes-simétricos-espejo)
- [Figuras y tablas como recursos](https://postext.dev/es/docs/document-format.md#recursos)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo), [`colorPalette`](https://postext.dev/es/docs/configuration.md#paleta-de-colores), [`footer`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`header`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`headingStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado), [`headings`](https://postext.dev/es/docs/configuration.md#encabezados), [`layout`](https://postext.dev/es/docs/configuration.md#disposición), [`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), [`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**

- Alegreya (OFL-1.1), Alegreya SC (OFL-1.1), Alegreya Sans SC (OFL-1.1)

## Elaboración

### 1 · Un estilo de título por letra, cada uno con su pestaña

El código está en [la respuesta corta](#la-respuesta-corta), más arriba. `TABS` enumera 22 pestañas; las letras con pocas voces comparten una (QR, UV, XYZ). Cada pestaña mide dos líneas de texto (7,34 mm), así que entre todas cubren las 44 líneas de la caja. El bucle da a cada letra un estilo de título cuyo `header` suma a las cabeceras y al índice pálido la pestaña azul de esa letra, un paso más abajo que la anterior ([estilos de encabezado](/es/docs/configuration#estilos-de-encabezado)). Cada celda se escribe dos veces, una por `parity`, y se ancla al borde exterior de la página, no al contenedor propio de la cabecera. Ese contenedor es el margen de cabeza, del ancho de la caja y desde el borde superior de la página hasta el texto, y las pestañas tienen que llegar al corte delantero y bajar por las 44 líneas. Una página toma la cabecera de la última sección que empieza en ella, así que la [página 2](https://postext.dev/cookbook/dictionary-thumb-index/es/p02.webp?v=139728f8) empieza en la A y lleva la pestaña de la B.

### 2 · Las cabeceras, fuera de la primera página

```js
// script.js, líneas 70–86
const HEAD = 11; // mm from the trim's top to the heads' top
const head = (id, content, parity, edge, x, extra) => ({ kind: 'text', id, content, parity,
  pages: 'body', fontFamily: LABEL, fontSize: pt(8), fontWeight: 500, letterSpacing: pt(1),
  color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD) } }, ...extra });
const folio = { fontWeight: 700, color: col('ink'), letterSpacing: pt(0) };
const runningHeads = [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-title', '{title}', 'even', 'top-left', OUTER + 8),
  head('recto-letter', t({ en: 'Letter {chapterTitle}', es: 'Letra {chapterTitle}' }), 'odd',
    'top-right', -(OUTER + 8)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
];
// The first page drops its folio to the foot, under the text block.
const footer = { elements: [head('drop-folio', '{pageNumber}', 'all', 'top', 0, {
  ...folio, pages: 'opener',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(6) } } })] };
```

Las cabeceras son elementos de texto anclados a la página y filtrados por `parity` y por `pages: 'body'` ([elementos de texto](/es/docs/configuration#elementos-de-texto)). El título de la página 1 abarca las dos columnas, y eso la convierte en página de apertura. Las cabeceras no salen en ella, y su folio lo pone el pie, un elemento con `pages: 'opener'` que se imprime 6 mm por debajo de la caja. En las impares, `{chapterTitle}` imprime la letra del último título de nivel 1 que hay en la página o antes de ella. Postext no tiene marcador para el primer lema de una página ni para el último, así que la cabecera impar dice *Letra C* donde un diccionario pondría las palabras guía.

### 3 · Color para los lemas y nada más

```js
// script.js, líneas 90–101
// Bold and italic default to the engine's blue: the body sets them to ink, and only the
// entry style prints its bold, the headwords, in navy.
const BODY = 8; // pt: a reference size, about 50 characters to the 60 mm column
const bodyText = { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: mm(0),
  minWordSpacing: 0.7, maxWordSpacing: 1.7,
  maxRuntTracking: 0 }; // gotcha: runt-tracking-unpainted
const paragraphStyles = [
  { id: 'entry', hangingIndent: em(1), boldColor: col('navy') }, // :::paragraphs{style="entry"}
  { id: 'colophon', fontSize: pt(7), color: col('muted'), textAlign: 'center',
    marginTop: pt(LEAD) },
];
```

Cada entrada es un párrafo de un bloque `:::paragraphs{style="entry"}`, con sangría francesa de 1 em ([estilos de párrafo](/es/docs/configuration#estilos-de-párrafo)). El `boldColor` del cuerpo deja el **Nota.** de la página 1 en el color del texto, y el del estilo de entrada pone los lemas en azul marino. Un estilo de párrafo no tiene `italicColor`, así que las cursivas de las entradas conservan el color del cuerpo.

### 4 · Una inicial de cuatro líneas dentro de la columna

```js
// script.js, líneas 105–118
// Its cap line meets the first body line's and it stands on the fourth baseline. Cap
// heights measured on the glyph H: 0.652 em in Alegreya SC 900, 0.646 em in Alegreya.
const DROP = 4; // body lines the letter spans
const LETTER = ((DROP - 1) * LEAD + 0.646 * BODY) / 0.652; // pt: 55.8
// A design line sets its baseline 0.8 of its height below its top; a body line does too.
const letterHead = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'letter', content: '{titleText}', fontFamily: DISPLAY, fontWeight: 900,
    fontSize: pt(LETTER), lineHeight: 1, color: col('navy'), align: 'left',
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { y: pt((DROP - 0.2) * LEAD - 0.8 * LETTER) } } },
  { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.6), color: col('brass'),
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { y: pt((DROP + 0.55) * LEAD) }, size: { width: 'fill' } } },
] } };
```

`LETTER`, 55,8 pt, es el cuerpo con el que una mayúscula de Alegreya SC 900 (0,652 em de alto) se apoya en la cuarta línea de base y llega a la altura de las mayúsculas de la primera línea de texto. La letra y el filete color latón acaban dentro de la quinta línea, y el bloque del título baja hasta la siguiente línea de la rejilla base, así que cada inicial ocupa cinco líneas y las entradas de debajo siguen en la rejilla. En la [página 3](https://postext.dev/cookbook/dictionary-thumb-index/es/p03.webp?v=139728f8), la C que encabeza la segunda columna mide lo mismo que las cuatro primeras líneas de la primera. El nivel 1 declara `breakBefore: { enabled: false }`, y los estilos de las letras lo heredan. En 1.4.1 cualquier objeto `headings` ya quita el salto de página del nivel 1; con el valor escrito, las letras seguirán en la columna aunque una versión posterior recupere ese salto.

### 5 · El título a lo ancho de las dos columnas

```js
// script.js, líneas 122–148
const centred = (id, content, family, size, lineHeight, y, width, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), lineHeight, color: col('navy'), align: 'center',
  overflow: 'wrap', ...extra, placement: { anchor: { to: 'container', edge: 'top' },
    offset: { y: mm(y) }, size: { width: width ? mm(width) : 'fill' } } });
const rule = (id, y, thickness) => ({ kind: 'rule', id, direction: 'horizontal',
  thickness: pt(thickness), color: col('navy'), placement: { anchor: { to: 'container',
    edge: 'top-left' }, offset: { y: mm(y) }, size: { width: 'fill' } } });
const titleBand = { enabled: true, slot: { elements: [
  // The column rule starts at the top of the text block, under the band too: a field of
  // paper down to the thin rule covers it, so it hangs from the double rule.
  { kind: 'box', id: 'field', style: { backgroundColor: col('paper') }, placement: {
    anchor: { to: 'container', edge: 'top-left' }, size: { width: 'fill', height: mm(45) } } },
  { kind: 'image', id: 'anchor', resourceId: 'anchor', placement: {
    anchor: { to: 'container', edge: 'top' }, size: { width: 'auto', height: mm(18) } } },
  centred('name', '{titleText}', DISPLAY, 22, 1.1, 20, 0, { fontWeight: 900 }),
  centred('subtitle', '{attr.subtitle}', TEXT, 9, 1.25, 29.5, 92, { italic: true,
    color: col('ink') }),
  centred('byline', '{attr.byline}', LABEL, 7, 1.2, 38.8, 0, { fontWeight: 500,
    letterSpacing: pt(1.2), color: col('muted') }),
  rule('thick', 43.6, 1.2),
  rule('thin', 45, 0.4),
] } };
// '# The Sailor’s Word-Book {style="title" subtitle="…" byline="…"}' opens page 1. The band's
// {titleText} joins the hidden heading's wrapped lines with a space, and a page-span heading
// wraps at the column width: at the default H1 size the band printed 'Word- Book'. At body
// size the hidden title fits one line of the 60 mm column.
const titleStyle = { id: 'title', span: 'page', advancedDesign: titleBand, fontSize: pt(BODY) };
```

El estilo del título abarca la página con una banda que reúne el ancla, el nombre, un subtítulo y una firma tomados de atributos del título, y un filete doble ([span y diseño avanzado](/es/docs/configuration#span-y-diseño-avanzado)). El filete fino acaba 45,1 mm por debajo del borde superior de la caja, así que la banda se ajusta a 13 líneas de la rejilla, 47,7 mm. Bajo un título a todo el ancho, el filete de columna empieza igualmente en el borde superior de la caja y cruzaría el título. Un rectángulo del color del papel lo tapa hasta el filete fino, y el filete de columna solo asoma por debajo. `{titleText}` une con un espacio las líneas del título oculto, y 1.4.1 corta un título a todo el ancho a la medida de una columna. En inglés, con el cuerpo del H1 por defecto, la banda imprimía *Word- Book*; por eso el estilo del título da al título oculto el cuerpo del texto, con el que el nombre cabe en una línea de la columna de 60 mm.

## 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/dictionary-thumb-index

### script.js

```js
// ═══ Postext Cookbook · Nº 046 · Dictionary with a moving thumb index ═══════════════
// https://postext.dev/en/cookbook/dictionary-thumb-index
// Code: MIT · Text: W. H. Smyth, 1867 (PD); Spanish translation CC BY 4.0 · Pictures: code
// Fonts: Alegreya, Alegreya SC, Alegreya Sans SC (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'dictionary-thumb-index';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: ink and navy on a warm paper, brass for the rules and the rope
const palette = {
  ink: '#1b1f24', // text
  navy: '#1f3a5f', // headwords, letters and tabs
  brass: '#a4834f', // hairlines and the rope of the vignette
  rule: '#cfccc3', // the column rule
  tint: '#e4e9f0', // the pale cells of the index
  slate: '#56657b', // their letters
  muted: '#62666b', // running heads
  paper: '#fbfaf6', // the page, and the letter reversed out of each tab
};
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' } }));
// #endregion
const TEXT = 'Alegreya', DISPLAY = 'Alegreya SC', LABEL = 'Alegreya Sans SC';
const PT = 25.4 / 72; // mm in a point
const LEAD = 10.4; // pt: the body's leading, the grid every column is set on
const LINES = 44; // lines in a full column
const TOP = 18, INNER = 12, OUTER = 14; // mm; the foot margin makes the block whole lines
const BOTTOM = 200 - TOP - LINES * LEAD * PT;

// #region answer: one heading style per letter, each with its tab one step down the fore-edge
// 22 tabs share the 44 lines of the text block, two lines each; letters with few words
// share a tab, as in most thumb-indexed dictionaries.
const TABS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P',
  'QR', 'S', 'T', 'UV', 'W', 'XYZ'];
const STEP = 2 * LEAD * PT; // mm: 7.34
const EDGE = { odd: 'top-right', even: 'top-left' }; // the fore-edge: right on a recto
// A cell of the index, `width` mm inside the trim and 3 mm past it, so that only its
// inner corners show their rounding; its label is centred on the part inside the trim.
const cell = (id, label, i, parity, width, fill, ink, size) => {
  const y = mm(TOP + i * STEP + 0.3); // a 0.6 mm gap between neighbours
  const at = (x, w) => ({ anchor: { to: 'page', edge: EDGE[parity] }, offset: { x: mm(x), y },
    size: { width: mm(w), height: mm(STEP - 0.6) } });
  return [
    { kind: 'box', id, parity, style: { backgroundColor: col(fill), borderRadius: mm(1.2) },
      placement: at(parity === 'odd' ? 3 : -3, width + 3) },
    { kind: 'text', id: `${id}-label`, parity, content: label, fontFamily: LABEL, fontWeight: 700,
      fontSize: pt(size), lineHeight: 1, color: col(ink), align: 'center',
      verticalAlign: 'middle', placement: at(0, width) },
  ];
};
const sides = (make) => ['odd', 'even'].flatMap(make);
// Every page prints the whole index, pale; the section's own letter stands out of it.
const ladder = sides((p) => TABS.flatMap((label, i) =>
  cell(`index-${i}-${p}`, label, i, p, 5.8, 'tint', 'slate', 7.5)));
const letterStyles = () => TABS.flatMap((label, i) => [...label].map((letter) => ({
  id: letter,
  // '# B {style="B"}' opens the section of B: its pages take this header, and a page
  // where A ends and B begins takes B's (gotcha: section-last-wins).
  header: { elements: [...runningHeads, ...ladder,
    ...sides((p) => cell(`tab-${p}`, label, i, p, 9, 'navy', 'paper', 8.5))] },
})));
// #endregion

// #region running-heads: the book on the verso, the letter on the recto, folios outside
const HEAD = 11; // mm from the trim's top to the heads' top
const head = (id, content, parity, edge, x, extra) => ({ kind: 'text', id, content, parity,
  pages: 'body', fontFamily: LABEL, fontSize: pt(8), fontWeight: 500, letterSpacing: pt(1),
  color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD) } }, ...extra });
const folio = { fontWeight: 700, color: col('ink'), letterSpacing: pt(0) };
const runningHeads = [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-title', '{title}', 'even', 'top-left', OUTER + 8),
  head('recto-letter', t({ en: 'Letter {chapterTitle}', es: 'Letra {chapterTitle}' }), 'odd',
    'top-right', -(OUTER + 8)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
];
// The first page drops its folio to the foot, under the text block.
const footer = { elements: [head('drop-folio', '{pageNumber}', 'all', 'top', 0, {
  ...folio, pages: 'opener',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(6) } } })] };
// #endregion

// #region entries: one paragraph per entry, the headword bold in navy, the turnovers hanging
// Bold and italic default to the engine's blue: the body sets them to ink, and only the
// entry style prints its bold, the headwords, in navy.
const BODY = 8; // pt: a reference size, about 50 characters to the 60 mm column
const bodyText = { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: mm(0),
  minWordSpacing: 0.7, maxWordSpacing: 1.7,
  maxRuntTracking: 0 }; // gotcha: runt-tracking-unpainted
const paragraphStyles = [
  { id: 'entry', hangingIndent: em(1), boldColor: col('navy') }, // :::paragraphs{style="entry"}
  { id: 'colophon', fontSize: pt(7), color: col('muted'), textAlign: 'center',
    marginTop: pt(LEAD) },
];
// #endregion

// #region letter-heads: a four-line initial over a brass hairline, in the column, running on
// Its cap line meets the first body line's and it stands on the fourth baseline. Cap
// heights measured on the glyph H: 0.652 em in Alegreya SC 900, 0.646 em in Alegreya.
const DROP = 4; // body lines the letter spans
const LETTER = ((DROP - 1) * LEAD + 0.646 * BODY) / 0.652; // pt: 55.8
// A design line sets its baseline 0.8 of its height below its top; a body line does too.
const letterHead = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'letter', content: '{titleText}', fontFamily: DISPLAY, fontWeight: 900,
    fontSize: pt(LETTER), lineHeight: 1, color: col('navy'), align: 'left',
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { y: pt((DROP - 0.2) * LEAD - 0.8 * LETTER) } } },
  { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.6), color: col('brass'),
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { y: pt((DROP + 0.55) * LEAD) }, size: { width: 'fill' } } },
] } };
// #endregion

// #region title: the book's name across both columns, under a fouled anchor
const centred = (id, content, family, size, lineHeight, y, width, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), lineHeight, color: col('navy'), align: 'center',
  overflow: 'wrap', ...extra, placement: { anchor: { to: 'container', edge: 'top' },
    offset: { y: mm(y) }, size: { width: width ? mm(width) : 'fill' } } });
const rule = (id, y, thickness) => ({ kind: 'rule', id, direction: 'horizontal',
  thickness: pt(thickness), color: col('navy'), placement: { anchor: { to: 'container',
    edge: 'top-left' }, offset: { y: mm(y) }, size: { width: 'fill' } } });
const titleBand = { enabled: true, slot: { elements: [
  // The column rule starts at the top of the text block, under the band too: a field of
  // paper down to the thin rule covers it, so it hangs from the double rule.
  { kind: 'box', id: 'field', style: { backgroundColor: col('paper') }, placement: {
    anchor: { to: 'container', edge: 'top-left' }, size: { width: 'fill', height: mm(45) } } },
  { kind: 'image', id: 'anchor', resourceId: 'anchor', placement: {
    anchor: { to: 'container', edge: 'top' }, size: { width: 'auto', height: mm(18) } } },
  centred('name', '{titleText}', DISPLAY, 22, 1.1, 20, 0, { fontWeight: 900 }),
  centred('subtitle', '{attr.subtitle}', TEXT, 9, 1.25, 29.5, 92, { italic: true,
    color: col('ink') }),
  centred('byline', '{attr.byline}', LABEL, 7, 1.2, 38.8, 0, { fontWeight: 500,
    letterSpacing: pt(1.2), color: col('muted') }),
  rule('thick', 43.6, 1.2),
  rule('thin', 45, 0.4),
] } };
// '# The Sailor’s Word-Book {style="title" subtitle="…" byline="…"}' opens page 1. The band's
// {titleText} joins the hidden heading's wrapped lines with a space, and a page-span heading
// wraps at the column width: at the default H1 size the band printed 'Word- Book'. At body
// size the hidden title fits one line of the 60 mm column.
const titleStyle = { id: 'title', span: 'page', advancedDesign: titleBand, fontSize: pt(BODY) };
// #endregion

const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity)
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  colorPalette, footer,
  header: { elements: [] }, // every page takes the header of its letter's style
  page: { sizePreset: 'custom', width: mm(150), height: mm(200), dpi: 150,
    backgroundColor: col('paper'),
    margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER),
      mirror: true } },
  layout: { gutterWidth: mm(4), // two columns is the default layout
    columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.4) } },
  bodyText, paragraphStyles,
  // The designs paint every heading; the hidden ones are still measured, and in the face
  // FONTS loads, or the kit would fetch Open Sans 700 for text nobody sees.
  headings: { fontFamily: DISPLAY, fontWeight: 900, levels: [
    // Letters run on in the column. Any headings object already drops the H1 break
    // (gotcha: headings-drop-h1-break); stating it keeps them running on if that default
    // returns, and the letter styles inherit it (gotcha: style-inherits-break).
    { level: 1, breakBefore: { enabled: false }, marginTop: pt(LEAD), marginBottom: pt(0),
      advancedDesign: letterHead },
  ] },
  headingStyles: [
    titleStyle,
    ...letterStyles(),
  ],
});

// #region art: a fouled anchor, the Admiralty's badge, for the title band
// Drawn in the page's navy and brass; the rope's lay is a dashed navy stroke over it.
const anchorSvg = () => {
  const { navy, brass } = palette;
  const rope = 'M66.5 19 C88 26 86 44 60 52 C34 60 32 76 60 82 C88 88 86 104 60 110 '
    + 'C44 114 38 122 42 136';
  const fluke = (s) => `<path transform="translate(60 0) scale(${s} 1)" fill="${navy}"
    d="M-44 92 L-50 78 L-36 70 L-34 88 Z"/>`;
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 150" width="120" height="150">
  <circle cx="60" cy="13" r="8.5" fill="none" stroke="${navy}" stroke-width="4"/>
  <path d="M56.5 21 L63.5 21 L64.5 124 L55.5 124 Z" fill="${navy}"/>
  <rect x="20" y="27" width="80" height="6.5" rx="3.2" fill="${navy}"/>
  <circle cx="19" cy="30.2" r="5" fill="${navy}"/>
  <circle cx="101" cy="30.2" r="5" fill="${navy}"/>
  <path d="M16 84 Q20 132 60 134 Q100 132 104 84" fill="none" stroke="${navy}"
    stroke-width="7" stroke-linecap="round"/>
  ${fluke(1)}${fluke(-1)}
  <path d="M60 126 L66 134 L60 142 L54 134 Z" fill="${navy}"/>
  <path d="${rope}" fill="none" stroke="${brass}" stroke-width="4.2" stroke-linecap="round"/>
  <path d="${rope}" fill="none" stroke="${navy}" stroke-opacity="0.5" stroke-width="4.2"
    stroke-dasharray="1 1.8"/>
</svg>`;
};
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Vocabulario del marinero"
author: "W. H. Smyth"
---

# Vocabulario del marinero {style="title" subtitle="Compendio alfabético de voces náuticas, con algunas más propiamente militares y científicas, pero útiles a la gente de mar" byline="Almirante W. H. Smyth · Londres, 1867"}

**Nota.** El almirante William Henry Smyth dejó manuscrito este vocabulario al morir, el 8 de septiembre de 1865. El vicealmirante sir Edward Belcher lo revisó para la imprenta, y Blackie and Son lo publicó en Londres en 1867 con el título de *The Sailor’s Word-Book*. Esta selección traduce entradas de la A, la B y la C, algunas abreviadas. Los lemas siguen en inglés y en el orden de Smyth, casi todos con su equivalencia castellana en cursiva; entre ellos están los que pasaron al inglés corriente: *A1*, *taken aback*, *aloof*, *bitter end*, *wide berth*, *clear the decks*, *cut and run*. Las entradas a las que remite *véase* no están en esta selección.

# A {style="A"}

:::paragraphs{style="entry"}
**A.** La clase más alta de calidad de los buques mercantes en el registro del Lloyd’s, dividida en A1 y A2, tras las cuales se desciende por las vocales, y A1 es lo mejor de la primera clase. Antes, un buque construido en el río (el Támesis) obtenía la primera clase por 12 años, uno de Bristol por 11 y los de los puertos del norte por 10. Algunos buques construidos en puertos menores conservan su clase de 6 a 8 años, y los inferiores solo 4. Pero las mejoras de la construcción naval, y el uso cada vez mayor del hierro, piden ya plazos más largos.

**Aback.** *En facha.* Situación de las velas de un buque cuando el viento les da por la cara de proa. Se *ponen en facha* a propósito, para quitarle arrancada cobrando las brazas de barlovento, y *quedan en facha* cuando las coge un salto de viento imprevisto o un descuido del timonel. – *All aback forward*, aviso que se da desde el castillo cuando un salto de viento pone en facha las velas de proa. (*Véase* Work aback.) – *Taken aback*, en lenguaje llano, verse sorprendido o descubierto de pronto.

**Abeam.** *Por el través.* En línea perpendicular a la eslora del buque; frente al centro de su costado.

**About.** *Virado.* En redondo; situación del buque después de haber virado y mareado las velas en la otra amura. – *Ready about!* y *About-ship!* son las voces con que se ordena a la dotación prepararse para virar por avante, cada cual en su puesto.

**Adrift.** *A la deriva.* Flotando al azar; estado de un bote o de un buque que ha roto sus amarras y va de acá para allá sin gobierno, a merced de los vientos y las olas. Suelto; cortado de sus amarras.

**Afloat.** *A flote.* Sostenido y llevado por el agua; boyante, libre del fondo; se dice también de quien vive embarcado.

**Ahead.** *Avante; por la proa.* Término que se aplica a todo objeto situado más adelante, o justo delante del buque, o en el rumbo que se gobierna, y por tanto lo opuesto de *astern*. – *Ahead of the reckoning*, ir adelantado a la estima. – *Ahead* se usa también para el progreso, como en *cannot get ahead*, «no poder avanzar», y en general por adelante.

**A-hull.** *A palo seco.* Buque sin vela alguna y con el timón a sotavento, que deriva ante el viento y la mar con la popa por delante. También, buque abandonado y expuesto a los temporales.

**A-lee.** *A sotavento.* Lo contrario de *a-weather*: la posición del timón cuando su caña se lleva a la banda de sotavento para virar por avante o llevar la proa a barlovento. – *Hard a-lee!* o *luff a-lee!* es la voz al timonel para que lleve la caña a sotavento. – *Helm’s a-lee!*, la voz de mando que se da al llevarla, y que hace flamear las velas de proa.

**Aloof.** *Orza; a distancia.* La voz antigua por «keep your luff», al navegar ciñendo el viento. (*Véase* Luff.) – *Keep aloof*, mantenerse a distancia.

**Anchor.** *Ancla.* Instrumento grande y pesado, en uso desde la Antigüedad para sujetar y retener los buques, y lo hace con una fuerza admirable. Salvo excepciones, consta de una larga caña de hierro que tiene en un extremo un arganeo, al que se afirma el cable, y en el otro se abre en dos brazos con uñas, o mapas, en sus picos. Un cepo de madera o de hierro, fijo en ángulo recto con los brazos, sirve para que las uñas entren perpendiculares al fondo. Según su forma y tamaño, las anclas reciben los nombres de *sheet*, *best bower*, *small bower*, *spare*, *stream*, *kedge* y *grapling* (véanse en su lugar).

**Apple-pie order.** *Como una patena.* Término extraño, pero corriente, para un buque en excelente estado y bien cuidado; limpio y ordenado. Se dice, sin fundamento, que viene de *du pol au pied*.

**Ashore.** *En tierra.* Varado, en tierra. – *To go ashore*, desembarcar de un bote. Lo opuesto de *aboard*.

**Astern.** *Por la popa.* A cualquier distancia detrás de un buque; en la parte de popa; en dirección a la popa, y por tanto lo opuesto de *ahead*. – *To drop astern*, quedarse atrás: cuando un buque queda a popa de la perpendicular a la quilla por el palo mayor, se dice que *drops astern*.

**Athwart.** *De través.* La dirección transversal; todo lo que se extiende atravesado en la derrota de un buque. – *Athwart hawse*, buque, bote o madera flotante que la deriva ha atravesado por delante de la roda de un buque; se entiende la posición transversal de lo que deriva. – *Athwart the fore-foot*, justo por delante de la roda; los buques disparan un cañonazo en esa dirección para detener a un desconocido y obligarlo a ponerse en facha. – *Athwart ships*, en la dirección de los baos; de banda a banda: lo opuesto de *fore-and-aft*.

**Avast.** *¡Basta!* Voz para parar, aguantar, cesar o suspender cualquier faena: su derivación del italiano *basta* es más verosímil que la de *have fast*.

**Awning.** *Toldo.* Cubierta o dosel de lona suspendido de una araña y tendido sobre un buque, un bote o cualquier embarcación para resguardar las cubiertas y la gente del sol y del tiempo. (*Véase* Euphroe.) También, la parte de la toldilla que se prolonga a proa más allá del mamparo de la cámara.

**Azure.** *Azur.* El azul intenso del cielo cuando está del todo despejado.
:::

# B {style="B"}

:::paragraphs{style="entry"}
**Ballast.** *Lastre.* Como verbo, significa afirmar; como sustantivo, un juicio sólido. Se dice que un hombre «loses his ballast», pierde el lastre, cuando le falla el juicio o, envanecido, se vuelve pesado de arriba.

**Beam-ends.** *Tumbado.* Se dice que un buque está *on her beam-ends* cuando escora tanto que sus baos se acercan a la vertical; de ahí que también se diga, en sentido figurado, de una persona tendida.

**Berth.** *Fondeadero; puesto.* El lugar en que un buque está fondeado, solo o en una escuadra; así, está en buen fondeadero, es decir, en buen tenedero, abrigado del viento y la mar y a la debida distancia de la costa y de otros buques. – *Snug berth*, un buen acomodo, un puesto o empleo. Una litera. – *To berth a vessel*, elegirle a un buque su puesto y llevarlo a él. – *To berth a ship’s company*, señalar a cada hombre el sitio donde ha de colgar su coy, con las 14 pulgadas de ancho de costumbre. – *To give a berth*, apartarse; dar a una punta de tierra un *wide berth* es mantenerse bien lejos de ella.

**Bitter-end.** *Chicote.* La parte del cable que queda a popa de las bitas, y por tanto dentro de la borda, cuando el buque está fondeado. Dicen «Bend to the bitter-end» cuando quieren entalingar ese chicote al ancla; y cuando una cadena o un cabo se han filado hasta el *bitter-end*, no queda más por largar. El *bitter-end* es el chicote de la talinga: a veces se entalinga ese al ancla, porque no se ha usado nunca y es más de fiar. De un cable de 115 brazas, las primeras 40 suelen estar gastadas cuando el chicote de dentro está casi nuevo.

**Booby.** *Piquero.* Conocida ave marina tropical, *Sula fusca*, de la familia de los *Pelecanidae*. Gusta de descansar fuera del agua por la noche, y hasta prefiere una percha inestable en la verga de un buque. El nombre inglés, que vale tanto como «bobo», le viene de lo fácil que se deja atrapar recién posado. La dirección en que vuelan al caer la tarde indica a menudo dónde hay tierra.

**Brace of shakes.** *Un santiamén.* Un momento, tomado del flamear de una vela: «Estaré contigo antes de que flamee tres veces».

**Bring up with a round turn.** *Parar en seco.* Detener de golpe un cabo que corre dándole una vuelta redonda a un bolardo, una cabeza de bita o una cornamusa. Se dice de lo que se hace con eficacia, aunque con brusquedad, y también de hacer entrar en razón a alguien con una buena reprimenda.

**Broach-to, to.** *Atravesarse.* Orzar bruscamente hasta quedar proa al viento. Suele ocurrir cuando un buque lleva mucho trapo con el viento por la aleta y bastante vela a popa. La arboladura corre peligro, porque el cambio de rumbo la opone más al viento y aumenta su presión. En casos extremos las velas quedan en facha, y entonces los palos pueden ceder, o el buque irse a pique de popa.

**Broadside.** *Andanada; costado.* Todo el aparato, o la descarga simultánea, de la artillería de una banda de un buque de guerra, en todas sus baterías. Designa también todo el costado del buque sobre el agua que queda entre la amura y la aleta, casi perpendicular al horizonte. También, nombre que se daba a los antiguos pliegos en folio en que se imprimían coplas y bandos (*broad-sheet*).

**By the board.** *Por la borda.* Por el costado del buque. Cuando un palo se rompe cerca de la cubierta, se dice que se va *by the board*.
:::

# C {style="C"}

:::paragraphs{style="entry"}
**Cabin.** *Cámara.* Aposento o compartimento separado a bordo, donde viven los oficiales y los pasajeros. En un buque de guerra, la cámara principal, donde vive el comandante o el almirante, está en la parte alta de popa.

**Cabin-boy.** *Paje de cámara.* Muchacho encargado de atender y servir a los oficiales y a los pasajeros en la cámara.

**Cable’s length.** *Cable* (medida). Medida de unas cien brazas, con la que se suele estimar la distancia entre los buques de una escuadra. El término se entiende mal a menudo. En todas las cartas náuticas un cable vale 607,56 pies, la décima parte de una milla marina. En cordelería, el cable tiene entre 100 y 115 brazas de largo; el *cablet*, 120 brazas, y el cabo *hawser-laid*, 130, según fijó el Almirantazgo en 1830.

**Caboose.** *Fogón.* La cocina de los mercantes, sobre cubierta; sustituto menor de la cocina de un buque de guerra. Suele tener aparatos de hierro fundido para guisar.

**Careen, to.** *Escorar.* Se dice que un buque escora al inclinarse sobre una banda, o cuando se tumba al navegar ciñendo el viento, apartado de su quilla, en latín *carina*.

**Cat o’ nine tails.** *Gato de nueve colas.* Instrumento de castigo usado a bordo en la marina de guerra; suele ser de nueve trozos de piola o cordel, de media yarda de largo, fijos en un trozo de cabo grueso que hace de mango, con tres nudos cada uno, a cortos intervalos, cerca de un extremo; con él se azota en la espalda desnuda a los marineros que faltan.

**Caulk, to.** *Calafatear.* (*Véase* Caulking.) Echarse a dormir vestido en cubierta.

**Chart.** *Carta náutica.* Mapa hidrográfico, o proyección de alguna parte de la superficie terrestre *in plano*, para uso de los navegantes; se distinguen además las cartas planas, las de Mercator, las globulares y la carta de botellas o de corrientes, que ayuda a estudiar las corrientes de superficie (véanse todas). Una carta selenográfica representa la Luna, sobre todo tal como se ve con ayuda de la fotografía y del aparato del señor De la Rue.

**Cheer, to.** *Saludar a la voz.* Saludar a un buque *en passant*: la gente sube a cubierta y lanza tres hurras; significa también alentar o animar. (*Véase también* Hearty y Man ship!)

**Chock-a-block, or chock and block.** *A besar.* Igual que *two-blocks* y *block-a-block* (véanse). Cuando el motón de abajo de un aparejo sube hasta tocar el de arriba, de modo que ya no se puede izar más, los motones quedan juntos.

**Clear, to.** *Despejar; zafar.* Tiene varias acepciones, sobre todo librarse de, descargar, vaciar o preparar, como en: – *To clear for action*, hacer zafarrancho de combate. – *To clear away*, quitar de en medio lo que estorba para una faena. – *To clear the decks*, despejar las cubiertas: retirar los trastos, poner cada cosa en su sitio y adujar los cabos; también, levantar la mesa después de comer. – *To clear goods*, despachar las mercancías pagando los derechos de aduana. – *To clear the land*, librarse de la costa. – *To clear a lighter, or the hold*, vaciar una gabarra o la bodega.

**Coil.** *Adujada.* Porción de cabo recogida en forma de anillo; así se disponen a bordo todos los cabos, para estibarlos con comodidad. Se recogen en redondo, una aduja sobre otra, o en vueltas concéntricas, llamadas *Flemish coil*, que forman una sola capa y quedan planas sobre la cubierta, con el chicote en el centro, como se enroscan la culebra y el gusano.

**Convoy.** *Convoy; conserva.* Flota de mercantes con un mismo destino, protegida por una fuerza armada. También, el buque o buques que la conducen y defienden en la travesía, y la escolta de tropas que lleva pertrechos a una fuerza destacada.

**Cross-trees.** *Crucetas.* Maderos que, apoyados en las cacholas y los baos, se cruzan en lo alto de los palos machos y de los masteleros para sostener el armazón de la cofa en los primeros y abrir los obenques de juanete en los segundos.

**Cuddy.** *Camareta.* Especie de camarote o de cocina, por lo general a proa, aunque a veces cerca de la popa, de las gabarras y barcazas de carga. En los buques de travesía oceánica es una cámara a popa, bajo la toldilla, destinada al capitán y a sus pasajeros. También, el camarote pequeño de un bote.

**Cut and run, to.** *Picar el cable.* Cortar el cable para huir. También, largarse; dejar el empleo; desaparecer.
:::

:::paragraphs{style="colophon"}
Tipos: Alegreya, Alegreya SC y Alegreya Sans SC (SIL OFL)

Texto: W. H. Smyth, 1867 (Proyecto Gutenberg, n.º 26000)

Traducción, nota y ancla: Postext Cookbook (CC BY 4.0)
:::
`; // content.<lang>.md, inlined by the Cookbook

// No paragraph cites the anchor, so it is never placed as a figure: only the title band's
// image element draws it.
const resources = [{ id: 'anchor', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  altText: t({ en: 'An anchor with a rope wound round its shank.',
    es: 'Un ancla con un cabo enrollado en la caña.' }),
  svg: { fileId: 'anchor.svg', width: 120, height: 150 } }];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = {
  Alegreya: ['400', '400i', '700'],
  'Alegreya SC': ['900'],
  'Alegreya Sans SC': ['500', '700'],
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await loadSvg('anchor.svg', anchorSvg());
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: t({ en: 'The Sailor’s Word-Book', es: 'Vocabulario del marinero' }) });

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

### Imprime solo la pestaña en curso

Los índices de pestañas impresos suelen mostrar solo la de la letra en curso; quita `ladder` de la cabecera y la pestaña azul seguirá bajando de la A a la C.

```diff
-  header: { elements: [...runningHeads, ...ladder,
+  header: { elements: [...runningHeads,
```

## Errores frecuentes

- **Una página toma la cabecera de la última sección que empieza en ella.** Cuando dos estilos de título empiezan en la misma página, esta toma la cabecera, el pie y la paleta del último. Una página en blanco de separación pertenece al capítulo anterior, y el relleno de paridad al siguiente.
- **Un estilo de título hereda el salto de página de su nivel.** Una entrada de headingStyles toma de su nivel de título todo lo que no fija, también breakBefore. Un índice o un colofón con estilo sobre un H1 tras un :::pagebreak hereda la paridad 'odd' y cae detrás de una página en blanco. Dale a ese estilo breakBefore: { enabled: false }.
- **Cualquier objeto headings desactiva el salto de página del H1.** Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración.
- **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.
- **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.
- **Un espacio de no separación sigue partiendo la línea.** En postext 1.4.1 el algoritmo de corte trata U+00A0 como un espacio normal, así que 0,08 %, 2,006 s o sección 2 pueden quedar en dos líneas. Junta los dos elementos (0,08%) o reescribe la frase.
- **El arreglo de las líneas cortas puede apretar un interletraje que nunca se pinta.** En postext 1.4.1, cuando un párrafo acaba en una línea corta, el motor lo compone con una línea menos: primero aprieta el espacio entre palabras y luego aplica hasta maxRuntTracking milésimas de em de interletraje negativo. Los renderizadores de canvas y PDF solo pintan el interletraje mayor que cero, así que el párrafo se imprime sin él: sus líneas justificadas pierden esa diferencia en los espacios entre palabras, que salen aplastados, y su última línea puede pasarse de la medida y quedar cortada en el borde de la columna. Pon bodyText.maxRuntTracking: 0, que conserva el arreglo por el espacio entre palabras, y reescribe los párrafos que vuelvan a acabar en una línea corta.
- **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.
- **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".

- En postext 1.4.1 el filete de columna bajo un título a todo el ancho empieza en el borde superior de la caja aunque la banda mida un número entero de líneas de la rejilla, como esta. Lo tapa un rectángulo del color de la página, pintado debajo de los demás elementos del diseño.
- Una columna de 60 mm admite unos 50 caracteres, y el inglés de Smyth no se puede reescribir. En la captura, 18 de las 218 líneas justificadas inglesas (un 8 %) estiran el espacio más de 1,7 veces su ancho normal, frente a menos de un 2 % en la edición española, cuya traducción se ajustó a la columna. El inglés llegaba al 17 % antes de cambiar por otras las cinco entradas que peor componían (*Aground*, *Aloft*, *Belay*, *Cockpit*, *Crow’s nest*). Elige entradas que compongan bien, o ensancha la columna.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texto: The Sailor’s Word-Book (Londres: Blackie and Son, 1867), entradas de la A, la B y la C: W. H. Smyth; Sir Edward Belcher ([fuente](https://www.gutenberg.org/ebooks/26000)), dominio público
- Texto: Traducción al español, nota del editor y colofón: Postext Cookbook, original
- Tipografías: Alegreya (OFL-1.1), Alegreya SC (OFL-1.1), Alegreya Sans SC (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 031 · Final de tesis: apéndice, glosario e índice](https://postext.dev/es/cookbook/thesis-back-matter.md): El final de una tesis en blanco y negro: apéndice con letra, glosario a dos columnas, bibliografía APA e índice cuyos números de página calcula el pen. · Nivel 3 (Avanzado) · Artículos y trabajos académicos
- [N.º 029 · Chuleta de anclajes: un cartel de elementos encadenados](https://postext.dev/es/cookbook/anchoring-cheat-sheet.md): Un cartel A3 cuyos elementos cuelgan de la sangre, de la página, de su ranura o unos de otros, y una segunda hoja que enmarca cada elemento y rotula trece. · Nivel 2 (Intermedio) · Hojas sueltas y efímeros
- [N.º 019 · Partes en color con un solo atributo](https://postext.dev/es/cookbook/parts-in-colour.md): En esta guía de campo, cada :::part redefine un color de la paleta, que tiñe su portadilla, el dorso pintado, la pestaña, las negritas y su fila del índice. · Nivel 3 (Avanzado) · Manuales, guías y obras de consulta
