# Final de tesis: apéndice, glosario e índice

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

- Versión HTML: https://postext.dev/es/cookbook/thesis-back-matter
- Receta N.º 031 · Estructura del libro · Nivel 3 (Avanzado) · Salidas: Canvas, PDF
- Géneros: Artículos y trabajos académicos
- Requiere postext ≥ 1.4.1, postext-pdf ≥ 1.4.1 · probada con 1.4.1, postext-pdf 1.4.1 el 2026-09-26
- Páginas: [171](https://postext.dev/cookbook/thesis-back-matter/en/p01.webp?v=1edadd62), [172](https://postext.dev/cookbook/thesis-back-matter/en/p02.webp?v=1edadd62), [173](https://postext.dev/cookbook/thesis-back-matter/en/p03.webp?v=1edadd62), [174](https://postext.dev/cookbook/thesis-back-matter/en/p04.webp?v=1edadd62), [175](https://postext.dev/cookbook/thesis-back-matter/en/p05.webp?v=1edadd62), [176](https://postext.dev/cookbook/thesis-back-matter/en/p06.webp?v=1edadd62), [177](https://postext.dev/cookbook/thesis-back-matter/en/p07.webp?v=1edadd62)
- PDF: https://postext.dev/cookbook/thesis-back-matter/en/thesis-back-matter.pdf?v=1edadd62
- Última actualización: 2026-09-26
- Otros idiomas: [en](https://postext.dev/en/cookbook/thesis-back-matter.md)

## Lo que vas a componer

Las siete últimas páginas de una tesis doctoral sobre la lectura en pantalla y en papel, en formato B5 y en blanco y negro. El capítulo 6, el apéndice A, el glosario, la bibliografía y el índice alfabético abren bajo la misma banda negra de 62 mm, con el título calado en blanco. El capítulo lleva su numeral en la banda; el apéndice, su letra; y las secciones finales, el antetítulo BACK MATTER y una nota breve en cursiva. El capítulo va a una columna justificada. El glosario y el índice pasan a dos columnas en bandera y la bibliografía vuelve a una, con las líneas de continuación de cada entrada sangradas. El pen lee los números de página del índice en las páginas compuestas y agrupa los seguidos en tramos como 171–73. Los números en cursiva remiten a una tabla, y los de negrita, al glosario.

**Esta receta responde a:**

- ¿Cómo compongo el glosario, la bibliografía y el índice de una tesis, con sangría francesa y cuerpo menor?
- ¿Cómo evito que los títulos, las negritas y las viñetas salgan en azul?
- ¿Cómo numero los títulos (1, 1.1, 1.1.1) y doy a cada nivel un estilo distinto?
- ¿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 fuerzo un salto de página o de columna, y hago que cada capítulo empiece en página impar?

## La respuesta corta

```js
// script.js, líneas 27–51
// '# Glossary {style="glossary"}' in the Markdown picks a style. Each style starts a page
// of either parity, the appendix a recto (a style that sets no break inherits its level's
// 'odd': gotcha style-inherits-break), stays out of the chapter count (numbered: false, so
// its band has no numeral) and brings its own running heads; the glossary and the index
// set their pages in two columns until the next '#'. config() takes both lists below.
const twoColumns = { layoutType: 'double', gutterWidth: mm(6) };
const backMatter = (id, extra) => ({ id, numbered: false, breakBefore: { enabled: true,
  parity: 'any' }, advancedDesign: opener('Back matter'), header: sectionHeads, ...extra });
const headingStyles = () => [
  backMatter('appendix', { breakBefore: { enabled: true, parity: 'odd' }, // {letter="A"}
    header: appendixHeads, advancedDesign: opener('Appendix', '{attr.letter}') }),
  backMatter('glossary', { layout: twoColumns }),
  backMatter('references'),
  backMatter('index', { layout: twoColumns }),
];
// One paragraph per entry, in :::paragraphs{style="…"}: the turnover lines hang, so the
// first word of every entry stands clear at the left. Ragged, as APA asks of references,
// and so never hyphenated (gotcha: ragged-no-hyphenation).
const entries = (id, size, lead, hang, extra) => ({ id, fontSize: pt(size),
  lineHeight: pt(lead), textAlign: 'left', hangingIndent: em(hang), ...extra });
const paragraphStyles = () => [
  entries('term', 9.3, 12.4, 1), // the glossary: a bold term, then its definition
  entries('reference', 9.3, 12.4, 1.5, { spaceBetween: pt(2.4) }),
  entries('entry', 9, 11.6, 2), // the index, written by writeIndex()
];
```

## Ingredientes

**Enseña**

- [Geometría por sección](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado): Un estilo de título que cambia márgenes y columnas en su sección, como un prólogo a una columna en un libro a dos.
- [Bibliografías y glosarios](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo): Listas de referencias y glosarios en cuerpo menor con sangría francesa, un párrafo por entrada.
- [Capítulos sin número](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado): Un título de prólogo, apéndice o colofón que no lleva número ni altera la cuenta de capítulos.

**También usa**

- [Cabeceras por sección](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Estilos de título](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Títulos numerados](https://postext.dev/es/docs/configuration.md#configuración-por-nivel)
- [Capítulos que abren en página impar](https://postext.dev/es/docs/configuration.md#saltar-antes)
- [Banda de capítulo a todo el ancho](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [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 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)
- [Tipos de recurso propios](https://postext.dev/es/docs/configuration.md#tipos-de-recurso)
- [Citas que colocan las figuras](https://postext.dev/es/docs/document-format.md#referencia-en-línea-la-forma-principal)
- [Tablas a partir de datos](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita)
- [Estilo de los pies](https://postext.dev/es/docs/configuration.md#estilo-de-pies-de-recurso)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration.md#paleta-de-colores)
- [Negrita, cursiva y sus colores](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo)
- [Equilibrado de columnas](https://postext.dev/es/docs/configuration.md#equilibrado-de-columnas)
- [Exportación a PDF](https://postext.dev/es/docs/configuration.md#generación-de-pdf)
- [Recuadros](https://postext.dev/es/docs/configuration.md#estilos-de-aviso)
- [Figura y Tabla en tu idioma](https://postext.dev/es/docs/configuration.md#tipos-de-recurso)
- [Salir de la rejilla a propósito](https://postext.dev/es/docs/architecture.md#elementos-que-rompen-la-rejilla)
- [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), [`calloutStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-aviso), [`captionStyle`](https://postext.dev/es/docs/configuration.md#estilo-de-pies-de-recurso), [`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), [`orderedLists`](https://postext.dev/es/docs/configuration.md#listas-ordenadas), [`page`](https://postext.dev/es/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo), [`resourceTypes`](https://postext.dev/es/docs/configuration.md#tipos-de-recurso), [`tableStyle`](https://postext.dev/es/docs/configuration.md#estilo-de-tablas), [`unorderedLists`](https://postext.dev/es/docs/configuration.md#listas-no-ordenadas)

**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), [`defaultResourceTypes`](https://postext.dev/es/docs/configuration.md#tipos-de-recurso), [`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**

- Libertinus Serif (OFL-1.1), Libertinus Serif Display (OFL-1.1), Libertinus Sans (OFL-1.1)

## Elaboración

### 1 · Un estilo de título para cada parte del final

El código es [la respuesta corta](#la-respuesta-corta) de arriba. `# Glossary {style="glossary" note="…"}` abre una sección que llega hasta el siguiente título de nivel 1, y sus páginas toman la disposición, las cabeceras y la apertura del estilo ([estilos de encabezado](/es/docs/configuration#estilos-de-encabezado)), de modo que el glosario y el índice pasan a dos columnas y la bibliografía, cuyo estilo no fija disposición, vuelve a la columna única del documento. `numbered: false` deja estos títulos fuera de la cuenta de capítulos (sin él, en la banda del glosario saldría un 8), y cada estilo declara su salto de página, porque el que no lo declara hereda el `'odd'` del capítulo y deja páginas pares en blanco. Las entradas del glosario y de la bibliografía son párrafos de un bloque `:::paragraphs{style="…"}` en cuerpo 9,3 sobre 12,4 pt, frente al 11 sobre 14,6 del texto, con las líneas de continuación sangradas 1 em en el glosario y 1,5 em en la bibliografía ([estilos de párrafo](/es/docs/configuration#estilos-de-párrafo)).

### 2 · Una sola banda para todas las aperturas

```js
// script.js, líneas 55–79
const SINK = 8; // lines reserved, 41.2 mm: 3.2 mm more than the band, and text on the grid
// Design text sets each baseline 0.8 of its line under the line's top, and a line is 1.2 × the
// size unless lineHeight says otherwise. In mm, a line's part above its baseline and below it:
const PT = 25.4 / 72;
const above = (size, lineHeight = 1.2) => 0.8 * size * lineHeight * PT;
const below = (size, lineHeight = 1.2) => 0.2 * size * lineHeight * PT;
const KICKER = 4.3, TITLE = BAND - TOP - 9.5; // mm under the text block's top: two baselines
// A bottom-aligned box that ends below() under a baseline sets its last line on it. The
// numeral's line is 0.72 of its size: a line taller than its box would hang from its top.
const text = (id, content, family, size, lineHeight, baseline, edge, w, extra) => ({
  kind: 'text', id, content, fontFamily: family, fontSize: pt(size), lineHeight,
  color: col('paper'), overflow: 'wrap', align: edge.endsWith('right') ? 'right' : 'left',
  verticalAlign: 'bottom', ...extra, placement: { anchor: { to: 'container', edge },
    size: { width: mm(w), height: mm(baseline + below(size, lineHeight)) } } });
// The mark: '{number}', empty on an unnumbered heading, or the appendix's '{attr.letter}'.
const opener = (label, mark = '{number}') => ({ enabled: true, minHeight: pt(SINK * LEAD),
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('band') }, placement: {
      anchor: { to: 'page', edge: 'top-left' }, size: { width: 'fill', height: mm(BAND) } } },
    text('label', label, LABEL, 8, 1.2, KICKER, 'top-left', 80,
      { fontWeight: 700, letterSpacing: pt(1.6), textTransform: 'uppercase' }),
    text('title', '{titleText}', DISPLAY, 34, 1.04, TITLE, 'top-left', 84),
    text('mark', mark, DISPLAY, 118, 0.72, TITLE, 'top-right', 34),
    text('note', '{attr.note}', TEXT, 8.6, 1.3, TITLE, 'top-right', 44, { italic: true }),
  ] } });
```

La marca es `{number}`, que queda vacío en un título sin numerar, así que el mismo diseño sirve para el capítulo, para el apéndice, que pasa `{attr.letter}` como marca, y para las secciones finales, que imprimen su `{attr.note}` donde iría el numeral. `minHeight` reserva ocho líneas de 14,6 pt, 41,2 mm desde la cabeza de la mancha, que dejan 3,2 mm libres bajo la banda y mantienen el texto en su rejilla. En los textos de diseño, la línea base cae a 0,8 de la altura de línea, de modo que una caja alineada abajo cuyo pie queda a `below()` de una línea base asienta en ella su última línea. Así, el título, el numeral de 118 pt y la última línea de la nota se apoyan en la línea base del título, a 52,5 mm del corte. La línea del numeral mide 0,72 veces su cuerpo porque en la 1.4.1 una línea más alta que su caja ignora `verticalAlign: 'bottom'`.

### 3 · La letra del apéndice se pone a mano

```js
// script.js, líneas 109–113
// In 1.4.1 a heading style cannot change the numbering: the appendix is unnumbered, and its
// letter feeds the band (see answer), the running head and a table type that counts A.1.
const appendixHeads = heads('Appendix {attr.letter}. {chapterTitle}');
const appendixTables = { ...defaultResourceTypes(LANG).find((type) => type.id === 'table'),
  id: 'table-a', numberingTemplate: 'A.{n}' }; // a copy of 'table'
```

En postext 1.4.1 un estilo de título no puede cambiar la numeración de su nivel, así que el apéndice va sin numerar y su letra se escribe como atributo: `# Interview guide {style="appendix" letter="A"}`. La banda y la cabecera del apéndice la toman de ahí, y un tipo de recurso propio numera sus tablas A.1, A.2, etc.; con el tipo por defecto, la tabla sería la 6.2, porque un título sin numerar deja el contador de capítulos en 6. Los títulos del capítulo se numeran con las plantillas de cada nivel: `'{1}.{2}'` imprime 6.1, y `continuation.headings.h1: 5` hace de este el sexto capítulo.

### 4 · Las cabeceras, hacia el margen exterior

```js
// script.js, líneas 83–105
const HEAD = 17.5, GAP = 9; // mm: the heads' baseline under the trim; the folio to the words
// Each text is placed by its top, above() over HEAD: the folio and the capitals share a baseline.
const head = (id, content, parity, edge, x, size = 7.5, extra) => ({ kind: 'text', id, content,
  parity, pages: 'body', fontFamily: LABEL, fontSize: pt(size), fontWeight: 700,
  letterSpacing: pt(1.3), textTransform: 'uppercase', color: col('ink'), ...extra, placement: {
    anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD - above(size)) } } });
const folio = { fontFamily: TEXT, fontWeight: 400, letterSpacing: pt(0) };
const heads = (recto) => ({ elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, 9.5, folio),
  head('verso', '{title}', 'even', 'top-left', OUTER + GAP),
  head('recto', recto, 'odd', 'top-right', -(OUTER + GAP)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, 9.5, folio),
  // The header's container spans the text block, so one rule serves both pages.
  { kind: 'rule', id: 'hairline', pages: 'body', direction: 'horizontal', thickness: pt(0.5),
    color: col('rule'), placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { y: mm(HEAD + 2) }, size: { width: 'fill' } } },
] });
const chapterHeads = heads('Chapter {chapterNumber}. {chapterTitle}');
const sectionHeads = heads('{chapterTitle}'); // 'Glossary', 'References', 'Index'
// Openers drop the folio to the foot, centred under the text block, its baseline 12 mm below.
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
  pages: 'opener', ...folio, fontSize: pt(9.5), color: col('ink'), align: 'center',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(12 - above(9.5)) } } }] };
```

Los cuatro textos se anclan a la página y se filtran por `parity`, lo que mantiene el folio en el borde exterior de las dos páginas; cada uno se coloca por su borde superior, a `above()` por encima de una línea base situada a 17,5 mm del corte, así que el folio de 9,5 pt y las mayúsculas de 7,5 pt quedan en la misma línea. La par lleva el título de la tesis (`{title}`, del frontmatter) y la impar el de la sección (`{chapterTitle}`), como INDEX en la [página 177](https://postext.dev/cookbook/thesis-back-matter/en/p07.webp?v=1edadd62). `pages: 'body'` las deja fuera de las aperturas, donde el folio pasa al pie. La impar del capítulo diría *Chapter 6. Conclusion* y la del apéndice *Appendix A. Interview guide*, pero en esta muestra solo el índice sigue en una página impar después de su apertura.

### 5 · Los números del índice salen de las páginas

```js
// script.js, líneas 154–224
// The Markdown lists the entries in :::paragraphs{style="index-terms"}, one a line, as 'term:
// pattern' (a regular expression, in any case, from a word's start); two spaces: a sub-entry.
const TERMS = /^:::paragraphs\{style="index-terms"\}\n([\s\S]*?)\n:::$/m;
const KIND = { glossary: 'term', references: 'skip', index: 'skip' }; // other sections: 'text'
// Every searched line in one string (of the glossary, only the bold terms), with the page and
// kind of each character. A line ending in '-' runs into the next without it, a hard hyphen too
// ('meta-' + 'analyses' reads 'metaanalyses'), so every hyphen in a pattern is optional.
function pagesText(doc) {
  let text = ''; const at = [];
  const termOf = (line) => line.segments.filter((s) => s.bold).map((s) => s.text).join('');
  const read = (lines, page, kind) => (lines ?? []).forEach((line) => {
    const words = kind === 'term' ? termOf(line) : line.text; // the glossary: the term defined
    const part = words.endsWith('-') ? words.slice(0, -1) : `${words} `; // 'expos-' + 'itory'
    text += part;
    at.push(...Array(part.length).fill({ page, kind }));
  });
  let kind = 'text';
  for (const block of doc.blocks) {
    if (block.headingLevel === 1) kind = KIND[block.headingStyleId] ?? 'text'; // a new section
    if (kind !== 'skip') read(block.lines, doc.pages[block.pageIndex], kind);
  }
  for (const page of doc.pages) { // tables float: they hang on their page, not in doc.blocks
    page.floats?.flatMap((float) => float.resourceBlock?.table?.cells ?? [])
      .forEach((cell) => read(cell.lines, page, 'table'));
  }
  return { text, at };
}
// 'look-backs, *171*, 172, **174**': runs of text pages join (171–72, Chicago's short form); a
// page that names the term only in a table is set in italics, the glossary's page in bold.
const MARK = { text: '', table: '*', term: '**' };
function locators(pattern, { text, at }) {
  const pages = new Map(); // page number → 'text', 'table' or 'term'
  const start = new RegExp(`(?<![\\p{L}\\p{N}])(?:${pattern.replaceAll('-', '-?')})`, 'giu');
  for (const m of text.matchAll(start)) { // from the start of a word: 'índice' too
    const { page: { pageNumberValue: n }, kind } = at[m.index];
    if (pages.get(n) !== 'text') pages.set(n, kind); // the text outranks a table on its page
  }
  const runs = [];
  for (const [n, kind] of [...pages].sort(([a], [b]) => a - b)) {
    const run = runs.at(-1);
    if (run?.kind === 'text' && kind === 'text' && n === run.to + 1) run.to = n;
    else runs.push({ from: n, to: n, kind });
  }
  return runs.map(({ from, to, kind }) => {
    const last = from % 100 && Math.trunc(from / 100) === Math.trunc(to / 100) ? to % 100 : to;
    return `${MARK[kind]}${from === to ? from : `${from}–${last}`}${MARK[kind]}`;
  }).join(', ');
}
// Sorts the entries, heads each letter and adds the numbers (none on the first pass).
function writeIndex(markdown, found) {
  const tree = [];
  for (const line of TERMS.exec(markdown)[1].split('\n').filter((row) => row.trim())) {
    const [, indent, term, pattern] = /^( *)(.+?): (.+)$/.exec(line);
    (indent ? tree.at(-1).subs : tree).push({ term, pattern, subs: [] });
  }
  const byTerm = (a, b) => a.term.localeCompare(b.term, LANG);
  const entry = ({ term, pattern }, lead = '') => {
    const pages = found && locators(pattern, found);
    if (found && !pages) console.warn(`Index: no page mentions “${term}”`);
    return `${lead}${term}${pages ? `, ${pages}` : ''}`;
  };
  const out = []; let letter = '';
  for (const main of tree.sort(byTerm)) {
    const initial = main.term.normalize('NFD')[0].toUpperCase(); // 'Á' files under A
    if (initial !== letter) out.push(`### ${(letter = initial)}`); // an H3 among the entries
    // Two en spaces behind a zero-width space indent a sub-entry (gotcha: latin-subset): the
    // font lacks an em space, which a plain PDF line sets at no width; a bare space is trimmed.
    out.push(entry(main), ...main.subs.sort(byTerm).map((sub) => entry(sub, '\u200B\u2002\u2002')));
  }
  return markdown.replace(TERMS, () => `:::paragraphs{style="entry"}\n${out.join('\n\n')}\n:::`);
}
```

Postext no genera índices alfabéticos, pero el documento compuesto guarda cada línea con su texto y su página. El pen compone una vez con las entradas sin números, une las líneas en una sola cadena (del glosario, solo los términos en negrita; nada de la bibliografía ni del índice), busca en ella el patrón de cada entrada y vuelve a componer con los números escritos; el índice es la última sección y abre página, así que nada de lo que va antes se mueve entre las dos composiciones. Las celdas de tabla, leídas de `page.floats`, dan números en cursiva, y el glosario, en negrita, como avisa la nota del índice. Una línea que acaba en guion se une a la siguiente sin él, y cada guion de un patrón es opcional, porque una línea justificada que corta *meta-analyses* por su propio guion se marca como partida, igual que la que corta *expository* tras *expos-*.

### 6 · Todos los valores por defecto, en negro

```js
// script.js, líneas 15–19
const palette = { ink: '#000000', band: '#000000', rule: '#000000', paper: '#ffffff' };
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// Bold, italic and list markers default to 'main-color': pointed at the ink, they print black.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.ink })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
```

La negrita, la cursiva y las viñetas y números de las listas siguen por defecto la entrada `main-color` de la paleta, así que, si le das el color de la tinta, salen en negro y no en azul ([paleta de colores](/es/docs/configuration#paleta-de-colores)). En la 1.4.1 la paleta no llega a `bodyText.referenceColor`, y por eso la configuración lo repite: sin esa línea, *Table 6.1* sale en el texto en azul #295AA3. `referenceBold: false` pone la referencia en redonda, como las citas autor-año que la rodean.

> Tres cosas siguen haciéndose a mano: una lista de tablas, las remisiones a una página del texto (*véase p. 172*) y, en la 1.4.1, un contador con letras para los apéndices. El pen busca los términos del índice en las páginas 171 a 174, todo lo que precede a la bibliografía; en una tesis entera compuesta como un solo documento, los buscaría desde la primera página.

## 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/thesis-back-matter

### script.js

```js
// ═══ Postext Cookbook · Nº 031 · Thesis back matter: appendix, glossary and index ═══
// https://postext.dev/en/cookbook/thesis-back-matter
// Code: MIT · Text: original (CC BY 4.0) · Pictures: none
// Fonts: Libertinus Serif, Serif Display and Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, defaultResourceTypes,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the sample document ('en')
const RECIPE = 'thesis-back-matter';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: one ink; every colour is black or white, each under its own name
const palette = { ink: '#000000', band: '#000000', rule: '#000000', paper: '#ffffff' };
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// Bold, italic and list markers default to 'main-color': pointed at the ink, they print black.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.ink })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
const TEXT = 'Libertinus Serif', DISPLAY = 'Libertinus Serif Display', LABEL = 'Libertinus Sans';
const TOP = 24, INNER = 25, OUTER = 31; // mm: a 120 mm measure, about 70 characters at 11 pt
const LEAD = 14.6; // pt: the body's leading, the grid every page is set on
const BAND = 62; // mm from the trim's top: the black band at the head of every opener

// #region answer: back matter as unnumbered heading styles, entries in hanging indents
// '# Glossary {style="glossary"}' in the Markdown picks a style. Each style starts a page
// of either parity, the appendix a recto (a style that sets no break inherits its level's
// 'odd': gotcha style-inherits-break), stays out of the chapter count (numbered: false, so
// its band has no numeral) and brings its own running heads; the glossary and the index
// set their pages in two columns until the next '#'. config() takes both lists below.
const twoColumns = { layoutType: 'double', gutterWidth: mm(6) };
const backMatter = (id, extra) => ({ id, numbered: false, breakBefore: { enabled: true,
  parity: 'any' }, advancedDesign: opener('Back matter'), header: sectionHeads, ...extra });
const headingStyles = () => [
  backMatter('appendix', { breakBefore: { enabled: true, parity: 'odd' }, // {letter="A"}
    header: appendixHeads, advancedDesign: opener('Appendix', '{attr.letter}') }),
  backMatter('glossary', { layout: twoColumns }),
  backMatter('references'),
  backMatter('index', { layout: twoColumns }),
];
// One paragraph per entry, in :::paragraphs{style="…"}: the turnover lines hang, so the
// first word of every entry stands clear at the left. Ragged, as APA asks of references,
// and so never hyphenated (gotcha: ragged-no-hyphenation).
const entries = (id, size, lead, hang, extra) => ({ id, fontSize: pt(size),
  lineHeight: pt(lead), textAlign: 'left', hangingIndent: em(hang), ...extra });
const paragraphStyles = () => [
  entries('term', 9.3, 12.4, 1), // the glossary: a bold term, then its definition
  entries('reference', 9.3, 12.4, 1.5, { spaceBetween: pt(2.4) }),
  entries('entry', 9, 11.6, 2), // the index, written by writeIndex()
];
// #endregion

// #region opener: a black band across the head of the page, the title reversed out of it
const SINK = 8; // lines reserved, 41.2 mm: 3.2 mm more than the band, and text on the grid
// Design text sets each baseline 0.8 of its line under the line's top, and a line is 1.2 × the
// size unless lineHeight says otherwise. In mm, a line's part above its baseline and below it:
const PT = 25.4 / 72;
const above = (size, lineHeight = 1.2) => 0.8 * size * lineHeight * PT;
const below = (size, lineHeight = 1.2) => 0.2 * size * lineHeight * PT;
const KICKER = 4.3, TITLE = BAND - TOP - 9.5; // mm under the text block's top: two baselines
// A bottom-aligned box that ends below() under a baseline sets its last line on it. The
// numeral's line is 0.72 of its size: a line taller than its box would hang from its top.
const text = (id, content, family, size, lineHeight, baseline, edge, w, extra) => ({
  kind: 'text', id, content, fontFamily: family, fontSize: pt(size), lineHeight,
  color: col('paper'), overflow: 'wrap', align: edge.endsWith('right') ? 'right' : 'left',
  verticalAlign: 'bottom', ...extra, placement: { anchor: { to: 'container', edge },
    size: { width: mm(w), height: mm(baseline + below(size, lineHeight)) } } });
// The mark: '{number}', empty on an unnumbered heading, or the appendix's '{attr.letter}'.
const opener = (label, mark = '{number}') => ({ enabled: true, minHeight: pt(SINK * LEAD),
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('band') }, placement: {
      anchor: { to: 'page', edge: 'top-left' }, size: { width: 'fill', height: mm(BAND) } } },
    text('label', label, LABEL, 8, 1.2, KICKER, 'top-left', 80,
      { fontWeight: 700, letterSpacing: pt(1.6), textTransform: 'uppercase' }),
    text('title', '{titleText}', DISPLAY, 34, 1.04, TITLE, 'top-left', 84),
    text('mark', mark, DISPLAY, 118, 0.72, TITLE, 'top-right', 34),
    text('note', '{attr.note}', TEXT, 8.6, 1.3, TITLE, 'top-right', 44, { italic: true }),
  ] } });
// #endregion

// #region running-heads: the thesis on the verso, the section on the recto, a hairline under
const HEAD = 17.5, GAP = 9; // mm: the heads' baseline under the trim; the folio to the words
// Each text is placed by its top, above() over HEAD: the folio and the capitals share a baseline.
const head = (id, content, parity, edge, x, size = 7.5, extra) => ({ kind: 'text', id, content,
  parity, pages: 'body', fontFamily: LABEL, fontSize: pt(size), fontWeight: 700,
  letterSpacing: pt(1.3), textTransform: 'uppercase', color: col('ink'), ...extra, placement: {
    anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD - above(size)) } } });
const folio = { fontFamily: TEXT, fontWeight: 400, letterSpacing: pt(0) };
const heads = (recto) => ({ elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, 9.5, folio),
  head('verso', '{title}', 'even', 'top-left', OUTER + GAP),
  head('recto', recto, 'odd', 'top-right', -(OUTER + GAP)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, 9.5, folio),
  // The header's container spans the text block, so one rule serves both pages.
  { kind: 'rule', id: 'hairline', pages: 'body', direction: 'horizontal', thickness: pt(0.5),
    color: col('rule'), placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { y: mm(HEAD + 2) }, size: { width: 'fill' } } },
] });
const chapterHeads = heads('Chapter {chapterNumber}. {chapterTitle}');
const sectionHeads = heads('{chapterTitle}'); // 'Glossary', 'References', 'Index'
// Openers drop the folio to the foot, centred under the text block, its baseline 12 mm below.
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
  pages: 'opener', ...folio, fontSize: pt(9.5), color: col('ink'), align: 'center',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(12 - above(9.5)) } } }] };
