En bref
Le scénario d’un court métrage mis en page comme l’attendent les studios de cinéma. Il utilise une police de machine à écrire, avec les répliques et les numéros de séquence à des distances fixes du bord.
Ce que vous allez composer
La version de tournage de Lost Property, un court métrage inventé de trois pages, relié comme le sont les scénarios des studios : une couverture en carte jaune d’or avec deux attaches parisiennes en laiton et une étiquette dactylographiée, son intérieur tamponné « COPY 07 », puis le scénario, composé en Courier Prime 12 sur 12 sur du papier US Letter perforé. Les justifications sont celles des logiciels d’écriture de scénario : l’action de 1,5 à 7,5 pouces, une réplique de 2,5 à 6 sous le nom du personnage à 3,7, une didascalie à partir de 3,1, et « CUT TO: » et « FADE OUT. » qui finissent à la marge droite. Chaque intitulé de séquence porte son numéro dans les deux marges. La page 1 du scénario n’est pas numérotée ; les autres impriment leur numéro en haut à droite, suivi d’un point. Chaque réplique est un encadré sans cadre, dont le nom du personnage est composé sur l’interlignage de 12 pt du texte courant : action et dialogue gardent ainsi six lignes au pouce.
Cette recette répond à
- Comment composer un scénario ou une pièce de théâtre : en-têtes de scène, noms des personnages et dialogues en retrait des deux côtés ?
La réponse courte
// 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,
};
Ingrédients
- Fonctionnalités
- EncadrésSortir de la grille volontairementStyles de paragrapheTitres numérotésStyles de titreOuvertures composéesAttributs de titreCouvertures, pages de titre et colophonsTitres courants selon le rôle de la pageTitres courants et foliosTitres courants par sectionTexte, filets et boîtes dans les maquettes de pageFormat de la pageUnités et dimensionsTexte courantRetraits, alignement et espacement des paragraphesÉquilibrage des colonnesPalette de couleurs sémantiquePages sur un canvas
- Utilise aussi
- Pages liminaires en chiffres romains
- Typographie
- Courier Prime, Oswald (SIL OFL 1.1) · Special Elite (Apache 2.0)
- Ressources
- Aucune : chaque image est dessinée en code
Préparation
#1 · Caler une boîte sans cadre sur le bloc de dialogue
Le code est dans la réponse courte plus haut. La marge interne d’un encadré se mesure depuis le bloc de texte : 1 pouce à gauche et 1,5 pouce à droite laissent le bloc de 3,5 pouces qui commence à 2,5 pouces du bord de la feuille (styles d’encadré). Le nom du personnage est le titre de la boîte, écrit dans le bloc sous la forme title="Dora (cont’d)". textTransform l’imprime en capitales, et indent le décale de 1,2 pouce au-delà de la marge interne, à 3,7 pouces. gap vaut 0, car la valeur par défaut laisse un demi-cadratin, 6 pt, entre le nom et la réplique. Par défaut, le titre d’une boîte repose sur une ligne de 1,2 fois son corps, 14,4 pt ici. titleStyle.lineHeight le place plutôt sur les 12 pt du texte courant : le nom se tient alors sur une ligne de la grille et la réplique occupe un nombre entier de lignes de 12 pt ; avec la valeur par défaut, chaque réplique décalerait de 2,4 pt le texte qui la suit par rapport à la grille. Avec snapToGrid désactivé, la marge inférieure d’une réplique et la marge supérieure de la suivante fusionnent en une seule ligne blanche. Une boîte est gardée entière par défaut : une réplique qui ne tient plus passe à la page suivante avec son nom.
#2 · Régler la page en pouces
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
Du US Letter avec la marge gauche de 1,5 pouce que traversent les attaches, et un pouce sur les trois autres côtés. Courier Prime est un caractère à chasse fixe : en 12 pt, chaque lettre mesure un dixième de pouce de large, si bien qu’une ligne d’action contient 60 caractères et une réplique 35, et qu’avec un interlignage de 12 pt la page contient 54 lignes. Le texte est composé au fer à gauche, en drapeau, et paragraphSpacing place une ligne blanche entre les paragraphes. Un texte en drapeau n’est coupé que si bodyText.hyphenation.ragged le demande : aucun mot du scénario n’est donc coupé, comme l’exige le format.
#3 · Faire des didascalies et des transitions des styles de paragraphe
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 conteneur :::paragraphs{style="paren"} à l’intérieur d’une réplique met sa première ligne en retrait de 0,6 pouce au-delà du bord du dialogue, à 3,1 pouces (styles de paragraphe). Tenez les didascalies sur une ligne : le style ne met en retrait que la première. Le body.paragraphSpacing: false du style de dialogue garde hors de la boîte la ligne blanche des paragraphes d’action : (then) se place donc juste sous la ligne précédente. CUT TO: et FADE OUT. vont dans des conteneurs transition dont le textAlign: 'right' les aligne sur la fin des lignes d’action. Un conteneur ramène le flux sur la grille de ligne de base après son dernier paragraphe ; ce scénario ne quitte jamais la grille, si bien que l’intitulé qui suit CUT TO: garde ses deux lignes blanches.
#4 · Suspendre les numéros de séquence dans les deux marges
// 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}' compte les titres de niveau 2, et {number} imprime ce compte dans la maquette (réglages par niveau). Un titre doté d’une maquette avancée garde sa place dans la colonne et dessine ses éléments au lieu de son texte (largeur et maquette avancée) : les deux numéros peuvent donc se tenir dans les marges, à côté de l’intitulé en gras. Chacun est ancré à la boîte du titre et poussé hors d’elle, de 0,6 pouce vers la gauche et d’un quart de pouce au-delà de la fin du texte à droite, dans une boîte d’un demi-pouce de large au texte aligné à gauche : dans un scénario qui dépasse la séquence 9, les numéros à un et deux chiffres commencent ainsi au même endroit. Le texte propre du titre n’est pas peint mais reste mesuré, d’où le corps de 12 pt du niveau : avec les 15 pt par défaut, l’intitulé de la séquence 1 passerait à la ligne et réserverait une seconde ligne. L’équilibrage des colonnes est désactivé : il ajouterait des lignes blanches au-dessus des intitulés pour remplir une page courte, alors qu’une page de scénario se termine là où finit sa dernière réplique ou son dernier paragraphe.
#5 · Perforer et numéroter les seules pages du scénario
// 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) };
Le style de titre du scénario porte son propre header, qui remplace celui du document sur les pages de sa section, du titre jusqu’à la fin (styles de titre). L’en-tête propre du document est vide : les trous et le numéro de page ne s’impriment donc que sur le scénario. Le numéro de page est ancré à un demi-pouce du haut de la feuille, et pages: 'body' l’écarte des pages d’ouverture (éléments de texte). Le titre est un titre de niveau 1 qui force un saut de page : la page 1 est donc une ouverture et n’imprime aucun numéro, comme le veut le format. Les couvertures sont aussi des titres de niveau 1 ; :::numbering{startAt=1}, entre l’intérieur de la couverture et le titre, donne le numéro 1 à la page du titre, et la page suivante imprime 2. Le style du titre règle une ligne de 12 pt ; avec les 18 pt propres au niveau 1, le titre reposerait sur une ligne de 21,6 pt et repousserait « FADE IN: » de 9,6 pt vers le bas.
La recette complète
// ═══ 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`---Exemple Markdown · 166 lignes · content.en.md
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 · core, fonts, viewer : identique dans toutes les recettes · 224 lignes
// ─── 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 ───────────────────────────────────────────────────────────────────────
Le script.js composé s’exécute tel quel : collez-le dans le script de module de n’importe quelle page, ou ouvrez la recette sur CodePen. Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)
Variantes
#Recaler les répliques sur la grille
Chaque réplique garde alors sa marge inférieure et la suivante ajoute sa marge supérieure, ce qui laisse deux lignes blanches entre les répliques (trois avant un intitulé de séquence) et repousse le scénario sur une quatrième page.
- snapToGrid: false,
};#Taper les intitulés de séquence en graisse normale
Les scénarios dactylographiés avaient leurs intitulés en simples capitales ; le gras est arrivé avec les logiciels d’écriture de scénario.
- fontSize: pt(12), fontWeight: 700, lineHeight: 1, color: col('ink'), align: 'left',
+ fontSize: pt(12), fontWeight: 400, lineHeight: 1, color: col('ink'), align: 'left',- fontWeight: 700, lineHeight: 1, color: col('ink'), align: 'left', overflow: 'wrap',
+ fontWeight: 400, lineHeight: 1, color: col('ink'), align: 'left', overflow: 'wrap',Erreurs fréquentes
- Postext n’imprime pas
(MORE)sous une réplique coupée entre deux pages et ne répète pas le nom au-dessus de sa suite. LaissezkeepTogetherà sa valeur par défaut pour qu’une réplique passe entière à la page suivante. - Les logiciels d’écriture de scénario ajoutent
(CONT'D)à un nom automatiquement. Ici, il fait partie dutitledu bloc : tapez-le vous-même là où la réplique d’un personnage reprend après une ligne d’action. titleStyle.lineHeightne garde une réplique sur la grille que tant qu’il est égal à l’interlignage du texte courant. Un nom composé plus grand ou plus petit que le texte doit garder ces 12 pt, faute de quoi chaque réplique décale de la grille les lignes qui la suivent.
Crédits
- Recette
- Ignacio Ferro
- Texte
- Texte original, CC BY 4.0
- Polices
- Courier Prime (SIL OFL 1.1) · Special Elite (Apache 2.0) · Oswald (SIL OFL 1.1)
Modifier ce texte ↗ (s’ouvre dans un nouvel onglet)Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)


