# Drehbuchformat

> Ein Drehbuch in Courier Prime 12 auf US Letter, jede Replik ein rahmenloser Kasten, eingerückt auf den Dialogblock, jede Szene in beiden Rändern nummeriert.

- HTML-Fassung: https://postext.dev/de/cookbook/screenplay-format
- Rezept Nº 061 · Schrift und Text · Stufe 2 (Mittel) · Ausgaben: Canvas
- Gattungen: Jedes Genre
- Benötigt postext ≥ 1.25.0 · getestet mit 1.31.0 am 2026-10-13
- Seiten: [1](https://postext.dev/cookbook/screenplay-format/en/p01.webp?v=76ef1d22), [2](https://postext.dev/cookbook/screenplay-format/en/p02.webp?v=76ef1d22), [1](https://postext.dev/cookbook/screenplay-format/en/p03.webp?v=76ef1d22), [2](https://postext.dev/cookbook/screenplay-format/en/p04.webp?v=76ef1d22), [3](https://postext.dev/cookbook/screenplay-format/en/p05.webp?v=76ef1d22)
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=screenplay-format&lang=en (.postext: https://postext.dev/cookbook/screenplay-format/en/screenplay-format.postext)
- Zuletzt aktualisiert: 2026-10-11
- Andere Sprachen: [en](https://postext.dev/en/cookbook/screenplay-format.md), [es](https://postext.dev/es/cookbook/screenplay-format.md), [ca](https://postext.dev/ca/cookbook/screenplay-format.md), [fr](https://postext.dev/fr/cookbook/screenplay-format.md), [it](https://postext.dev/it/cookbook/screenplay-format.md), [pt](https://postext.dev/pt/cookbook/screenplay-format.md), [zh](https://postext.dev/zh/cookbook/screenplay-format.md), [ja](https://postext.dev/ja/cookbook/screenplay-format.md), [ar](https://postext.dev/ar/cookbook/screenplay-format.md)

## Kurz gesagt

Das Drehbuch eines Kurzfilms, so gestaltet, wie Filmstudios es erwarten. Es verwendet eine Schrift im Stil einer Schreibmaschine, mit Repliken und Szenennummern in festen Abständen vom Rand.

## Was Sie setzen

Die Drehfassung von *Lost Property*, einem erfundenen dreiseitigen Kurzfilm, gebunden wie Studiodrehbücher: ein goldgelber Kartonumschlag mit zwei Messingklammern und einem getippten Etikett, innen mit „COPY 07“ gestempelt, dann das Drehbuch, 12 auf 12 in der Courier Prime auf gelochtem US Letter gesetzt. Die Satzbreiten sind die der Drehbuchsoftware: Handlung von 1,5 bis 7,5 Zoll, eine Replik von 2,5 bis 6 Zoll unter dem Namen der Figur bei 3,7, eine Regieanweisung in Klammern ab 3,1, und „CUT TO:“ und „FADE OUT.“ enden am rechten Rand. Jede Szenenüberschrift trägt ihre Nummer in beiden Rändern. Seite 1 des Drehbuchs ist nicht nummeriert; die übrigen drucken ihre Nummer oben rechts, mit Punkt. Jede Replik ist ein Kasten ohne Rahmen, mit dem Namen der Figur auf dem Zeilenabstand des Grundtextes von 12 pt, sodass Handlung und Dialog sechs Zeilen pro Zoll behalten.

**Dieses Rezept beantwortet:**

- Wie setze ich ein Drehbuch mit nummerierten Szenen und jeder Replik in einem 3,5-Zoll-Block unter ihrem Namen?

## Die kurze Antwort

```js
// script.js, Zeilen 43–64
// 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 dialogue = {
  id: 'dialogue',
  backgroundEnabled: false, // no fill and no border: the box is only a measure
  padding: { top: pt(0), bottom: pt(0),
    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). On the
  // body's 12-pt leading (the default is 1.2 times its size, 14.4 pt), the name sits on a
  // grid line and a speech is a whole number of lines.
  titleStyle: { fontWeight: 400, textTransform: 'uppercase', lineHeight: pt(LEAD),
    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,
};
```

## Zutaten

**Zeigt**

- [Kästen](https://postext.dev/de/docs/configuration-styles.md#kastenstile): Benannte Kastenformate für Hinweise, Tipps und Warnungen: Hintergrund, Rahmen, Radius, Randstreifen, Titel und eigene Typografie für Inhalt und Listen.
- [Das Raster bewusst verlassen](https://postext.dev/de/docs/architecture.md#rasterbrechende-elemente): Überschriften, Absatzformate und gestapelte Kästen können exakte Abstände abseits des Rasters behalten; der Text danach rastet wieder darauf ein.
- [Absatzformate](https://postext.dev/de/docs/configuration-styles.md#absatzformate): Benannte Formate für Folgen von Absätzen, die sich vom Fließtext abheben: Verse, Mottos, Widmungen, Unterschriften, Kleingedrucktes.

**Verwendet außerdem**

- [Nummerierte Überschriften](https://postext.dev/de/docs/configuration-text.md#überschreibungen-pro-ebene)
- [Überschriftenformate](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Gestaltete Auftakte](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung)
- [Überschriftenattribute](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)
- [Umschläge, Titelseiten und Impressum](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Kopfzeilen nach Seitenrolle](https://postext.dev/de/docs/configuration-page-layout.md#textelemente)
- [Kolumnentitel und Seitenzahlen](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen)
- [Kolumnentitel je Abschnitt](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Text, Linien und Kästen in Seitengestaltungen](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen)
- [Endformat](https://postext.dev/de/docs/configuration-page-layout.md#vorgegebene-seitenformate)
- [Einheiten und Maße](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#maße)
- [Grundschrift](https://postext.dev/de/docs/configuration-text.md#fließtext)
- [Einzüge, Ausrichtung und Absatzabstände](https://postext.dev/de/docs/configuration-text.md#fließtext)
- [Spaltenausgleich](https://postext.dev/de/docs/configuration-text.md#spaltenausgleich)
- [Semantische Farbpalette](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette)
- [Seiten auf einem Canvas](https://postext.dev/de/docs/configuration-programmatic-usage.md#eine-seite-als-bitmap-rendern)
- [Römisch paginierte Titelei](https://postext.dev/de/docs/document-format.md#numbering)

**Die Konfiguration auf einen Blick**

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

**APIs**

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

**Schriften**

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

## Zubereitung

### 1 · Einen Kasten ohne Rahmen bis zum Dialogblock einrücken

Der Code steht oben in [der kurzen Antwort](https://postext.dev/de/cookbook/screenplay-format.md#die-kurze-antwort). Der Innenabstand eines Kastens wird vom Satzspiegel aus gemessen, sodass 1 Zoll links und 1,5 Zoll rechts den Block von 3,5 Zoll lassen, der 2,5 Zoll von der Blattkante beginnt ([Kastenformate](https://postext.dev/de/docs/configuration-styles.md#kastenstile)). Der Name der Figur ist der Titel des Kastens, im Block als `title="Dora (cont’d)"` geschrieben. `textTransform` druckt ihn in Versalien, und `indent` rückt ihn 1,2 Zoll über den Innenabstand hinaus, auf 3,7 Zoll. `gap` ist 0, weil der Standardwert ein halbes Geviert, 6 pt, zwischen Name und Replik lässt. Standardmäßig steht ein Kastentitel auf einer Zeile vom 1,2-Fachen seines Grades, hier 14,4 pt. `titleStyle.lineHeight` setzt ihn stattdessen auf die 12 pt des Grundtextes, sodass der Name auf einer Rasterzeile steht und die Replik eine ganze Zahl von 12-pt-Zeilen umfasst; beim Standardwert schöbe jede Replik den Text danach 2,4 pt weiter vom Raster. Mit abgeschaltetem `snapToGrid` fallen der untere Abstand einer Replik und der obere der nächsten zu einer einzigen Leerzeile zusammen. Ein Kasten wird standardmäßig zusammengehalten, sodass eine Replik, die nicht mehr passt, mit ihrem Namen auf die nächste Seite wandert.

### 2 · Die Seite in Zoll anlegen

```js
// script.js, Zeilen 33–39
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
```

US Letter mit dem linken Rand von 1,5 Zoll, durch den die Klammern gehen, und einem Zoll auf den anderen drei Seiten. Die Courier Prime ist eine dicktengleiche Schrift: In 12 pt ist jeder Buchstabe ein Zehntelzoll breit, sodass eine Handlungszeile 60 Zeichen fasst und eine Replik 35, und bei 12 pt Zeilenabstand fasst die Seite 54 Zeilen. Der Text steht linksbündig im Flattersatz, und `paragraphSpacing` setzt eine Leerzeile zwischen die Absätze. Flattersatz wird nur getrennt, wenn `bodyText.hyphenation.ragged` es verlangt, sodass im Drehbuch kein Wort getrennt wird, wie es das Format fordert.

### 3 · Regieanweisungen und Übergänge zu Absatzformaten machen

```js
// script.js, Zeilen 68–72
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
];
```

Ein Container `:::paragraphs{style="paren"}` in einer Replik zieht seine erste Zeile 0,6 Zoll über die Dialogkante hinaus ein, auf 3,1 Zoll ([Absatzformate](https://postext.dev/de/docs/configuration-styles.md#absatzformate)). Halten Sie Regieanweisungen in Klammern bei einer Zeile: Das Format zieht nur die erste Zeile ein. `body.paragraphSpacing: false` im Dialogformat hält die Leerzeile der Handlungsabsätze aus dem Kasten heraus, sodass `(then)` direkt unter der Zeile davor steht. `CUT TO:` und `FADE OUT.` stehen in Containern `transition`, deren `textAlign: 'right'` sie bündig mit dem Ende der Handlungszeilen setzt. Ein Container führt den Fluss nach seinem letzten Absatz auf das Grundlinienraster zurück; dieses Drehbuch verlässt das Raster nie, deshalb behält die Überschrift nach `CUT TO:` ihre zwei Leerzeilen.

### 4 · Die Szenennummern in beide Ränder hängen

```js
// script.js, Zeilen 76–105
// 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 the sluglines to fill them.
  balancing: { enabled: false },
  levels: [
    // 'any': the cover, its inside and the title each open the next page, with no blank.
    { level: 1, breakBefore: { enabled: true, parity: 'any' } },
    slugline,
  ] };
```

`numberingTemplate: '{2}'` zählt die Überschriften der Ebene 2, und `{number}` druckt die Zählung in die Gestaltung ([Einstellungen pro Ebene](https://postext.dev/de/docs/configuration-text.md#überschreibungen-pro-ebene)). Eine Überschrift mit erweiterter Gestaltung behält ihren Platz in der Spalte und zeichnet statt ihres Textes ihre Elemente ([Seitenbreite und erweiterte Gestaltung](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung)), sodass die beiden Nummern in den Rändern neben der fetten Überschrift stehen können. Jede ist am eigenen Kasten der Überschrift verankert und aus ihm hinausgeschoben, 0,6 Zoll nach links und rechts einen Viertelzoll hinter das Textende, in einem Kasten von einem halben Zoll Breite mit linksbündigem Text, sodass in einem Drehbuch mit mehr als neun Szenen ein- und zweistellige Nummern an derselben Stelle beginnen. Der eigene Text der Überschrift wird nicht gemalt, aber gemessen, deshalb steht die Ebene auf 12 pt: Bei den standardmäßigen 15 pt würde die Überschrift von Szene 1 umbrechen und eine zweite Zeile reservieren. Der Spaltenausgleich ist aus: Er würde über den Szenenüberschriften Leerzeilen einfügen, um eine kurze Seite zu füllen, und eine Drehbuchseite endet dort, wo ihre letzte Replik oder ihr letzter Absatz endet.

### 5 · Nur die Seiten des Drehbuchs lochen und nummerieren

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

Das Titelformat des Drehbuchs trägt seine eigene Kopfzeile, `header`, die die des Dokuments auf den Seiten seines Abschnitts ersetzt, vom Titel bis zum Ende ([Überschriftenformate](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)). Die eigene Kopfzeile des Dokuments ist leer, deshalb erscheinen Löcher und Seitenzahl nur im Drehbuch. Die Seitenzahl ist einen halben Zoll unter der Oberkante des Blattes verankert, und `pages: 'body'` hält sie von Eröffnungsseiten fern ([Textelemente](https://postext.dev/de/docs/configuration-page-layout.md#textelemente)). Der Titel ist eine Überschrift der Ebene 1, die einen Seitenumbruch auslöst, Seite 1 ist also eine Eröffnungsseite und druckt keine Nummer, wie es das Format verlangt. Auch die Umschlagseiten sind Überschriften der Ebene 1; `:::numbering{startAt=1}` zwischen der Innenseite des Umschlags und dem Titel gibt der Seite des Titels die Nummer 1, und die nächste Seite druckt 2. Das Titelformat setzt eine Zeile von 12 pt; mit den eigenen 18 pt der Ebene 1 säße die Überschrift auf einer Zeile von 21,6 pt und schöbe „FADE IN:“ 9,6 pt nach unten.

## Das ganze Rezept

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

- Quellordner: https://github.com/drnachio/postext/tree/main/cookbook/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.25.0
import { buildDocumentWithFonts, renderPageToCanvas } from 'https://esm.sh/postext';

const LANG = 'en'; // @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)
};
// Every colour is linked to its palette entry by id.
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
// #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 dialogue = {
  id: 'dialogue',
  backgroundEnabled: false, // no fill and no border: the box is only a measure
  padding: { top: pt(0), bottom: pt(0),
    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). On the
  // body's 12-pt leading (the default is 1.2 times its size, 14.4 pt), the name sits on a
  // grid line and a speech is a whole number of lines.
  titleStyle: { fontWeight: 400, textTransform: 'uppercase', lineHeight: pt(LEAD),
    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 the sluglines to fill them.
  balancing: { enabled: false },
  levels: [
    // 'any': the cover, its inside and the title each open the next page, with no blank.
    { 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 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 on a box of width w.
const caps = (id, content, x, y, w, size, track, color) => line(id, content, x, 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 would end at the foot of the text block.
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 = () => ({
  colorPalette,
  locale: t({ en: 'en-us', es: 'es' }), // the script's language, and its bundle's
  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: "Lost Property"
author: "Maren Arrieta"
---

# Lost Property {style="cover" credit="written by" draft="Shooting draft" date="Oct 14 2026" company="Half-Light Pictures"}

# Notice {style="flyleaf" notice="This script is the property of Half-Light Pictures and is lent to the cast and crew. If found, please return it to the production office, 40 Orchard Lane, Port Ellery." copy="Copy 07" colophon="An original screenplay for the Postext Cookbook · CC BY 4.0" fonts="Set in Courier Prime, Special Elite and Oswald"}

:::numbering{startAt=1}

# Lost Property {style="script"}

FADE IN:

## INT. BRANNOCK STREET STATION, CONCOURSE — MORNING

Rain drums on the glass roof. Commuters stream under the departures board.

TOBY ANKRAH (24) pushes the other way: dinner jacket under a wet anorak, a bow tie stuffed in one pocket, no luggage. He stops under a sign. LOST PROPERTY, and an arrow down a flight of stairs.

## INT. LOST PROPERTY OFFICE — CONTINUOUS

A long counter. Behind it, shelves to the ceiling: umbrellas by the hundred, a child’s scooter, a wedding dress in a dry cleaner’s bag, a stuffed heron wearing a claim ticket.

DORA PELL (68), cardigan, glasses on a chain, writes in a ledger with a fountain pen. She does not look up.

:::callout{type="dialogue" title="Toby"}
Morning. I left a cello on the last train from Port Ellery. The 11:40.
:::

:::callout{type="dialogue" title="Dora"}
:::paragraphs{style="paren"}
(turning a page)
:::
Which car?
:::

:::callout{type="dialogue" title="Toby"}
First car. On the rack by the door.
:::

:::callout{type="dialogue" title="Dora"}
Color of the case?
:::

:::callout{type="dialogue" title="Toby"}
Black.
:::

:::callout{type="dialogue" title="Dora"}
They’re all black.
:::

:::callout{type="dialogue" title="Toby"}
:::paragraphs{style="paren"}
(beat)
:::
There’s a sticker on the lid. A lighthouse, half peeled off.
:::

Dora writes it down. She slides a claim form across to him.

:::callout{type="dialogue" title="Dora"}
Name and address. Capitals.
:::

He fills it in. She reads it upside down as he writes.

:::callout{type="dialogue" title="Dora (cont’d)"}
The lid has a pocket. What’s in it?
:::

Toby stops writing.

:::callout{type="dialogue" title="Toby"}
Rosin. A spare A string.
:::paragraphs{style="paren"}
(then)
:::
And a letter.
:::

:::callout{type="dialogue" title="Dora"}
Addressed to?
:::

:::callout{type="dialogue" title="Toby"}
Nobody yet.
:::

Dora takes off her glasses. She looks at him.

:::callout{type="dialogue" title="Dora"}
Wait here.
:::

## INT. LOST PROPERTY OFFICE, STORE ROOM — CONTINUOUS

Strip lights stutter on, one bay at a time. Bicycles. Suitcases. A crate of single gloves marked LEFT.

Dora walks the aisle and stops at a cello case. A lighthouse sticker, half peeled off. A brown tag on the handle reads PORT ELLERY 11:40 PM, CAR 1.

She unzips the pocket in the lid. A cake of rosin. A string in its paper sleeve. A white envelope, sealed, with nothing written on it.

She weighs the envelope in her hand, then puts it back.

## INT. LOST PROPERTY OFFICE — CONTINUOUS

Dora lays the case on the counter. Toby reaches for it. She keeps her hand on the lid.

:::callout{type="dialogue" title="Dora"}
Play me something.
:::

:::callout{type="dialogue" title="Toby"}
Here?
:::

:::callout{type="dialogue" title="Dora"}
Last spring a man claimed a harp. He couldn’t tell me how many strings it had.
:::

A line has formed behind Toby: a WOMAN with a stroller, a TEENAGER holding one soccer cleat.

Toby unlatches the case, sits on the edge of a plastic chair and plays the opening bars of the Prelude from Bach’s first cello suite.

The office goes quiet. The teenager lowers the cleat.

Dora waits for the end of the phrase. Then she stamps the form: RETURNED.

:::callout{type="dialogue" title="Dora"}
Sign there.
:::

He signs. She takes the envelope from the lid pocket and holds it out.

:::callout{type="dialogue" title="Dora (cont’d)"}
And mail that. The box by the doors gets picked up at five.
:::

:::callout{type="dialogue" title="Toby"}
:::paragraphs{style="paren"}
(smiling)
:::
I haven’t written who it’s for.
:::

:::callout{type="dialogue" title="Dora"}
You’ve got till five.
:::

:::paragraphs{style="transition"}
CUT TO:
:::

## EXT. BRANNOCK STREET STATION — DAY

The rain has stopped. Toby, the cello on his back, stops at a blue mailbox. The plate says LAST PICKUP 5:00 PM.

He writes a name on the envelope, holds it at the slot for a moment and lets go.

He hitches up the cello and heads for the bus stop.

:::paragraphs{style="transition"}
FADE OUT.
:::
`; // 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 ───────────────────────────────────────────────────────
const doc = await buildDocumentWithFonts({ markdown }, config(), kitFonts(FONTS));
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 v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout (prepareFonts,
// buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the
// files the PDF embeds too.

/** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts:
 *  the Fontsource resolver, and the faces of FONTS loaded with the ones the
 *  config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */
function kitFonts(FONTS = {}) {
  const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) =>
    ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' })));
  return { resolve: fontsourceResolver, faces };
}

/** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek
 *  when `text` needs them and the family ships them (kitSubsetsFor), each with
 *  its unicode-range. Null for a weight or slant Fontsource does not ship. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null;
  kitStatus('Loading fonts…');
  const at = await fontsourceAt(id);
  return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({
    source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`,
    unicodeRange: kitRanges()[subset],
  }));
}

/** The unicode-range of each Fontsource file the kit loads (a function: the
 *  kit is inlined after the pen's code, which runs before a const would be set). */
function kitRanges() {
  return {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
    greek: 'U+0370-03FF',
  };
}

/** For the cjk, arabic and comics blocks, which add their own files: the
 *  faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  The files are added once all have loaded, in the order asked (latin, then
 *  latin-ext, then greek), whatever order they arrive in: the last one added
 *  wins a character two files hold. Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = kitRanges();
  const jobs = [];
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'])
      .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await
    const at = fontsourceAt(id);
    const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null;
    const subsets = ['latin', ...kitSubsetsFor(text, meta)];
    for (const [weight, style] of todo) {
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`;
        const face = new FontFace(family, `url(${url}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: ranges[subset] });
        jobs.push(face.load().catch(() => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return ready.filter((face) => face && document.fonts.add(face)).length;
}

/** A loaded FontFace covers this family, weight and style (fonts.check() would
 *  also say yes for families nobody declared). */
function hasFace(family, weight, style) {
  for (const face of document.fonts) {
    if (face.status !== 'loaded' || face.style !== style) continue;
    if (face.family.replace(/^["']|["']$/g, '') !== family) continue;
    const [low, high = low] = face.weight.split(' ').map(Number);
    if (weight >= low && weight <= high) return true;
  }
  return false;
}

/** The files beyond latin `text` needs that `meta`'s family ships. */
function kitSubsetsFor(text, meta) {
  return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']]
    .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x);
}

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

/** A package's folder at the one version @5 names now (#766). */
function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) {
  return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json())
    .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`);
}

/** The family's Fontsource metadata (weights, styles, subsets), or null. */
function fontsourceMeta(family) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

// ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook
/** The pages as spreads on a dark desk, page 1 alone, then verso | recto,
 *  each painted when it scrolls near. Other options go to renderPageToCanvas. */
function showPages(docs, { title, width = 460, ...render } = {}) {
  const root = viewer(title);
  const pages = [docs].flat().flatMap((doc) =>
    doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index })));
  const spreads = [];
  let verso = null;
  for (const p of pages) {
    if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; }
    else { spreads.push([verso, p]); verso = null; }
  }
  if (verso) spreads.push([verso, null]);
  const density = Math.min(window.devicePixelRatio || 1, 2);
  showPages.painter?.disconnect();
  const painter = new IntersectionObserver((entries) => {
    for (const { isIntersecting, target } of entries) {
      if (!isIntersecting) continue;
      painter.unobserve(target);
      const { doc, page } = target.postext;
      renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width });
    }
  }, { rootMargin: '800px' });
  showPages.painter = painter;
  root.replaceChildren(...spreads.map((pair) => {
    const spread = document.createElement('div');
    spread.className = 'pt-spread';
    for (const p of pair) {
      const figure = document.createElement('figure');
      if (p) {
        const label = p.page.pageLabel || String(p.n + 1);
        const canvas = document.createElement('canvas');
        canvas.postext = p;
        canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`;
        canvas.setAttribute('role', 'img');
        canvas.setAttribute('aria-label', `Page ${label}`);
        const folio = document.createElement('figcaption');
        folio.textContent = label;
        figure.append(canvas, folio);
        painter.observe(canvas);
      } else figure.className = 'pt-blank';
      spread.append(figure);
    }
    return spread;
  }));
  kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`);
  document.documentElement.dataset.postext = 'ready';
  return pages.length;
}

/** The desk, the bar and the error reporting, created once. */
function viewer(title) {
  if (!document.getElementById('pt-kit')) {
    document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit">
      :root { color-scheme: dark; }
      body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; }
      #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center;
        gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px);
        border-bottom: 1px solid #23262d; }
      #pt-bar strong { color: #f4f1ea; font-weight: 600; }
      #pt-actions { display: flex; gap: 12px; margin-left: auto; }
      #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
      #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; }
      .pt-spread { display: flex; }
      .pt-spread figure { margin: 0; width: min(460px, 44vw); }
      .pt-spread canvas { display: block; width: 100%; background: #fff;
        box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif;
        letter-spacing: .18em; text-transform: uppercase; color: #6c7079; }
      .pt-blank { visibility: hidden; }
      @media (max-width: 760px) {
        .pt-spread { flex-direction: column; gap: 32px; }
        .pt-spread figure { width: min(460px, 92vw); }
        .pt-blank { display: none; }
      }
    </style>`);
    document.body.insertAdjacentHTML('afterbegin',
      '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>');
    document.getElementById('pt-title').textContent = document.title || 'Postext';
    addEventListener('error', (event) => kitFail(event.error ?? event.message));
    addEventListener('unhandledrejection', (event) => kitFail(event.reason));
  }
  if (title) document.getElementById('pt-title').textContent = title;
  return document.getElementById('pages')
    ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' }));
}

function kitStatus(text) {
  viewer();
  document.getElementById('pt-status').textContent = text;
}

function kitFail(error) {
  document.documentElement.dataset.postext = 'error';
  kitStatus(`Error: ${error?.message ?? error}`);
}

// ─── /Kit ───────────────────────────────────────────────────────────────────────
```

## Varianten

### Die Repliken wieder ins Raster einrasten lassen

Jede Replik behält dann ihren unteren Abstand, und die nächste fügt ihren oberen hinzu, was zwei Leerzeilen zwischen Repliken lässt (drei vor einer Szenenüberschrift) und das Drehbuch auf eine vierte Seite schiebt.

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

### Die Szenenüberschriften in normaler Schriftstärke tippen

Mit der Schreibmaschine geschriebene Drehbücher hatten ihre Szenenüberschriften in schlichten Versalien; Fett kam mit der Drehbuchsoftware.

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

## Häufige Fehler

- Postext druckt kein `(MORE)` unter eine Replik, die über Seiten umbricht, und wiederholt den Namen nicht über ihrem Rest. Lassen Sie `keepTogether` auf seinem Standardwert, damit eine Replik ganz auf die nächste Seite wandert.
- Drehbuchsoftware fügt `(CONT'D)` automatisch an einen Namen an. Hier ist es Teil des `title` im Block, tippen Sie es also selbst, wo die Replik einer Figur nach einer Handlungszeile weitergeht.
- `titleStyle.lineHeight` hält eine Replik nur so lange auf dem Raster, wie es dem Zeilenabstand des Grundtextes entspricht. Ein Name, der größer oder kleiner als der Text gesetzt ist, nimmt trotzdem diese 12 pt ein, sonst schiebt jede Replik die Zeilen danach vom Raster.

## Nachweise

- Rezept: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Schrift: Courier Prime (OFL-1.1), Special Elite (Apache-2.0), Oswald (OFL-1.1)
- Code: MIT · Beispielinhalt: CC-BY-4.0

## Verwandte Rezepte

- [Nº 070 · Theatertext: Personenverzeichnis, Sprecher, Regieanweisungen](https://postext.dev/de/cookbook/stage-play.md): Wildes Stück als Bühnenfassung: Namen fett in Pflaumenrot, Regieanweisungen grau kursiv, ein Personenverzeichnis mit Punktlinie zu jedem Darsteller am Tabstopp. · Stufe 2 (Mittel) · Belletristik, Drama und Prosa
- [Nº 045 · Marginalüberschriften, hängende Nummern, eingebundene Titel](https://postext.dev/de/cookbook/side-heads-hanging-numbers.md): Eine Wettbewerbsauslobung mit Abschnittstiteln in einer Randspalte auf den Grundlinien des Textes, hängenden Unternummern und roten Anfangsbegriffen. · Stufe 3 (Fortgeschritten) · Berichte
- [Nº 048 · Codelistings und Tastenkappen](https://postext.dev/de/cookbook/code-listings-and-keycaps.md): Ein Shell-Leitfaden, dessen Codeblöcke als dunkle Listing-Kästen gesetzt sind, nach Token eingefärbt, mit Tasten als Chips in Form von Tastenkappen. · Stufe 2 (Mittel) · Handbücher und Nachschlagewerke
