# Notas al pie de la columna que las cita

> Las llamadas [^id] pasan a notas numeradas al pie de la columna que las cita, con interruptores para una columna, notas finales y numeración continua.

- Versión HTML: https://postext.dev/es/cookbook/novel-footnotes-column-foot
- Receta N.º 071 · Recuadros y notas · Nivel 2 (Intermedio) · Salidas: Canvas, PDF, Controles en vivo
- Géneros: Narrativa, teatro y prosa literaria
- Requiere postext ≥ 1.6.0, postext-pdf ≥ 1.6.0 · probada con 1.6.0, postext-pdf 1.6.0 el 2026-09-28
- Páginas: [1](https://postext.dev/cookbook/novel-footnotes-column-foot/es/p01.webp?v=a54e4840), [2](https://postext.dev/cookbook/novel-footnotes-column-foot/es/p02.webp?v=a54e4840), [3](https://postext.dev/cookbook/novel-footnotes-column-foot/es/p03.webp?v=a54e4840)
- PDF: https://postext.dev/cookbook/novel-footnotes-column-foot/es/novel-footnotes-column-foot.pdf?v=a54e4840
- Abrir en el Sandbox: https://postext.dev/es/sandbox#recipe=novel-footnotes-column-foot&lang=es (.postext: https://postext.dev/cookbook/novel-footnotes-column-foot/es/novel-footnotes-column-foot.postext)
- Última actualización: 2026-09-28
- Otros idiomas: [en](https://postext.dev/en/cookbook/novel-footnotes-column-foot.md)

## Lo que vas a componer

Una edición escolar del *Lazarillo de Tormes*, de 210 × 280 mm y a dos columnas de EB Garamond: el prólogo y el comienzo del tratado primero, donde Lázaro nace en una aceña del Tormes y sale al mundo con un ciego. Las notas aclaran lo que un lector de 1554 sabía y uno de hoy ignora: la aldea de Tejares, la armada contra los Gelves, el toro de piedra del puente de Salamanca. Cada nota va al pie de la columna que la cita, en cuerpo menor y bajo un filete corto de color burdeos, de modo que la vista baja del número a la nota y vuelve sin pasar página. Las notas de la edición en español adaptan las de Clements Markham, de 1908, y añaden algunas glosas de vocabulario.

**Esta receta responde a:**

- ¿Cómo compongo las notas al pie de la columna donde se citan?

## La respuesta corta

```js
// script.js, líneas 40–52
// In the Markdown: `Tejares,[^9]` cites a note (the id may be a word: `[^tejares]`), and
// a paragraph that opens with `[^9]:` anywhere in the chapter defines it. The engine
// numbers the notes in order of citation and keeps each note in the column of the line
// that cites it: a line whose note does not fit under it moves on with the note.
const footnotes = () => ({
  placement: options.placement, // 'column' (the foot of the citing column) or 'chapterEnd'
  numbering: options.numbering, // 'chapter': 1, 2, 3… again under every chapter heading
  fontSize: pt(8.5), // off the baseline grid: the notes stack up from the column's foot
  lineHeight: pt(10.5),
  hangingIndent: em(0.9), // turnover lines align past the number
  spaceBetween: pt(1.5),
  separator: { width: 0.25, lineWidth: pt(0.5), color: col('oxblood') },
});
```

## Ingredientes

**Enseña**

- [Notas al pie](https://postext.dev/es/docs/document-format.md#notas-al-pie): Notas citadas con [^id] y compuestas al pie de la columna que las cita, o tras el capítulo, numeradas por capítulo o por todo el libro.

**También usa**

- [Superíndices y subíndices](https://postext.dev/es/docs/document-format.md#formato-en-línea)
- [Una o dos columnas](https://postext.dev/es/docs/configuration.md#tipos-de-disposición)
- [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)
- [Cabeceras y folios](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Márgenes simétricos](https://postext.dev/es/docs/configuration.md#márgenes-simétricos-espejo)
- [Cabeceras según el tipo de página](https://postext.dev/es/docs/configuration.md#elementos-de-texto)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)
- [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)
- [Viudas, huérfanas y líneas cortas](https://postext.dev/es/docs/configuration.md#huérfanas-viudas-runts-y-reglas-de-cohesión)
- [Exportación a PDF](https://postext.dev/es/docs/configuration.md#generación-de-pdf)
- [PDF accesible etiquetado](https://postext.dev/es/docs/configuration.md#generación-de-pdf-configuración)
- [Banda de capítulo a todo el ancho](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Fuentes incrustadas en el PDF](https://postext.dev/es/docs/configuration.md#por-qué-un-proveedor-de-fuentes)

**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), `footnotes`, [`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), [`decompressWoff2`](https://postext.dev/es/docs/configuration.md#proveedor-de-fuentes-en-el-navegador-fontsource--woff2), [`renderPageToCanvas`](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap), [`renderToPdf`](https://postext.dev/es/docs/configuration.md#generación-de-pdf)

**Tipografías**

- EB Garamond (OFL-1.1), Bodoni Moda (OFL-1.1), IBM Plex Sans Condensed (OFL-1.1)

## Elaboración

### 1 · Cita la nota donde toca y defínela donde quieras

La configuración es [la respuesta corta](#la-respuesta-corta) de arriba; lo demás es Markdown. `naturales de Tejares[^tejares]` imprime un número volado pegado a la palabra, y un párrafo que empieza por `[^tejares]:` contiene la nota. Las definiciones salen del flujo estén donde estén, así que esta muestra las reúne al final del capítulo. Los números siguen el orden de las llamadas: los ids del Markdown pueden ser palabras, y renumerar un capítulo nunca obliga a tocar las notas.

### 2 · Deja sitio en la columna para sus notas

Antes de colocar una línea, la maquetación suma la altura de las notas que esa línea cita por primera vez, y la del filete si es la primera nota de la columna. Una línea cuya nota no cabe debajo pasa a la columna siguiente con su párrafo, según las reglas de viudas y huérfanas, de modo que un número nunca remite a una nota de otra columna. Las notas se apilan desde el pie con su propio cuerpo e interlineado, fuera de la rejilla base, y el texto de encima sigue en ella; el equilibrado de columnas solo cuenta el texto.

### 3 · Hunde todas las aperturas hasta la misma línea

```js
// script.js, líneas 56–74
const opener = {
  enabled: true,
  // The sink holds a two-line title and its rule, with ~8 mm to the first line of text,
  // so every chapter starts on the same grid line whatever its title's length.
  minHeight: pt(LEAD * 10),
  slot: { elements: [
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...label, fontSize: pt(8.5),
      letterSpacing: pt(1.8), color: col('oxblood'), align: 'center',
      placement: at('container', 'top', 0, 7) },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Bodoni Moda', italic: true,
      fontSize: pt(26), lineHeight: 1.1, color: col('ink'), align: 'center',
      overflow: 'wrap', // design text ends in an ellipsis by default
      placement: { ...at('container', 'top-left', 0, 13),
        size: { width: 'fill', height: 'auto' } } },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.75), color: col('oxblood'),
      placement: { anchor: { to: '#title', edge: 'below' }, // a rule starts at the box's left
        offset: { x: mm((MEASURE - 14) / 2), y: mm(4.5) }, size: { width: mm(14) } } },
  ] },
};
```

La apertura ocupa las dos columnas y toma el antetítulo del atributo `kicker` del título. `minHeight` reserva diez líneas de la rejilla, lo que cabe un título de dos líneas en Bodoni con su filete, así que el texto de cada capítulo empieza en la misma línea y el filete nunca cae sobre la primera línea de texto.

### 4 · Pon el libro en la página par y el capítulo en la impar

```js
// script.js, líneas 78–95
const head = (id, content, parity, edge, x, extra = {}) => ({
  kind: 'text', id, content, parity, pages: 'body', // never on openers
  ...label, fontSize: pt(7.5), letterSpacing: pt(1.3), color: col('muted'),
  placement: at('page', edge, x, 13), ...extra,
});
const folio = { color: col('oxblood') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', MARGIN.outer,
    { ...folio, align: 'left' }),
  head('verso-title', '{title}', 'even', 'top-left', MARGIN.outer + 9, { align: 'left' }),
  head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(MARGIN.outer + 9),
    { align: 'right', overflow: 'ellipsis', size: { width: mm(120) } }),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -MARGIN.outer,
    { ...folio, align: 'right' }),
] };
// Openers carry a drop folio instead, centred under the text block.
const footer = { elements: [{ ...head('drop-folio', '{pageNumber}', 'all', 'bottom', 0,
  { ...folio, align: 'center', placement: at('page', 'bottom', 0, -11) }), pages: 'opener' }] };
```

Las cabeceras se anclan a la página según su paridad, con el folio en el borde exterior; `pages: 'body'` las quita de las aperturas, que llevan un folio centrado al pie. Un título de capítulo largo acaba en puntos suspensivos a los 120 mm en vez de chocar con el folio.

### 5 · Prueba las otras posiciones

```js
// script.js, líneas 224–250
const CHOICES = {
  columns: [[2, t({ en: 'Two columns', es: 'Dos columnas' })],
    [1, t({ en: 'One column', es: 'Una columna' })]],
  placement: [['column', t({ en: 'Column foot', es: 'Pie de columna' })],
    ['chapterEnd', t({ en: 'End of chapter', es: 'Final del capítulo' })]],
  numbering: [['chapter', t({ en: 'Per chapter', es: 'Por capítulo' })],
    ['document', t({ en: 'Continuous', es: 'Continua' })]],
};
const bar = document.createElement('form');
bar.id = 'options';
bar.ariaLabel = t({ en: 'Footnote options', es: 'Opciones de las notas' });
for (const [key, choices] of Object.entries(CHOICES)) {
  const group = bar.appendChild(document.createElement('fieldset'));
  for (const [value, text] of choices) {
    const input = Object.assign(document.createElement('input'), { type: 'radio', name: key,
      value: String(value), checked: options[key] === value });
    const item = group.appendChild(document.createElement('label'));
    item.append(input, text);
  }
}
bar.addEventListener('change', (event) => {
  const { name, value } = event.target;
  options[name] = name === 'columns' ? Number(value) : value;
  doc = buildDocument({ markdown }, config()); // the fonts are loaded by now
  showPages(doc, { title: TITLE });
});
document.getElementById('pages').before(bar);
```

Cada interruptor vuelve a componer el capítulo con una opción cambiada. **Una columna** pone las notas al pie de la página, que ahí es la columna. **Final del capítulo** las compone tras el último párrafo y las baja al pie de la columna que cierran, bajo el mismo filete. **Continua** sigue la numeración del prólogo en el tratado en lugar de volver a empezar en 1.

## La receta completa

Los archivos de abajo se componen a partir de la carpeta de la receta, con el texto de ejemplo y el kit común del Recetario ya incluidos. Para ejecutarlos como una sola página, pon el HTML en `<body>`, el CSS en un elemento `<style>` y el script en un `<script type="module">`; o pega cada uno en el panel correspondiente de un pen nuevo de CodePen (el JS como módulo). El script 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/novel-footnotes-column-foot

### style.css

```css
/* The option bar over the pages: three pairs of choices, each a segmented switch. */
#options {
  display: flex; flex-wrap: wrap; justify-content: center; gap: 10px 18px;
  max-width: 1120px; margin: 0 auto; padding: 18px 16px 6px;
  font: 600 12px/1.2 "IBM Plex Sans Condensed", system-ui, sans-serif;
  letter-spacing: .06em; text-transform: uppercase;
}
#options fieldset {
  display: flex; margin: 0; padding: 2px; border: 1px solid #4a433d; border-radius: 999px;
}
#options label {
  display: block; padding: 7px 14px; border-radius: 999px; color: #c9bdae; cursor: pointer;
}
#options input { position: absolute; opacity: 0; pointer-events: none; }
#options label:has(input:checked) { background: #7d2b1f; color: #fbf8f2; }
#options label:has(input:focus-visible) { outline: 2px solid #c9bdae; outline-offset: 2px; }
```

### script.js

```js
// ═══ Postext Cookbook · Nº 071 · Footnotes at the foot of the column ══════════════
// https://postext.dev/en/cookbook/novel-footnotes-column-foot
// Code: MIT · Text: Lazarillo de Tormes (1554) and Markham's 1908 translation (PD)
// Fonts: EB Garamond, Bodoni Moda, IBM Plex Sans Condensed (SIL OFL 1.1) · Needs postext ≥ 1.6.0
import { buildDocument, renderPageToCanvas, clearMeasurementCache } from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'novel-footnotes-column-foot';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: oxblood for the kickers, the folios and the notes' rule; ink on warm paper
const palette = {
  ink: '#241e1a', // text: a warm near-black
  oxblood: '#7d2b1f', // the one accent
  muted: '#6f655b', // running heads, colophon
  rule: '#c9bdae', // hairlines
  paper: '#fbf8f2',
};
// A design element paints the hex written beside its paletteId (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to main-color: point it at the accent, never the default blue.
  { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.oxblood, model: 'hex' } },
];
// #endregion
const TRIM = { width: 210, height: 280 }; // a school edition: two 82 mm columns
const MARGIN = { top: 23, bottom: 22, inner: 21, outer: 17 }; // mirrored
const MEASURE = TRIM.width - MARGIN.inner - MARGIN.outer; // 172 mm
const LEAD = 13.5; // body leading in pt: the baseline grid
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const label = { fontFamily: 'IBM Plex Sans Condensed', fontWeight: 600,
  textTransform: 'uppercase' };

