# Obra de teatre: repartiment, personatges i acotacions

> L'inici de l'obra de Wilde per a actors. Els noms van en negreta pruna i les acotacions en cursiva grisa; el repartiment surt d'un text tabulat.

- Versió HTML: https://postext.dev/ca/cookbook/stage-play
- Recepta Núm. 070 · Publicacions completes · Nivell 2 (Intermedi) · Sortides: Canvas
- Gèneres: Narrativa, teatre i prosa literària
- Requereix postext ≥ 1.4.1 · provada amb 1.4.1 el 2026-09-26
- Pàgines: [1](https://postext.dev/cookbook/stage-play/es/p01.webp?v=ff2781e4), [2](https://postext.dev/cookbook/stage-play/es/p02.webp?v=ff2781e4), [3](https://postext.dev/cookbook/stage-play/es/p03.webp?v=ff2781e4), [4](https://postext.dev/cookbook/stage-play/es/p04.webp?v=ff2781e4), [5](https://postext.dev/cookbook/stage-play/es/p05.webp?v=ff2781e4)
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=stage-play&lang=es (.postext: https://postext.dev/cookbook/stage-play/es/stage-play.postext)
- Última actualització: 2026-09-26
- Altres idiomes: [en](https://postext.dev/en/cookbook/stage-play.md), [es](https://postext.dev/es/cookbook/stage-play.md), [zh](https://postext.dev/zh/cookbook/stage-play.md), [ar](https://postext.dev/ar/cookbook/stage-play.md)

## En poques paraules

El començament d'una comèdia famosa d'Oscar Wilde, impresa per als actors. El nom de qui parla destaca en negreta de color, i les acotacions van en gris per saltar-les amb facilitat.

## Què compondràs

El començament de *La importància de dir-se Ernest*, d'Oscar Wilde, en una edició per a actors de cinc pàgines de 140 × 216 mm. La portada és un cartell color pruna dins d'un doble marc daurat, amb *Importancia* i *Ernesto* en versals gruixudes de cartell i un clavell daurat sota el títol. Davant del primer acte, el repartiment anomena l'actor que va estrenar cada paper al teatre St James’s el 1895. Cada parlament comença amb el nom del personatge en negreta pruna i sagna 5 mm les línies següents, de manera que l'actor veu de seguida quan li toca parlar. Les acotacions van en cursiva grisa, tant si són dins d'un parlament com entre dos, i en repassar el paper la vista se les salta. L'edició espanyola és una traducció nova que segueix el costum del teatre en espanyol: guió llarg després del nom i acotacions entre parèntesis.

**Aquesta recepta respon a:**

- Com componc una obra de teatre: personatges, acotacions, títols d'acte i escena, i el repartiment?
- Com dono color als termes clau (en negreta o cursiva) en el text o dins dels requadres?
- Com faig una taula amb files de capçalera, cel·les combinades, amplades de columna i alineació per cel·la?
- Com componc il·lustracions sense numerar: ornaments, vinyetes, logotips?

## La resposta curta

```js
// script.js, línies 34–52
// 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
  firstLineIndent: mm(5), hangingIndent: true, // the name at the margin, the turnovers hung
  maxRuntTracking: 0, // gotcha: runt-tracking-unpainted
};
// Directions: smaller and centred, on the same grid. A paragraph style has no italic switch
// or colour (gotcha: style-italic-colour): the text is written *…* and takes the grey.
const direction = { id: 'direction', fontSize: pt(8.6),
  textAlign: 'center', firstLineIndent: pt(0) };
```

## Ingredients

**Ensenya**

- [Estils de paràgraf](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf): Estils amb nom per a grups de paràgrafs que s'aparten del text general: versos, epígrafs, dedicatòries, signatures, lletra petita.
- [Negreta, cursiva i els seus colors](https://postext.dev/ca/docs/configuration.md#text-de-cos): Els fragments en negreta i cursiva i el color amb què s'imprimeixen; per defecte prenen el blau d'accent, així que en un llibre a una tinta cal tornar-los al color del text.
- [Taules a partir de dades](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita): Taules com a recursos amb files de capçalera, cel·les combinades, proporcions de columna, alineació per cel·la i llistes dins de les cel·les; les taules amb barres no s'interpreten.

**També fa servir**

- [Sagnats, alineació i separació de paràgrafs](https://postext.dev/ca/docs/configuration.md#text-de-cos)
- [Nivells de títol](https://postext.dev/ca/docs/configuration.md#configuració-per-nivell)
- [Capítols que obren en pàgina senar](https://postext.dev/ca/docs/configuration.md#saltar-abans)
- [Estil de taules](https://postext.dev/ca/docs/configuration.md#estil-de-taules)
- [Estils de taula amb nom](https://postext.dev/ca/docs/configuration.md#estils-de-taula-amb-nom)
- [Tipus de recurs propis](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs)
- [Figures just aquí](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita)
- [Estils de títol](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Cobertes, portades i colofons](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Imatges als dissenys de pàgina](https://postext.dev/ca/docs/configuration.md#elements-dimatge)
- [Textos, filets i caixes als dissenys de pàgina](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Capçaleres segons el tipus de pàgina](https://postext.dev/ca/docs/configuration.md#elements-de-text)
- [Metadades del document](https://postext.dev/ca/docs/document-format.md#frontmatter)
- [Color del paper](https://postext.dev/ca/docs/configuration.md#pàgina)
- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration.md#paleta-de-colors)
- [Obertures dissenyades](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Salts de pàgina i de columna](https://postext.dev/ca/docs/document-format.md#pagebreak)
- [Figures i taules com a recursos](https://postext.dev/ca/docs/document-format.md#recursos)
- [Capçaleres per secció](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Espai vertical explícit](https://postext.dev/ca/docs/document-format.md#space)

**La configuració d'un cop d'ull**

- [`bodyText`](https://postext.dev/ca/docs/configuration.md#text-de-cos), [`colorPalette`](https://postext.dev/ca/docs/configuration.md#paleta-de-colors), [`footer`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`header`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`headingStyles`](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament), [`headings`](https://postext.dev/ca/docs/configuration.md#encapçalaments), [`layout`](https://postext.dev/ca/docs/configuration.md#disposició), [`locale`](https://postext.dev/ca/docs/configuration.md#partició-de-mots), [`page`](https://postext.dev/ca/docs/configuration.md#pàgina), [`paragraphStyles`](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf), [`resourceTypes`](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs), [`tableStyle`](https://postext.dev/ca/docs/configuration.md#estil-de-taules), [`tableStyles`](https://postext.dev/ca/docs/configuration.md#estils-de-taula-amb-nom)

**API**

- [`buildDocument`](https://postext.dev/ca/docs/configuration.md#construir-un-document), [`clearMeasurementCache`](https://postext.dev/ca/docs/configuration.md#memòria-cau-de-mesures), [`mergeCells`](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita), [`parseTSV`](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita), [`registerResourceImage`](https://postext.dev/ca/docs/architecture.md#superfície-dapi), [`renderPageToCanvas`](https://postext.dev/ca/docs/configuration.md#renderitzar-una-pàgina-a-un-bitmap)

**Tipus de lletra**

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

## Elaboració

### 1 · Un acte és un títol i prou

```js
// script.js, línies 56–68
// Any headings object drops the H1 break: restated (gotcha: headings-drop-h1-break).
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') },
  ],
};
```

L'acte s'obre amb un títol de primer nivell en Abril Fatface de 24 pt, sense ranura de disseny. `textTransform` el passa a versals, i les tres línies de 13,6 pt de la retícula que ocupa deixen aquestes versals 5,5 mm per sota de la vora superior de la caixa ([encapçalaments](/ca/docs/configuration#encapçalaments)). `textAlign: 'center'` no admet un valor per nivell, així que també centra els títols del repartiment i el rètol *Escena*. `breakBefore` amb paritat `'odd'` porta l'acte a una pàgina senar, davant del repartiment. La configuració ho ha de repetir, perquè qualsevol objecte `headings` desactiva el salt de pàgina del primer nivell ([saltar abans](/ca/docs/configuration#saltar-abans)).

### 2 · El repartiment és una taula llegida de línies amb tabuladors

```js
// script.js, línies 72–92
// One row per line, cells split by tabs. A line with no tab spans the table: mergeCells
// joins its cells (gotcha: merged-cells-hiddenby). Each column has its own alignment.
function billTable(tsv, widths, align, headerRowCount = 0) {
  let model = { ...parseTSV(tsv.trim()), columnWidths: widths, headerRowCount };
  model.rows.forEach((row) => row.forEach((cell, c) => { cell.align = align[c]; }));
  tsv.trim().split('\n').forEach((line, r) => {
    if (line.includes('\t')) return;
    model.rows[r][0].align = 'center';
    const end = { row: r, col: widths.length - 1 };
    model = mergeCells(model, { start: { row: r, col: 0 }, end });
  });
  return model;
}
// One filled header cell: two side by side would show a seam (gotcha: table-fill-seams).
const tableStyle = {
  rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
  headerBackground: col('plum'), headerColor: col('paper'), headerFontFamily: LABEL,
  headerFontSize: pt(8.5), headerBold: false, // small capitals from the face itself
  bodyFontSize: pt(8.8), cellPadding: mm(1.5), // the body's face and ink, a size smaller
};
const tableStyles = [{ id: 'scenes', rules: 'none', cellPadding: mm(1) }]; // the scenes
```

Postext no té taules amb barres, així que cada taula és un recurs. Les files són a `content.cast.es.md`, una per línia i amb un tabulador entre cel·la i cel·la, i `parseTSV` les converteix en un model ([construir models de taula](/ca/docs/configuration#construir-models-de-taula)). L'única línia sense tabulador, la del repartiment de l'estrena amb el teatre i la data, passa a ser una fila combinada: `mergeCells` dona a la primera cel·la `colSpan: 2` i deixa la segona a la quadrícula, marcada amb `hiddenBy`. `headerRowCount: 1` posa aquesta fila a la banda pruna. Cap altra fila no porta emplenament, perquè el llenç deixa una línia clara entre dues cel·les emplenades contigües. La columna dels actors ocupa un terç de l'amplada, i cadascuna de les seves cel·les va alineada a la dreta. Els llocs de l'acció passen per la mateixa funció amb un estil de taula propi, sense filets ([estils de taula amb nom](/ca/docs/configuration#estils-de-taula-amb-nom)). Una taula compon tot el text de les cel·les en un sol color, la tinta del text tret que `bodyColor` n'indiqui un altre, així que aquí la negreta dels personatges surt en tinta i, al diàleg, en pruna.

### 3 · Ornaments sense número ni peu

```js
// script.js, línies 96–102
// ::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' }),
  unnumbered('bill', 'Bill', { position: 'here' }),
];
```

Un tipus de recurs amb `captionPrefix` i `shortLabel` buits imprimeix les il·lustracions soles, sense peu ([tipus de recurs](/ca/docs/configuration#tipus-de-recurs)). El seu `defaultPlacement` col·loca el clavell on hi ha `::resource{id="fleuron"}`, centrat i amb una amplada de 0,24 de la mesura de 107 mm, uns 26 mm. Les dues taules fan servir un segon tipus, també sense número, així que cap no porta el rètol *Tabla 1*.

### 4 · La portada és un estil de títol

```js
// script.js, línies 106–132
// # The Importance of Being Earnest {style="playbill" small="The" big="Importance" …}
// Each attribute is a playbill line in its own face and size, its lineHeight a multiple
// (gotcha: design-lineheight-multiple). The ground is an image and reserves no height
// (gotcha: opener-image-no-reserve): :::pagebreak keeps the title page alone if its foot
// lines move up.
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',
  // Centred and tracked, a line sits half its tracking left of centre: x puts it back.
  placement: { anchor: { to: 'page', edge: 'top' },
    offset: { x: pt((extra.letterSpacing?.value ?? 0) / 2), y: mm(y) } }, ...extra });
const tracked = (track) => ({ textTransform: 'uppercase', letterSpacing: pt(track) });
const playbill = {
  id: 'playbill', span: 'page', // even in one column (gotcha: opener-clipped-at-top)
  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 }),
  ] } },
};
```

`# La importancia de llamarse Ernesto {style="playbill" …}` porta el títol en quatre atributs que el disseny compon en dues famílies: Abril Fatface de 40 i 60 pt per als substantius i Libre Baskerville cursiva de 17 pt per a *La* i *de llamarse* ([atributs d'encapçalament](/ca/docs/document-format#atributs-dencapçalament)). El fons pruna, el marc i el clavell són un sol SVG dibuixat en codi i ancorat a la sang ([elements d'imatge](/ca/docs/configuration#elements-dimatge)). L'estil porta `span: 'page'` encara que el llibre tingui una sola columna. Si el disseny es quedés a la columna, es tallaria per dalt i per baix per les vores de la caixa de text, a 20 mm de la vora superior del full i a 23,3 mm de la inferior. El `header` buit de l'estil treu les capçaleres del repartiment, que pertany a la mateixa secció, i el seu `footer` buit treu de la portada el foli al peu, que sortiria en pruna sobre pruna ([estils d'encapçalament](/ca/docs/configuration#estils-dencapçalament)).

Les imatges d'una obertura no reserven alçada, així que el títol ocupa la pàgina només fins a la seva línia de text més baixa, la de l'estrena, a 176 mm. El buit que queda a sota no basta per al títol següent, i sense el `:::pagebreak` que segueix la portada les pàgines capturades surten iguals. El salt hi és per si es retoca la portada: si el treus i puges el teatre i l'estrena a 150 i 156 mm, *Personajes de la obra* comença sobre el full pruna. A la 1.4.1, una línia centrada amb `letterSpacing` queda desplaçada a l'esquerra la meitat d'aquest espaiat, i `line()` desplaça cada línia espaiada el mateix cap a la dreta. Així, *OSCAR WILDE* queda a 0,07 mm del centre en lloc de 0,56.

### 5 · Capçaleres que se salten la primera pàgina de l'acte

```js
// script.js, línies 136–152
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) } } }] };
```

La pàgina parella porta el títol de l'obra, pres del frontmatter, i la senar el de l'acte, de `{chapterTitle}`; tots dos van en versals de Playfair Display SC, amb el foli en pruna a la vora exterior ([elements de text](/ca/docs/configuration#elements-de-text)). La primera pàgina de l'acte compta com a obertura perquè el seu primer bloc és un títol que salta de pàgina, així que `pages: 'body'` li treu les capçaleres, i el peu de pàgina hi posa en el seu lloc un foli centrat.

> Un guió de cinema sagna el diàleg pels dos costats en lloc de fer servir sagnat francès: [Format de guió cinematogràfic](https://postext.dev/ca/cookbook/screenplay-format.md) compon cada rèplica com un requadre sense marc de l'amplada del bloc de diàleg.

## La recepta completa

Un sol fitxer, compost a partir de la carpeta de la recepta amb el text d'exemple i el kit comú del Receptari ja inclosos; construeix la seva pròpia pàgina. Per executar-lo, posa'l en un `<script type="module">` d'una pàgina buida o enganxa'l al tauler JS d'un pen nou de CodePen (com a mòdul). Importa postext des d'esm.sh, així que no cal instal·lar ni compilar res.

- Carpeta de la recepta: 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.4.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  parseTSV, mergeCells } from 'https://esm.sh/postext';

const LANG = 'es'; // @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)
  rule: '#d8cbb7', // hairlines between the persons of the cast list
  paper: '#fbf6ec', // a cream stock
};
// The hex travels with the id: designs do not read the palette (gotcha: palette-skips-designs).
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
  firstLineIndent: mm(5), hangingIndent: true, // the name at the margin, the turnovers hung
  maxRuntTracking: 0, // gotcha: runt-tracking-unpainted
};
// Directions: smaller and centred, on the same grid. A paragraph style has no italic switch
// or colour (gotcha: style-italic-colour): the text is written *…* and takes the 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
// Any headings object drops the H1 break: restated (gotcha: headings-drop-h1-break).
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: a table from tab-separated lines, with merged rows
// One row per line, cells split by tabs. A line with no tab spans the table: mergeCells
// joins its cells (gotcha: merged-cells-hiddenby). Each column has its own alignment.
function billTable(tsv, widths, align, headerRowCount = 0) {
  let model = { ...parseTSV(tsv.trim()), columnWidths: widths, headerRowCount };
  model.rows.forEach((row) => row.forEach((cell, c) => { cell.align = align[c]; }));
  tsv.trim().split('\n').forEach((line, r) => {
    if (line.includes('\t')) return;
    model.rows[r][0].align = 'center';
    const end = { row: r, col: widths.length - 1 };
    model = mergeCells(model, { start: { row: r, col: 0 }, end });
  });
  return model;
}
// One filled header cell: two side by side would show a seam (gotcha: table-fill-seams).
const tableStyle = {
  rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
  headerBackground: col('plum'), headerColor: col('paper'), headerFontFamily: LABEL,
  headerFontSize: pt(8.5), headerBold: false, // small capitals from the face itself
  bodyFontSize: pt(8.8), cellPadding: mm(1.5), // the body's face and ink, a size smaller
};
const tableStyles = [{ id: 'scenes', rules: 'none', cellPadding: mm(1) }]; // the scenes
// #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' }),
  unnumbered('bill', 'Bill', { position: 'here' }),
];
// #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, its lineHeight a multiple
// (gotcha: design-lineheight-multiple). The ground is an image and reserves no height
// (gotcha: opener-image-no-reserve): :::pagebreak keeps the title page alone if its foot
// lines move up.
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',
  // Centred and tracked, a line sits half its tracking left of centre: x puts it back.
  placement: { anchor: { to: 'page', edge: 'top' },
    offset: { x: pt((extra.letterSpacing?.value ?? 0) / 2), y: mm(y) } }, ...extra });
const tracked = (track) => ({ textTransform: 'uppercase', letterSpacing: pt(track) });
const playbill = {
  id: 'playbill', span: 'page', // even in one column (gotcha: opener-clipped-at-top)
  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 = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-us', es: 'es' }), // hyphenation by exact code (gotcha: hyphenation-locales)
  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, { id: 'colophon', fontSize: pt(7.5), color: col('muted'),
    textAlign: 'center', firstLineIndent: pt(0), marginTop: pt(LEAD) }],
  tableStyle,
  tableStyles,
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "La importancia de llamarse Ernesto"
subtitle: "Comedia trivial para gente seria"
author: "Oscar Wilde"
---

# La importancia de llamarse Ernesto {style="playbill" small="La" big="Importancia" link="de llamarse" name="Ernesto" theatre="Teatro St James’s · Londres" premiere="Estrenada el 14 de febrero de 1895"}

:::pagebreak

## Personajes de la obra

::resource{id="persons"}

## Lugares de la acción

::resource{id="scenes"}

:::paragraphs{style="colophon"}
Texto inglés: Proyecto Gutenberg, libro electrónico n.º 844, de dominio público; traducción de esta edición. Compuesto en Libre Baskerville, Abril Fatface y Playfair Display SC (SIL Open Font License).
:::

# Primer acto

::resource{id="fleuron"}

### Escena

:::paragraphs{style="direction"}
*Saloncito en el piso de Algernon, en Half-Moon Street. El mobiliario es lujoso y artístico. En la habitación contigua suena un piano.*

*(Lane está preparando el té de la tarde en la mesa y, cuando cesa la música, entra Algernon.)*
:::

:::space

**ALGERNON.**—¿Ha oído lo que estaba tocando, Lane?

**LANE.**—No me pareció correcto escuchar, señor.

**ALGERNON.**—Lo lamento por usted. Yo no toco con exactitud —cualquiera puede tocar con exactitud—, pero toco con una expresión maravillosa. En lo que al piano se refiere, mi fuerte es el sentimiento. La ciencia la reservo para la Vida.

**LANE.**—Sí, señor.

**ALGERNON.**—Y, hablando de la ciencia de la Vida, ¿ha cortado los emparedados de pepino para lady Bracknell?

**LANE.**—Sí, señor. *(Se los ofrece en una bandeja.)*

**ALGERNON.**—*(Los examina, toma dos y se sienta en el sofá.)* ¡Ah!… Por cierto, Lane: veo en su libro que el jueves por la noche, cuando lord Shoreman y el señor Worthing vinieron a cenar, se anotaron como consumidas ocho botellas de champán.

**LANE.**—Sí, señor; ocho botellas y una pinta.

**ALGERNON.**—¿Por qué en casa de un soltero los criados se beben siempre el champán? Lo pregunto solo por saberlo.

**LANE.**—Lo atribuyo a la calidad superior del vino, señor. He notado a menudo que en las casas de los casados el champán rara vez es de primera.

**ALGERNON.**—¡Santo cielo! ¿Tanto desmoraliza el matrimonio?

**LANE.**—Yo creo que *es* un estado muy agradable, señor. Mi experiencia en ese terreno es, hasta ahora, muy escasa. Solo me he casado una vez. Fue a consecuencia de un malentendido entre una joven y yo.

**ALGERNON.**—*(Lánguidamente.)* No sé si me interesa mucho su vida familiar, Lane.

**LANE.**—No, señor; no es un tema muy interesante. Yo mismo nunca pienso en él.

**ALGERNON.**—Muy natural. Puede retirarse, Lane, gracias.

**LANE.**—Gracias, señor. *(Sale Lane.)*

**ALGERNON.**—Las ideas de Lane sobre el matrimonio parecen algo relajadas. La verdad, si las clases bajas no nos dan buen ejemplo, ¿para qué demonios sirven? Como clase, parecen carecer de todo sentido de la responsabilidad moral.

:::paragraphs{style="direction"}
*(Entra Lane.)*
:::

**LANE.**—El señor Ernesto Worthing.

:::paragraphs{style="direction"}
*(Entra Jack.)*

*(Sale Lane.)*
:::

**ALGERNON.**—¿Qué tal, querido Ernesto? ¿Qué te trae por aquí?

**JACK.**—¡Oh, el placer, el placer! ¿Hay otra razón para ir de un sitio a otro? ¡Comiendo, como siempre, Algy!

**ALGERNON.**—*(Muy digno.)* Creo que en la buena sociedad se acostumbra tomar un ligero refrigerio a las cinco. ¿Dónde has estado desde el jueves pasado?

**JACK.**—*(Sentándose en el sofá.)* En el campo.

**ALGERNON.**—¿Y qué demonios haces allí?

**JACK.**—*(Quitándose los guantes.)* Cuando uno está en la ciudad, se divierte. Cuando está en el campo, divierte a los demás. Es de lo más aburrido.

**ALGERNON.**—¿Y quiénes son esas personas a las que diviertes?

**JACK.**—*(Con ligereza.)* ¡Oh!, los vecinos, los vecinos.

**ALGERNON.**—¿Tienes vecinos simpáticos en Shropshire?

**JACK.**—¡Espantosos! No hablo con ninguno.

**ALGERNON.**—¡Cuánto debes de divertirlos! *(Se acerca y toma un emparedado.)* Por cierto, Shropshire es tu condado, ¿no?

**JACK.**—¿Eh? ¿Shropshire? Sí, claro. ¡Vaya! ¿Por qué tantas tazas? ¿Por qué emparedados de pepino? ¿Por qué semejante derroche en alguien tan joven? ¿Quién viene a tomar el té?

**ALGERNON.**—¡Oh!, solo tía Augusta y Gwendolen.

**JACK.**—¡Qué delicia!

**ALGERNON.**—Sí, todo eso está muy bien, pero me temo que tía Augusta no verá con buenos ojos que estés aquí.

**JACK.**—¿Puedo preguntar por qué?

**ALGERNON.**—Querido amigo, tu coqueteo con Gwendolen es una verdadera vergüenza. Casi tanto como el de ella contigo.

**JACK.**—Estoy enamorado de Gwendolen. He venido a la ciudad expresamente para declararme.

**ALGERNON.**—¿No venías por placer?… Eso son negocios.

**JACK.**—¡Qué poco romántico eres!

**ALGERNON.**—Pues yo no veo qué tiene de romántico declararse. Estar enamorado es muy romántico; una declaración en toda regla, en cambio, no tiene nada de romántico. Porque, vamos, a uno pueden decirle que sí. Suelen decírselo, creo. Y entonces se acabó la emoción. La esencia misma del romance es la incertidumbre. Si alguna vez me caso, haré todo lo posible por olvidarlo.

**JACK.**—No lo dudo, querido Algy. El Tribunal de Divorcios se inventó expresamente para las personas de memoria tan curiosamente constituida.

**ALGERNON.**—¡Oh!, no vale la pena especular sobre ese asunto. Divorcio y mortaja, del cielo baja… *(Jack alarga la mano para tomar un emparedado. Algernon se lo impide al instante.)* Haz el favor de no tocar los emparedados de pepino. Los han encargado especialmente para tía Augusta. *(Toma uno y se lo come.)*
`; // content.<lang>.md, inlined by the Cookbook
const cast = String.raw`Reparto del estreno · Teatro St James’s, 14 de febrero de 1895
**John Worthing**, juez de paz	*Mr. George Alexander*
**Algernon Moncrieff**	*Mr. Allen Aynesworth*
**Canónigo Chasuble**, doctor en Teología	*Mr. H. H. Vincent*
**Merriman**, mayordomo	*Mr. Frank Dyall*
**Lane**, criado	*Mr. F. Kinsey Peile*
**Lady Bracknell**	*Miss Rose Leclercq*
**La honorable Gwendolen Fairfax**	*Miss Irene Vanbrugh*
**Cecily Cardew**	*Miss Evelyn Millard*
**Miss Prism**, institutriz	*Mrs. George Canninge*
`; // content.cast.<lang>.md: the persons, tab-separated
const scenes = String.raw`Acto I	Piso de Algernon Moncrieff en Half-Moon Street, W.
Acto II	Jardín de la casa solariega de Woolton.
Acto III	Salón de la casa solariega de Woolton.
*Época: la actual.*
`; // content.scenes.<lang>.md: the acts and their places

// #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 table = (id, model, styleId) => ({ id, typeId: 'bill', kind: 'table',
  table: { model, styleId }, createdAt: 0, updatedAt: 0 });
const resources = [...art,
  table('persons', billTable(cast, [2, 1], ['left', 'right'], 1)), // the first line heads it
  table('scenes', billTable(scenes, [3, 17], ['right', 'left']), 'scenes'),
];

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

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown + cast + scenes);
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
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 v1 ── the same in every recipe · postext.dev/cookbook ────────
// Postext measures text with the faces the browser has loaded, and caches the
// widths, so every face must be ready before the first build. Faces come from
// Fontsource: the same static files the PDF embeds, so screen and PDF agree.

/** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = {
    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',
  };
  const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin'];
  const jobs = [];
  let added = 0;
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const meta = optional ? await fontsourceMeta(family) : null;
    for (const spec of new Set(specs)) {
      const weight = parseInt(spec, 10);
      const style = spec.endsWith('i') ? 'italic' : 'normal';
      if (hasFace(family, weight, style)) continue;
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/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().then((ready) => { document.fonts.add(ready); added++; }, () => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return added;
}

/** Runs `build` (a buildDocument or buildBundle call) and checks the faces
 *  the pages use. A regular face missing from FONTS is loaded with a warning;
 *  bold and italic variants are loaded when the family ships them. Then the
 *  measurement caches are cleared and the build runs again. */
async function buildWithFonts(build, text = '') {
  const tried = new Set();
  for (let round = 0; round < 3; round++) {
    kitStatus('Laying out…');
    await new Promise(requestAnimationFrame);          // let the status paint first
    const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; });
    const wanted = { base: {}, variants: {} };
    for (const { font, base } of [result].flat().flatMap(fontStringsOf)) {
      const { family, weight, style } = parseFont(font);
      const key = `${family}|${weight}|${style}`;
      if (tried.has(key) || hasFace(family, weight, style)) continue;
      tried.add(key);
      (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`);
    }
    if (Object.keys(wanted.base).length) {
      console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`);
    }
    const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true });
    if (added === 0) return result;
    clearMeasurementCache();
  }
  throw new Error('The fonts did not settle after three builds.');
}

/** Every font string of the layout. `base` marks a block's own face; its
 *  bold, italic and bold-italic variants are listed whether or not used. */
function fontStringsOf(doc) {
  const found = new Map();
  const walk = (node) => {
    if (!node || typeof node !== 'object') return;
    if (Array.isArray(node)) { node.forEach(walk); return; }
    for (const [key, value] of Object.entries(node)) {
      if (typeof value === 'string' && /fontString$/i.test(key)) {
        found.set(value, found.get(value) || key === 'fontString');
      } else if (value && typeof value === 'object') walk(value);
    }
  };
  walk(doc.pages);
  walk(doc.blocks);
  return [...found].map(([font, base]) => ({ font, base }));
}

/** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }.
 *  A string with no weight ('95.8px Young Serif', from a design text) is 400. */
function parseFont(font) {
  const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim());
  if (!m) throw new Error(`Unexpected font string: ${font}`);
  const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]);
  return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' };
}

/** True when a loaded FontFace covers exactly this family, weight and style
 *  (document.fonts.check() is also true 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;
}

/** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */
function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); }

/** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), 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 v1 ── the same in every recipe · postext.dev/cookbook ───────
/** Shows the pages as facing spreads on a dark desk: the first page is a
 *  recto on its own, then verso | recto pairs, as in a bound book. Pages
 *  are painted when they scroll near the screen. */
function showPages(docs, { title, width = 460 } = {}) {
  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, { 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 v1 ── 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 (drawn in code, or fetched) as a vector image. */
async function loadSvg(fileId, svg) {
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

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

## Variants

### Alinea les acotacions amb les línies sagnades

En bandera i amb un sagnat de primera línia de 5 mm, una acotació d'una línia comença a l'altura de les línies sagnades dels parlaments, i la descripció de l'escena es converteix en dos paràgrafs sagnats sota el seu rètol centrat.

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

### Deixa una línia entre parlaments

`paragraphSpacing: true` afegeix una línia en blanc de la retícula després de cada parlament. L'acte anglès passa llavors de tres a cinc pàgines, i l'espanyol, de tres a quatre.

```diff
   maxRuntTracking: 0, // gotcha: runt-tracking-unpainted
+  paragraphSpacing: true,
 };
```

## Errors freqüents

- **Qualsevol objecte headings desactiva el salt de pàgina de l'H1.** Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració.
- **Un estil de paràgraf no té color de cursiva.** A postext 1.4.1 un estil de paràgraf fixa color i boldColor, però no italicColor: les seves cursives prenen bodyText.italicColor. Un estil atenuat (la lletra petita, la línia «Font:» d'una taula) imprimeix els seus títols en cursiva més foscos que el text que els envolta. Deixa aquests estils en el color del text o evita-hi les cursives.
- **Les cel·les combinades necessiten hiddenBy: fes servir mergeCells.** Les cel·les es col·loquen segons la seva posició a la fila, així que una cel·la combinada necessita cel·les de farciment marcades amb hiddenBy on s'estén; ometre-les, com en HTML, desplaça totes les columnes següents. Combina cel·les amb mergeCells.
- **Una taula 'here' no es parteix mai.** Només es parteixen entre columnes i pàgines les taules flotants; una taula col·locada 'here' es mou sencera. Deixa flotar les taules llargues o mantén curtes les taules en línia.
- **::resource{id="…"} només admet cometes dobles.** Una inserció de bloc només es reconeix com a ::resource{id="…"} amb cometes dobles; qualsevol altra forma es queda al text com una línia visible.
- **Una paleta canviada no arriba als elements de disseny ni al color de les remissions.** postext 1.4.1 aplica colorPalette als estils de text (cos, títols, llistes, peus, taules, requadres), però no als elements de capçaleres, peus de pàgina, obertures i portadelles, ni a bodyText.referenceColor: conserven l'hex escrit al costat del seu paletteId. Si canvies la paleta, per a una edició de pantalla fosca o per recolorejar, reescriu cada color enllaçat a partir de colorPalette abans de compondre.
- **El lineHeight d'un text de disseny és un múltiple, mai una mesura.** En una ranura de disseny, el lineHeight d'un element de text multiplica el seu cos (lineHeight: 1.05). A postext 1.4.1 una mesura com pt(15) no dona error: l'alçada de l'obertura surt NaN, l'espai que reserva, minHeight inclòs, es perd sense avís i el text se superposa al títol.
- **La correcció de les línies curtes pot estrènyer un espaiat entre lletres que no es pinta mai.** A postext 1.4.1, quan un paràgraf acaba en una línia curta, el motor el compon amb una línia menys: primer estreny l'espai entre paraules i després aplica fins a maxRuntTracking mil·lèsimes d'em d'espaiat negatiu entre lletres. Els renderitzadors de canvas i PDF només pinten l'espaiat entre lletres més gran que zero, així que el paràgraf s'imprimeix sense aquest espaiat: les seves línies justificades perden aquesta diferència en els espais entre paraules, que surten aixafats, i l'última línia pot passar-se de la mesura i quedar tallada a la vora de la columna. Posa bodyText.maxRuntTracking: 0, que conserva la correcció amb l'espai entre paraules, i reescriu els paràgrafs que tornin a acabar en una línia curta.
- **Posa entre cometes cada valor del frontmatter.** YAML llegeix title: 1984 com un nombre i una data com un objecte Date, i els valors que no són cadenes s'imprimeixen buits als marcadors i deixen el PDF sense títol. Posa entre cometes cada valor: title: "1984".
- **Només 8 llengües tenen partició de mots, amb el codi exacte.** La partició de mots existeix per a en-us, es, fr, de, it, pt, ca i nl, amb el codi exacte: 'es-ES' o qualsevol altra llengua passa sense avís a l'anglès americà.
- **Carrega totes les fonts abans de compondre.** La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard.
- **Una obertura que es queda a la seva columna es talla al capdamunt de la caixa de text.** A postext 1.4.1, un títol amb disseny avançat que es queda a la seva columna es retalla per la vora superior de la columna: una caixa o una imatge ancorades a la pàgina o a la sang es pinten als marges laterals, però no al superior, i cap avís no ho diu. Dona span: 'page' a aquest títol, encara que el llibre tingui una sola columna: llavors el seu disseny es pinta sencer, com a banda d'obertura de la pàgina.
- **Les imatges d'una obertura no compten per a l'alçada que reserva.** A postext 1.4.1, un títol amb disseny avançat mesura l'alçada que reserva sense comptar-ne les imatges: els seus textos, filets i caixes compten, encara que estiguin ancorats a la pàgina, però una imatge, com un dibuix a sang al capdamunt de la pàgina, no reserva res, de manera que el text pot començar a sobre. Fixa amb minHeight on ha de començar el text.
- **Entre dues cel·les de taula emplenades s'entreveu una línia clara al canvas.** A postext 1.4.1, el renderitzador de canvas emplena cada cel·la de la taula amb un rectangle propi en posicions de píxel fraccionàries, així que on es toquen dues cel·les emplenades (les d'una fila de capçalera en color o cel·les del cos amb fons) s'entreveu entre elles una línia finíssima del color del paper. Emplena només files les cel·les de les quals estiguin combinades en una, traça els filets en el color de l'emplenament o deixa les cel·les sense emplenar.

- Tota cursiva del diàleg pren `italicColor`, també la d'èmfasi, i l'*es* que subratlla Lane surt en el gris de les acotacions. El gris ha d'anar a `bodyText` perquè a la 1.4.1 un estil de paràgraf no pot donar a la seva cursiva un color propi; només en rodona quedaria aquest *es* en tinta.
- A l'edició anglesa, tres parlaments de Wilde acaben en una paraula sola: *information.*, *that?* i *you.* L'script posa `bodyText.maxRuntTracking: 0` per l'error *L'arranjament de les línies curtes pot estrènyer un interlletratge que mai no es pinta*. Amb el valor per defecte, la 1.4.1 puja *that?* a la línia anterior amb 2,0 mm d'interlletratge negatiu que el llenç no pinta, i la línia surt amb els espais entre paraules a la meitat de la seva amplada. Tocant només l'espai entre paraules, *that?* puja únicament amb un `minWordSpacing` de 0,5, que estreny la línia igual, o amb una mesura 1,5 mm més ampla, que deixa deu línies per sobre d'1,5 vegades l'espai normal en lloc de dues. Les tres línies curtes s'hi queden, per no tocar el text de Wilde. La traducció espanyola es va reescriure allà on una línia acabava en una paraula d'una lletra o on una síl·laba quedava sola.
- Els noms es divideixen com qualsevol altra paraula: a la pàgina 5 de l'edició anglesa, *Au-gusta* i *Gwen-dolen* es parteixen entre dues línies. Un caràcter d'unió de paraules (U+2060) dins d'*Augusta* manté el nom sencer, però estira la línia anterior fins a 1,71 vegades l'espai normal, així que el text anglès conserva els guionets.
- Al llenç, Playfair Display SC dibuixa *fi* com una lligadura en minúscula, així que *first* sortiria amb una *fi* minúscula entre versaletes. Per això la banda del repartiment anglès diu *The original cast*.

## Crèdits

- Recepta: 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 ([font](https://www.gutenberg.org/ebooks/844)), domini públic
- Text: The Spanish translation of the extract, the cast list and the scenes: Postext Cookbook, original
- Imatges: The title page’s frame and the carnation fleuron, drawn in code in the page’s palette: Ignacio Ferro, MIT
- Tipus de lletra: Libre Baskerville (OFL-1.1), Abril Fatface (OFL-1.1), Playfair Display SC (OFL-1.1)
- Codi: MIT · Contingut d'exemple: MIT

## Relacionades

- [Núm. 061 · Format de guió cinematogràfic](https://postext.dev/ca/cookbook/screenplay-format.md): Guió de rodatge en Courier Prime 12 sobre paper carta, amb les rèpliques en requadres sense marc de l'amplada del diàleg i les escenes numerades als dos marges. · Nivell 2 (Intermedi) · Qualsevol gènere
- [Núm. 015 · Poemes compostos vers a vers](https://postext.dev/ca/cookbook/poetry-collection.md): Cada vers és un paràgraf, i el que no hi cap continua amb 4 em de sagnat francès. Els espais eme guarden els sagnats de 1918; :::space separa les estrofes. · Nivell 2 (Intermedi) · Poesia
- [Núm. 010 · Full tècnic: taules de dades amb capçaleres combinades](https://postext.dev/ca/cookbook/technical-datasheet.md): Taules enganxades com a TSV, llegides amb parseTSV i ajustades amb mergeCells, setAlignment i setCellBackground; un mapa de registres que es parteix sol. · Nivell 3 (Avançat) · Manuals, guies i obres de consulta