// #endregion

// #region appendix: the letter comes from the heading, '# Interview guide {letter="A"}'
// In 1.4.1 a heading style cannot change the numbering: the appendix is unnumbered, and its
// letter feeds the band (see answer), the running head and a table type that counts A.1.
const appendixHeads = heads('Appendix {attr.letter}. {chapterTitle}');
const appendixTables = { ...defaultResourceTypes(LANG).find((type) => type.id === 'table'),
  id: 'table-a', numberingTemplate: 'A.{n}' }; // a copy of 'table'
// #endregion

const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity)
  colorPalette, header: chapterHeads, footer, layout: { layoutType: 'single' },
  page: { sizePreset: 'custom', width: mm(176), height: mm(250), dpi: 150, // B5
    margins: { top: mm(TOP), bottom: mm(24), left: mm(INNER), right: mm(OUTER), mirror: true } },
  bodyText: { fontFamily: TEXT, fontSize: pt(11), lineHeight: pt(LEAD), color: col('ink'),
    // 'Table 6.1' in roman and in ink, outside the palette's reach (gotcha: palette-skips-designs)
    referenceColor: col('ink'), referenceBold: false,
    firstLineIndent: mm(4.5), indentAfterHeading: false, minWordSpacing: 0.8, maxWordSpacing: 1.8 },
  // Exact heading margins (snapToGrid: false) keep the index's letters close to their entries,
  // and no lines are added above them; the chapter's heads measure whole grid lines.
  headings: { fontFamily: DISPLAY, fontWeight: 400, color: col('ink'), snapToGrid: false,
    balancing: { maxLinesPerHeading: 0 }, levels: [
      // The H1 break restated (gotcha: headings-drop-h1-break). span: 'page' (the styles inherit
      // it) sets the band above the columns: inside a column, its top would be clipped.
      { level: 1, numberingTemplate: '{1}', span: 'page', marginBottom: pt(0),
        advancedDesign: opener('Chapter'), breakBefore: { enabled: true, parity: 'odd' } },
      { level: 2, numberingTemplate: '{1}.{2}', fontSize: pt(14), lineHeight: pt(LEAD),
        marginTop: pt(LEAD * 1.5), marginBottom: pt(LEAD / 2) }, // three lines in all
      // The index's letters carry their space in their own line, so both columns start level.
      { level: 3, fontSize: pt(13), lineHeight: pt(21), marginTop: pt(0), marginBottom: pt(0) },
    ] },
  headingStyles: headingStyles(), paragraphStyles: paragraphStyles(),
  orderedLists: { marginTop: pt(LEAD / 2), marginBottom: pt(LEAD / 2) },
  unorderedLists: { bulletChar: '–' },
  resourceTypes: [...defaultResourceTypes(LANG), appendixTables], // tables 6.1… and A.1…
  // Captions in the text face, as APA sets a table's number and title.
  captionStyle: { fontSize: pt(9), position: 'above', gap: pt(4), note: { fontSize: pt(8) } },
  // Rules only and a bold header: filled header cells show seams between the columns.
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackgroundEnabled: false, headerFontSize: pt(9.5),
    bodyFontSize: pt(9.5), cellPadding: mm(1) },
  calloutStyles: [{ id: 'colophon', span: 'page', marginTop: pt(LEAD), backgroundEnabled: false,
    stripe: { enabled: true, side: 'top', width: pt(0.5), color: col('rule') },
    padding: { top: mm(2.5), right: mm(0), bottom: mm(0), left: mm(0) },
    body: { fontSize: pt(8), lineHeight: pt(10.5), firstLineIndent: pt(0), textAlign: 'left' } }],
});

