En poques paraules
El guió d'un curtmetratge amb el format que fan servir els estudis de cinema. Porta una lletra de màquina d'escriure, i els diàlegs i els números d'escena van a distàncies fixes de la vora.
Què compondràs
La versió de rodatge d'Objetos perdidos, un curtmetratge inventat de tres pàgines, enquadernada com els guions dels estudis: coberta de cartolina mostassa amb dos enquadernadors de llautó i etiqueta mecanografiada, l'interior segellat amb COPIA 07 i, al darrere, el guió en Courier Prime de 12 sobre 12 en paper carta foradat. Les mesures són les dels programes de guió: acció d'1,5 a 7,5 polzades; rèplica de 2,5 a 6, sota el nom del personatge a 3,7; acotació des de 3,1, i CORTE A: i FUNDIDO A NEGRO. fins al marge dret. Cada escena porta el seu número als dos marges. La pàgina 1 del guió no porta número; les altres, a dalt a la dreta i amb punt. Cada rèplica és un requadre sense marc que un farciment superior de −2,4 pt ajusta a línies senceres de 12 pt, i així acció i diàleg mantenen sis línies per polzada.
Aquesta recepta respon a
- Com componc un guió de cinema o una obra de teatre: encapçalaments d'escena, noms de personatge i diàleg sagnat pels dos costats?
La 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,
};
Ingredients
- Funcions
- RequadresSortir de la retícula a propòsitEstils de paràgrafTítols numeratsEstils de títolObertures dissenyadesAtributs de títolCobertes, portades i colofonsCapçaleres segons el tipus de pàginaCapçaleres i folisCapçaleres per seccióTextos, filets i caixes als dissenys de pàginaFormat de pàginaUnitats i mesuresTipografia del textSagnats, alineació i separació de paràgrafsEquilibri de columnesPaleta de color semànticaPàgines en un canvas
- També fa servir
- Preliminars en romans
- Tipografia
- Courier Prime, Oswald (SIL OFL 1.1) · Special Elite (Apache 2.0)
- Recursos
- Cap: totes les imatges es dibuixen en codi
Elaboració
#1 · Ajusta un requadre sense marc al bloc de diàleg
El codi és a la resposta curta, més amunt. El farciment d'un requadre es mesura des de la caixa de text, així que 1 polzada a l'esquerra i 1,5 a la dreta deixen el bloc de 3,5 polzades que comença a 2,5 de la vora del full (estils d'avís). El nom del personatge és el títol del requadre i s'escriu a la tanca: title="Dora (cont.)". textTransform el passa a majúscules, i indent el porta 1,2 polzades més enllà del farciment, fins a 3,7. gap val 0 perquè, per defecte, entre el nom i la rèplica queda mig quadratí, 6 pt. A la 1.4.1 el títol d'un requadre es compon en una línia d'1,2 vegades el seu cos, 14,4 pt aquí, i titleStyle no té un interlineat propi que la canviï. El farciment superior de −2,4 pt descompta aquests 2,4 pt de més, de manera que la rèplica ocupa un nombre enter de línies de 12 pt i el nom queda 0,48 pt per sobre de la seva línia de la retícula. Amb el farciment a 0, cada nom baixa 1,92 pt, cada rèplica allunya 2,4 pt més de la retícula el text que la segueix, i l'encapçalament de l'escena 3, en tornar a la retícula, queda a 33,6 pt de la seva primera línia d'acció en lloc de 24. Amb snapToGrid desactivat, el marge inferior d'una rèplica i el superior de la següent es fonen en una sola línia en blanc. Un requadre es manté sencer per defecte, de manera que una rèplica que ja no hi cap passa a la pàgina següent amb el seu nom.
#2 · Mesura la pàgina en polzades
const MARGIN = { top: 1, bottom: 1, left: 1.5, right: 1 }; // inches: the left one takes the brads
const page = { sizePreset: 'custom', width: mm(8.5 * IN), height: mm(11 * IN), dpi: 150,
margins: { top: mm(MARGIN.top * IN), bottom: mm(MARGIN.bottom * IN),
left: mm(MARGIN.left * IN), right: mm(MARGIN.right * IN) } };
const bodyText = { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(LEAD), color: col('ink'),
textAlign: 'left', firstLineIndent: pt(0), // action: flush left, never justified
paragraphSpacing: true, // a blank line between paragraphs
// No :ref in this script, but one added later prints in ink, not the default link blue:
// main-color does not reach this key (gotcha: palette-skips-designs).
referenceColor: col('ink') };
Mida carta, amb el marge esquerre d'1,5 polzades per on passen els enquadernadors i una polzada als altres tres costats. Courier Prime és una lletra monoespaiada: a 12 pt cada caràcter fa una dècima de polzada, així que una línia d'acció admet 60 caràcters i una de diàleg 35, i amb un interlineat de 12 pt hi caben 54 línies a la pàgina. El text va en bandera, alineat a l'esquerra, i paragraphSpacing deixa una línia en blanc entre paràgrafs. Postext 1.4.1 només aplica la partició de mots al text justificat, així que cap paraula no es talla, com demana el format.
#3 · Fes de les acotacions i les transicions estils de paràgraf
const PAREN = 3.1;
const paragraphStyles = [
{ id: 'paren', firstLineIndent: mm((PAREN - DIALOGUE.left) * IN) }, // inside a speech
{ id: 'transition', textAlign: 'right' }, // CUT TO:, flush with the action's right edge
];
Un contenidor :::paragraphs{style="paren"} dins d'una rèplica sagna la primera línia 0,6 polzades des de la vora del diàleg, fins a 3,1 (estils de paràgraf). Escriu cada acotació en una sola línia, perquè l'estil només sagna la primera. A l'estil del diàleg, body.paragraphSpacing: false deixa fora del requadre la línia en blanc que separa els paràgrafs d'acció, i (luego) queda just a sota de la línia anterior. CORTE A: i FUNDIDO A NEGRO. van en contenidors transition, el textAlign: 'right' dels quals els alinea amb el final de les línies d'acció. Un contenidor torna el text a la retícula de base després del seu últim paràgraf; aquest guió no en surt mai, així que l'encapçalament que segueix CORTE A: conserva les seves dues línies en blanc.
#4 · Penja els números d'escena als dos marges
// Each number sits in a box half an inch wide, text aligned left, so that 9, 12 and an
// inserted 12A start at the same place on both sides.
const NUMBER_W = 0.5; // inches
const number = (id, edge, x) => ({ kind: 'text', id, content: '{number}', fontFamily: TEXT,
fontSize: pt(12), fontWeight: 700, lineHeight: 1, color: col('ink'), align: 'left',
placement: { ...at('container', edge, x * IN), size: { width: mm(NUMBER_W * IN) } } });
// The heading's own text is not painted but still measured: at the level's default 15 pt,
// scene 1's heading would wrap and reserve a second line.
const slugline = { level: 2, fontSize: pt(12), lineHeight: pt(LEAD),
marginTop: pt(2 * LEAD), marginBottom: pt(LEAD), // two blank lines above, one below
numberingTemplate: '{2}', // the scene count, which {number} prints
advancedDesign: { enabled: true, slot: { elements: [
{ kind: 'text', id: 'heading', content: '{titleText}', fontFamily: TEXT, fontSize: pt(12),
fontWeight: 700, lineHeight: 1, color: col('ink'), align: 'left', overflow: 'wrap',
placement: { ...at('container', 'top-left'), size: { width: 'fill' } } },
number('left', 'top-left', -0.6), // starts at 0.9 inches from the edge of the sheet
number('right', 'top-right', 0.25 + NUMBER_W), // starts a quarter inch past the text
] } } };
const headings = {
// The family of the speech titles, and the one the headings' unpainted text is measured
// in (left unset, a sixth face, Open Sans Bold, would be loaded for nothing).
fontFamily: TEXT,
// Script pages end where the last whole speech or paragraph ends. Balancing would add
// blank lines above sluglines to fill them, and push a closing speech to the foot
// (gotcha: balancing-drops-last-box).
balancing: { enabled: false },
levels: [
// Any headings object drops the H1 page break (gotcha: headings-drop-h1-break).
{ level: 1, breakBefore: { enabled: true, parity: 'any' } },
slugline,
] };
numberingTemplate: '{2}' compta els títols de nivell 2, i {number} imprimeix aquest compte al disseny (configuració per nivell). Un títol amb disseny avançat conserva el seu lloc a la columna i dibuixa els seus elements en lloc del seu text (span i disseny avançat), i així els dos números es poden quedar als marges, al costat de l'encapçalament en negreta. Cadascun s'ancora a la caixa del mateix títol i en surt: 0,6 polzades a l'esquerra i, a la dreta, un quart de polzada més enllà del final del text, en una caixa de mitja polzada amb el text alineat a l'esquerra, perquè en un guió de més de nou escenes els números d'una i de dues xifres comencin al mateix lloc. El text propi del títol no es pinta, però es mesura, i per això el nivell va a 12 pt: amb els 15 pt per defecte, l'encapçalament de l'escena 1 passaria a dues línies i reservaria la segona. L'equilibri de columnes està desactivat: afegeix línies en blanc per omplir les pàgines curtes, i amb ell la pàgina 1 del guió deixa una línia en blanc de més sobre l'encapçalament de l'escena 1.
#5 · Forada i numera només les pàgines del guió
// Three holes down the bound edge, 4.25 inches apart, as a three-hole punch leaves them.
const HOLES = [1.25, 5.5, 9.75].map((y) => y * IN); // mm: hole centres from the top
const EDGE = 0.375 * IN; // mm: from the bound edge to the centre of each hole
const HOLE = 7; // mm across
const punched = HOLES.map((y, i) => ({ kind: 'box', id: `punch${i}`,
style: { backgroundColor: col('hole'), borderRadius: mm(HOLE / 2) },
placement: { ...at('page', 'top-left', EDGE - HOLE / 2, y - HOLE / 2),
size: { width: mm(HOLE), height: mm(HOLE) } } }));
// The script's first page opens with its title, an H1 that breaks the page, so the page is
// an 'opener' and pages: 'body' leaves it unnumbered, as the format asks.
const folio = { kind: 'text', id: 'folio', content: '{pageNumber}.', pages: 'body',
fontFamily: TEXT, fontSize: pt(12), color: col('ink'), align: 'right',
placement: at('page', 'top-right', -MARGIN.right * IN, 0.5 * IN) };
// The title's style carries the header of its section: the pages from the title to the end.
const title = { id: 'script', header: { elements: [...punched, folio] },
advancedDesign: { enabled: true, slot: { elements: [
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: TEXT, fontSize: pt(12),
lineHeight: 1, textTransform: 'uppercase', color: col('ink'), align: 'center',
placement: { ...at('container', 'top'), size: { width: 'fill' } } },
] } }, // H1's own line, 1.2 × 18 pt, would reserve 21.6 pt and drop FADE IN: 9.6 pt
lineHeight: pt(LEAD), marginBottom: pt(LEAD) };
L'estil del títol del guió porta la seva pròpia capçalera (header), que substitueix la del document a les pàgines de la seva secció, del títol al final (estils d'encapçalament). La capçalera del document és buida, així que els forats i el número de pàgina només surten al guió. El número s'ancora a mitja polzada de la vora superior del full, i pages: 'body' el deixa fora de les pàgines d'obertura (elements de text). El títol del guió és de nivell 1 i obre pàgina, així que la pàgina 1 compta com a pàgina d'obertura i no porta número, com demana el format. Les cobertes també són títols de nivell 1; :::numbering{startAt=1}, entre l'interior de la coberta i el títol, dona el número 1 a la pàgina del títol, i la següent porta el 2. L'estil del títol fixa una línia de 12 pt; amb els 18 pt propis del nivell 1, el títol aniria en una línia de 21,6 pt i baixaria FUNDIDO DE ENTRADA: 9,6 pt.
La recepta completa
// ═══ Postext Cookbook · Nº 061 · Screenplay format ═══════════════════════════════ // https://postext.dev/en/cookbook/screenplay-format // Code: MIT · Text: original (CC BY 4.0) · Pictures: none // Fonts: Courier Prime, Oswald (SIL OFL 1.1), Special Elite (Apache 2.0) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache } from 'https://esm.sh/postext'; const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'screenplay-format'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { // black type on white bond, and a card cover in goldenrod ink: '#1b1b1b', // every line of the script paper: '#ffffff', hole: '#e3e1db', // the punched holes down the left edge of each script page card: '#e6b84a', // the cover stock cardDark: '#b98a2a', // the label's shadow and the rims of the punched holes cardInk: '#4a3510', // the small print on the cover (6.3:1 on the card) brass: '#a8812f', // the brads brassLight: '#e9cf82', // the glint on each brad stamp: '#8f231c', // the draft stamp (4.7:1 on the card) }; // The hex as well as the id: design slots read only the hex (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); // The engine's defaults link to main-color: pointed at the ink, anything left unset prints black. const colorPalette = Object.entries({ ...palette, 'main-color': palette.ink }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); const TEXT = 'Courier Prime'; const IN = 25.4; // mm in an inch: the format is specified in inches const LEAD = 12; // pt: 12-point Courier at six lines to the inch, so one line is one grid line const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); // #region page: US Letter, a 1.5-inch binding margin, Courier 12 on 12, ragged right const MARGIN = { top: 1, bottom: 1, left: 1.5, right: 1 }; // inches: the left one takes the brads const page = { sizePreset: 'custom', width: mm(8.5 * IN), height: mm(11 * IN), dpi: 150, margins: { top: mm(MARGIN.top * IN), bottom: mm(MARGIN.bottom * IN), left: mm(MARGIN.left * IN), right: mm(MARGIN.right * IN) } }; const bodyText = { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(LEAD), color: col('ink'), textAlign: 'left', firstLineIndent: pt(0), // action: flush left, never justified paragraphSpacing: true, // a blank line between paragraphs // No :ref in this script, but one added later prints in ink, not the default link blue: // main-color does not reach this key (gotcha: palette-skips-designs). referenceColor: col('ink') }; // #endregion // #region answer: a speech is a callout with no frame, padded to the 3.5-inch dialogue block // Positions from the left edge of the sheet, in inches, as screenwriting software gives them. const DIALOGUE = { left: 2.5, right: 6 }; // the block of speech, 3.5 inches wide const CUE = 3.7; // the character's name const TEXT_RIGHT = 8.5 - MARGIN.right; // 7.5: where action lines end const CUE_LINE = 1.2 * 12; // pt: a callout title sits on a line 1.2 times its size const dialogue = { id: 'dialogue', backgroundEnabled: false, // no fill and no border: the box is only a measure padding: { top: pt(LEAD - CUE_LINE), bottom: pt(0), // −2.4 pt: see the title below left: mm((DIALOGUE.left - MARGIN.left) * IN), // 1 inch in from the action right: mm((TEXT_RIGHT - DIALOGUE.right) * IN) }, // 1.5 inches short of it // The fence's title is the cue: title="Dora (cont’d)" prints DORA (CONT’D) at 3.7 inches, // in the headings' Courier, with no gap under it (the default is half a line). Its 14.4-pt // line starts 2.4 pt above the box, so a speech is a whole number of 12-pt lines and the // name sits 0.48 pt above its grid line. titleStyle: { fontWeight: 400, textTransform: 'uppercase', indent: mm((CUE - DIALOGUE.left) * IN), gap: pt(0) }, body: { paragraphSpacing: false }, // no blank line before a parenthetical mid-speech marginTop: pt(LEAD), marginBottom: pt(LEAD), // one blank line above and below // Snapped to the grid, a box keeps its bottom margin and the next speech adds its top // margin: two blank lines between speeches. Unsnapped, the two margins collapse into one. snapToGrid: false, }; // #endregion // #region styles: parentheticals start at 3.1 inches; transitions end at the right margin const PAREN = 3.1; const paragraphStyles = [ { id: 'paren', firstLineIndent: mm((PAREN - DIALOGUE.left) * IN) }, // inside a speech { id: 'transition', textAlign: 'right' }, // CUT TO:, flush with the action's right edge ]; // #endregion // #region slugline: each scene heading carries its number in both margins // Each number sits in a box half an inch wide, text aligned left, so that 9, 12 and an // inserted 12A start at the same place on both sides. const NUMBER_W = 0.5; // inches const number = (id, edge, x) => ({ kind: 'text', id, content: '{number}', fontFamily: TEXT, fontSize: pt(12), fontWeight: 700, lineHeight: 1, color: col('ink'), align: 'left', placement: { ...at('container', edge, x * IN), size: { width: mm(NUMBER_W * IN) } } }); // The heading's own text is not painted but still measured: at the level's default 15 pt, // scene 1's heading would wrap and reserve a second line. const slugline = { level: 2, fontSize: pt(12), lineHeight: pt(LEAD), marginTop: pt(2 * LEAD), marginBottom: pt(LEAD), // two blank lines above, one below numberingTemplate: '{2}', // the scene count, which {number} prints advancedDesign: { enabled: true, slot: { elements: [ { kind: 'text', id: 'heading', content: '{titleText}', fontFamily: TEXT, fontSize: pt(12), fontWeight: 700, lineHeight: 1, color: col('ink'), align: 'left', overflow: 'wrap', placement: { ...at('container', 'top-left'), size: { width: 'fill' } } }, number('left', 'top-left', -0.6), // starts at 0.9 inches from the edge of the sheet number('right', 'top-right', 0.25 + NUMBER_W), // starts a quarter inch past the text ] } } }; const headings = { // The family of the speech titles, and the one the headings' unpainted text is measured // in (left unset, a sixth face, Open Sans Bold, would be loaded for nothing). fontFamily: TEXT, // Script pages end where the last whole speech or paragraph ends. Balancing would add // blank lines above sluglines to fill them, and push a closing speech to the foot // (gotcha: balancing-drops-last-box). balancing: { enabled: false }, levels: [ // Any headings object drops the H1 page break (gotcha: headings-drop-h1-break). { level: 1, breakBefore: { enabled: true, parity: 'any' } }, slugline, ] }; // #endregion // #region furniture: the script's pages are punched, and numbered from page 2 on // Three holes down the bound edge, 4.25 inches apart, as a three-hole punch leaves them. const HOLES = [1.25, 5.5, 9.75].map((y) => y * IN); // mm: hole centres from the top const EDGE = 0.375 * IN; // mm: from the bound edge to the centre of each hole const HOLE = 7; // mm across const punched = HOLES.map((y, i) => ({ kind: 'box', id: `punch${i}`, style: { backgroundColor: col('hole'), borderRadius: mm(HOLE / 2) }, placement: { ...at('page', 'top-left', EDGE - HOLE / 2, y - HOLE / 2), size: { width: mm(HOLE), height: mm(HOLE) } } })); // The script's first page opens with its title, an H1 that breaks the page, so the page is // an 'opener' and pages: 'body' leaves it unnumbered, as the format asks. const folio = { kind: 'text', id: 'folio', content: '{pageNumber}.', pages: 'body', fontFamily: TEXT, fontSize: pt(12), color: col('ink'), align: 'right', placement: at('page', 'top-right', -MARGIN.right * IN, 0.5 * IN) }; // The title's style carries the header of its section: the pages from the title to the end. const title = { id: 'script', header: { elements: [...punched, folio] }, advancedDesign: { enabled: true, slot: { elements: [ { kind: 'text', id: 'title', content: '{titleText}', fontFamily: TEXT, fontSize: pt(12), lineHeight: 1, textTransform: 'uppercase', color: col('ink'), align: 'center', placement: { ...at('container', 'top'), size: { width: 'fill' } } }, ] } }, // H1's own line, 1.2 × 18 pt, would reserve 21.6 pt and drop FADE IN: 9.6 pt lineHeight: pt(LEAD), marginBottom: pt(LEAD) }; // #endregion // #region art: the card covers: a three-hole punch, brass brads, a typed label, two stamps const [W, H] = [8.5 * IN, 11 * IN]; // mm: the sheet const PT = 25.4 / 72; // mm in a point const box = (id, x, y, w, h, style) => ({ kind: 'box', id, style, placement: { ...at('page', 'top-left', x, y), size: { width: mm(w), height: mm(h) } } }); const circle = (id, cx, cy, d, style) => box(id, cx - d / 2, cy - d / 2, d, d, { ...style, borderRadius: mm(d / 2) }); const line = (id, content, x, y, w, style) => ({ kind: 'text', id, content, lineHeight: 1, ...style, placement: { ...at('page', 'top-left', x, y), size: { width: mm(w) } } }); const card = box('card', 0, 0, W, H, { backgroundColor: col('card') }); // Outside, a brass head in the top and bottom holes; the middle hole stays empty, as on // studio scripts, and shows the white page under the cover. const heads = HOLES.flatMap((y, i) => (i === 1 ? [circle('hole', EDGE, y, HOLE, { backgroundColor: col('paper'), borderColor: col('cardDark'), borderWidth: pt(1) })] : [circle(`shade${i}`, EDGE + 0.5, y + 0.7, 11.5, { backgroundColor: col('cardDark') }), circle(`head${i}`, EDGE, y, 11, { backgroundColor: col('brass') }), circle(`glint${i}`, EDGE - 1.8, y - 1.8, 3.6, { backgroundColor: col('brassLight') })])); // Inside, the holes are at the right edge, with the prongs of two brads through them. const prongs = HOLES.flatMap((y, i) => [ circle(`hole${i}`, W - EDGE, y, HOLE, { backgroundColor: col('cardInk') }), ...(i === 1 ? [] : [box(`prong${i}`, W - EDGE - 1.1, y - 4.2, 2.2, 8.4, { backgroundColor: col('brassLight'), borderRadius: mm(1.1) })]), ]); const stamp = (id, x, y, w, h) => [ box(id, x, y, w, h, { borderColor: col('stamp'), borderWidth: pt(1.8), borderRadius: mm(1.5) }), box(`${id}-rim`, x + 1.4, y + 1.4, w - 2.8, h - 2.8, { borderColor: col('stamp'), borderWidth: pt(0.6), borderRadius: mm(1) }), ]; const typed = (size) => ({ fontFamily: TEXT, fontSize: pt(size), color: col('ink') }); // Tracked capitals, centred. 1.4.1 centres a tracked line with the tracking after its last // letter, half a unit left of the middle: the box moves right by that half. const caps = (id, content, x, y, w, size, track, color) => line(id, content, x + (track * PT) / 2, y, w, { align: 'center', fontFamily: 'Oswald', fontWeight: 500, fontSize: pt(size), letterSpacing: pt(track), textTransform: 'uppercase', color: col(color) }); const small = { fontFamily: TEXT, fontSize: pt(7.5), color: col('cardInk'), align: 'left' }; const LABEL = { w: 120, h: 64, y: 78 }; // mm: a white label, centred across the sheet const LABEL_X = (W - LABEL.w) / 2; const DRAFT = { w: 56, h: 20 }; // mm: under the label, flush with its right edge [DRAFT.x, DRAFT.y] = [LABEL_X + LABEL.w - DRAFT.w, LABEL.y + LABEL.h + 9]; const center = { align: 'center' }; // span: 'page' on both: kept in the column, the card is clipped an inch from the top and foot. const cover = { id: 'cover', span: 'page', advancedDesign: { enabled: true, slot: { elements: [ card, ...heads, box('shadow', LABEL_X + 1.2, LABEL.y + 1.4, LABEL.w, LABEL.h, { backgroundColor: col('cardDark'), borderRadius: mm(2) }), box('label', LABEL_X, LABEL.y, LABEL.w, LABEL.h, { backgroundColor: col('paper'), borderRadius: mm(2) }), line('name', '{titleText}', LABEL_X, LABEL.y + 15, LABEL.w, { ...center, fontFamily: 'Special Elite', fontSize: pt(32), textTransform: 'uppercase', color: col('ink') }), line('credit', '{attr.credit}', LABEL_X, LABEL.y + 37, LABEL.w, { ...center, ...typed(12) }), line('author', '{author}', LABEL_X, LABEL.y + 44, LABEL.w, { ...center, ...typed(12) }), ...stamp('draft-box', DRAFT.x, DRAFT.y, DRAFT.w, DRAFT.h), caps('draft', '{attr.draft}', DRAFT.x, DRAFT.y + 3.6, DRAFT.w, 15, 2.2, 'stamp'), caps('date', '{attr.date}', DRAFT.x, DRAFT.y + 12.4, DRAFT.w, 8.5, 1.6, 'stamp'), caps('company', '{attr.company}', 0, 250, W, 10, 3, 'cardInk'), ] } } }; const COPY = { w: 64, h: 24, x: IN, y: IN + 28 }; // mm: the copy number, under the notice const flyleaf = { id: 'flyleaf', span: 'page', // the inside of the cover, facing script page 1 advancedDesign: { enabled: true, slot: { elements: [ card, ...prongs, line('notice', '{attr.notice}', IN, IN, 120, { ...typed(10), lineHeight: 1.3, color: col('cardInk'), align: 'left', overflow: 'wrap' }), ...stamp('copy-box', COPY.x, COPY.y, COPY.w, COPY.h), caps('copy', '{attr.copy}', COPY.x, COPY.y + 6.2, COPY.w, 26, 3.5, 'stamp'), line('colophon', '{attr.colophon}', IN, 250, 150, small), line('fonts', '{attr.fonts}', IN, 254.5, 150, small), ] } } }; // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object colorPalette, page, layout: { layoutType: 'single' }, bodyText, headings, headingStyles: [cover, flyleaf, title], paragraphStyles, calloutStyles: [dialogue], // Empty slots: by default the header prints the section's title at the top of each cover // and the footer a page number at the foot of every page. header: { elements: [] }, footer: { elements: [] }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Mostra en Markdown · 166 línies · content.es.md
title: "Objetos perdidos" author: "Maren Arrieta" --- # Objetos perdidos {style="cover" credit="guion de" draft="Guion de rodaje" date="14 oct. 2026" company="Medialuz Producciones"} # Aviso {style="flyleaf" notice="Este guion es propiedad de Medialuz Producciones y se presta al equipo técnico y artístico. Si lo encuentra, devuélvalo a la oficina de producción: calle del Huerto 40, Puerto Elera." copy="Copia 07" colophon="Guion original para el Recetario de Postext · CC BY 4.0" fonts="Compuesto en Courier Prime, Special Elite y Oswald"} :::numbering{startAt=1} # Objetos perdidos {style="script"} FUNDIDO DE ENTRADA: ## INT. ESTACIÓN DE LA CALLE BRAÑOSA, VESTÍBULO — MAÑANA La lluvia repiquetea en la cubierta de cristal. Los viajeros pasan en tropel bajo el panel de salidas. TEO ANKRAH (24) avanza a contracorriente: esmoquin bajo un anorak empapado, la pajarita hecha un ovillo en un bolsillo, sin equipaje. Se para bajo un cartel. OBJETOS PERDIDOS, y una flecha escaleras abajo. ## INT. OFICINA DE OBJETOS PERDIDOS — CONTINUO Un mostrador largo. Detrás, estanterías hasta el techo: paraguas por centenares, un patinete, un vestido de novia en su bolsa de tintorería, una garza disecada con resguardo. DORA PELÁEZ (68), rebeca, gafas con cadenita, escribe en un libro registro con pluma estilográfica. No levanta la vista. :::callout{type="dialogue" title="Teo"} Buenos días. Me dejé un violonchelo en el último tren de Puerto Elera. El de las 23.40. ::: :::callout{type="dialogue" title="Dora"} :::paragraphs{style="paren"} (pasando una página) ::: ¿Qué coche? ::: :::callout{type="dialogue" title="Teo"} El primero. Junto a la puerta. ::: :::callout{type="dialogue" title="Dora"} ¿De qué color es la funda? ::: :::callout{type="dialogue" title="Teo"} Negra. ::: :::callout{type="dialogue" title="Dora"} Todas son negras. ::: :::callout{type="dialogue" title="Teo"} :::paragraphs{style="paren"} (pausa) ::: Tiene una pegatina en la tapa. Un faro, medio despegado. ::: Dora lo apunta y le pasa un impreso de reclamación. :::callout{type="dialogue" title="Dora"} Nombre y dirección. En mayúsculas. ::: Él lo rellena. Ella lo lee del revés mientras escribe. :::callout{type="dialogue" title="Dora (cont.)"} ¿Y en el bolsillo de la tapa? ::: Teo deja de escribir. :::callout{type="dialogue" title="Teo"} Resina. Un la de repuesto. :::paragraphs{style="paren"} (luego) ::: Y una carta. ::: :::callout{type="dialogue" title="Dora"} ¿Dirigida a quién? ::: :::callout{type="dialogue" title="Teo"} A nadie, todavía. ::: Dora se quita las gafas. Lo mira. :::callout{type="dialogue" title="Dora"} Espere aquí. ::: ## INT. OFICINA DE OBJETOS PERDIDOS, ALMACÉN — CONTINUO Los fluorescentes se encienden a trompicones, pasillo a pasillo. Bicicletas. Maletas. Un cajón de guantes desparejados con el rótulo IZQUIERDOS. Dora recorre el pasillo y se detiene ante la funda de un violonchelo. Una pegatina con un faro, medio despegada. En el asa, una etiqueta parda: PUERTO ELERA 23.40, COCHE 1. Abre el bolsillo de la tapa. Una pastilla de resina. Una cuerda en su sobre de papel. Un sobre blanco, cerrado, sin nada escrito. Sopesa el sobre en la mano y vuelve a guardarlo. ## INT. OFICINA DE OBJETOS PERDIDOS — CONTINUO Dora deja la funda sobre el mostrador. Teo va a cogerla. Ella mantiene la mano sobre la tapa. :::callout{type="dialogue" title="Dora"} Tóqueme algo. ::: :::callout{type="dialogue" title="Teo"} ¿Aquí? ::: :::callout{type="dialogue" title="Dora"} En primavera un señor reclamó un arpa. No supo decirme cuántas cuerdas tenía. ::: A la espalda de Teo se ha formado una cola: una MUJER con un carrito de bebé, un ADOLESCENTE con una sola bota de fútbol. Teo abre los cierres, se sienta en el borde de una silla de plástico y toca los primeros compases del preludio de la primera suite para violonchelo de Bach. Silencio en la oficina. El adolescente baja la bota. Dora espera a que acabe la frase. Luego estampa un sello en el impreso: DEVUELTO. :::callout{type="dialogue" title="Dora"} Firme aquí. ::: Él firma. Ella saca el sobre del bolsillo y se lo tiende. :::callout{type="dialogue" title="Dora (cont.)"} Y eche esto al correo. En el buzón de la entrada recogen a las cinco. ::: :::callout{type="dialogue" title="Teo"} :::paragraphs{style="paren"} (sonriendo) ::: No he escrito para quién es. ::: :::callout{type="dialogue" title="Dora"} Tiene hasta las cinco. ::: :::paragraphs{style="transition"} CORTE A: ::: ## EXT. ESTACIÓN DE LA CALLE BRAÑOSA — DÍA Ha dejado de llover. Teo, con el violonchelo a la espalda, se para ante un buzón amarillo: ÚLTIMA RECOGIDA 17.00 h. Escribe un nombre en el sobre, lo sostiene un momento en la ranura y lo suelta. Se sube el violonchelo al hombro y va hacia el autobús. :::paragraphs{style="transition"} FUNDIDO A NEGRO. :::`; // content.<lang>.md, inlined by the Cookbook // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Courier Prime': ['400', '700'], // the script, the typed label and the colophon 'Special Elite': ['400'], // the title typed on the label Oswald: ['500'], // the stamps and the company }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown); showPages(doc, { title: t({ en: 'Lost Property · a short film', es: 'Objetos perdidos · un cortometraje' }) });Kit · core, fonts, viewer: igual a totes les receptes · 235 línies
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
L'script.js compost funciona tal com és: enganxa'l com a script de mòdul en qualsevol pàgina o obre la recepta a CodePen. Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)
Variants
#Torna les rèpliques a la retícula
Cada rèplica conserva llavors el seu marge inferior i la següent hi suma el superior, cosa que deixa dues línies en blanc entre rèpliques (tres abans d'un encapçalament d'escena) i porta el guió a una quarta pàgina.
- snapToGrid: false,
};#Escriu els encapçalaments d'escena sense negreta
A màquina, els encapçalaments d'escena s'escrivien només en majúscules; la negreta va arribar amb els programes de guió.
- 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',Errors freqüents
Error freqüent
L'equilibrat de columnes baixa al peu el requadre que tanca una pàgina
Quan un requadre és l'últim bloc d'una pàgina després de la qual continua el text, l'equilibrat de columnes (actiu per defecte) passa l'espai que queda a sota per sobre del requadre, que se separa del bloc anterior i acaba a l'última línia de la pàgina. A postext 1.4.1 cap opció de l'equilibrat no el desactiva per separat: headings.balancing.enabled: false apaga tot l'equilibrat, cosa que convé a pàgines que han d'acabar curtes, com una pàgina de poemes. Equilibri de columnes →
Error freqüent
En el text en bandera no s'eviten les línies curtes
optimalLineBreaking, avoidRunts, runtPenalty i runtMinCharacters actuen sobre l'algorisme de Knuth–Plass, que postext 1.4.1 només aplica al text justificat. Un paràgraf en bandera es talla línia a línia i pot acabar en una sola paraula curta, diguin el que diguin aquestes opcions. Revisa les últimes línies del text en bandera i reescriu el paràgraf que acabi en una línia curta. Vídues, òrfenes i línies curtes →
Error freqüent
Qualsevol objecte headings desactiva el salt de pàgina de l'H1
Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració. Capítols que obren en pàgina senar →
Error freqüent
Una paleta canviada no arriba als elements de disseny ni al color de les remissions
postext 1.4.1 aplica colorPalette als estils de text (cos, títols, llistes, peus, taules, requadres), però no als elements de capçaleres, peus de pàgina, obertures i portadelles, ni a bodyText.referenceColor: conserven l'hex escrit al costat del seu paletteId. Si canvies la paleta, per a una edició de pantalla fosca o per recolorejar, reescriu cada color enllaçat a partir de colorPalette abans de compondre. Paleta de color semàntica →
- Postext no imprimeix
(MÁS)sota una rèplica que es parteix entre dues pàgines ni repeteix el nom sobre la resta. DeixakeepTogetheramb el seu valor per defecte perquè cada rèplica passi sencera a la pàgina següent. - Els programes de guió afegeixen
(CONT.)al nom automàticament. Aquí va altitlede la tanca, així que escriu-lo tu on un personatge continua parlant després d'una acció. - El farciment de −2,4 pt serveix per a un títol del mateix cos que el text. Un títol més gran o més petit va en una altra línia, d'1,2 vegades el seu propi cos: torna a calcular el farciment com l'interlineat menys aquesta línia.
Crèdits
- Recepta
- Ignacio Ferro
- Text
- Text original, CC BY 4.0
- Fonts
- Courier Prime (SIL OFL 1.1) · Special Elite (Apache 2.0) · Oswald (SIL OFL 1.1)
- Codi
- MIT, com Postext
Editar aquest text ↗ (s'obre en una pestanya nova)Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)