// The switches under the title change these and build again (#region live).
const options = { columns: 2, placement: 'column', numbering: 'chapter' };

// #region answer: the notes at the foot of the column that cites them
// In the Markdown: `Tejares,[^9]` cites a note (the id may be a word: `[^tejares]`), and
// a paragraph that opens with `[^9]:` anywhere in the chapter defines it. The engine
// numbers the notes in order of citation and keeps each note in the column of the line
// that cites it: a line whose note does not fit under it moves on with the note.
const footnotes = () => ({
  placement: options.placement, // 'column' (the foot of the citing column) or 'chapterEnd'
  numbering: options.numbering, // 'chapter': 1, 2, 3… again under every chapter heading
  fontSize: pt(8.5), // off the baseline grid: the notes stack up from the column's foot
  lineHeight: pt(10.5),
  hangingIndent: em(0.9), // turnover lines align past the number
  spaceBetween: pt(1.5),
  separator: { width: 0.25, lineWidth: pt(0.5), color: col('oxblood') },
});
// #endregion

// #region opener: kicker, a Bodoni title and a short rule, centred over both columns
const opener = {
  enabled: true,
  // The sink holds a two-line title and its rule, with ~8 mm to the first line of text,
  // so every chapter starts on the same grid line whatever its title's length.
  minHeight: pt(LEAD * 10),
  slot: { elements: [
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...label, fontSize: pt(8.5),
      letterSpacing: pt(1.8), color: col('oxblood'), align: 'center',
      placement: at('container', 'top', 0, 7) },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Bodoni Moda', italic: true,
      fontSize: pt(26), lineHeight: 1.1, color: col('ink'), align: 'center',
      overflow: 'wrap', // design text ends in an ellipsis by default
      placement: { ...at('container', 'top-left', 0, 13),
        size: { width: 'fill', height: 'auto' } } },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.75), color: col('oxblood'),
      placement: { anchor: { to: '#title', edge: 'below' }, // a rule starts at the box's left
        offset: { x: mm((MEASURE - 14) / 2), y: mm(4.5) }, size: { width: mm(14) } } },
  ] },
};
// #endregion