// #region index: the index's page numbers, read off the laid-out pages
// The Markdown lists the entries in :::paragraphs{style="index-terms"}, one a line, as 'term:
// pattern' (a regular expression, in any case, from a word's start); two spaces: a sub-entry.
const TERMS = /^:::paragraphs\{style="index-terms"\}\n([\s\S]*?)\n:::$/m;
const KIND = { glossary: 'term', references: 'skip', index: 'skip' }; // other sections: 'text'
// Every searched line in one string (of the glossary, only the bold terms), with the page and
// kind of each character. A line ending in '-' runs into the next without it, a hard hyphen too
// ('meta-' + 'analyses' reads 'metaanalyses'), so every hyphen in a pattern is optional.
function pagesText(doc) {
  let text = ''; const at = [];
  const termOf = (line) => line.segments.filter((s) => s.bold).map((s) => s.text).join('');
  const read = (lines, page, kind) => (lines ?? []).forEach((line) => {
    const words = kind === 'term' ? termOf(line) : line.text; // the glossary: the term defined
    const part = words.endsWith('-') ? words.slice(0, -1) : `${words} `; // 'expos-' + 'itory'
    text += part;
    at.push(...Array(part.length).fill({ page, kind }));
  });
  let kind = 'text';
  for (const block of doc.blocks) {
    if (block.headingLevel === 1) kind = KIND[block.headingStyleId] ?? 'text'; // a new section
    if (kind !== 'skip') read(block.lines, doc.pages[block.pageIndex], kind);
  }
  for (const page of doc.pages) { // tables float: they hang on their page, not in doc.blocks
    page.floats?.flatMap((float) => float.resourceBlock?.table?.cells ?? [])
      .forEach((cell) => read(cell.lines, page, 'table'));
  }
  return { text, at };
}
// 'look-backs, *171*, 172, **174**': runs of text pages join (171–72, Chicago's short form); a
// page that names the term only in a table is set in italics, the glossary's page in bold.
const MARK = { text: '', table: '*', term: '**' };
function locators(pattern, { text, at }) {
  const pages = new Map(); // page number → 'text', 'table' or 'term'
  const start = new RegExp(`(?<![\\p{L}\\p{N}])(?:${pattern.replaceAll('-', '-?')})`, 'giu');
  for (const m of text.matchAll(start)) { // from the start of a word: 'índice' too
    const { page: { pageNumberValue: n }, kind } = at[m.index];
    if (pages.get(n) !== 'text') pages.set(n, kind); // the text outranks a table on its page
  }
  const runs = [];
  for (const [n, kind] of [...pages].sort(([a], [b]) => a - b)) {
    const run = runs.at(-1);
    if (run?.kind === 'text' && kind === 'text' && n === run.to + 1) run.to = n;
    else runs.push({ from: n, to: n, kind });
  }
  return runs.map(({ from, to, kind }) => {
    const last = from % 100 && Math.trunc(from / 100) === Math.trunc(to / 100) ? to % 100 : to;
    return `${MARK[kind]}${from === to ? from : `${from}–${last}`}${MARK[kind]}`;
  }).join(', ');
}
// Sorts the entries, heads each letter and adds the numbers (none on the first pass).
function writeIndex(markdown, found) {
  const tree = [];
  for (const line of TERMS.exec(markdown)[1].split('\n').filter((row) => row.trim())) {
    const [, indent, term, pattern] = /^( *)(.+?): (.+)$/.exec(line);
    (indent ? tree.at(-1).subs : tree).push({ term, pattern, subs: [] });
  }
  const byTerm = (a, b) => a.term.localeCompare(b.term, LANG);
  const entry = ({ term, pattern }, lead = '') => {
    const pages = found && locators(pattern, found);
    if (found && !pages) console.warn(`Index: no page mentions “${term}”`);
    return `${lead}${term}${pages ? `, ${pages}` : ''}`;
  };
  const out = []; let letter = '';
  for (const main of tree.sort(byTerm)) {
    const initial = main.term.normalize('NFD')[0].toUpperCase(); // 'Á' files under A
    if (initial !== letter) out.push(`### ${(letter = initial)}`); // an H3 among the entries
    // Two en spaces behind a zero-width space indent a sub-entry (gotcha: latin-subset): the
    // font lacks an em space, which a plain PDF line sets at no width; a bare space is trimmed.
    out.push(entry(main), ...main.subs.sort(byTerm).map((sub) => entry(sub, '\u200B\u2002\u2002')));
  }
  return markdown.replace(TERMS, () => `:::paragraphs{style="entry"}\n${out.join('\n\n')}\n:::`);
}
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Reading on Screens and Paper"
subtitle: "A Mixed-Methods Study of Comprehension, Confidence and Navigation"
author: "Ines Varley"
---

