# Une ouverture de chapitre sur un bandeau à fond perdu

> Une ouverture advancedDesign sur le titre de niveau 1 : bandeau à fond perdu, numéro de chapitre posé sur son pied, surtitre et chapeau tirés des attributs.

- Version HTML: https://postext.dev/fr/cookbook/chapter-opener-bleed-band
- Recette Nº 003 · Titres et ouvertures · Niveau 3 (Avancé) · Sorties: Canvas
- Genres: Manuels scolaires
- Nécessite postext ≥ 1.25.0 · testée avec 1.31.0 le 2026-10-13
- Pages: [41](https://postext.dev/cookbook/chapter-opener-bleed-band/en/p01.webp?v=48a51403), [42](https://postext.dev/cookbook/chapter-opener-bleed-band/en/p02.webp?v=48a51403), [43](https://postext.dev/cookbook/chapter-opener-bleed-band/en/p03.webp?v=48a51403), [44](https://postext.dev/cookbook/chapter-opener-bleed-band/en/p04.webp?v=48a51403)
- Ouvrir dans le Sandbox: https://postext.dev/fr/sandbox#recipe=chapter-opener-bleed-band&lang=en (.postext: https://postext.dev/cookbook/chapter-opener-bleed-band/en/chapter-opener-bleed-band.postext)
- Dernière mise à jour: 2026-10-11
- Autres langues: [en](https://postext.dev/en/cookbook/chapter-opener-bleed-band.md), [es](https://postext.dev/es/cookbook/chapter-opener-bleed-band.md), [ca](https://postext.dev/ca/cookbook/chapter-opener-bleed-band.md), [pt](https://postext.dev/pt/cookbook/chapter-opener-bleed-band.md), [zh](https://postext.dev/zh/cookbook/chapter-opener-bleed-band.md), [ja](https://postext.dev/ja/cookbook/chapter-opener-bleed-band.md), [ar](https://postext.dev/ar/cookbook/chapter-opener-bleed-band.md)

## En bref

Des pages d’ouverture de chapitre pour un manuel de géologie. Un bandeau de couleur portant un grand numéro de chapitre déborde du haut de la page, et la maquette se règle une seule fois pour tous les chapitres.

## Ce que vous allez composer

Les ouvertures de chapitre de *Earth in Motion*, un manuel d’initiation à la géologie sur une page de 210 × 280 mm. Le chapitre 3 s’ouvre sous un bandeau terre cuite à fond perdu en haut de la page. Le bandeau porte un surtitre interlettré, le titre coupé là où l’auteur l’a voulu, un chapeau en italique et un numéro de chapitre de 168 pt debout sur le pied sombre du bandeau, côté grand fond. Deux colonnes justifiées commencent sous le bandeau, et une coupe d’une plaque qui s’enfonce occupe le pied de la page. Le chapitre 4 reprend la maquette en bleu canard, jusqu’à l’onglet, aux numéros de section, aux renvois et à l’étiquette de sa figure. La maquette se règle une seule fois, sur le titre de niveau 1. La ligne de titre de chaque chapitre fournit en attributs le sujet du surtitre et le chapeau, et nomme un style quand le chapitre prend une autre couleur.

**Cette recette répond à:**

- Comment faire une ouverture de chapitre avec bandeau à fond perdu, grand numéro et titre sur deux lignes ?
- Comment composer un titre de chapitre sur toute la largeur de la page, avec le texte en deux colonnes en dessous ?
- Comment ajouter à une ouverture une ligne d’auteur, un chapeau ou une attaque avec lettrine ?
- Comment donner à chaque chapitre sa propre photo, sa propre couleur ou sa propre variante d’ouverture ?
- Comment numéroter les titres (1, 1.1, 1.1.1) et styler chaque niveau différemment ?

## La réponse courte

```js
// script.js, lignes 38–76
const TOP = 24; // top margin, mm: the opener's container starts here
const OUTER = 16; // outer margin, mm: the numeral and the folios line up on it
const BLEED = 0; // mm: 0 on screen; about 3 for print, which also switches on page.cutLines
const BAND = 100; // mm from the trim to the foot of the band
const NUMERAL = 168; // pt; the baseline of a design text sits 0.8 down its line box
const PT = 25.4 / 72; // mm in a point
const AIR = 12; // mm, foot to text: ≥ 0.2 × NUMERAL × PT ≈ 11.9, the numeral box below its baseline
const BEARING = 2; // mm past the text edge: a rough optical nudge, as each digit's bearing differs
const bleedTop = { to: 'bleed', edge: 'top-left' }; // the trim's corner, pushed out by BLEED
// Design text centres by default; in a heading design it wraps by default too.
const onBand = { color: col('paper'), align: 'left' };
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' }, offset: { y: mm(y) },
  size: { width: mm(width) } }); // chained: a longer title pushes the standfirst down
const opener = { enabled: true,
  // The text starts below the lowest element or minHeight (gotcha: opener-reserves-anchored).
  minHeight: mm(BAND - TOP + AIR), // clears the numeral, so the text starts AIR below the foot
  slot: {
    elements: [ // array order is paint order: the band first, the type on top
      { kind: 'box', id: 'band', style: { backgroundColor: col('band') }, placement: {
        anchor: bleedTop, size: { height: mm(BLEED + BAND) } } }, // no width: runs to the far edge
      { kind: 'rule', id: 'foot', color: col('ink'), thickness: mm(1.6), placement: {
        anchor: bleedTop, offset: { y: mm(BLEED + BAND) } } },
      { kind: 'text', id: 'numeral', content: '{chapterNumber}', ...onBand,
        fontFamily: 'Archivo', fontWeight: 800, fontSize: pt(NUMERAL),
        lineHeight: 1, // a box as tall as the type: the y below counts on it
        placement: { anchor: { to: 'page', edge: 'top-right' }, // the fore-edge of a recto
          offset: { x: mm(BEARING - OUTER), y: mm(BAND - 0.8 * NUMERAL * PT) } } }, // on the foot
      { kind: 'text', id: 'kicker', ...onBand, fontFamily: 'Archivo Narrow', fontWeight: 600,
        content: `${t({ en: 'Chapter', es: 'Capítulo' })} {chapterNumber} · {attr.topic}`,
        fontSize: pt(8.5), letterSpacing: pt(2), textTransform: 'uppercase',
        placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(7) } } },
      { kind: 'text', id: 'title', content: '{titleText}', ...onBand, // breaks at the \\
        fontFamily: 'Archivo', fontWeight: 800, fontSize: pt(34), lineHeight: 1.02,
        placement: below('kicker', 3, 118) },
      { kind: 'text', id: 'lead', content: '{attr.lead}', ...onBand, fontFamily: 'Roboto Serif',
        italic: true, fontSize: pt(10.5), lineHeight: 1.36, placement: below('title', 6, 100) },
    ],
  },
}; // hook-up: headings.levels[0] = { span: 'page', breakBefore, advancedDesign: opener }
```

## Ingrédients

**Enseigne**

- [Ouvertures composées](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée): Un titre dessiné comme une composition libre de texte, de filets, de boîtes et d’images, qui réserve au-dessus du texte courant la hauteur dont il a besoin.
- [Bandeau de chapitre sur toute la largeur](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée): Un titre qui couvre toutes les colonnes comme un bandeau d’ouverture, avec le texte courant en colonnes en dessous.

**Utilise aussi**

- [Attributs de titre](https://postext.dev/fr/docs/document-format.md#attributs-de-titre)
- [Sauts de ligne dans les titres](https://postext.dev/fr/docs/document-format.md#sauts-de-ligne-dans-les-titres)
- [Styles de titre](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Couleurs par partie](https://postext.dev/fr/docs/configuration-styles.md#le-conteneur-part)
- [Titres numérotés](https://postext.dev/fr/docs/configuration-text.md#réglages-par-niveau)
- [Chapitres qui commencent en belle page](https://postext.dev/fr/docs/configuration-text.md#saut-avant)
- [Ancrer les éléments de maquette](https://postext.dev/fr/docs/configuration-page-layout.md#placement-des-éléments)
- [Texte, filets et boîtes dans les maquettes de page](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page)
- [Bandeaux à fond perdu et onglets](https://postext.dev/fr/docs/configuration-page-layout.md#placement-des-éléments)
- [Palette de couleurs sémantique](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs)
- [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)
- [Des citations qui placent les figures](https://postext.dev/fr/docs/document-format.md#renvoi-dans-le-texte-la-forme-principale)
- [Placement des figures](https://postext.dev/fr/docs/document-format.md#placement)
- [Figures et tableaux comme ressources](https://postext.dev/fr/docs/document-format.md#ressources)
- [Livres construits chapitre par chapitre](https://postext.dev/fr/docs/configuration-programmatic-usage.md#mettre-en-page-et-rendre-un-paquet)
- [Pages sur un canvas](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-bitmap)
- [Fond perdu et traits de coupe](https://postext.dev/fr/docs/configuration-page-layout.md#traits-de-coupe)
- [Styles de paragraphe](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)

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

- [`bodyText`](https://postext.dev/fr/docs/configuration-text.md#texte-courant), [`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), [`layout`](https://postext.dev/fr/docs/configuration-page-layout.md#mise-en-page), [`locale`](https://postext.dev/fr/docs/configuration-text.md#césure), [`orderedLists`](https://postext.dev/fr/docs/configuration-text.md#listes-numérotées), [`page`](https://postext.dev/fr/docs/configuration-page-layout.md#page), [`paragraphStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)

**API**

- [`buildDocumentWithFonts`](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)

**Caractères**

- Roboto Serif (OFL-1.1), Archivo (OFL-1.1), Archivo Narrow (OFL-1.1)

## Préparation

### 1 · Accrocher le bandeau et le chiffre à la page

Le code est [la réponse courte](https://postext.dev/fr/cookbook/chapter-opener-bleed-band.md#la-réponse-courte) ci-dessus. Le bandeau et son pied sont accrochés au coin supérieur gauche du fond perdu. Comme toute boîte ou tout filet sans largeur, ils s’étirent jusqu’au bord opposé de ce cadre, si bien qu’ils atteignent la coupe, et le fond perdu dès que `BLEED` est réglé. Le chiffre est accroché au coin supérieur droit de la page : `y: BAND - 0.8 * NUMERAL * PT` pose sa ligne de base sur le pied, et `BEARING` décale sa boîte de 2 mm dans la marge pour compenser l’approche latérale du chiffre, si bien que l’encre s’aligne à peu près sur le bord du texte. Le surtitre est accroché au conteneur. Le titre est accroché sous le surtitre et le chapeau sous le titre, par des ancres `below`, et une maquette de titre renvoie son texte à la ligne par défaut, si bien qu’un titre plus long repousse le chapeau vers le bas. Le chapeau est un élément texte rempli à partir de l’attribut `lead` du titre ; une ligne d’auteur en serait un de plus, `{attr.author}`, enchaîné de la même façon. `minHeight: BAND - TOP + AIR` fixe l’endroit où commence le texte courant. La boîte du chiffre descend de 0,2 × son corps (11,9 mm) sous le pied, et une ouverture réserve la place jusqu’à son élément le plus bas, si bien que `AIR` doit valoir au moins 11,9 mm ; en dessous, ce serait la boîte du chiffre qui fixerait le début du texte.

### 2 · Donner la page au titre de chapitre

```js
// script.js, lignes 124–136
  headings: {
    fontFamily: 'Archivo', color: col('band'),
    levels: [
      // parity: 'odd' keeps openers on rectos, so the numeral's right-edge anchor is the
      // fore-edge. The copy is fitted so no chapter spills a few lines onto a page of its own,
      // which would leave that page and a blank verso before the next opener.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
        // 0: the hidden title's default 0.5 em would add a grid line, and AIR alone sets the gap
        marginBottom: pt(0), advancedDesign: opener },
      { level: 2, fontSize: pt(11.5), lineHeight: pt(LEAD), numberingTemplate: '{1}.{2}',
        marginTop: pt(LEAD), marginBottom: pt(0) }, // one grid line above, none below
    ],
  },
```

`span: 'page'` ouvre chaque chapitre sur une nouvelle page et compose le titre de niveau 1 sur les deux colonnes ; `advancedDesign` dessine l’ouverture à la place du texte du titre, et le texte courant commence sous la hauteur réservée. `breakBefore` avec `parity: 'odd'` place chaque ouverture sur un recto, où l’ancre de droite du chiffre est le grand fond. Au niveau 2, `numberingTemplate: '{1}.{2}'` numérote les sections 3.1, 3.2… dans la couleur du bandeau ; un niveau 3 avec `'{1}.{2}.{3}'` continuerait en 3.1.1.

### 3 · Nommer une seule fois la couleur du chapitre

```js
// script.js, lignes 16–34
const palette = {
  ink: '#1b1f23', // text: a cool near-black
  band: '#c2562b', // the chapter colour: band, tab, section numbers, references, captions
  band2: '#2e6f73', // the second chapter colour, a teal; the sea in Figure 3.1
  land: '#d5cbc0', // the crust in the drawings
  muted: '#6b6259', // running heads and the colophon
  paper: '#ffffff', // type on the band
};
// col(id): a colour linked to its palette entry, with the entry's hex beside the link.
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 band, so nothing prints blue.
  { id: 'main-color', name: 'band (defaults)', value: { hex: palette.band, model: 'hex' } },
];
// {style="teal"} on a heading line swaps 'band' up to the next level-1 heading: the style's
// palette reaches design slots through their link and repaints every text colour equal to
// the base 'band'.
const headingStyles = [{ id: 'teal', palette: { band: palette.band2 } }];
```

Chaque couleur de la configuration est liée à l’une de ces entrées. Les valeurs par défaut du moteur sont liées à `main-color`, si bien que faire pointer cette entrée sur la couleur du bandeau recolore aussi toutes les valeurs par défaut que la configuration laisse non réglées. La ligne de titre du chapitre 4, `# Fire from \\ Below {style="teal" topic="Volcanoes" lead="…"}`, choisit le style, qui remplace `band` jusqu’au titre de niveau 1 suivant. Les emplacements de maquette suivent ce remplacement par leur lien de palette, et les couleurs de texte égales au `band` de base sont repeintes, si bien que l’onglet, les numéros de section et de liste, les renvois, l’étiquette de légende et le folio en pied passent au bleu canard sur les [pages 43 et 44](https://postext.dev/cookbook/chapter-opener-bleed-band/en/p03.webp?v=48a51403). La même ligne remplit `{attr.topic}` et `{attr.lead}`, et son `\\` coupe `{titleText}` là où le veut l’auteur ([attributs de titre](https://postext.dev/fr/docs/document-format.md#attributs-de-titre), [sauts de ligne dans les titres](https://postext.dev/fr/docs/document-format.md#sauts-de-ligne-dans-les-titres)).

### 4 · Laisser le premier renvoi placer chaque figure

```js
// script.js, lignes 314–329
const figure = (id, placement, caption) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0,
  updatedAt: 0, svg: { fileId: `${id}.svg`, width: ART[id].width, height: ART[id].height },
  placement, caption, altText: ALT[id] }); // altText: read aloud in a PDF or HTML edition
const resources = [
  // 'auto' may take the citing page's foot, 'top' never can (gotcha: top-float-next-page)
  figure('plate-cycle', { position: 'auto', span: 'page' },
    t({ en: 'A plate’s life in cross-section: born at a ridge (left), it cools as it drifts away '
      + 'and sinks under a continent; dots mark earthquakes.',
    es: 'La vida de una placa, en corte: nace en la dorsal (izquierda), se enfría al alejarse y '
      + 'se hunde bajo el continente; los puntos son terremotos.' })),
  figure('two-volcanoes', { position: 'bottom', span: 'page' }, // under the text on a last page
    t({ en: 'Two volcanoes in cross-section: a broad shield built of runny basalt flows (left) '
      + 'and a steep stratovolcano of lava and ash layers (right).',
    es: 'Dos volcanes en corte: un escudo ancho, de coladas de basalto fluido (izquierda), y un '
      + 'estratovolcán de capas de lava y ceniza (derecha).' })),
];
```

Un `:ref` numérote sa figure et la place dans le premier emplacement libre après le paragraphe qui la cite. La figure 3.1 est en `auto`. Elle est citée sur l’ouverture pendant que les deux colonnes ont encore de la place, si bien qu’elle prend le pied de cette page ; en flottant `top`, elle aurait ouvert la page 42, car un flottant ne monte jamais au-dessus de son propre renvoi. La figure 4.1 est citée bas dans la deuxième colonne de la page 43, où une bande sur toute la largeur de la page ne tient plus, si bien que `bottom` l’envoie en page 44. C’est la dernière page du chapitre, et sur une dernière page le moteur remonte les flottants de pied juste sous les dernières lignes de texte, sans espace au-dessus.

### 5 · Titres courants, onglet et folio en pied

```js
// script.js, lignes 80–109
const HEAD = 15.5; // mm from the trim to the folio's and the head's baseline: 8.5 above TOP
const INSET = 9; // mm from the folio's edge to the head: room for a three-digit folio
const TAB = { y: 36, w: 9, h: 26 }; // the thumb tab, mm: its top, its width, its height
const tabSize = (w) => ({ width: mm(w), height: mm(TAB.h) });
const baseline = (size) => mm(HEAD - 0.96 * size * PT); // box: 1.2 × size, baseline 0.8 down
const label = { fontFamily: 'Archivo Narrow', fontSize: pt(7.5), fontWeight: 600,
  letterSpacing: pt(1.3), textTransform: 'uppercase', color: col('muted') };
const folio = { fontFamily: 'Archivo', fontSize: pt(9), fontWeight: 800, color: col('ink') };
const side = (parity) => { // the fore-edge is a verso's left edge and a recto's right edge
  const [edge, s] = parity === 'even' ? ['left', 1] : ['right', -1]; // s: into the page
  const at = (to, x, y, size) => ({ anchor: { to, edge: `top-${edge}` },
    offset: { x: mm(s * x), y }, ...(size && { size }) });
  return [
    { kind: 'text', id: `folio-${parity}`, content: '{pageNumber}', ...folio,
      placement: at('page', OUTER, baseline(9)) },
    // Textbooks name the chapter on the verso and the section on the recto; there is no
    // section placeholder, so the recto names the book.
    { kind: 'text', id: `head-${parity}`, content: s > 0 ? '{chapterTitle}' : '{title}',
      ...label, placement: at('page', OUTER + INSET, baseline(7.5)) },
    { kind: 'box', id: `tab-${parity}`, style: { backgroundColor: col('band') }, // off the trim
      placement: at('bleed', 0, mm(BLEED + TAB.y), tabSize(BLEED + TAB.w)) },
    { kind: 'text', id: `tab-no-${parity}`, content: '{chapterNumber}', ...folio, fontSize: pt(13),
      color: col('paper'), // design text centres by default: here, on the trimmed tab
      placement: at('page', 0, mm(TAB.y), tabSize(TAB.w)) },
  ].map((element) => ({ ...element, parity, pages: 'body' })); // never on an opener
};
const header = { elements: [...side('even'), ...side('odd')] };
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
  ...folio, color: col('band'), pages: 'opener', // centred by its anchor
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(10) } } }] };
```

Chaque élément d’en-tête est accroché à un coin supérieur de la page et filtré par `parity`, si bien que le folio et l’onglet se trouvent toujours côté grand fond. La boîte de l’onglet est accrochée, elle, au coin du fond perdu et s’élargit avec `BLEED`, si bien qu’elle reste à fond perdu après la coupe. `pages: 'body'` tient tous ces éléments à l’écart des ouvertures, qui reçoivent un folio en pied depuis le pied de page. Le verso nomme le chapitre avec `{chapterTitle}`, sur une seule ligne, car le `\\` ne coupe le titre que dans l’ouverture. Les manuels nomment d’ordinaire la section au recto. Postext n’a pas d’espace réservé pour la section, si bien que le titre courant du recto imprime le `{title}` du livre ; ces quatre pages ne le montrent jamais, car leurs deux rectos sont des ouvertures.

### 6 · Commencer le livre au chapitre 3

```js
// script.js, lignes 340–345
const continuation = { pageNumbering: { startAt: 41 }, // an odd folio: page 1 is still a recto
  headings: { h1: 2, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } }; // the next # is chapter 3
const doc = await buildDocumentWithFonts({ markdown, resources, continuation }, config(),
  kitFonts(FONTS));
showPages(doc, { title: t({ en: 'Chapter opener on a full-bleed band',
  es: 'Apertura de capítulo sobre banda a sangre' }) });
```

Ces pages sont les chapitres 3 et 4 d’un livre plus long, si bien que `buildDocument` reçoit une `continuation` avec les compteurs tels que les ont laissés les chapitres 1 et 2. Avec `h1: 2` dans ses `headings`, le `#` suivant est le chapitre 3 : `{chapterNumber}` imprime 3, les sections se numérotent à partir de 3.1 et la première figure devient 3.1. `pageNumbering.startAt: 41` fait commencer les folios à 41. La première page reste un recto quel que soit son folio, car la parité se compte sur la page physique et `pageIndexOffset` reste à 0 ; le premier folio, 41, est impair pour concorder.

## 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/chapter-opener-bleed-band

### script.js

```js
// ═══ Postext Cookbook · Nº 003 · Chapter opener on a full-bleed band ══════════════
// https://postext.dev/en/cookbook/chapter-opener-bleed-band
// Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0)
// Fonts: Roboto Serif, Archivo, Archivo Narrow (SIL OFL 1.1) · Needs postext ≥ 1.25.0
// Two chapters of a geology textbook. Every level-1 heading becomes a colour band bled off the
// top of the page, its number standing on the band's foot; a heading style recolours chapter 4.
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'chapter-opener-bleed-band';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: semantic colours, and the style that swaps a chapter's
const palette = {
  ink: '#1b1f23', // text: a cool near-black
  band: '#c2562b', // the chapter colour: band, tab, section numbers, references, captions
  band2: '#2e6f73', // the second chapter colour, a teal; the sea in Figure 3.1
  land: '#d5cbc0', // the crust in the drawings
  muted: '#6b6259', // running heads and the colophon
  paper: '#ffffff', // type on the band
};
// col(id): a colour linked to its palette entry, with the entry's hex beside the link.
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 band, so nothing prints blue.
  { id: 'main-color', name: 'band (defaults)', value: { hex: palette.band, model: 'hex' } },
];
// {style="teal"} on a heading line swaps 'band' up to the next level-1 heading: the style's
// palette reaches design slots through their link and repaints every text colour equal to
// the base 'band'.
const headingStyles = [{ id: 'teal', palette: { band: palette.band2 } }];
// #endregion

// #region answer: the opener: a bleed band, the numeral on its foot, texts from the heading
const TOP = 24; // top margin, mm: the opener's container starts here
const OUTER = 16; // outer margin, mm: the numeral and the folios line up on it
const BLEED = 0; // mm: 0 on screen; about 3 for print, which also switches on page.cutLines
const BAND = 100; // mm from the trim to the foot of the band
const NUMERAL = 168; // pt; the baseline of a design text sits 0.8 down its line box
const PT = 25.4 / 72; // mm in a point
const AIR = 12; // mm, foot to text: ≥ 0.2 × NUMERAL × PT ≈ 11.9, the numeral box below its baseline
const BEARING = 2; // mm past the text edge: a rough optical nudge, as each digit's bearing differs
const bleedTop = { to: 'bleed', edge: 'top-left' }; // the trim's corner, pushed out by BLEED
// Design text centres by default; in a heading design it wraps by default too.
const onBand = { color: col('paper'), align: 'left' };
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' }, offset: { y: mm(y) },
  size: { width: mm(width) } }); // chained: a longer title pushes the standfirst down
const opener = { enabled: true,
  // The text starts below the lowest element or minHeight (gotcha: opener-reserves-anchored).
  minHeight: mm(BAND - TOP + AIR), // clears the numeral, so the text starts AIR below the foot
  slot: {
    elements: [ // array order is paint order: the band first, the type on top
      { kind: 'box', id: 'band', style: { backgroundColor: col('band') }, placement: {
        anchor: bleedTop, size: { height: mm(BLEED + BAND) } } }, // no width: runs to the far edge
      { kind: 'rule', id: 'foot', color: col('ink'), thickness: mm(1.6), placement: {
        anchor: bleedTop, offset: { y: mm(BLEED + BAND) } } },
      { kind: 'text', id: 'numeral', content: '{chapterNumber}', ...onBand,
        fontFamily: 'Archivo', fontWeight: 800, fontSize: pt(NUMERAL),
        lineHeight: 1, // a box as tall as the type: the y below counts on it
        placement: { anchor: { to: 'page', edge: 'top-right' }, // the fore-edge of a recto
          offset: { x: mm(BEARING - OUTER), y: mm(BAND - 0.8 * NUMERAL * PT) } } }, // on the foot
      { kind: 'text', id: 'kicker', ...onBand, fontFamily: 'Archivo Narrow', fontWeight: 600,
        content: `${t({ en: 'Chapter', es: 'Capítulo' })} {chapterNumber} · {attr.topic}`,
        fontSize: pt(8.5), letterSpacing: pt(2), textTransform: 'uppercase',
        placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(7) } } },
      { kind: 'text', id: 'title', content: '{titleText}', ...onBand, // breaks at the \\
        fontFamily: 'Archivo', fontWeight: 800, fontSize: pt(34), lineHeight: 1.02,
        placement: below('kicker', 3, 118) },
      { kind: 'text', id: 'lead', content: '{attr.lead}', ...onBand, fontFamily: 'Roboto Serif',
        italic: true, fontSize: pt(10.5), lineHeight: 1.36, placement: below('title', 6, 100) },
    ],
  },
}; // hook-up: headings.levels[0] = { span: 'page', breakBefore, advancedDesign: opener }
// #endregion

// #region heads: folios and heads on body pages, a thumb tab that bleeds, drop folios
const HEAD = 15.5; // mm from the trim to the folio's and the head's baseline: 8.5 above TOP
const INSET = 9; // mm from the folio's edge to the head: room for a three-digit folio
const TAB = { y: 36, w: 9, h: 26 }; // the thumb tab, mm: its top, its width, its height
const tabSize = (w) => ({ width: mm(w), height: mm(TAB.h) });
const baseline = (size) => mm(HEAD - 0.96 * size * PT); // box: 1.2 × size, baseline 0.8 down
const label = { fontFamily: 'Archivo Narrow', fontSize: pt(7.5), fontWeight: 600,
  letterSpacing: pt(1.3), textTransform: 'uppercase', color: col('muted') };
const folio = { fontFamily: 'Archivo', fontSize: pt(9), fontWeight: 800, color: col('ink') };
const side = (parity) => { // the fore-edge is a verso's left edge and a recto's right edge
  const [edge, s] = parity === 'even' ? ['left', 1] : ['right', -1]; // s: into the page
  const at = (to, x, y, size) => ({ anchor: { to, edge: `top-${edge}` },
    offset: { x: mm(s * x), y }, ...(size && { size }) });
  return [
    { kind: 'text', id: `folio-${parity}`, content: '{pageNumber}', ...folio,
      placement: at('page', OUTER, baseline(9)) },
    // Textbooks name the chapter on the verso and the section on the recto; there is no
    // section placeholder, so the recto names the book.
    { kind: 'text', id: `head-${parity}`, content: s > 0 ? '{chapterTitle}' : '{title}',
      ...label, placement: at('page', OUTER + INSET, baseline(7.5)) },
    { kind: 'box', id: `tab-${parity}`, style: { backgroundColor: col('band') }, // off the trim
      placement: at('bleed', 0, mm(BLEED + TAB.y), tabSize(BLEED + TAB.w)) },
    { kind: 'text', id: `tab-no-${parity}`, content: '{chapterNumber}', ...folio, fontSize: pt(13),
      color: col('paper'), // design text centres by default: here, on the trimmed tab
      placement: at('page', 0, mm(TAB.y), tabSize(TAB.w)) },
  ].map((element) => ({ ...element, parity, pages: 'body' })); // never on an opener
};
const header = { elements: [...side('even'), ...side('odd')] };
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
  ...folio, color: col('band'), pages: 'opener', // centred by its anchor
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(10) } } }] };
// #endregion

const LEAD = 13.2; // body leading in pt: the baseline grid
const config = () => ({
  locale: t({ en: 'en-us', es: 'es' }), // hyphenation, and "Figura" in the Spanish captions
  colorPalette, headingStyles,
  page: { width: mm(210), height: mm(280), dpi: 150, // a textbook trim; 150 dpi is for the screen
    cutLines: { enabled: BLEED > 0, bleed: mm(BLEED) }, // bleed and crop marks once BLEED is set
    margins: { top: mm(TOP), bottom: mm(22), left: mm(20), right: mm(OUTER), mirror: true } },
  layout: { layoutType: 'double', gutterWidth: mm(8) },
  bodyText: { fontFamily: 'Roboto Serif', fontSize: pt(9.3), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('band'),
    textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false }, // book texture
  // #region levels: the H1 takes the page and draws the opener; sections number in the band
  headings: {
    fontFamily: 'Archivo', color: col('band'),
    levels: [
      // parity: 'odd' keeps openers on rectos, so the numeral's right-edge anchor is the
      // fore-edge. The copy is fitted so no chapter spills a few lines onto a page of its own,
      // which would leave that page and a blank verso before the next opener.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
        // 0: the hidden title's default 0.5 em would add a grid line, and AIR alone sets the gap
        marginBottom: pt(0), advancedDesign: opener },
      { level: 2, fontSize: pt(11.5), lineHeight: pt(LEAD), numberingTemplate: '{1}.{2}',
        marginTop: pt(LEAD), marginBottom: pt(0) }, // one grid line above, none below
    ],
  },
  // #endregion
  orderedLists: { fontFamily: 'Archivo', color: col('band'), // numbers bold by default
    marginTop: pt(0), marginBottom: pt(0) },
  captionStyle: { fontFamily: 'Archivo Narrow', fontSize: pt(8.5), labelColor: col('band'),
    gap: mm(2.2) }, // the text in bodyText's ink, the label bold in the chapter colour
  paragraphStyles: [{ id: 'colophon', fontFamily: 'Archivo Narrow', fontSize: pt(7.5),
    lineHeight: pt(10), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0),
    marginTop: pt(LEAD) }],
  header, footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Earth in Motion"
subtitle: "An Introduction to Geology"
---

# The Restless \\ Crust {topic="Plate tectonics" lead="The ground under your feet is moving at about the speed your fingernails grow. This chapter follows the plates that carry the continents and the oceans, and the seams where they part, collide and grind past each other."}

The Earth’s surface is a mosaic of rigid plates, some of them larger than a continent, that move slowly but without pause over the hotter rock beneath them. New sea floor forms along the ridges in the middle of the oceans and is destroyed where it sinks back into the mantle at the deep trenches (:ref{id="plate-cycle"}). The continents travel with the plates like luggage on a conveyor belt, and nearly every earthquake, volcano and mountain range on the planet lies where two plates meet.

The idea took a long time to be accepted. In 1915 the German meteorologist Alfred Wegener noticed that the coasts on either side of the Atlantic fit together like the pieces of a jigsaw, and argued that the continents had once been joined in a single landmass. He pointed to fossils of the same land reptiles on both sides of the ocean, rock formations that matched across it and the scratches of ancient glaciers in what are now the tropics. What he could not find was a force strong enough to push a continent through solid rock, and for half a century most geologists rejected the idea. The answer came from the ocean floor, where surveys after the Second World War revealed long undersea mountain chains, and rocks that grow steadily older with distance from their crests.

## A planet in pieces

The outer shell of the Earth, the lithosphere, is cold and rigid. It takes in the crust and the uppermost part of the mantle, and it is about a hundred kilometres thick: much more under the old cores of the continents, much less under young oceans. Beneath it lies the asthenosphere, rock that is solid but hot enough to flow, very slowly, like warm wax. The lithosphere is broken into seven large plates and a dozen or so smaller ones, and each of them moves as a single slab over the softer layer below.

Plates carry two kinds of crust. Oceanic crust is thin, about seven kilometres, and made of dark, dense basalt. Continental crust is 30 to 50 kilometres thick and made largely of lighter, granite-like rock. When plates meet, the denser ocean floor sinks into the mantle and the continent stays on top. That is why the oldest ocean floor anywhere is only about 180 million years old, while rocks on the continents reach back four billion years.

Plates move between one and fifteen centimetres a year. At five centimetres a year a plate travels fifty kilometres in a million years, and the Atlantic has opened by several thousand kilometres since the age of the dinosaurs.

## Where plates meet

Geologists recognise three kinds of plate boundary, named for the way the plates move against each other:

1. **Divergent boundaries**, where plates pull apart and magma rises to fill the gap. Most lie along the mid-ocean ridges; Iceland is a piece of the Mid-Atlantic Ridge that stands above the sea.
2. **Convergent boundaries**, where plates collide. When ocean floor meets a continent, the denser plate slides beneath the other and sinks into the mantle, a process called *subduction*. When two continents meet, neither sinks, and the crust crumples into ranges such as the Himalaya.
3. **Transform boundaries**, where plates slide past each other sideways: they lock, strain and then slip again. The San Andreas Fault in California is the best-known example on land.

Each kind has its own pattern of earthquakes and volcanoes. Where plates part, the earthquakes are shallow and the eruptions quiet, pouring out runny basalt. Where they collide, they cause the most powerful earthquakes on Earth, open deep trenches and raise chains of explosive volcanoes. Where they slide past each other, earthquakes are frequent but volcanoes are almost absent, because crust is neither made nor destroyed there.

## Reading the evidence

The clearest evidence for plate tectonics comes from the magnetism of the sea floor. As basalt cools at a ridge, tiny crystals of magnetite line up with the Earth’s magnetic field, like compass needles frozen in place. The field reverses its direction every few hundred thousand years on average, so the sea floor records the reversals as stripes of normal and reversed magnetism. In 1963 Fred Vine and Drummond Matthews explained the stripes, and by 1966 surveys had shown that those on either side of a ridge are mirror images of each other, which is what you would expect if new floor forms at the crest and moves away in both directions.

The earthquake record agrees. On a map, earthquakes fall in narrow belts along the plate boundaries. Beneath the trenches they fall on an inclined plane that dips under the neighbouring plate to depths of 700 kilometres, marking the path of the sinking slab.

Chains of volcanic islands add a third line of evidence. The Hawaiian Islands grew over a hot spot, a plume of unusually hot rock rising from deep in the mantle that stays put while the plate slides over it. Each volcano is carried away from the plume in turn and dies, and a new one grows in its place. The volcanoes line up like a trail of footprints: the Big Island still erupts, Kauai is about five million years old, and the drowned volcanoes beyond Midway are older still. The direction and the spacing of the chain record where the Pacific Plate has gone, and how fast.

Today the motion can be measured directly. GPS receivers bolted to bedrock on every continent fix their own positions to within a millimetre or two, and they show Hawaii creeping north-west towards Japan and Australia moving so fast, about seven centimetres a year, that the country has to revise its official coordinates every few decades.

Wegener thought that the continents ploughed through the rock of the ocean floor. In fact they ride on the plates, and the plates are driven by the slow escape of heat from the Earth’s interior. The next chapter turns to the places where that heat reaches the surface.

# Fire from \\ Below {style="teal" topic="Volcanoes" lead="Volcanoes are where the heat of the Earth’s interior reaches the surface. This chapter asks why solid rock melts, why some magma pours out quietly while some explodes, and how the shape of a mountain records the way it erupts."}

About 1,350 volcanoes on land are considered potentially active, meaning that they have erupted in the last 10,000 years, and many more lie hidden on the sea floor. Three quarters of the active ones stand around the rim of the Pacific Ocean, the so-called Ring of Fire, above the subduction zones described in the previous chapter (:ref{id="plate-cycle"}). Most of the others line the mid-ocean ridges or sit over isolated hot spots such as Hawaii.

Every volcano is a pile of its own eruptions. Each one adds a layer of lava or ash to the slopes, so the shape of the mountain records how it has erupted over thousands of years. Its eruptions, in turn, depend on where and how its magma formed.

## Where magma comes from

The mantle is almost entirely solid. It melts only when something upsets the balance between temperature and pressure, and there are three ways that happens. At ridges and hot spots, hot rock rises and the pressure on it falls; lower pressure lowers the melting point, so some of the rock melts without getting any hotter. This is *decompression melting*. At subduction zones, the sinking slab releases water into the hot mantle above it, and water, like salt on an icy road, lowers the melting point. This is *flux melting*, and it feeds the volcanoes of the Andes, Japan and the Cascades. Finally, a body of hot magma can melt the crust around it, adding to its own volume and changing its chemistry.

Molten rock is lighter than the rock around it, so it rises and collects in magma chambers a few kilometres below the surface. There it may cool slowly into granite or gabbro and never erupt at all, or it may find a way out through the central conduit or through a crack in the side of the mountain.

## Quiet and violent eruptions

Whether an eruption oozes or explodes depends mostly on two things: how much silica the magma holds and how much gas is dissolved in it. Silica makes magma thick. Basalt, about half silica by weight, is runny, and its gas escapes easily; its eruptions send out rivers of lava, as on Hawaii and Iceland, and build broad, gently sloping shield volcanoes. Magmas richer in silica, such as andesite and rhyolite, are so stiff that the gas cannot get out. Pressure builds until the magma shatters into ash and pumice, and the volcano blows apart.

The cone volcanoes of subduction zones, called *stratovolcanoes*, record both kinds of behaviour. They are built of alternating layers of lava and ash, each layer an eruption, which is why their slopes are steep and their outlines so regular (:ref{id="two-volcanoes"}). Mount Fuji is one. Mount St Helens had the same outline until 18 May 1980, when the north side of the mountain collapsed in a landslide and the sudden release of pressure drove a sideways blast across 600 square kilometres of forest.

The largest explosive eruptions reach far beyond their own region. In 1991 Mount Pinatubo in the Philippines injected about fifteen million tonnes of sulphur dioxide into the stratosphere. There it formed a veil of fine droplets that reflected sunlight back into space and lowered the average temperature of the whole planet by about half a degree Celsius for more than a year.

Volcanologists compare eruptions on the Volcanic Explosivity Index, a scale from 0 to 8 based mainly on the volume of rock and ash thrown out, on which most steps are ten times larger than the one below. The lava fountains of Kilauea rate 0 or 1, Mount St Helens rated 5 and Pinatubo 6. In 1815 Tambora, in Indonesia, rated 7, and the year that followed was remembered in Europe and North America as the year without a summer.

## Living with volcanoes

Some 800 million people live within a hundred kilometres of an active volcano, drawn by fertile soils and geothermal heat, or because their families have always lived there. Most volcanoes give warning before they erupt: swarms of small earthquakes as magma forces its way upwards, a swelling of the ground that satellites can measure to the centimetre, and changes in the gases that seep from the summit. Observatories that watch for these signs have given towns time to evacuate.

In Iceland, geothermal heat from young volcanic rock warms about nine homes in every ten and generates nearly a third of the country’s electricity, and the richest farmland of Java and southern Italy lies on weathered volcanic ash.

Most lava flows move slowly enough for people to walk away; they bury houses, roads and fields but seldom take lives. Far more people have died in pyroclastic flows, fast, scorching avalanches of gas, ash and rock that can race down a mountainside at more than a hundred kilometres an hour, and in mudflows, which form when an eruption melts snow or when rain soaks loose ash.

Scientists can say that an eruption is becoming more likely, but they can rarely say when it will happen or how large it will be, and a false alarm has its own cost when a city has to be emptied. Around Naples, where more than half a million people live in the zone most exposed to Vesuvius, the emergency plan counts on about three days of warning to move them all to safety.

## Check your understanding

1. Why does ocean floor sink at a subduction zone while a continent stays at the surface?
2. Explain how adding water to hot mantle rock can make it melt without heating it.
3. Mount Fuji and Mauna Loa are both volcanoes. Why is one steep and the other broad?
4. What signs warn volcanologists that magma is rising towards the surface?
5. Why can a single large eruption lower the temperature of the whole planet for a year?

:::paragraphs{style="colophon"}
From *Earth in Motion*, an imaginary textbook. Set in Roboto Serif, Archivo and Archivo Narrow (SIL OFL) · Text and drawings: original, CC BY 4.0.
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region art: two drawings made of shapes only; their labels live in the captions
const PX = 10; // SVG pixels per viewBox unit: 348 units = 174 mm, so about 500 dpi in print
const svg = (w, h, body) => ({ width: w * PX, height: h * PX, // the size the resource declares
  markup: `<svg xmlns="http://www.w3.org/2000/svg" width="${w * PX}" height="${h * PX}" `
    + `viewBox="0 0 ${w} ${h}">${body}</svg>` });
const fill = (id, a = 1) => `fill="${palette[id]}" fill-opacity="${a}"`;
const path = (d, paint) => `<path d="${d}" ${paint}/>`;
const poly = (pts, paint) => path(`M${pts.join('L').replaceAll(',', ' ')}Z`, paint);
const dot = (x, y, r, paint) => `<circle cx="${x}" cy="${y}" r="${r}" ${paint}/>`;
const plume = (x, y, w, h, paint, m = y + h / 2, f = y + h * 0.8) => path(`M${x} ${y}`
  + `C${x} ${m} ${x + w} ${m} ${x + w} ${f}A${w} ${h / 5} 0 0 1 ${x - w} ${f}`
  + `C${x - w} ${m} ${x} ${m} ${x} ${y}Z`, paint); // a teardrop of magma, its tip at (x, y)
const arrow = (x, y, dx, id, a = 1, turn = 0, s = Math.sign(dx)) => path( // never a <marker>
  `M${x} ${y - 0.8}h${dx - s * 5}v-1.8l${s * 5} 2.6l${-s * 5} 2.6v-1.8H${x}Z`,
  `${fill(id, a)} transform="rotate(${turn} ${x} ${y})"`);

// Each drawing takes its chapter's colour: a picture's colours are fixed when it is drawn.
function plateCycle(hot) { // a plate is born at a ridge and sinks under a continent
  const top = [[0, 30], [40, 23], [80, 30], [160, 32], [212, 34], [222, 41], [254, 54],
    [300, 74], [348, 96]]; // the sea floor, then the top of the sinking slab
  const under = (d) => top.map(([x, y], i) => [x, y + (d[i] ?? d.at(-1))]);
  const back = (points) => [...points].reverse();
  const crust = under([5]);
  const lith = under([9, 4, 11, 15]);
  const quakes = [226, 239, 252, 265, 278, 291, 304, 317, 330].map((x, i) => // in the slab
    dot(x, 40 + (x - 222) * 0.44 + (i % 3), 1.5, `${fill('ink')} stroke="${palette.paper}" `
      + 'stroke-width=".6"'));
  return svg(348, 102, `<rect y="24" width="348" height="80" ${fill(hot, 0.15)}/>`
    + poly([[0, 16], [238, 16], [222, 41], ...back(top.slice(0, 5))], fill('band2', 0.3))
    + poly([...crust, ...back(lith)], fill('muted', 0.45)) // the plate
    + poly([...top, ...back(crust)], fill('ink', 0.82)) // its oceanic crust
    + poly([[222, 41], [238, 16], [262, 15], [270, 10], [277, 5], [281, 5], [288, 10],
      [296, 14], [318, 12], [327, 9], [336, 12], [348, 10], [348, 50], [300, 45], [258, 44],
      [240, 47]], fill('land')) // the continent and its volcano
    + plume(41, 23, 9, 26, fill(hot)) + plume(279, 5, 5.5, 26, fill(hot)) + quakes.join('')
    + dot(272, 51, 2.2, fill(hot)) + dot(275, 43, 1.7, fill(hot)) // melt rising
    + arrow(34, 19, -26, 'ink', 0.7) + arrow(90, 23, 70, 'ink', 0.7) // the plates part
    + arrow(60, 68, 56, hot, 0.5) + arrow(284, 74, 31, 'paper', 1, 25));
}

function twoVolcanoes(hot) { // a shield (left) and a stratovolcano (right), cut in half
  const G = 70; // the ground line
  const shield = (x, w, h) => `M${x - w} ${G}C${x - w * 0.55} ${G - 2} ${x - w * 0.35} ${G - h} `
    + `${x} ${G - h}C${x + w * 0.35} ${G - h} ${x + w * 0.55} ${G - 2} ${x + w} ${G}Z`; // convex
  const cone = (x, w, h, c = w * 0.055) => `M${x - w} ${G}C${x - w * 0.5} ${G - 3} ${x - w * 0.2} `
    + `${G - h * 0.6} ${x - c} ${G - h}H${x + c}` // concave flanks around a crater
    + `C${x + w * 0.2} ${G - h * 0.6} ${x + w * 0.5} ${G - 3} ${x + w} ${G}Z`;
  const layers = (shape, x, sizes, paints) => sizes.map(([w, h], i) => // eruption on eruption
    path(shape(x, w, h), fill('paper')) + path(shape(x, w, h), paints[i % 2])).join('');
  const ash = [[267, 10, 4], [274, 7, 5.5], [282, 8.5, 5], [289, 5.5, 5], [297, 7, 4.5],
    [304, 5, 3.5], [279, 12, 3.5], [292, 10, 3.5]].map(([x, y, r]) => dot(x, y, r, fill('land')));
  return svg(348, 92, `<rect y="${G}" width="348" height="${92 - G}" ${fill('land')}/>`
    + layers(shield, 91, [[86, 24], [70, 18], [54, 12], [38, 7]], [fill(hot, 0.3), fill(hot, 0.5)])
    + layers(cone, 269, [[74, 56], [61, 46], [48, 36], [35, 26], [22, 16]],
      [fill(hot, 0.55), fill('muted', 0.35)]) // lava, then ash
    + ash.join('') // the explosive one blows ash; the shield only pours lava
    + `<ellipse cx="91" cy="80" rx="24" ry="4.5" ${fill(hot)}/>` // a shallow magma chamber
    + `<ellipse cx="269" cy="86" rx="20" ry="5" ${fill(hot)}/>` // a deeper one
    + path(`M89.5 77V${G - 24}h3V77Z`, fill(hot)) + path(`M267.5 83V${G - 56}h3V83Z`, fill(hot)));
}
const ART = { 'plate-cycle': plateCycle('band'), 'two-volcanoes': twoVolcanoes('band2') };
const ALT = { // the drawings described, for the figures' altText
  'plate-cycle': t({ en: 'A dark plate spreads from a ridge on the left, where magma rises, runs '
    + 'under a pale sea and bends down beneath a continent topped by a volcano; a row of dots '
    + 'along the sinking slab marks earthquakes, and arrows show the plates moving apart.',
  es: 'Una placa oscura nace en una dorsal a la izquierda, donde sube el magma, corre bajo un mar '
    + 'claro y se dobla hacia abajo bajo un continente coronado por un volcán; una fila de puntos '
    + 'a lo largo de la placa que se hunde marca los terremotos, y unas flechas muestran cómo se '
    + 'separan las placas.' }),
  'two-volcanoes': t({ en: 'On the left, a low, broad volcano of thin layers over a shallow magma '
    + 'chamber; on the right, a steep cone of alternating lava and ash layers over a deeper '
    + 'chamber, with a cloud of ash above its crater.',
  es: 'A la izquierda, un volcán bajo y ancho, de capas finas, sobre una cámara magmática poco '
    + 'profunda; a la derecha, un cono empinado de capas alternas de lava y ceniza sobre una '
    + 'cámara más profunda, con una nube de ceniza sobre el cráter.' }),
};
// #endregion

// #region figure: each figure takes the first free slot after its first citation
const figure = (id, placement, caption) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0,
  updatedAt: 0, svg: { fileId: `${id}.svg`, width: ART[id].width, height: ART[id].height },
  placement, caption, altText: ALT[id] }); // altText: read aloud in a PDF or HTML edition
const resources = [
  // 'auto' may take the citing page's foot, 'top' never can (gotcha: top-float-next-page)
  figure('plate-cycle', { position: 'auto', span: 'page' },
    t({ en: 'A plate’s life in cross-section: born at a ridge (left), it cools as it drifts away '
      + 'and sinks under a continent; dots mark earthquakes.',
    es: 'La vida de una placa, en corte: nace en la dorsal (izquierda), se enfría al alejarse y '
      + 'se hunde bajo el continente; los puntos son terremotos.' })),
  figure('two-volcanoes', { position: 'bottom', span: 'page' }, // under the text on a last page
    t({ en: 'Two volcanoes in cross-section: a broad shield built of runny basalt flows (left) '
      + 'and a steep stratovolcano of lava and ash layers (right).',
    es: 'Dos volcanes en corte: un escudo ancho, de coladas de basalto fluido (izquierda), y un '
      + 'estratovolcán de capas de lava y ceniza (derecha).' })),
];
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // text, display and label faces, loaded before the build
  'Roboto Serif': ['400', '400i', '700'], Archivo: ['700', '800'],
  'Archivo Narrow': ['400', '400i', '600', '700'] }; // 400i: the colophon's book title

// ─── 4 · Build & show ───────────────────────────────────────────────────────
for (const [id, art] of Object.entries(ART)) await loadSvg(`${id}.svg`, art.markup);
// #region build: chapters 3 and 4 of a longer book, so the counters start where 2 ended
const continuation = { pageNumbering: { startAt: 41 }, // an odd folio: page 1 is still a recto
  headings: { h1: 2, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } }; // the next # is chapter 3
const doc = await buildDocumentWithFonts({ markdown, resources, continuation }, config(),
  kitFonts(FONTS));
showPages(doc, { title: t({ en: 'Chapter opener on a full-bleed band',
  es: 'Apertura de capítulo sobre banda a sangre' }) });
// #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 · 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

### Approfondir le bandeau

`BAND` fixe la hauteur du bandeau, la position du chiffre et la hauteur réservée au-dessus du texte. Un bandeau plus haut repousse du texte sur la page suivante : recalibrez chaque chapitre (voir la dernière erreur fréquente) ou laissez les chapitres s’enchaîner.

```diff
-const BAND = 100; // mm from the trim to the foot of the band
+const BAND = 128; // mm from the trim to the foot of the band
```

### L’imprimer avec un fond perdu

Avec un fond perdu de 3 mm, le bandeau, son pied et l’onglet dépassent la coupe de 3 mm, le chiffre reste sur le pied, et `page.cutLines` s’active avec la même valeur.

```diff
-const BLEED = 0; // mm: 0 on screen; about 3 for print, which also switches on page.cutLines
+const BLEED = 3; // mm: 0 on screen; about 3 for print, which also switches on page.cutLines
```

### Ajouter une couleur pour un autre chapitre

Chaque couleur de chapitre est une entrée de palette et un style de plus ; écrivez `{style="slate"}` sur la ligne de titre de ce chapitre.

```diff
   band2: '#2e6f73', // the second chapter colour, a teal; the sea in Figure 3.1
+  band3: '#3d4f6b', // a third chapter colour, a slate blue
@@
-const headingStyles = [{ id: 'teal', palette: { band: palette.band2 } }];
+const headingStyles = [{ id: 'teal', palette: { band: palette.band2 } },
+  { id: 'slate', palette: { band: palette.band3 } }];
```

### Laisser les chapitres s’enchaîner

Avec `'any'`, un chapitre s’ouvre sur la page suivante. Après un chapitre qui finit sur un recto, il s’ouvre sur le verso, sans page blanche devant lui, et son chiffre se tient près du dos, puisqu’il est accroché au bord droit de la page. Ces pages-ci ne changent pas, puisque le chapitre 3 finit déjà sur un verso.

```diff
-      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
+      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
```

## Erreurs fréquentes

- **Une ouverture réserve la hauteur jusqu’à son élément ancré à la page le plus bas.** Une ouverture en maquette avancée réserve la hauteur de son élément le plus bas, et les éléments ancrés à la page ou au fond perdu sous le titre comptent aussi : un décor au pied de la page repousse donc le texte à la page suivante. Donnez à un tel élément reserve: false (depuis postext 1.5) : il est peint là où il est ancré et ne réserve rien. Les éléments qui suivent le bandeau lui-même (ancrés à son pied, ou de hauteur 'fill') ne fixent jamais sa hauteur.
- **Valeurs d’attribut : pas de { ni de } ; une valeur avec " entre apostrophes simples.** Une valeur d’attribut s’arrête à l’accolade fermante : elle ne peut donc contenir ni { ni }. Une valeur qui contient un guillemet double se met entre apostrophes simples ; un signe dollar ne pose pas de problème.
- **Un flottant 'top' va à la page qui suit sa citation, sauf s’il peut ouvrir la page qui le cite.** Par défaut, un flottant ne passe jamais au-dessus de son propre renvoi : un flottant 'top' sur toute la largeur cité en page N ouvre donc la page N+1. Réglez placement.citingPage: true sur la ressource ou sur le defaultPlacement de son type (ou layout.floatsAtCitingPage pour tout le livre, depuis postext 1.25) et il ouvre plutôt la page N, le texte au-dessus du renvoi descendant sous lui, quand il tient dans layout.maxTopFraction et que la ligne qui le cite reste sur cette page. Il n’ouvre jamais la première page d’un chapitre ni une page qui suit un saut forcé : là, il ouvre toujours la page suivante, et 'auto' ou 'bottom' peut prendre le pied de la page qui le cite.

- Le bandeau a une hauteur fixe, alors que le surtitre, le titre et le chapeau sont accrochés à la marge supérieure et grandissent vers le bas : un chapeau plus long ou un titre sur trois lignes empiète sur le pied. Limitez les chapeaux à environ cinq lignes, comme le font les deux éditions, ou calculez `BAND` d’après l’ouverture la plus longue.
- La formule du `y` du chiffre suppose `lineHeight: 1`. La ligne de base d’un texte de maquette se trouve aux 0,8 de la hauteur de sa boîte de ligne, et `0.8 * NUMERAL` ne correspond à cette distance que si la boîte est aussi haute que le corps. Changez l’interlignage ou l’ancre, et la formule doit changer avec. `BEARING` se règle à l’œil, car chaque chiffre a sa propre approche latérale (ici, le 3 et le 4 se terminent à 0,8 mm d’écart).
- La palette d’un style recolore les éléments de maquette et le texte. Une image garde les couleurs avec lesquelles elle a été dessinée : la figure 4.1 est bleu canard parce que le code la dessine à partir de `band2`, et une figure terre cuite placée dans le chapitre 4 resterait terre cuite.
- Avec `parity: 'odd'`, un chapitre qui dépasse sa dernière page de quelques lignes laisse une page presque vide et un verso blanc avant l’ouverture suivante. Calibrez le texte, comme le font ici les deux éditions, et vérifiez la dernière page de chaque chapitre dès que le texte ou `BAND` change.

## Crédits

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

## Recettes liées

- [Nº 017 · Cinq ouvertures de chapitre dans un même livre](https://postext.dev/fr/cookbook/five-chapter-openers.md): Une ouverture sur le niveau 1 et quatre styles de titre nommés dans le Markdown ; chaque style change l’accent, certains aussi les marges, colonnes ou folios. · Niveau 3 (Avancé) · Tous genres
- [Nº 001 · Manuel avec une colonne marginale](https://postext.dev/fr/cookbook/textbook-margin-column.md): Une page à colonne et demie dont la colonne extérieure ne prend que des flottants : figures span 'side' et gloses s’y empilent, captionSide y met les légendes. · Niveau 3 (Avancé) · Manuels scolaires
- [Nº 007 · Un livre à partir de chapitres séparés](https://postext.dev/fr/cookbook/book-from-chapters.md): buildBundle compose cinq fichiers Markdown en un seul livre : chaque chapitre s’ouvre sur un recto, et les numéros de page, de chapitre et de figure se suivent. · Niveau 3 (Avancé) · Manuels, guides et ouvrages de référence
