# Roman broché : ouvertures basses et chapitres au recto

> Madame Gervaisais en broché de 140 × 216 mm : couverture peinte et colophon sans titres courants, puis chapitres ouverts bas au recto sous un chiffre italique.

- Version HTML: https://postext.dev/fr/cookbook/trade-paperback-novel
- Recette Nº 038 · Publications complètes · Niveau 2 (Intermédiaire) · Sorties: Canvas
- Genres: Fiction, théâtre et prose littéraire
- Nécessite postext ≥ 1.25.0 · testée avec 1.33.4 le 2026-10-15
- Pages: [1](https://postext.dev/cookbook/trade-paperback-novel/fr/p01.webp?v=480a0753), [2](https://postext.dev/cookbook/trade-paperback-novel/fr/p02.webp?v=480a0753), [3](https://postext.dev/cookbook/trade-paperback-novel/fr/p03.webp?v=480a0753), [4](https://postext.dev/cookbook/trade-paperback-novel/fr/p04.webp?v=480a0753), [5](https://postext.dev/cookbook/trade-paperback-novel/fr/p05.webp?v=480a0753), [6](https://postext.dev/cookbook/trade-paperback-novel/fr/p06.webp?v=480a0753), [7](https://postext.dev/cookbook/trade-paperback-novel/fr/p07.webp?v=480a0753), [8](https://postext.dev/cookbook/trade-paperback-novel/fr/p08.webp?v=480a0753), [9](https://postext.dev/cookbook/trade-paperback-novel/fr/p09.webp?v=480a0753)
- Ouvrir dans le Sandbox: https://postext.dev/fr/sandbox#recipe=trade-paperback-novel&lang=fr (.postext: https://postext.dev/cookbook/trade-paperback-novel/fr/trade-paperback-novel.postext)
- Dernière mise à jour: 2026-10-10
- Autres langues: [en](https://postext.dev/en/cookbook/trade-paperback-novel.md), [es](https://postext.dev/es/cookbook/trade-paperback-novel.md), [ca](https://postext.dev/ca/cookbook/trade-paperback-novel.md), [pt](https://postext.dev/pt/cookbook/trade-paperback-novel.md), [zh](https://postext.dev/zh/cookbook/trade-paperback-novel.md), [ja](https://postext.dev/ja/cookbook/trade-paperback-novel.md), [ar](https://postext.dev/ar/cookbook/trade-paperback-novel.md)

## En bref

Les deux premiers chapitres d’un roman français en livre broché. Chaque chapitre commence sur une page de droite, plus bas, sous un grand numéro de chapitre.

## Ce que vous allez composer

Neuf pages d’un roman broché de 140 × 216 mm contiennent les deux premiers chapitres de *Madame Gervaisais* (1869), d’Edmond et Jules de Goncourt. La couverture, une peinture générée par des modèles de diffusion et chargée en JPEG, montre la mer entre les troncs de deux arbres : un voilier à l’horizon, et une ombrelle blanche qui monte de la plage par un chemin entre des fleurs jaunes et blanches. Le colophon se trouve au pied de son verso. Chaque chapitre s’ouvre sur un recto avec son texte à 81 mm du haut, sous un chiffre italique de 48 pt, un filet fin rouille de 18 mm et la première phrase du chapitre en petites capitales, centrée : la réplique qui ouvre le chapitre I tient sur une ligne, et la première phrase du chapitre II, coupée à la virgule, se répartit selon le sens sur deux lignes. En page 5, la carte de visite de l’héroïne, *madame gervaisais.*, est un paragraphe centré en petites capitales. Le chapitre I se termine sur cette page par un cul-de-lampe en camomille : la page 6 reste blanche, sans titre courant ni folio, et le chapitre II s’ouvre en page 7 et se termine en page 9. Les titres courants portent les auteurs sur les versos et le titre en italique sur les rectos.

**Cette recette répond à:**

- Comment ouvrir chaque chapitre sur une page de droite, au tiers de la hauteur, comme dans un roman broché ?
- Comment ajouter à une ouverture une ligne d’auteur, un chapeau ou une attaque avec lettrine ?
- Comment insérer des pages blanches voulues, ou commencer une section sur une nouvelle double page ?
- Comment régler les titres courants : titre du livre sur la page de gauche, titre du chapitre sur celle de droite, numéro de page à l’extérieur ?
- Comment faire une couverture, un faux-titre, une page de titre et un colophon ?
- Comment ajouter de l’espace vertical entre deux blocs, quand les lignes vides n’y font rien ?
- Comment composer des illustrations non numérotées : ornements, vignettes, logos ?

## La réponse courte

```js
// script.js, lignes 47–77
// # I {lead="A green and yellow parrot, … kept repeating over and over:"}
// The heading's text is the numeral, printed by {titleText}; the chapter's first sentence
// travels as the heading's lead attribute, printed by {attr.lead} in small capitals: a
// centred display, which a \n in the attribute breaks by sense.
const [LEAD_AT, BODY_AT] = [9, 12]; // grid lines: the lead's top; where the text starts
const NUMERAL = 48; // pt
const RULE_Y = (LEAD_AT - 1.5) * LEAD * MM_PER_PT; // mm: a line and a half above the lead
const NUMERAL_Y = RULE_Y - NUMERAL * MM_PER_PT - 3; // mm: the numeral's box ends 3 mm above it
const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y } });
const opener = {
  enabled: true,
  // The two-line lead ends on line LEAD_AT + 2; minHeight leaves a blank line under it and
  // starts the text on line BODY_AT (a one-line lead would leave two).
  minHeight: line(BODY_AT),
  slot: { elements: [
    { kind: 'text', id: 'numeral', content: '{titleText}', fontFamily: DISPLAY, italic: true,
      fontWeight: 500, fontSize: pt(NUMERAL), lineHeight: 1, color: col('ink'),
      align: 'center', placement: centred(mm(NUMERAL_Y)) },
    { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.6),
      color: col('rubric'), placement: { ...centred(mm(RULE_Y)), size: { width: mm(18) } } },
    { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: LABEL, fontWeight: 600,
      fontSize: pt(9.5), letterSpacing: pt(0.6), color: col('ink'), align: 'center',
      lineHeight: LEAD / 9.5, // one grid line to each line of the lead
      placement: { ...centred(line(LEAD_AT)), size: { width: mm(MEASURE) } } },
  ] },
};
// Every chapter opens on a recto: 'odd' adds a blank verso only after a chapter that ends on
// a recto, where the default 'always-odd' leaves a blank page after every chapter.
// marginBottom 0: the level's default 0.5 em would start the text a line lower.
const chapter = { level: 1, breakBefore: { enabled: true, parity: 'odd' },
  marginBottom: pt(0), italic: true, 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.
- [Chapitres qui commencent en belle page](https://postext.dev/fr/docs/configuration-text.md#saut-avant): Un niveau de titre commence une nouvelle page : la suivante, le recto suivant ou le verso suivant, avec une page blanche ajoutée si nécessaire.
- [Titres courants par section](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre): Un style de titre porte son propre en-tête et son propre pied de page, si bien que les pages liminaires, les sections ou les lettres d’un dictionnaire ont leur propre habillage.

**Utilise aussi**

- [Marges en miroir](https://postext.dev/fr/docs/configuration-page-layout.md#marges-en-miroir)
- [Styles de titre](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Couvertures, pages de titre et colophons](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Attributs de titre](https://postext.dev/fr/docs/document-format.md#attributs-de-titre)
- [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)
- [Images dans les maquettes de page](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-image)
- [Géométrie de section](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Titres courants selon le rôle de la page](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-de-texte)
- [Titres courants et folios](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page)
- [Métadonnées du document](https://postext.dev/fr/docs/document-format.md#frontmatter)
- [Styles de paragraphe](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)
- [Espace vertical explicite](https://postext.dev/fr/docs/document-format.md#space)
- [Figures et tableaux comme ressources](https://postext.dev/fr/docs/document-format.md#ressources)
- [Types de ressource personnalisés](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources)
- [Des figures exactement ici](https://postext.dev/fr/docs/document-format.md#insertion-en-bloc-facultative-placement-explicite-dans-le-texte)
- [Couleur du papier](https://postext.dev/fr/docs/configuration-page-layout.md#page)
- [Veuves, orphelines et fins de paragraphe trop courtes](https://postext.dev/fr/docs/configuration-text.md#orphelines-veuves-lignes-creuses-et-règles-de-maintien)

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

- [`bodyText`](https://postext.dev/fr/docs/configuration-text.md#texte-courant), [`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), [`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)

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

- Crimson Pro (OFL-1.1), Cormorant Garamond (OFL-1.1), Cormorant SC (OFL-1.1)

## Préparation

### 1 · L’ouverture vient du titre et de son attribut

Le code est dans [la réponse courte](https://postext.dev/fr/cookbook/trade-paperback-novel.md#la-réponse-courte) plus haut. Les chapitres des Goncourt n’ont pas de titre : chaque titre ne contient que son chiffre (`# I`), que `{titleText}` imprime. `{attr.lead}` imprime l’attribut `lead` du titre, qui contient la première phrase du chapitre ; le paragraphe Markdown sous le titre commence par la phrase suivante ([attributs de titre](https://postext.dev/fr/docs/document-format.md#attributs-de-titre)). L’attaque est un texte de titrage centré. Celle du chapitre I, la réplique *— Quarante scudi ?*, tient sur une ligne et n’a pas de `\n`. La première phrase du chapitre II est trop longue pour une attaque : l’attribut s’arrête à la virgule après *soir-là*, son `\n` le coupe selon le sens après *Minerve*, et le paragraphe sous le titre reprend la phrase à *et de place en place*. La boîte de l’attaque commence neuf lignes de grille sous le haut du bloc de texte, et un `minHeight` de 12 lignes laisse une ligne blanche sous une attaque de deux lignes et deux sous une attaque d’une ligne : le texte des deux chapitres commence sur la même ligne de grille. Sans `minHeight`, le texte commence juste sous l’attaque ([largeur et maquette avancée](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)). `marginBottom: pt(0)` remplace la marge par défaut d’un demi-cadratin du niveau, qui ferait descendre le texte d’une ligne.

`breakBefore` avec la parité `'odd'` envoie chaque chapitre sur un recto, avec un verso blanc seulement après un chapitre qui finit sur un recto ([saut avant](https://postext.dev/fr/docs/configuration-text.md#saut-avant)). Pour forcer un recto là où il n’y a pas de titre, écrivez `:::pagebreak{parity="odd"}` dans le Markdown ; dans une mise en page de deux colonnes ou plus, `:::columnbreak` fait passer le texte à la colonne suivante ([`:::pagebreak`](https://postext.dev/fr/docs/document-format.md#pagebreak)).

### 2 · Une page de broché en lignes entières

```js
// script.js, lignes 33–43
const TRIM = { width: 140, height: 216 }; // mm: 5½ × 8½ in
const [TOP, INNER, OUTER] = [22, 20, 15.5]; // mm; mirrored, so INNER is left on a recto
const LINES = 35; // every full page ends on the same line
const MEASURE = TRIM.width - INNER - OUTER; // 104.5 mm: about 69 characters of Crimson Pro
const MM_PER_PT = 25.4 / 72;
const page = {
  sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
  backgroundColor: col('paper'),
  margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - LINES * LEAD * MM_PER_PT),
    left: mm(INNER), right: mm(OUTER), mirror: true },
};
```

La marge de pied est ce que laisse le format de 216 mm après la marge de tête et 35 lignes de 14 pt : chaque page pleine finit sur la même ligne, comme les pages 3, 4 et 8. La page 7 finit une ligne plus haut, pour ne pas laisser seule en tête de la page 8 la dernière ligne d’un paragraphe. Avec `mirror: true`, la marge intérieure de 20 mm tombe du côté du dos sur les rectos comme sur les versos ([marges en miroir](https://postext.dev/fr/docs/configuration-page-layout.md#marges-en-miroir)). La justification de 104,5 mm contient environ 69 caractères de Crimson Pro en 10,5 pt. Le texte courant garde des espaces entre 0,7 et 1,9 fois l’espace normal ; avec le minimum par défaut de 0,6, une ligne de l’édition anglaise se resserre à 0,62. Dans l’édition française, trois lignes des pages 3 et 4 dépassent quand même 1,9 (2,05 au plus).

### 3 · Des titres courants qui sautent les ouvertures et les pages blanches

```js
// script.js, lignes 81–101
// {author} and {title} come from the frontmatter.
const HEAD_Y = 12; // mm from the top edge to the top of the running heads
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
const head = (id, content, parity, edge, x, style) => ({
  kind: 'text', id, content, parity, pages: 'body', // never on openers or blank pages
  fontSize: pt(9), color: col('muted'), ...style,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD_Y) } },
});
const smallCaps = { fontFamily: LABEL, fontWeight: 600, letterSpacing: pt(1.2) };
const italic = { fontFamily: DISPLAY, italic: true, fontWeight: 500, fontSize: pt(9.75) };
const folio = { fontFamily: TEXT, color: col('ink') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-author', '{author}', 'even', 'top', -SHIFT, smallCaps),
  head('recto-title', '{title}', 'odd', 'top', SHIFT, italic),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
// Openers carry a drop folio instead, centred under the text block.
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
  pages: 'opener', fontFamily: TEXT, fontSize: pt(9), color: col('muted'), align: 'center',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(7) } } }] };
```

`parity: 'even'` place les auteurs en petites capitales Cormorant SC sur les versos, et `'odd'` le titre en italique sur les rectos ; les folios se placent au bord extérieur du bloc de texte. `pages: 'body'` écarte les quatre éléments des ouvertures et de la page 6, le verso blanc qu’ajoute le saut de parité. Avec `pages: 'all'`, cette page imprimerait *6* et *Edmond et Jules de Goncourt*. Les ouvertures reçoivent à la place un folio en pied, un élément de pied de page que `pages: 'opener'` réserve à elles seules ([éléments texte](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-de-texte)).

### 4 · La couverture est une section sans titres courants

```js
// script.js, lignes 105–128
// Page 1 is a recto (gotcha: parity-page1-recto): the cover, the colophon on its verso, and
// chapter I on page 3. # The Awakening {style="cover"} opens the section; chapter I closes it.
// The colophon: 2 + 2 + 1 lines of 11 pt and two gaps of 14.5 pt make 84 pt, six grid lines.
const COLOPHON_LINES = 6;
const COLOPHON_TOP = TOP + (LINES - COLOPHON_LINES) * LEAD * MM_PER_PT; // mm: the section's margin
const onPage = (y) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } }); // y mm down
const cover = {
  id: 'cover',
  header: { elements: [] }, footer: { elements: [] }, // no running heads on p. 1 or p. 2
  margins: { top: mm(COLOPHON_TOP) },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'art', resourceId: 'cover',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    { kind: 'text', id: 'title', content: '{title}', fontFamily: DISPLAY, italic: true,
      fontWeight: 500, fontSize: pt(50), lineHeight: 1, color: col('ink'), align: 'center',
      placement: onPage(155) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6),
      color: col('rubric'), placement: { ...onPage(179), size: { width: mm(18) } } },
    { kind: 'text', id: 'author', content: '{author}', ...smallCaps, fontSize: pt(12),
      letterSpacing: pt(2.4), color: col('rubric'), align: 'center', placement: onPage(184) },
  ] } },
};
const colophon = { id: 'colophon', fontSize: pt(8), lineHeight: pt(11), color: col('muted'),
  textAlign: 'left', firstLineIndent: pt(0), spaceBetween: pt(14.5) };
```

La couverture est un titre, `# Madame Gervaisais {style="cover"}`. Son style place la peinture sur les 134 mm du haut, le titre du frontmatter en 50 pt et les auteurs en petites capitales. Le style s’applique aussi à la section qu’ouvre le titre, les pages 1 et 2, jusqu’au chapitre I. Ses `header` et `footer` vides écartent le folio en pied de la couverture et les titres courants du colophon. Sa marge de tête place le colophon sur les six dernières lignes de grille du bloc de texte : la dernière ligne du colophon est au niveau de la dernière ligne de la page 3 ([styles de titre](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)). La colonne de la section commence à 165 mm du haut, et la maquette, ancrée à la page et au fond perdu, peint quand même au-dessus : seul le pied d’une colonne coupe une maquette qui y est gardée.

![Page 2. Le colophon, composé petit au pied de la page, sans titre courant ni folio : « Publié en volume par A. Lacroix, Verboeckhoven et Cie, à Paris, en 1869. »](https://postext.dev/cookbook/trade-paperback-novel/fr/p02.webp?v=480a0753)

*Page 2 : le colophon au pied du verso de la couverture, sans titre courant ni folio.*

Comme le colophon, la carte de visite du chapitre I a son style de paragraphe, `card`, que seule l’édition française emploie : `:::paragraphs{style="card"}` centre *madame gervaisais.* en Cormorant SC, sans alinéa ([styles de paragraphe](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)).

### 5 · Un cul-de-lampe ferme chaque chapitre

```js
// script.js, lignes 132–156
// At a chapter's end: :::space, then ::resource{id="tailpiece-1"} (double quotes: gotcha
// resource-double-quotes). The space adds a line to the gap the embed keeps above it.
const ornament = { id: 'ornament', name: 'Ornament', shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  defaultPlacement: { position: 'here', width: 0.17, align: 'center' } };
const svg = (id, fileId, width, height, altText) => ({ id, typeId: 'ornament', kind: 'svg',
  svg: { fileId, width, height }, altText, createdAt: 0, updatedAt: 0 });
// The cover is a painting, a JPEG in assets/ cut to the art's 140 × 134 mm, declared at its pixels.
const painting = { id: 'cover', typeId: 'ornament', kind: 'bitmap', createdAt: 0, updatedAt: 0,
  bitmap: { fileId: 'cover-1190.jpg', format: 'jpeg', width: 1190, height: 1139 },
  altText: t({ en: 'The gulf seen between the trunks of water-oaks, a lugger on the horizon; a '
    + 'white sunshade comes up from the beach through the camomile.',
  pt: 'O mar visto entre os troncos de duas árvores, um veleiro no horizonte; uma sombrinha '
    + 'branca sobe da praia por um caminho entre flores amarelas e brancas.',
  fr: 'La mer vue entre les troncs de deux arbres, un voilier à l’horizon ; une ombrelle '
    + 'blanche monte de la plage par un chemin entre des fleurs jaunes et blanches.' }) };
const resources = [
  painting,
  // ::resource places each ornament once, so each chapter end has its own id.
  ...t({ en: ['I', 'II'], pt: ['I', 'II', 'III'], fr: ['I', 'II'] }).map((n, i) => svg(
    `tailpiece-${i + 1}`, 'fleuron.svg', 40, 12,
    t({ en: `A camomile flower between two sprigs closes chapter ${n}.`,
      pt: `Uma flor de camomila entre dois ramos fecha o capítulo ${n}.`,
      fr: `Une fleur de camomille entre deux brins clôt le chapitre ${n}.` }))),
];
```

Le type ornement n’a pas de préfixe de légende : la fleur de camomille s’imprime sans libellé, large de 17 % de la justification, là où se trouve `::resource{id="tailpiece-1"}` ([types de ressources](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources)). Une image insérée dans le texte garde une ligne de grille au-dessus d’elle, et le `:::space` qui la précède en ajoute une deuxième entre la dernière ligne du chapitre et la fleur ([`:::space`](https://postext.dev/fr/docs/document-format.md#space)). Les deux culs-de-lampe dessinent le même fichier, `fleuron.svg`, sous deux identifiants, car une ressource n’est placée qu’une fois.

## 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/trade-paperback-novel

### script.js

```js
// ═══ Postext Cookbook · Nº 038 · Trade paperback: sunk openers and recto chapters ═══
// https://postext.dev/en/cookbook/trade-paperback-novel
// Code: MIT · Text: Chopin, Machado de Assis, the Goncourts (PD) · Cover: diffusion models
// Fonts: Crimson Pro, Cormorant Garamond, Cormorant SC (SIL OFL 1.1) · Needs postext ≥ 1.25.0
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'fr'; // @lang: 'en' (Chopin) | 'pt' (Machado de Assis) | 'fr' (the Goncourts)
const RECIPE = 'trade-paperback-novel';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // every colour in the config links to one of these
  ink: '#231d18', // the text: a warm near-black
  rubric: '#8e3b22', // the one accent: the opener's hairline, the author, the tailpiece
  muted: '#6e655b', // running heads, folios on openers, the colophon
  paper: '#fbf7ef', // a cream book paper
};
// Each colour names its palette entry and carries its hex.
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 (headings, lists, callouts, captions) link to 'main-color', the rubric.
  { id: 'main-color', name: 'rubric (defaults)', value: { hex: palette.rubric, model: 'hex' } },
];
const TEXT = 'Crimson Pro'; // the text face
const DISPLAY = 'Cormorant Garamond'; // italic numerals and the cover's title
const LABEL = 'Cormorant SC'; // small capitals: the lead, the author's name
const [BODY, LEAD] = [10.5, 14]; // pt: the body size and its leading, the grid's pitch
const line = (n) => pt(n * LEAD); // n grid lines

// #region page: a 140 × 216 mm trade page whose text block holds 35 whole lines
const TRIM = { width: 140, height: 216 }; // mm: 5½ × 8½ in
const [TOP, INNER, OUTER] = [22, 20, 15.5]; // mm; mirrored, so INNER is left on a recto
const LINES = 35; // every full page ends on the same line
const MEASURE = TRIM.width - INNER - OUTER; // 104.5 mm: about 69 characters of Crimson Pro
const MM_PER_PT = 25.4 / 72;
const page = {
  sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
  backgroundColor: col('paper'),
  margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - LINES * LEAD * MM_PER_PT),
    left: mm(INNER), right: mm(OUTER), mirror: true },
};
// #endregion

// #region answer: a sunk opener: the numeral, a hairline, and the first sentence in small caps
// # I {lead="A green and yellow parrot, … kept repeating over and over:"}
// The heading's text is the numeral, printed by {titleText}; the chapter's first sentence
// travels as the heading's lead attribute, printed by {attr.lead} in small capitals: a
// centred display, which a \n in the attribute breaks by sense.
const [LEAD_AT, BODY_AT] = [9, 12]; // grid lines: the lead's top; where the text starts
const NUMERAL = 48; // pt
const RULE_Y = (LEAD_AT - 1.5) * LEAD * MM_PER_PT; // mm: a line and a half above the lead
const NUMERAL_Y = RULE_Y - NUMERAL * MM_PER_PT - 3; // mm: the numeral's box ends 3 mm above it
const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y } });
const opener = {
  enabled: true,
  // The two-line lead ends on line LEAD_AT + 2; minHeight leaves a blank line under it and
  // starts the text on line BODY_AT (a one-line lead would leave two).
  minHeight: line(BODY_AT),
  slot: { elements: [
    { kind: 'text', id: 'numeral', content: '{titleText}', fontFamily: DISPLAY, italic: true,
      fontWeight: 500, fontSize: pt(NUMERAL), lineHeight: 1, color: col('ink'),
      align: 'center', placement: centred(mm(NUMERAL_Y)) },
    { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.6),
      color: col('rubric'), placement: { ...centred(mm(RULE_Y)), size: { width: mm(18) } } },
    { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: LABEL, fontWeight: 600,
      fontSize: pt(9.5), letterSpacing: pt(0.6), color: col('ink'), align: 'center',
      lineHeight: LEAD / 9.5, // one grid line to each line of the lead
      placement: { ...centred(line(LEAD_AT)), size: { width: mm(MEASURE) } } },
  ] },
};
// Every chapter opens on a recto: 'odd' adds a blank verso only after a chapter that ends on
// a recto, where the default 'always-odd' leaves a blank page after every chapter.
// marginBottom 0: the level's default 0.5 em would start the text a line lower.
const chapter = { level: 1, breakBefore: { enabled: true, parity: 'odd' },
  marginBottom: pt(0), italic: true, advancedDesign: opener };
// #endregion

// #region heads: the author on the verso, the title on the recto, folios outside
// {author} and {title} come from the frontmatter.
const HEAD_Y = 12; // mm from the top edge to the top of the running heads
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
const head = (id, content, parity, edge, x, style) => ({
  kind: 'text', id, content, parity, pages: 'body', // never on openers or blank pages
  fontSize: pt(9), color: col('muted'), ...style,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD_Y) } },
});
const smallCaps = { fontFamily: LABEL, fontWeight: 600, letterSpacing: pt(1.2) };
const italic = { fontFamily: DISPLAY, italic: true, fontWeight: 500, fontSize: pt(9.75) };
const folio = { fontFamily: TEXT, color: col('ink') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-author', '{author}', 'even', 'top', -SHIFT, smallCaps),
  head('recto-title', '{title}', 'odd', 'top', SHIFT, italic),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
// Openers carry a drop folio instead, centred under the text block.
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
  pages: 'opener', fontFamily: TEXT, fontSize: pt(9), color: col('muted'), align: 'center',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(7) } } }] };
// #endregion

// #region cover: a section of its own: art, title, author, no heads, a colophon on its verso
// Page 1 is a recto (gotcha: parity-page1-recto): the cover, the colophon on its verso, and
// chapter I on page 3. # The Awakening {style="cover"} opens the section; chapter I closes it.
// The colophon: 2 + 2 + 1 lines of 11 pt and two gaps of 14.5 pt make 84 pt, six grid lines.
const COLOPHON_LINES = 6;
const COLOPHON_TOP = TOP + (LINES - COLOPHON_LINES) * LEAD * MM_PER_PT; // mm: the section's margin
const onPage = (y) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } }); // y mm down
const cover = {
  id: 'cover',
  header: { elements: [] }, footer: { elements: [] }, // no running heads on p. 1 or p. 2
  margins: { top: mm(COLOPHON_TOP) },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'art', resourceId: 'cover',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    { kind: 'text', id: 'title', content: '{title}', fontFamily: DISPLAY, italic: true,
      fontWeight: 500, fontSize: pt(50), lineHeight: 1, color: col('ink'), align: 'center',
      placement: onPage(155) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6),
      color: col('rubric'), placement: { ...onPage(179), size: { width: mm(18) } } },
    { kind: 'text', id: 'author', content: '{author}', ...smallCaps, fontSize: pt(12),
      letterSpacing: pt(2.4), color: col('rubric'), align: 'center', placement: onPage(184) },
  ] } },
};
const colophon = { id: 'colophon', fontSize: pt(8), lineHeight: pt(11), color: col('muted'),
  textAlign: 'left', firstLineIndent: pt(0), spaceBetween: pt(14.5) };
// #endregion

// #region tailpiece: an ornament type that prints no caption, where ::resource stands
// At a chapter's end: :::space, then ::resource{id="tailpiece-1"} (double quotes: gotcha
// resource-double-quotes). The space adds a line to the gap the embed keeps above it.
const ornament = { id: 'ornament', name: 'Ornament', shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  defaultPlacement: { position: 'here', width: 0.17, align: 'center' } };
const svg = (id, fileId, width, height, altText) => ({ id, typeId: 'ornament', kind: 'svg',
  svg: { fileId, width, height }, altText, createdAt: 0, updatedAt: 0 });
// The cover is a painting, a JPEG in assets/ cut to the art's 140 × 134 mm, declared at its pixels.
const painting = { id: 'cover', typeId: 'ornament', kind: 'bitmap', createdAt: 0, updatedAt: 0,
  bitmap: { fileId: 'cover-1190.jpg', format: 'jpeg', width: 1190, height: 1139 },
  altText: t({ en: 'The gulf seen between the trunks of water-oaks, a lugger on the horizon; a '
    + 'white sunshade comes up from the beach through the camomile.',
  pt: 'O mar visto entre os troncos de duas árvores, um veleiro no horizonte; uma sombrinha '
    + 'branca sobe da praia por um caminho entre flores amarelas e brancas.',
  fr: 'La mer vue entre les troncs de deux arbres, un voilier à l’horizon ; une ombrelle '
    + 'blanche monte de la plage par un chemin entre des fleurs jaunes et blanches.' }) };
const resources = [
  painting,
  // ::resource places each ornament once, so each chapter end has its own id.
  ...t({ en: ['I', 'II'], pt: ['I', 'II', 'III'], fr: ['I', 'II'] }).map((n, i) => svg(
    `tailpiece-${i + 1}`, 'fleuron.svg', 40, 12,
    t({ en: `A camomile flower between two sprigs closes chapter ${n}.`,
      pt: `Uma flor de camomila entre dois ramos fecha o capítulo ${n}.`,
      fr: `Une fleur de camomille entre deux brins clôt le chapitre ${n}.` }))),
];
// #endregion

const card = { id: 'card', fontFamily: LABEL, fontWeight: 600, // the fr edition's visiting card
  textAlign: 'center', firstLineIndent: pt(0) };

const config = () => ({
  locale: t({ en: 'en-us', pt: 'pt', fr: 'fr' }), // each edition's hyphenation patterns
  colorPalette,
  resourceTypes: [ornament],
  page,
  layout: { layoutType: 'single' },
  bodyText: {
    fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'),
    firstLineIndent: mm(4), indentAfterHeading: false,
    minWordSpacing: 0.7, maxWordSpacing: 1.9, // at the default 0.6 a line closes to 0.62
  },
  // The designs paint every heading; the level's own face is the numeral's (500 italic), so the
  // kit has no unused face to load.
  headings: { fontFamily: DISPLAY, fontWeight: 500, levels: [chapter] },
  headingStyles: [cover],
  paragraphStyles: [colophon, card],
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Madame Gervaisais"
author: "Edmond et Jules de Goncourt"
---

# Madame Gervaisais {style="cover"}

:::paragraphs{style="colophon"}
Publié en volume par A. Lacroix, Verboeckhoven et Cie, à Paris, en 1869. Les chapitres I et II suivent cette première édition, transcrite sur Wikisource, avec son orthographe.

Composé en Crimson Pro, Cormorant Garamond et Cormorant SC (SIL Open Font License). La couverture, une peinture, montre une plage vue entre les arbres.

Une édition des Recettes Postext. Le texte est dans le domaine public.
:::

# I {lead="— Quarante scudi ?"}

— Oui, signora.

— Cela fait, n’est-ce pas, en monnaie de France, deux cents francs ?

— Deux cents francs ?… — fit la romaine qui montrait l’appartement à l’étrangère : elle parut chercher, compter dans sa tête.

— Oui, oui… deux cents francs. Mais la signora n’a pas bien vu…

Et, jetant son châle brusquement sur un lit défait, elle se mit à marcher de chambre en chambre, avec de vives ondulations de taille, en parlant avec la volubilité d’une *padrona* de chambres meublées : — Voyez-vous, ils sont partis ce matin… Une famille anglaise… des gens malpropres, qui jetaient de l’eau partout… Tout est en désordre… On n’a pas eu le temps de rien ranger…

Mais l’étrangère n’écoutait pas : elle s’était arrêtée devant une fenêtre avec l’enfant qu’elle avait à la main et qui se tenait dans sa robe, et elle lui montrait ce qu’on voyait de là, la place d’Espagne et l’escalier de la Trinité-du-Mont. Puis elle lui demanda : — Pierre-Charles, veux-tu rester ici ?

L’enfant ne répondit pas, mais il leva vers sa mère des yeux tout grands de bonheur.

— *Che bellezza !* — fit la loueuse avec le cri de l’admiration romaine devant tout ce qui est beau.

À ce mot, l’étrangère regarda une minute son fils avec ce regard de mère qui semble embrasser, sur le visage de son enfant, la beauté qu’on lui trouve.

— Et cette petite langue, elle ne parle donc pas ? — dit l’italienne.

— Il est un peu retardé pour son âge… — et le front de l’étrangère devint tout à coup sérieux. Elle reprit presque aussitôt d’un ton brusque : — Ainsi, c’est bien cela, n’est-ce pas ?… une petite antichambre, la cuisine de l’autre côté du palier avec une chambre de domestique, et ces quatre pièces qui se suivent…

— Oui, signora… Nous, nous nous retirerons dans la petite chambre du fond… Nous n’avons pas besoin de plus pour nous deux, n’est-ce pas, ma mère ?

Et la romaine se tourna vers une vieille femme, aux superbes traits ruinés, qui se tenait debout dans la dignité de sa robe de deuil, silencieuse, assistant à cette conversation échangée en une langue qu’elle ne comprenait pas et dont elle semblait tout deviner avec l’intelligence méridionale de ses yeux.

— Eh bien ! c’est convenu… j’arrête l’appartement…

— Ah ! signora, ce n’est pas cher… Si, cette année, il y avait plus d’étrangers à Rome…

— Dites-moi : la maison est tranquille ? Il n’y a pas de bruit ? C’est que, tout à l’heure… je suis entrée dans une maison… Quand j’ai lu dans l’allée : *Maestro di Musica*…

— Oh ! ici… En bas, vous avez vu, c’est un libraire, avec la chalcographie que mon père avait autrefois… et en haut ses magasins… et pour nous, nous ne recevons jamais personne…

— C’est que je suis souffrante, un peu souffrante… J’ai besoin de calme, de beaucoup de calme…

— Ah !… madame est souffrante ? dit lentement la padrona en qui venait de se glisser cette peur populaire des loueuses de Rome pour la contagion des maladies de poitrine, rencontrée déjà par Chateaubriand lorsqu’il y cherchait un dernier logis pour madame de Beaumont ; et comme elle essayait de tourner dans sa tête une phrase qui fit s’expliquer l’étrangère sur son mal, celle-ci, prenant un ton haut et bref :

— Tenez, mademoiselle, finissons… Voici les deux cents francs du premier mois…

Et elle posa l’argent sur une table.

— Je verrai après, si je me trouve bien ici…

— Ma mère va aller décrocher l’écriteau de location, — dit la fille ; et trempant la plume dans la boue d’un encrier séché :

— À quel nom faut-il donner le reçu ?

L’étrangère tendit une carte sur laquelle était :

:::paragraphs{style="card"}
madame gervaisais.
:::

L’Italienne se pencha, s’appliquant à copier le nom, et en relevant la tête elle aperçut l’enfant qui, tenant retournée la main gantée de sa mère, l’embrassait à la place de la paume.

— Doit-il être aimé !

— Oh ! il n’a plus que sa mère pour cela… — soupira la mère.

— Madame sait que nous sommes obligées de donner les passe-ports à la police…

— On m’a gardé le mien à l’hôtel. Je vous le remettrai demain en prenant possession de l’appartement…

— Madame n’aura pas besoin qu’on lui fasse à déjeuner ?

— Non… je compte prendre dans quelques jours un domestique d’ici qui me fera la cuisine. À demain, mesdames… Viens, Pierre-Charles…

Et s’adressant à sa femme de chambre qui se rencognait dans le fond de la pièce, avec le cœur gros et la tristesse prête à éclater d’une Bourguignonne dépaysée :

— Allons donc, Honorine ! Nous reviendrons, ma fille…

Sur la porte :

— Ah ! j’avais oublié, madame — fit l’Italienne en recourant après elle. Je dois vous prévenir pour les *scarpe*, les souliers… à moins que ce ne soit votre femme de chambre…

— Ma femme de chambre ne fait que nos lits…

— Alors… ce sera deux baioques pour chaque paire… C’est le petit profit de la *serva*.

— Eh bien ! la serva aura ses deux baioques…

Et madame Gervaisais ne put s’empêcher de sourire du bon marché avec lequel on fait à Rome la joie du pauvre.

:::space

::resource{id="tailpiece-1"}

# II {lead="Les tables de l’Hôtel de la Minerve\nétaient pleines ce soir-là,"}

et de place en place des touristes consciencieux lisaient le « Guide » dans leur assiette à soupe encore vide.

— Non… de la soupe grasse pour moi… et mon enfant — dit madame Gervaisais à un garçon qui lui apportait le potage maigre de la table servie en maigre, à laquelle, sans le savoir, elle s’était assise.

À cette demande, un ecclésiastique sanguin, en train de réciter à côté d’elle son *Benedicite* debout, lui jeta un regard qu’il abaissa presque aussitôt, et recula un peu sa chaise en s’asseyant.

Dans la salle à manger monumentale où se levaient des Vertus, des Martyres et des Héroïnes chrétiennes en plâtre, sur une imitation peinte de mosaïque, mangeait le monde mélangé des visiteurs de la Ville Éternelle : des hôtes de partout, des catholiques, des laïcs à redingote cléricale, des prêtres de toute sorte et de tout costume, des échantillons de peuple et d’aristocratie d’église, des évêques à la calotte violette, beaucoup de curés de pays à gros vin et à gros accent, de maigres abbés de compagnie escortant de vieilles dames dans le pèlerinage de leur curiosité pieuse, des commerçants enrichis payant à leurs femmes le voyage des gens distingués, d’épais industriels estropiant les noms de Saintes, et que leurs filles reprenaient tout haut avec les leçons encore fraîches de leur éducation de couvent, des commis voyageurs expliquant savamment à leurs voisins comme quoi les vins des pays tempérés sont sujets à *graisser* dans les États Romains ; enfin, tout ce fond de passants cosmopolites, anonymes, impersonnels et vagues, dont la vie d’hôtel, le coudoiement du repas, rapproche et fait communier la vulgarité.

De tout ce monde, à un bout de la table, se détachait, observant de haut les convives, un Chevalier d’Ordre noble, enveloppé de cette robe qui habille la vieillesse religieuse de la grâce correcte du blanc. Il parlait, avec des gestes d’homme du monde, à l’oreille d’une italienne dont les cheveux étaient noués d’un ruban feu rappelant une bandelette de coiffure antique.

Et à mesure que le dîner avançait, que l’expansion se répandait des estomacs remplis, la causerie des voisinages se mêlait et devenait générale. Alors éclatait et s’épanouissait la bêtise du français à table d’hôte, prenant toutes ses comparaisons, ses mesures, d’après les idées, les préjugés, les produits français, voulant partout à l’étranger retrouver la France, et n’admettant rien du droit des autres peuples à être un autre peuple que celui qu’il est. Des paroles professorales, de grossières ignorances, critiquaient les mœurs, les habitudes, les institutions du pays. Il s’élevait des plaintes de civilisés débarqués dans un pays sauvage de la part des messieurs auxquels on avait servi du café au lait dans des verres. Il y avait des gens qui disaient, à propos de la petitesse des poulets rôtis, que cela donnait une triste idée du gouvernement, et un homme chauve, à visage considérable, accusait l’édilité de « l’endroit » de ne pas même faire balayer tous les matins la prison de Saint-Pierre !

Madame Gervaisais fut contente de penser que c’était son dernier dîner à la *Minerve*. Tout ce bruit niais qui l’entourait, l’ennuyait, la blessait presque : elle éprouvait une espèce d’écœurement à entendre là — la sottise parler si haut. Son amour-propre de française, de parisienne, souffrait de ces inepties sortant de la bouche de compatriotes, et il y avait en elle une humiliation, en même temps qu’un agacement presque douloureux, à toucher de si près et dans sa plus grosse expression le Béotisme exubérant que développe, par un singulier et ironique privilége, le spectacle de la plus grande ville du monde chez le peuple le plus spirituel de la terre.

— Prends une orange et allons-nous-en… — finit-elle par dire à son enfant, avant le dessert, à bout de patience, et choquée de la tenue de son voisin, l’ecclésiastique au *Benedicite* qui, ragaillardi par le dîner et le coude avancé près d’elle, se tapotait l’épaule avec la carte des vins.

Elle remonta le grand escalier, en frôlant un capucin qui, plaqué contre la loge du portier, immobile, tendait aux passants le tronc d’une *bussola* ; et par les longs corridors où s’apercevaient, dans des coins, des malles de prélats ayant l’apparence de boîtes d’argenterie en maroquin rouge, gaufrées d’or, elle arriva à sa chambre où elle s’enferma avec son fils.

Le jour baissait ; et avec le jour qui baissait revenait en elle le sentiment de tristesse dont certains tempéraments de femme ne peuvent se défendre à la venue du soir, au moment défaillant, à l’heure tombante de la journée. Peu à peu, elle était envahie par cette espèce de mélancolie, songeuse, instinctivement peureuse, que donnent aux malades la crainte de l’ombre et la menace de la nuit. Elle prit son fils sur ses genoux et se mit à le bercer, pressant contre son sein le premier sommeil de son enfant, en lui murmurant la *Berceuse* de Schumann, la bouche sur sa tête et la voix dans ses cheveux.

En la couchant, sa femme de chambre lui dit :

— Est-ce que Madame ne prend pas de la potion que M. Andral lui a recommandé de prendre tous les soirs ?

— Mon Dieu ! Honorine, si vous voulez… donnez-m’en… cela m’empêchera peut-être de sentir les puces de la *Minerve*…

:::space

::resource{id="tailpiece-2"}
`; // content.<lang>.md, inlined by the Cookbook

// #region art: the fleuron, drawn in millimetres in the page's colour
function drawFleuron(color) { // a camomile head between two leafy sprigs
  const petals = Array.from({ length: 10 }, (_, i) => `<ellipse cx="22.1" cy="6" rx="1.55" `
    + `ry="0.62" transform="rotate(${i * 36} 20 6)" fill="${color}"/>`).join('');
  const sprig = `<path d="M17.2 6.3C14 7.4 10 7.2 6.4 5.6C5.2 5.1 4.2 5.2 3.4 5.9" fill="none" `
    + `stroke="${color}" stroke-width="0.55" stroke-linecap="round"/>`
    + `<path d="M13.4 6.9C12.6 4.9 10.8 4 9 4.2C10 5.6 11.4 6.6 13.4 6.9Z" fill="${color}"/>`
    + `<path d="M9.2 6.5C8.7 7.9 7.4 8.7 5.9 8.8C6.6 7.5 7.7 6.7 9.2 6.5Z" fill="${color}"/>`
    + `<circle cx="2.8" cy="6.3" r="0.7" fill="${color}"/>`;
  return '<svg xmlns="http://www.w3.org/2000/svg" width="40mm" height="12mm" '
    + `viewBox="0 0 40 12">${petals}<circle cx="20" cy="6" r="1.05" fill="${color}"/>${sprig}`
    + `<g transform="translate(40 0) scale(-1 1)">${sprig}</g></svg>`;
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the pages use, loaded before the build
  'Crimson Pro': ['400', '400i'], // text, colophon, folios
  'Cormorant Garamond': ['500i'], // numerals, cover title, the recto's running head
  'Cormorant SC': ['600'], // the leads, the author on the cover and the verso
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadImage(painting.bitmap.fileId, asset(painting.bitmap.fileId));
await loadSvg('fleuron.svg', drawFleuron(palette.rubric));
const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS));
showPages(doc, { title: t({ en: 'Trade paperback: sunk openers and recto chapters',
  pt: 'Romance em brochura: aberturas rebaixadas em página ímpar',
  fr: 'Roman broché : ouvertures basses et chapitres au recto' }) });

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

### Laisser le chapitre II suivre à la page suivante

Avec la parité `'any'`, le chapitre II s’ouvre en page 6, un verso, et le livre se termine en page 8 ; le folio en pied est centré sur le bloc de texte, pas sur la page : sur ce verso, il reste donc sous le milieu du texte.

```diff
-const chapter = { level: 1, breakBefore: { enabled: true, parity: 'odd' },
+const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },
```

### Marquer un changement de scène dans un chapitre

Placée entre deux lignes `:::space`, la même fleur marque un changement de scène, et le paragraphe suivant commence sans alinéa parce que `indentAfterHeading` est désactivé. Dans l’édition anglaise, placée après *seemed long to him.*, la fleur tombe au pied de la page 4 ; le second espace n’y tient plus et il est supprimé. Le changement de scène ajoute quatre lignes, qui repoussent seul le cul-de-lampe du chapitre I en page 6, sous un titre courant : réajustez donc le chapitre après en avoir ajouté un.

```diff
 const resources = [
+  svg('break-1', 'fleuron.svg', 40, 12, 'A scene break.'),
```

```md
That was why the morning seemed long to him.

:::space

::resource{id="break-1"}

:::space

“You are burnt beyond recognition,” he added, …
```

## Erreurs fréquentes

- **La page 1 est un recto : planifiez les pages avec les numéros physiques.** La page 1 est une page de droite et la page 2 le premier verso : planifiez donc les doubles pages avec les numéros de page physiques. Une ouverture sur une page paire fait face à la page impaire qui la suit.
- **::resource{id="…"} n’accepte que les guillemets doubles.** Une intégration en bloc n’est reconnue que sous la forme ::resource{id="…"} avec des guillemets doubles ; toute autre forme reste dans le texte comme une ligne visible.
- **Avertissement de mise en page: Cascade de parité** (`parityCascade`). Des sauts de parité s’accumulent et produisent plus de deux pages blanches d’affilée. Solution: Revoyez les sauts always-odd et les parités de breakBefore ; pour des chapitres courts, la parité any évite en général les pages blanches. ([Documentation](https://postext.dev/fr/docs/configuration-text.md#saut-avant))

- Avec `'always-odd'`, une page blanche de séparation suit chaque section avant tout complément de parité : dans l’édition anglaise, le chapitre I passerait en page 5, après deux pages blanches, et le livre compterait dix pages.
- Les pages de l’édition anglaise sont ajustées au texte. Avec une marge extérieure de 16 mm au lieu de 15,5, la page 7 coupe *after-din-ner* à un second trait d’union et finit une ligne plus tôt, la page 8 termine un paragraphe sur le bout de mot *spondent.*, et le second cul-de-lampe tombe sur une neuvième page. Vérifiez le pied de chaque page après avoir changé la justification, le corps ou la descente.
- Dans l’édition anglaise, *Mr.* et *Mrs.* sont liés au nom par une espace insécable (U+00A0). Le paragraphe qui finit par *seemed long to him.* garde une espace ordinaire, car y lier le nom ouvre sa première ligne à 2,2 fois l’espace normal.

## Crédits

- Recette: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texte: The Awakening (L’Éveil, 1899), chapitres I et II, dans l’édition anglaise: Kate Chopin ([source](https://www.gutenberg.org/ebooks/160)), domaine public
- Texte: Quincas Borba (1891), chapitres I à III, dans l’édition portugaise, en orthographe modernisée: Machado de Assis ([source](https://pt.wikisource.org/wiki/Quincas_Borba)), domaine public
- Texte: Madame Gervaisais (1869), chapitres I et II, avec l’orthographe de la première édition: Edmond et Jules de Goncourt ([source](https://fr.wikisource.org/wiki/Madame_Gervaisais)), domaine public
- Texte: Le colophon: Postext Cookbook, original
- Images: La vue de la mer de la couverture, une peinture: Generated With Diffusion Models, original
- Images: Le cul-de-lampe à la camomille, dessiné en code dans la palette de la page: Ignacio Ferro, MIT
- Caractères: Crimson Pro (OFL-1.1), Cormorant Garamond (OFL-1.1), Cormorant SC (OFL-1.1)
- Code: MIT · Contenu d’exemple: MIT

## Recettes liées

- [Nº 016 · De l’espagnol justifié dans un roman de poche](https://postext.dev/fr/cookbook/spanish-pocket-novel.md): Le début du chapitre I de Marianela en poche : césure espagnole, espaces sous 1,7×, aucune fin trop courte, lettrine montante et Figura 1 sur le plan. · Niveau 2 (Intermédiaire) · Fiction, théâtre et prose littéraire
- [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º 006 · Pages liminaires en chiffres romains, puis la page 1](https://postext.dev/fr/cookbook/front-matter-roman-to-arabic.md): La couverture et les pages liminaires sont des titres non numérotés comptés en romain bas de casse ; :::numbering repart à 1 sur le recto où s’ouvre le roman. · Niveau 3 (Avancé) · Fiction, théâtre et prose littéraire