# Conclusion

This thesis set out to test whether it matters if a long text is read on paper or on a screen. Chapters 3 to 5 reported a within-subjects experiment with forty-eight undergraduates and interviews with sixteen of them, combined in the convergent design described by Creswell and Plano Clark (2018). This chapter brings the two strands together and sets out what they mean for teaching and for the design of reading software.

## What the study found

:ref{id="findings" style="full"} summarises the results. On literal questions, answerable from a single sentence, the medium made no difference. On inferential questions, which required connecting ideas across paragraphs, paper readers scored higher. The difference points the same way as the meta-analyses of Delgado et al. (2018) and Clinton (2019), which found the paper advantage in expository rather than narrative texts.

Calibration showed the larger difference. Screen readers predicted higher scores than paper readers and obtained lower ones, so the gap between confidence and accuracy was nearly three times as wide. The result repeats the overconfidence that Ackerman and Goldsmith (2011) found in students who read on screen and set their own study time. Such readers stop once they judge a text understood, so overconfidence cuts their study short.

Paper readers also turned back almost twice as often as screen readers scrolled back, most often just before an inferential question. In the interviews, eleven of the sixteen participants remembered where on a page an idea had been (“top left, next to the diagram”), and three gave up looking for a passage on the tablet because “it could have been anywhere”. Liu (2005) described a drift towards browsing and keyword spotting on screen; these readers went through the whole text but had fewer landmarks to return to.

