# Texte de théâtre : distribution, répliques et didascalies

> La pièce de Wilde en édition de jeu : noms en gras prune, didascalies en italique gris, distribution avec points de conduite jusqu’à chaque acteur.

- Version HTML: https://postext.dev/fr/cookbook/stage-play
- Recette Nº 070 · 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.31.0 le 2026-10-13
- Pages: [1](https://postext.dev/cookbook/stage-play/en/p01.webp?v=507755ee), [2](https://postext.dev/cookbook/stage-play/en/p02.webp?v=507755ee), [3](https://postext.dev/cookbook/stage-play/en/p03.webp?v=507755ee), [4](https://postext.dev/cookbook/stage-play/en/p04.webp?v=507755ee), [5](https://postext.dev/cookbook/stage-play/en/p05.webp?v=507755ee)
- Ouvrir dans le Sandbox: https://postext.dev/fr/sandbox#recipe=stage-play&lang=en (.postext: https://postext.dev/cookbook/stage-play/en/stage-play.postext)
- Dernière mise à jour: 2026-10-11
- Autres langues: [en](https://postext.dev/en/cookbook/stage-play.md), [es](https://postext.dev/es/cookbook/stage-play.md), [ca](https://postext.dev/ca/cookbook/stage-play.md), [pt](https://postext.dev/pt/cookbook/stage-play.md), [zh](https://postext.dev/zh/cookbook/stage-play.md), [ja](https://postext.dev/ja/cookbook/stage-play.md), [ar](https://postext.dev/ar/cookbook/stage-play.md)

## En bref

Le début d’une comédie célèbre d’Oscar Wilde, imprimé pour les comédiens. Les noms des personnages ressortent en gras et en couleur, et les didascalies sont en gris pour qu’on puisse les sauter.

## Ce que vous allez composer

Le début de *The Importance of Being Earnest* d’Oscar Wilde, composé en édition de jeu de cinq pages au format 140 × 216 mm. La page de titre est une affiche prune dans un double cadre or, avec *Importance* et *Earnest* en capitales grasses à fort contraste et un œillet or sous le titre. Face au premier acte, la distribution nomme l’acteur qui a créé chaque rôle au St James’s Theatre en 1895. Chaque réplique s’ouvre sur le nom du personnage en gras prune, et ses lignes suivantes sont en retrait suspendu de 5 mm : un comédien trouve sa prochaine réplique d’un coup d’œil. Les didascalies, dans une réplique ou entre deux, sont en italique gris, et l’œil peut les sauter quand on répète son texte. L’édition espagnole est une nouvelle traduction qui suit l’usage théâtral espagnol, avec un tiret après chaque nom et les didascalies entre parenthèses.

**Cette recette répond à:**

- Comment composer une pièce : noms des personnages, didascalies, titres d’actes et de scènes, distribution ?
- Comment colorer les termes clés (en gras ou en italique) dans le texte ou dans les encadrés ?
- Comment composer des illustrations non numérotées : ornements, vignettes, logos ?

## La réponse courte

```js
// script.js, lignes 34–54
// Speeches are plain paragraphs, the speaker's name in bold and each stage direction in
// italic; a direction between two speeches is a paragraph of its own, in a container:
//   **LANE.** Yes, sir. *[Hands them on a salver.]*
//   :::paragraphs{style="direction"}
//   *[Enter Lane.]*
//   :::
// The body's two emphasis colours then mark who speaks (plum) and what is done (grey).
const dialogue = {
  fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('plum'), // **ALGERNON.**
  italicColor: col('muted'), // *[Languidly.]*, in a speech or a paragraph of its own
  minWordSpacing: 0.65, maxWordSpacing: 1.8, // justified (the default); limits inside 0.6–2
  // A runt fix tracks by 5/1000 em at most (default 10). At 10, “that?” went up beside “as”
  // and the speech stood darker than the ones around it; at 5 the word turns over.
  maxRuntTracking: 5,
  firstLineIndent: mm(5), hangingIndent: true, // the name at the margin, the turnovers hung
};
// Directions: smaller and centred, on the same grid. They are written *…* like the ones
// inside a speech, so both take the body's italic grey.
const direction = { id: 'direction', fontSize: pt(8.6),
  textAlign: 'center', firstLineIndent: pt(0) };
```

## Ingrédients

**Enseigne**

- [Styles de paragraphe](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe): Des styles nommés pour des suites de paragraphes qui se distinguent du texte courant : vers, épigraphes, dédicaces, signatures, petits caractères.
- [Gras, italique et leurs couleurs](https://postext.dev/fr/docs/configuration-text.md#texte-courant): Les passages en gras et en italique et la couleur dans laquelle ils s’impriment ; par défaut ils prennent le bleu d’accent, si bien qu’un livre monochrome les remet à la couleur du texte.
- [Taquets de tabulation et points de conduite](https://postext.dev/fr/docs/configuration-text.md#taquets-de-tabulation): tabStops sur un style de paragraphe, sur bodyText ou sur le corps d’un encadré, avec :tab dans le texte, aligne les mots qui suivent chaque tabulation sur un taquet (début, fin, centre ou virgule décimale), précédé au choix de points de conduite ou d’un filet : menus, listes de prix, distributions, barèmes d’examen et formulaires composés en texte.

**Utilise aussi**

- [Retraits, alignement et espacement des paragraphes](https://postext.dev/fr/docs/configuration-text.md#texte-courant)
- [Niveaux de titre](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)
- [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)
- [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)
- [Images dans les maquettes de page](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-image)
- [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)
- [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)
- [Métadonnées du document](https://postext.dev/fr/docs/document-format.md#frontmatter)
- [Couleur du papier](https://postext.dev/fr/docs/configuration-page-layout.md#page)
- [Palette de couleurs sémantique](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs)
- [Ouvertures composées](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)
- [Figures et tableaux comme ressources](https://postext.dev/fr/docs/document-format.md#ressources)
- [Titres courants par section](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Espace vertical explicite](https://postext.dev/fr/docs/document-format.md#space)

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

- Libre Baskerville (OFL-1.1), Abril Fatface (OFL-1.1), Playfair Display SC (OFL-1.1)

## Préparation

### 1 · Un acte est un simple titre

```js
// script.js, lignes 58–69
const headings = {
  fontFamily: LABEL, fontWeight: 400, color: col('ink'),
  textAlign: 'center', // every level: the act, the cast list's heads, the scene
  lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0), // whole lines of the grid
  levels: [
    { level: 1, fontFamily: DISPLAY, fontSize: pt(24), lineHeight: pt(3 * LEAD),
      color: col('plum'), textTransform: 'uppercase',
      breakBefore: { enabled: true, parity: 'odd' } }, // a recto, facing the cast list
    { level: 2, fontSize: pt(13), lineHeight: pt(2 * LEAD) },
    { level: 3, fontSize: pt(9.5), color: col('plum') },
  ],
};
```

Le titre de l’acte est un titre de niveau 1 en Abril Fatface à 24 pt, sans emplacement de maquette. `textTransform` le compose en capitales, et ses trois lignes de grille de 13,6 pt placent les capitales à 5,5 mm sous le haut du bloc de texte ([titres](https://postext.dev/fr/docs/configuration-text.md#titres)). `textAlign: 'center'` ne se règle pas par niveau : il centre donc aussi les titres de la distribution et l’étiquette *Scene*. `breakBefore` avec la parité `'odd'` fait commencer l’acte sur un recto, face à la distribution. La valeur par défaut, `'always-odd'`, mettrait deux pages blanches entre les deux ([saut avant](https://postext.dev/fr/docs/configuration-text.md#saut-avant)).

### 2 · La distribution est une ligne de points jusqu’à chaque acteur

```js
// script.js, lignes 73–85
// A tab sends the actor to a stop at the end of the measure that ends the text there; the stop's
// leader fills the room before it with dots, from half an em after the person to half an em
// before the actor. The bill's head is a centred line in the face's own small capitals.
const person = { id: 'person', fontSize: pt(8.8), firstLineIndent: pt(0), textAlign: 'left',
  tabStops: [{ position: 'end', align: 'end', leader: '.' }],
  spaceBetween: pt(LEAD / 2) }; // a person every line and a half
const billHead = { id: 'bill-head', fontFamily: LABEL, fontSize: pt(8.5), color: col('plum'),
  textAlign: 'center', firstLineIndent: pt(0), marginTop: pt(LEAD / 2),
  marginBottom: pt(LEAD / 2) };
// The scenes: two stops, so `:tab Act I :tab Algernon’s flat` ends the act on the first
// (15 mm in) and starts the place on the second, 2 mm after it, as a two-column list would.
const scene = { id: 'scene', textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD / 2),
  tabStops: [{ position: mm(15), align: 'end' }, { position: mm(17) }] };
```

Chaque personnage est un paragraphe du style `person`, l’acteur après `:tab` ([tabulations et taquets](https://postext.dev/fr/docs/document-format.md#tabulations-et-taquets)). Le style a un seul taquet, à `'end'`, la fin de la justification, et avec `align: 'end'` le nom de l’acteur s’y termine. Son `leader: '.'` remplit l’espace avant le taquet de points, à un demi-cadratin du personnage et de l’acteur, et `spaceBetween` place un personnage toutes les lignes et demie. Les noms des personnages sont en gras : ils s’impriment donc en prune, comme les noms des personnages dans le dialogue. La distribution d’origine, le théâtre et la date coiffent la liste dans le style `bill-head`, centré en Playfair Display SC, dont les minuscules sont des petites capitales. Les décors prennent deux taquets : `:tab Act I :tab …` termine *Act I* sur le premier, à 15 mm, avec `align: 'end'`, si bien que les trois actes s’alignent à droite sur une colonne, et fait commencer le lieu sur le second, 2 mm plus loin. L’époque de l’action est une ligne centrée dans le style des didascalies.

### 3 · Des ornements sans numéro ni légende

```js
// script.js, lignes 89–94
// ::resource{id="fleuron"} sets it where it stands (gotcha: resource-double-quotes).
const unnumbered = (id, name, defaultPlacement) => ({ id, name, shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', defaultPlacement });
const resourceTypes = [
  unnumbered('ornament', 'Ornament', { position: 'here', width: 0.24, align: 'center' }),
];
```

Un type de ressource avec `captionPrefix` et `shortLabel` vides imprime ses images nues ([types de ressources](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources)). Son `defaultPlacement` place l’œillet là où se trouve `::resource{id="fleuron"}`, centré et large de 0,24 de la justification de 107 mm, soit environ 26 mm.

### 4 · La page de titre est un style de titre

```js
// script.js, lignes 98–120
// # The Importance of Being Earnest {style="playbill" small="The" big="Importance" …}
// Each attribute is a playbill line in its own face and size. The ground is an image that
// fills the sheet: the heading reserves down to its foot, so the cast list starts on page 2.
const line = (id, content, font, size, y, color, extra = {}) => ({ kind: 'text', id, content,
  fontFamily: font, fontSize: pt(size), lineHeight: 1, color: col(color), align: 'center',
  placement: { anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } }, ...extra });
const tracked = (track) => ({ textTransform: 'uppercase', letterSpacing: pt(track) });
const playbill = {
  id: 'playbill', span: 'page', // kept in the column, its foot would cut the ground
  header: { elements: [] }, footer: { elements: [] }, // no heads on p. 2, no folio on p. 1
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'ground', resourceId: 'playbill',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    line('kicker', '{subtitle}', LABEL, 8.5, 36, 'gilt', tracked(1.7)),
    line('small', '{attr.small}', TEXT, 17, 51, 'paper', { italic: true }),
    line('big', '{attr.big}', DISPLAY, 40, 60, 'paper', { textTransform: 'uppercase' }),
    line('link', '{attr.link}', TEXT, 17, 78, 'gilt', { italic: true }),
    line('name', '{attr.name}', DISPLAY, 60, 87, 'paper', { textTransform: 'uppercase' }),
    line('author', '{author}', LABEL, 13, 133, 'paper', tracked(3)),
    line('theatre', '{attr.theatre}', LABEL, 8, 170, 'gilt', tracked(1.6)),
    line('premiere', '{attr.premiere}', TEXT, 8.5, 176, 'paper', { italic: true }),
  ] } },
};
```

`# The Importance of Being Earnest {style="playbill" …}` porte le titre en quatre attributs, que la maquette compose en deux caractères : Abril Fatface à 40 et 60 pt pour les noms, Libre Baskerville italique à 17 pt pour *The* et *of Being* ([attributs de titre](https://postext.dev/fr/docs/document-format.md#attributs-de-titre)). Le fond prune, le cadre et l’œillet forment un seul SVG dessiné en code et ancré au fond perdu ([éléments image](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-image)). Le style règle `span: 'page'` alors que le livre n’a qu’une colonne. Gardée dans la colonne, la maquette serait coupée au pied du bloc de texte, 23,3 mm au-dessus du pied de la feuille. L’`header` vide du style écarte les titres courants de la distribution, qui appartient à la même section, et son `footer` vide écarte le folio en pied de la page de titre, où il s’imprimerait prune sur prune ([styles de titre](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)).

Le fond est un élément image, et une ouverture réserve la place jusqu’au pied de ses images : le titre occupe donc toute la feuille. *The Persons of the Play* commence en page 2 sans `:::pagebreak` après le titre, quelle que soit la position des lignes de la page de titre. Une ligne centrée avec `letterSpacing` ne demande aucune correction non plus. L’approche après sa dernière lettre est exclue du centrage, et *OSCAR WILDE* tombe au centre de la page.

### 5 · Des titres courants qui sautent la première page de l’acte

```js
// script.js, lignes 124–140
const head = (id, content, parity, edge, x, style) => ({
  kind: 'text', id, content, parity, pages: 'body', // never on openers or blank pages
  fontFamily: LABEL, fontSize: pt(8.5), letterSpacing: pt(0.8), color: col('muted'),
  textTransform: 'uppercase', ...style,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(11) } },
});
const folio = { fontFamily: TEXT, letterSpacing: pt(0), color: col('plum') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-title', '{title}', 'even', 'top-left', OUTER + 8),
  head('recto-act', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 8)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
// The act's first page carries a drop folio instead, centred under the text block.
const footer = { elements: [{ ...head('drop-folio', '{pageNumber}', 'all', 'top', 0, folio),
  pages: 'opener', align: 'center',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(8) } } }] };
```

Le verso porte le titre de la pièce, tiré du frontmatter, et le recto l’acte, tiré de `{chapterTitle}`, tous deux en capitales Playfair Display SC avec le folio en prune au bord extérieur ([éléments texte](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-de-texte)). La première page de l’acte compte comme une ouverture, car son premier bloc est un titre qui provoque un saut de page : `pages: 'body'` en écarte donc les titres courants, et le pied de page lui donne à la place un folio en pied.

> Un scénario met son dialogue en retrait des deux côtés au lieu de le suspendre : [Mise en page d’un scénario](https://postext.dev/fr/cookbook/screenplay-format.md) compose chaque réplique comme un encadré sans cadre, rembourré à la largeur du bloc de dialogue.

## 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/stage-play

### script.js

```js
// ═══ Postext Cookbook · Nº 070 · Play script: cast list, speakers and stage directions ═══
// https://postext.dev/en/cookbook/stage-play
// Code: MIT · Text: Oscar Wilde, 1895 (PD, Gutenberg #844), Spanish: the Cookbook · Art: in code
// Fonts: Libre Baskerville, Abril Fatface, Playfair Display SC (OFL 1.1) · Needs postext ≥ 1.25.0
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'stage-play';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // every colour in the config links to one of these
  ink: '#221b1f', // the dialogue: a near-black with a little plum in it
  plum: '#5b2349', // the one accent: speakers' names, act titles, the title page's ground
  gold: '#b48a45', // rules and ornaments
  gilt: '#c9aa77', // gold lightened for small type on the plum ground (5.3:1)
  muted: '#6c6168', // stage directions and running heads (5.5:1 on the paper)
  paper: '#fbf6ec', // a cream stock
};
// A colour is its hex and the palette entry it follows.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue.
  { id: 'main-color', name: 'plum (defaults)', value: { hex: palette.plum, model: 'hex' } },
];
const [TEXT, DISPLAY, LABEL] = ['Libre Baskerville', 'Abril Fatface', 'Playfair Display SC'];
const [BODY, LEAD] = [9.4, 13.6]; // pt: the dialogue and its leading, the pitch of the grid
const TRIM = { width: 140, height: 216 }; // mm: 5½ × 8½ in, the acting-edition size
const [TOP, INNER, OUTER, LINES] = [20, 18, 15, 36]; // mm, and 36 lines to a full page

// #region answer: a speech is a paragraph: the name in bold, the directions in italic
// Speeches are plain paragraphs, the speaker's name in bold and each stage direction in
// italic; a direction between two speeches is a paragraph of its own, in a container:
//   **LANE.** Yes, sir. *[Hands them on a salver.]*
//   :::paragraphs{style="direction"}
//   *[Enter Lane.]*
//   :::
// The body's two emphasis colours then mark who speaks (plum) and what is done (grey).
const dialogue = {
  fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('plum'), // **ALGERNON.**
  italicColor: col('muted'), // *[Languidly.]*, in a speech or a paragraph of its own
  minWordSpacing: 0.65, maxWordSpacing: 1.8, // justified (the default); limits inside 0.6–2
  // A runt fix tracks by 5/1000 em at most (default 10). At 10, “that?” went up beside “as”
  // and the speech stood darker than the ones around it; at 5 the word turns over.
  maxRuntTracking: 5,
  firstLineIndent: mm(5), hangingIndent: true, // the name at the margin, the turnovers hung
};
// Directions: smaller and centred, on the same grid. They are written *…* like the ones
// inside a speech, so both take the body's italic grey.
const direction = { id: 'direction', fontSize: pt(8.6),
  textAlign: 'center', firstLineIndent: pt(0) };
// #endregion

// #region acts: an act title is a plain first-level heading, centred and set in capitals
const headings = {
  fontFamily: LABEL, fontWeight: 400, color: col('ink'),
  textAlign: 'center', // every level: the act, the cast list's heads, the scene
  lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0), // whole lines of the grid
  levels: [
    { level: 1, fontFamily: DISPLAY, fontSize: pt(24), lineHeight: pt(3 * LEAD),
      color: col('plum'), textTransform: 'uppercase',
      breakBefore: { enabled: true, parity: 'odd' } }, // a recto, facing the cast list
    { level: 2, fontSize: pt(13), lineHeight: pt(2 * LEAD) },
    { level: 3, fontSize: pt(9.5), color: col('plum') },
  ],
};
// #endregion

// #region cast: the cast list: each person a paragraph, the actor after a dot leader
// A tab sends the actor to a stop at the end of the measure that ends the text there; the stop's
// leader fills the room before it with dots, from half an em after the person to half an em
// before the actor. The bill's head is a centred line in the face's own small capitals.
const person = { id: 'person', fontSize: pt(8.8), firstLineIndent: pt(0), textAlign: 'left',
  tabStops: [{ position: 'end', align: 'end', leader: '.' }],
  spaceBetween: pt(LEAD / 2) }; // a person every line and a half
const billHead = { id: 'bill-head', fontFamily: LABEL, fontSize: pt(8.5), color: col('plum'),
  textAlign: 'center', firstLineIndent: pt(0), marginTop: pt(LEAD / 2),
  marginBottom: pt(LEAD / 2) };
// The scenes: two stops, so `:tab Act I :tab Algernon’s flat` ends the act on the first
// (15 mm in) and starts the place on the second, 2 mm after it, as a two-column list would.
const scene = { id: 'scene', textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD / 2),
  tabStops: [{ position: mm(15), align: 'end' }, { position: mm(17) }] };
// #endregion

// #region ornament: a resource type for artwork with no number and no caption
// ::resource{id="fleuron"} sets it where it stands (gotcha: resource-double-quotes).
const unnumbered = (id, name, defaultPlacement) => ({ id, name, shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', defaultPlacement });
const resourceTypes = [
  unnumbered('ornament', 'Ornament', { position: 'here', width: 0.24, align: 'center' }),
];
// #endregion

// #region playbill: the title page, a heading style whose design fills the sheet
// # The Importance of Being Earnest {style="playbill" small="The" big="Importance" …}
// Each attribute is a playbill line in its own face and size. The ground is an image that
// fills the sheet: the heading reserves down to its foot, so the cast list starts on page 2.
const line = (id, content, font, size, y, color, extra = {}) => ({ kind: 'text', id, content,
  fontFamily: font, fontSize: pt(size), lineHeight: 1, color: col(color), align: 'center',
  placement: { anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } }, ...extra });
const tracked = (track) => ({ textTransform: 'uppercase', letterSpacing: pt(track) });
const playbill = {
  id: 'playbill', span: 'page', // kept in the column, its foot would cut the ground
  header: { elements: [] }, footer: { elements: [] }, // no heads on p. 2, no folio on p. 1
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'ground', resourceId: 'playbill',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    line('kicker', '{subtitle}', LABEL, 8.5, 36, 'gilt', tracked(1.7)),
    line('small', '{attr.small}', TEXT, 17, 51, 'paper', { italic: true }),
    line('big', '{attr.big}', DISPLAY, 40, 60, 'paper', { textTransform: 'uppercase' }),
    line('link', '{attr.link}', TEXT, 17, 78, 'gilt', { italic: true }),
    line('name', '{attr.name}', DISPLAY, 60, 87, 'paper', { textTransform: 'uppercase' }),
    line('author', '{author}', LABEL, 13, 133, 'paper', tracked(3)),
    line('theatre', '{attr.theatre}', LABEL, 8, 170, 'gilt', tracked(1.6)),
    line('premiere', '{attr.premiere}', TEXT, 8.5, 176, 'paper', { italic: true }),
  ] } },
};
// #endregion

// #region heads: the play on the verso, the act on the recto, folios outside
const head = (id, content, parity, edge, x, style) => ({
  kind: 'text', id, content, parity, pages: 'body', // never on openers or blank pages
  fontFamily: LABEL, fontSize: pt(8.5), letterSpacing: pt(0.8), color: col('muted'),
  textTransform: 'uppercase', ...style,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(11) } },
});
const folio = { fontFamily: TEXT, letterSpacing: pt(0), color: col('plum') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-title', '{title}', 'even', 'top-left', OUTER + 8),
  head('recto-act', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 8)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
// The act's first page carries a drop folio instead, centred under the text block.
const footer = { elements: [{ ...head('drop-folio', '{pageNumber}', 'all', 'top', 0, folio),
  pages: 'opener', align: 'center',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(8) } } }] };
// #endregion

const config = () => ({
  locale: t({ en: 'en-us', es: 'es' }), // each edition's hyphenation patterns
  colorPalette,
  resourceTypes,
  page: {
    sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height),
    backgroundColor: col('paper'),
    margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - (LINES * LEAD * 25.4) / 72),
      left: mm(INNER), right: mm(OUTER), mirror: true },
  },
  layout: { layoutType: 'single' },
  bodyText: dialogue,
  headings,
  headingStyles: [playbill],
  paragraphStyles: [direction, person, billHead, scene, { id: 'colophon', fontSize: pt(7.5),
    color: col('muted'), textAlign: 'center', firstLineIndent: pt(0), marginTop: pt(LEAD) }],
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "The Importance of Being Earnest"
subtitle: "A Trivial Comedy for Serious People"
author: "Oscar Wilde"
---

# The Importance of Being Earnest {style="playbill" small="The" big="Importance" link="of Being" name="Earnest" theatre="St James’s Theatre · London" premiere="First performed on 14 February 1895"}

## The Persons of the Play

:::paragraphs{style="bill-head"}
The original cast · St James’s Theatre, 14 February 1895
:::

:::paragraphs{style="person"}
**John Worthing**, J.P. :tab Mr. George Alexander

**Algernon Moncrieff** :tab Mr. Allen Aynesworth

**Rev. Canon Chasuble**, D.D. :tab Mr. H. H. Vincent

**Merriman**, butler :tab Mr. Frank Dyall

**Lane**, manservant :tab Mr. F. Kinsey Peile

**Lady Bracknell** :tab Miss Rose Leclercq

**Hon. Gwendolen Fairfax** :tab Miss Irene Vanbrugh

**Cecily Cardew** :tab Miss Evelyn Millard

**Miss Prism**, governess :tab Mrs. George Canninge
:::

## The Scenes of the Play

:::paragraphs{style="scene"}
:tab Act I :tab Algernon Moncrieff’s Flat in Half-Moon Street, W.

:tab Act II :tab The Garden at the Manor House, Woolton.

:tab Act III :tab Drawing-Room at the Manor House, Woolton.
:::

:::paragraphs{style="direction"}
*Time: The Present.*
:::

:::paragraphs{style="colophon"}
Text: Project Gutenberg eBook #844, in the public domain. Set in Libre Baskerville, Abril Fatface and Playfair Display SC (SIL Open Font License).
:::

# First Act

::resource{id="fleuron"}

### Scene

:::paragraphs{style="direction"}
*Morning-room in Algernon’s flat in Half-Moon Street. The room is luxuriously and artistically furnished. The sound of a piano is heard in the adjoining room.*

*[Lane is arranging afternoon tea on the table, and after the music has ceased, Algernon enters.]*
:::

:::space

**ALGERNON.** Did you hear what I was playing, Lane?

**LANE.** I didn’t think it polite to listen, sir.

**ALGERNON.** I’m sorry for that, for your sake. I don’t play accurately—any one can play accurately—but I play with wonderful expression. As far as the piano is concerned, sentiment is my forte. I keep science for Life.

**LANE.** Yes, sir.

**ALGERNON.** And, speaking of the science of Life, have you got the cucumber sandwiches cut for Lady Bracknell?

**LANE.** Yes, sir. *[Hands them on a salver.]*

**ALGERNON.** *[Inspects them, takes two, and sits down on the sofa.]* Oh!… by the way, Lane, I see from your book that on Thursday night, when Lord Shoreman and Mr. Worthing were dining with me, eight bottles of champagne are entered as having been consumed.

**LANE.** Yes, sir; eight bottles and a pint.

**ALGERNON.** Why is it that at a bachelor’s establishment the servants invariably drink the champagne? I ask merely for information.

**LANE.** I attribute it to the superior quality of the wine, sir. I have often observed that in married households the champagne is rarely of a first-rate brand.

**ALGERNON.** Good heavens! Is marriage so demoralising as that?

**LANE.** I believe it *is* a very pleasant state, sir. I have had very little experience of it myself up to the present. I have only been married once. That was in consequence of a misunderstanding between myself and a young person.

**ALGERNON.** *[Languidly.]* I don’t know that I am much interested in your family life, Lane.

**LANE.** No, sir; it is not a very interesting subject. I never think of it myself.

**ALGERNON.** Very natural, I am sure. That will do, Lane, thank you.

**LANE.** Thank you, sir. *[Lane goes out.]*

**ALGERNON.** Lane’s views on marriage seem somewhat lax. Really, if the lower orders don’t set us a good example, what on earth is the use of them? They seem, as a class, to have absolutely no sense of moral responsibility.

:::paragraphs{style="direction"}
*[Enter Lane.]*
:::

**LANE.** Mr. Ernest Worthing.

:::paragraphs{style="direction"}
*[Enter Jack.]*

*[Lane goes out.]*
:::

**ALGERNON.** How are you, my dear Ernest? What brings you up to town?

**JACK.** Oh, pleasure, pleasure! What else should bring one anywhere? Eating as usual, I see, Algy!

**ALGERNON.** *[Stiffly.]* I believe it is customary in good society to take some slight refreshment at five o’clock. Where have you been since last Thursday?

**JACK.** *[Sitting down on the sofa.]* In the country.

**ALGERNON.** What on earth do you do there?

**JACK.** *[Pulling off his gloves.]* When one is in town one amuses oneself. When one is in the country one amuses other people. It is excessively boring.

**ALGERNON.** And who are the people you amuse?

**JACK.** *[Airily.]* Oh, neighbours, neighbours.

**ALGERNON.** Got nice neighbours in your part of Shropshire?

**JACK.** Perfectly horrid! Never speak to one of them.

**ALGERNON.** How immensely you must amuse them! *[Goes over and takes sandwich.]* By the way, Shropshire is your county, is it not?

**JACK.** Eh? Shropshire? Yes, of course. Hallo! Why all these cups? Why cucumber sandwiches? Why such reckless extravagance in one so young? Who is coming to tea?

**ALGERNON.** Oh! merely Aunt Augusta and Gwendolen.

**JACK.** How perfectly delightful!

**ALGERNON.** Yes, that is all very well; but I am afraid Aunt Augusta won’t quite approve of your being here.

**JACK.** May I ask why?

**ALGERNON.** My dear fellow, the way you flirt with Gwendolen is perfectly disgraceful. It is almost as bad as the way Gwendolen flirts with you.

**JACK.** I am in love with Gwendolen. I have come up to town expressly to propose to her.

**ALGERNON.** I thought you had come up for pleasure?… I call that business.

**JACK.** How utterly unromantic you are!

**ALGERNON.** I really don’t see anything romantic in proposing. It is very romantic to be in love. But there is nothing romantic about a definite proposal. Why, one may be accepted. One usually is, I believe. Then the excitement is all over. The very essence of romance is uncertainty. If ever I get married, I’ll certainly try to forget the fact.

**JACK.** I have no doubt about that, dear Algy. The Divorce Court was specially invented for people whose memories are so curiously constituted.

**ALGERNON.** Oh! there is no use speculating on that subject. Divorces are made in Heaven—*[Jack puts out his hand to take a sandwich. Algernon at once interferes.]* Please don’t touch the cucumber sandwiches. They are ordered specially for Aunt Augusta. *[Takes one and eats it.]*
`; // content.<lang>.md, inlined by the Cookbook

// #region art: the title page's ground and double frame, and the carnation fleuron, in mm
let seed = 1895; // Mulberry32, a seeded PRNG: never Math.random() in a recipe
const rand = () => {
  let r = Math.imul((seed = (seed + 0x6d2b79f5) | 0) ^ (seed >>> 15), 1 | seed);
  r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
  return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
const f = (n) => +n.toFixed(2);
const mix = (a, b, k) => `#${[1, 3, 5].map((i) => Math.round(parseInt(palette[a].slice(i, i + 2),
  16) * (1 - k) + parseInt(palette[b].slice(i, i + 2), 16) * k).toString(16).padStart(2, '0'))
  .join('')}`;
const svgOf = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" `
  + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
const pts = (list) => list.map(([x, y]) => `${f(x)} ${f(y)}`).join('L');
const poly = (list, fill) => `<path d="M${pts(list)}Z" fill="${fill}"/>`;
const stroke = (list, color, w) => `<path d="M${pts(list)}" fill="none" stroke="${color}" `
  + `stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round"/>`;
const quad = ([x0, y0], [cx, cy], [x1, y1], t) => [
  (1 - t) ** 2 * x0 + 2 * (1 - t) * t * cx + t * t * x1,
  (1 - t) ** 2 * y0 + 2 * (1 - t) * t * cy + t * t * y1];

// A carnation `s` mm tall standing on (cx, cy), seen from the side: three fans of pinked
// petals spring from the rim of a calyx, notched apart in the ground colour behind them.
const DEG = Math.PI / 180;
function carnation(cx, cy, s, [back, mid, front], green, ground) {
  const out = [];
  const [rx, ry] = [cx, cy - s * 0.32]; // the rim of the calyx
  const fan = (from, to, r, lift, petals, fill) => { // a fan from `from`° to `to`°, r mm deep
    const at = (deg, k) => [rx + Math.cos(deg * DEG) * r * k,
      ry - lift + Math.sin(deg * DEG) * r * k * 0.92];
    const edge = [];
    for (let i = 0; i <= petals * 6; i++) { // six teeth to a petal, each petal a rounded lobe
      const lobe = 0.84 + 0.16 * Math.sin((Math.PI * (i % 6)) / 6);
      edge.push(at(from + ((to - from) * i) / (petals * 6),
        (i % 2 ? 0.9 : 1) * lobe * (0.98 + rand() * 0.04)));
    }
    out.push(poly([[rx, ry - lift], ...edge], fill));
    const notch = (to - from) / petals / 14; // half the angle of a notch at the edge
    for (let p = 1; p < petals; p++) { // a thin wedge between two petals
      const deg = from + ((to - from) * p) / petals;
      out.push(poly([at(deg, 0.66), at(deg - notch, 1.1), at(deg + notch, 1.1)], ground));
    }
  };
  fan(-162, -18, s * 0.74, 0, 7, back);
  fan(-146, -34, s * 0.56, s * 0.06, 5, mid);
  fan(-124, -56, s * 0.36, s * 0.1, 3, front);
  // The calyx narrows to the stem; three short sepals rise over the petals, and an outline in
  // the ground colour keeps it clear of them.
  const [a, b] = [s * 0.11, s * 0.045]; // half-widths at the rim and at the stem
  const cup = [[rx - a, ry + s * 0.03], [rx - a * 1.25, ry - s * 0.07],
    [rx - a * 0.45, ry - s * 0.01], [rx, ry - s * 0.1], [rx + a * 0.45, ry - s * 0.01],
    [rx + a * 1.25, ry - s * 0.07], [rx + a, ry + s * 0.03], [cx + b, cy], [cx - b, cy]];
  out.push(`<path d="M${pts(cup)}Z" fill="${green}" stroke="${ground}" `
    + `stroke-width="${f(s * 0.03)}" stroke-linejoin="round"/>`);
  return out.join('');
}
// A scroll: an arm out from the stem that ends in a spiral curl.
function scroll(x0, y0, dir, len, curl, color, w) {
  const list = [];
  for (let i = 0; i <= 24; i++) {
    const t = i / 24;
    list.push([x0 + dir * len * t, y0 + Math.sin(t * Math.PI) * curl * 0.3 - t * curl * 0.25]);
  }
  const [ex, ey] = list[list.length - 1];
  for (let i = 1; i <= 48; i++) {
    const a = (i / 48) * Math.PI * 1.8;
    const r = curl * 0.5 * Math.exp(-0.38 * a);
    list.push([ex + dir * Math.sin(a) * r, ey - curl * 0.5 + Math.cos(a) * r]);
  }
  return stroke(list, color, w);
}
// A carnation leaf: a narrow blade along a curve from its base.
function blade(p0, c, p1, w, fill) {
  const [left, right] = [[], []];
  for (let i = 0; i <= 16; i++) {
    const t = i / 16;
    const [x, y] = quad(p0, c, p1, t);
    const [x2, y2] = quad(p0, c, p1, Math.min(1, t + 0.01));
    const [dx, dy] = [x2 - x, y2 - y];
    const k = (w * Math.sin(Math.PI * Math.min(1, t * 1.15)) ** 0.7) / 2
      / (Math.hypot(dx, dy) || 1);
    left.push([x - dy * k, y + dx * k]);
    right.unshift([x + dy * k, y - dx * k]);
  }
  return poly([...left, ...right], fill);
}
// The fleuron, w × h mm: the carnation between two leaves and two scrolls, on `ground`.
function fleuron(w, h, petals, green, ground) {
  const [cx, base] = [w / 2, h * 0.9];
  const parts = [-1, 1].map((d) => scroll(cx + d * 0.6, base, d, w * 0.38, h * 0.46, green,
    h * 0.035) + blade([cx + d * 0.8, base - 0.2], [cx + d * w * 0.12, base - h * 0.02],
    [cx + d * w * 0.24, base - h * 0.3], h * 0.07, green));
  return svgOf(w, h, parts.join('') + carnation(cx, base, h * 0.88, petals, green, ground));
}
// The title page: a plum sheet, a double gold frame whose rules cross at the corners
// (Oxford corners), and the carnation in gold between the title and the author.
const FLEURON_Y = 109; // mm: the top of the title page's carnation
function playbillArt(W, H) {
  const out = [`<rect width="${W}" height="${H}" fill="${palette.plum}"/>`];
  const frame = (inset, w, reach) => { // four rules `inset` mm in, running `reach` mm past
    const [a, bx, by] = [inset, W - inset, H - inset];
    for (const [p, q] of [[[a - reach, a], [bx + reach, a]], [[a - reach, by], [bx + reach, by]],
      [[a, a - reach], [a, by + reach]], [[bx, a - reach], [bx, by + reach]]]) {
      out.push(stroke([p, q], palette.gold, w));
    }
  };
  frame(9, 0.55, 3.2);
  frame(11, 0.22, -1.2);
  for (const [x, y] of [[9, 9], [W - 9, 9], [9, H - 9], [W - 9, H - 9]]) {
    out.push(`<circle cx="${x}" cy="${y}" r="0.9" fill="${palette.gold}"/>`);
  }
  const petals = [mix('gold', 'plum', 0.35), palette.gold, mix('paper', 'gold', 0.2)];
  const sprig = fleuron(46, 16, petals, palette.gold, palette.plum)
    .replace(/^<svg[^>]*>|<\/svg>$/g, '');
  out.push(`<g transform="translate(${(W - 46) / 2} ${FLEURON_Y})">${sprig}</g>`);
  return svgOf(W, H, out.join(''));
}
await loadSvg('playbill.svg', playbillArt(TRIM.width, TRIM.height));
await loadSvg('fleuron.svg', fleuron(30, 11, [mix('plum', 'ink', 0.4), mix('plum', 'paper', 0.12),
  mix('plum', 'paper', 0.45)], palette.gold, palette.paper));
const svg = (id, w, h, altText) => ({ id, typeId: 'ornament', kind: 'svg', altText,
  svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, createdAt: 0, updatedAt: 0 });
const art = [
  svg('playbill', TRIM.width, TRIM.height, 'A plum title page in a double gold frame whose '
    + 'rules cross at the corners, with a gold carnation between two scrolls.'),
  svg('fleuron', 30, 11, 'Ornament: a plum carnation between two gold scrolls.'),
];
// #endregion
const resources = art;

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { 'Libre Baskerville': ['400', '400i', '700'], 'Abril Fatface': ['400'],
  'Playfair Display SC': ['400'] }; // all loaded before the first build

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS));
showPages(doc, { title: doc.metadata.title }); // the frontmatter's title

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

### Aligner les didascalies sur les lignes suivantes

Composée au fer à gauche avec un alinéa de 5 mm, une didascalie d’une ligne commence là où commencent les lignes suivantes des répliques, et la description du décor devient deux paragraphes en alinéa sous son étiquette centrée.

```diff
 const direction = { id: 'direction', fontSize: pt(8.6),
-  textAlign: 'center', firstLineIndent: pt(0) };
+  textAlign: 'left', firstLineIndent: mm(5) };
```

### Laisser une ligne entre les répliques

`paragraphSpacing: true` ajoute une ligne de grille blanche après chaque réplique. L’acte anglais court alors sur cinq pages au lieu de trois, l’espagnol sur quatre.

```diff
   firstLineIndent: mm(5), hangingIndent: true, // the name at the margin, the turnovers hung
+  paragraphSpacing: true,
 };
```

## Erreurs fréquentes

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

- Tout passage en italique du dialogue prend `italicColor`, y compris l’emphase : le *is* appuyé de Lane s’imprime donc dans le gris des didascalies. Le gris est posé sur `bodyText` parce que la plupart des didascalies se trouvent dans une réplique, dans le même paragraphe que l’emphase ; seul le romain garderait le *is* à l’encre.
- Trois répliques de Wilde se terminent sur un mot seul : *information.*, *you.* et *that?* La correction des fins de paragraphe trop courtes peut remonter la troisième sur la ligne précédente, mais seulement avec le 1 % d’approche négative que `bodyText.maxRuntTracking` autorise par défaut, et la réplique paraît alors plus sombre que ses voisines. `maxRuntTracking: 5` divise ce plafond par deux : *that?* passe à la ligne, et la ligne au-dessus s’ouvre à 1,78 fois l’espace normal, sous la limite de 1,8. Les pages anglaises gardent les trois fins courtes plutôt que de changer les mots de Wilde. La traduction espagnole a été reformulée partout où une ligne finissait sur un mot d’une lettre ou laissait une syllabe seule.
- Les noms se coupent comme n’importe quel mot : *Au-gusta* et *Gwen-dolen* sont coupés en fin de ligne en page 5. Un gluon (U+2060) dans *Augusta* le garde entier, mais il étire la ligne au-dessus à 1,71 fois l’espace normal : le texte anglais garde donc les césures.
- Playfair Display SC dessine *fi* comme une ligature minuscule sur le canvas : *first* s’imprimerait avec un *fi* minuscule parmi des petites capitales. C’est pourquoi l’en-tête de la distribution dit *The original cast*.

## Crédits

- Recette: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texte: The Importance of Being Earnest (1895): the persons and scenes of the play, the first cast and the opening of the first act: Oscar Wilde ([source](https://www.gutenberg.org/ebooks/844)), domaine public
- Texte: The Spanish translation of the extract, the cast list and the scenes: Postext Cookbook, original
- Images: The title page’s frame and the carnation fleuron, drawn in code in the page’s palette: Ignacio Ferro, MIT
- Caractères: Libre Baskerville (OFL-1.1), Abril Fatface (OFL-1.1), Playfair Display SC (OFL-1.1)
- Code: MIT · Contenu d’exemple: MIT

## Recettes liées

- [Nº 061 · Mise en page d’un scénario](https://postext.dev/fr/cookbook/screenplay-format.md): Scénario de tournage en Courier Prime 12 sur US Letter : répliques en encadrés sans cadre calés sur le bloc de dialogue, séquences numérotées des deux côtés. · Niveau 2 (Intermédiaire) · Tous genres
- [Nº 015 · Des poèmes composés vers à vers](https://postext.dev/fr/cookbook/poetry-collection.md): Chaque poème est un bloc :::verse écrit tel qu’imprimé : retraits de 1918 en espaces de tête, renvois pendant 2 em plus loin, ligne blanche entre strophes. · Niveau 2 (Intermédiaire) · Poésie
- [Nº 010 · Fiche technique : tableaux de données, en-têtes fusionnés](https://postext.dev/fr/cookbook/technical-datasheet.md): Des tableaux collés en TSV, lus par parseTSV, façonnés par mergeCells, setAlignment et setCellBackground ; une carte de registres qui se coupe seule. · Niveau 3 (Avancé) · Manuels, guides et ouvrages de référence
