# Un índex analític que segueix el text

> Termes marcats on el text els explica, impresos per :::index a dues columnes amb les pàgines que els dona buildBundle, intervals i remissions.

- Versió HTML: https://postext.dev/ca/cookbook/back-of-book-index
- Recepta Núm. 072 · Estructura del llibre · Nivell 3 (Avançat) · Sortides: Canvas, PDF
- Gèneres: Llibres de text
- Requereix postext ≥ 1.7.0, postext-pdf ≥ 1.7.0 · provada amb 1.7.0, postext-pdf 1.7.0 el 2026-09-28
- Pàgines: [297](https://postext.dev/cookbook/back-of-book-index/es/p01.webp?v=dbd4142c), [298](https://postext.dev/cookbook/back-of-book-index/es/p02.webp?v=dbd4142c), [299](https://postext.dev/cookbook/back-of-book-index/es/p03.webp?v=dbd4142c), [300](https://postext.dev/cookbook/back-of-book-index/es/p04.webp?v=dbd4142c), [301](https://postext.dev/cookbook/back-of-book-index/es/p05.webp?v=dbd4142c), [302](https://postext.dev/cookbook/back-of-book-index/es/p06.webp?v=dbd4142c), [303](https://postext.dev/cookbook/back-of-book-index/es/p07.webp?v=dbd4142c), [304](https://postext.dev/cookbook/back-of-book-index/es/p08.webp?v=dbd4142c), [305](https://postext.dev/cookbook/back-of-book-index/es/p09.webp?v=dbd4142c), [306](https://postext.dev/cookbook/back-of-book-index/es/p10.webp?v=dbd4142c)
- PDF: https://postext.dev/cookbook/back-of-book-index/es/back-of-book-index.pdf?v=dbd4142c
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=back-of-book-index&lang=es (.postext: https://postext.dev/cookbook/back-of-book-index/es/back-of-book-index.postext)
- Última actualització: 2026-09-28
- Altres idiomes: [en](https://postext.dev/en/cookbook/back-of-book-index.md), [es](https://postext.dev/es/cookbook/back-of-book-index.md), [zh](https://postext.dev/zh/cookbook/back-of-book-index.md), [ar](https://postext.dev/ar/cookbook/back-of-book-index.md)

## En poques paraules

Dos capítols d'un llibre de fisiologia amb un índex al final. Les paraules es marquen al text i el llibre troba sol els números de pàgina, sense escriure'ls a mà.

## Què compondràs

La part IV de *Principios de fisiología humana*, un manual en pàgina de 210 × 277 mm: el capítol 14, *El corazón como bomba*, i el 15, *Los vasos y la presión arterial*, s'obren en pàgina senar sota una banda vermella i porten els requadres clínics i els peus en una columna lateral exterior. La part es tanca amb un índex analític d'unes noranta entrades i trenta subentrades a dues columnes, com el compon un manual de medicina: entrades en minúscula, lletres de grup en el color d'accent, subentrades a un quadratí, la pàgina que defineix el terme en negreta, *véase* i *véase también* en cursiva. No s'ha teclejat cap número de pàgina. Cada terme està marcat al capítol, on el text l'explica, i l'índex llegeix les pàgines on han caigut aquestes marques, de la 297 a la 304.

**Aquesta recepta respon a:**

- Com faig un índex analític amb números de pàgina que segueixin el text quan aquest es mou?

## La resposta curta

```js
// script.js, línies 45–68
// The chapters mark each term where the text discusses it:
//   the :index[stroke volume]{main}         prints the words, files them, bold page
//   mitral:index{term="heart!valves!mitral"} prints nothing, files the word before it
//   ## Venous return :index{term="venous return" range="start" main}  … range="end"
//   :index{term="inotropy" see="contractility"}      a cross-reference, no page
// The last chapter is `# Index {style="index"}` and `:::index`. buildBundle hands it every
// chapter's marks with the pages they landed on, and lays the book out again until those
// pages stop moving.
const indexStyle = {
  id: 'index', numbered: false, toc: false, // not counted: no chapter 16
  layout: { layoutType: 'double', gutterWidth: mm(6) }, // two columns in this section only
  advancedDesign: opener(INDEX_BAND), // the chapters' band, shallower and with no number
  header: runningHeads(t({ en: 'Index', es: 'Índice analítico' })),
};
const index = {
  fontFamily: SERIF, fontSize: pt(8.6), lineHeight: pt(11.4),
  indent: em(1), turnoverIndent: em(2), // sub-entries step in 1 em; wrapped lines hang 2 em
  // English: Chicago's short ranges (301–3; 298–300 keeps the digit that changes). Spanish
  // writes both numbers in full and joins them with a hyphen (301-303).
  rangeFormat: t({ en: 'chicago', es: 'full' }), rangeSeparator: t({ en: '–', es: '-' }),
  main: { bold: true }, // the defining page in bold
  see: { italic: true }, // See / See also, Véase / Véase también by the document's locale
  groups: { ...label, fontSize: pt(9.5), letterSpacing: pt(0), color: col('accent') },
};
```

## Ingredients

**Ensenya**

- [Índex analític](https://postext.dev/ca/docs/document-format.md#índex-analític): Termes marcats al text amb :index i llistats per :::index en ordre alfabètic, amb subentrades, intervals, remissions i números de pàgina que segueixen el text.
- [Llibres construïts capítol a capítol](https://postext.dev/ca/docs/configuration.md#compondre-i-renderitzar-un-paquet): Capítols compostos un a un que continuen la numeració de pàgines, figures i capítols, la part oberta i la paritat del capítol anterior.
- [Estils de títol](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament): Variants amb nom d'un nivell de títol, triades amb {style="…"}: una altra obertura, tipografia, capçaleres o pàgina per capítol.

**També fa servir**

- [Obertures dissenyades](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Títols numerats](https://postext.dev/ca/docs/configuration.md#configuració-per-nivell)
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Capçaleres segons el tipus de pàgina](https://postext.dev/ca/docs/configuration.md#elements-de-text)
- [Marges simètrics](https://postext.dev/ca/docs/configuration.md#marges-simètrics-mirall)
- [Columna al marge per a flotants](https://postext.dev/ca/docs/configuration.md#disposició)
- [Notes al marge](https://postext.dev/ca/docs/configuration.md#estils-davís)
- [Peus laterals](https://postext.dev/ca/docs/document-format.md#collocació)
- [Capítols sense número](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Requadres flotants](https://postext.dev/ca/docs/configuration.md#el-contenidor-callout)
- [Requadres](https://postext.dev/ca/docs/configuration.md#estils-davís)
- [Peus numerats](https://postext.dev/ca/docs/document-format.md#numeració-per-primera-referència)
- [Exportació a PDF](https://postext.dev/ca/docs/configuration.md#generació-de-pdf)
- citations
- [Columna i mitja](https://postext.dev/ca/docs/configuration.md#tipus-de-disposició)
- [Figura i Taula en la teva llengua](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs)
- [Banda de capítol d'amplada completa](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Estils de paràgraf](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf)
- [Fonts incrustades al PDF](https://postext.dev/ca/docs/configuration.md#per-què-un-proveïdor-de-fonts)
- [Tipus de recurs propis](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs)
- [Figures i taules com a recursos](https://postext.dev/ca/docs/document-format.md#recursos)
- [Preliminars en romans](https://postext.dev/ca/docs/document-format.md#numbering)
- [Geometria per secció](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Capçaleres per secció](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Superíndexs i subíndexs](https://postext.dev/ca/docs/document-format.md#format-en-línia)

**La configuració d'un cop d'ull**

- [`bodyText`](https://postext.dev/ca/docs/configuration.md#text-de-cos), [`calloutStyles`](https://postext.dev/ca/docs/configuration.md#estils-davís), [`captionStyle`](https://postext.dev/ca/docs/configuration.md#estil-dels-peus-de-recurs), [`colorPalette`](https://postext.dev/ca/docs/configuration.md#paleta-de-colors), [`footer`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`header`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`headingStyles`](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament), [`headings`](https://postext.dev/ca/docs/configuration.md#encapçalaments), [`index`](https://postext.dev/ca/docs/configuration.md#índex-analític), [`layout`](https://postext.dev/ca/docs/configuration.md#disposició), [`locale`](https://postext.dev/ca/docs/configuration.md#partició-de-mots), [`page`](https://postext.dev/ca/docs/configuration.md#pàgina), [`paragraphStyles`](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf), [`resourceTypes`](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs), [`tableStyle`](https://postext.dev/ca/docs/configuration.md#estil-de-taules)

**API**

- [`buildBundle`](https://postext.dev/ca/docs/configuration.md#compondre-i-renderitzar-un-paquet), [`clearMeasurementCache`](https://postext.dev/ca/docs/configuration.md#memòria-cau-de-mesures), [`decompressWoff2`](https://postext.dev/ca/docs/configuration.md#proveïdor-de-fonts-al-navegador-fontsource--woff2), [`defaultResourceTypes`](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs), [`registerResourceImage`](https://postext.dev/ca/docs/architecture.md#superfície-dapi), [`renderPageToCanvas`](https://postext.dev/ca/docs/configuration.md#renderitzar-una-pàgina-a-un-bitmap), [`renderToPdf`](https://postext.dev/ca/docs/configuration.md#generació-de-pdf)

**Tipus de lletra**

- Literata (OFL-1.1), Libre Franklin (OFL-1.1)

## Elaboració

### 1 · Marca el passatge, no la paraula

```js
// script.js, línies 45–68
// The chapters mark each term where the text discusses it:
//   the :index[stroke volume]{main}         prints the words, files them, bold page
//   mitral:index{term="heart!valves!mitral"} prints nothing, files the word before it
//   ## Venous return :index{term="venous return" range="start" main}  … range="end"
//   :index{term="inotropy" see="contractility"}      a cross-reference, no page
// The last chapter is `# Index {style="index"}` and `:::index`. buildBundle hands it every
// chapter's marks with the pages they landed on, and lays the book out again until those
// pages stop moving.
const indexStyle = {
  id: 'index', numbered: false, toc: false, // not counted: no chapter 16
  layout: { layoutType: 'double', gutterWidth: mm(6) }, // two columns in this section only
  advancedDesign: opener(INDEX_BAND), // the chapters' band, shallower and with no number
  header: runningHeads(t({ en: 'Index', es: 'Índice analítico' })),
};
const index = {
  fontFamily: SERIF, fontSize: pt(8.6), lineHeight: pt(11.4),
  indent: em(1), turnoverIndent: em(2), // sub-entries step in 1 em; wrapped lines hang 2 em
  // English: Chicago's short ranges (301–3; 298–300 keeps the digit that changes). Spanish
  // writes both numbers in full and joins them with a hyphen (301-303).
  rangeFormat: t({ en: 'chicago', es: 'full' }), rangeSeparator: t({ en: '–', es: '-' }),
  main: { bold: true }, // the defining page in bold
  see: { italic: true }, // See / See also, Véase / Véase también by the document's locale
  groups: { ...label, fontSize: pt(9.5), letterSpacing: pt(0), color: col('accent') },
};
```

Qui fa un índex marca els llocs on s'explica un terme, no cada vegada que apareix la paraula. Els dos capítols diuen *volumen sistólico* una dotzena de vegades, i l'índex envia el lector a dos llocs: la pàgina 298, on es defineix, i la 299-300, on s'explica què el regula. Una marca visible, `:index[gasto cardíaco]{main}`, imprimeix les seves paraules i les arxiva. Una d'invisible, `mitral:index{term="corazón!válvulas!mitral"}`, no imprimeix res i pren la pàgina de la paraula anterior; així el passatge de les quatre vàlvules arxiva *mitral*, *tricúspide*, *aórtica* i *pulmonar* sota *corazón* sense tocar la frase. El `!` separa nivells: *corazón* en té tres, vàlvules i després cada vàlvula. Una marca funciona també en un títol (els intervals de 14.1 i 15.6 hi comencen) i en un requadre (*insuficiencia cardíaca* es defineix a la nota lateral de la pàgina 300).

### 2 · Pàgines principals, intervals i remissions

```js
// script.js, línies 45–68
// The chapters mark each term where the text discusses it:
//   the :index[stroke volume]{main}         prints the words, files them, bold page
//   mitral:index{term="heart!valves!mitral"} prints nothing, files the word before it
//   ## Venous return :index{term="venous return" range="start" main}  … range="end"
//   :index{term="inotropy" see="contractility"}      a cross-reference, no page
// The last chapter is `# Index {style="index"}` and `:::index`. buildBundle hands it every
// chapter's marks with the pages they landed on, and lays the book out again until those
// pages stop moving.
const indexStyle = {
  id: 'index', numbered: false, toc: false, // not counted: no chapter 16
  layout: { layoutType: 'double', gutterWidth: mm(6) }, // two columns in this section only
  advancedDesign: opener(INDEX_BAND), // the chapters' band, shallower and with no number
  header: runningHeads(t({ en: 'Index', es: 'Índice analítico' })),
};
const index = {
  fontFamily: SERIF, fontSize: pt(8.6), lineHeight: pt(11.4),
  indent: em(1), turnoverIndent: em(2), // sub-entries step in 1 em; wrapped lines hang 2 em
  // English: Chicago's short ranges (301–3; 298–300 keeps the digit that changes). Spanish
  // writes both numbers in full and joins them with a hyphen (301-303).
  rangeFormat: t({ en: 'chicago', es: 'full' }), rangeSeparator: t({ en: '–', es: '-' }),
  main: { bold: true }, // the defining page in bold
  see: { italic: true }, // See / See also, Véase / Véase también by the document's locale
  groups: { ...label, fontSize: pt(9.5), letterSpacing: pt(0), color: col('accent') },
};
```

`main` posa en negreta la pàgina que defineix un terme, de manera que qui cerca *precarga* va primer a la 299. `range="start"` i `range="end"`, amb el mateix terme, delimiten una explicació que travessa un salt de pàgina: *volumen sistólico, regulación, 299-300*. Un `main` a l'inici posa en negreta tot l'interval. Una pàgina marcada una altra vegada dins del seu propi interval s'hi fon, i un `main` en aquest punt posa l'interval en negreta. `see` substitueix els números de pàgina (*inotropismo. Véase contractilidad*) i `seealso` va darrere seu (*insuficiencia cardíaca, 300. Véase también fracción de eyección*). Una destinació amb nivells s'escriu amb `!` i s'imprimeix amb dos punts: *Véase presión arterial: media*.

### 3 · Un capítol d'índex a dues columnes

```js
// script.js, línies 45–68
// The chapters mark each term where the text discusses it:
//   the :index[stroke volume]{main}         prints the words, files them, bold page
//   mitral:index{term="heart!valves!mitral"} prints nothing, files the word before it
//   ## Venous return :index{term="venous return" range="start" main}  … range="end"
//   :index{term="inotropy" see="contractility"}      a cross-reference, no page
// The last chapter is `# Index {style="index"}` and `:::index`. buildBundle hands it every
// chapter's marks with the pages they landed on, and lays the book out again until those
// pages stop moving.
const indexStyle = {
  id: 'index', numbered: false, toc: false, // not counted: no chapter 16
  layout: { layoutType: 'double', gutterWidth: mm(6) }, // two columns in this section only
  advancedDesign: opener(INDEX_BAND), // the chapters' band, shallower and with no number
  header: runningHeads(t({ en: 'Index', es: 'Índice analítico' })),
};
const index = {
  fontFamily: SERIF, fontSize: pt(8.6), lineHeight: pt(11.4),
  indent: em(1), turnoverIndent: em(2), // sub-entries step in 1 em; wrapped lines hang 2 em
  // English: Chicago's short ranges (301–3; 298–300 keeps the digit that changes). Spanish
  // writes both numbers in full and joins them with a hyphen (301-303).
  rangeFormat: t({ en: 'chicago', es: 'full' }), rangeSeparator: t({ en: '–', es: '-' }),
  main: { bold: true }, // the defining page in bold
  see: { italic: true }, // See / See also, Véase / Véase también by the document's locale
  groups: { ...label, fontSize: pt(9.5), letterSpacing: pt(0), color: col('accent') },
};
```

L'índex és l'últim document Markdown del llibre: `# Índice analítico {style="index"}` i, a sota, `:::index`. L'estil de títol canvia la columna i mitja dels capítols per dues columnes separades 6 mm, només a la seva secció, i dona a l'índex la seva pròpia obertura i la seva capçalera. Es compon en la lletra del text a 8,6/11,4 pt, un quadratí per nivell de subentrada, i les línies que es parteixen sagnen dos quadratins, perquè mai no quedin alineades amb una subentrada. En castellà els intervals s'escriuen complets i amb guionet (*303-304*), com als llibres en castellà; l'edició anglesa fa servir `rangeFormat: 'chicago'`, que suprimeix les xifres que es repeteixen (*303–4*, però *299–300*).

### 4 · Per què els números segueixen el text

```js
// script.js, línies 370–378
const text = chapters.map((chapter) => chapter.markdown).join('\n');
await loadFonts(FONTS, text);
await loadSvg('pv-loop.svg', pvLoop());
const docs = await buildWithFonts(() => buildBundle({ chapters, config: config(), resources }),
  text);
showPages(docs, { title: t({ en: 'Principles of Human Physiology',
  es: 'Principios de fisiología humana' }) });
offerPdf(() => renderToPdf(docs, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),
  `${RECIPE}.pdf`);
```

`buildBundle` compon els capítols en ordre i veu que el tercer conté `:::index`. Després de la primera passada reuneix les marques de tots els capítols amb la pàgina on ha caigut cadascuna i les dona al capítol de l'índex com a esquema. Després torna a compondre el llibre, tres passades com a màxim, fins que cap número no es mou. Reescriu un paràgraf del capítol 14, canvia el format o el cos de lletra, i l'índex es reimprimeix amb les pàgines noves. Al PDF cada número de pàgina és un enllaç a la seva pàgina.

### 5 · Obertures i capçaleres

```js
// script.js, línies 72–91
function opener(band = BAND, kicker = '') {
  const onBand = { color: col('paper'), align: 'left', overflow: 'wrap' }; // wrap: never '…'
  const inBand = band - MARGIN.top - 8; // the title's box: it stands on a line 8 mm above the foot
  return { enabled: true, minHeight: mm(inBand + 8 + 31), slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('accent') },
      placement: { ...at('bleed', 'top-left', 0, 0), size: { height: mm(band) } } },
    { kind: 'text', id: 'kicker', content: kicker, ...label, ...onBand, fontSize: pt(8.5),
      placement: at('container', 'top-left', 0, -6) },
    { kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: SANS,
      fontWeight: 800, fontSize: pt(30), lineHeight: 1.05, // a multiple, never pt()
      verticalAlign: 'bottom', // one line or two, the title sits on the same line
      placement: { ...at('container', 'top-left', 0, 0),
        size: { width: mm(170), height: mm(inBand) } } },
    { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: SERIF, italic: true,
      fontSize: pt(10.5), lineHeight: 1.4, color: col('ink'), align: 'left', overflow: 'wrap',
      inlineMarks: true, // *See* in the index's lead
      placement: { ...at('container', 'top-left', 0, inBand + 15),
        size: { width: mm(118) } } },
  ] } };
}
```

Capítols i índex comparteixen una obertura: una banda des del tall fins a 62 mm (46 mm a l'índex), amb el títol recolzat al seu peu mitjançant una caixa alineada a baix, de manera que un títol de dues línies i un d'una acaben a la mateixa línia. L'avanttítol imprimeix `{chapterNumber}`, que l'atribut `{startAt=14}` del títol fixa en 14, i `page.pageNumbering.startAt: 297` col·loca la part on seria dins del llibre. L'estil de l'índex porta `numbered: false`, així que no es converteix en el capítol 16.

## La recepta completa

Un sol fitxer, compost a partir de la carpeta de la recepta amb el text d'exemple i el kit comú del Receptari ja inclosos; construeix la seva pròpia pàgina. Per executar-lo, posa'l en un `<script type="module">` d'una pàgina buida o enganxa'l al tauler JS d'un pen nou de CodePen (com a mòdul). Importa postext des d'esm.sh, així que no cal instal·lar ni compilar res.

- Carpeta de la recepta: https://github.com/drnachio/postext/tree/main/cookbook/back-of-book-index

### script.js

```js
// ═══ Postext Cookbook · Nº 072 · A textbook index that follows the text ═══════════════
// https://postext.dev/en/cookbook/back-of-book-index
// Code: MIT · Text: original (CC BY 4.0) · Drawing: generated in code (CC BY 4.0)
// Fonts: Literata, Libre Franklin (SIL OFL 1.1) · Needs postext ≥ 1.7.0
// Two chapters of a physiology textbook and the index that closes them, laid out by
// buildBundle as one book: the terms are marked where the text discusses them, and the
// index chapter prints them with the pages they land on.
import {
  buildBundle, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  defaultResourceTypes,
} 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 = 'back-of-book-index';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // every colour in the config links to one of these ids
  ink: '#1f1a1c', // text: a warm near-black
  accent: '#9e1b32', // the one accent (7.4:1 on paper): bands, letter heads, numbers, labels
  tint: '#f7ebe9', // the loop in Figure 14.1
  rule: '#d9c6c3', // hairlines
  muted: '#6b5f61', // running heads, notes, the colophon
  paper: '#ffffff', // type on the bands
};
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, so nothing prints blue.
  { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.accent, model: 'hex' } },
];
// A textbook trim, mirrored, in mm. The body is a column and a half: text in the main column,
// figures' captions and clinical notes in the outer channel.
const TRIM = { width: 210, height: 277 };
const MARGIN = { top: 24, bottom: 22, inner: 20, outer: 14 };
const LEAD = 13.6; // body leading in pt: the baseline grid
const [SERIF, SANS] = ['Literata', 'Libre Franklin'];
const [BAND, INDEX_BAND] = [62, 46]; // mm from the trim to the foot of the opener bands
const HEAD_Y = 13; // mm from the top trim to the running heads
const at = (to, edge, x, y) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const label = { fontFamily: SANS, fontSize: pt(7.5), fontWeight: 700, letterSpacing: pt(1.3),
  textTransform: 'uppercase' };

// #region answer: marks in the chapters, and an index chapter set in two columns
// The chapters mark each term where the text discusses it:
//   the :index[stroke volume]{main}         prints the words, files them, bold page
//   mitral:index{term="heart!valves!mitral"} prints nothing, files the word before it
//   ## Venous return :index{term="venous return" range="start" main}  … range="end"
//   :index{term="inotropy" see="contractility"}      a cross-reference, no page
// The last chapter is `# Index {style="index"}` and `:::index`. buildBundle hands it every
// chapter's marks with the pages they landed on, and lays the book out again until those
// pages stop moving.
const indexStyle = {
  id: 'index', numbered: false, toc: false, // not counted: no chapter 16
  layout: { layoutType: 'double', gutterWidth: mm(6) }, // two columns in this section only
  advancedDesign: opener(INDEX_BAND), // the chapters' band, shallower and with no number
  header: runningHeads(t({ en: 'Index', es: 'Índice analítico' })),
};
const index = {
  fontFamily: SERIF, fontSize: pt(8.6), lineHeight: pt(11.4),
  indent: em(1), turnoverIndent: em(2), // sub-entries step in 1 em; wrapped lines hang 2 em
  // English: Chicago's short ranges (301–3; 298–300 keeps the digit that changes). Spanish
  // writes both numbers in full and joins them with a hyphen (301-303).
  rangeFormat: t({ en: 'chicago', es: 'full' }), rangeSeparator: t({ en: '–', es: '-' }),
  main: { bold: true }, // the defining page in bold
  see: { italic: true }, // See / See also, Véase / Véase también by the document's locale
  groups: { ...label, fontSize: pt(9.5), letterSpacing: pt(0), color: col('accent') },
};
// #endregion

// #region opener: a band across the top of the page: kicker, title and lead from the heading
function opener(band = BAND, kicker = '') {
  const onBand = { color: col('paper'), align: 'left', overflow: 'wrap' }; // wrap: never '…'
  const inBand = band - MARGIN.top - 8; // the title's box: it stands on a line 8 mm above the foot
  return { enabled: true, minHeight: mm(inBand + 8 + 31), slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('accent') },
      placement: { ...at('bleed', 'top-left', 0, 0), size: { height: mm(band) } } },
    { kind: 'text', id: 'kicker', content: kicker, ...label, ...onBand, fontSize: pt(8.5),
      placement: at('container', 'top-left', 0, -6) },
    { kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: SANS,
      fontWeight: 800, fontSize: pt(30), lineHeight: 1.05, // a multiple, never pt()
      verticalAlign: 'bottom', // one line or two, the title sits on the same line
      placement: { ...at('container', 'top-left', 0, 0),
        size: { width: mm(170), height: mm(inBand) } } },
    { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: SERIF, italic: true,
      fontSize: pt(10.5), lineHeight: 1.4, color: col('ink'), align: 'left', overflow: 'wrap',
      inlineMarks: true, // *See* in the index's lead
      placement: { ...at('container', 'top-left', 0, inBand + 15),
        size: { width: mm(118) } } },
  ] } };
}
// #endregion