## Implications for teaching and design

For short texts and factual questions, screens serve as well as paper. For long expository texts that students must understand rather than search, paper remains the safer choice. Where it is not available, students should test their understanding instead of trusting their sense of it: in the pilot sessions, a short self-test after reading halved the overconfidence on screen.

Readers also used the fixed position of text on a page as a map, one of the uses of paper that Sellen and Harper (2002) observed in offices. Reading applications that keep a stable page and show the reader’s place in the whole text may restore some of that map.

## Limitations and further work

The participants were students at one university who read English fluently, and the medium matters more for some readers, texts and tasks than it does for others (Singer & Alexander, 2017). The texts were expository and about 1,800 words long, and the screen condition used a single tablet. A replication with a larger sample, several devices and the eye-movement recording reviewed by Rayner (1998) would show where on the page the two media part company.

Huey (1908) thought that a complete analysis of what we do when we read would be almost the acme of a psychologist’s achievements. The experiments reported here add a small part to that analysis; the replication proposed above could measure how far readers rely on the position of a passage on the page when they look back.

# Interview guide {style="appendix" letter="A"}

The interviews took place within a week of each participant’s second session. They were audio-recorded, transcribed in full and analysed thematically following Braun and Clarke (2006); :ref{id="session-plan" style="full"} gives their timing. The questions were asked in this order, and a prompt only when the participant had not already covered its point.