// #region running-heads: book title on the verso, chapter on the recto, folios outside
const head = (id, content, parity, edge, x, extra = {}) => ({
  kind: 'text', id, content, parity, pages: 'body', // never on openers
  ...label, fontSize: pt(7.5), letterSpacing: pt(1.3), color: col('muted'),
  placement: at('page', edge, x, 13), ...extra,
});
const folio = { color: col('oxblood') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', MARGIN.outer,
    { ...folio, align: 'left' }),
  head('verso-title', '{title}', 'even', 'top-left', MARGIN.outer + 9, { align: 'left' }),
  head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(MARGIN.outer + 9),
    { align: 'right', overflow: 'ellipsis', size: { width: mm(120) } }),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -MARGIN.outer,
    { ...folio, align: 'right' }),
] };
// Openers carry a drop folio instead, centred under the text block.
const footer = { elements: [{ ...head('drop-folio', '{pageNumber}', 'all', 'bottom', 0,
  { ...folio, align: 'center', placement: at('page', 'bottom', 0, -11) }), pages: 'opener' }] };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  colorPalette,
  page: { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
    backgroundColor: col('paper'),
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true } }, // left = inner
  layout: options.columns === 2
    ? { layoutType: 'double', gutterWidth: mm(7) }
    : { layoutType: 'single' },
  bodyText: { fontFamily: 'EB Garamond', fontSize: pt(10.5), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: mm(4.5), indentAfterHeading: false,
    hyphenation: { enabled: true }, optimalLineBreaking: true,
    avoidWidows: true, avoidOrphans: true, avoidRunts: true },
  headings: { fontFamily: 'EB Garamond', color: col('ink'), levels: [
    // Restated on purpose: any headings object drops the H1 break
    // (gotcha: headings-drop-h1-break). 'any': the treatise opens on the next page.
    { level: 1, span: 'page', advancedDesign: opener, marginBottom: pt(0),
      breakBefore: { enabled: true, parity: 'any' } },
    { level: 2, fontSize: pt(12.5), lineHeight: pt(LEAD), italic: true, fontWeight: 400,
      color: col('oxblood'), marginTop: pt(LEAD), marginBottom: pt(0) },
  ] },
  paragraphStyles: [
    { id: 'colophon', ...label, textTransform: 'none', fontSize: pt(7.5), lineHeight: pt(10),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  footnotes: footnotes(),
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Lazarillo de Tormes"
---

# Prólogo

Yo por bien tengo que cosas tan señaladas, y por ventura nunca oídas ni vistas, vengan a noticia de muchos y no se entierren en la sepultura del olvido, pues podría ser que alguno que las lea halle algo que le agrade, y a los que no ahondaren tanto los deleite; y a este propósito dice Plinio que no hay libro, por malo que sea, que no tenga alguna cosa buena[^plinio]; mayormente que los gustos no son todos unos, mas lo que uno no come, otro se pierde por ello. Y así vemos cosas tenidas en poco de algunos, que de otros no lo son. Y esto, para ninguna cosa se debría romper ni echar a mal, si muy detestable no fuese, sino que a todos se comunicase, mayormente siendo sin perjuicio y pudiendo sacar della algún fruto; porque si así no fuese, muy pocos escribirían para uno solo, pues no se hace sin trabajo, y quieren, ya que lo pasan, ser recompensados, no con dineros, mas con que vean y lean sus obras, y si hay de qué, se las alaben; y a este propósito dice Tulio: «La honra cría las artes.» ¿Quién piensa que el soldado que es primero del escala, tiene más aborrecido el vivir? No, por cierto; mas el deseo de alabanza le hace ponerse en peligro; y así, en las artes y letras es lo mesmo. Predica muy bien el presentado, y es hombre que desea mucho el provecho de las ánimas; mas pregunten a su merced si le pesa cuando le dicen: «¡Oh, qué maravillosamente lo ha hecho vuestra reverencia!» Justó muy ruinmente el señor don Fulano, y dio el sayete de armas al truhán, porque le loaba de haber llevado muy buenas lanzas. ¿Qué hiciera si fuera verdad?

Y todo va desta manera: que confesando yo no ser más santo que mis vecinos, desta nonada, que en este grosero estilo escribo, no me pesará que hayan parte y se huelguen con ello todos los que en ella algún gusto hallaren, y vean que vive un hombre con tantas fortunas, peligros y adversidades.

Suplico a vuestra M. reciba el pobre servicio de mano de quien lo hiciera más rico si su poder y deseo se conformaran. Y pues V.M. escribe se le escriba y relate el caso por muy extenso, parecióme no tomalle por el medio, sino por el principio, porque se tenga entera noticia de mi persona, y también porque consideren los que heredaron nobles estados cuán poco se les debe, pues Fortuna fue con ellos parcial, y cuánto más hicieron los que, siéndoles contraria, con fuerza y maña remando, salieron a buen puerto.

# Cuenta Lázaro su vida, y cuyo hijo fue {kicker="Tratado primero"}

Pues sepa V.M. ante todas cosas que a mí llaman Lázaro de Tormes, hijo de Tomé González y de Antona Pérez, naturales de Tejares[^tejares], aldea de Salamanca. Mi nacimiento fue dentro del río Tormes[^tormes], por la cual causa tomé el sobrenombre, y fue desta manera. Mi padre, que Dios perdone, tenía cargo de proveer una molienda de una aceña[^acena], que está ribera de aquel río, en la cual fue molinero más de quince años; y estando mi madre una noche en la aceña, preñada de mí, tomóle el parto y parióme allí: de manera que con verdad puedo decir nacido en el río. Pues siendo yo niño de ocho años, achacaron a mi padre ciertas sangrías mal hechas en los costales[^sangrias] de los que allí a moler venían, por lo que fue preso, y confesó y no negó y padeció persecución por justicia. Espero en Dios que está en la Gloria, pues el Evangelio los llama bienaventurados. En este tiempo se hizo cierta armada contra moros[^armada], entre los cuales fue mi padre, que a la sazón estaba desterrado por el desastre ya dicho, con cargo de acemilero[^acemilero] de un caballero que allá fue, y con su señor, como leal criado, feneció su vida.

Mi viuda madre, como sin marido y sin abrigo se viese, determinó arrimarse a los buenos[^buenos] por ser uno dellos, y vínose a vivir a la ciudad, y alquiló una casilla, y metióse a guisar de comer a ciertos estudiantes, y lavaba la ropa a ciertos mozos de caballos del Comendador de la Magdalena[^comendador], de manera que fue frecuentando las caballerizas. Ella y un hombre moreno de aquellos que las bestias curaban, vinieron en conocimiento. Éste algunas veces se venía a nuestra casa, y se iba a la mañana; otras veces de día llegaba a la puerta, en achaque de comprar huevos, y entrábase en casa. Yo al principio de su entrada, pesábame con él y habíale miedo, viendo el color y mal gesto que tenía; mas de que vi que con su venida mejoraba el comer, fuile queriendo bien, porque siempre traía pan, pedazos de carne, y en el invierno leños, a que nos calentábamos. De manera que, continuando con la posada y conversación, mi madre vino a darme un negrito muy bonito, el cual yo brincaba y ayudaba a calentar. Y acuérdome que, estando el negro de mi padre trebejando con el mozuelo, como el niño vía a mi madre y a mí blancos, y a él no, huía dél con miedo para mi madre, y señalando con el dedo decía: «¡Madre, coco!».

Respondió él riendo: «¡Hideputa!»

Yo, aunque bien mochacho, noté aquella palabra de mi hermanico, y dije entre mí:

«¡Cuántos debe de haber en el mundo que huyen de otros porque no se ven a sí mesmos!»

Quiso nuestra fortuna que la conversación del Zaide, que así se llamaba, llegó a oídos del mayordomo, y hecha pesquisa, hallóse que la mitad por medio de la cebada, que para las bestias le daban, hurtaba, y salvados, leña, almohazas, mandiles, y las mantas y sábanas de los caballos hacía perdidas, y cuando otra cosa no tenía, las bestias desherraba, y con todo esto acudía a mi madre para criar a mi hermanico. No nos maravillemos de un clérigo ni fraile, porque el uno hurta de los pobres y el otro de casa para sus devotas y para ayuda de otro tanto, cuando a un pobre esclavo el amor le animaba a esto. Y probósele cuanto digo y aun más, porque a mí con amenazas me preguntaban, y como niño respondía, y descubría cuanto sabía con miedo, hasta ciertas herraduras que pormandado de mi madre a un herrero vendí. Al triste de mi padrastro azotaron y pringaron, y a mi madre pusieron pena por justicia, sobre el acostumbrado centenario, que en casa del sobredicho Comendador no entrase, ni al lastimado Zaide en la suya acogiese.

Por no echar la soga tras el caldero, la triste se esforzó y cumplió la sentencia; y por evitar peligro y quitarse de malas lenguas, se fue a servir a los que al presente vivían en el mesón de la Solana; y allí, padeciendo mil importunidades, se acabó de criar mi hermanico hasta que supo andar, y a mí hasta ser buen mozuelo, que iba a los huéspedes por vino y candelas y por lo demás que me mandaban.

En este tiempo vino a posar al mesón un ciego, el cual, pareciéndole que yo sería para adestralle, me pidió a mi madre, y ella me encomendó a él, diciéndole como era hijo de un buen hombre, el cual por ensalzar la fe había muerto en la de los Gelves, y que ella confiaba en Dios no saldría peor hombre que mi padre, y que le rogaba me tratase bien y mirase por mí, pues era huérfano. Él le respondió que así lo haría, y que me recibía no por mozo sino por hijo. Y así le comencé a servir y adestrar a mi nuevo y viejo amo.

Como estuvimos en Salamanca algunos días, pareciéndole a mi amo que no era la ganancia a su contento, determinó irse de allí; y cuando nos hubimos de partir, yo fui a ver a mi madre, y ambos llorando, me dio su bendición y dijo:

«Hijo, ya sé que no te veré más. Procura ser bueno, y Dios te guíe. Criado te he y con buen amo te he puesto. Válete por ti.»

Y así me fui para mi amo, que esperándome estaba. Salimos de Salamanca, y llegando a la puente, está a la entrada della un animal de piedra[^toro], que casi tiene forma de toro, y el ciego mandóme que llegase cerca del animal, y allí puesto, me dijo:

«Lázaro, llega el oído a este toro, y oirás gran ruido dentro dél.»

Yo simplemente llegué, creyendo ser ansí; y como sintió que tenía la cabeza par de la piedra, afirmó recio la mano y diome una gran calabazada en el diablo del toro, que más de tres días me duró el dolor de la cornada, y díjome:

«Necio, aprende que el mozo del ciego un punto ha de saber más que el diablo», y rió mucho la burla.

Parecióme que en aquel instante desperté de la simpleza en que como niño dormido estaba. Dije entre mí:

«Verdad dice éste, que me cumple avivar el ojo y avisar, pues solo soy, y pensar cómo me sepa valer.»

Comenzamos nuestro camino, y en muy pocos días me mostró jerigonza, y como me viese de buen ingenio, holgábase mucho, y decía:

«Yo oro ni plata no te lo puedo dar, mas avisos para vivir muchos te mostraré.»

Y fue ansí, que después de Dios éste me dio la vida, y siendo ciego me alumbró y adestró en la carrera de vivir. Huelgo de contar a V.M. estas niñerías para mostrar cuánta virtud sea saber los hombres subir siendo bajos, y dejarse bajar siendo altos cuánto vicio.

:::paragraphs{style="colophon"}
Compuesto en EB Garamond, Bodoni Moda e IBM Plex Sans Condensed (SIL OFL) · Texto: *La vida de Lazarillo de Tormes*, Burgos, 1554, prólogo y comienzo del tratado primero (dominio público) · Notas: adaptadas de Clements Markham (1908) y de la edición (CC BY 4.0)
:::

[^plinio]: Plinio el Joven atribuye la sentencia a su tío, Plinio el Viejo. Cervantes la recoge en la segunda parte del *Quijote* (cap. III), en boca del bachiller Sansón Carrasco.

[^tejares]: Aldea en la orilla izquierda del Tormes, a unas dos millas de Salamanca, con una iglesia dedicada a San Pedro y medio centenar de casas en la falda de un cerro.

[^tormes]: El Tormes nace en la sierra de Gredos, en tierras de Ávila; corre hacia el norte por Alba de Tormes, donde lo cruza un puente de piedra, gira al noroeste para pasar por Salamanca y Ledesma y desemboca en el Duero, en la raya de Portugal. Mueve muchos molinos harineros.

[^acena]: Molino harinero de agua instalado en el cauce de un río.

[^sangrias]: Eufemismo: el molinero sisaba harina de los costales que le llevaban a moler, como quien saca sangre de una vena.

[^armada]: La expedición que salió de Málaga en 1510 al mando de don García de Toledo contra la isla de los Gelves (Yerba), entre Túnez y Trípoli. El ejército desembarcó el 8 de agosto, cayó en una emboscada y perdió cuatro mil hombres entre muertos y cautivos; don García murió en la jornada.

[^acemilero]: Mozo que cuida y conduce las acémilas, las mulas de carga.

[^buenos]: Juego de palabras: «arrimarse a los buenos» es acogerse a la gente de bien, pero también a los acomodados; la madre busca amparo, no virtud.

[^comendador]: Los comendadores eran caballeros de las órdenes de Santiago, Calatrava y Alcántara, con el título de su encomienda. El de la Magdalena pertenecía a la de Alcántara.

[^toro]: Un gran bloque de granito labrado en forma de animal, que estaba en el puente desde tiempo inmemorial. Se retiró por su peso en el siglo XIX y hoy, sin cabeza, se conserva en Salamanca.
`; // content.<lang>.md, inlined by the Cookbook

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the design uses (gotcha: fonts-first).
const FONTS = {
  'EB Garamond': ['400', '400i', '600', '600i', '700'],
  'Bodoni Moda': ['400i'],
  'IBM Plex Sans Condensed': ['600'],
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
const TITLE = t({ en: 'Footnotes at the foot of the column', es: 'Notas al pie de columna' });
let doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown);
showPages(doc, { title: TITLE });
offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider }), `${RECIPE}.pdf`);

// #region live: three switches, each a fresh build of the chapter with the other option
const CHOICES = {
  columns: [[2, t({ en: 'Two columns', es: 'Dos columnas' })],
    [1, t({ en: 'One column', es: 'Una columna' })]],
  placement: [['column', t({ en: 'Column foot', es: 'Pie de columna' })],
    ['chapterEnd', t({ en: 'End of chapter', es: 'Final del capítulo' })]],
  numbering: [['chapter', t({ en: 'Per chapter', es: 'Por capítulo' })],
    ['document', t({ en: 'Continuous', es: 'Continua' })]],
};
const bar = document.createElement('form');
bar.id = 'options';
bar.ariaLabel = t({ en: 'Footnote options', es: 'Opciones de las notas' });
for (const [key, choices] of Object.entries(CHOICES)) {
  const group = bar.appendChild(document.createElement('fieldset'));
  for (const [value, text] of choices) {
    const input = Object.assign(document.createElement('input'), { type: 'radio', name: key,
      value: String(value), checked: options[key] === value });
    const item = group.appendChild(document.createElement('label'));
    item.append(input, text);
  }
}
bar.addEventListener('change', (event) => {
  const { name, value } = event.target;
  options[name] = name === 'columns' ? Number(value) : value;
  doc = buildDocument({ markdown }, config()); // the fonts are loaded by now
  showPages(doc, { title: TITLE });
});
document.getElementById('pages').before(bar);
// #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 · pdf v1 ── the same in every recipe that exports a PDF ──────────────
/** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen
 *  used, snapping to a weight the family ships and falling back to upright
 *  when it has no italic: the PDF asks for every face a block could use. */
async function fontsourceProvider(family, weight, style) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  const weights = meta?.weights?.length ? meta.weights : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style;
  const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`);
  if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`);
  return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
}

/** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new
 *  tab, since CodePen's preview frame cannot show PDFs) and a download link. */
function offerPdf(makePdf, filename) {
  viewer();
  const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' });
  button.dataset.postextPdf = filename;
  button.addEventListener('click', async () => {
    button.disabled = true;
    button.textContent = 'Building the PDF…';
    try {
      const bytes = await makePdf();
      const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
      const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`;
      button.replaceWith(
        Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }),
        Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` }));
    } catch (error) {
      button.disabled = false;
      button.textContent = 'Build the PDF';
      kitFail(error);
    }
  });
  document.getElementById('pt-actions').append(button);
}

// ─── /Kit ───────────────────────────────────────────────────────────────────────
```