// #region running-heads: book title on the verso, chapter on the recto, folios outside
function runningHeads(recto) {
  const head = (id, content, parity, edge, x, extra = {}) => ({ kind: 'text', id, content,
    parity, pages: 'body', ...label, fontWeight: 600, color: col('muted'), // never on openers
    placement: at('page', edge, x, HEAD_Y), ...extra });
  const folio = { fontSize: pt(8.5), fontWeight: 700, letterSpacing: pt(0), color: col('accent') };
  const { outer } = MARGIN;
  return { elements: [
    head('verso-folio', '{pageNumber}', 'even', 'top-left', outer, folio),
    head('verso-title', '{title}', 'even', 'top-left', outer + 10),
    head('recto-title', recto, 'odd', 'top-right', -(outer + 10)),
    head('recto-folio', '{pageNumber}', 'odd', 'top-right', -outer, folio),
  ] };
}
const header = runningHeads(t({ en: 'Chapter {chapterNumber} · {chapterTitle}',
  es: 'Capítulo {chapterNumber} · {chapterTitle}' }));
// Openers carry a drop folio at the foot, on the outer edge.
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
  pages: 'opener', ...label, fontSize: pt(8.5), color: col('accent'), align: 'right',
  placement: { ...at('page', 'bottom-right', -MARGIN.outer, -12) } }] };
