# Theatertext: Personenverzeichnis, Sprecher, Regieanweisungen

> Wildes Stück als Bühnenfassung: Namen fett in Pflaumenrot, Regieanweisungen grau kursiv, ein Personenverzeichnis mit Punktlinie zu jedem Darsteller am Tabstopp.

- HTML-Fassung: https://postext.dev/de/cookbook/stage-play
- Rezept Nº 070 · Vollständige Publikationen · Stufe 2 (Mittel) · Ausgaben: Canvas
- Gattungen: Belletristik, Drama und Prosa
- Benötigt postext ≥ 1.25.0 · getestet mit 1.31.0 am 2026-10-13
- Seiten: [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)
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=stage-play&lang=en (.postext: https://postext.dev/cookbook/stage-play/en/stage-play.postext)
- Zuletzt aktualisiert: 2026-10-11
- Andere Sprachen: [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), [fr](https://postext.dev/fr/cookbook/stage-play.md), [it](https://postext.dev/it/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)

## Kurz gesagt

Der Anfang einer berühmten Komödie von Oscar Wilde, für Schauspieler gedruckt. Die Namen der Sprecher stechen in fetter Farbe hervor, und die Regieanweisungen sind grau, damit man sie leicht überspringen kann.

## Was Sie setzen

Der Anfang von Oscar Wildes *The Importance of Being Earnest*, gesetzt als fünfseitige Bühnenfassung in 140 × 216 mm. Die Titelseite ist ein pflaumenroter Theaterzettel in einem doppelten goldenen Rahmen, mit *Importance* und *Earnest* in fetten Versalien einer Fat Face und einer goldenen Nelke unter dem Titel. Gegenüber dem ersten Akt nennt das Personenverzeichnis den Darsteller, der jede Rolle 1895 am St James’s Theatre schuf. Jede Replik beginnt mit dem Namen des Sprechers in fettem Pflaumenrot und zieht ihre Folgezeilen 5 mm hängend ein, sodass ein Schauspieler das nächste Stichwort auf einen Blick findet. Regieanweisungen, innerhalb einer Replik oder zwischen zweien, stehen grau kursiv, und das Auge kann sie beim Textlernen überspringen. Die spanische Ausgabe ist eine neue Übersetzung, die der spanischen Bühnenpraxis folgt, mit einem Gedankenstrich nach jedem Namen und den Regieanweisungen in Klammern.

**Dieses Rezept beantwortet:**

- Wie setze ich ein Theaterstück: Sprechernamen, Regieanweisungen, Akte und Szenen, eine Besetzungsliste?
- Wie färbe ich Schlüsselbegriffe (fett oder kursiv) im Text oder in Kästen ein?
- Wie setze ich nicht nummerierte Grafiken: Ornamente, Vignetten, Logos?

## Die kurze Antwort

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

## Zutaten

**Zeigt**

- [Absatzformate](https://postext.dev/de/docs/configuration-styles.md#absatzformate): Benannte Formate für Folgen von Absätzen, die sich vom Fließtext abheben: Verse, Mottos, Widmungen, Unterschriften, Kleingedrucktes.
- [Fett, kursiv und ihre Farben](https://postext.dev/de/docs/configuration-text.md#fließtext): Fette und kursive Passagen und die Farbe, in der sie gedruckt werden; standardmäßig erhalten sie das Akzentblau, ein einfarbiges Buch setzt sie also auf die Textfarbe zurück.
- [Tabulatoren und Füllzeichen](https://postext.dev/de/docs/configuration-text.md#tabulatoren): tabStops an einem Absatzformat, an bodyText oder am Inhalt eines Kastens richtet zusammen mit :tab im Text die Wörter nach jedem Tabulator an einem Tabstopp aus (Anfang, Ende, Mitte oder Dezimalzeichen), wahlweise mit Füllpunkten oder einer Linie davor: Speisekarten, Preislisten, Besetzungslisten, Prüfungspunkte und Formulare als Text gesetzt.

**Verwendet außerdem**

- [Einzüge, Ausrichtung und Absatzabstände](https://postext.dev/de/docs/configuration-text.md#fließtext)
- [Überschriftenebenen](https://postext.dev/de/docs/configuration-text.md#überschreibungen-pro-ebene)
- [Kapitel, die rechts beginnen](https://postext.dev/de/docs/configuration-text.md#umbruch-davor)
- [Eigene Ressourcentypen](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen)
- [Abbildungen genau hier](https://postext.dev/de/docs/document-format.md#blockeinbettung-optional-ausdrückliche-platzierung-im-text)
- [Überschriftenformate](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Umschläge, Titelseiten und Impressum](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Überschriftenattribute](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)
- [Bilder in Seitengestaltungen](https://postext.dev/de/docs/configuration-page-layout.md#bildelemente)
- [Text, Linien und Kästen in Seitengestaltungen](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen)
- [Kolumnentitel und Seitenzahlen](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen)
- [Kopfzeilen nach Seitenrolle](https://postext.dev/de/docs/configuration-page-layout.md#textelemente)
- [Dokument-Metadaten](https://postext.dev/de/docs/document-format.md#frontmatter)
- [Papierfarbe](https://postext.dev/de/docs/configuration-page-layout.md#seite)
- [Semantische Farbpalette](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette)
- [Gestaltete Auftakte](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung)
- [Abbildungen und Tabellen als Ressourcen](https://postext.dev/de/docs/document-format.md#ressourcen)
- [Kolumnentitel je Abschnitt](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Expliziter vertikaler Abstand](https://postext.dev/de/docs/document-format.md#space)

**Die Konfiguration auf einen Blick**

- [`bodyText`](https://postext.dev/de/docs/configuration-text.md#fließtext), [`colorPalette`](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette), [`footer`](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen), [`header`](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen), [`headingStyles`](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile), [`headings`](https://postext.dev/de/docs/configuration-text.md#überschriften), [`layout`](https://postext.dev/de/docs/configuration-page-layout.md#layout), [`locale`](https://postext.dev/de/docs/configuration-text.md#silbentrennung), [`page`](https://postext.dev/de/docs/configuration-page-layout.md#seite), [`paragraphStyles`](https://postext.dev/de/docs/configuration-styles.md#absatzformate), [`resourceTypes`](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen)

**APIs**

- [`buildDocumentWithFonts`](https://postext.dev/de/docs/configuration-programmatic-usage.md#schriften-vor-dem-layout-laden), [`registerResourceImage`](https://postext.dev/de/docs/architecture.md#api-oberfläche), [`renderPageToCanvas`](https://postext.dev/de/docs/configuration-programmatic-usage.md#eine-seite-als-bitmap-rendern)

**Schriften**

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

## Zubereitung

### 1 · Ein Akt ist eine schlichte Überschrift

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

Der Akttitel ist eine Überschrift der Ebene 1 in der Abril Fatface in 24 pt, ohne Gestaltungsfeld. `textTransform` setzt ihn in Versalien, und seine drei Rasterzeilen zu 13,6 pt stellen die Versalien 5,5 mm unter die Oberkante des Satzspiegels ([Überschriften](https://postext.dev/de/docs/configuration-text.md#überschriften)). `textAlign: 'center'` lässt sich nicht pro Ebene setzen, deshalb zentriert es auch die Überschriften des Personenverzeichnisses und die Bezeichnung *Scene*. `breakBefore` mit der Parität `'odd'` beginnt den Akt auf einer rechten Seite, gegenüber dem Personenverzeichnis. Der Standardwert `'always-odd'` würde zwei leere Seiten zwischen sie setzen ([Umbruch davor](https://postext.dev/de/docs/configuration-text.md#umbruch-davor)).

### 2 · Das Personenverzeichnis ist eine Punktlinie zu jedem Darsteller

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

Jede Person ist ein Absatz im Stil `person`, der Darsteller nach `:tab` ([Tabulatoren und Tabstopps](https://postext.dev/de/docs/document-format.md#tabulatoren-und-tabstopps)). Der Stil hat einen Tabstopp bei `'end'`, dem Ende der Satzbreite, und mit `align: 'end'` endet der Name des Darstellers dort. Sein `leader: '.'` füllt den Raum vor dem Tabstopp mit Punkten, ein halbes Geviert von Person und Darsteller entfernt, und `spaceBetween` setzt eine Person auf jede anderthalbte Zeile. Die Namen der Personen sind fett und erscheinen deshalb in Pflaumenrot, wie die Namen der Sprecher im Dialog. Originalbesetzung, Theater und Datum stehen im Stil `bill-head` über der Liste, zentriert in der Playfair Display SC, deren Kleinbuchstaben Kapitälchen sind. Die Schauplätze nehmen zwei Tabstopps: `:tab Act I :tab …` lässt *Act I* am ersten enden, 15 mm eingerückt, mit `align: 'end'`, sodass die drei Akte rechtsbündig auf einer Spalte stehen, und beginnt den Ort am zweiten, 2 mm danach. Die Zeit der Handlung ist eine zentrierte Zeile im Stil der Regieanweisungen.

### 3 · Ornamente ohne Nummer und ohne Bildunterschrift

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

Ein Ressourcentyp mit leerem `captionPrefix` und leerem `shortLabel` druckt seine Bilder ohne alles ([Ressourcentypen](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen)). Sein `defaultPlacement` setzt die Nelke dorthin, wo `::resource{id="fleuron"}` steht, zentriert und 0,24 der 107 mm breiten Satzbreite breit, etwa 26 mm.

### 4 · Die Titelseite ist ein Überschriftenstil

```js
// script.js, Zeilen 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" …}` trägt den Titel in vier Attributen, die die Gestaltung in zwei Schriften setzt: Abril Fatface in 40 und 60 pt für die Substantive, Libre Baskerville kursiv in 17 pt für *The* und *of Being* ([Attribute von Überschriften](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)). Der pflaumenrote Grund, der Rahmen und die Nelke sind ein einziges, in Code gezeichnetes SVG, am Anschnitt verankert ([Bildelemente](https://postext.dev/de/docs/configuration-page-layout.md#bildelemente)). Der Stil setzt `span: 'page'`, obwohl das Buch einspaltig ist. In der Spalte gehalten, würde die Gestaltung am Fuß des Satzspiegels abgeschnitten, 23,3 mm über dem Fuß des Blatts. Der leere `header` des Stils hält die Kolumnentitel vom Personenverzeichnis fern, das zum selben Abschnitt gehört, und sein leerer `footer` hält die Seitenzahl am Fuß von der Titelseite fern, wo sie pflaumenrot auf Pflaumenrot stünde ([Überschriftenstile](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)).

Der Grund ist ein Bildelement, und ein Auftakt reserviert Platz bis zum Fuß seiner Bilder, sodass die Überschrift das ganze Blatt einnimmt: *The Persons of the Play* beginnt auf Seite 2 ohne `:::pagebreak` nach dem Titel, wo auch immer die Zeilen der Titelseite stehen. Auch eine zentrierte Zeile mit `letterSpacing` braucht keine Korrektur. Die Sperrung nach ihrem letzten Buchstaben bleibt beim Zentrieren außen vor, und *OSCAR WILDE* steht auf der Mitte der Seite.

### 5 · Kolumnentitel, die die erste Seite des Akts auslassen

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

Die linke Seite trägt den Titel des Stücks aus dem Frontmatter, die rechte den Akt aus `{chapterTitle}`, beide in Versalien der Playfair Display SC, mit der Seitenzahl in Pflaumenrot am Außenrand ([Textelemente](https://postext.dev/de/docs/configuration-page-layout.md#textelemente)). Die erste Seite des Akts gilt als Auftaktseite, weil ihr erster Block eine Überschrift ist, die die Seite umbricht; deshalb hält `pages: 'body'` die Kolumnentitel von ihr fern, und die Fußzeile gibt ihr stattdessen eine Seitenzahl am Fuß.

> Ein Drehbuch zieht seinen Dialog von beiden Seiten ein, statt ihn hängen zu lassen: [Drehbuchformat](https://postext.dev/de/cookbook/screenplay-format.md) setzt jede Replik als rahmenlosen Kasten, dessen Innenabstand ihn auf die Breite des Dialogblocks bringt.

## Das ganze Rezept

Eine Datei, aus dem Ordner des Rezepts zusammengesetzt, mit dem Beispieltext und dem gemeinsamen Baukasten der Rezepte bereits eingebettet; sie baut ihre eigene Seite auf. Zum Ausführen setzen Sie sie in ein `<script type="module">` auf einer leeren Seite oder fügen sie in das JS-Feld eines neuen CodePen-Pens ein (als Modul). Sie importiert postext von esm.sh, es gibt also nichts zu installieren oder zu bauen.

- Quellordner: 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 ───────────────────────────────────────────────────────────────────────
```

## Varianten

### Die Regieanweisungen an den Folgezeilen ausrichten

Linksbündig mit 5 mm Erstzeileneinzug beginnt eine einzeilige Regieanweisung dort, wo die Folgezeilen der Repliken beginnen, und die Szenerie wird zu zwei eingezogenen Absätzen unter ihrer zentrierten Bezeichnung.

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

### Eine Zeile zwischen den Repliken lassen

`paragraphSpacing: true` fügt nach jeder Replik eine leere Rasterzeile ein. Der englische Akt läuft dann auf fünf statt drei Seiten, der spanische auf vier.

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

## Häufige Fehler

- **::resource{id="…"} nimmt nur doppelte Anführungszeichen.** Eine Block-Einbettung wird nur als ::resource{id="…"} mit doppelten Anführungszeichen erkannt; jede andere Form bleibt als sichtbare Zeile im Text.

- Jeder kursive Lauf im Dialog nimmt `italicColor` an, Hervorhebungen eingeschlossen, sodass Lanes betontes *is* im Grau der Regieanweisungen erscheint. Das Grau sitzt an `bodyText`, weil die meisten Regieanweisungen innerhalb einer Replik stehen, im selben Absatz wie die Hervorhebung; nur aufrechte Schrift hielte das *is* in Textfarbe.
- Drei von Wildes Repliken enden mit einem einzelnen Wort: *information.*, *you.* und *that?* Die Korrektur zu kurzer Ausgangszeilen kann das dritte in die Zeile davor ziehen, aber nur mit 1 % negativer Laufweite, so viel, wie `bodyText.maxRuntTracking` standardmäßig erlaubt, und die Replik wirkt dann dunkler als die umgebenden. `maxRuntTracking: 5` halbiert diese Grenze: *that?* bricht um, und die Zeile darüber öffnet sich auf das 1,78-Fache des normalen Wortabstands, innerhalb der Grenze von 1,8. Die englischen Seiten behalten die drei kurzen Ausgangszeilen, statt Wildes Worte zu ändern. Die spanische Übersetzung wurde überall umformuliert, wo eine Zeile auf einem Wort aus einem Buchstaben endete oder eine Silbe allein stand.
- Namen werden getrennt wie jedes andere Wort: *Au-gusta* und *Gwen-dolen* brechen auf Seite 5 über Zeilen um. Ein Wortverbinder (U+2060) in *Augusta* hält es ganz, dehnt aber die Zeile darüber auf das 1,71-Fache des normalen Wortabstands, deshalb behält der englische Text die Trennstriche.
- Playfair Display SC zeichnet *fi* auf dem Canvas als Kleinbuchstaben-Ligatur, sodass *first* ein kleines *fi* zwischen Kapitälchen drucken würde. Deshalb lautet der Kopf des Personenverzeichnisses *The original cast*.

## Nachweise

- Rezept: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: 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 ([Quelle](https://www.gutenberg.org/ebooks/844)), gemeinfrei
- Text: The Spanish translation of the extract, the cast list and the scenes: Postext Cookbook, Original
- Bilder: The title page’s frame and the carnation fleuron, drawn in code in the page’s palette: Ignacio Ferro, MIT
- Schrift: Libre Baskerville (OFL-1.1), Abril Fatface (OFL-1.1), Playfair Display SC (OFL-1.1)
- Code: MIT · Beispielinhalt: MIT

## Verwandte Rezepte

- [Nº 061 · Drehbuchformat](https://postext.dev/de/cookbook/screenplay-format.md): Ein Drehbuch in Courier Prime 12 auf US Letter, jede Replik ein rahmenloser Kasten, eingerückt auf den Dialogblock, jede Szene in beiden Rändern nummeriert. · Stufe 2 (Mittel) · Jedes Genre
- [Nº 015 · Gedichte, Zeile für Zeile gesetzt](https://postext.dev/de/cookbook/poetry-collection.md): Jedes Gedicht ist ein Block :::verse, geschrieben wie gedruckt: Die Einzüge von 1918 sind Leerzeichen, Folgezeilen hängen 2 em ein, Leerzeilen trennen Strophen. · Stufe 2 (Mittel) · Lyrik
- [Nº 010 · Datenblatt: Tabellen aus Daten, verbundene Köpfe](https://postext.dev/de/cookbook/technical-datasheet.md): Als TSV eingefügte Tabellen, mit parseTSV gelesen und mit mergeCells, setAlignment und setCellBackground geformt; eine Registertabelle, die selbst umbricht. · Stufe 3 (Fortgeschritten) · Handbücher und Nachschlagewerke
