# Obra de teatro: reparto, personajes y acotaciones

> El inicio de la obra de Wilde para actores. Los nombres van en negrita ciruela y las acotaciones en cursiva gris; el reparto sale de un texto tabulado.

- Versión HTML: https://postext.dev/es/cookbook/stage-play
- Receta N.º 070 · Publicaciones completas · Nivel 2 (Intermedio) · Salidas: Canvas
- Géneros: Narrativa, teatro y prosa literaria
- Requiere postext ≥ 1.4.1 · probada con 1.4.1 el 2026-09-26
- Páginas: [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)
- Última actualización: 2026-09-26
- Otros idiomas: [en](https://postext.dev/en/cookbook/stage-play.md)

## Lo que vas a componer

El comienzo de *La importancia de llamarse Ernesto*, de Oscar Wilde, en una edición para actores de cinco páginas de 140 × 216 mm. La portada es un cartel color ciruela dentro de un doble marco dorado, con *Importancia* y *Ernesto* en versales gruesas de cartel y un clavel dorado bajo el título. Frente al primer acto, el reparto nombra al actor que estrenó cada papel en el teatro St James’s en 1895. Cada parlamento empieza con el nombre del personaje en negrita ciruela y sangra 5 mm las líneas siguientes, de modo que el actor ve enseguida cuándo le toca hablar. Las acotaciones van en cursiva gris, estén dentro de un parlamento o entre dos, y al repasar el papel la vista se las salta. La edición española es una traducción nueva que sigue la costumbre del teatro en español: raya tras el nombre y acotaciones entre paréntesis.

**Esta receta responde a:**

- ¿Cómo compongo una obra de teatro: personajes, acotaciones, títulos de acto y escena, y el reparto?
- ¿Cómo doy color a los términos clave (en negrita o cursiva) en el texto o dentro de los recuadros?
- ¿Cómo hago una tabla con filas de cabecera, celdas combinadas, anchos de columna y alineación por celda?
- ¿Cómo compongo ilustraciones sin numerar: adornos, viñetas, logotipos?

## La respuesta corta

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

## Ingredientes

**Enseña**

- [Estilos de párrafo](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo): Estilos con nombre para grupos de párrafos que se apartan del texto general: versos, epígrafes, dedicatorias, firmas, letra pequeña.
- [Negrita, cursiva y sus colores](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo): Los fragmentos en negrita y cursiva y el color con que se imprimen; por defecto toman el azul de acento, así que en un libro a una tinta hay que devolverlos al color del texto.
- [Tablas a partir de datos](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita): Tablas como recursos con filas de cabecera, celdas combinadas, proporciones de columna, alineación por celda y listas dentro de las celdas; las tablas con barras no se interpretan.

**También usa**

- [Sangrías, alineación y separación de párrafos](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo)
- [Niveles de título](https://postext.dev/es/docs/configuration.md#configuración-por-nivel)
- [Capítulos que abren en página impar](https://postext.dev/es/docs/configuration.md#saltar-antes)
- [Estilo de tablas](https://postext.dev/es/docs/configuration.md#estilo-de-tablas)
- [Estilos de tabla con nombre](https://postext.dev/es/docs/configuration.md#estilos-de-tabla-con-nombre)
- [Tipos de recurso propios](https://postext.dev/es/docs/configuration.md#tipos-de-recurso)
- [Figuras justo aquí](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita)
- [Estilos de título](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Cubiertas, portadas y colofones](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Imágenes en los diseños de página](https://postext.dev/es/docs/configuration.md#elementos-de-imagen)
- [Textos, filetes y cajas en los diseños de página](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Cabeceras y folios](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Cabeceras según el tipo de página](https://postext.dev/es/docs/configuration.md#elementos-de-texto)
- [Metadatos del documento](https://postext.dev/es/docs/document-format.md#frontmatter)
- [Color del papel](https://postext.dev/es/docs/configuration.md#página)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration.md#paleta-de-colores)
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Saltos de página y de columna](https://postext.dev/es/docs/document-format.md#pagebreak)
- [Figuras y tablas como recursos](https://postext.dev/es/docs/document-format.md#recursos)
- [Cabeceras por sección](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Espacio vertical explícito](https://postext.dev/es/docs/document-format.md#space)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo), [`colorPalette`](https://postext.dev/es/docs/configuration.md#paleta-de-colores), [`footer`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`header`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`headingStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado), [`headings`](https://postext.dev/es/docs/configuration.md#encabezados), [`layout`](https://postext.dev/es/docs/configuration.md#disposición), [`locale`](https://postext.dev/es/docs/configuration.md#separación-silábica), [`page`](https://postext.dev/es/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo), [`resourceTypes`](https://postext.dev/es/docs/configuration.md#tipos-de-recurso), [`tableStyle`](https://postext.dev/es/docs/configuration.md#estilo-de-tablas), [`tableStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-tabla-con-nombre)

**API**

- [`buildDocument`](https://postext.dev/es/docs/configuration.md#construir-un-documento), [`clearMeasurementCache`](https://postext.dev/es/docs/configuration.md#caché-de-medidas), [`mergeCells`](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita), [`parseTSV`](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita), [`registerResourceImage`](https://postext.dev/es/docs/architecture.md#superficie-de-api), [`renderPageToCanvas`](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap)

**Tipografías**

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

## Elaboración

### 1 · Un acto es un título sin más

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

El acto se abre con un título de primer nivel en Abril Fatface de 24 pt, sin ranura de diseño. `textTransform` lo pasa a versales, y las tres líneas de 13,6 pt de la rejilla que ocupa dejan esas versales 5,5 mm por debajo del borde superior de la caja ([encabezados](/es/docs/configuration#encabezados)). `textAlign: 'center'` no admite un valor por nivel, así que centra también los títulos del reparto y el rótulo *Escena*. `breakBefore` con paridad `'odd'` lleva el acto a una página impar, frente al reparto. La configuración tiene que repetirlo, porque cualquier objeto `headings` desactiva el salto de página del primer nivel ([saltar antes](/es/docs/configuration#saltar-antes)).

### 2 · El reparto es una tabla leída de líneas con tabuladores

```js
// script.js, líneas 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 tiene tablas con barras, así que cada tabla es un recurso. Sus filas están en `content.cast.es.md`, una por línea y con un tabulador entre celda y celda, y `parseTSV` las convierte en un modelo ([construir modelos de tabla](/es/docs/configuration#construir-modelos-de-tabla)). La única línea sin tabulador, la del reparto del estreno con el teatro y la fecha, pasa a ser una fila combinada: `mergeCells` da a su primera celda `colSpan: 2` y deja la segunda en la cuadrícula, marcada con `hiddenBy`. `headerRowCount: 1` pone esa fila en la banda ciruela. Ninguna otra fila lleva relleno, porque el lienzo deja una línea clara entre dos celdas rellenas contiguas. La columna de los actores ocupa un tercio del ancho, y cada una de sus celdas va alineada a la derecha. Los lugares de la acción pasan por la misma función con un estilo de tabla propio, sin filetes ([estilos de tabla con nombre](/es/docs/configuration#estilos-de-tabla-con-nombre)). Una tabla compone todo el texto de sus celdas en un solo color, la tinta del texto salvo que `bodyColor` indique otro, así que aquí la negrita de los personajes sale en tinta y en el diálogo, en ciruela.

### 3 · Adornos sin número ni pie

```js
// script.js, líneas 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 tipo de recurso con `captionPrefix` y `shortLabel` vacíos imprime sus ilustraciones solas, sin pie ([tipos de recurso](/es/docs/configuration#tipos-de-recurso)). Su `defaultPlacement` coloca el clavel donde está `::resource{id="fleuron"}`, centrado y con un ancho de 0,24 de la medida de 107 mm, unos 26 mm. Las dos tablas usan un segundo tipo, también sin número, así que ninguna lleva el rótulo *Tabla 1*.

### 4 · La portada es un estilo de título

```js
// script.js, líneas 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" …}` lleva el título en cuatro atributos que el diseño compone en dos familias: Abril Fatface de 40 y 60 pt para los sustantivos y Libre Baskerville cursiva de 17 pt para *La* y *de llamarse* ([atributos de encabezado](/es/docs/document-format#atributos-de-encabezado)). El fondo ciruela, el marco y el clavel son un solo SVG dibujado en código y anclado al sangrado ([elementos de imagen](/es/docs/configuration#elementos-de-imagen)). El estilo lleva `span: 'page'` aunque el libro tenga una sola columna. Si el diseño se quedara en la columna, se cortaría arriba y abajo por los bordes de la caja de texto, a 20 mm del borde superior de la hoja y a 23,3 mm del inferior. El `header` vacío del estilo quita las cabeceras del reparto, que pertenece a la misma sección, y su `footer` vacío quita de la portada el folio al pie, que saldría en ciruela sobre ciruela ([estilos de encabezado](/es/docs/configuration#estilos-de-encabezado)).

Las imágenes de una apertura no reservan altura, así que el título ocupa la página solo hasta su línea de texto más baja, la del estreno, a 176 mm. El hueco que queda debajo no basta para el título siguiente, y sin el `:::pagebreak` que sigue a la portada las páginas capturadas salen iguales. El salto está ahí por si se retoca la portada: si lo quitas y subes el teatro y el estreno a 150 y 156 mm, *Personajes de la obra* empieza sobre la hoja ciruela. En la 1.4.1, una línea centrada con `letterSpacing` queda desplazada a la izquierda la mitad de ese espaciado, y `line()` corre cada línea espaciada lo mismo hacia la derecha. *OSCAR WILDE* queda así a 0,07 mm del centro en lugar de a 0,56.

### 5 · Cabeceras que se saltan la primera página del acto

```js
// script.js, líneas 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 par lleva el título de la obra, tomado del frontmatter, y la impar el del acto, de `{chapterTitle}`; las dos van en versales de Playfair Display SC, con el folio en ciruela en el borde exterior ([elementos de texto](/es/docs/configuration#elementos-de-texto)). La primera página del acto cuenta como apertura porque su primer bloque es un título que salta de página, así que `pages: 'body'` le quita las cabeceras, y el pie de página le pone en su lugar un folio centrado.

> Un guion de cine sangra el diálogo por los dos lados en lugar de usar sangría francesa: [Formato de guion cinematográfico](https://postext.dev/es/cookbook/screenplay-format.md) compone cada réplica como un recuadro sin marco del ancho del bloque de diálogo.

## La receta completa

Un solo archivo, compuesto a partir de la carpeta de la receta con el texto de ejemplo y el kit común del Recetario ya incluidos; construye su propia página. Para ejecutarlo, ponlo en un `<script type="module">` de una página vacía o pégalo en el panel JS de un pen nuevo de CodePen (como módulo). Importa postext desde esm.sh, así que no hay nada que instalar ni compilar.

- Carpeta de la receta: 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 ───────────────────────────────────────────────────────────────────────
```

## Variantes

### Alinea las acotaciones con las líneas sangradas

En bandera y con una sangría de primera línea de 5 mm, una acotación de una línea arranca a la altura de las líneas sangradas de los parlamentos, y la descripción de la escena se convierte en dos párrafos sangrados bajo su rótulo centrado.

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

### Deja una línea entre parlamentos

`paragraphSpacing: true` añade una línea en blanco de la rejilla tras cada parlamento. El acto inglés pasa entonces de tres a cinco páginas, y el español, de tres a cuatro.

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

## Errores frecuentes

- **Cualquier objeto headings desactiva el salto de página del H1.** Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración.
- **Un estilo de párrafo no tiene color de cursiva.** En postext 1.4.1 un estilo de párrafo fija color y boldColor, pero no italicColor: sus cursivas toman bodyText.italicColor. Un estilo atenuado (la letra pequeña, la línea «Fuente:» de una tabla) imprime sus títulos en cursiva más oscuros que el texto que los rodea. Deja esos estilos en el color del texto o evita en ellos las cursivas.
- **Las celdas combinadas necesitan hiddenBy: usa mergeCells.** Las celdas se colocan según su posición en la fila, así que una celda combinada necesita celdas de relleno marcadas con hiddenBy donde se extiende; omitirlas, como en HTML, desplaza todas las columnas siguientes. Combina celdas con mergeCells.
- **Una tabla 'here' nunca se parte.** Solo se parten entre columnas y páginas las tablas flotantes; una tabla colocada 'here' se mueve entera. Deja flotar las tablas largas o mantén cortas las tablas en línea.
- **::resource{id="…"} solo admite comillas dobles.** Una inserción de bloque solo se reconoce como ::resource{id="…"} con comillas dobles; cualquier otra forma se queda en el texto como una línea visible.
- **Una paleta cambiada no llega a los elementos de diseño ni al color de las remisiones.** postext 1.4.1 aplica colorPalette a los estilos de texto (cuerpo, títulos, listas, pies, tablas, recuadros), pero no a los elementos de cabeceras, pies de página, aperturas y portadillas, ni a bodyText.referenceColor: conservan el hex escrito junto a su paletteId. Si cambias la paleta, para una edición de pantalla oscura o para recolorear, reescribe cada color enlazado a partir de colorPalette antes de componer.
- **El lineHeight de un texto de diseño es un múltiplo, nunca una medida.** En una ranura de diseño, el lineHeight de un elemento de texto multiplica su cuerpo (lineHeight: 1.05). En postext 1.4.1 una medida como pt(15) no da error: la altura de la apertura sale NaN, el espacio que reserva, minHeight incluido, se pierde sin aviso y el texto se superpone al título.
- **El arreglo de las líneas cortas puede apretar un interletraje que nunca se pinta.** En postext 1.4.1, cuando un párrafo acaba en una línea corta, el motor lo compone con una línea menos: primero aprieta el espacio entre palabras y luego aplica hasta maxRuntTracking milésimas de em de interletraje negativo. Los renderizadores de canvas y PDF solo pintan el interletraje mayor que cero, así que el párrafo se imprime sin él: sus líneas justificadas pierden esa diferencia en los espacios entre palabras, que salen aplastados, y su última línea puede pasarse de la medida y quedar cortada en el borde de la columna. Pon bodyText.maxRuntTracking: 0, que conserva el arreglo por el espacio entre palabras, y reescribe los párrafos que vuelvan a acabar en una línea corta.
- **Entrecomilla cada valor del frontmatter.** YAML lee title: 1984 como un número y una fecha como un objeto Date, y los valores que no son cadenas se imprimen vacíos en los marcadores y dejan el PDF sin título. Entrecomilla cada valor: title: "1984".
- **Solo 8 idiomas tienen separación silábica, con el código exacto.** La separación silábica existe para en-us, es, fr, de, it, pt, ca y nl, con el código exacto: 'es-ES' o cualquier otro idioma pasa sin aviso al inglés americano.
- **Carga todas las fuentes antes de componer.** La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde.
- **Una apertura que se queda en su columna se corta en la cabeza de la caja de texto.** En postext 1.4.1, un título con diseño avanzado que se queda en su columna se recorta por el borde superior de la columna: una caja o una imagen ancladas a la página o a la sangre se pintan en los márgenes laterales, pero no en el de cabeza, y ningún aviso lo dice. Dale span: 'page' a ese título, aunque el libro tenga una sola columna: su diseño se pinta entonces entero, como banda de apertura de la página.
- **Las imágenes de una apertura no cuentan para la altura que reserva.** En postext 1.4.1, un título con diseño avanzado mide la altura que reserva sin contar sus imágenes: sus textos, filetes y cajas cuentan, aunque estén anclados a la página, pero una imagen, como un dibujo a sangre en la cabeza de la página, no reserva nada, así que el texto puede empezar encima de ella. Fija con minHeight dónde debe empezar el texto.
- **Entre dos celdas de tabla rellenas asoma una línea clara en el canvas.** En postext 1.4.1, el renderizador de canvas rellena cada celda de la tabla con un rectángulo propio en posiciones de píxel fraccionarias, así que donde se tocan dos celdas rellenas (las de una fila de cabecera en color o celdas del cuerpo con fondo) asoma entre ellas una línea finísima del color del papel. Rellena solo filas cuyas celdas estén combinadas en una, traza los filetes en el color del relleno o deja las celdas sin relleno.

- Toda cursiva del diálogo toma `italicColor`, también la de énfasis, y el *es* que subraya Lane sale en el gris de las acotaciones. El gris tiene que ir en `bodyText` porque en la 1.4.1 un estilo de párrafo no puede dar a su cursiva un color propio; solo en redonda quedaría ese *es* en tinta.
- En la edición inglesa, tres parlamentos de Wilde terminan en una palabra sola: *information.*, *that?* y *you.* El script pone `bodyText.maxRuntTracking: 0` por el error *El arreglo de las líneas cortas puede apretar un interletraje que nunca se pinta*. Con el valor por defecto, la 1.4.1 sube *that?* a la línea anterior con 2,0 mm de interletraje negativo que el lienzo no pinta, y la línea sale con los espacios entre palabras a la mitad de su ancho. Tocando solo el espacio entre palabras, *that?* sube únicamente con un `minWordSpacing` de 0,5, que aprieta la línea igual, o con una medida 1,5 mm más ancha, que deja diez líneas por encima de 1,5 veces el espacio normal en lugar de dos. Las tres líneas cortas se quedan, para no tocar el texto de Wilde. La traducción española se reescribió allí donde una línea acababa en una palabra de una letra o donde una sílaba quedaba sola.
- Los nombres se dividen como cualquier otra palabra: en la página 5 de la edición inglesa, *Au-gusta* y *Gwen-dolen* se parten entre dos líneas. Un carácter de unión de palabras (U+2060) dentro de *Augusta* mantiene el nombre entero, pero estira la línea anterior hasta 1,71 veces el espacio normal, así que el texto inglés conserva los guiones.
- En el lienzo, Playfair Display SC dibuja *fi* como una ligadura en minúscula, así que *first* saldría con una *fi* minúscula entre versalitas. Por eso la banda del reparto inglés dice *The original cast*.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texto: The Importance of Being Earnest (La importancia de llamarse Ernesto, 1895): los personajes y los lugares de la obra, el reparto del estreno y el comienzo del primer acto: Oscar Wilde ([fuente](https://www.gutenberg.org/ebooks/844)), dominio público
- Texto: La traducción al español del fragmento, del reparto y de los lugares de la acción: Postext Cookbook, original
- Imágenes: El marco de la portada y el florón del clavel, dibujados en código con la paleta de la página: Ignacio Ferro, MIT
- Tipografías: Libre Baskerville (OFL-1.1), Abril Fatface (OFL-1.1), Playfair Display SC (OFL-1.1)
- Código: MIT · Contenido de ejemplo: MIT

## Relacionadas

- [N.º 061 · Formato de guion cinematográfico](https://postext.dev/es/cookbook/screenplay-format.md): Guion de rodaje en Courier Prime 12 sobre papel carta, con las réplicas en recuadros sin marco del ancho del diálogo y las escenas numeradas en ambos márgenes. · Nivel 2 (Intermedio) · Cualquier género
- [N.º 015 · Poemas compuestos verso a verso](https://postext.dev/es/cookbook/poetry-collection.md): Cada verso es un párrafo, y el que no cabe sigue con 4 em de sangría francesa. Los espacios eme guardan las sangrías de 1918; :::space separa las estrofas. · Nivel 2 (Intermedio) · Poesía
- [N.º 010 · Hoja técnica: tablas de datos con cabeceras combinadas](https://postext.dev/es/cookbook/technical-datasheet.md): Tablas pegadas como TSV, leídas con parseTSV y ajustadas con mergeCells, setAlignment y setCellBackground; un mapa de registros que se parte solo. · Nivel 3 (Avanzado) · Manuales, guías y obras de consulta