// #endregion

const note = { fontFamily: SANS, fontSize: pt(8), lineHeight: pt(11.3), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), textAlign: 'left', firstLineIndent: pt(0) };
const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-gb', es: 'es' }), // hyphenation and the index's sort order
  // Figura and Tabla in Spanish (gotcha: resource-types-locale); captions stand in the channel.
  resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type,
    defaultPlacement: { captionSide: true } })),
  colorPalette,
  page: { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
    pageNumbering: { startAt: 297 }, // this part of the book opens on page 297
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true } },
  layout: { layoutType: 'oneAndHalf', sideColumnPercent: 28, sideColumnRole: 'floats',
    sideColumnSide: 'outer', gutterWidth: mm(7) }, // main column 119.7 mm, channel 49.3 mm
  bodyText: { fontFamily: SERIF, fontSize: pt(9.6), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false },
  headings: { fontFamily: SANS, color: col('ink'), fontWeight: 700, levels: [
    // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' }, marginBottom: pt(0),
      numberingTemplate: '{1}', advancedDesign: opener(BAND,
        t({ en: 'Chapter {chapterNumber}', es: 'Capítulo {chapterNumber}' })) },
    { level: 2, fontSize: pt(12), lineHeight: pt(LEAD * 1.5), numberingTemplate: '{1}.{2}',
      color: col('accent'), marginTop: pt(LEAD), marginBottom: pt(0) },
  ] },
  headingStyles: [indexStyle],
  index,
  paragraphStyles: [{ id: 'formula', textAlign: 'center', firstLineIndent: pt(0), italic: true,
    marginTop: pt(LEAD * 0.5), marginBottom: pt(LEAD * 0.5) }],
  calloutStyles: [
    { id: 'clinical', span: 'side', backgroundEnabled: false,
      padding: { top: mm(2.4), right: pt(0), bottom: pt(0), left: pt(0) },
      stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('accent') },
      titleStyle: { ...label, color: col('accent'), gap: mm(1.6) }, body: note,
      marginTop: pt(0), marginBottom: pt(LEAD) },
    { id: 'colophon', span: 'page', placement: 'bottom', backgroundEnabled: false,
      padding: { top: mm(2), right: pt(0), bottom: pt(0), left: pt(0) },
      stripe: { enabled: true, side: 'top', width: pt(0.5), color: col('rule') },
      body: { ...note, fontSize: pt(7), lineHeight: pt(9.5), color: col('muted') } },
  ],
  captionStyle: { fontFamily: SANS, fontSize: pt(7.8), labelColor: col('accent'), gap: mm(2) },
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackground: col('ink'), headerColor: col('paper'), headerFontFamily: SANS,
    bodyFontFamily: SANS, bodyFontSize: pt(8.2), bodyColor: col('ink'), cellPadding: mm(1.4) },
  header, footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const heart = String.raw`---
title: "Principios de fisiología humana"
subtitle: "Parte IV · La circulación"
author: "Elena Marsh y Tomás Ibarra"
---

# El corazón como bomba {startAt=14 lead="Dos bombas musculares en serie mueven todo el volumen de sangre por el cuerpo aproximadamente una vez por minuto en reposo. Este capítulo sigue un latido desde la primera señal eléctrica hasta la última gota expulsada, y después se pregunta qué fija el tamaño de cada latido."}

El corazón humano son dos bombas reunidas en un solo órgano. El lado derecho recibe la sangre venosa del cuerpo y la impulsa a través de los pulmones; el izquierdo recibe la sangre oxigenada y la impulsa por la circulación sistémica. Como ambas bombas están conectadas en serie, en cualquier intervalo más largo que unos pocos latidos tienen que mover exactamente el mismo volumen de sangre. Lo que sigue se centra en el ventrículo izquierdo; el derecho hace el mismo trabajo contra una presión unas cinco veces menor.:index{term="ventrículo!derecho"}

## El ciclo cardíaco :index{term="ciclo cardíaco" range="start" main}

La secuencia de acontecimientos que va desde el comienzo de un latido hasta el comienzo del siguiente es el ciclo cardíaco. Con una frecuencia de 72 latidos por minuto, cada ciclo dura 0,83 s: la :index[sístole] ventricular (contracción) ocupa unos 0,3 s y la :index[diástole] (relajación), los 0,5 s restantes. Cuando la frecuencia aumenta, la diástole se acorta mucho más que la sístole: a 180 latidos por minuto el ciclo entero dura 0,33 s y queda menos de la mitad para el llenado.

El paso de la sangre por el corazón depende solo de diferencias de presión y de cuatro válvulas que se abren en un único sentido. Las válvulas mitral:index{term="corazón!válvulas!mitral"} y tricúspide:index{term="corazón!válvulas!tricúspide"} separan aurículas y ventrículos; la aórtica:index{term="corazón!válvulas!aórtica"} y la pulmonar:index{term="corazón!válvulas!pulmonar"} guardan la salida de los ventrículos. Una válvula se abre cuando la presión que tiene detrás supera a la que tiene delante, y se cierra cuando el gradiente se invierte. Conviene dividir el ciclo en cinco fases.

**Sístole auricular.**:index{term="ciclo cardíaco!sístole auricular"} La contracción de las aurículas empuja un último volumen de sangre hacia unos ventrículos que ya están casi llenos. En reposo esta contribución auricular:index{term="contribución auricular" see="ciclo cardíaco!sístole auricular"} aporta solo el 10-20 % del llenado ventricular; pesa más con frecuencias altas y en un ventrículo rígido, y su pérdida en la fibrilación auricular:index{term="fibrilación auricular"} se nota sobre todo con el esfuerzo.

**Contracción isovolumétrica.**:index{term="ciclo cardíaco!contracción isovolumétrica"} Al comenzar la contracción, la presión ventricular supera a la auricular, la válvula mitral se cierra y se oye el primer ruido cardíaco.:index{term="corazón!ruidos"} Durante unos 0,05 s las cuatro válvulas están cerradas: el ventrículo se contrae alrededor de un volumen fijo de sangre y su presión sube con rapidez.

**Eyección.**:index{term="ciclo cardíaco!eyección"} Cuando la presión del ventrículo izquierdo supera a la aórtica, unos 80 mmHg, la válvula aórtica se abre y la sangre sale del ventrículo, deprisa al principio y más despacio después. Las presiones ventricular y aórtica suben juntas hasta un máximo de unos 120 mmHg.

**Relajación isovolumétrica.**:index{term="ciclo cardíaco!relajación isovolumétrica"} Al relajarse el músculo, la presión ventricular cae por debajo de la aórtica; la válvula aórtica se cierra, se oye el segundo ruido y aparece en el trazado de la presión aórtica una breve muesca, la incisura.:index{term="incisura"}:index{term="corazón!ruidos"} El ventrículo vuelve a ser una cámara cerrada, que ahora se relaja a volumen constante.

**Llenado ventricular.**:index{term="ciclo cardíaco!llenado"} En cuanto la presión ventricular baja de la auricular, la válvula mitral se abre y entra de golpe la sangre que se ha acumulado en la aurícula durante la sístole. La mayor parte del llenado ocurre en este primer tercio de la diástole; el tercio medio añade poco, y la sístole auricular completa el proceso.

## El bucle presión-volumen

Si se representa la presión del ventrículo izquierdo frente a su volumen, el ciclo se convierte en una sola curva cerrada, el :index[bucle presión-volumen]{main} (:ref{id="pv-loop"}). El ventrículo termina la diástole con unos 120 mL, el :index[volumen telediastólico]{main}, y la sístole con unos 50 mL, el :index[volumen telesistólico]. La diferencia, unos 70 mL, es el :index[volumen sistólico]{main}. La fracción del volumen telediastólico que se expulsa, aquí 70/120, o un 58 %, es la :index[fracción de eyección]{main}; los valores entre el 55 y el 70 % son normales, y es la cifra que más se usa para describir cómo bombea un ventrículo.

El área que encierra el bucle es el trabajo externo que realiza el ventrículo en un latido, el :index[trabajo sistólico]. Cada lado del bucle corresponde a una fase del ciclo, y cada vértice a la apertura o el cierre de una válvula, de modo que un solo bucle recoge el latido entero.:index{term="ciclo cardíaco" range="end"}

## El gasto cardíaco

El volumen de sangre que bombea cada ventrículo por minuto es el :index[gasto cardíaco]{main}. Es el producto del volumen sistólico por la frecuencia cardíaca: 70 mL × 72 latidos por minuto dan unos 5 L/min en un adulto en reposo. Como el gasto crece con el tamaño corporal, suele dividirse por la superficie corporal;:index{term="superficie corporal"} el :index[índice cardíaco] resultante es de unos 3 L/min por metro cuadrado. En un ejercicio máximo, el gasto se multiplica por cuatro o cinco en un adulto joven no entrenado y llega a 30-35 L/min en un deportista de fondo,:index{term="deportistas de fondo"} cuyo corazón, más grande, lo consigue sobre todo con un volumen sistólico mayor.:index{term="ejercicio!gasto cardíaco"}

Todo cambio del gasto cardíaco es un cambio de la :index[frecuencia cardíaca], del volumen sistólico o de ambos.

## El nódulo sinoauricular y el sistema de conducción :index{term="corazón!sistema de conducción" range="start"}

El músculo cardíaco no necesita un nervio para contraerse. El latido nace en el :index[nódulo sinoauricular]{main}, una pequeña franja de músculo especializado en la pared de la aurícula derecha, junto a la desembocadura de la vena cava superior. Sus células no tienen un potencial de reposo estable: después de cada potencial de acción la membrana se despolariza despacio, en buena parte por la llamada :index[corriente funny], hasta alcanzar el umbral y disparar de nuevo. Aislado de toda influencia nerviosa, el nódulo descarga unas 100 veces por minuto.:index{term="marcapasos" see="nódulo sinoauricular"} La frecuencia de reposo, de 60-80 latidos por minuto, es menor porque el :index[nervio vago] libera sin cesar :index[acetilcolina] sobre el nódulo y frena esa despolarización.

Desde el nódulo, el impulso recorre las dos aurículas y converge en el :index[nódulo auriculoventricular]{main}, que conduce a solo unos 0,05 m/s. El retraso resultante, de alrededor de 0,1 s, deja que las aurículas terminen de vaciarse antes de que se contraigan los ventrículos. Pasado el nódulo, el impulso baja por el :index[haz de His] y sus ramas hasta las :index[fibras de Purkinje], que conducen hasta a 4 m/s y activan toda la masa ventricular en unos 0,08 s.

El nódulo sinoauricular marca el ritmo solo porque es el más rápido. Si falla, el nódulo auriculoventricular toma el relevo a 40-60 latidos por minuto, y las fibras de Purkinje, si la conducción por el nódulo está bloqueada, a 15-40.:index{term="bloqueo cardíaco"}:index{term="corazón!sistema de conducción" range="end"}

## La regulación del volumen sistólico

Tres factores deciden cuánta sangre expulsa el ventrículo en cada latido: el volumen que contiene antes de contraerse, la presión que debe vencer y la fuerza de su contracción. :index{term="volumen sistólico!regulación" range="start"}

**Precarga y mecanismo de Frank-Starling.** Cuanto más se llena un ventrículo durante la diástole, con más fuerza se contrae y más sangre expulsa. Esta propiedad intrínseca del músculo cardíaco es el :index[mecanismo de Frank-Starling]{term="Frank-Starling, mecanismo de" main}, por el fisiólogo alemán Otto Frank:index{term="Frank, Otto"} y el británico Ernest Starling,:index{term="Starling, Ernest"} que lo describieron en 1895 y 1914. Al estirarse, una fibra muscular cardíaca lleva sus :index[sarcómeros]{term="sarcómero"} hacia una longitud óptima de unos 2,2 µm, en la que son máximos el solapamiento de los filamentos de actina y miosina y su sensibilidad al calcio.:index{term="calcio!contracción"} El grado de estiramiento previo a la contracción es la :index[precarga]{main}, que suele medirse como el volumen telediastólico.

**Poscarga.** La :index[poscarga]{main seealso="presión arterial"} es la carga contra la que trabaja el ventrículo una vez empieza a expulsar sangre; para el ventrículo izquierdo es, en esencia, la presión aórtica. Si la poscarga sube de repente, la válvula aórtica se abre más tarde y se cierra antes, de modo que el ventrículo expulsa menos y se queda con un volumen telesistólico mayor. En los latidos siguientes el mecanismo de Frank-Starling recupera el volumen sistólico a costa de un corazón más grande.

:::callout{type="clinical" title="Insuficiencia cardíaca"}
En la :index[insuficiencia cardíaca]{main} el corazón no puede bombear la sangre que el organismo necesita con presiones de llenado normales. Cuando la fracción de eyección baja al 40 % o menos se habla de insuficiencia cardíaca con fracción de eyección reducida;:index{term="insuficiencia cardíaca!fracción de eyección reducida"} muchos pacientes, sin embargo, tienen una fracción de eyección normal y un ventrículo rígido que se llena mal.:index{term="insuficiencia cardíaca!fracción de eyección conservada"} En el lenguaje clínico se dice también fallo cardíaco.:index{term="fallo cardíaco" see="insuficiencia cardíaca"}:index{term="insuficiencia cardíaca" seealso="fracción de eyección"}
:::

**Contractilidad.** Un ventrículo también puede expulsar más sangre con el mismo volumen telediastólico si sus fibras se contraen con más fuerza. Esta propiedad, la :index[contractilidad]{main} o inotropismo,:index{term="inotropismo" see="contractilidad"} la eleva sobre todo el :index[sistema nervioso simpático]{term="sistema nervioso simpático!corazón"}. La :index[noradrenalina] liberada por los nervios simpáticos actúa sobre los :index[receptores adrenérgicos]{term="receptores adrenérgicos!beta-1"} beta-1 de las células musculares, aumenta la entrada de calcio en cada potencial de acción:index{term="calcio!contracción"} y hace cada contracción más fuerte y más breve. El mismo transmisor, al actuar sobre el nódulo sinoauricular, acelera la frecuencia, de modo que la estimulación simpática aumenta el gasto cardíaco por sus dos factores a la vez; en el ejercicio, los tres mecanismos actúan juntos.:index{term="norepinefrina" see="noradrenalina"}:index{term="volumen sistólico!regulación" range="end"}:index{term="ejercicio!volumen sistólico"}

## La auscultación del corazón :index{term="corazón!ruidos" range="start" main}

El cierre de las válvulas se oye a través de la pared torácica, y la auscultación con el :index[estetoscopio], que el médico francés René Laennec:index{term="Laennec, René"} introdujo en 1816, sigue siendo la primera exploración del corazón. El primer ruido, grave y relativamente largo, señala el cierre de las válvulas mitral y tricúspide al comienzo de la sístole; se oye mejor sobre la punta. El segundo, más corto y seco, señala el cierre de las válvulas aórtica y pulmonar al final de la eyección. En la inspiración, la caída de la presión intratorácica aumenta el retorno venoso al ventrículo derecho, que tarda un poco más en vaciarse, y la válvula pulmonar se cierra unas centésimas de segundo después que la aórtica: el segundo ruido se oye desdoblado.:index{term="corazón!ruidos!desdoblamiento del segundo ruido"}

Un tercer ruido al comienzo de la diástole se debe a la entrada rápida de sangre en el ventrículo. Es normal en niños y deportistas jóvenes, pero pasados los cuarenta años suele indicar un ventrículo dilatado e insuficiente.:index{term="insuficiencia cardíaca"} Un cuarto ruido, justo antes del primero, lo produce la sístole auricular contra un ventrículo rígido.:index{term="corazón!ruidos!tercero y cuarto"}

Entre los ruidos, el corazón está normalmente en silencio, porque la sangre atraviesa sin obstáculo las válvulas abiertas. Un :index[soplo]{term="soplos" seealso="flujo turbulento"} es el ruido de un flujo turbulento: a través de una válvula estrechada que aun así debe abrirse (estenosis) o de una válvula que no cierra (insuficiencia). El momento del ciclo en que aparece dice al explorador qué válvula falla; una estenosis aórtica, por ejemplo, produce un soplo durante la eyección, entre el primer y el segundo ruido.:index{term="corazón!ruidos" range="end"}:index{term="corazón!válvulas!estenosis e insuficiencia"}
`; // chapter 14, with the book's frontmatter (content.<lang>.md)
const vessels = String.raw`# Los vasos y la presión arterial {lead="El corazón pone la presión; los vasos deciden adónde va la sangre. Este capítulo explica cómo unas micras de cambio en el radio de las arterias pequeñas reparten el gasto cardíaco, y cómo el tronco del encéfalo mantiene estable la presión arterial de un latido al siguiente."}

La sangre sale del ventrículo izquierdo a impulsos y llega a los capilares como una corriente continua. Las propiedades de los vasos que hay entre ambos extremos explican casi todo lo que sigue: por qué la presión arterial tiene un valor sistólico y otro diastólico, por qué la presión cae tanto en las arterias más pequeñas y por qué la mayor parte de la sangre del cuerpo está en las venas.

## El árbol vascular

La :index[aorta] y sus grandes ramas son :index[arterias]{term="arterias!elásticas"} ricas en fibras elásticas. Se distienden al entrar cada volumen sistólico y se retraen durante la diástole, de modo que el flujo continúa entre latido y latido. Las :index[arterias]{term="arterias!musculares"} más pequeñas tienen más músculo liso y reparten la sangre entre los órganos. Las :index[arteriolas]{main}, de 10 a 100 µm de calibre, tienen las paredes más gruesas en proporción a su tamaño y son el lugar principal de resistencia al flujo: como muestra la :ref{id="pressures"}, la presión media cae en ellas de unos 85 a 35 mmHg. Al contraer o relajar su músculo liso, las arteriolas fijan a la vez la resistencia total de la circulación y la parte del gasto cardíaco que recibe cada órgano.

Los :index[capilares]{main} son tubos de una sola capa de células endoteliales,:index{term="endotelio"} de 5 a 8 µm de diámetro, lo justo para que pase un hematíe deformándose. Hay unos diez mil millones, con una superficie total de intercambio de 500 a 700 m², y la sangre los recorre a unos 0,3 mm/s, la milésima parte de su velocidad media en la aorta.:index{term="flujo sanguíneo!velocidad"} La sangre vuelve por las :index[vénulas] y las :index[venas]{main}, de paredes finas y fáciles de distender. En reposo, las venas sistémicas contienen alrededor del 64 % del volumen de sangre; por eso se las llama vasos de capacitancia.:index{term="vasos de capacitancia" see="venas"}

## Flujo, presión y resistencia

El flujo por cualquier vaso obedece una relación análoga a la :index[ley de Ohm]{term="Ohm, ley de"}: el flujo es igual a la diferencia de presión entre los dos extremos dividida por la resistencia, *Q* = (*P*~1~ − *P*~2~)/*R*. Aplicada a toda la circulación sistémica, con una presión arterial media de 93 mmHg, una presión en la aurícula derecha cercana a cero y un gasto cardíaco de 5 L/min, da una :index[resistencia periférica total]{term="resistencia vascular!periférica total"} de unos 19 mmHg·min/L.

¿De qué depende la resistencia de un vaso? Para el flujo estable y ordenado de un fluido por un tubo rígido, la respuesta es la :index[ley de Poiseuille]{term="Poiseuille, ley de" main}, que el médico francés Jean Poiseuille:index{term="Poiseuille, Jean"} dedujo en la década de 1840 de sus experimentos con agua en capilares de vidrio. La resistencia es proporcional a la longitud del tubo y a la :index[viscosidad] del fluido, e inversamente proporcional a la cuarta potencia del radio, *r*^4^. Si el radio de una arteriola se reduce a la mitad, su resistencia se multiplica por dieciséis; si aumenta solo un 19 %, el flujo que la atraviesa se duplica a la misma presión. Esa sensibilidad extrema es la que da a las arteriolas el control de la circulación.

La ley de Poiseuille supone un :index[flujo laminar], en el que la sangre avanza en capas concéntricas, más deprisa en el centro. Cuando la velocidad es alta, el vaso ancho o la pared irregular, las capas se rompen en remolinos. La tendencia a ese :index[flujo turbulento]{seealso="Reynolds, número de"} se expresa con el :index[número de Reynolds]{term="Reynolds, número de"}, el producto de la densidad de la sangre, su velocidad y el diámetro del vaso dividido por la viscosidad. Por encima de unos 2000 el flujo se vuelve turbulento, y la turbulencia se oye: un soplo:index{term="soplos"} sobre una válvula estrechada, un :index[soplo vascular] sobre una arteria estenosada y los :index[ruidos de Korotkoff]{term="Korotkoff, ruidos de"} que se escuchan al medir la presión con un manguito.

## La presión arterial :index{term="presión arterial" range="start"}

En un adulto joven y sano, la presión arterial sube a unos 120 mmHg con cada eyección, la :index[presión sistólica]{term="presión arterial!sistólica"}, y baja a unos 80 mmHg antes de la siguiente, la :index[presión diastólica]{term="presión arterial!diastólica"}. La diferencia, 40 mmHg, es la :index[presión diferencial]{term="presión arterial!diferencial"} o presión de pulso.:index{term="presión de pulso" see="presión arterial!diferencial"} Como en reposo la diástole dura aproximadamente el doble que la sístole, el promedio de la presión a lo largo del ciclo queda más cerca del valor diastólico. Se estima sumando a la presión diastólica un tercio de la diferencial:

:::paragraphs{style="formula"}
PAM = PAD + (PAS − PAD)/3 = 80 + 40/3 = 93 mmHg
:::

Esta :index[presión arterial media]{term="presión arterial!media" main} es la que impulsa la sangre por la circulación sistémica.:index{term="presión arterial media" see="presión arterial!media"}

La presión diferencial depende sobre todo de dos magnitudes: el volumen sistólico y la :index[distensibilidad arterial]{term="distensibilidad arterial" main}, el cambio de volumen arterial por unidad de cambio de presión. Las arterias elásticas almacenan cerca de la mitad de cada volumen sistólico durante la sístole y lo devuelven en la diástole, un mecanismo que se conoce como :index[efecto Windkessel]{term="Windkessel, efecto" main} por la cámara de aire de las bombas contra incendios del siglo XVIII, que convertía los golpes de la bomba en un chorro continuo. Con la edad la aorta se vuelve rígida,:index{term="envejecimiento!arterias"} la distensibilidad disminuye y el mismo volumen sistólico produce una presión sistólica más alta y una diastólica más baja.

:::callout{type="clinical" title="Hipertensión"}
Se diagnostica :index[hipertensión arterial]{main} cuando la presión medida en reposo en varias ocasiones es de 140/90 mmHg o más; las guías estadounidenses usan desde 2017 el umbral de 130/80 mmHg. En las personas mayores, una presión sistólica alta con una diastólica normal o baja, la hipertensión sistólica aislada, refleja la pérdida de distensibilidad de la aorta.:index{term="hipertensión arterial!sistólica aislada"}:index{term="envejecimiento!arterias"}
:::

## El intercambio capilar

El movimiento de líquido a través de la pared capilar depende del equilibrio entre la presión hidrostática, que lo empuja hacia fuera, y la :index[presión coloidosmótica]{main} de las proteínas del plasma, que lo retiene.:index{term="presión oncótica" see="presión coloidosmótica"} Estas :index[fuerzas de Starling]{term="capilares!fuerzas de Starling"} favorecen la filtración en el extremo arterial del capilar, donde la presión hidrostática:index{term="capilares!presión hidrostática"} es de unos 35 mmHg frente a una presión oncótica de 25 mmHg, y la reabsorción en el extremo venoso, donde la hidrostática ha bajado a unos 15 mmHg. De los 20 L que se filtran al día en los capilares sistémicos, unos 17 L vuelven directamente a la sangre; el resto lo devuelven los vasos :index[linfáticos]{term="linfa" main}. Cuando la filtración supera ese retorno, el líquido se acumula en los tejidos: es el edema.:index{term="edema"}:index{term="Starling, fuerzas de" see="capilares!fuerzas de Starling"}

## El reflejo barorreceptor :index{term="reflejo barorreceptor" range="start" main}

De un momento a otro, el reflejo barorreceptor mantiene la presión arterial a pocos milímetros de mercurio de su valor de referencia. Los :index[barorreceptores] son terminaciones nerviosas sensibles al estiramiento situadas en la pared del :index[seno carotídeo]{term="barorreceptores!seno carotídeo"}, en la bifurcación de cada carótida común, y en el :index[cayado aórtico]{term="barorreceptores!cayado aórtico"}. Su frecuencia de descarga sube con cada distensión sistólica. Las señales del seno carotídeo viajan por el :index[nervio glosofaríngeo] y las del cayado por el :index[nervio vago]{seealso="reflejo barorreceptor"}; ambas terminan en el :index[núcleo del tracto solitario] del bulbo raquídeo.:index{term="bulbo raquídeo"}

Una subida de la presión aumenta la descarga de los barorreceptores, lo que inhibe la actividad simpática sobre el corazón y los vasos y aumenta la vagal. Bajan la frecuencia y la contractilidad, las arteriolas se dilatan y la presión vuelve hacia su valor normal.:index{term="sistema nervioso simpático!vasos"} Una caída de la presión tiene los efectos contrarios. Los receptores carotídeos responden entre unos 60 y 180 mmHg y son más sensibles en torno a la presión arterial media normal. En uno o dos días, sin embargo, se reajustan a cualquier presión que se mantenga, así que el reflejo corrige los cambios rápidos pero no puede fijar por sí solo el nivel de la presión a largo plazo, que depende de cómo manejan los riñones la sal y el agua.:index{term="riñones!control de la presión a largo plazo"}:index{term="presión arterial!control a largo plazo"}:index{term="presión arterial" range="end"}

El reflejo se pone a prueba cada vez que una persona se levanta.:index{term="bipedestación, efectos circulatorios"} Unos 500 mL de sangre se estancan en las venas de las piernas, bajan el retorno venoso y el volumen sistólico, y la presión arterial empieza a caer; en pocos segundos el reflejo acelera el corazón y contrae las arteriolas. Cuando falla, como puede ocurrir en ancianos o en enfermedades de los nervios autónomos, ponerse de pie hace caer la presión sistólica 20 mmHg o más: es la :index[hipotensión ortostática]{main}, con mareo o desmayo.:index{term="desmayo" see="hipotensión ortostática"}:index{term="reflejo barorreceptor" range="end"}:index{term="retorno venoso"}

## El retorno venoso :index{term="retorno venoso" range="start" main}

Todo lo que el corazón expulsa tiene que volver antes a él. El flujo de sangre de las venas a la aurícula derecha, el retorno venoso, lo impulsa una diferencia de presión pequeña: unos 7 mmHg entre las vénulas y la aurícula derecha, donde la :index[presión venosa central] es casi nula. Como las venas son tan distensibles, un pequeño cambio de su tono desplaza un volumen de sangre grande. La constricción simpática de las venas puede llevar en segundos varios cientos de mililitros desde las venas periféricas hacia el corazón, lo que aumenta la precarga y, por el mecanismo de Frank-Starling, el volumen sistólico.:index{term="sistema nervioso simpático!vasos"}

Dos bombas ajenas al corazón ayudan. En las piernas, las venas profundas discurren entre los músculos y tienen válvulas de un solo sentido cada pocos centímetros.:index{term="venas!válvulas"} Cada contracción de la pantorrilla comprime las venas y empuja la sangre hacia arriba, y las válvulas impiden que vuelva a caer; esta :index[bomba muscular]{main} puede bajar la presión venosa en el tobillo de una persona que camina de unos 90 mmHg a 25 mmHg o menos. Cuando las válvulas fallan, las venas de las piernas se dilatan y se vuelven tortuosas: son las :index[varices].:index{term="venas!varicosas" see="varices"} En el tórax, cada inspiración baja la presión intratorácica y sube la abdominal, lo que aspira sangre de las venas del abdomen hacia el tórax: es la :index[bomba respiratoria].:index{term="retorno venoso" range="end"}:index{term="gasto cardíaco!retorno venoso"}
`; // chapter 15 (content.vessels.<lang>.md)
const indexChapter = String.raw`# Índice analítico {style="index" lead="Los números en negrita remiten a la explicación principal de un término. Un intervalo como 301-303 indica que la explicación ocupa esas páginas. *Véase* lleva a la entrada que reúne los números de página; *véase también*, a una entrada relacionada."}

:::index

:::callout{type="colophon"}
*Principios de fisiología humana*, parte IV, capítulos 14 y 15, una muestra compuesta con Postext. Tipografías: Literata y Libre Franklin (SIL OFL 1.1). Texto y dibujo: originales, CC BY 4.0. El libro y sus autores son ficticios.
:::
`; // # Index and :::index (content.index.<lang>.md)
const chapters = [heart, vessels, indexChapter].map((markdown) => ({ markdown }));

