# Un index de manuel qui suit le texte

> Des termes marqués là où le texte en traite, imprimés par :::index sur deux colonnes avec les pages que trouve buildBundle, des étendues et des renvois.

- Version HTML: https://postext.dev/fr/cookbook/back-of-book-index
- Recette Nº 072 · Structure du livre · Niveau 3 (Avancé) · Sorties: Canvas, PDF
- Genres: Manuels scolaires
- Nécessite postext ≥ 1.28.0, postext-pdf ≥ 1.28.0 · testée avec 1.31.0, postext-pdf 1.31.0 le 2026-10-13
- Pages: [297](https://postext.dev/cookbook/back-of-book-index/en/p01.webp?v=26c72515), [298](https://postext.dev/cookbook/back-of-book-index/en/p02.webp?v=26c72515), [299](https://postext.dev/cookbook/back-of-book-index/en/p03.webp?v=26c72515), [300](https://postext.dev/cookbook/back-of-book-index/en/p04.webp?v=26c72515), [301](https://postext.dev/cookbook/back-of-book-index/en/p05.webp?v=26c72515), [302](https://postext.dev/cookbook/back-of-book-index/en/p06.webp?v=26c72515), [303](https://postext.dev/cookbook/back-of-book-index/en/p07.webp?v=26c72515), [304](https://postext.dev/cookbook/back-of-book-index/en/p08.webp?v=26c72515), [305](https://postext.dev/cookbook/back-of-book-index/en/p09.webp?v=26c72515), [306](https://postext.dev/cookbook/back-of-book-index/en/p10.webp?v=26c72515)
- PDF: https://postext.dev/cookbook/back-of-book-index/en/back-of-book-index.pdf?v=26c72515
- Ouvrir dans le Sandbox: https://postext.dev/fr/sandbox#recipe=back-of-book-index&lang=en (.postext: https://postext.dev/cookbook/back-of-book-index/en/back-of-book-index.postext)
- Dernière mise à jour: 2026-10-11
- Autres langues: [en](https://postext.dev/en/cookbook/back-of-book-index.md), [es](https://postext.dev/es/cookbook/back-of-book-index.md), [ca](https://postext.dev/ca/cookbook/back-of-book-index.md), [pt](https://postext.dev/pt/cookbook/back-of-book-index.md), [zh](https://postext.dev/zh/cookbook/back-of-book-index.md), [ja](https://postext.dev/ja/cookbook/back-of-book-index.md), [ar](https://postext.dev/ar/cookbook/back-of-book-index.md)

## En bref

Deux chapitres d’un manuel de physiologie avec un index à la fin. Les mots sont marqués dans le texte, et le livre trouve leurs numéros de page tout seul : aucun n’est saisi à la main.

## Ce que vous allez composer

La partie IV de *Principles of Human Physiology*, un manuel sur une page de 210 × 277 mm : le chapitre 14, *The heart as a pump*, et le chapitre 15, *Vessels and blood pressure*, s’ouvrent sur des rectos sous un bandeau rouge et composent leurs notes cliniques et leurs légendes dans un couloir extérieur. La partie se termine par un index d’environ quatre-vingt-dix entrées et trente sous-entrées sur deux colonnes, comme le compose un manuel de médecine : entrées en minuscules, lettres de groupe dans la couleur d’accent, sous-entrées en retrait d’un cadratin, page de définition en gras, *see* et *see also* en italique. Aucun numéro de page n’y a été saisi. Chaque terme est marqué dans le chapitre, là où le texte l’explique, et l’index lit les pages où ces marques sont tombées, folios 297 à 304.

**Cette recette répond à:**

- Comment construire un index dont les numéros de page suivent le texte, où qu’il se déplace ?

## La réponse courte

```js
// script.js, lignes 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') },
};
```

## Ingrédients

**Enseigne**

- [Index](https://postext.dev/fr/docs/document-format.md#index-1): Des termes marqués dans le texte avec :index et listés par :::index dans l’ordre alphabétique, avec des sous-entrées, des plages, des renvois et des numéros de page qui suivent le texte.
- [Livres construits chapitre par chapitre](https://postext.dev/fr/docs/configuration-programmatic-usage.md#mettre-en-page-et-rendre-un-paquet): Des chapitres mis en page un par un qui poursuivent les numéros de page, de figure et de chapitre, la partie ouverte et la parité des pages du chapitre précédent.
- [Styles de titre](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre): Des variantes nommées d’un niveau de titre, choisies avec {style="…"} : une ouverture, une typographie, des titres courants ou une mise en page différents pour chaque chapitre.

**Utilise aussi**

- [Ouvertures composées](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)
- [Attributs de titre](https://postext.dev/fr/docs/document-format.md#attributs-de-titre)
- [Titres numérotés](https://postext.dev/fr/docs/configuration-text.md#réglages-par-niveau)
- [Titres courants et folios](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page)
- [Titres courants selon le rôle de la page](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-de-texte)
- [Marges en miroir](https://postext.dev/fr/docs/configuration-page-layout.md#marges-en-miroir)
- [Colonne marginale pour les flottants](https://postext.dev/fr/docs/configuration-page-layout.md#mise-en-page)
- [Notes marginales](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré)
- [Légendes latérales](https://postext.dev/fr/docs/document-format.md#placement)
- [Chapitres non numérotés](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Encadrés flottants](https://postext.dev/fr/docs/configuration-styles.md#le-conteneur-callout)
- [Encadrés](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré)
- [Légendes numérotées](https://postext.dev/fr/docs/document-format.md#numérotation-au-premier-renvoi)
- [Export PDF](https://postext.dev/fr/docs/configuration-programmatic-usage.md#générer-des-pdf)
- [Colonne et demie](https://postext.dev/fr/docs/configuration-page-layout.md#types-de-mise-en-page)
- [Des citations qui placent les figures](https://postext.dev/fr/docs/document-format.md#renvoi-dans-le-texte-la-forme-principale)
- [Figure et Tableau dans votre langue](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources)
- [Bandeau de chapitre sur toute la largeur](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)
- [Styles de paragraphe](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)
- [Polices intégrées au PDF](https://postext.dev/fr/docs/configuration-programmatic-usage.md#pourquoi-un-fournisseur-de-polices)
- [Types de ressource personnalisés](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources)
- [Figures et tableaux comme ressources](https://postext.dev/fr/docs/document-format.md#ressources)
- [Pages liminaires en chiffres romains](https://postext.dev/fr/docs/document-format.md#numbering)
- [Géométrie de section](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Titres courants par section](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Exposants et indices](https://postext.dev/fr/docs/document-format.md#mise-en-forme-en-ligne)

**La configuration en un coup d’œil**

- [`bodyText`](https://postext.dev/fr/docs/configuration-text.md#texte-courant), [`calloutStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré), [`captionStyle`](https://postext.dev/fr/docs/configuration-resources.md#style-de-légende), [`colorPalette`](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs), [`footer`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`header`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`headingStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre), [`headings`](https://postext.dev/fr/docs/configuration-text.md#titres), [`index`](https://postext.dev/fr/docs/configuration-notes-references.md#index), [`layout`](https://postext.dev/fr/docs/configuration-page-layout.md#mise-en-page), [`locale`](https://postext.dev/fr/docs/configuration-text.md#césure), [`page`](https://postext.dev/fr/docs/configuration-page-layout.md#page), [`paragraphStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe), [`resourceTypes`](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources), [`tableStyle`](https://postext.dev/fr/docs/configuration-resources.md#style-de-tableau)

**API**

- [`buildBundle`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#mettre-en-page-et-rendre-un-paquet), [`decompressWoff2`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#fournisseur-de-polices-dans-le-navigateur-fontsource--woff2), [`defaultResourceTypes`](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources), [`prepareFonts`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#charger-les-polices-avant-la-mise-en-page), [`registerResourceImage`](https://postext.dev/fr/docs/architecture.md#surface-de-lapi), [`renderPageToCanvas`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-bitmap), [`renderToPdf`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#générer-des-pdf), [`withLoadedFonts`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#charger-les-polices-avant-la-mise-en-page)

**Caractères**

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

## Préparation

### 1 · Marquez le passage, pas le mot

```js
// script.js, lignes 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') },
};
```

Un indexeur marque les endroits où un terme est expliqué, pas chaque endroit où le mot apparaît. Les deux chapitres emploient *stroke volume* douze fois, et l’index renvoie le lecteur à deux endroits : la page 298, où le terme est défini, et 299–300, où le texte explique ce qui le régule. Une marque visible, `:index[cardiac output]{main}`, imprime ses mots et les classe. Une marque invisible, `mitral:index{term="heart!valves!mitral"}`, n’imprime rien et prend la page du mot qui la précède : c’est ainsi que le passage sur les quatre valves classe *mitral*, *tricuspid*, *aortic* et *pulmonary* sous *heart* sans reformuler la phrase. Le `!` sépare les niveaux : *heart* en reçoit donc trois, les valves, puis chaque valve. Une marque fonctionne aussi dans un titre (les étendues de 14.1 et 15.6 commencent là) et dans un encadré (*heart failure* est défini dans la note latérale de la page 300).

### 2 · Pages principales, étendues et renvois

```js
// script.js, lignes 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` compose en gras la page qui définit un terme : un étudiant qui cherche *preload* va d’abord en page 299. `range="start"` et `range="end"`, avec le même terme, bornent une discussion qui passe un saut de page : *stroke volume, regulation, 299–300*, *baroreceptor reflex, 303–4*. Un `main` sur le début met toute l’étendue en gras. Une page marquée de nouveau à l’intérieur de sa propre étendue s’y fond, et un `main` à cet endroit met l’étendue en gras. `see` remplace les numéros de page (*inotropy. See contractility*), et `seealso` les suit (*heart failure, 300. See also ejection fraction*). Une cible à plusieurs niveaux s’écrit avec `!` et s’imprime avec un deux-points : *See blood pressure: mean arterial*.

### 3 · Un chapitre d’index sur deux colonnes

```js
// script.js, lignes 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’index est le dernier document Markdown du livre, `# Index {style="index"}` suivi de `:::index`. Le style de titre remplace la colonne et demie des chapitres par deux colonnes espacées de 6 mm, dans sa seule section, et donne à l’index sa propre ouverture et son propre titre courant. L’index est composé dans le caractère du texte en 8,6/11,4 pt, un cadratin par sous-niveau, et une ligne de suite se place en retrait de deux cadratins : une ligne renvoyée ne s’aligne jamais sur une sous-entrée. `rangeFormat: 'chicago'` supprime les chiffres qu’une étendue répète (*303–4*, mais *299–300*). L’édition espagnole écrit les étendues en entier avec un trait d’union (*303-304*), l’usage courant dans les livres espagnols.

### 4 · Pourquoi les numéros suivent le texte

```js
// script.js, lignes 377–385
const text = chapters.map((chapter) => chapter.markdown).join('\n');
await loadSvg('pv-loop.svg', pvLoop());
await prepareFonts(text, config, kitFonts(FONTS));
const docs = await withLoadedFonts(() => buildBundle({ chapters, config, resources }),
  { ...kitFonts(FONTS), 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` met les chapitres en page dans l’ordre et découvre que le troisième contient `:::index`. Après la première passe, il rassemble chaque marque de chaque chapitre avec la page où elle est tombée, et transmet cette liste au chapitre d’index comme plan. Puis il remet le livre en page, jusqu’à trois passes, jusqu’à ce qu’aucun numéro de page ne bouge. Réécrivez un paragraphe du chapitre 14, changez le format ou le corps du texte, et l’index se réimprime avec les nouvelles pages. Dans le PDF, chaque numéro de page renvoie à sa page.

### 5 · Ouvertures et titres courants

```js
// script.js, lignes 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) } } },
  ] } };
}
```

Les chapitres et l’index partagent une même ouverture : un bandeau du bord de coupe jusqu’à 62 mm (46 mm pour l’index), le titre posé sur son pied par une boîte alignée en bas, si bien qu’un titre de deux lignes et un titre d’une ligne finissent sur la même ligne. Le surtitre imprime `{chapterNumber}`, que l’attribut de titre `{startAt=14}` règle à 14, et `page.pageNumbering.startAt: 297` place la partie là où elle se trouverait dans le livre. Le style d’index est `numbered: false` : il ne devient donc pas le chapitre 16.

## La recette complète

Un seul fichier, composé à partir du dossier de la recette avec le texte d’exemple et le kit commun des Recettes déjà inclus ; il construit sa propre page. Pour l’exécuter, placez-le dans un `<script type="module">` d’une page vide ou collez-le dans le panneau JS d’un nouveau pen CodePen (en tant que module). Il importe postext depuis esm.sh : il n’y a rien à installer ni à compiler.

- Dossier de la recette: 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.28.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, prepareFonts, withLoadedFonts, renderPageToCanvas, registerResourceImage,
  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' | '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 = {
  locale: t({ en: 'en-gb', es: 'es' }), // hyphenation and the index's sort order
  // The built-in types in the edition's language; their 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,
    hyphenateAcrossColumns: false }, // no word is divided over a page turn
  headings: { fontFamily: SANS, color: col('ink'), fontWeight: 700, levels: [
    // parity 'odd': the next recto; the default 'always-odd' also leaves a blank page before it.
    { 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: "Principles of Human Physiology"
subtitle: "Part IV · The Circulation"
author: "Elena Marsh and Tomás Ibarra"
---

# The heart as a pump {startAt=14 lead="Two muscular pumps in series move the whole blood volume round the body about once a minute at rest. This chapter follows one heartbeat from the first electrical signal to the last drop of blood ejected, and then asks what sets the size of each beat."}

The human heart is a pair of pumps built into a single organ. The right side receives venous blood from the body and drives it through the lungs; the left side receives the oxygenated blood and drives it round the systemic circulation. Because the two are connected in series, they must, over any period longer than a few beats, move exactly the same volume of blood. Most of what follows concerns the left ventricle, but the principles apply to both sides of the heart: the right ventricle does the same work against a pressure about one-fifth as high.:index{term="ventricle!right"}

## The cardiac cycle :index{term="cardiac cycle" range="start" main}

The sequence of events from the beginning of one heartbeat to the beginning of the next is the cardiac cycle. At a resting heart rate of 72 beats per minute each cycle lasts 0.83 s, of which ventricular :index[systole] (contraction) takes about 0.3 s and :index[diastole] (relaxation) the remaining 0.5 s. When the heart rate rises, diastole shortens far more than systole: at 180 beats per minute the whole cycle lasts 0.33 s and less than half of it is left for filling.

The flow of blood through the heart is governed entirely by pressure differences and by four one-way valves. The mitral:index{term="heart!valves!mitral"} and tricuspid:index{term="heart!valves!tricuspid"} valves separate atria from ventricles; the aortic:index{term="heart!valves!aortic"} and pulmonary:index{term="heart!valves!pulmonary"} valves guard the exits of the ventricles. A valve opens when the pressure behind it exceeds the pressure ahead of it and closes when the gradient reverses. Nothing else moves them. It is convenient to divide the cycle into five phases.

**Atrial systole.**:index{term="cardiac cycle!atrial systole"} Contraction of the atria pushes a final volume of blood into ventricles that are already mostly full. At rest this atrial kick:index{term="atrial kick" see="cardiac cycle!atrial systole"} adds only 10–20 % of ventricular filling; it matters more at high heart rates and in a stiff ventricle, and its loss in atrial fibrillation:index{term="atrial fibrillation"} is felt mainly on exertion.

**Isovolumetric contraction.**:index{term="cardiac cycle!isovolumetric contraction"} As the ventricle begins to contract, its pressure rises above atrial pressure and the mitral valve closes, producing the first heart sound.:index{term="heart!sounds"} For about 0.05 s all four valves are shut. The ventricle contracts around a fixed volume of blood and its pressure climbs steeply.

**Ejection.**:index{term="cardiac cycle!ejection"} When left ventricular pressure exceeds aortic pressure, about 80 mmHg, the aortic valve opens and blood leaves the ventricle, rapidly at first and then more slowly. Ventricular and aortic pressures rise together to a peak of about 120 mmHg.

**Isovolumetric relaxation.**:index{term="cardiac cycle!isovolumetric relaxation"} As the muscle relaxes, ventricular pressure falls below aortic pressure; the aortic valve closes and gives the second heart sound, and a brief notch, the incisura,:index{term="incisura"} appears on the aortic pressure trace.:index{term="heart!sounds"} Again the ventricle is a closed chamber, now relaxing at constant volume.

**Ventricular filling.**:index{term="cardiac cycle!filling"} Once ventricular pressure drops below atrial pressure, the mitral valve opens and blood that has collected in the atrium during systole rushes in. Most filling occurs in this first third of diastole; the middle third adds little, and atrial systole completes the process.

## The pressure–volume loop

Plotting left ventricular pressure against volume turns the cycle into a single closed curve, the :index[pressure–volume loop]{main} (:ref{id="pv-loop"}). The ventricle ends diastole holding about 120 mL, the :index[end-diastolic volume]{main}, and ends systole with about 50 mL, the :index[end-systolic volume]. The difference, some 70 mL, is the :index[stroke volume]{main}. The fraction of the end-diastolic volume that is ejected, here 70/120 or 58 %, is the :index[ejection fraction]{main}; values between 55 and 70 % are normal, and the ejection fraction is the single number most often used to describe the pumping performance of a ventricle.

The area enclosed by the loop is the external work done by the ventricle in one beat, the :index[stroke work]. Each side of the loop corresponds to one phase of the cycle, and each corner to the opening or closing of a valve, so a single loop records the whole beat.:index{term="cardiac cycle" range="end"}

## Cardiac output

The volume of blood pumped by each ventricle per minute is the :index[cardiac output]{main}. It is the product of stroke volume and heart rate: 70 mL × 72 beats per minute gives about 5 L/min in a resting adult, which means that the entire blood volume passes through each side of the heart roughly once a minute. Because output scales with body size, it is often divided by body surface area;:index{term="body surface area"} the resulting :index[cardiac index] is about 3 L/min per square metre. During maximal exercise cardiac output rises four- to fivefold in an untrained young adult and to 30–35 L/min in an endurance athlete,:index{term="athletes, endurance"} whose larger heart achieves this mainly through a greater stroke volume.:index{term="exercise!cardiac output"}

Every change in cardiac output is a change in :index[heart rate] or in stroke volume, or in both. The rest of this chapter considers each in turn.

## The sinoatrial node and the conduction system :index{term="heart!conduction system" range="start"}

Cardiac muscle does not need a nerve to contract. The beat starts in the :index[sinoatrial node]{main}, a small strip of specialised muscle in the wall of the right atrium near the entry of the superior vena cava. Its cells have no stable resting potential: after each action potential the membrane depolarises slowly, largely through the so-called :index[funny current], until it reaches threshold and fires again. Isolated from all nervous influence, the node fires about 100 times a minute.:index{term="pacemaker" see="sinoatrial node"} The resting rate of 60–80 beats per minute is lower because the :index[vagus nerve] continuously releases :index[acetylcholine] onto the node and slows this pacemaker depolarisation.

From the node the impulse spreads through both atria and converges on the :index[atrioventricular node]{main}, which conducts at only about 0.05 m/s.:index{term="AV node" see="atrioventricular node"} The resulting delay of roughly 0.1 s lets the atria finish emptying before the ventricles contract. Beyond the node the impulse runs down the :index[bundle of His] and its branches into the :index[Purkinje fibres], which conduct at up to 4 m/s and activate the whole ventricular mass within about 0.08 s.

The sinoatrial node sets the pace only because it is the fastest. If it fails, the atrioventricular node takes over at 40–60 beats per minute, and the Purkinje fibres, if conduction through the node is blocked, at 15–40.:index{term="heart block"}:index{term="heart!conduction system" range="end"}

## Regulating stroke volume

Three factors determine how much blood the ventricle ejects with each beat: the volume it holds before it contracts, the pressure it must overcome, and the strength of its contraction. :index{term="stroke volume!regulation" range="start"}

**Preload and the Frank–Starling mechanism.** The more a ventricle is filled during diastole, the more forcefully it contracts and the more blood it ejects. This intrinsic property of heart muscle is the :index[Frank–Starling mechanism]{main}, after the German physiologist Otto Frank:index{term="Frank, Otto"} and the British physiologist Ernest Starling,:index{term="Starling, Ernest"} who described it in 1895 and 1914. Stretching a cardiac muscle fibre brings its :index[sarcomeres]{term="sarcomere"} towards an optimal length of about 2.2 µm, at which the overlap of actin and myosin filaments, and the sensitivity of the filaments to calcium, are greatest.:index{term="calcium!contraction"} The degree of stretch before contraction is the :index[preload]{main}, usually taken as the end-diastolic volume. The mechanism keeps the two ventricles in step: if the right ventricle pumps a little more for a few beats, more blood reaches the left ventricle, which stretches and ejects the extra volume.

**Afterload.** The :index[afterload]{main seealso="blood pressure"} is the load the ventricle works against once it starts to eject, which for the left ventricle is essentially the aortic pressure. A sudden rise in afterload means the aortic valve opens later and closes earlier, so the ventricle ejects less and is left with a larger end-systolic volume. Over the next few beats the Frank–Starling mechanism restores the stroke volume at the cost of a larger heart.

:::callout{type="clinical" title="Heart failure"}
In :index[heart failure]{main} the heart cannot pump enough blood for the needs of the body at normal filling pressures. When the ejection fraction falls to 40 % or less, the condition is called heart failure with reduced ejection fraction;:index{term="heart failure!reduced ejection fraction"} many patients, however, have a normal ejection fraction and a stiff ventricle that fills poorly.:index{term="heart failure!preserved ejection fraction"} Older texts use the term cardiac insufficiency.:index{term="cardiac insufficiency" see="heart failure"}:index{term="heart failure" seealso="ejection fraction"}
:::

**Contractility.** A ventricle can also eject more blood from the same end-diastolic volume if its fibres contract more strongly. This property, :index[contractility]{main} or inotropy,:index{term="inotropy" see="contractility"} is raised above all by the :index[sympathetic nervous system]{term="sympathetic nervous system!heart"}. Noradrenaline:index{term="noradrenaline"} released from sympathetic nerves acts on beta-1 :index[adrenergic receptors]{term="adrenergic receptors!beta-1"} of the muscle cells, increases calcium entry:index{term="calcium!contraction"} during each action potential, and makes each contraction stronger and shorter. The same transmitter acting on the sinoatrial node increases the heart rate, so sympathetic stimulation raises cardiac output through both of its factors at once.:index{term="norepinephrine" see="noradrenaline"}

These three mechanisms rarely act alone. During exercise, venous return increases the preload, sympathetic activity raises contractility and heart rate, and dilatation of the muscle arterioles keeps the afterload from rising in proportion to the flow.:index{term="stroke volume!regulation" range="end"}:index{term="exercise!stroke volume"}

## Listening to the heart :index{term="heart!sounds" range="start" main}

The closure of the valves can be heard through the chest wall, and auscultation with the :index[stethoscope], which the French physician René Laennec:index{term="Laennec, René"} introduced in 1816, remains the first examination of the heart. The first heart sound, low-pitched and relatively long, marks the closure of the mitral and tricuspid valves at the start of systole; it is best heard over the apex. The second, shorter and sharper, marks the closure of the aortic and pulmonary valves at the end of ejection. During inspiration the fall in intrathoracic pressure increases venous return to the right ventricle, which then ejects for slightly longer, so the pulmonary valve closes a few hundredths of a second after the aortic valve: the second sound is heard split.:index{term="heart!sounds!splitting of the second sound"}

A third heart sound in early diastole comes from the rapid inflow of blood into the ventricle. It is normal in children and young athletes, but after the age of about forty it usually means a dilated, failing ventricle.:index{term="heart failure"} A fourth sound, just before the first, is produced by atrial systole against a stiff ventricle.:index{term="heart!sounds!third and fourth"}

Between the sounds the heart is normally silent, because blood flows smoothly through open valves. A :index[murmur]{term="murmurs" seealso="turbulent flow"} is the noise of turbulent flow: through a narrowed valve that must still open (stenosis) or through a valve that fails to close (regurgitation). Its timing in the cycle tells the examiner which valve is at fault; an aortic stenosis, for example, produces a murmur during ejection, between the first and the second sounds.:index{term="heart!sounds" range="end"}:index{term="heart!valves!stenosis and regurgitation"}
`; // chapter 14, with the book's frontmatter (content.<lang>.md)
const vessels = String.raw`# Vessels and blood pressure {lead="The heart supplies the pressure; the vessels decide where the blood goes. This chapter explains how a few micrometres of change in the radius of small arteries redistribute the cardiac output, and how the brainstem keeps arterial pressure steady from one heartbeat to the next."}

Blood leaves the left ventricle in pulses and reaches the capillaries as a steady stream. Between the two lies a branching system of tubes whose walls differ in thickness, in elastic tissue and in smooth muscle, and whose properties explain most of what follows: why arterial pressure has a systolic and a diastolic value, why pressure falls so steeply in the smallest arteries, and why most of the blood in the body is found in the veins.

## The vascular tree

The :index[aorta] and its large branches are :index[arteries]{term="arteries!elastic"} rich in elastic fibres. They expand as each stroke volume enters and recoil during diastole, so that flow continues between beats. Smaller :index[arteries]{term="arteries!muscular"} carry more smooth muscle and distribute blood to the organs. The :index[arterioles]{main}, 10–100 µm across, have the thickest walls for their size and are the main site of resistance to flow: as :ref{id="pressures"} shows, mean pressure falls from about 85 to 35 mmHg across them. By contracting or relaxing their smooth muscle, the arterioles set both the total resistance of the circulation and the share of the cardiac output that each organ receives.

The :index[capillaries]{main} are tubes of a single layer of endothelial cells,:index{term="endothelium"} 5–8 µm in diameter, just wide enough for a red cell to squeeze through. There are some ten billion of them, with a total exchange surface of 500–700 m², and blood moves through them at about 0.3 mm/s, a thousandth of its mean velocity in the aorta.:index{term="blood flow!velocity"} Blood returns through :index[venules] and :index[veins]{main}, whose walls are thin and easily distended. At rest the systemic veins hold about 64 % of the blood volume, which is why they are called capacitance vessels.:index{term="capacitance vessels" see="veins"}

## Flow, pressure and resistance

Flow through any vessel obeys a relation analogous to :index[Ohm's law]: flow equals the pressure difference between the two ends divided by the resistance, *Q* = (*P*~1~ − *P*~2~)/*R*. Applied to the whole systemic circulation, with a mean arterial pressure of 93 mmHg, a right atrial pressure close to zero and a cardiac output of 5 L/min, it gives a :index[total peripheral resistance]{term="resistance, vascular!total peripheral"} of about 19 mmHg·min/L.:index{term="total peripheral resistance" see="resistance, vascular!total peripheral"}

What determines the resistance of a vessel? For steady, streamlined flow of a fluid through a rigid tube, the answer is :index[Poiseuille's law]{main}, derived in the 1840s by the French physician Jean Poiseuille:index{term="Poiseuille, Jean"} from experiments on water flowing through glass capillaries. The resistance is proportional to the length of the tube and to the :index[viscosity] of the fluid, and inversely proportional to the fourth power of the radius, *r*^4^. Halving the radius of an arteriole multiplies its resistance by sixteen; a rise in radius of only 19 % doubles the flow through it at the same pressure. This extreme sensitivity is what gives the arterioles their control over the circulation. The viscosity of blood, about three to four times that of water, depends mainly on the haematocrit, and it rises steeply when the haematocrit exceeds 60 %.:index{term="haematocrit"}

Poiseuille's law assumes :index[laminar flow], in which the blood moves in concentric layers, fastest at the centre. When velocity is high, the vessel wide or the wall irregular, the layers break into eddies. The tendency to such :index[turbulent flow]{seealso="Reynolds number"} is expressed by the :index[Reynolds number], the product of the density of blood, its velocity and the diameter of the vessel divided by the viscosity. Above about 2000 flow becomes turbulent, and turbulence is audible: a murmur:index{term="murmurs"} over a narrowed valve, a :index[bruit] over a stenosed artery, and the :index[Korotkoff sounds] heard when blood pressure is measured with a cuff.

Vessels arranged in series add their resistances; vessels arranged in parallel add their conductances, so the total resistance of a parallel network is lower than that of any one of its branches.:index{term="resistance, vascular!series and parallel"} The organs of the body are supplied in parallel, which allows each to adjust its own blood flow without much effect on the others.

## Arterial pressure :index{term="blood pressure" range="start"}

In a healthy young adult arterial pressure rises to about 120 mmHg with each ejection, the :index[systolic pressure]{term="blood pressure!systolic"}, and falls to about 80 mmHg before the next, the :index[diastolic pressure]{term="blood pressure!diastolic"}. The difference, 40 mmHg, is the :index[pulse pressure]{term="blood pressure!pulse pressure"}. Because diastole lasts about twice as long as systole at rest, the average pressure over the cycle lies closer to the diastolic value. It is estimated as the diastolic pressure plus one-third of the pulse pressure:

:::paragraphs{style="formula"}
MAP = DBP + (SBP − DBP)/3 = 80 + 40/3 = 93 mmHg
:::

This :index[mean arterial pressure]{term="blood pressure!mean arterial" main} is the pressure that drives blood through the systemic circulation. At high heart rates diastole shortens and the true mean moves towards the arithmetic mean of systolic and diastolic pressure.:index{term="mean arterial pressure" see="blood pressure!mean arterial"}

The pulse pressure depends mainly on two quantities: the stroke volume and the :index[arterial compliance]{term="compliance, arterial" main}, the change in arterial volume per unit change in pressure. The elastic arteries store about half of each stroke volume during systole and return it during diastole, an arrangement known as the :index[Windkessel effect]{main} after the air chamber of eighteenth-century fire engines, which turned the strokes of the pump into a steady jet. With age the aorta stiffens,:index{term="ageing!arteries"} compliance falls, and the same stroke volume produces a higher systolic and a lower diastolic pressure.

:::callout{type="clinical" title="Hypertension"}
Sustained :index[hypertension]{main} is diagnosed when the arterial pressure measured at rest on repeated occasions is 140/90 mmHg or higher; American guidelines since 2017 use 130/80 mmHg. In older people a high systolic pressure with a normal or low diastolic pressure, isolated systolic hypertension, reflects the loss of aortic compliance.:index{term="hypertension!isolated systolic"}:index{term="ageing!arteries"}
:::

## Capillary exchange

Across the capillary wall, fluid movement is determined by the balance between hydrostatic pressure, which pushes fluid out, and the :index[colloid osmotic pressure]{main} of the plasma proteins,:index{term="oncotic pressure" see="colloid osmotic pressure"} which holds it in. These :index[Starling forces]{term="capillaries!Starling forces"} favour filtration at the arterial end of the capillary, where the hydrostatic pressure:index{term="capillaries!hydrostatic pressure"} is about 35 mmHg against an oncotic pressure of 25 mmHg, and reabsorption at the venous end, where the hydrostatic pressure has fallen to about 15 mmHg. Of the 20 L or so filtered each day in the systemic capillaries, some 17 L return directly to the blood; the rest is carried back by the :index[lymph]{main} vessels. When filtration outstrips this return, fluid gathers in the tissues as oedema.:index{term="oedema"}:index{term="Starling forces" see="capillaries!Starling forces"}

## The baroreceptor reflex :index{term="baroreceptor reflex" range="start" main}

Arterial pressure is held within a few millimetres of mercury of its set point from moment to moment by the baroreceptor reflex. :index[Baroreceptors]{term="baroreceptors"} are stretch-sensitive nerve endings in the wall of the :index[carotid sinus]{term="baroreceptors!carotid sinus"}, at the bifurcation of each common carotid artery, and in the :index[aortic arch]{term="baroreceptors!aortic arch"}. Their firing rises with each systolic distension. Signals from the carotid sinus travel in the :index[glossopharyngeal nerve] and those from the aortic arch in the :index[vagus nerve]{seealso="baroreceptor reflex"}; both end in the :index[nucleus tractus solitarius] of the medulla.:index{term="medulla oblongata"}

A rise in pressure increases baroreceptor firing, which inhibits the sympathetic outflow to the heart and vessels and increases vagal activity. Heart rate and contractility fall, the arterioles dilate, and pressure returns towards normal.:index{term="sympathetic nervous system!vessels"} A fall in pressure has the opposite effects. The carotid receptors respond between about 60 and 180 mmHg and are most sensitive around the normal mean arterial pressure. Within one or two days, however, they reset to any pressure that is sustained, so the reflex corrects rapid changes but cannot, by itself, set the long-term level of arterial pressure, which depends on the handling of salt and water by the kidneys.:index{term="kidneys!long-term control of pressure"}:index{term="blood pressure!long-term control"}:index{term="blood pressure" range="end"}

The reflex is tested every time a person stands up.:index{term="standing, circulatory effects of"} Some 500 mL of blood pools in the veins of the legs, venous return and stroke volume fall, and arterial pressure starts to drop; within a few seconds the reflex raises the heart rate and constricts the arterioles. When it fails, as it may in the elderly or in diseases of the autonomic nerves, standing causes a fall in systolic pressure of 20 mmHg or more, :index[orthostatic hypotension]{term="hypotension, orthostatic" main}, with dizziness or fainting.:index{term="fainting" see="hypotension, orthostatic"}:index{term="baroreceptor reflex" range="end"}:index{term="venous return"}

## Venous return :index{term="venous return" range="start" main}

Whatever the heart pumps out must first come back to it. The flow of blood from the veins into the right atrium, the venous return, is driven by a small pressure difference: about 7 mmHg between the venules and the right atrium, where the :index[central venous pressure] is close to zero. Because the veins are so compliant, a small change in their tone moves a large volume of blood. Sympathetic constriction of the veins can shift several hundred millilitres from the peripheral veins towards the heart within seconds, raising the preload and, through the Frank–Starling mechanism, the stroke volume.:index{term="sympathetic nervous system!vessels"}

Two pumps outside the heart help. In the legs, the deep veins run between the muscles and contain one-way valves every few centimetres.:index{term="veins!valves"} Each contraction of the calf squeezes the veins and drives blood upwards, and the valves stop it from falling back; this :index[skeletal muscle pump]{main} can lower the venous pressure at the ankle of a walking person from about 90 mmHg to 25 mmHg or less. When the valves fail, the veins of the legs dilate and become tortuous, as :index[varicose veins].:index{term="veins!varicose" see="varicose veins"} In the chest, each inspiration lowers the intrathoracic pressure and raises the abdominal pressure, drawing blood from the abdominal veins into the thorax: the :index[respiratory pump].

Over any period longer than a few beats, venous return and cardiac output are equal. This is why the heart, although it generates the pressure, is not the only organ that sets the output: in a healthy person at rest, the cardiac output follows the venous return, and the venous return follows the metabolic needs of the tissues, each of which controls its own blood flow through its arterioles.:index{term="venous return" range="end"}:index{term="cardiac output!venous return"}
`; // chapter 15 (content.vessels.<lang>.md)
const indexChapter = String.raw`# Index {style="index" lead="Numbers in bold mark the principal discussion of a term. A range such as 301–3 means that the discussion runs across those pages. *See* sends you to the entry that holds the page numbers; *see also* to a related entry."}

:::index

:::callout{type="colophon"}
*Principles of Human Physiology*, Part IV, chapters 14 and 15, a sample set with Postext. Set in Literata and Libre Franklin (SIL OFL 1.1). Text and drawing: original, CC BY 4.0. The book and its authors are fictional.
:::
`; // # 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
// Paths only: the drawing carries no lettering, and the caption reads it.
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 with the ones the config names.
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 loadSvg('pv-loop.svg', pvLoop());
await prepareFonts(text, config, kitFonts(FONTS));
const docs = await withLoadedFonts(() => buildBundle({ chapters, config, resources }),
  { ...kitFonts(FONTS), 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 v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout (prepareFonts,
// buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the
// files the PDF embeds too.

/** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts:
 *  the Fontsource resolver, and the faces of FONTS loaded with the ones the
 *  config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */
function kitFonts(FONTS = {}) {
  const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) =>
    ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' })));
  return { resolve: fontsourceResolver, faces };
}

/** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek
 *  when `text` needs them and the family ships them (kitSubsetsFor), each with
 *  its unicode-range. Null for a weight or slant Fontsource does not ship. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null;
  kitStatus('Loading fonts…');
  const at = await fontsourceAt(id);
  return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({
    source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`,
    unicodeRange: kitRanges()[subset],
  }));
}

/** The unicode-range of each Fontsource file the kit loads (a function: the
 *  kit is inlined after the pen's code, which runs before a const would be set). */
function kitRanges() {
  return {
    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',
    greek: 'U+0370-03FF',
  };
}

/** For the cjk, arabic and comics blocks, which add their own files: the
 *  faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  The files are added once all have loaded, in the order asked (latin, then
 *  latin-ext, then greek), whatever order they arrive in: the last one added
 *  wins a character two files hold. Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = kitRanges();
  const jobs = [];
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'])
      .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await
    const at = fontsourceAt(id);
    const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null;
    const subsets = ['latin', ...kitSubsetsFor(text, meta)];
    for (const [weight, style] of todo) {
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `${await at}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().catch(() => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return ready.filter((face) => face && document.fonts.add(face)).length;
}

/** A loaded FontFace covers this family, weight and style (fonts.check() would
 *  also say yes 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;
}

/** The files beyond latin `text` needs that `meta`'s family ships. */
function kitSubsetsFor(text, meta) {
  return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']]
    .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x);
}

/** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */
function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); }

/** A package's folder at the one version @5 names now (#766). */
function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) {
  return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json())
    .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`);
}

/** The family's Fontsource metadata (weights, styles, subsets), 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 v2 ── the same in every recipe · postext.dev/cookbook
/** The pages as spreads on a dark desk, page 1 alone, then verso | recto,
 *  each painted when it scrolls near. Other options go to renderPageToCanvas. */
function showPages(docs, { title, width = 460, ...render } = {}) {
  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, { ...render, 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 v3 ── the same in every recipe that exports a PDF
/** The Fontsource files the screen used, as TrueType, with their range and
 *  place: the nearest weight the family ships, upright with no italic;
 *  latin, then what its letters need (kitSubsetsFor). */
async function fontsourceProvider(family, weight, style, request) {
  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 text = String.fromCodePoint(...(request?.codePoints ?? []));
  const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => {
    const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`);
    if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`);
    return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())),
      { unicodeRange: kitRanges()[subset], order });
  }));
  return files.length === 1 ? files[0] : files;
}

/** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame
 *  shows no 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 v3 ── 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 as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25).
 *  `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */
async function loadSvg(fileId, svg, options) {
  if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces);
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img, options);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** A face's Fontsource files: nearest weight; latin + what the SVG sets. */
async function svgFaces(family, weight, style, { codePoints = [] } = {}) {
  const [id, m] = [fontsourceId(family), await fontsourceMeta(family)];
  const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight;
  const s = m?.styles.includes(style) === false ? 'normal' : style;
  return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array(
    await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer())));
}

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

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

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

## Variantes

### Écrivez chaque étendue en entier

Certaines chartes maison impriment en entier les deux numéros d’une étendue.

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

### Un index sans lettres de groupe

Un index court enchaîne souvent ses groupes, séparés seulement par une ligne blanche.

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

## Erreurs fréquentes

- **Les cellules fusionnées demandent des cellules hiddenBy : utilisez mergeCells.** Les cellules sont placées selon leur position dans le tableau de la ligne : une cellule fusionnée demande donc des cellules de remplacement marquées hiddenBy là où elle s’étend ; les omettre, comme le fait le HTML, décale toutes les colonnes suivantes. Construisez les fusions avec mergeCells. Depuis postext 1.5, un tableau dont la grille est irrégulière est signalé dans doc.contentWarnings comme raggedTableGrid, et parseTSV(text, { headerRows }) marque les lignes d’en-tête.

- La capture de cette recette ne signale pas `indexSeeUnknown` : un renvoi dont la cible s’écrit autrement que l’entrée (*Reynolds number* contre *number, Reynolds*) s’imprime sans protester. Relisez chaque *See* de l’index terminé en le comparant à sa cible.
- Une liste de sous-entrées coupée entre deux colonnes ne répète pas son entrée principale sous forme de ligne *heart (cont.)* ; une entrée sans page propre reste toujours avec sa première sous-entrée, si bien qu’une entrée principale ne finit jamais seule une colonne.

## Crédits

- Recette: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Caractères: Literata (OFL-1.1), Libre Franklin (OFL-1.1)
- Code: MIT · Contenu d’exemple: CC-BY-4.0

## Recettes liées

- [Nº 094 · Un livre technique aux renvois d’un chapitre à l’autre](https://postext.dev/fr/cookbook/technical-book-crossref-chapters.md): Un manuel solaire en trois chapitres dont le texte écrit @fig:sun, @tbl:loads et @sec:array-size ; buildBundle imprime numéros et pages d’un chapitre à l’autre. · Niveau 3 (Avancé) · Manuels, guides et ouvrages de référence, Manuels scolaires
- [Nº 098 · Ouvrage collectif avec une bibliographie par chapitre](https://postext.dev/fr/cookbook/edited-volume-chapter-bibliographies.md): Trois essais de trois contributeurs, chacun document distinct dans buildBundle, chacun clos par une liste Chicago auteur-date des seuls ouvrages qu’il cite. · Niveau 3 (Avancé) · Articles et publications universitaires, Manuels scolaires
- [Nº 126 · Un index japonais dans l’ordre gojūon, par lecture](https://postext.dev/fr/cookbook/gojuon-index.md): Chapitre sur les termes de la composition japonaise et son index : chaque marque donne sa lecture (furigana ou yomi), entrées sous あ行, か行, さ行 dans l’ordre JIS. · Niveau 2 (Intermédiaire) · Manuels, guides et ouvrages de référence, Manuels scolaires
