In breve
Il copione di un cortometraggio impaginato come se lo aspettano gli studi cinematografici. Usa un carattere da macchina da scrivere, con battute e numeri di scena a distanze fisse dal bordo.
Che cosa comporrai
Il copione di lavorazione di Lost Property, un cortometraggio inventato di tre pagine, rilegato come i copioni degli studi: una copertina in cartoncino giallo oro con due fermacampioni d’ottone e un’etichetta dattiloscritta, l’interno timbrato «COPY 07», poi il copione, composto 12 su 12 in Courier Prime su fogli US Letter forati. Le giustezze sono quelle dei programmi di sceneggiatura: l’azione da 1,5 a 7,5 pollici, una battuta da 2,5 a 6 sotto il nome del personaggio a 3,7, un’indicazione tra parentesi da 3,1, e «CUT TO:» e «FADE OUT.» che finiscono al margine destro. Ogni intestazione di scena porta il suo numero nei due margini. La pagina 1 del copione non è numerata; le altre stampano il loro numero in alto a destra, con un punto. Ogni battuta è un riquadro senza cornice, con il nome del personaggio composto sull’interlinea di 12 pt del corpo del testo, così azione e dialogo mantengono sei righe per pollice.
Questa ricetta risponde a
- Come compongo una sceneggiatura o un testo teatrale: intestazioni di scena, nomi dei personaggi e dialoghi rientrati da entrambi i lati?
La risposta breve
// 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,
};
Ingredienti
- Funzionalità
- RiquadriUscire dalla griglia di propositoStili di paragrafoTitoli numeratiStili di titoloAperture progettateAttributi dei titoliCopertine, frontespizi e colophonTestatine secondo il ruolo della paginaTestatine e folioTestatine per sezioneTesto, filetti e riquadri nei progetti di paginaFormato della paginaUnità e dimensioniCarattere del testo correnteRientri, allineamento e spaziatura dei paragrafiBilanciamento delle colonneTavolozza di colori semanticaPagine su un canvas
- Usa anche
- Pagine preliminari in numeri romani
- Tipo
- Courier Prime, Oswald (SIL OFL 1.1) · Special Elite (Apache 2.0)
- Risorse
- Nessuna: ogni immagine è disegnata nel codice
Preparazione
#1 · Spazia un riquadro senza cornice fino al blocco del dialogo
Il codice è nella risposta breve qui sopra. La spaziatura interna di un riquadro si misura dalla gabbia, quindi 1 pollice a sinistra e 1,5 pollici a destra lasciano il blocco di 3,5 pollici che comincia a 2,5 pollici dal bordo del foglio (stili dei riquadri). Il nome del personaggio è il titolo del riquadro, scritto nel blocco come title="Dora (cont’d)". textTransform lo stampa in maiuscolo, e indent lo sposta 1,2 pollici oltre la spaziatura interna, a 3,7 pollici. gap è 0 perché il valore predefinito lascia mezzo em, 6 pt, tra il nome e la battuta. Per impostazione predefinita il titolo di un riquadro sta su una riga di 1,2 volte il suo corpo, qui 14,4 pt. titleStyle.lineHeight lo compone invece sui 12 pt del corpo del testo, così il nome poggia su una linea di griglia e la battuta è un numero intero di righe da 12 pt; con il valore predefinito, ogni battuta spingerebbe il testo che la segue 2,4 pt più fuori griglia. Con snapToGrid disattivato, il margine inferiore di una battuta e il margine superiore della successiva si fondono in un’unica riga vuota. Un riquadro resta unito per impostazione predefinita, quindi una battuta che non ci sta più passa alla pagina successiva con il suo nome.
#2 · Imposta la pagina in pollici
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 con il margine sinistro di 1,5 pollici attraverso cui passano i fermacampioni, e un pollice sugli altri tre lati. Courier Prime è un carattere monospaziato: in corpo 12 ogni lettera è larga un decimo di pollice, quindi una riga d’azione contiene 60 caratteri e una battuta 35, e con un’interlinea di 12 pt la pagina contiene 54 righe. Il testo è allineato a sinistra, in bandiera a destra, e paragraphSpacing mette una riga vuota tra i paragrafi. Il testo in bandiera si sillaba solo quando lo chiede bodyText.hyphenation.ragged, quindi nessuna parola del copione viene spezzata, come vuole il formato.
#3 · Fai delle indicazioni tra parentesi e delle transizioni stili di paragrafo
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 contenitore :::paragraphs{style="paren"} dentro una battuta rientra la sua prima riga di 0,6 pollici oltre il bordo del dialogo, a 3,1 pollici (stili di paragrafo). Tieni le indicazioni tra parentesi su una riga: lo stile rientra solo la prima riga. body.paragraphSpacing: false dello stile del dialogo tiene fuori dal riquadro la riga vuota dei paragrafi d’azione, così (then) sta subito sotto la riga che lo precede. CUT TO: e FADE OUT. vanno in contenitori transition il cui textAlign: 'right' li allinea con la fine delle righe d’azione. Un contenitore riporta il flusso sulla griglia della linea di base dopo il suo ultimo paragrafo; questo copione non lascia mai la griglia, quindi l’intestazione dopo CUT TO: conserva le sue due righe vuote.
#4 · Appendi i numeri di scena nei due margini
// 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}' conta i titoli di livello 2, e {number} stampa il conteggio nel progetto grafico (impostazioni per livello). Un titolo con un progetto grafico avanzato mantiene il suo posto nella colonna e disegna i suoi elementi invece del suo testo (estensione e progetto grafico avanzato), così i due numeri possono stare nei margini accanto al titolo in grassetto. Ciascuno è ancorato al riquadro del titolo stesso e spinto fuori da esso, 0,6 pollici a sinistra e un quarto di pollice oltre la fine del testo a destra, in un riquadro largo mezzo pollice con il testo allineato a sinistra, così in un copione che supera la scena 9 i numeri di una e di due cifre cominciano nello stesso punto. Il testo del titolo non viene dipinto ma viene comunque misurato, perciò il livello è impostato a 12 pt: con i 15 pt predefiniti, il titolo della scena 1 andrebbe a capo e riserverebbe una seconda riga. Il bilanciamento delle colonne è disattivato: aggiungerebbe righe vuote sopra le intestazioni di scena per riempire una pagina corta, e una pagina di copione finisce dove finisce la sua ultima battuta o il suo ultimo paragrafo.
#5 · Fora e numera solo le pagine del copione
// 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) };
Lo stile del titolo del copione porta un proprio header, che sostituisce quello del documento nelle pagine della sua sezione, dal titolo alla fine (stili di titolo). L’intestazione del documento è vuota, quindi i fori e il numero di pagina si stampano solo sul copione. Il numero di pagina è ancorato a mezzo pollice dalla testa del foglio, e pages: 'body' lo tiene fuori dalle pagine d’apertura (elementi di testo). Il titolo è un titolo di livello 1 che interrompe la pagina, quindi la pagina 1 è una pagina d’apertura e non stampa numero, come chiede il formato. Anche le copertine sono titoli di livello 1; :::numbering{startAt=1} tra l’interno della copertina e il titolo dà alla pagina del titolo il numero 1, e la pagina successiva stampa 2. Lo stile del titolo imposta una riga di 12 pt; con i 18 pt propri del livello 1, il titolo starebbe su una riga di 21,6 pt e spingerebbe «FADE IN:» 9,6 pt più in basso.
La ricetta completa
// ═══ 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`---Esempio Markdown · 166 righe · 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: uguale in ogni ricetta · 224 righe
// ─── 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 ───────────────────────────────────────────────────────────────────────
Lo script.js composto funziona così com’è: incollalo nello script modulo di qualsiasi pagina, oppure apri la ricetta su CodePen. Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)
Varianti
#Riporta le battute sulla griglia
Ogni battuta allora conserva il suo margine inferiore e la successiva aggiunge il suo margine superiore, il che lascia due righe vuote tra le battute (tre prima di un’intestazione di scena) e porta il copione a una quarta pagina.
- snapToGrid: false,
};#Scrivi le intestazioni di scena in tondo normale
I copioni dattiloscritti avevano le intestazioni di scena in semplici maiuscole; il grassetto è arrivato con i programmi di sceneggiatura.
- 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',Errori frequenti
- Postext non stampa
(MORE)sotto una battuta che si spezza tra due pagine né ripete il nome sopra il resto. LasciakeepTogetheral suo valore predefinito, così una battuta passa intera alla pagina successiva. - I programmi di sceneggiatura aggiungono
(CONT'D)al nome in automatico. Qui fa parte deltitledel blocco, quindi scrivilo tu dove la battuta di un personaggio riprende dopo una riga d’azione. titleStyle.lineHeighttiene una battuta sulla griglia solo finché è uguale all’interlinea del corpo del testo. Un nome composto più grande o più piccolo del testo prende comunque quei 12 pt, altrimenti ogni battuta sposta fuori griglia le righe che la seguono.
Crediti
- Ricetta
- Ignacio Ferro
- Testo
- Testo originale, CC BY 4.0
- Font
- Courier Prime (SIL OFL 1.1) · Special Elite (Apache 2.0) · Oswald (SIL OFL 1.1)
- Codice
- MIT, come Postext
Modifica questo testo ↗ (si apre in una nuova scheda)Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)