// #region art: the pressure–volume loop of Figure 14.1, drawn in code
// No text in the drawing: an SVG image cannot use the page's web fonts (gotcha: svg-no-webfonts).
const PX = 10; // SVG pixels per unit
const [W, H] = [100, 52];
const vx = (volume) => 12 + volume * 0.56; // 0–150 mL across
const py = (pressure) => 47 - pressure * 0.3; // 0–140 mmHg up
const pv = (v, p) => `${vx(v).toFixed(1)} ${py(p).toFixed(1)}`;
function pvLoop() {
  const stroke = (id, w, extra = '') => `fill="none" stroke="${palette[id]}" stroke-width="${w}"
    ${extra}`;
  const loop = `M${pv(50, 6)}Q${pv(88, 2)} ${pv(120, 10)}L${pv(120, 80)}`
    + `C${pv(108, 128)} ${pv(70, 132)} ${pv(50, 100)}Z`;
  const arrow = (x, y, dx, dy) => `<path d="M${x} ${y}l${dx} ${dy}" ${stroke('ink', 0.5)}/>`
    + `<path d="M${x + dx} ${y + dy}${dx ? 'l-2.6 -1.2v2.4z' : 'l-1.2 2.6h2.4z'}"`
    + ` fill="${palette.ink}"/>`;
  const guide = (v, p) => `<path d="M${pv(v, p)}V${py(0)}" ${stroke('muted', 0.35,
    'stroke-dasharray="1.2 1"')}/>`;
  const dot = (v, p) => `<circle cx="${vx(v)}" cy="${py(p)}" r="1.5" fill="${palette.ink}"/>`;
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W * PX}" height="${H * PX}" `
    + `viewBox="0 0 ${W} ${H}"><path d="${loop}" fill="${palette.tint}"/>`
    + `<path d="M${pv(10, 0)}L${pv(62, 130)}" `
    + `${stroke('muted', 0.45, 'stroke-dasharray="2 1.4"')}/>`
    + guide(50, 6) + guide(120, 10)
    + `<path d="${loop}" ${stroke('accent', 1.1, 'stroke-linejoin="round"')}/>`
    + [[50, 6], [120, 10], [120, 80], [50, 100]].map(([v, p]) => dot(v, p)).join('')
    + arrow(vx(0), py(0), W - 16, 0) + arrow(vx(0), py(0), 0, -(py(0) - 3)) + '</svg>';
}
// #endregion