## Variantes

### Deja las notas finales bajo el texto

Con `placement: 'chapterEnd'` las notas quedan por defecto al pie de la última columna; `chapterEndAlign: 'text'` las pone justo debajo del último párrafo.

```diff
   placement: options.placement,
+  chapterEndAlign: 'text',
```

### Quita el filete

Sin filete, las notas siguen separadas del texto por `spaceAbove` y `spaceBelowRule`.

```diff
-  separator: { width: 0.25, lineWidth: pt(0.5), color: col('oxblood') },
+  separator: { enabled: false },
```

## Errores frecuentes

- **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.
- **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.
- **Una paleta cambiada no llega a los elementos de diseño ni al color de las remisiones.** postext 1.4.1 aplica colorPalette a los estilos de texto (cuerpo, títulos, listas, pies, tablas, recuadros), pero no a los elementos de cabeceras, pies de página, aperturas y portadillas, ni a bodyText.referenceColor: conservan el hex escrito junto a su paletteId. Si cambias la paleta, para una edición de pantalla oscura o para recolorear, reescribe cada color enlazado a partir de colorPalette antes de componer.
- **El 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.
- **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.
- **Una configuración se cachea por identidad: crea un objeto nuevo.** El motor guarda en caché las configuraciones resueltas según la identidad del objeto, así que modificar el mismo objeto y volver a componer reutiliza el resultado anterior. Crea un objeto nuevo en cada composición: por eso la configuración de una receta es una función, config().
- **Una apertura reserva altura hasta su elemento anclado más bajo.** Una apertura de diseño avanzado reserva la altura de su elemento más bajo, y cuentan también los anclados a la página o a la sangre que quedan por debajo del título, así que un adorno al pie de la página empuja el texto a la siguiente. Deja esos adornos por encima del título, pásalos a una ranura de cabecera o de pie, o fija la reserva con minHeight.
- **Aviso de maquetación: Nota sin definición** (`undefinedFootnote`). Una marca [^id] cita una nota que ningún párrafo [^id]: define, así que el número se imprime sobre una nota vacía. Solución: Añade un párrafo [^id]: con la nota o corrige el id de la marca. ([Documentación](https://postext.dev/es/docs/configuration.md#notas-al-pie))
- **Aviso de maquetación: Nota sin citar** (`unusedFootnote`). Una definición [^id]: no tiene marca en el texto, así que la nota no se compone. Solución: Cítala con [^id] donde corresponda o borra la definición. ([Documentación](https://postext.dev/es/docs/configuration.md#notas-al-pie))

- Una llamada en un pie de figura, una celda de tabla o un título se imprime tal cual: las notas se leen en párrafos, elementos de lista, citas y recuadros.
- Una nota nunca se parte entre columnas: una más alta que su columna la desborda. Mantén las notas en pocas líneas, como hacen los editores.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texto: La vida de Lazarillo de Tormes, edición de Burgos de 1554: el prólogo y el comienzo del tratado primero: Anonymous ([fuente](https://www.gutenberg.org/ebooks/320)), dominio público
- Texto: The Life of Lazarillo de Tormes, traducción de Clements Markham (1908), con sus notas: el prólogo y el primer capítulo, abreviados: Clements Markham ([fuente](https://www.gutenberg.org/ebooks/53489)), dominio público
- Texto: Las notas en español, adaptadas de las de Markham y escritas para esta edición: Ignacio Ferro, CC-BY-4.0
- Tipografías: EB Garamond (OFL-1.1), Bodoni Moda (OFL-1.1), IBM Plex Sans Condensed (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 032 · Clásico anotado con glosas al margen](https://postext.dev/es/cookbook/annotated-classic-glosses.md): La merienda de locos de Alicia en edición anotada: glosas verdes y rojas en el margen exterior, junto a lo que explican, con llamadas de su mismo color. · Nivel 3 (Avanzado) · Narrativa, teatro y prosa literaria
- [N.º 044 · Edición crítica: numeración de versos y notas por verso](https://postext.dev/es/cookbook/critical-edition-line-numbers.md): El Lycidas de Milton, texto de 1645: una función cuenta los versos, pone el número cada cinco en un recuadro lateral y abre cada nota con el número del verso. · Nivel 3 (Avanzado) · Poesía
- [N.º 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
