# Formato de guion cinematográfico

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

- Versión HTML: https://postext.dev/es/cookbook/screenplay-format
- Receta N.º 061 · Texto y tipografía · Nivel 2 (Intermedio) · Salidas: Canvas
- Géneros: Cualquier género
- Requiere postext ≥ 1.4.1 · probada con 1.4.1 el 2026-09-26
- Páginas: [1](https://postext.dev/cookbook/screenplay-format/es/p01.webp?v=fec65500), [2](https://postext.dev/cookbook/screenplay-format/es/p02.webp?v=fec65500), [1](https://postext.dev/cookbook/screenplay-format/es/p03.webp?v=fec65500), [2](https://postext.dev/cookbook/screenplay-format/es/p04.webp?v=fec65500), [3](https://postext.dev/cookbook/screenplay-format/es/p05.webp?v=fec65500)
- Última actualización: 2026-09-26
- Otros idiomas: [en](https://postext.dev/en/cookbook/screenplay-format.md)

## Lo que vas a componer

La versión de rodaje de *Objetos perdidos*, un cortometraje inventado de tres páginas, encuadernada como los guiones de los estudios: cubierta de cartulina mostaza con dos encuadernadores de latón y etiqueta mecanografiada, el interior sellado con COPIA 07 y, detrás, el guion en Courier Prime de 12 sobre 12 en papel carta taladrado. Las medidas son las de los programas de guion: acción de 1,5 a 7,5 pulgadas; réplica de 2,5 a 6, bajo el nombre del personaje a 3,7; acotación desde 3,1, y CORTE A: y FUNDIDO A NEGRO. hasta el margen derecho. Cada escena lleva su número en los dos márgenes. La página 1 del guion no lleva número; las demás, arriba a la derecha y con punto. Cada réplica es un recuadro sin marco que un relleno superior de −2,4 pt ajusta a líneas enteras de 12 pt, y así acción y diálogo mantienen seis líneas por pulgada.

**Esta receta responde a:**

- ¿Cómo compongo un guion con escenas numeradas y cada réplica en un bloque de 3,5 pulgadas bajo el nombre?

## La respuesta corta

```js
// script.js, líneas 46–68
// Positions from the left edge of the sheet, in inches, as screenwriting software gives them.
const DIALOGUE = { left: 2.5, right: 6 }; // the block of speech, 3.5 inches wide
const CUE = 3.7; // the character's name
const TEXT_RIGHT = 8.5 - MARGIN.right; // 7.5: where action lines end
const CUE_LINE = 1.2 * 12; // pt: a callout title sits on a line 1.2 times its size
const dialogue = {
  id: 'dialogue',
  backgroundEnabled: false, // no fill and no border: the box is only a measure
  padding: { top: pt(LEAD - CUE_LINE), bottom: pt(0), // −2.4 pt: see the title below
    left: mm((DIALOGUE.left - MARGIN.left) * IN), // 1 inch in from the action
    right: mm((TEXT_RIGHT - DIALOGUE.right) * IN) }, // 1.5 inches short of it
  // The fence's title is the cue: title="Dora (cont’d)" prints DORA (CONT’D) at 3.7 inches,
  // in the headings' Courier, with no gap under it (the default is half a line). Its 14.4-pt
  // line starts 2.4 pt above the box, so a speech is a whole number of 12-pt lines and the
  // name sits 0.48 pt above its grid line.
  titleStyle: { fontWeight: 400, textTransform: 'uppercase',
    indent: mm((CUE - DIALOGUE.left) * IN), gap: pt(0) },
  body: { paragraphSpacing: false }, // no blank line before a parenthetical mid-speech
  marginTop: pt(LEAD), marginBottom: pt(LEAD), // one blank line above and below
  // Snapped to the grid, a box keeps its bottom margin and the next speech adds its top
  // margin: two blank lines between speeches. Unsnapped, the two margins collapse into one.
  snapToGrid: false,
};
```

## Ingredientes

**Enseña**

- [Recuadros](https://postext.dev/es/docs/configuration.md#estilos-de-aviso): Estilos de recuadro con nombre para notas, consejos y advertencias: fondo, borde, radio, franja, título y tipografía propia de texto y listas.
- [Salir de la rejilla a propósito](https://postext.dev/es/docs/architecture.md#elementos-que-rompen-la-rejilla): Los títulos, los estilos de párrafo y los recuadros apilados pueden conservar un espaciado exacto fuera de la rejilla; el texto que los sigue vuelve a ella.
- [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.

**También usa**

- [Títulos numerados](https://postext.dev/es/docs/configuration.md#configuración-por-nivel)
- [Estilos de título](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Cubiertas, portadas y colofones](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Cabeceras según el tipo de página](https://postext.dev/es/docs/configuration.md#elementos-de-texto)
- [Cabeceras y folios](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Cabeceras por sección](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Textos, filetes y cajas en los diseños de página](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Formato de página](https://postext.dev/es/docs/configuration.md#tamaños-de-página-predefinidos)
- [Unidades y medidas](https://postext.dev/es/docs/configuration.md#dimensiones)
- [Tipografía del texto](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo)
- [Sangrías, alineación y separación de párrafos](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo)
- [Equilibrado de columnas](https://postext.dev/es/docs/configuration.md#equilibrado-de-columnas)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration.md#paleta-de-colores)
- [Páginas en un canvas](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap)
- [Preliminares en romanos](https://postext.dev/es/docs/document-format.md#numbering)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo), [`calloutStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-aviso), [`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), [`page`](https://postext.dev/es/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)

**API**

- [`buildDocument`](https://postext.dev/es/docs/configuration.md#construir-un-documento), [`clearMeasurementCache`](https://postext.dev/es/docs/configuration.md#caché-de-medidas), [`renderPageToCanvas`](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap)

**Tipografías**

- Courier Prime (OFL-1.1), Special Elite (Apache-2.0), Oswald (OFL-1.1)

## Elaboración

### 1 · Ajusta un recuadro sin marco al bloque de diálogo

El código está en [la respuesta corta](#la-respuesta-corta), más arriba. El relleno de un recuadro se mide desde la caja de texto, así que 1 pulgada a la izquierda y 1,5 a la derecha dejan el bloque de 3,5 pulgadas que empieza a 2,5 del borde de la hoja ([estilos de aviso](/es/docs/configuration#estilos-de-aviso)). El nombre del personaje es el título del recuadro y se escribe en la valla: `title="Dora (cont.)"`. `textTransform` lo pasa a mayúsculas, e `indent` lo lleva 1,2 pulgadas más allá del relleno, hasta 3,7. `gap` vale 0 porque, por defecto, entre el nombre y la réplica queda medio cuadratín, 6 pt. En la 1.4.1 el título de un recuadro se compone en una línea de 1,2 veces su cuerpo, 14,4 pt aquí, y `titleStyle` no tiene un interlineado propio que la cambie. El relleno superior de −2,4 pt descuenta esos 2,4 pt de más, así que la réplica ocupa un número entero de líneas de 12 pt y el nombre queda 0,48 pt por encima de su línea de la rejilla. Con el relleno a 0, cada nombre baja 1,92 pt, cada réplica aleja 2,4 pt más de la rejilla el texto que la sigue, y el encabezado de la escena 3, al volver a la rejilla, queda a 33,6 pt de su primera línea de acción en lugar de 24. Con `snapToGrid` desactivado, el margen inferior de una réplica y el superior de la siguiente se funden en una sola línea en blanco. Un recuadro se mantiene entero por defecto, de modo que una réplica que ya no cabe pasa a la página siguiente con su nombre.

### 2 · Mide la página en pulgadas

```js
// script.js, líneas 33–42
const MARGIN = { top: 1, bottom: 1, left: 1.5, right: 1 }; // inches: the left one takes the brads
const page = { sizePreset: 'custom', width: mm(8.5 * IN), height: mm(11 * IN), dpi: 150,
  margins: { top: mm(MARGIN.top * IN), bottom: mm(MARGIN.bottom * IN),
    left: mm(MARGIN.left * IN), right: mm(MARGIN.right * IN) } };
const bodyText = { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(LEAD), color: col('ink'),
  textAlign: 'left', firstLineIndent: pt(0), // action: flush left, never justified
  paragraphSpacing: true, // a blank line between paragraphs
  // No :ref in this script, but one added later prints in ink, not the default link blue:
  // main-color does not reach this key (gotcha: palette-skips-designs).
  referenceColor: col('ink') };
```

Tamaño carta, con el margen izquierdo de 1,5 pulgadas por el que pasan los encuadernadores y una pulgada en los otros tres lados. Courier Prime es una letra monoespaciada: a 12 pt cada carácter mide una décima de pulgada, así que una línea de acción admite 60 caracteres y una de diálogo 35, y con un interlineado de 12 pt caben 54 líneas en la página. El texto va en bandera, alineado a la izquierda, y `paragraphSpacing` deja una línea en blanco entre párrafos. Postext 1.4.1 solo aplica la separación silábica al texto justificado, así que ninguna palabra se corta, como pide el formato.

### 3 · Haz de las acotaciones y las transiciones estilos de párrafo

```js
// script.js, líneas 72–76
const PAREN = 3.1;
const paragraphStyles = [
  { id: 'paren', firstLineIndent: mm((PAREN - DIALOGUE.left) * IN) }, // inside a speech
  { id: 'transition', textAlign: 'right' }, // CUT TO:, flush with the action's right edge
];
```

Un contenedor `:::paragraphs{style="paren"}` dentro de una réplica sangra su primera línea 0,6 pulgadas desde el borde del diálogo, hasta 3,1 ([estilos de párrafo](/es/docs/configuration#estilos-de-párrafo)). Escribe cada acotación en una sola línea, porque el estilo solo sangra la primera. En el estilo del diálogo, `body.paragraphSpacing: false` deja fuera del recuadro la línea en blanco que separa los párrafos de acción, y `(luego)` queda justo debajo de la línea anterior. `CORTE A:` y `FUNDIDO A NEGRO.` van en contenedores `transition`, cuyo `textAlign: 'right'` los alinea con el final de las líneas de acción. Un contenedor devuelve el texto a la rejilla base después de su último párrafo; este guion no sale nunca de ella, así que el encabezado que sigue a `CORTE A:` conserva sus dos líneas en blanco.

### 4 · Cuelga los números de escena en los dos márgenes

```js
// script.js, líneas 80–110
// Each number sits in a box half an inch wide, text aligned left, so that 9, 12 and an
// inserted 12A start at the same place on both sides.
const NUMBER_W = 0.5; // inches
const number = (id, edge, x) => ({ kind: 'text', id, content: '{number}', fontFamily: TEXT,
  fontSize: pt(12), fontWeight: 700, lineHeight: 1, color: col('ink'), align: 'left',
  placement: { ...at('container', edge, x * IN), size: { width: mm(NUMBER_W * IN) } } });
// The heading's own text is not painted but still measured: at the level's default 15 pt,
// scene 1's heading would wrap and reserve a second line.
const slugline = { level: 2, fontSize: pt(12), lineHeight: pt(LEAD),
  marginTop: pt(2 * LEAD), marginBottom: pt(LEAD), // two blank lines above, one below
  numberingTemplate: '{2}', // the scene count, which {number} prints
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'text', id: 'heading', content: '{titleText}', fontFamily: TEXT, fontSize: pt(12),
      fontWeight: 700, lineHeight: 1, color: col('ink'), align: 'left', overflow: 'wrap',
      placement: { ...at('container', 'top-left'), size: { width: 'fill' } } },
    number('left', 'top-left', -0.6), // starts at 0.9 inches from the edge of the sheet
    number('right', 'top-right', 0.25 + NUMBER_W), // starts a quarter inch past the text
  ] } } };
const headings = {
  // The family of the speech titles, and the one the headings' unpainted text is measured
  // in (left unset, a sixth face, Open Sans Bold, would be loaded for nothing).
  fontFamily: TEXT,
  // Script pages end where the last whole speech or paragraph ends. Balancing would add
  // blank lines above sluglines to fill them, and push a closing speech to the foot
  // (gotcha: balancing-drops-last-box).
  balancing: { enabled: false },
  levels: [
    // Any headings object drops the H1 page break (gotcha: headings-drop-h1-break).
    { level: 1, breakBefore: { enabled: true, parity: 'any' } },
    slugline,
  ] };
```

`numberingTemplate: '{2}'` cuenta los títulos de nivel 2, y `{number}` imprime esa cuenta en el diseño ([configuración por nivel](/es/docs/configuration#configuración-por-nivel)). Un título con diseño avanzado conserva su sitio en la columna y dibuja sus elementos en lugar de su texto ([span y diseño avanzado](/es/docs/configuration#span-y-diseño-avanzado)), y así los dos números pueden quedarse en los márgenes, junto al encabezado en negrita. Cada uno se ancla a la caja del propio título y sale de ella: 0,6 pulgadas a la izquierda y, a la derecha, un cuarto de pulgada más allá del final del texto, en una caja de media pulgada con el texto alineado a la izquierda, para que en un guion de más de nueve escenas los números de una y de dos cifras empiecen en el mismo sitio. El texto propio del título no se pinta, pero se mide, y por eso el nivel va a 12 pt: con los 15 pt por defecto, el encabezado de la escena 1 pasaría a dos líneas y reservaría la segunda. El equilibrado de columnas está desactivado: añade líneas en blanco para llenar las páginas cortas, y con él la página 1 del guion deja una línea en blanco de más sobre el encabezado de la escena 1.

### 5 · Taladra y numera solo las páginas del guion

```js
// script.js, líneas 114–134
// Three holes down the bound edge, 4.25 inches apart, as a three-hole punch leaves them.
const HOLES = [1.25, 5.5, 9.75].map((y) => y * IN); // mm: hole centres from the top
const EDGE = 0.375 * IN; // mm: from the bound edge to the centre of each hole
const HOLE = 7; // mm across
const punched = HOLES.map((y, i) => ({ kind: 'box', id: `punch${i}`,
  style: { backgroundColor: col('hole'), borderRadius: mm(HOLE / 2) },
  placement: { ...at('page', 'top-left', EDGE - HOLE / 2, y - HOLE / 2),
    size: { width: mm(HOLE), height: mm(HOLE) } } }));
// The script's first page opens with its title, an H1 that breaks the page, so the page is
// an 'opener' and pages: 'body' leaves it unnumbered, as the format asks.
const folio = { kind: 'text', id: 'folio', content: '{pageNumber}.', pages: 'body',
  fontFamily: TEXT, fontSize: pt(12), color: col('ink'), align: 'right',
  placement: at('page', 'top-right', -MARGIN.right * IN, 0.5 * IN) };
// The title's style carries the header of its section: the pages from the title to the end.
const title = { id: 'script', header: { elements: [...punched, folio] },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: TEXT, fontSize: pt(12),
      lineHeight: 1, textTransform: 'uppercase', color: col('ink'), align: 'center',
      placement: { ...at('container', 'top'), size: { width: 'fill' } } },
  ] } }, // H1's own line, 1.2 × 18 pt, would reserve 21.6 pt and drop FADE IN: 9.6 pt
  lineHeight: pt(LEAD), marginBottom: pt(LEAD) };
```

El estilo del título del guion lleva su propia cabecera (`header`), que sustituye a la del documento en las páginas de su sección, del título al final ([estilos de encabezado](/es/docs/configuration#estilos-de-encabezado)). La cabecera del documento está vacía, así que los agujeros y el número de página solo salen en el guion. El número se ancla a media pulgada del borde superior de la hoja, y `pages: 'body'` lo deja fuera de las páginas de apertura ([elementos de texto](/es/docs/configuration#elementos-de-texto)). El título del guion es de nivel 1 y abre página, así que la página 1 cuenta como página de apertura y no lleva número, como pide el formato. Las cubiertas también son títulos de nivel 1; `:::numbering{startAt=1}`, entre el interior de la cubierta y el título, da el número 1 a la página del título, y la siguiente lleva el 2. El estilo del título fija una línea de 12 pt; con los 18 pt propios del nivel 1, el título iría en una línea de 21,6 pt y bajaría FUNDIDO DE ENTRADA: 9,6 pt.

## 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/screenplay-format

### script.js

```js
// ═══ Postext Cookbook · Nº 061 · Screenplay format ═══════════════════════════════
// https://postext.dev/en/cookbook/screenplay-format
// Code: MIT · Text: original (CC BY 4.0) · Pictures: none
// Fonts: Courier Prime, Oswald (SIL OFL 1.1), Special Elite (Apache 2.0) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache } from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // black type on white bond, and a card cover in goldenrod
  ink: '#1b1b1b', // every line of the script
  paper: '#ffffff',
  hole: '#e3e1db', // the punched holes down the left edge of each script page
  card: '#e6b84a', // the cover stock
  cardDark: '#b98a2a', // the label's shadow and the rims of the punched holes
  cardInk: '#4a3510', // the small print on the cover (6.3:1 on the card)
  brass: '#a8812f', // the brads
  brassLight: '#e9cf82', // the glint on each brad
  stamp: '#8f231c', // the draft stamp (4.7:1 on the card)
};
// The hex as well as the id: design slots read only the hex (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to main-color: pointed at the ink, anything left unset prints black.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.ink })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const TEXT = 'Courier Prime';
const IN = 25.4; // mm in an inch: the format is specified in inches
const LEAD = 12; // pt: 12-point Courier at six lines to the inch, so one line is one grid line
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });

// #region page: US Letter, a 1.5-inch binding margin, Courier 12 on 12, ragged right
const MARGIN = { top: 1, bottom: 1, left: 1.5, right: 1 }; // inches: the left one takes the brads
const page = { sizePreset: 'custom', width: mm(8.5 * IN), height: mm(11 * IN), dpi: 150,
  margins: { top: mm(MARGIN.top * IN), bottom: mm(MARGIN.bottom * IN),
    left: mm(MARGIN.left * IN), right: mm(MARGIN.right * IN) } };
const bodyText = { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(LEAD), color: col('ink'),
  textAlign: 'left', firstLineIndent: pt(0), // action: flush left, never justified
  paragraphSpacing: true, // a blank line between paragraphs
  // No :ref in this script, but one added later prints in ink, not the default link blue:
  // main-color does not reach this key (gotcha: palette-skips-designs).
  referenceColor: col('ink') };
// #endregion

// #region answer: a speech is a callout with no frame, padded to the 3.5-inch dialogue block
// Positions from the left edge of the sheet, in inches, as screenwriting software gives them.
const DIALOGUE = { left: 2.5, right: 6 }; // the block of speech, 3.5 inches wide
const CUE = 3.7; // the character's name
const TEXT_RIGHT = 8.5 - MARGIN.right; // 7.5: where action lines end
const CUE_LINE = 1.2 * 12; // pt: a callout title sits on a line 1.2 times its size
const dialogue = {
  id: 'dialogue',
  backgroundEnabled: false, // no fill and no border: the box is only a measure
  padding: { top: pt(LEAD - CUE_LINE), bottom: pt(0), // −2.4 pt: see the title below
    left: mm((DIALOGUE.left - MARGIN.left) * IN), // 1 inch in from the action
    right: mm((TEXT_RIGHT - DIALOGUE.right) * IN) }, // 1.5 inches short of it
  // The fence's title is the cue: title="Dora (cont’d)" prints DORA (CONT’D) at 3.7 inches,
  // in the headings' Courier, with no gap under it (the default is half a line). Its 14.4-pt
  // line starts 2.4 pt above the box, so a speech is a whole number of 12-pt lines and the
  // name sits 0.48 pt above its grid line.
  titleStyle: { fontWeight: 400, textTransform: 'uppercase',
    indent: mm((CUE - DIALOGUE.left) * IN), gap: pt(0) },
  body: { paragraphSpacing: false }, // no blank line before a parenthetical mid-speech
  marginTop: pt(LEAD), marginBottom: pt(LEAD), // one blank line above and below
  // Snapped to the grid, a box keeps its bottom margin and the next speech adds its top
  // margin: two blank lines between speeches. Unsnapped, the two margins collapse into one.
  snapToGrid: false,
};
// #endregion

// #region styles: parentheticals start at 3.1 inches; transitions end at the right margin
const PAREN = 3.1;
const paragraphStyles = [
  { id: 'paren', firstLineIndent: mm((PAREN - DIALOGUE.left) * IN) }, // inside a speech
  { id: 'transition', textAlign: 'right' }, // CUT TO:, flush with the action's right edge
];
// #endregion

// #region slugline: each scene heading carries its number in both margins
// Each number sits in a box half an inch wide, text aligned left, so that 9, 12 and an
// inserted 12A start at the same place on both sides.
const NUMBER_W = 0.5; // inches
const number = (id, edge, x) => ({ kind: 'text', id, content: '{number}', fontFamily: TEXT,
  fontSize: pt(12), fontWeight: 700, lineHeight: 1, color: col('ink'), align: 'left',
  placement: { ...at('container', edge, x * IN), size: { width: mm(NUMBER_W * IN) } } });
// The heading's own text is not painted but still measured: at the level's default 15 pt,
// scene 1's heading would wrap and reserve a second line.
const slugline = { level: 2, fontSize: pt(12), lineHeight: pt(LEAD),
  marginTop: pt(2 * LEAD), marginBottom: pt(LEAD), // two blank lines above, one below
  numberingTemplate: '{2}', // the scene count, which {number} prints
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'text', id: 'heading', content: '{titleText}', fontFamily: TEXT, fontSize: pt(12),
      fontWeight: 700, lineHeight: 1, color: col('ink'), align: 'left', overflow: 'wrap',
      placement: { ...at('container', 'top-left'), size: { width: 'fill' } } },
    number('left', 'top-left', -0.6), // starts at 0.9 inches from the edge of the sheet
    number('right', 'top-right', 0.25 + NUMBER_W), // starts a quarter inch past the text
  ] } } };
const headings = {
  // The family of the speech titles, and the one the headings' unpainted text is measured
  // in (left unset, a sixth face, Open Sans Bold, would be loaded for nothing).
  fontFamily: TEXT,
  // Script pages end where the last whole speech or paragraph ends. Balancing would add
  // blank lines above sluglines to fill them, and push a closing speech to the foot
  // (gotcha: balancing-drops-last-box).
  balancing: { enabled: false },
  levels: [
    // Any headings object drops the H1 page break (gotcha: headings-drop-h1-break).
    { level: 1, breakBefore: { enabled: true, parity: 'any' } },
    slugline,
  ] };
// #endregion

// #region furniture: the script's pages are punched, and numbered from page 2 on
// Three holes down the bound edge, 4.25 inches apart, as a three-hole punch leaves them.
const HOLES = [1.25, 5.5, 9.75].map((y) => y * IN); // mm: hole centres from the top
const EDGE = 0.375 * IN; // mm: from the bound edge to the centre of each hole
const HOLE = 7; // mm across
const punched = HOLES.map((y, i) => ({ kind: 'box', id: `punch${i}`,
  style: { backgroundColor: col('hole'), borderRadius: mm(HOLE / 2) },
  placement: { ...at('page', 'top-left', EDGE - HOLE / 2, y - HOLE / 2),
    size: { width: mm(HOLE), height: mm(HOLE) } } }));
// The script's first page opens with its title, an H1 that breaks the page, so the page is
// an 'opener' and pages: 'body' leaves it unnumbered, as the format asks.
const folio = { kind: 'text', id: 'folio', content: '{pageNumber}.', pages: 'body',
  fontFamily: TEXT, fontSize: pt(12), color: col('ink'), align: 'right',
  placement: at('page', 'top-right', -MARGIN.right * IN, 0.5 * IN) };
// The title's style carries the header of its section: the pages from the title to the end.
const title = { id: 'script', header: { elements: [...punched, folio] },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: TEXT, fontSize: pt(12),
      lineHeight: 1, textTransform: 'uppercase', color: col('ink'), align: 'center',
      placement: { ...at('container', 'top'), size: { width: 'fill' } } },
  ] } }, // H1's own line, 1.2 × 18 pt, would reserve 21.6 pt and drop FADE IN: 9.6 pt
  lineHeight: pt(LEAD), marginBottom: pt(LEAD) };
// #endregion

// #region art: the card covers: a three-hole punch, brass brads, a typed label, two stamps
const [W, H] = [8.5 * IN, 11 * IN]; // mm: the sheet
const PT = 25.4 / 72; // mm in a point
const box = (id, x, y, w, h, style) => ({ kind: 'box', id, style,
  placement: { ...at('page', 'top-left', x, y), size: { width: mm(w), height: mm(h) } } });
const circle = (id, cx, cy, d, style) => box(id, cx - d / 2, cy - d / 2, d, d,
  { ...style, borderRadius: mm(d / 2) });
const line = (id, content, x, y, w, style) => ({ kind: 'text', id, content, lineHeight: 1,
  ...style, placement: { ...at('page', 'top-left', x, y), size: { width: mm(w) } } });
const card = box('card', 0, 0, W, H, { backgroundColor: col('card') });
// Outside, a brass head in the top and bottom holes; the middle hole stays empty, as on
// studio scripts, and shows the white page under the cover.
const heads = HOLES.flatMap((y, i) => (i === 1
  ? [circle('hole', EDGE, y, HOLE, { backgroundColor: col('paper'),
    borderColor: col('cardDark'), borderWidth: pt(1) })]
  : [circle(`shade${i}`, EDGE + 0.5, y + 0.7, 11.5, { backgroundColor: col('cardDark') }),
    circle(`head${i}`, EDGE, y, 11, { backgroundColor: col('brass') }),
    circle(`glint${i}`, EDGE - 1.8, y - 1.8, 3.6, { backgroundColor: col('brassLight') })]));
// Inside, the holes are at the right edge, with the prongs of two brads through them.
const prongs = HOLES.flatMap((y, i) => [
  circle(`hole${i}`, W - EDGE, y, HOLE, { backgroundColor: col('cardInk') }),
  ...(i === 1 ? [] : [box(`prong${i}`, W - EDGE - 1.1, y - 4.2, 2.2, 8.4,
    { backgroundColor: col('brassLight'), borderRadius: mm(1.1) })]),
]);
const stamp = (id, x, y, w, h) => [
  box(id, x, y, w, h,
    { borderColor: col('stamp'), borderWidth: pt(1.8), borderRadius: mm(1.5) }),
  box(`${id}-rim`, x + 1.4, y + 1.4, w - 2.8, h - 2.8,
    { borderColor: col('stamp'), borderWidth: pt(0.6), borderRadius: mm(1) }),
];
const typed = (size) => ({ fontFamily: TEXT, fontSize: pt(size), color: col('ink') });
// Tracked capitals, centred. 1.4.1 centres a tracked line with the tracking after its last
// letter, half a unit left of the middle: the box moves right by that half.
const caps = (id, content, x, y, w, size, track, color) => line(id, content,
  x + (track * PT) / 2, y, w, { align: 'center', fontFamily: 'Oswald', fontWeight: 500,
    fontSize: pt(size), letterSpacing: pt(track), textTransform: 'uppercase', color: col(color) });
const small = { fontFamily: TEXT, fontSize: pt(7.5), color: col('cardInk'), align: 'left' };
const LABEL = { w: 120, h: 64, y: 78 }; // mm: a white label, centred across the sheet
const LABEL_X = (W - LABEL.w) / 2;
const DRAFT = { w: 56, h: 20 }; // mm: under the label, flush with its right edge
[DRAFT.x, DRAFT.y] = [LABEL_X + LABEL.w - DRAFT.w, LABEL.y + LABEL.h + 9];
const center = { align: 'center' };
// span: 'page' on both: kept in the column, the card is clipped an inch from the top and foot.
const cover = { id: 'cover', span: 'page',
  advancedDesign: { enabled: true, slot: { elements: [
    card, ...heads,
    box('shadow', LABEL_X + 1.2, LABEL.y + 1.4, LABEL.w, LABEL.h,
      { backgroundColor: col('cardDark'), borderRadius: mm(2) }),
    box('label', LABEL_X, LABEL.y, LABEL.w, LABEL.h,
      { backgroundColor: col('paper'), borderRadius: mm(2) }),
    line('name', '{titleText}', LABEL_X, LABEL.y + 15, LABEL.w, { ...center,
      fontFamily: 'Special Elite', fontSize: pt(32), textTransform: 'uppercase',
      color: col('ink') }),
    line('credit', '{attr.credit}', LABEL_X, LABEL.y + 37, LABEL.w, { ...center, ...typed(12) }),
    line('author', '{author}', LABEL_X, LABEL.y + 44, LABEL.w, { ...center, ...typed(12) }),
    ...stamp('draft-box', DRAFT.x, DRAFT.y, DRAFT.w, DRAFT.h),
    caps('draft', '{attr.draft}', DRAFT.x, DRAFT.y + 3.6, DRAFT.w, 15, 2.2, 'stamp'),
    caps('date', '{attr.date}', DRAFT.x, DRAFT.y + 12.4, DRAFT.w, 8.5, 1.6, 'stamp'),
    caps('company', '{attr.company}', 0, 250, W, 10, 3, 'cardInk'),
  ] } } };
const COPY = { w: 64, h: 24, x: IN, y: IN + 28 }; // mm: the copy number, under the notice
const flyleaf = { id: 'flyleaf', span: 'page', // the inside of the cover, facing script page 1
  advancedDesign: { enabled: true, slot: { elements: [
    card, ...prongs,
    line('notice', '{attr.notice}', IN, IN, 120, { ...typed(10), lineHeight: 1.3,
      color: col('cardInk'), align: 'left', overflow: 'wrap' }),
    ...stamp('copy-box', COPY.x, COPY.y, COPY.w, COPY.h),
    caps('copy', '{attr.copy}', COPY.x, COPY.y + 6.2, COPY.w, 26, 3.5, 'stamp'),
    line('colophon', '{attr.colophon}', IN, 250, 150, small),
    line('fonts', '{attr.fonts}', IN, 254.5, 150, small),
  ] } } };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  colorPalette,
  page,
  layout: { layoutType: 'single' },
  bodyText,
  headings,
  headingStyles: [cover, flyleaf, title],
  paragraphStyles,
  calloutStyles: [dialogue],
  // Empty slots: by default the header prints the section's title at the top of each cover
  // and the footer a page number at the foot of every page.
  header: { elements: [] },
  footer: { elements: [] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Objetos perdidos"
author: "Maren Arrieta"
---

# Objetos perdidos {style="cover" credit="guion de" draft="Guion de rodaje" date="14 oct. 2026" company="Medialuz Producciones"}

# Aviso {style="flyleaf" notice="Este guion es propiedad de Medialuz Producciones y se presta al equipo técnico y artístico. Si lo encuentra, devuélvalo a la oficina de producción: calle del Huerto 40, Puerto Elera." copy="Copia 07" colophon="Guion original para el Recetario de Postext · CC BY 4.0" fonts="Compuesto en Courier Prime, Special Elite y Oswald"}

:::numbering{startAt=1}

# Objetos perdidos {style="script"}

FUNDIDO DE ENTRADA:

## INT. ESTACIÓN DE LA CALLE BRAÑOSA, VESTÍBULO — MAÑANA

La lluvia repiquetea en la cubierta de cristal. Los viajeros pasan en tropel bajo el panel de salidas.

TEO ANKRAH (24) avanza a contracorriente: esmoquin bajo un anorak empapado, la pajarita hecha un ovillo en un bolsillo, sin equipaje. Se para bajo un cartel. OBJETOS PERDIDOS, y una flecha escaleras abajo.

## INT. OFICINA DE OBJETOS PERDIDOS — CONTINUO

Un mostrador largo. Detrás, estanterías hasta el techo: paraguas por centenares, un patinete, un vestido de novia en su bolsa de tintorería, una garza disecada con resguardo.

DORA PELÁEZ (68), rebeca, gafas con cadenita, escribe en un libro registro con pluma estilográfica. No levanta la vista.

:::callout{type="dialogue" title="Teo"}
Buenos días. Me dejé un violonchelo en el último tren de Puerto Elera. El de las 23.40.
:::

:::callout{type="dialogue" title="Dora"}
:::paragraphs{style="paren"}
(pasando una página)
:::
¿Qué coche?
:::

:::callout{type="dialogue" title="Teo"}
El primero. Junto a la puerta.
:::

:::callout{type="dialogue" title="Dora"}
¿De qué color es la funda?
:::

:::callout{type="dialogue" title="Teo"}
Negra.
:::

:::callout{type="dialogue" title="Dora"}
Todas son negras.
:::

:::callout{type="dialogue" title="Teo"}
:::paragraphs{style="paren"}
(pausa)
:::
Tiene una pegatina en la tapa. Un faro, medio despegado.
:::

Dora lo apunta y le pasa un impreso de reclamación.

:::callout{type="dialogue" title="Dora"}
Nombre y dirección. En mayúsculas.
:::

Él lo rellena. Ella lo lee del revés mientras escribe.

:::callout{type="dialogue" title="Dora (cont.)"}
¿Y en el bolsillo de la tapa?
:::

Teo deja de escribir.

:::callout{type="dialogue" title="Teo"}
Resina. Un la de repuesto.
:::paragraphs{style="paren"}
(luego)
:::
Y una carta.
:::

:::callout{type="dialogue" title="Dora"}
¿Dirigida a quién?
:::

:::callout{type="dialogue" title="Teo"}
A nadie, todavía.
:::

Dora se quita las gafas. Lo mira.

:::callout{type="dialogue" title="Dora"}
Espere aquí.
:::

## INT. OFICINA DE OBJETOS PERDIDOS, ALMACÉN — CONTINUO

Los fluorescentes se encienden a trompicones, pasillo a pasillo. Bicicletas. Maletas. Un cajón de guantes desparejados con el rótulo IZQUIERDOS.

Dora recorre el pasillo y se detiene ante la funda de un violonchelo. Una pegatina con un faro, medio despegada. En el asa, una etiqueta parda: PUERTO ELERA 23.40, COCHE 1.

Abre el bolsillo de la tapa. Una pastilla de resina. Una cuerda en su sobre de papel. Un sobre blanco, cerrado, sin nada escrito.

Sopesa el sobre en la mano y vuelve a guardarlo.

## INT. OFICINA DE OBJETOS PERDIDOS — CONTINUO

Dora deja la funda sobre el mostrador. Teo va a cogerla. Ella mantiene la mano sobre la tapa.

:::callout{type="dialogue" title="Dora"}
Tóqueme algo.
:::

:::callout{type="dialogue" title="Teo"}
¿Aquí?
:::

:::callout{type="dialogue" title="Dora"}
En primavera un señor reclamó un arpa. No supo decirme cuántas cuerdas tenía.
:::

A la espalda de Teo se ha formado una cola: una MUJER con un carrito de bebé, un ADOLESCENTE con una sola bota de fútbol.

Teo abre los cierres, se sienta en el borde de una silla de plástico y toca los primeros compases del preludio de la primera suite para violonchelo de Bach.

Silencio en la oficina. El adolescente baja la bota.

Dora espera a que acabe la frase. Luego estampa un sello en el impreso: DEVUELTO.

:::callout{type="dialogue" title="Dora"}
Firme aquí.
:::

Él firma. Ella saca el sobre del bolsillo y se lo tiende.

:::callout{type="dialogue" title="Dora (cont.)"}
Y eche esto al correo. En el buzón de la entrada recogen a las cinco.
:::

:::callout{type="dialogue" title="Teo"}
:::paragraphs{style="paren"}
(sonriendo)
:::
No he escrito para quién es.
:::

:::callout{type="dialogue" title="Dora"}
Tiene hasta las cinco.
:::

:::paragraphs{style="transition"}
CORTE A:
:::

## EXT. ESTACIÓN DE LA CALLE BRAÑOSA — DÍA

Ha dejado de llover. Teo, con el violonchelo a la espalda, se para ante un buzón amarillo: ÚLTIMA RECOGIDA 17.00 h.

Escribe un nombre en el sobre, lo sostiene un momento en la ranura y lo suelta.

Se sube el violonchelo al hombro y va hacia el autobús.

:::paragraphs{style="transition"}
FUNDIDO A NEGRO.
:::
`; // content.<lang>.md, inlined by the Cookbook

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = {
  'Courier Prime': ['400', '700'], // the script, the typed label and the colophon
  'Special Elite': ['400'], // the title typed on the label
  Oswald: ['500'], // the stamps and the company
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown);
showPages(doc, { title: t({ en: 'Lost Property · a short film',
  es: 'Objetos perdidos · un cortometraje' }) });

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

## Variantes

### Devuelve las réplicas a la rejilla

Cada réplica conserva entonces su margen inferior y la siguiente le suma el superior, lo que deja dos líneas en blanco entre réplicas (tres antes de un encabezado de escena) y lleva el guion a una cuarta página.

```diff
-  snapToGrid: false,
 };
```

### Escribe los encabezados de escena sin negrita

A máquina, los encabezados de escena se escribían solo en mayúsculas; la negrita llegó con los programas de guion.

```diff
-  fontSize: pt(12), fontWeight: 700, lineHeight: 1, color: col('ink'), align: 'left',
+  fontSize: pt(12), fontWeight: 400, lineHeight: 1, color: col('ink'), align: 'left',
```

```diff
-      fontWeight: 700, lineHeight: 1, color: col('ink'), align: 'left', overflow: 'wrap',
+      fontWeight: 400, lineHeight: 1, color: col('ink'), align: 'left', overflow: 'wrap',
```

## Errores frecuentes

- **El equilibrado de columnas baja al pie el recuadro que cierra una página.** Cuando un recuadro es el último bloque de una página tras la que sigue el texto, el equilibrado de columnas (activo por defecto) pasa el espacio que queda debajo por encima del recuadro, que se separa del bloque anterior y termina en la última línea de la página. En postext 1.4.1 ninguna opción del equilibrado lo desactiva por separado: headings.balancing.enabled: false apaga todo el equilibrado, lo que conviene a páginas que deben terminar cortas, como una página de poemas.
- **En el texto en bandera no se evitan las líneas cortas.** optimalLineBreaking, avoidRunts, runtPenalty y runtMinCharacters actúan sobre el algoritmo de Knuth–Plass, que postext 1.4.1 solo aplica al texto justificado. Un párrafo en bandera se corta línea a línea y puede terminar en una sola palabra corta, digan lo que digan esos ajustes. Revisa las últimas líneas del texto en bandera y reescribe el párrafo que acabe en una línea corta.
- **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.
- **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.

- Postext no imprime `(MÁS)` bajo una réplica que se parte entre dos páginas ni repite el nombre sobre el resto. Deja `keepTogether` con su valor por defecto para que cada réplica pase entera a la página siguiente.
- Los programas de guion añaden `(CONT.)` al nombre automáticamente. Aquí va en el `title` de la valla, así que escríbelo tú donde un personaje sigue hablando después de una acción.
- El relleno de −2,4 pt vale para un título del mismo cuerpo que el texto. Un título más grande o más pequeño va en otra línea, de 1,2 veces su propio cuerpo: vuelve a calcular el relleno como el interlineado menos esa línea.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Tipografías: Courier Prime (OFL-1.1), Special Elite (Apache-2.0), Oswald (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 070 · Obra de teatro: reparto, personajes y acotaciones](https://postext.dev/es/cookbook/stage-play.md): 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. · Nivel 2 (Intermedio) · Narrativa, teatro y prosa literaria
- [N.º 045 · Títulos al margen, números colgados y títulos en línea](https://postext.dev/es/cookbook/side-heads-hanging-numbers.md): Bases de concurso con los títulos de sección en un canal al margen, sobre las líneas base del texto, números colgados en el medianil y títulos en línea en rojo. · Nivel 3 (Avanzado) · Informes y memorias
- [N.º 048 · Listados de código y teclas sin bloques de código](https://postext.dev/es/cookbook/code-listings-and-keycaps.md): Guía de la terminal cuyos bloques de código pasan a ser recuadros oscuros antes de componer, con la negrita y la cursiva como colores y las teclas como chips. · Nivel 2 (Intermedio) · Manuales, guías y obras de consulta