1. Tell me about the last long text you read for a course.
   - Where did you read it, and on paper or on a screen?
2. Which of the texts in this study do you remember best, and why?
3. When you wanted to check an earlier passage, what did you do?
   - How did you know where to look?
4. How sure were you of your answers? What made you more or less sure?
5. Did reading on the tablet feel different from reading on paper?
6. Some students say they read more carefully on paper. Do you?
7. What would the ideal way to read a long text for study be like?

# Glossary {style="glossary" note="Words in italics are defined under entries of their own."}

:::paragraphs{style="term"}
**calibration** The agreement between a reader’s confidence in having understood a text and the accuracy of that understanding, measured here as the difference between predicted and actual scores.

**comprehension, inferential** Understanding that requires the reader to connect information from different parts of a text or to add knowledge the text does not state.

**comprehension, literal** Understanding of what a single sentence or passage states directly.

**confidence judgement** A reader’s estimate, made after reading and before seeing the questions, of how many answers will be correct.

**convergent design** A mixed-methods design in which quantitative and qualitative data are collected in the same period, analysed separately and then compared.

**expository text** A text written to explain or inform, such as a textbook chapter or a report, as opposed to a narrative text.

**fixation** A pause of the eyes, typically about a quarter of a second, during which the reader takes in text; fixations alternate with *saccades*.

