Em poucas palavras
O roteiro de um curta-metragem no formato que os estúdios de cinema esperam. Usa uma letra de máquina de escrever, e as falas e os números das cenas ficam a distâncias fixas da borda.
O que você vai compor
A versão de filmagem de Lost Property, um curta inventado de três páginas, encadernado como os roteiros dos estúdios: capa de cartão amarelo-ouro com dois colchetes de latão e uma etiqueta datilografada, o verso carimbado com COPY 07 e, em seguida, o roteiro, composto em Courier Prime 12 sobre 12 em papel Carta furado. As medidas são as dos programas de roteiro: ação de 1,5 a 7,5 polegadas; fala de 2,5 a 6, sob o nome do personagem a 3,7; rubrica a partir de 3,1; e CUT TO: e FADE OUT. terminando na margem direita. Cada cabeçalho de cena leva o seu número nas duas margens. A página 1 do roteiro não tem número; as outras imprimem o número no alto à direita, com ponto. Cada fala é um boxe sem moldura, ajustado por um preenchimento superior de −2,4 pt a um número inteiro de linhas de 12 pt, de modo que ação e diálogo mantêm seis linhas por polegada.
Esta receita responde a
- Como componho um roteiro de cinema ou uma peça de teatro: cabeçalhos de cena, nomes de personagens e diálogo recuado dos dois lados?
A resposta curta
// Positions from the left edge of the sheet, in inches, as screenwriting software gives them.
const DIALOGUE = { left: 2.5, right: 6 }; // the block of speech, 3.5 inches wide
const CUE = 3.7; // the character's name
const TEXT_RIGHT = 8.5 - MARGIN.right; // 7.5: where action lines end
const CUE_LINE = 1.2 * 12; // pt: a callout title sits on a line 1.2 times its size
const dialogue = {
id: 'dialogue',
backgroundEnabled: false, // no fill and no border: the box is only a measure
padding: { top: pt(LEAD - CUE_LINE), bottom: pt(0), // −2.4 pt: see the title below
left: mm((DIALOGUE.left - MARGIN.left) * IN), // 1 inch in from the action
right: mm((TEXT_RIGHT - DIALOGUE.right) * IN) }, // 1.5 inches short of it
// The fence's title is the cue: title="Dora (cont’d)" prints DORA (CONT’D) at 3.7 inches,
// in the headings' Courier, with no gap under it (the default is half a line). Its 14.4-pt
// line starts 2.4 pt above the box, so a speech is a whole number of 12-pt lines and the
// name sits 0.48 pt above its grid line.
titleStyle: { fontWeight: 400, textTransform: 'uppercase',
indent: mm((CUE - DIALOGUE.left) * IN), gap: pt(0) },
body: { paragraphSpacing: false }, // no blank line before a parenthetical mid-speech
marginTop: pt(LEAD), marginBottom: pt(LEAD), // one blank line above and below
// Snapped to the grid, a box keeps its bottom margin and the next speech adds its top
// margin: two blank lines between speeches. Unsnapped, the two margins collapse into one.
snapToGrid: false,
};
Ingredientes
- Funcionalidades
- BoxesSair da grade de propósitoEstilos de parágrafoTítulos numeradosEstilos de títuloAberturas desenhadasAtributos de títuloCapas, folhas de rosto e colofõesCabeços por tipo de páginaCabeços e fóliosCabeços por seçãoTextos, fios e caixas nos designs de páginaFormato da páginaUnidades e medidasTipografia do textoRecuos, alinhamento e espaço entre parágrafosEquilíbrio de colunasPaleta de cores semânticaPáginas em um canvas
- Também usa
- Elementos pré-textuais em romanos
- Tipografia
- Courier Prime, Oswald (SIL OFL 1.1) · Special Elite (Apache 2.0)
- Materiais
- Nenhum: todas as imagens são desenhadas em código
Preparo
#1 · Ajuste o preenchimento de um boxe sem moldura ao bloco de diálogo
O código está na resposta curta lá em cima. O preenchimento de um boxe é medido a partir da mancha, então 1 polegada à esquerda e 1,5 à direita deixam o bloco de 3,5 polegadas que começa a 2,5 da borda da folha (estilos de boxe). O nome do personagem é o título do boxe, escrito no bloco como title="Dora (cont’d)". textTransform o imprime em maiúsculas, e indent o leva 1,2 polegada além do preenchimento, até 3,7 polegadas. gap vale 0 porque o padrão deixa meio eme, 6 pt, entre o nome e a fala. Na versão 1.4.1, o título de um boxe fica numa linha de 1,2 vez o seu corpo, aqui 14,4 pt, e titleStyle não tem entrelinha própria para mudar isso. Um preenchimento superior de −2,4 pt desconta esses 2,4 pt a mais, de modo que a fala ocupa um número inteiro de linhas de 12 pt e o nome fica 0,48 pt acima da sua linha da grade. Com preenchimento 0, cada nome desce 1,92 pt, cada fala empurra o texto seguinte mais 2,4 pt para fora da grade, e o cabeçalho da cena 3, ao voltar para a grade, fica 33,6 pt acima da sua primeira linha de ação em vez de 24. Com snapToGrid desligado, a margem inferior de uma fala e a superior da seguinte se fundem numa única linha em branco. Por padrão um boxe fica inteiro, então uma fala que não cabe mais passa para a página seguinte junto com o nome.
#2 · Monte a página em polegadas
const MARGIN = { top: 1, bottom: 1, left: 1.5, right: 1 }; // inches: the left one takes the brads
const page = { sizePreset: 'custom', width: mm(8.5 * IN), height: mm(11 * IN), dpi: 150,
margins: { top: mm(MARGIN.top * IN), bottom: mm(MARGIN.bottom * IN),
left: mm(MARGIN.left * IN), right: mm(MARGIN.right * IN) } };
const bodyText = { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(LEAD), color: col('ink'),
textAlign: 'left', firstLineIndent: pt(0), // action: flush left, never justified
paragraphSpacing: true, // a blank line between paragraphs
// No :ref in this script, but one added later prints in ink, not the default link blue:
// main-color does not reach this key (gotcha: palette-skips-designs).
referenceColor: col('ink') };
Papel Carta com a margem esquerda de 1,5 polegada por onde passam os colchetes e uma polegada nos outros três lados. A Courier Prime é uma fonte monoespaçada: em 12 pt cada letra tem um décimo de polegada de largura, então uma linha de ação comporta 60 caracteres e uma fala, 35, e com entrelinha de 12 pt a página comporta 54 linhas. O texto é alinhado à esquerda, em bandeira, e paragraphSpacing põe uma linha em branco entre os parágrafos. O Postext 1.4.1 só hifeniza texto justificado, então nenhuma palavra do roteiro é dividida, como o formato exige.
#3 · Transforme rubricas e transições em estilos de parágrafo
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
];
Um contêiner :::paragraphs{style="paren"} dentro de uma fala recua a primeira linha 0,6 polegada além da borda do diálogo, até 3,1 polegadas (estilos de parágrafo). Mantenha cada rubrica numa linha só: o estilo recua apenas a primeira. O body.paragraphSpacing: false do estilo de diálogo deixa fora do boxe a linha em branco dos parágrafos de ação, e assim (then) fica logo abaixo da linha anterior. CUT TO: e FADE OUT. vão em contêineres transition, cujo textAlign: 'right' os alinha com o fim das linhas de ação. Um contêiner devolve o texto à grade de linhas de base depois do último parágrafo; este roteiro nunca sai da grade, então o cabeçalho depois de CUT TO: mantém as suas duas linhas em branco.
#4 · Pendure os números de cena nas duas margens
// Each number sits in a box half an inch wide, text aligned left, so that 9, 12 and an
// inserted 12A start at the same place on both sides.
const NUMBER_W = 0.5; // inches
const number = (id, edge, x) => ({ kind: 'text', id, content: '{number}', fontFamily: TEXT,
fontSize: pt(12), fontWeight: 700, lineHeight: 1, color: col('ink'), align: 'left',
placement: { ...at('container', edge, x * IN), size: { width: mm(NUMBER_W * IN) } } });
// The heading's own text is not painted but still measured: at the level's default 15 pt,
// scene 1's heading would wrap and reserve a second line.
const slugline = { level: 2, fontSize: pt(12), lineHeight: pt(LEAD),
marginTop: pt(2 * LEAD), marginBottom: pt(LEAD), // two blank lines above, one below
numberingTemplate: '{2}', // the scene count, which {number} prints
advancedDesign: { enabled: true, slot: { elements: [
{ kind: 'text', id: 'heading', content: '{titleText}', fontFamily: TEXT, fontSize: pt(12),
fontWeight: 700, lineHeight: 1, color: col('ink'), align: 'left', overflow: 'wrap',
placement: { ...at('container', 'top-left'), size: { width: 'fill' } } },
number('left', 'top-left', -0.6), // starts at 0.9 inches from the edge of the sheet
number('right', 'top-right', 0.25 + NUMBER_W), // starts a quarter inch past the text
] } } };
const headings = {
// The family of the speech titles, and the one the headings' unpainted text is measured
// in (left unset, a sixth face, Open Sans Bold, would be loaded for nothing).
fontFamily: TEXT,
// Script pages end where the last whole speech or paragraph ends. Balancing would add
// blank lines above sluglines to fill them, and push a closing speech to the foot
// (gotcha: balancing-drops-last-box).
balancing: { enabled: false },
levels: [
// Any headings object drops the H1 page break (gotcha: headings-drop-h1-break).
{ level: 1, breakBefore: { enabled: true, parity: 'any' } },
slugline,
] };
numberingTemplate: '{2}' conta os títulos de nível 2, e {number} imprime essa contagem no design (ajustes por nível). Um título com design avançado mantém o seu lugar na coluna e desenha os seus elementos no lugar do texto (span e design avançado), e por isso os dois números podem ficar nas margens ao lado do cabeçalho em negrito. Cada um é ancorado à caixa do próprio título e empurrado para fora dela, 0,6 polegada à esquerda e, à direita, um quarto de polegada além do fim do texto, numa caixa de meia polegada com o texto alinhado à esquerda, para que, num roteiro com mais de nove cenas, os números de um e de dois algarismos comecem no mesmo ponto. O texto do próprio título não é pintado, mas continua sendo medido, por isso o nível fica em 12 pt: com os 15 pt padrão, o cabeçalho da cena 1 quebraria e reservaria uma segunda linha. O equilíbrio de colunas está desligado: ele acrescenta linhas em branco para encher páginas curtas, e, com ele ligado, o “Here?” de Toby se separa da fala de Dora e cai para o pé da página 2 do roteiro.
#5 · Fure e numere só as páginas do roteiro
// 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) };
O estilo do título do roteiro traz o seu próprio header, que substitui o do documento nas páginas da sua seção, do título até o fim (estilos de título). O cabeçalho do próprio documento é vazio, então os furos e o número de página só aparecem no roteiro. O número de página é ancorado a meia polegada do alto da folha, e pages: 'body' o mantém fora das páginas de abertura (elementos de texto). O título é de nível 1 e quebra a página, então a página 1 é uma página de abertura e não imprime número, como o formato pede. As capas também são títulos de nível 1; :::numbering{startAt=1}, entre o verso da capa e o título, dá à página do título o número 1, e a página seguinte imprime 2. O estilo do título fixa uma linha de 12 pt; com os 18 pt próprios do nível 1, o título ficaria numa linha de 21,6 pt e empurraria FADE IN: 9,6 pt para baixo.
A receita 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.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache } 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) }; // The hex as well as the id: design slots read only the hex (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); // The engine's defaults link to main-color: pointed at the ink, anything left unset prints black. const colorPalette = Object.entries({ ...palette, 'main-color': palette.ink }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); const TEXT = 'Courier Prime'; const IN = 25.4; // mm in an inch: the format is specified in inches const LEAD = 12; // pt: 12-point Courier at six lines to the inch, so one line is one grid line const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); // #region page: US Letter, a 1.5-inch binding margin, Courier 12 on 12, ragged right const MARGIN = { top: 1, bottom: 1, left: 1.5, right: 1 }; // inches: the left one takes the brads const page = { sizePreset: 'custom', width: mm(8.5 * IN), height: mm(11 * IN), dpi: 150, margins: { top: mm(MARGIN.top * IN), bottom: mm(MARGIN.bottom * IN), left: mm(MARGIN.left * IN), right: mm(MARGIN.right * IN) } }; const bodyText = { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(LEAD), color: col('ink'), textAlign: 'left', firstLineIndent: pt(0), // action: flush left, never justified paragraphSpacing: true, // a blank line between paragraphs // No :ref in this script, but one added later prints in ink, not the default link blue: // main-color does not reach this key (gotcha: palette-skips-designs). referenceColor: col('ink') }; // #endregion // #region answer: a speech is a callout with no frame, padded to the 3.5-inch dialogue block // Positions from the left edge of the sheet, in inches, as screenwriting software gives them. const DIALOGUE = { left: 2.5, right: 6 }; // the block of speech, 3.5 inches wide const CUE = 3.7; // the character's name const TEXT_RIGHT = 8.5 - MARGIN.right; // 7.5: where action lines end const CUE_LINE = 1.2 * 12; // pt: a callout title sits on a line 1.2 times its size const dialogue = { id: 'dialogue', backgroundEnabled: false, // no fill and no border: the box is only a measure padding: { top: pt(LEAD - CUE_LINE), bottom: pt(0), // −2.4 pt: see the title below left: mm((DIALOGUE.left - MARGIN.left) * IN), // 1 inch in from the action right: mm((TEXT_RIGHT - DIALOGUE.right) * IN) }, // 1.5 inches short of it // The fence's title is the cue: title="Dora (cont’d)" prints DORA (CONT’D) at 3.7 inches, // in the headings' Courier, with no gap under it (the default is half a line). Its 14.4-pt // line starts 2.4 pt above the box, so a speech is a whole number of 12-pt lines and the // name sits 0.48 pt above its grid line. titleStyle: { fontWeight: 400, textTransform: 'uppercase', indent: mm((CUE - DIALOGUE.left) * IN), gap: pt(0) }, body: { paragraphSpacing: false }, // no blank line before a parenthetical mid-speech marginTop: pt(LEAD), marginBottom: pt(LEAD), // one blank line above and below // Snapped to the grid, a box keeps its bottom margin and the next speech adds its top // margin: two blank lines between speeches. Unsnapped, the two margins collapse into one. snapToGrid: false, }; // #endregion // #region styles: parentheticals start at 3.1 inches; transitions end at the right margin const PAREN = 3.1; const paragraphStyles = [ { id: 'paren', firstLineIndent: mm((PAREN - DIALOGUE.left) * IN) }, // inside a speech { id: 'transition', textAlign: 'right' }, // CUT TO:, flush with the action's right edge ]; // #endregion // #region slugline: each scene heading carries its number in both margins // Each number sits in a box half an inch wide, text aligned left, so that 9, 12 and an // inserted 12A start at the same place on both sides. const NUMBER_W = 0.5; // inches const number = (id, edge, x) => ({ kind: 'text', id, content: '{number}', fontFamily: TEXT, fontSize: pt(12), fontWeight: 700, lineHeight: 1, color: col('ink'), align: 'left', placement: { ...at('container', edge, x * IN), size: { width: mm(NUMBER_W * IN) } } }); // The heading's own text is not painted but still measured: at the level's default 15 pt, // scene 1's heading would wrap and reserve a second line. const slugline = { level: 2, fontSize: pt(12), lineHeight: pt(LEAD), marginTop: pt(2 * LEAD), marginBottom: pt(LEAD), // two blank lines above, one below numberingTemplate: '{2}', // the scene count, which {number} prints advancedDesign: { enabled: true, slot: { elements: [ { kind: 'text', id: 'heading', content: '{titleText}', fontFamily: TEXT, fontSize: pt(12), fontWeight: 700, lineHeight: 1, color: col('ink'), align: 'left', overflow: 'wrap', placement: { ...at('container', 'top-left'), size: { width: 'fill' } } }, number('left', 'top-left', -0.6), // starts at 0.9 inches from the edge of the sheet number('right', 'top-right', 0.25 + NUMBER_W), // starts a quarter inch past the text ] } } }; const headings = { // The family of the speech titles, and the one the headings' unpainted text is measured // in (left unset, a sixth face, Open Sans Bold, would be loaded for nothing). fontFamily: TEXT, // Script pages end where the last whole speech or paragraph ends. Balancing would add // blank lines above sluglines to fill them, and push a closing speech to the foot // (gotcha: balancing-drops-last-box). balancing: { enabled: false }, levels: [ // Any headings object drops the H1 page break (gotcha: headings-drop-h1-break). { level: 1, breakBefore: { enabled: true, parity: 'any' } }, slugline, ] }; // #endregion // #region furniture: the script's pages are punched, and numbered from page 2 on // Three holes down the bound edge, 4.25 inches apart, as a three-hole punch leaves them. const HOLES = [1.25, 5.5, 9.75].map((y) => y * IN); // mm: hole centres from the top const EDGE = 0.375 * IN; // mm: from the bound edge to the centre of each hole const HOLE = 7; // mm across const punched = HOLES.map((y, i) => ({ kind: 'box', id: `punch${i}`, style: { backgroundColor: col('hole'), borderRadius: mm(HOLE / 2) }, placement: { ...at('page', 'top-left', EDGE - HOLE / 2, y - HOLE / 2), size: { width: mm(HOLE), height: mm(HOLE) } } })); // The script's first page opens with its title, an H1 that breaks the page, so the page is // an 'opener' and pages: 'body' leaves it unnumbered, as the format asks. const folio = { kind: 'text', id: 'folio', content: '{pageNumber}.', pages: 'body', fontFamily: TEXT, fontSize: pt(12), color: col('ink'), align: 'right', placement: at('page', 'top-right', -MARGIN.right * IN, 0.5 * IN) }; // The title's style carries the header of its section: the pages from the title to the end. const title = { id: 'script', header: { elements: [...punched, folio] }, advancedDesign: { enabled: true, slot: { elements: [ { kind: 'text', id: 'title', content: '{titleText}', fontFamily: TEXT, fontSize: pt(12), lineHeight: 1, textTransform: 'uppercase', color: col('ink'), align: 'center', placement: { ...at('container', 'top'), size: { width: 'fill' } } }, ] } }, // H1's own line, 1.2 × 18 pt, would reserve 21.6 pt and drop FADE IN: 9.6 pt lineHeight: pt(LEAD), marginBottom: pt(LEAD) }; // #endregion // #region art: the card covers: a three-hole punch, brass brads, a typed label, two stamps const [W, H] = [8.5 * IN, 11 * IN]; // mm: the sheet const PT = 25.4 / 72; // mm in a point const box = (id, x, y, w, h, style) => ({ kind: 'box', id, style, placement: { ...at('page', 'top-left', x, y), size: { width: mm(w), height: mm(h) } } }); const circle = (id, cx, cy, d, style) => box(id, cx - d / 2, cy - d / 2, d, d, { ...style, borderRadius: mm(d / 2) }); const line = (id, content, x, y, w, style) => ({ kind: 'text', id, content, lineHeight: 1, ...style, placement: { ...at('page', 'top-left', x, y), size: { width: mm(w) } } }); const card = box('card', 0, 0, W, H, { backgroundColor: col('card') }); // Outside, a brass head in the top and bottom holes; the middle hole stays empty, as on // studio scripts, and shows the white page under the cover. const heads = HOLES.flatMap((y, i) => (i === 1 ? [circle('hole', EDGE, y, HOLE, { backgroundColor: col('paper'), borderColor: col('cardDark'), borderWidth: pt(1) })] : [circle(`shade${i}`, EDGE + 0.5, y + 0.7, 11.5, { backgroundColor: col('cardDark') }), circle(`head${i}`, EDGE, y, 11, { backgroundColor: col('brass') }), circle(`glint${i}`, EDGE - 1.8, y - 1.8, 3.6, { backgroundColor: col('brassLight') })])); // Inside, the holes are at the right edge, with the prongs of two brads through them. const prongs = HOLES.flatMap((y, i) => [ circle(`hole${i}`, W - EDGE, y, HOLE, { backgroundColor: col('cardInk') }), ...(i === 1 ? [] : [box(`prong${i}`, W - EDGE - 1.1, y - 4.2, 2.2, 8.4, { backgroundColor: col('brassLight'), borderRadius: mm(1.1) })]), ]); const stamp = (id, x, y, w, h) => [ box(id, x, y, w, h, { borderColor: col('stamp'), borderWidth: pt(1.8), borderRadius: mm(1.5) }), box(`${id}-rim`, x + 1.4, y + 1.4, w - 2.8, h - 2.8, { borderColor: col('stamp'), borderWidth: pt(0.6), borderRadius: mm(1) }), ]; const typed = (size) => ({ fontFamily: TEXT, fontSize: pt(size), color: col('ink') }); // Tracked capitals, centred. 1.4.1 centres a tracked line with the tracking after its last // letter, half a unit left of the middle: the box moves right by that half. const caps = (id, content, x, y, w, size, track, color) => line(id, content, x + (track * PT) / 2, y, w, { align: 'center', fontFamily: 'Oswald', fontWeight: 500, fontSize: pt(size), letterSpacing: pt(track), textTransform: 'uppercase', color: col(color) }); const small = { fontFamily: TEXT, fontSize: pt(7.5), color: col('cardInk'), align: 'left' }; const LABEL = { w: 120, h: 64, y: 78 }; // mm: a white label, centred across the sheet const LABEL_X = (W - LABEL.w) / 2; const DRAFT = { w: 56, h: 20 }; // mm: under the label, flush with its right edge [DRAFT.x, DRAFT.y] = [LABEL_X + LABEL.w - DRAFT.w, LABEL.y + LABEL.h + 9]; const center = { align: 'center' }; // span: 'page' on both: kept in the column, the card is clipped an inch from the top and foot. const cover = { id: 'cover', span: 'page', advancedDesign: { enabled: true, slot: { elements: [ card, ...heads, box('shadow', LABEL_X + 1.2, LABEL.y + 1.4, LABEL.w, LABEL.h, { backgroundColor: col('cardDark'), borderRadius: mm(2) }), box('label', LABEL_X, LABEL.y, LABEL.w, LABEL.h, { backgroundColor: col('paper'), borderRadius: mm(2) }), line('name', '{titleText}', LABEL_X, LABEL.y + 15, LABEL.w, { ...center, fontFamily: 'Special Elite', fontSize: pt(32), textTransform: 'uppercase', color: col('ink') }), line('credit', '{attr.credit}', LABEL_X, LABEL.y + 37, LABEL.w, { ...center, ...typed(12) }), line('author', '{author}', LABEL_X, LABEL.y + 44, LABEL.w, { ...center, ...typed(12) }), ...stamp('draft-box', DRAFT.x, DRAFT.y, DRAFT.w, DRAFT.h), caps('draft', '{attr.draft}', DRAFT.x, DRAFT.y + 3.6, DRAFT.w, 15, 2.2, 'stamp'), caps('date', '{attr.date}', DRAFT.x, DRAFT.y + 12.4, DRAFT.w, 8.5, 1.6, 'stamp'), caps('company', '{attr.company}', 0, 250, W, 10, 3, 'cardInk'), ] } } }; const COPY = { w: 64, h: 24, x: IN, y: IN + 28 }; // mm: the copy number, under the notice const flyleaf = { id: 'flyleaf', span: 'page', // the inside of the cover, facing script page 1 advancedDesign: { enabled: true, slot: { elements: [ card, ...prongs, line('notice', '{attr.notice}', IN, IN, 120, { ...typed(10), lineHeight: 1.3, color: col('cardInk'), align: 'left', overflow: 'wrap' }), ...stamp('copy-box', COPY.x, COPY.y, COPY.w, COPY.h), caps('copy', '{attr.copy}', COPY.x, COPY.y + 6.2, COPY.w, 26, 3.5, 'stamp'), line('colophon', '{attr.colophon}', IN, 250, 150, small), line('fonts', '{attr.fonts}', IN, 254.5, 150, small), ] } } }; // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object colorPalette, page, layout: { layoutType: 'single' }, bodyText, headings, headingStyles: [cover, flyleaf, title], paragraphStyles, calloutStyles: [dialogue], // Empty slots: by default the header prints the section's title at the top of each cover // and the footer a page number at the foot of every page. header: { elements: [] }, footer: { elements: [] }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Amostra em Markdown · 166 linhas · 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 ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown); showPages(doc, { title: t({ en: 'Lost Property · a short film', es: 'Objetos perdidos · un cortometraje' }) });Kit · core, fonts, viewer: igual em todas as receitas · 236 linhas
// ─── 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 v2 ── the same in every recipe · postext.dev/cookbook // Postext measures with the loaded faces and caches the widths: load every face // before the first build, from Fontsource, the files the PDF embeds too. /** 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. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; const jobs = []; let added = 0; 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 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 = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` and loads any face the pages use that FONTS missed (a regular * one with a warning), then clears the measurement cache and builds again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout; `base` marks a block's own face. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** 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, '-'); } /** 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 v1 ── 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. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
O script.js montado funciona como está: cole-o como script de módulo em qualquer página ou abra a receita no CodePen. Pasta da receita no GitHub ↗ (abre em uma nova aba)
Variações
#Devolva as falas à grade
Cada fala passa então a manter a sua margem inferior e a seguinte soma a margem superior, o que deixa duas linhas em branco entre as falas (três antes de um cabeçalho de cena) e empurra o roteiro para uma quarta página.
- snapToGrid: false,
};#Escreva os cabeçalhos de cena sem negrito
Nos roteiros datilografados, os cabeçalhos de cena vinham só em maiúsculas; o negrito chegou com os programas de roteiro.
- 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',Erros comuns
Erro comum
O balanceamento de colunas desce até o pé o boxe que fecha uma página
Quando um boxe é o último bloco de uma página depois da qual o texto continua, o balanceamento de colunas (ativo por padrão) passa o espaço que sobra embaixo do boxe para cima dele, de modo que o boxe se afasta do bloco anterior e termina na última linha da página. No postext 1.4.1 nenhuma opção do balanceamento desativa só isso: headings.balancing.enabled: false desliga todo o balanceamento, o que serve para páginas que devem terminar curtas, como uma página de poemas. Equilíbrio de colunas →
Erro comum
Texto em bandeira nunca é verificado contra linhas curtas
optimalLineBreaking, avoidRunts, runtPenalty e runtMinCharacters agem sobre o algoritmo de quebra de linha de Knuth–Plass, que o postext 1.4.1 só aplica a texto justificado. Um parágrafo em bandeira é quebrado linha a linha e pode terminar numa única palavra curta, digam o que disserem essas configurações. Revise as últimas linhas do texto em bandeira e reescreva o parágrafo que terminar numa linha curta. Viúvas, órfãs e linhas curtas →
Erro comum
Qualquer objeto headings desativa a quebra de página do H1
Por padrão, um H1 salta para uma página ímpar (always-odd), mas passar qualquer objeto headings redefine esse padrão, então os capítulos ficam emendados e span: 'page' não faz nada. Declare de novo headings.levels[0].breakBefore: { enabled: true, parity } em toda configuração. Capítulos que abrem em página ímpar →
Erro comum
Uma paleta trocada não chega aos elementos de design nem à cor das referências
postext 1.4.1 aplica colorPalette aos estilos de texto (corpo, títulos, listas, legendas, tabelas, boxes), mas não aos elementos de cabeçalhos, rodapés, aberturas e páginas de parte, nem a bodyText.referenceColor: eles mantêm o hex escrito ao lado do seu paletteId. Se você trocar a paleta, para uma edição de tela escura ou para mudar as cores, reescreva cada cor vinculada a partir de colorPalette antes de compor. Paleta de cores semântica →
- O Postext não imprime
(MORE)sob uma fala que se divide entre duas páginas nem repete o nome acima do resto dela. DeixekeepTogetherno valor padrão para que cada fala passe inteira para a página seguinte. - Os programas de roteiro acrescentam
(CONT'D)ao nome automaticamente. Aqui ele faz parte dotitledo bloco, então digite-o você mesmo onde a fala de um personagem continua depois de uma linha de ação. - O preenchimento de −2,4 pt funciona para um título no corpo do texto. Um título maior ou menor fica numa linha diferente, de 1,2 vez o seu próprio corpo: calcule de novo o preenchimento como a entrelinha menos essa linha.
Créditos
- Receita
- Ignacio Ferro
- Texto
- Texto original, CC BY 4.0
- Fontes
- Courier Prime (SIL OFL 1.1) · Special Elite (Apache 2.0) · Oswald (SIL OFL 1.1)
- Código
- MIT, como o Postext
Editar este texto ↗ (abre em uma nova aba)Pasta da receita no GitHub ↗ (abre em uma nova aba)


