# Format de guió cinematogràfic

> Guió de rodatge en Courier Prime 12 sobre paper carta, amb les rèpliques en requadres sense marc de l'amplada del diàleg i les escenes numerades als dos marges.

- Versió HTML: https://postext.dev/ca/cookbook/screenplay-format
- Recepta Núm. 061 · Text i tipografia · Nivell 2 (Intermedi) · Sortides: Canvas
- Gèneres: Qualsevol gènere
- Requereix postext ≥ 1.4.1 · provada amb 1.4.1 el 2026-09-26
- Pàgines: [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)
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=screenplay-format&lang=es (.postext: https://postext.dev/cookbook/screenplay-format/es/screenplay-format.postext)
- Última actualització: 2026-09-26
- Altres idiomes: [en](https://postext.dev/en/cookbook/screenplay-format.md), [es](https://postext.dev/es/cookbook/screenplay-format.md), [zh](https://postext.dev/zh/cookbook/screenplay-format.md), [ar](https://postext.dev/ar/cookbook/screenplay-format.md)

## En poques paraules

El guió d'un curtmetratge amb el format que fan servir els estudis de cinema. Porta una lletra de màquina d'escriure, i els diàlegs i els números d'escena van a distàncies fixes de la vora.

## Què compondràs

La versió de rodatge d'*Objetos perdidos*, un curtmetratge inventat de tres pàgines, enquadernada com els guions dels estudis: coberta de cartolina mostassa amb dos enquadernadors de llautó i etiqueta mecanografiada, l'interior segellat amb COPIA 07 i, al darrere, el guió en Courier Prime de 12 sobre 12 en paper carta foradat. Les mesures són les dels programes de guió: acció d'1,5 a 7,5 polzades; rèplica de 2,5 a 6, sota el nom del personatge a 3,7; acotació des de 3,1, i CORTE A: i FUNDIDO A NEGRO. fins al marge dret. Cada escena porta el seu número als dos marges. La pàgina 1 del guió no porta número; les altres, a dalt a la dreta i amb punt. Cada rèplica és un requadre sense marc que un farciment superior de −2,4 pt ajusta a línies senceres de 12 pt, i així acció i diàleg mantenen sis línies per polzada.

**Aquesta recepta respon a:**

- Com componc un guió amb escenes numerades i cada rèplica en un bloc de 3,5 polzades sota el nom?

## La resposta curta

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

## Ingredients

**Ensenya**

- [Requadres](https://postext.dev/ca/docs/configuration.md#estils-davís): Estils de requadre amb nom per a notes, consells i advertiments: fons, vora, radi, franja, títol i tipografia pròpia de text i llistes.
- [Sortir de la retícula a propòsit](https://postext.dev/ca/docs/architecture.md#elements-que-trenquen-la-retícula): Els títols, els estils de paràgraf i els requadres apilats poden conservar un espaiat exacte fora de la retícula; el text que els segueix hi torna.
- [Estils de paràgraf](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf): Estils amb nom per a grups de paràgrafs que s'aparten del text general: versos, epígrafs, dedicatòries, signatures, lletra petita.

**També fa servir**

- [Títols numerats](https://postext.dev/ca/docs/configuration.md#configuració-per-nivell)
- [Estils de títol](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Obertures dissenyades](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Cobertes, portades i colofons](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Capçaleres segons el tipus de pàgina](https://postext.dev/ca/docs/configuration.md#elements-de-text)
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Capçaleres per secció](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Textos, filets i caixes als dissenys de pàgina](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Format de pàgina](https://postext.dev/ca/docs/configuration.md#mides-de-pàgina-predefinides)
- [Unitats i mesures](https://postext.dev/ca/docs/configuration.md#dimensions)
- [Tipografia del text](https://postext.dev/ca/docs/configuration.md#text-de-cos)
- [Sagnats, alineació i separació de paràgrafs](https://postext.dev/ca/docs/configuration.md#text-de-cos)
- [Equilibri de columnes](https://postext.dev/ca/docs/configuration.md#equilibratge-de-columnes)
- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration.md#paleta-de-colors)
- [Pàgines en un canvas](https://postext.dev/ca/docs/configuration.md#renderitzar-una-pàgina-a-un-bitmap)
- [Preliminars en romans](https://postext.dev/ca/docs/document-format.md#numbering)

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

- [`bodyText`](https://postext.dev/ca/docs/configuration.md#text-de-cos), [`calloutStyles`](https://postext.dev/ca/docs/configuration.md#estils-davís), [`colorPalette`](https://postext.dev/ca/docs/configuration.md#paleta-de-colors), [`footer`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`header`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`headingStyles`](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament), [`headings`](https://postext.dev/ca/docs/configuration.md#encapçalaments), [`layout`](https://postext.dev/ca/docs/configuration.md#disposició), [`page`](https://postext.dev/ca/docs/configuration.md#pàgina), [`paragraphStyles`](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf)

**API**

- [`buildDocument`](https://postext.dev/ca/docs/configuration.md#construir-un-document), [`clearMeasurementCache`](https://postext.dev/ca/docs/configuration.md#memòria-cau-de-mesures), [`renderPageToCanvas`](https://postext.dev/ca/docs/configuration.md#renderitzar-una-pàgina-a-un-bitmap)

**Tipus de lletra**

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

## Elaboració

### 1 · Ajusta un requadre sense marc al bloc de diàleg

El codi és a [la resposta curta](#la-resposta-curta), més amunt. El farciment d'un requadre es mesura des de la caixa de text, així que 1 polzada a l'esquerra i 1,5 a la dreta deixen el bloc de 3,5 polzades que comença a 2,5 de la vora del full ([estils d'avís](/ca/docs/configuration#estils-davís)). El nom del personatge és el títol del requadre i s'escriu a la tanca: `title="Dora (cont.)"`. `textTransform` el passa a majúscules, i `indent` el porta 1,2 polzades més enllà del farciment, fins a 3,7. `gap` val 0 perquè, per defecte, entre el nom i la rèplica queda mig quadratí, 6 pt. A la 1.4.1 el títol d'un requadre es compon en una línia d'1,2 vegades el seu cos, 14,4 pt aquí, i `titleStyle` no té un interlineat propi que la canviï. El farciment superior de −2,4 pt descompta aquests 2,4 pt de més, de manera que la rèplica ocupa un nombre enter de línies de 12 pt i el nom queda 0,48 pt per sobre de la seva línia de la retícula. Amb el farciment a 0, cada nom baixa 1,92 pt, cada rèplica allunya 2,4 pt més de la retícula el text que la segueix, i l'encapçalament de l'escena 3, en tornar a la retícula, queda a 33,6 pt de la seva primera línia d'acció en lloc de 24. Amb `snapToGrid` desactivat, el marge inferior d'una rèplica i el superior de la següent es fonen en una sola línia en blanc. Un requadre es manté sencer per defecte, de manera que una rèplica que ja no hi cap passa a la pàgina següent amb el seu nom.

### 2 · Mesura la pàgina en polzades

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

Mida carta, amb el marge esquerre d'1,5 polzades per on passen els enquadernadors i una polzada als altres tres costats. Courier Prime és una lletra monoespaiada: a 12 pt cada caràcter fa una dècima de polzada, així que una línia d'acció admet 60 caràcters i una de diàleg 35, i amb un interlineat de 12 pt hi caben 54 línies a la pàgina. El text va en bandera, alineat a l'esquerra, i `paragraphSpacing` deixa una línia en blanc entre paràgrafs. Postext 1.4.1 només aplica la partició de mots al text justificat, així que cap paraula no es talla, com demana el format.

### 3 · Fes de les acotacions i les transicions estils de paràgraf

```js
// script.js, línies 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 contenidor `:::paragraphs{style="paren"}` dins d'una rèplica sagna la primera línia 0,6 polzades des de la vora del diàleg, fins a 3,1 ([estils de paràgraf](/ca/docs/configuration#estils-de-paràgraf)). Escriu cada acotació en una sola línia, perquè l'estil només sagna la primera. A l'estil del diàleg, `body.paragraphSpacing: false` deixa fora del requadre la línia en blanc que separa els paràgrafs d'acció, i `(luego)` queda just a sota de la línia anterior. `CORTE A:` i `FUNDIDO A NEGRO.` van en contenidors `transition`, el `textAlign: 'right'` dels quals els alinea amb el final de les línies d'acció. Un contenidor torna el text a la retícula de base després del seu últim paràgraf; aquest guió no en surt mai, així que l'encapçalament que segueix `CORTE A:` conserva les seves dues línies en blanc.

### 4 · Penja els números d'escena als dos marges

```js
// script.js, línies 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}'` compta els títols de nivell 2, i `{number}` imprimeix aquest compte al disseny ([configuració per nivell](/ca/docs/configuration#configuració-per-nivell)). Un títol amb disseny avançat conserva el seu lloc a la columna i dibuixa els seus elements en lloc del seu text ([span i disseny avançat](/ca/docs/configuration#span-i-disseny-avançat)), i així els dos números es poden quedar als marges, al costat de l'encapçalament en negreta. Cadascun s'ancora a la caixa del mateix títol i en surt: 0,6 polzades a l'esquerra i, a la dreta, un quart de polzada més enllà del final del text, en una caixa de mitja polzada amb el text alineat a l'esquerra, perquè en un guió de més de nou escenes els números d'una i de dues xifres comencin al mateix lloc. El text propi del títol no es pinta, però es mesura, i per això el nivell va a 12 pt: amb els 15 pt per defecte, l'encapçalament de l'escena 1 passaria a dues línies i reservaria la segona. L'equilibri de columnes està desactivat: afegeix línies en blanc per omplir les pàgines curtes, i amb ell la pàgina 1 del guió deixa una línia en blanc de més sobre l'encapçalament de l'escena 1.

### 5 · Forada i numera només les pàgines del guió

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

L'estil del títol del guió porta la seva pròpia capçalera (`header`), que substitueix la del document a les pàgines de la seva secció, del títol al final ([estils d'encapçalament](/ca/docs/configuration#estils-dencapçalament)). La capçalera del document és buida, així que els forats i el número de pàgina només surten al guió. El número s'ancora a mitja polzada de la vora superior del full, i `pages: 'body'` el deixa fora de les pàgines d'obertura ([elements de text](/ca/docs/configuration#elements-de-text)). El títol del guió és de nivell 1 i obre pàgina, així que la pàgina 1 compta com a pàgina d'obertura i no porta número, com demana el format. Les cobertes també són títols de nivell 1; `:::numbering{startAt=1}`, entre l'interior de la coberta i el títol, dona el número 1 a la pàgina del títol, i la següent porta el 2. L'estil del títol fixa una línia de 12 pt; amb els 18 pt propis del nivell 1, el títol aniria en una línia de 21,6 pt i baixaria FUNDIDO DE ENTRADA: 9,6 pt.

## La recepta completa

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

- Carpeta de la recepta: https://github.com/drnachio/postext/tree/main/cookbook/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 ───────────────────────────────────────────────────────────────────────
```

## Variants

### Torna les rèpliques a la retícula

Cada rèplica conserva llavors el seu marge inferior i la següent hi suma el superior, cosa que deixa dues línies en blanc entre rèpliques (tres abans d'un encapçalament d'escena) i porta el guió a una quarta pàgina.

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

### Escriu els encapçalaments d'escena sense negreta

A màquina, els encapçalaments d'escena s'escrivien només en majúscules; la negreta va arribar amb els programes de guió.

```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',
```

## Errors freqüents

- **L'equilibrat de columnes baixa al peu el requadre que tanca una pàgina.** Quan un requadre és l'últim bloc d'una pàgina després de la qual continua el text, l'equilibrat de columnes (actiu per defecte) passa l'espai que queda a sota per sobre del requadre, que se separa del bloc anterior i acaba a l'última línia de la pàgina. A postext 1.4.1 cap opció de l'equilibrat no el desactiva per separat: headings.balancing.enabled: false apaga tot l'equilibrat, cosa que convé a pàgines que han d'acabar curtes, com una pàgina de poemes.
- **En el text en bandera no s'eviten les línies curtes.** optimalLineBreaking, avoidRunts, runtPenalty i runtMinCharacters actuen sobre l'algorisme de Knuth–Plass, que postext 1.4.1 només aplica al text justificat. Un paràgraf en bandera es talla línia a línia i pot acabar en una sola paraula curta, diguin el que diguin aquestes opcions. Revisa les últimes línies del text en bandera i reescriu el paràgraf que acabi en una línia curta.
- **Qualsevol objecte headings desactiva el salt de pàgina de l'H1.** Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració.
- **Una paleta canviada no arriba als elements de disseny ni al color de les remissions.** postext 1.4.1 aplica colorPalette als estils de text (cos, títols, llistes, peus, taules, requadres), però no als elements de capçaleres, peus de pàgina, obertures i portadelles, ni a bodyText.referenceColor: conserven l'hex escrit al costat del seu paletteId. Si canvies la paleta, per a una edició de pantalla fosca o per recolorejar, reescriu cada color enllaçat a partir de colorPalette abans de compondre.

- Postext no imprimeix `(MÁS)` sota una rèplica que es parteix entre dues pàgines ni repeteix el nom sobre la resta. Deixa `keepTogether` amb el seu valor per defecte perquè cada rèplica passi sencera a la pàgina següent.
- Els programes de guió afegeixen `(CONT.)` al nom automàticament. Aquí va al `title` de la tanca, així que escriu-lo tu on un personatge continua parlant després d'una acció.
- El farciment de −2,4 pt serveix per a un títol del mateix cos que el text. Un títol més gran o més petit va en una altra línia, d'1,2 vegades el seu propi cos: torna a calcular el farciment com l'interlineat menys aquesta línia.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Tipus de lletra: Courier Prime (OFL-1.1), Special Elite (Apache-2.0), Oswald (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 070 · Obra de teatre: repartiment, personatges i acotacions](https://postext.dev/ca/cookbook/stage-play.md): L'inici de l'obra de Wilde per a actors. Els noms van en negreta pruna i les acotacions en cursiva grisa; el repartiment surt d'un text tabulat. · Nivell 2 (Intermedi) · Narrativa, teatre i prosa literària
- [Núm. 045 · Títols al marge, números penjats i títols en línia](https://postext.dev/ca/cookbook/side-heads-hanging-numbers.md): Bases de concurs amb els títols de secció en un canal al marge, sobre les línies de base del text, números penjats entre columnes i títols en línia en vermell. · Nivell 3 (Avançat) · Informes i memòries
- [Núm. 048 · Llistats de codi i tecles sense blocs de codi](https://postext.dev/ca/cookbook/code-listings-and-keycaps.md): Guia del terminal amb blocs de codi que passen a ser requadres foscos abans de compondre, amb la negreta i la cursiva com a colors i les tecles com a xips. · Nivell 2 (Intermedi) · Manuals, guies i obres de consulta