**look-back** Any return to an earlier part of a text during reading: turning back a page, scrolling up or following a link to a previous section.

**metacomprehension** A reader’s knowledge and monitoring of their own understanding of a text; *calibration* is one of its measures.

**navigation** The movements a reader makes through a text as a whole, as distinct from the movements of the eyes along a line.

**overconfidence** Positive *calibration* bias: predicting a higher score than the one actually obtained.

**saccade** A rapid movement of the eyes from one *fixation* to the next, during which little or no text is taken in.

**screen inferiority effect** The finding that comprehension of the same text is lower on screen than on paper, most consistently for *expository texts* read under time pressure.

**self-regulated study** Reading in which the reader, not the experimenter, decides how long to spend on a text.

**spatial memory for text** Memory of where on a page or in a document a piece of information appeared, used as a cue for *look-backs*.

**thematic analysis** A method for identifying, analysing and reporting patterns of meaning across qualitative data such as interview transcripts.

**within-subjects design** An experimental design in which every participant takes part in every condition, here reading on both paper and screen.
:::

# References {style="references" note="Every work cited in the thesis, set in APA style (7th edition)."}

:::paragraphs{style="reference"}
Ackerman, R., & Goldsmith, M. (2011). Metacognitive regulation of text learning: On screen versus on paper. *Journal of Experimental Psychology: Applied, 17*(1), 18–32.

Baron, N. S. (2015). *Words onscreen: The fate of reading in a digital world.* Oxford University Press.

Braun, V., & Clarke, V. (2006). Using thematic analysis in psychology. *Qualitative Research in Psychology, 3*(2), 77–101.

Clinton, V. (2019). Reading from paper compared to screens: A systematic review and meta-analysis. *Journal of Research in Reading, 42*(2), 288–325.

Creswell, J. W., & Plano Clark, V. L. (2018). *Designing and conducting mixed methods research* (3rd ed.). SAGE.

Delgado, P., Vargas, C., Ackerman, R., & Salmerón, L. (2018). Don’t throw away your printed books: A meta-analysis on the effects of reading media on reading comprehension. *Educational Research Review, 25*, 23–38.

Dillon, A. (1992). Reading from paper versus screens: A critical review of the empirical literature. *Ergonomics, 35*(10), 1297–1326.

Huey, E. B. (1908). *The psychology and pedagogy of reading.* Macmillan.

Liu, Z. (2005). Reading behavior in the digital environment: Changes in reading behavior over the past ten years. *Journal of Documentation, 61*(6), 700–712.

Mangen, A., Walgermo, B. R., & Brønnick, K. (2013). Reading linear texts on paper versus computer screen: Effects on reading comprehension. *International Journal of Educational Research, 58*, 61–68.

Noyes, J. M., & Garland, K. J. (2008). Computer- vs. paper-based tasks: Are they equivalent? *Ergonomics, 51*(9), 1352–1375.

Paterson, D. G., & Tinker, M. A. (1940). *How to make type readable.* Harper & Brothers.

Rayner, K. (1998). Eye movements in reading and information processing: 20 years of research. *Psychological Bulletin, 124*(3), 372–422.

Sellen, A. J., & Harper, R. H. R. (2002). *The myth of the paperless office.* MIT Press.

Singer, L. M., & Alexander, P. A. (2017). Reading on paper and digitally: What the past decades of empirical research reveal. *Review of Educational Research, 87*(6), 1007–1041.

Tinker, M. A. (1963). *Legibility of print.* Iowa State University Press.

Wolf, M. (2018). *Reader, come home: The reading brain in a digital world.* Harper.
:::

# Index {style="index" note="Bold numbers refer to the glossary, italic numbers to tables."}

:::paragraphs{style="index-terms"}
accuracy: accura
Ackerman, Rakefet: Ackerman
Alexander, Patricia A.: Alexander
Braun, Virginia: Braun
browsing: brows
calibration: calibrat
  bias in: calibration bias|confidence and accuracy
  on screen: screen readers predicted|overconfidence on screen
Clarke, Victoria: Clarke
Clinton, Virginia: Clinton
comprehension: comprehen|understand
  inferential: inferential
  literal: literal
confidence: confiden|how sure
  judgements of: confidence judgement|predicted
convergent design: convergent
courses, reading for: course
Creswell, John W.: Creswell
debriefing: debrief
Delgado, Pablo: Delgado
design of reading software: design of reading|reading applications|ideal design
expository text: expository
eye movements: eye-movement|eyes
fixation: fixation
Goldsmith, Morris: Goldsmith
Harper, Richard H. R.: Harper
Huey, Edmund Burke: Huey
interviews: interview
  prompts in: prompt
  questions asked: questions were asked|Tell me about
  recording of: recorded|recorder
  timing of: timing|lasted
keyword spotting: keyword
landmarks: landmark|as a map
limitations: limitation|limits
Liu, Ziming: Liu
look-backs: look-back|turned back|scrolled back
meta-analyses: meta-analys
memory: remember
metacomprehension: metacomprehension
narrative text: narrative
navigation: navigat
offices, paper in: offices
overconfidence: overconfiden
paper advantage: paper advantage|safer choice
participants: participant
pilot sessions: pilot
Plano Clark, Vicki L.: Plano Clark
Rayner, Keith: Rayner
recall: recall
replication: replicat
saccade: saccade
screen inferiority effect: screen inferiority
scrolling: scroll
self-regulated study: self-regulated|set their own study
self-test: self-test
Sellen, Abigail J.: Sellen
Singer, Lauren M.: Singer
spatial memory: spatial memory|where on a page
tablet: tablet
teaching: teach
texts, length of: 1,800 words|long text
thematic analysis: thematic
transcription: transcri
undergraduates: undergraduate
university, single: one university
within-subjects design: within-subjects
:::

