# Copione teatrale: personaggi, battute e didascalie

> La commedia di Wilde come copione per attori: nomi in grassetto prugna, didascalie in corsivo grigio e un elenco dei personaggi con puntini fino a ogni attore.

- Versione HTML: https://postext.dev/it/cookbook/stage-play
- Ricetta Nº 070 · Pubblicazioni complete · Livello 2 (Intermedio) · Output: Canvas
- Generi: Narrativa, teatro e prosa letteraria
- Richiede postext ≥ 1.25.0 · provata con 1.31.0 il 2026-10-13
- Pagine: [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)
- Apri nella Sandbox: https://postext.dev/it/sandbox#recipe=stage-play&lang=en (.postext: https://postext.dev/cookbook/stage-play/en/stage-play.postext)
- Ultimo aggiornamento: 2026-10-11
- Altre lingue: [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), [de](https://postext.dev/de/cookbook/stage-play.md), [fr](https://postext.dev/fr/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)

## In breve

L’inizio di una celebre commedia di Oscar Wilde, stampata per gli attori. I nomi di chi parla risaltano in grassetto colorato, e le didascalie sono in grigio così si saltano facilmente.

## Che cosa comporrai

L’inizio di *The Importance of Being Earnest* di Oscar Wilde, composto come copione per attori di cinque pagine a 140 × 216 mm. Il frontespizio è una locandina prugna in una doppia cornice dorata, con *Importance* ed *Earnest* in maiuscole grassissime e un garofano dorato sotto il titolo. Di fronte al primo atto, l’elenco dei personaggi nomina l’attore che creò ogni ruolo al St James’s Theatre nel 1895. Ogni battuta si apre con il nome di chi parla in grassetto prugna e fa sporgere di 5 mm le righe successive, così un attore trova la battuta d’attacco seguente a colpo d’occhio. Le didascalie, dentro una battuta o tra due battute, sono in corsivo grigio, e l’occhio può saltarle mentre si ripassa la parte. L’edizione spagnola è una nuova traduzione che segue l’uso teatrale spagnolo, con una lineetta dopo ogni nome e le didascalie tra parentesi.

**Questa ricetta risponde a:**

- Come compongo un copione: nomi di chi parla, didascalie, titoli di atti e scene e l’elenco dei personaggi?
- Come coloro i termini chiave (in grassetto o in corsivo) nel testo o dentro i riquadri?
- Come compongo illustrazioni non numerate: fregi, vignette, loghi?

## La risposta breve

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

## Ingredienti

**Insegna**

- [Stili di paragrafo](https://postext.dev/it/docs/configuration-styles.md#stili-di-paragrafo): Stili con nome per gruppi di paragrafi distinti dal testo corrente: versi, epigrafi, dediche, firme, testo in corpo minore.
- [Grassetto, corsivo e i loro colori](https://postext.dev/it/docs/configuration-text.md#testo-corrente): I tratti in grassetto e in corsivo e il colore in cui vengono stampati; per impostazione predefinita prendono il blu d’accento, quindi un libro monocromatico li riporta al colore del testo.
- [Tabulazioni e riempitivi](https://postext.dev/it/docs/configuration-text.md#punti-di-tabulazione): tabStops in uno stile di paragrafo, in bodyText o nel corpo di un riquadro, con :tab nel testo, allinea le parole dopo ogni tabulazione a un arresto (inizio, fine, centro o virgola decimale), con un riempitivo facoltativo di puntini o un filetto davanti: menu, listini prezzi, elenchi di personaggi, punteggi d’esame e moduli composti come testo.

**Usa anche**

- [Rientri, allineamento e spaziatura dei paragrafi](https://postext.dev/it/docs/configuration-text.md#testo-corrente)
- [Livelli dei titoli](https://postext.dev/it/docs/configuration-text.md#sovrascritture-per-livello)
- [Capitoli che si aprono su un recto](https://postext.dev/it/docs/configuration-text.md#interruzione-prima-del-titolo)
- [Tipi di risorsa personalizzati](https://postext.dev/it/docs/configuration-resources.md#tipi-di-risorsa)
- [Figure proprio qui](https://postext.dev/it/docs/document-format.md#inserimento-di-blocco-facoltativo-collocazione-in-linea-esplicita)
- [Stili di titolo](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli)
- [Copertine, frontespizi e colophon](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli)
- [Attributi dei titoli](https://postext.dev/it/docs/document-format.md#attributi-dei-titoli)
- [Immagini nei progetti di pagina](https://postext.dev/it/docs/configuration-page-layout.md#elementi-immagine)
- [Testo, filetti e riquadri nei progetti di pagina](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina)
- [Testatine e folio](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina)
- [Testatine secondo il ruolo della pagina](https://postext.dev/it/docs/configuration-page-layout.md#elementi-di-testo)
- [Metadati del documento](https://postext.dev/it/docs/document-format.md#frontmatter)
- [Colore della carta](https://postext.dev/it/docs/configuration-page-layout.md#pagina)
- [Tavolozza di colori semantica](https://postext.dev/it/docs/configuration-fonts-colors-viewers.md#tavolozza-dei-colori)
- [Aperture progettate](https://postext.dev/it/docs/configuration-text.md#estensione-e-progetto-grafico-avanzato)
- [Figure e tabelle come risorse](https://postext.dev/it/docs/document-format.md#risorse)
- [Testatine per sezione](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli)
- [Spazio verticale esplicito](https://postext.dev/it/docs/document-format.md#space)

**La configurazione in breve**

- [`bodyText`](https://postext.dev/it/docs/configuration-text.md#testo-corrente), [`colorPalette`](https://postext.dev/it/docs/configuration-fonts-colors-viewers.md#tavolozza-dei-colori), [`footer`](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina), [`header`](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina), [`headingStyles`](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli), [`headings`](https://postext.dev/it/docs/configuration-text.md#titoli), [`layout`](https://postext.dev/it/docs/configuration-page-layout.md#impaginazione), [`locale`](https://postext.dev/it/docs/configuration-text.md#sillabazione), [`page`](https://postext.dev/it/docs/configuration-page-layout.md#pagina), [`paragraphStyles`](https://postext.dev/it/docs/configuration-styles.md#stili-di-paragrafo), [`resourceTypes`](https://postext.dev/it/docs/configuration-resources.md#tipi-di-risorsa)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/it/docs/configuration-programmatic-usage.md#caricare-i-font-prima-dellimpaginazione), [`registerResourceImage`](https://postext.dev/it/docs/architecture.md#superficie-dellapi), [`renderPageToCanvas`](https://postext.dev/it/docs/configuration-programmatic-usage.md#rendere-una-pagina-come-bitmap)

**Caratteri**

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

## Preparazione

### 1 · Un atto è un titolo semplice

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

Il titolo dell’atto è un titolo di primo livello in Abril Fatface a 24 pt, senza spazio grafico. `textTransform` lo compone in maiuscolo, e le sue tre righe di griglia da 13,6 pt mettono le maiuscole 5,5 mm sotto la sommità della gabbia del testo ([titoli](https://postext.dev/it/docs/configuration-text.md#titoli)). `textAlign: 'center'` non si può impostare per livello, quindi centra anche i titoli dell’elenco dei personaggi e l’etichetta *Scene*. `breakBefore` con parità `'odd'` fa cominciare l’atto su un recto, di fronte all’elenco dei personaggi. Il valore predefinito, `'always-odd'`, metterebbe due pagine bianche tra di loro ([interruzione prima](https://postext.dev/it/docs/configuration-text.md#interruzione-prima-del-titolo)).

### 2 · L’elenco dei personaggi è una fila di puntini fino a ogni attore

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

Ogni personaggio è un paragrafo dello stile `person`, con l’attore dopo `:tab` ([tabulazioni e punti di tabulazione](https://postext.dev/it/docs/document-format.md#tabulazioni-e-tabulatori)). Lo stile ha un solo punto di tabulazione, a `'end'`, la fine della giustezza, e con `align: 'end'` il nome dell’attore finisce lì. Il suo `leader: '.'` riempie lo spazio prima del punto di tabulazione con punti fermi, a mezzo em di distanza dal personaggio e dall’attore, e `spaceBetween` mette un personaggio ogni riga e mezza. I nomi dei personaggi sono in grassetto, quindi si stampano in prugna, come i nomi di chi parla nel dialogo. Il cast originale, il teatro e la data intestano l’elenco nello stile `bill-head`, centrati in Playfair Display SC, le cui minuscole sono maiuscoletti. Le scene usano due punti di tabulazione: `:tab Act I :tab …` fa finire *Act I* sul primo, a 15 mm, con `align: 'end'`, così i tre atti stanno allineati a destra su una colonna, e fa cominciare il luogo sul secondo, 2 mm dopo. Il tempo dell’azione è una riga centrata nello stile delle didascalie.

### 3 · Ornamenti senza numero e senza didascalia

```js
// script.js, righe 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 tipo di risorsa con `captionPrefix` e `shortLabel` vuoti stampa le sue immagini senza nulla ([tipi di risorsa](https://postext.dev/it/docs/configuration-resources.md#tipi-di-risorsa)). Il suo `defaultPlacement` mette il garofano dove sta `::resource{id="fleuron"}`, centrato e largo 0,24 della giustezza di 107 mm, circa 26 mm.

### 4 · Il frontespizio è uno stile di titolo

```js
// script.js, righe 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" …}` porta il titolo in quattro attributi, che il disegno compone in due caratteri: Abril Fatface a 40 e 60 pt per i sostantivi, Libre Baskerville corsivo a 17 pt per *The* e *of Being* ([attributi del titolo](https://postext.dev/it/docs/document-format.md#attributi-dei-titoli)). Il fondo prugna, la cornice e il garofano sono un unico SVG disegnato nel codice e ancorato all’abbondanza ([elementi immagine](https://postext.dev/it/docs/configuration-page-layout.md#elementi-immagine)). Lo stile imposta `span: 'page'` anche se il libro ha una sola colonna. Tenuto nella colonna, il disegno verrebbe tagliato al piede della gabbia del testo, 23,3 mm sopra il piede del foglio. L’`header` vuoto dello stile tiene le testatine lontane dall’elenco dei personaggi, che appartiene alla stessa sezione, e il suo `footer` vuoto tiene il folio al piede lontano dal frontespizio, dove si stamperebbe prugna su prugna ([stili dei titoli](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli)).

Il fondo è un elemento immagine, e un’apertura riserva lo spazio fino al piede delle sue immagini, quindi il titolo occupa l’intero foglio: *The Persons of the Play* comincia a pagina 2 senza un `:::pagebreak` dopo il titolo, ovunque stiano le righe del frontespizio. Nemmeno una riga centrata con `letterSpacing` richiede correzioni. La spaziatura dopo la sua ultima lettera è esclusa dalla centratura, e *OSCAR WILDE* sta al centro della pagina.

### 5 · Testatine che saltano la prima pagina dell’atto

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

Il verso porta il titolo della commedia dal frontmatter e il recto l’atto, da `{chapterTitle}`, entrambi in maiuscole di Playfair Display SC con il folio in prugna sul bordo esterno ([elementi di testo](https://postext.dev/it/docs/configuration-page-layout.md#elementi-di-testo)). La prima pagina dell’atto conta come apertura perché il suo primo blocco è un titolo che interrompe la pagina, quindi `pages: 'body'` tiene lontane le testatine, e il piè di pagina le dà invece un folio al piede.

> Una sceneggiatura cinematografica rientra il dialogo da entrambi i lati invece di farlo sporgere: [Formato di sceneggiatura](https://postext.dev/it/cookbook/screenplay-format.md) compone ogni battuta come un riquadro senza cornice con un padding pari alla larghezza del blocco del dialogo.

## La ricetta completa

Un unico file, composto a partire dalla cartella della ricetta con il testo di esempio e il kit comune del Ricettario già inclusi; costruisce la propria pagina. Per eseguirlo, mettilo in uno `<script type="module">` di una pagina vuota, oppure incollalo nel pannello JS di un nuovo pen di CodePen (come modulo). Importa postext da esm.sh: non c’è niente da installare né da compilare.

- Cartella della ricetta: 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 ───────────────────────────────────────────────────────────────────────
```

## Varianti

### Allinea le didascalie alle righe successive

Allineata a sinistra con un rientro di prima riga di 5 mm, una didascalia di una riga comincia dove cominciano le righe successive delle battute, e la descrizione della scena diventa due paragrafi rientrati sotto la sua etichetta centrata.

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

### Lascia una riga tra le battute

`paragraphSpacing: true` aggiunge una riga bianca della griglia dopo ogni battuta. L’atto inglese arriva allora a cinque pagine invece di tre, quello spagnolo a quattro.

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

## Errori frequenti

- **::resource{id="…"} accetta solo virgolette doppie.** Un inserimento a blocco viene riconosciuto solo come ::resource{id="…"} con virgolette doppie; qualsiasi altra forma resta nel testo come riga visibile.

- Ogni sequenza in corsivo del dialogo prende `italicColor`, enfasi compresa, quindi l’enfatico *is* di Lane si stampa nel grigio delle didascalie. Il grigio va su `bodyText` perché la maggior parte delle didascalie sta dentro una battuta, nello stesso paragrafo dell’enfasi; solo il tondo terrebbe l’*is* in nero.
- Tre battute di Wilde finiscono con una parola da sola: *information.*, *you.* e *that?* La correzione delle righe finali troppo corte può tirare su la terza nella riga precedente, ma solo con l’1 % di spaziatura negativa che `bodyText.maxRuntTracking` consente per impostazione predefinita, e la battuta risulta allora più scura di quelle intorno. `maxRuntTracking: 5` dimezza quel limite: *that?* va a capo, e la riga sopra si allarga a 1,78 volte lo spazio normale tra parole, entro il limite di 1,8. Le pagine inglesi tengono le tre righe corte piuttosto che cambiare le parole di Wilde. La traduzione spagnola è stata riformulata ovunque una riga finisse su una parola di una lettera o una sillaba restasse da sola.
- I nomi si sillabano come qualsiasi altra parola: *Au-gusta* e *Gwen-dolen* vanno a capo a pagina 5. Un word joiner (U+2060) dentro *Augusta* lo tiene intero, ma allarga la riga sopra a 1,71 volte lo spazio normale tra parole, quindi il testo inglese tiene i trattini.
- Playfair Display SC disegna *fi* come legatura minuscola sul canvas, quindi *first* stamperebbe una *fi* minuscola tra i maiuscoletti. Per questo il titolo dell’elenco dei personaggi dice *The original cast*.

## Crediti

- Ricetta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Testo: 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 ([fonte](https://www.gutenberg.org/ebooks/844)), pubblico dominio
- Testo: The Spanish translation of the extract, the cast list and the scenes: Postext Cookbook, originale
- Immagini: The title page’s frame and the carnation fleuron, drawn in code in the page’s palette: Ignacio Ferro, MIT
- Caratteri: Libre Baskerville (OFL-1.1), Abril Fatface (OFL-1.1), Playfair Display SC (OFL-1.1)
- Codice: MIT · Contenuto di esempio: MIT

## Ricette correlate

- [Nº 061 · Formato di sceneggiatura](https://postext.dev/it/cookbook/screenplay-format.md): Un copione di lavorazione in Courier Prime 12 su US Letter: ogni battuta è un riquadro senza cornice largo quanto il dialogo, ogni scena numerata nei margini. · Livello 2 (Intermedio) · Qualsiasi genere
- [Nº 015 · Poesie composte verso per verso](https://postext.dev/it/cookbook/poetry-collection.md): Ogni poesia è un blocco :::verse scritto come stampato: i rientri del 1918 sono spazi iniziali, i versi a capo sporgono di 2 em, righe vuote tra le strofe. · Livello 2 (Intermedio) · Poesia
- [Nº 010 · Scheda tecnica: tabelle dai dati, intestazioni unite](https://postext.dev/it/cookbook/technical-datasheet.md): Tabelle incollate come TSV, lette con parseTSV e modellate con mergeCells, setAlignment e setCellBackground; una mappa dei registri si divide da sola. · Livello 3 (Avanzato) · Manuali, guide e opere di consultazione