const TABLE = t({
  en: [['Segment', 'Blood volume (%)', 'Mean pressure (mmHg)'],
    ['Arteries', '13', '100 to 85'], ['Arterioles', '7', '85 to 35'],
    ['Capillaries', '', '35 to 15'],
    ['Venules and veins', '64', '15 to 0'], ['Heart', '7', '–'],
    ['Pulmonary circulation', '9', '15 to 8']],
  es: [['Segmento', 'Volumen de sangre (%)', 'Presión media (mmHg)'],
    ['Arterias', '13', '100 a 85'], ['Arteriolas', '7', '85 a 35'], ['Capilares', '', '35 a 15'],
    ['Vénulas y venas', '64', '15 a 0'], ['Corazón', '7', '–'],
    ['Circulación pulmonar', '9', '15 a 8']],
});
// Arterioles and capillaries share one volume cell: the cell under a rowSpan stays in the row,
// marked hiddenBy (gotcha: merged-cells-hiddenby).
const rows = TABLE.map((row, r) => row.map((content, c) => ({ content,
  ...(r === 0 && { isHeader: true }), ...(r === 2 && c === 1 && { rowSpan: 2 }),
  ...(r === 3 && c === 1 && { hiddenBy: { row: 2, col: 1 } }) })));
const resources = [
  { id: 'pv-loop', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
    svg: { fileId: 'pv-loop.svg', width: W * PX, height: H * PX },
    caption: t({
      en: 'Pressure–volume loop of the left ventricle at rest: volume from 0 to 150 mL across, '
        + 'pressure from 0 to 140 mmHg up. The dots, anticlockwise from bottom right: mitral '
        + 'valve closes (120 mL), aortic valve opens (80 mmHg), aortic valve closes, mitral '
        + 'valve opens (50 mL). Dashed: the end-systolic pressure–volume relation.',
      es: 'Bucle presión-volumen del ventrículo izquierdo en reposo: volumen de 0 a 150 mL en '
        + 'horizontal, presión de 0 a 140 mmHg en vertical. Los puntos, en sentido antihorario '
        + 'desde abajo a la derecha: cierre de la válvula mitral (120 mL), apertura de la aórtica '
        + '(80 mmHg), cierre de la aórtica y apertura de la mitral (50 mL). A trazos, la relación '
        + 'presión-volumen telesistólica.' }),
    altText: t({ en: 'A closed loop of ventricular pressure against volume.',
      es: 'Un bucle cerrado de presión ventricular frente a volumen.' }) },
  { id: 'pressures', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
    caption: t({ en: 'Blood volume and mean pressure along the circulation of a resting adult.',
      es: 'Volumen de sangre y presión media a lo largo de la circulación de un adulto en '
        + 'reposo.' }),
    table: { model: { headerRowCount: 1, columnWidths: [2.2, 1.4, 1.6], rows } } },
];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the design uses, loaded before the first build (gotcha: fonts-first).
const FONTS = {
  Literata: ['400', '400i', '700', '700i'],
  'Libre Franklin': ['400', '600', '700', '800'],
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region build: one book of three documents; one PDF whose index numbers are links
const text = chapters.map((chapter) => chapter.markdown).join('\n');
await loadFonts(FONTS, text);
await loadSvg('pv-loop.svg', pvLoop());
const docs = await buildWithFonts(() => buildBundle({ chapters, config: config(), resources }),
  text);
showPages(docs, { title: t({ en: 'Principles of Human Physiology',
  es: 'Principios de fisiología humana' }) });
offerPdf(() => renderToPdf(docs, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),
  `${RECIPE}.pdf`);
// #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 · images v1 ── recipes with pictures · postext.dev/cookbook ──────────
/** Registers a photo or PNG for the canvas and keeps its bytes for the PDF.
 *  fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */
async function loadImage(fileId, url) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`);
  const bytes = new Uint8Array(await res.arrayBuffer());
  registerResourceImage(fileId, await createImageBitmap(new Blob([bytes])));
  (loadImage.bytes ??= new Map()).set(fileId, bytes);
}

/** Registers SVG markup (drawn in code, or fetched) as a vector image. */
async function loadSvg(fileId, svg) {
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** renderToPdf({ resourceBytes: imageBytes }) */
function imageBytes(fileId) { return loadImage.bytes?.get(fileId); }

/** renderToHtml({ resourceImageUrl: imageUrl }) */
function imageUrl(fileId) {
  const bytes = imageBytes(fileId);
  if (!bytes) return undefined;
  imageUrl.urls ??= new Map();
  if (!imageUrl.urls.has(fileId)) {
    const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg';
    imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type })));
  }
  return imageUrl.urls.get(fileId);
}

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

## Variants

### Escriu tots els intervals complets

Alguns llibres anglesos també escriuen complets els dos números d'un interval.

```diff
-  rangeFormat: t({ en: 'chicago', es: 'full' }), rangeSeparator: t({ en: '–', es: '-' }),
+  rangeFormat: 'full', rangeSeparator: t({ en: '–', es: '-' }),
```

### Un índex sense lletres de grup

Un índex curt sol encadenar els grups, separats només per una línia en blanc.

```diff
-  groups: { ...label, fontSize: pt(9.5), letterSpacing: pt(0), color: col('accent') },
+  groups: { enabled: false },
```

## Errors freqüents

- **Qualsevol objecte headings desactiva el salt de pàgina de l'H1.** Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració.
- **Tradueix Figura i Taula amb defaultResourceTypes(locale).** El locale de la configuració fixa la partició de mots, no els peus: sense resourceTypes, els tipus de sèrie diuen Figure i Table en anglès. Passa resourceTypes: defaultResourceTypes('es') per al castellà; per a qualsevol altra llengua, escriu tu mateix els noms a resourceTypes.
- **El text dins d'un SVG <img> no pot fer servir fonts web.** Un SVG es dibuixa com a imatge, i una imatge no té accés a les fonts web de la pàgina, de manera que els seus rètols surten amb una font del sistema. Converteix el text en traçats, incrusta un subconjunt @font-face a l'SVG o porta els rètols al peu.
- **Les cel·les combinades necessiten hiddenBy: fes servir mergeCells.** Les cel·les es col·loquen segons la seva posició a la fila, així que una cel·la combinada necessita cel·les de farciment marcades amb hiddenBy on s'estén; ometre-les, com en HTML, desplaça totes les columnes següents. Combina cel·les amb mergeCells.
- **Carrega totes les fonts abans de compondre.** La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard.

- La captura d'aquesta recepta no avisa de `indexSeeUnknown`: una remissió amb una destinació escrita d'una altra manera que l'entrada (*número de Reynolds* davant de *Reynolds, número de*) s'imprimeix sense queixa. Confronta cada *Véase* de l'índex acabat amb la seva destinació.
- Una llista de subentrades que es parteix entre columnes no repeteix la seva entrada principal en una línia *corazón (cont.)*; una entrada sense pàgina pròpia va sempre amb la seva primera subentrada, així que un encapçalament mai no tanca una columna tot sol.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Tipus de lletra: Literata (OFL-1.1), Libre Franklin (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 094 · Un llibre tècnic amb remissions entre capítols](https://postext.dev/ca/cookbook/technical-book-crossref-chapters.md): Un manual solar de tres capítols on el text escriu @fig:sun, @tbl:loads i @sec:array-size, i buildBundle imprimeix cada número i pàgina entre capítols. · Nivell 3 (Avançat) · Manuals, guies i obres de consulta, Llibres de text
- [Núm. 098 · Una obra col·lectiva amb bibliografia a cada capítol](https://postext.dev/ca/cookbook/edited-volume-chapter-bibliographies.md): Tres assajos de tres autors, cadascun un document propi a buildBundle, i cadascun tancat per una llista Chicago autor-data amb només les obres que cita. · Nivell 3 (Avançat) · Articles i treballs acadèmics, Llibres de text
- [Núm. 001 · Llibre de text amb columna al marge](https://postext.dev/ca/cookbook/textbook-margin-column.md): Columna i mitja amb la columna exterior només per a flotants: s'hi apilen figures i glosses amb span 'side', i captionSide hi porta la resta de peus. · Nivell 3 (Avançat) · Llibres de text