:::callout{type="colophon"}
Set in Libertinus Serif, Libertinus Serif Display and Libertinus Sans (SIL Open Font License). Text: original, CC BY 4.0. The thesis, its author, its participants and its results are fictional; the works in the references are real.
:::
`; // content.<lang>.md, inlined by the Cookbook

// A table from rows of 'cell|cell|cell'; aligns has a letter a column, l or r.
const table = (id, typeId, caption, note, widths, aligns, rows) => ({ id, typeId, kind: 'table',
  caption, note, createdAt: 0, updatedAt: 0, table: { model: { headerRowCount: 1,
    columnWidths: widths, rows: rows.map((row, r) => row.split('|').map((content, c) => ({
      content, isHeader: r === 0, align: aligns[c] === 'r' ? 'right' : 'left' }))) } } });
const resources = [
  table('findings', 'table', 'Main results by medium',
    'Means for 48 participants. Bias is the predicted minus the actual score.', [5, 1.4, 1.4],
    'lrr', ['Measure|Paper|Screen', 'Literal comprehension (of 10)|7.8|7.7',
      'Inferential comprehension (of 10)|6.4|5.6', 'Predicted score (%)|75|78',
      'Actual score (%)|71|66.5', 'Calibration bias (points)|+4.0|+11.5',
      'Look-backs per text|5.8|3.1']),
  table('session-plan', 'table-a', 'Timing of an interview session', undefined, [1, 6, 1.4],
    'llr', ['Part|Content|Minutes', '1|Welcome, consent and a check of the recorder|3',
      '2|Free recall of the two study texts|5',
      '3|Questions 1–4: reading habits, look-backs and confidence|12',
      '4|Questions 5–7: the two media and an ideal design|12', '5|Debrief|3']),
];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { 'Libertinus Serif': ['400', '400i', '700'], 'Libertinus Serif Display': ['400'],
  'Libertinus Sans': ['700'] }; // every face the pages use, loaded first (gotcha: fonts-first)

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// The thesis's sixth and last chapter opens on page 171, a recto.
const continuation = { pageIndexOffset: 170, pageNumbering: { startAt: 171 }, headings: { h1: 5 } };
const build = (source) => buildDocument({ markdown: source, resources, continuation }, config());
await loadFonts(FONTS, markdown);
// Two passes: the first lays the index out without numbers, the second writes them in. The
// index is the last section and opens a page, so no page before it moves between passes.
const draft = await buildWithFonts(() => build(writeIndex(markdown, null)), markdown);
const doc = build(writeIndex(markdown, pagesText(draft)));
showPages(doc, { title: 'Reading on Screens and Paper: the back matter' });
offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider }), `${RECIPE}.pdf`);

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

### Abre cada sección en página impar

Las tesis que se encuadernan para una biblioteca suelen abrir cada sección en página impar. El glosario, la bibliografía y el índice pasan entonces a las páginas 175, 177 y 179, cada una tras una par en blanco, y los números en negrita del índice apuntan a la 175.

```diff
-const backMatter = (id, extra) => ({ id, numbered: false, breakBefore: { enabled: true,
-  parity: 'any' }, advancedDesign: opener('Back matter'), header: sectionHeads, ...extra });
+const backMatter = (id, extra) => ({ id, numbered: false, breakBefore: { enabled: true,
+  parity: 'odd' }, advancedDesign: opener('Back matter'), header: sectionHeads, ...extra });
```

### Compón también el principio de la tesis

Los preliminares, numerados en romanos antes de la página 1, están en [Preliminares con folios romanos y luego la página 1](https://postext.dev/es/cookbook/front-matter-roman-to-arabic.md).

## Errores frecuentes

- **Un estilo de título hereda el salto de página de su nivel.** Una entrada de headingStyles toma de su nivel de título todo lo que no fija, también breakBefore. Un índice o un colofón con estilo sobre un H1 tras un :::pagebreak hereda la paridad 'odd' y cae detrás de una página en blanco. Dale a ese estilo breakBefore: { enabled: false }.
- **Cualquier objeto headings desactiva el salto de página del H1.** Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración.
- **Una paleta cambiada no llega a los elementos de diseño ni al color de las remisiones.** postext 1.4.1 aplica colorPalette a los estilos de texto (cuerpo, títulos, listas, pies, tablas, recuadros), pero no a los elementos de cabeceras, pies de página, aperturas y portadillas, ni a bodyText.referenceColor: conservan el hex escrito junto a su paletteId. Si cambias la paleta, para una edición de pantalla oscura o para recolorear, reescribe cada color enlazado a partir de colorPalette antes de componer.
- **En el texto en bandera no hay separación silábica.** La separación silábica solo se aplica al texto justificado; el texto en bandera corta entre palabras, así que una columna estrecha en bandera queda muy desigual. Justifica el pasaje o ensancha la medida.
- **Los archivos latin de Fontsource solo traen glifos del rango latino.** El proveedor del PDF incrusta los archivos latin de Fontsource, que cubren el español y las lenguas de Europa occidental pero no →, ≈, ✓, ★, el griego ni las letras de Europa central; esos glifos faltan en el PDF. Mantén el texto del PDF dentro del rango latin.
- **El PDF pide todos los pesos y estilos de cada familia.** renderToPdf pide al proveedor de fuentes la negrita, la cursiva y la negrita cursiva de cada familia que un bloque podría usar, aunque nunca se imprima, y un solo rechazo detiene la exportación. El proveedor debe ajustarse al peso más cercano que tenga la familia y volver a la redonda cuando no haya cursiva.
- **La página 1 es impar: planifica con números físicos.** La página 1 queda a la derecha y la 2 es la primera página par, así que planifica los pliegos con números de página físicos: una apertura en página par queda frente a la impar que la sigue.
- **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".
- **Una configuración se cachea por identidad: crea un objeto nuevo.** El motor guarda en caché las configuraciones resueltas según la identidad del objeto, así que modificar el mismo objeto y volver a componer reutiliza el resultado anterior. Crea un objeto nuevo en cada composición: por eso la configuración de una receta es una función, config().
- **Carga todas las fuentes antes de componer.** La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Tipografías: Libertinus Serif (OFL-1.1), Libertinus Serif Display (OFL-1.1), Libertinus Sans (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 006 · Preliminares con folios romanos y luego la página 1](https://postext.dev/es/cookbook/front-matter-roman-to-arabic.md): La cubierta y los preliminares son títulos sin numerar contados en romanos; :::numbering vuelve a contar desde 1 en la página impar donde empieza la novela. · Nivel 3 (Avanzado) · Narrativa, teatro y prosa literaria
- [N.º 020 · Notas finales a dos columnas en lugar de notas al pie](https://postext.dev/es/cookbook/endnotes-instead-of-footnotes.md): Un preprocesador breve pasa las notas al pie de Markdown a llamadas voladas y a una sección que un estilo de título compone en página propia, a dos columnas. · Nivel 2 (Intermedio) · Artículos y trabajos académicos
- [N.º 002 · Artículo a dos columnas con ecuaciones numeradas](https://postext.dev/es/cookbook/journal-article-with-maths.md): Artículo de física a dos columnas con siete ecuaciones numeradas, compuestas con el MathJax de la versión ?bundle. En el PDF siguen siendo vectoriales. · Nivel 3 (Avanzado) · Artículos y trabajos académicos
