Lo que vas a componer
El comienzo de La importancia de llamarse Ernesto, de Oscar Wilde, en una edición para actores de cinco páginas de 140 × 216 mm. La portada es un cartel color ciruela dentro de un doble marco dorado, con Importancia y Ernesto en versales gruesas de cartel y un clavel dorado bajo el título. Frente al primer acto, el reparto nombra al actor que estrenó cada papel en el teatro St James’s en 1895. Cada parlamento empieza con el nombre del personaje en negrita ciruela y sangra 5 mm las líneas siguientes, de modo que el actor ve enseguida cuándo le toca hablar. Las acotaciones van en cursiva gris, estén dentro de un parlamento o entre dos, y al repasar el papel la vista se las salta. La edición española es una traducción nueva que sigue la costumbre del teatro en español: raya tras el nombre y acotaciones entre paréntesis.
Esta receta responde a
- ¿Cómo compongo un guion de cine o una obra de teatro: encabezados de escena, nombres de personaje y diálogo sangrado por los dos lados?
- ¿Cómo doy color a los términos clave (en negrita o cursiva) en el texto o dentro de los recuadros?
- ¿Cómo hago una tabla con filas de cabecera, celdas combinadas, anchos de columna y alineación por celda?
- ¿Cómo compongo ilustraciones sin numerar: adornos, viñetas, logotipos?
La respuesta corta
// Speeches are plain paragraphs, the speaker's name in bold and each stage direction in
// italic; a direction between two speeches is a paragraph of its own, in a container:
// **LANE.** Yes, sir. *[Hands them on a salver.]*
// :::paragraphs{style="direction"}
// *[Enter Lane.]*
// :::
// The body's two emphasis colours then mark who speaks (plum) and what is done (grey).
const dialogue = {
fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
boldColor: col('plum'), // **ALGERNON.**
italicColor: col('muted'), // *[Languidly.]*, in a speech or a paragraph of its own
minWordSpacing: 0.65, maxWordSpacing: 1.8, // justified (the default); limits inside 0.6–2
firstLineIndent: mm(5), hangingIndent: true, // the name at the margin, the turnovers hung
maxRuntTracking: 0, // gotcha: runt-tracking-unpainted
};
// Directions: smaller and centred, on the same grid. A paragraph style has no italic switch
// or colour (gotcha: style-italic-colour): the text is written *…* and takes the grey.
const direction = { id: 'direction', fontSize: pt(8.6),
textAlign: 'center', firstLineIndent: pt(0) };
Ingredientes
- Funciones
- Estilos de párrafoNegrita, cursiva y sus coloresTablas a partir de datosSangrías, alineación y separación de párrafosNiveles de títuloCapítulos que abren en página imparEstilo de tablasEstilos de tabla con nombreTipos de recurso propiosFiguras justo aquíEstilos de títuloCubiertas, portadas y colofonesAtributos de títuloImágenes en los diseños de páginaTextos, filetes y cajas en los diseños de páginaCabeceras y foliosCabeceras según el tipo de páginaMetadatos del documentoColor del papelPaleta de color semántica
- También usa
- Aperturas diseñadasSaltos de página y de columnaFiguras y tablas como recursosCabeceras por secciónEspacio vertical explícito
- Tipografía
- Libre Baskerville, Abril Fatface, Playfair Display SC (SIL OFL 1.1)
- Recursos
- El marco de la portada y el florón del clavel, dibujados en código con la paleta de la página (Ignacio Ferro, MIT)
Elaboración
#1 · Un acto es un título sin más
// Any headings object drops the H1 break: restated (gotcha: headings-drop-h1-break).
const headings = {
fontFamily: LABEL, fontWeight: 400, color: col('ink'),
textAlign: 'center', // every level: the act, the cast list's heads, the scene
lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0), // whole lines of the grid
levels: [
{ level: 1, fontFamily: DISPLAY, fontSize: pt(24), lineHeight: pt(3 * LEAD),
color: col('plum'), textTransform: 'uppercase',
breakBefore: { enabled: true, parity: 'odd' } }, // a recto, facing the cast list
{ level: 2, fontSize: pt(13), lineHeight: pt(2 * LEAD) },
{ level: 3, fontSize: pt(9.5), color: col('plum') },
],
};
El acto se abre con un título de primer nivel en Abril Fatface de 24 pt, sin ranura de diseño. textTransform lo pasa a versales, y las tres líneas de 13,6 pt de la rejilla que ocupa dejan esas versales 5,5 mm por debajo del borde superior de la caja (encabezados). textAlign: 'center' no admite un valor por nivel, así que centra también los títulos del reparto y el rótulo Escena. breakBefore con paridad 'odd' lleva el acto a una página impar, frente al reparto. La configuración tiene que repetirlo, porque cualquier objeto headings desactiva el salto de página del primer nivel (saltar antes).
#2 · El reparto es una tabla leída de líneas con tabuladores
// One row per line, cells split by tabs. A line with no tab spans the table: mergeCells
// joins its cells (gotcha: merged-cells-hiddenby). Each column has its own alignment.
function billTable(tsv, widths, align, headerRowCount = 0) {
let model = { ...parseTSV(tsv.trim()), columnWidths: widths, headerRowCount };
model.rows.forEach((row) => row.forEach((cell, c) => { cell.align = align[c]; }));
tsv.trim().split('\n').forEach((line, r) => {
if (line.includes('\t')) return;
model.rows[r][0].align = 'center';
const end = { row: r, col: widths.length - 1 };
model = mergeCells(model, { start: { row: r, col: 0 }, end });
});
return model;
}
// One filled header cell: two side by side would show a seam (gotcha: table-fill-seams).
const tableStyle = {
rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
headerBackground: col('plum'), headerColor: col('paper'), headerFontFamily: LABEL,
headerFontSize: pt(8.5), headerBold: false, // small capitals from the face itself
bodyFontSize: pt(8.8), cellPadding: mm(1.5), // the body's face and ink, a size smaller
};
const tableStyles = [{ id: 'scenes', rules: 'none', cellPadding: mm(1) }]; // the scenes
Postext no tiene tablas con barras, así que cada tabla es un recurso. Sus filas están en content.cast.es.md, una por línea y con un tabulador entre celda y celda, y parseTSV las convierte en un modelo (construir modelos de tabla). La única línea sin tabulador, la del reparto del estreno con el teatro y la fecha, pasa a ser una fila combinada: mergeCells da a su primera celda colSpan: 2 y deja la segunda en la cuadrícula, marcada con hiddenBy. headerRowCount: 1 pone esa fila en la banda ciruela. Ninguna otra fila lleva relleno, porque el lienzo deja una línea clara entre dos celdas rellenas contiguas. La columna de los actores ocupa un tercio del ancho, y cada una de sus celdas va alineada a la derecha. Los lugares de la acción pasan por la misma función con un estilo de tabla propio, sin filetes (estilos de tabla con nombre). Una tabla compone todo el texto de sus celdas en un solo color, la tinta del texto salvo que bodyColor indique otro, así que aquí la negrita de los personajes sale en tinta y en el diálogo, en ciruela.
#3 · Adornos sin número ni pie
// ::resource{id="fleuron"} sets it where it stands (gotcha: resource-double-quotes).
const unnumbered = (id, name, defaultPlacement) => ({ id, name, shortLabel: '', captionPrefix: '',
numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', defaultPlacement });
const resourceTypes = [
unnumbered('ornament', 'Ornament', { position: 'here', width: 0.24, align: 'center' }),
unnumbered('bill', 'Bill', { position: 'here' }),
];
Un tipo de recurso con captionPrefix y shortLabel vacíos imprime sus ilustraciones solas, sin pie (tipos de recurso). Su defaultPlacement coloca el clavel donde está ::resource{id="fleuron"}, centrado y con un ancho de 0,24 de la medida de 107 mm, unos 26 mm. Las dos tablas usan un segundo tipo, también sin número, así que ninguna lleva el rótulo Tabla 1.
#4 · La portada es un estilo de título
// # The Importance of Being Earnest {style="playbill" small="The" big="Importance" …}
// Each attribute is a playbill line in its own face and size, its lineHeight a multiple
// (gotcha: design-lineheight-multiple). The ground is an image and reserves no height
// (gotcha: opener-image-no-reserve): :::pagebreak keeps the title page alone if its foot
// lines move up.
const line = (id, content, font, size, y, color, extra = {}) => ({ kind: 'text', id, content,
fontFamily: font, fontSize: pt(size), lineHeight: 1, color: col(color), align: 'center',
// Centred and tracked, a line sits half its tracking left of centre: x puts it back.
placement: { anchor: { to: 'page', edge: 'top' },
offset: { x: pt((extra.letterSpacing?.value ?? 0) / 2), y: mm(y) } }, ...extra });
const tracked = (track) => ({ textTransform: 'uppercase', letterSpacing: pt(track) });
const playbill = {
id: 'playbill', span: 'page', // even in one column (gotcha: opener-clipped-at-top)
header: { elements: [] }, footer: { elements: [] }, // no heads on p. 2, no folio on p. 1
advancedDesign: { enabled: true, slot: { elements: [
{ kind: 'image', id: 'ground', resourceId: 'playbill',
placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
line('kicker', '{subtitle}', LABEL, 8.5, 36, 'gilt', tracked(1.7)),
line('small', '{attr.small}', TEXT, 17, 51, 'paper', { italic: true }),
line('big', '{attr.big}', DISPLAY, 40, 60, 'paper', { textTransform: 'uppercase' }),
line('link', '{attr.link}', TEXT, 17, 78, 'gilt', { italic: true }),
line('name', '{attr.name}', DISPLAY, 60, 87, 'paper', { textTransform: 'uppercase' }),
line('author', '{author}', LABEL, 13, 133, 'paper', tracked(3)),
line('theatre', '{attr.theatre}', LABEL, 8, 170, 'gilt', tracked(1.6)),
line('premiere', '{attr.premiere}', TEXT, 8.5, 176, 'paper', { italic: true }),
] } },
};
# La importancia de llamarse Ernesto {style="playbill" …} lleva el título en cuatro atributos que el diseño compone en dos familias: Abril Fatface de 40 y 60 pt para los sustantivos y Libre Baskerville cursiva de 17 pt para La y de llamarse (atributos de encabezado). El fondo ciruela, el marco y el clavel son un solo SVG dibujado en código y anclado al sangrado (elementos de imagen). El estilo lleva span: 'page' aunque el libro tenga una sola columna. Si el diseño se quedara en la columna, se cortaría arriba y abajo por los bordes de la caja de texto, a 20 mm del borde superior de la hoja y a 23,3 mm del inferior. El header vacío del estilo quita las cabeceras del reparto, que pertenece a la misma sección, y su footer vacío quita de la portada el folio al pie, que saldría en ciruela sobre ciruela (estilos de encabezado).
Las imágenes de una apertura no reservan altura, así que el título ocupa la página solo hasta su línea de texto más baja, la del estreno, a 176 mm. El hueco que queda debajo no basta para el título siguiente, y sin el :::pagebreak que sigue a la portada las páginas capturadas salen iguales. El salto está ahí por si se retoca la portada: si lo quitas y subes el teatro y el estreno a 150 y 156 mm, Personajes de la obra empieza sobre la hoja ciruela. En la 1.4.1, una línea centrada con letterSpacing queda desplazada a la izquierda la mitad de ese espaciado, y line() corre cada línea espaciada lo mismo hacia la derecha. OSCAR WILDE queda así a 0,07 mm del centro en lugar de a 0,56.
#5 · Cabeceras que se saltan la primera página del acto
const head = (id, content, parity, edge, x, style) => ({
kind: 'text', id, content, parity, pages: 'body', // never on openers or blank pages
fontFamily: LABEL, fontSize: pt(8.5), letterSpacing: pt(0.8), color: col('muted'),
textTransform: 'uppercase', ...style,
placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(11) } },
});
const folio = { fontFamily: TEXT, letterSpacing: pt(0), color: col('plum') };
const header = { elements: [
head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
head('verso-title', '{title}', 'even', 'top-left', OUTER + 8),
head('recto-act', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 8)),
head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
// The act's first page carries a drop folio instead, centred under the text block.
const footer = { elements: [{ ...head('drop-folio', '{pageNumber}', 'all', 'top', 0, folio),
pages: 'opener', align: 'center',
placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(8) } } }] };
La página par lleva el título de la obra, tomado del frontmatter, y la impar el del acto, de {chapterTitle}; las dos van en versales de Playfair Display SC, con el folio en ciruela en el borde exterior (elementos de texto). La primera página del acto cuenta como apertura porque su primer bloque es un título que salta de página, así que pages: 'body' le quita las cabeceras, y el pie de página le pone en su lugar un folio centrado.
La receta completa
// ═══ Postext Cookbook · Nº 070 · Play script: cast list, speakers and stage directions ═══ // https://postext.dev/en/cookbook/stage-play // Code: MIT · Text: Oscar Wilde, 1895 (PD, Gutenberg #844), Spanish: the Cookbook · Art: in code // Fonts: Libre Baskerville, Abril Fatface, Playfair Display SC (OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, parseTSV, mergeCells } from 'https://esm.sh/postext'; const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'stage-play'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { // every colour in the config links to one of these ink: '#221b1f', // the dialogue: a near-black with a little plum in it plum: '#5b2349', // the one accent: speakers' names, act titles, the title page's ground gold: '#b48a45', // rules and ornaments gilt: '#c9aa77', // gold lightened for small type on the plum ground (5.3:1) muted: '#6c6168', // stage directions and running heads (5.5:1 on the paper) rule: '#d8cbb7', // hairlines between the persons of the cast list paper: '#fbf6ec', // a cream stock }; // The hex travels with the id: designs do not read the palette (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), // The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue. { id: 'main-color', name: 'plum (defaults)', value: { hex: palette.plum, model: 'hex' } }, ]; const [TEXT, DISPLAY, LABEL] = ['Libre Baskerville', 'Abril Fatface', 'Playfair Display SC']; const [BODY, LEAD] = [9.4, 13.6]; // pt: the dialogue and its leading, the pitch of the grid const TRIM = { width: 140, height: 216 }; // mm: 5½ × 8½ in, the acting-edition size const [TOP, INNER, OUTER, LINES] = [20, 18, 15, 36]; // mm, and 36 lines to a full page // #region answer: a speech is a paragraph: the name in bold, the directions in italic // Speeches are plain paragraphs, the speaker's name in bold and each stage direction in // italic; a direction between two speeches is a paragraph of its own, in a container: // **LANE.** Yes, sir. *[Hands them on a salver.]* // :::paragraphs{style="direction"} // *[Enter Lane.]* // ::: // The body's two emphasis colours then mark who speaks (plum) and what is done (grey). const dialogue = { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('plum'), // **ALGERNON.** italicColor: col('muted'), // *[Languidly.]*, in a speech or a paragraph of its own minWordSpacing: 0.65, maxWordSpacing: 1.8, // justified (the default); limits inside 0.6–2 firstLineIndent: mm(5), hangingIndent: true, // the name at the margin, the turnovers hung maxRuntTracking: 0, // gotcha: runt-tracking-unpainted }; // Directions: smaller and centred, on the same grid. A paragraph style has no italic switch // or colour (gotcha: style-italic-colour): the text is written *…* and takes the grey. const direction = { id: 'direction', fontSize: pt(8.6), textAlign: 'center', firstLineIndent: pt(0) }; // #endregion // #region acts: an act title is a plain first-level heading, centred and set in capitals // Any headings object drops the H1 break: restated (gotcha: headings-drop-h1-break). const headings = { fontFamily: LABEL, fontWeight: 400, color: col('ink'), textAlign: 'center', // every level: the act, the cast list's heads, the scene lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0), // whole lines of the grid levels: [ { level: 1, fontFamily: DISPLAY, fontSize: pt(24), lineHeight: pt(3 * LEAD), color: col('plum'), textTransform: 'uppercase', breakBefore: { enabled: true, parity: 'odd' } }, // a recto, facing the cast list { level: 2, fontSize: pt(13), lineHeight: pt(2 * LEAD) }, { level: 3, fontSize: pt(9.5), color: col('plum') }, ], }; // #endregion // #region cast: the cast list: a table from tab-separated lines, with merged rows // One row per line, cells split by tabs. A line with no tab spans the table: mergeCells // joins its cells (gotcha: merged-cells-hiddenby). Each column has its own alignment. function billTable(tsv, widths, align, headerRowCount = 0) { let model = { ...parseTSV(tsv.trim()), columnWidths: widths, headerRowCount }; model.rows.forEach((row) => row.forEach((cell, c) => { cell.align = align[c]; })); tsv.trim().split('\n').forEach((line, r) => { if (line.includes('\t')) return; model.rows[r][0].align = 'center'; const end = { row: r, col: widths.length - 1 }; model = mergeCells(model, { start: { row: r, col: 0 }, end }); }); return model; } // One filled header cell: two side by side would show a seam (gotcha: table-fill-seams). const tableStyle = { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), headerBackground: col('plum'), headerColor: col('paper'), headerFontFamily: LABEL, headerFontSize: pt(8.5), headerBold: false, // small capitals from the face itself bodyFontSize: pt(8.8), cellPadding: mm(1.5), // the body's face and ink, a size smaller }; const tableStyles = [{ id: 'scenes', rules: 'none', cellPadding: mm(1) }]; // the scenes // #endregion // #region ornament: a resource type for artwork with no number and no caption // ::resource{id="fleuron"} sets it where it stands (gotcha: resource-double-quotes). const unnumbered = (id, name, defaultPlacement) => ({ id, name, shortLabel: '', captionPrefix: '', numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', defaultPlacement }); const resourceTypes = [ unnumbered('ornament', 'Ornament', { position: 'here', width: 0.24, align: 'center' }), unnumbered('bill', 'Bill', { position: 'here' }), ]; // #endregion // #region playbill: the title page, a heading style whose design fills the sheet // # The Importance of Being Earnest {style="playbill" small="The" big="Importance" …} // Each attribute is a playbill line in its own face and size, its lineHeight a multiple // (gotcha: design-lineheight-multiple). The ground is an image and reserves no height // (gotcha: opener-image-no-reserve): :::pagebreak keeps the title page alone if its foot // lines move up. const line = (id, content, font, size, y, color, extra = {}) => ({ kind: 'text', id, content, fontFamily: font, fontSize: pt(size), lineHeight: 1, color: col(color), align: 'center', // Centred and tracked, a line sits half its tracking left of centre: x puts it back. placement: { anchor: { to: 'page', edge: 'top' }, offset: { x: pt((extra.letterSpacing?.value ?? 0) / 2), y: mm(y) } }, ...extra }); const tracked = (track) => ({ textTransform: 'uppercase', letterSpacing: pt(track) }); const playbill = { id: 'playbill', span: 'page', // even in one column (gotcha: opener-clipped-at-top) header: { elements: [] }, footer: { elements: [] }, // no heads on p. 2, no folio on p. 1 advancedDesign: { enabled: true, slot: { elements: [ { kind: 'image', id: 'ground', resourceId: 'playbill', placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } }, line('kicker', '{subtitle}', LABEL, 8.5, 36, 'gilt', tracked(1.7)), line('small', '{attr.small}', TEXT, 17, 51, 'paper', { italic: true }), line('big', '{attr.big}', DISPLAY, 40, 60, 'paper', { textTransform: 'uppercase' }), line('link', '{attr.link}', TEXT, 17, 78, 'gilt', { italic: true }), line('name', '{attr.name}', DISPLAY, 60, 87, 'paper', { textTransform: 'uppercase' }), line('author', '{author}', LABEL, 13, 133, 'paper', tracked(3)), line('theatre', '{attr.theatre}', LABEL, 8, 170, 'gilt', tracked(1.6)), line('premiere', '{attr.premiere}', TEXT, 8.5, 176, 'paper', { italic: true }), ] } }, }; // #endregion // #region heads: the play on the verso, the act on the recto, folios outside const head = (id, content, parity, edge, x, style) => ({ kind: 'text', id, content, parity, pages: 'body', // never on openers or blank pages fontFamily: LABEL, fontSize: pt(8.5), letterSpacing: pt(0.8), color: col('muted'), textTransform: 'uppercase', ...style, placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(11) } }, }); const folio = { fontFamily: TEXT, letterSpacing: pt(0), color: col('plum') }; const header = { elements: [ head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio), head('verso-title', '{title}', 'even', 'top-left', OUTER + 8), head('recto-act', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 8)), head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio), ] }; // The act's first page carries a drop folio instead, centred under the text block. const footer = { elements: [{ ...head('drop-folio', '{pageNumber}', 'all', 'top', 0, folio), pages: 'opener', align: 'center', placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(8) } } }] }; // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object locale: t({ en: 'en-us', es: 'es' }), // hyphenation by exact code (gotcha: hyphenation-locales) colorPalette, resourceTypes, page: { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), backgroundColor: col('paper'), margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - (LINES * LEAD * 25.4) / 72), left: mm(INNER), right: mm(OUTER), mirror: true }, }, layout: { layoutType: 'single' }, bodyText: dialogue, headings, headingStyles: [playbill], paragraphStyles: [direction, { id: 'colophon', fontSize: pt(7.5), color: col('muted'), textAlign: 'center', firstLineIndent: pt(0), marginTop: pt(LEAD) }], tableStyle, tableStyles, header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Muestra en Markdown · 126 líneas · content.es.md
title: "La importancia de llamarse Ernesto" subtitle: "Comedia trivial para gente seria" author: "Oscar Wilde" --- # La importancia de llamarse Ernesto {style="playbill" small="La" big="Importancia" link="de llamarse" name="Ernesto" theatre="Teatro St James’s · Londres" premiere="Estrenada el 14 de febrero de 1895"} :::pagebreak ## Personajes de la obra ::resource{id="persons"} ## Lugares de la acción ::resource{id="scenes"} :::paragraphs{style="colophon"} Texto inglés: Proyecto Gutenberg, libro electrónico n.º 844, de dominio público; traducción de esta edición. Compuesto en Libre Baskerville, Abril Fatface y Playfair Display SC (SIL Open Font License). ::: # Primer acto ::resource{id="fleuron"} ### Escena :::paragraphs{style="direction"} *Saloncito en el piso de Algernon, en Half-Moon Street. El mobiliario es lujoso y artístico. En la habitación contigua suena un piano.* *(Lane está preparando el té de la tarde en la mesa y, cuando cesa la música, entra Algernon.)* ::: :::space **ALGERNON.**—¿Ha oído lo que estaba tocando, Lane? **LANE.**—No me pareció correcto escuchar, señor. **ALGERNON.**—Lo lamento por usted. Yo no toco con exactitud —cualquiera puede tocar con exactitud—, pero toco con una expresión maravillosa. En lo que al piano se refiere, mi fuerte es el sentimiento. La ciencia la reservo para la Vida. **LANE.**—Sí, señor. **ALGERNON.**—Y, hablando de la ciencia de la Vida, ¿ha cortado los emparedados de pepino para lady Bracknell? **LANE.**—Sí, señor. *(Se los ofrece en una bandeja.)* **ALGERNON.**—*(Los examina, toma dos y se sienta en el sofá.)* ¡Ah!… Por cierto, Lane: veo en su libro que el jueves por la noche, cuando lord Shoreman y el señor Worthing vinieron a cenar, se anotaron como consumidas ocho botellas de champán. **LANE.**—Sí, señor; ocho botellas y una pinta. **ALGERNON.**—¿Por qué en casa de un soltero los criados se beben siempre el champán? Lo pregunto solo por saberlo. **LANE.**—Lo atribuyo a la calidad superior del vino, señor. He notado a menudo que en las casas de los casados el champán rara vez es de primera. **ALGERNON.**—¡Santo cielo! ¿Tanto desmoraliza el matrimonio? **LANE.**—Yo creo que *es* un estado muy agradable, señor. Mi experiencia en ese terreno es, hasta ahora, muy escasa. Solo me he casado una vez. Fue a consecuencia de un malentendido entre una joven y yo. **ALGERNON.**—*(Lánguidamente.)* No sé si me interesa mucho su vida familiar, Lane. **LANE.**—No, señor; no es un tema muy interesante. Yo mismo nunca pienso en él. **ALGERNON.**—Muy natural. Puede retirarse, Lane, gracias. **LANE.**—Gracias, señor. *(Sale Lane.)* **ALGERNON.**—Las ideas de Lane sobre el matrimonio parecen algo relajadas. La verdad, si las clases bajas no nos dan buen ejemplo, ¿para qué demonios sirven? Como clase, parecen carecer de todo sentido de la responsabilidad moral. :::paragraphs{style="direction"} *(Entra Lane.)* ::: **LANE.**—El señor Ernesto Worthing. :::paragraphs{style="direction"} *(Entra Jack.)* *(Sale Lane.)* ::: **ALGERNON.**—¿Qué tal, querido Ernesto? ¿Qué te trae por aquí? **JACK.**—¡Oh, el placer, el placer! ¿Hay otra razón para ir de un sitio a otro? ¡Comiendo, como siempre, Algy! **ALGERNON.**—*(Muy digno.)* Creo que en la buena sociedad se acostumbra tomar un ligero refrigerio a las cinco. ¿Dónde has estado desde el jueves pasado? **JACK.**—*(Sentándose en el sofá.)* En el campo. **ALGERNON.**—¿Y qué demonios haces allí? **JACK.**—*(Quitándose los guantes.)* Cuando uno está en la ciudad, se divierte. Cuando está en el campo, divierte a los demás. Es de lo más aburrido. **ALGERNON.**—¿Y quiénes son esas personas a las que diviertes? **JACK.**—*(Con ligereza.)* ¡Oh!, los vecinos, los vecinos. **ALGERNON.**—¿Tienes vecinos simpáticos en Shropshire? **JACK.**—¡Espantosos! No hablo con ninguno. **ALGERNON.**—¡Cuánto debes de divertirlos! *(Se acerca y toma un emparedado.)* Por cierto, Shropshire es tu condado, ¿no? **JACK.**—¿Eh? ¿Shropshire? Sí, claro. ¡Vaya! ¿Por qué tantas tazas? ¿Por qué emparedados de pepino? ¿Por qué semejante derroche en alguien tan joven? ¿Quién viene a tomar el té? **ALGERNON.**—¡Oh!, solo tía Augusta y Gwendolen. **JACK.**—¡Qué delicia! **ALGERNON.**—Sí, todo eso está muy bien, pero me temo que tía Augusta no verá con buenos ojos que estés aquí. **JACK.**—¿Puedo preguntar por qué? **ALGERNON.**—Querido amigo, tu coqueteo con Gwendolen es una verdadera vergüenza. Casi tanto como el de ella contigo. **JACK.**—Estoy enamorado de Gwendolen. He venido a la ciudad expresamente para declararme. **ALGERNON.**—¿No venías por placer?… Eso son negocios. **JACK.**—¡Qué poco romántico eres! **ALGERNON.**—Pues yo no veo qué tiene de romántico declararse. Estar enamorado es muy romántico; una declaración en toda regla, en cambio, no tiene nada de romántico. Porque, vamos, a uno pueden decirle que sí. Suelen decírselo, creo. Y entonces se acabó la emoción. La esencia misma del romance es la incertidumbre. Si alguna vez me caso, haré todo lo posible por olvidarlo. **JACK.**—No lo dudo, querido Algy. El Tribunal de Divorcios se inventó expresamente para las personas de memoria tan curiosamente constituida. **ALGERNON.**—¡Oh!, no vale la pena especular sobre ese asunto. Divorcio y mortaja, del cielo baja… *(Jack alarga la mano para tomar un emparedado. Algernon se lo impide al instante.)* Haz el favor de no tocar los emparedados de pepino. Los han encargado especialmente para tía Augusta. *(Toma uno y se lo come.)*`; // content.<lang>.md, inlined by the Cookbook const cast = String.raw`Reparto del estreno · Teatro St James’s, 14 de febrero de 1895Muestra en Markdown · 9 líneas · content.cast.es.md
**John Worthing**, juez de paz *Mr. George Alexander* **Algernon Moncrieff** *Mr. Allen Aynesworth* **Canónigo Chasuble**, doctor en Teología *Mr. H. H. Vincent* **Merriman**, mayordomo *Mr. Frank Dyall* **Lane**, criado *Mr. F. Kinsey Peile* **Lady Bracknell** *Miss Rose Leclercq* **La honorable Gwendolen Fairfax** *Miss Irene Vanbrugh* **Cecily Cardew** *Miss Evelyn Millard* **Miss Prism**, institutriz *Mrs. George Canninge*`; // content.cast.<lang>.md: the persons, tab-separated const scenes = String.raw`Acto I Piso de Algernon Moncrieff en Half-Moon Street, W.Muestra en Markdown · 3 líneas · content.scenes.es.md
Acto II Jardín de la casa solariega de Woolton. Acto III Salón de la casa solariega de Woolton. *Época: la actual.*`; // content.scenes.<lang>.md: the acts and their places // #region art: the title page's ground and double frame, and the carnation fleuron, in mm let seed = 1895; // Mulberry32, a seeded PRNG: never Math.random() in a recipe const rand = () => { let r = Math.imul((seed = (seed + 0x6d2b79f5) | 0) ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; const f = (n) => +n.toFixed(2); const mix = (a, b, k) => `#${[1, 3, 5].map((i) => Math.round(parseInt(palette[a].slice(i, i + 2), 16) * (1 - k) + parseInt(palette[b].slice(i, i + 2), 16) * k).toString(16).padStart(2, '0')) .join('')}`; const svgOf = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" ` + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`; const pts = (list) => list.map(([x, y]) => `${f(x)} ${f(y)}`).join('L'); const poly = (list, fill) => `<path d="M${pts(list)}Z" fill="${fill}"/>`; const stroke = (list, color, w) => `<path d="M${pts(list)}" fill="none" stroke="${color}" ` + `stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round"/>`; const quad = ([x0, y0], [cx, cy], [x1, y1], t) => [ (1 - t) ** 2 * x0 + 2 * (1 - t) * t * cx + t * t * x1, (1 - t) ** 2 * y0 + 2 * (1 - t) * t * cy + t * t * y1]; // A carnation `s` mm tall standing on (cx, cy), seen from the side: three fans of pinked // petals spring from the rim of a calyx, notched apart in the ground colour behind them. const DEG = Math.PI / 180; function carnation(cx, cy, s, [back, mid, front], green, ground) { const out = []; const [rx, ry] = [cx, cy - s * 0.32]; // the rim of the calyx const fan = (from, to, r, lift, petals, fill) => { // a fan from `from`° to `to`°, r mm deep const at = (deg, k) => [rx + Math.cos(deg * DEG) * r * k, ry - lift + Math.sin(deg * DEG) * r * k * 0.92]; const edge = []; for (let i = 0; i <= petals * 6; i++) { // six teeth to a petal, each petal a rounded lobe const lobe = 0.84 + 0.16 * Math.sin((Math.PI * (i % 6)) / 6); edge.push(at(from + ((to - from) * i) / (petals * 6), (i % 2 ? 0.9 : 1) * lobe * (0.98 + rand() * 0.04))); } out.push(poly([[rx, ry - lift], ...edge], fill)); const notch = (to - from) / petals / 14; // half the angle of a notch at the edge for (let p = 1; p < petals; p++) { // a thin wedge between two petals const deg = from + ((to - from) * p) / petals; out.push(poly([at(deg, 0.66), at(deg - notch, 1.1), at(deg + notch, 1.1)], ground)); } }; fan(-162, -18, s * 0.74, 0, 7, back); fan(-146, -34, s * 0.56, s * 0.06, 5, mid); fan(-124, -56, s * 0.36, s * 0.1, 3, front); // The calyx narrows to the stem; three short sepals rise over the petals, and an outline in // the ground colour keeps it clear of them. const [a, b] = [s * 0.11, s * 0.045]; // half-widths at the rim and at the stem const cup = [[rx - a, ry + s * 0.03], [rx - a * 1.25, ry - s * 0.07], [rx - a * 0.45, ry - s * 0.01], [rx, ry - s * 0.1], [rx + a * 0.45, ry - s * 0.01], [rx + a * 1.25, ry - s * 0.07], [rx + a, ry + s * 0.03], [cx + b, cy], [cx - b, cy]]; out.push(`<path d="M${pts(cup)}Z" fill="${green}" stroke="${ground}" ` + `stroke-width="${f(s * 0.03)}" stroke-linejoin="round"/>`); return out.join(''); } // A scroll: an arm out from the stem that ends in a spiral curl. function scroll(x0, y0, dir, len, curl, color, w) { const list = []; for (let i = 0; i <= 24; i++) { const t = i / 24; list.push([x0 + dir * len * t, y0 + Math.sin(t * Math.PI) * curl * 0.3 - t * curl * 0.25]); } const [ex, ey] = list[list.length - 1]; for (let i = 1; i <= 48; i++) { const a = (i / 48) * Math.PI * 1.8; const r = curl * 0.5 * Math.exp(-0.38 * a); list.push([ex + dir * Math.sin(a) * r, ey - curl * 0.5 + Math.cos(a) * r]); } return stroke(list, color, w); } // A carnation leaf: a narrow blade along a curve from its base. function blade(p0, c, p1, w, fill) { const [left, right] = [[], []]; for (let i = 0; i <= 16; i++) { const t = i / 16; const [x, y] = quad(p0, c, p1, t); const [x2, y2] = quad(p0, c, p1, Math.min(1, t + 0.01)); const [dx, dy] = [x2 - x, y2 - y]; const k = (w * Math.sin(Math.PI * Math.min(1, t * 1.15)) ** 0.7) / 2 / (Math.hypot(dx, dy) || 1); left.push([x - dy * k, y + dx * k]); right.unshift([x + dy * k, y - dx * k]); } return poly([...left, ...right], fill); } // The fleuron, w × h mm: the carnation between two leaves and two scrolls, on `ground`. function fleuron(w, h, petals, green, ground) { const [cx, base] = [w / 2, h * 0.9]; const parts = [-1, 1].map((d) => scroll(cx + d * 0.6, base, d, w * 0.38, h * 0.46, green, h * 0.035) + blade([cx + d * 0.8, base - 0.2], [cx + d * w * 0.12, base - h * 0.02], [cx + d * w * 0.24, base - h * 0.3], h * 0.07, green)); return svgOf(w, h, parts.join('') + carnation(cx, base, h * 0.88, petals, green, ground)); } // The title page: a plum sheet, a double gold frame whose rules cross at the corners // (Oxford corners), and the carnation in gold between the title and the author. const FLEURON_Y = 109; // mm: the top of the title page's carnation function playbillArt(W, H) { const out = [`<rect width="${W}" height="${H}" fill="${palette.plum}"/>`]; const frame = (inset, w, reach) => { // four rules `inset` mm in, running `reach` mm past const [a, bx, by] = [inset, W - inset, H - inset]; for (const [p, q] of [[[a - reach, a], [bx + reach, a]], [[a - reach, by], [bx + reach, by]], [[a, a - reach], [a, by + reach]], [[bx, a - reach], [bx, by + reach]]]) { out.push(stroke([p, q], palette.gold, w)); } }; frame(9, 0.55, 3.2); frame(11, 0.22, -1.2); for (const [x, y] of [[9, 9], [W - 9, 9], [9, H - 9], [W - 9, H - 9]]) { out.push(`<circle cx="${x}" cy="${y}" r="0.9" fill="${palette.gold}"/>`); } const petals = [mix('gold', 'plum', 0.35), palette.gold, mix('paper', 'gold', 0.2)]; const sprig = fleuron(46, 16, petals, palette.gold, palette.plum) .replace(/^<svg[^>]*>|<\/svg>$/g, ''); out.push(`<g transform="translate(${(W - 46) / 2} ${FLEURON_Y})">${sprig}</g>`); return svgOf(W, H, out.join('')); } await loadSvg('playbill.svg', playbillArt(TRIM.width, TRIM.height)); await loadSvg('fleuron.svg', fleuron(30, 11, [mix('plum', 'ink', 0.4), mix('plum', 'paper', 0.12), mix('plum', 'paper', 0.45)], palette.gold, palette.paper)); const svg = (id, w, h, altText) => ({ id, typeId: 'ornament', kind: 'svg', altText, svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, createdAt: 0, updatedAt: 0 }); const art = [ svg('playbill', TRIM.width, TRIM.height, 'A plum title page in a double gold frame whose ' + 'rules cross at the corners, with a gold carnation between two scrolls.'), svg('fleuron', 30, 11, 'Ornament: a plum carnation between two gold scrolls.'), ]; // #endregion const table = (id, model, styleId) => ({ id, typeId: 'bill', kind: 'table', table: { model, styleId }, createdAt: 0, updatedAt: 0 }); const resources = [...art, table('persons', billTable(cast, [2, 1], ['left', 'right'], 1)), // the first line heads it table('scenes', billTable(scenes, [3, 17], ['right', 'left']), 'scenes'), ]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Libre Baskerville': ['400', '400i', '700'], 'Abril Fatface': ['400'], 'Playfair Display SC': ['400'] }; // all loaded before the first build (gotcha: fonts-first) // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown + cast + scenes); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: doc.metadata.title }); // the frontmatter's titleKit · core, fonts, viewer, images: igual en todas las recetas · 270 líneas
// ─── 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 · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
El script.js compuesto funciona tal cual: pégalo como script de módulo en cualquier página o abre la receta en CodePen. Carpeta de la receta en GitHub ↗
Variantes
#Alinea las acotaciones con las líneas sangradas
En bandera y con una sangría de primera línea de 5 mm, una acotación de una línea arranca a la altura de las líneas sangradas de los parlamentos, y la descripción de la escena se convierte en dos párrafos sangrados bajo su rótulo centrado.
const direction = { id: 'direction', fontSize: pt(8.6),
- textAlign: 'center', firstLineIndent: pt(0) };
+ textAlign: 'left', firstLineIndent: mm(5) };#Deja una línea entre parlamentos
paragraphSpacing: true añade una línea en blanco de la rejilla tras cada parlamento. El acto inglés pasa entonces de tres a cinco páginas, y el español, de tres a cuatro.
maxRuntTracking: 0, // gotcha: runt-tracking-unpainted
+ paragraphSpacing: true,
};Errores frecuentes
Error frecuente
Cualquier objeto headings desactiva el salto de página del H1
Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración. Capítulos que abren en página impar →
Error frecuente
Un estilo de párrafo no tiene color de cursiva
En postext 1.4.1 un estilo de párrafo fija color y boldColor, pero no italicColor: sus cursivas toman bodyText.italicColor. Un estilo atenuado (la letra pequeña, la línea «Fuente:» de una tabla) imprime sus títulos en cursiva más oscuros que el texto que los rodea. Deja esos estilos en el color del texto o evita en ellos las cursivas. Estilos de párrafo →
Error frecuente
Una tabla 'here' nunca se parte
Solo se parten entre columnas y páginas las tablas flotantes; una tabla colocada 'here' se mueve entera. Deja flotar las tablas largas o mantén cortas las tablas en línea. Tablas que pasan de página →
Error frecuente
::resource{id="…"} solo admite comillas dobles
Una inserción de bloque solo se reconoce como ::resource{id="…"} con comillas dobles; cualquier otra forma se queda en el texto como una línea visible. Figuras justo aquí →
Error frecuente
Una paleta cambiada no llega a los elementos de diseño ni al color de las remisiones
postext 1.4.1 aplica colorPalette a los estilos de texto (cuerpo, títulos, listas, pies, tablas, recuadros), pero no a los elementos de cabeceras, pies de página, aperturas y portadillas, ni a bodyText.referenceColor: conservan el hex escrito junto a su paletteId. Si cambias la paleta, para una edición de pantalla oscura o para recolorear, reescribe cada color enlazado a partir de colorPalette antes de componer. Paleta de color semántica →
Error frecuente
El lineHeight de un texto de diseño es un múltiplo, nunca una medida
En una ranura de diseño, el lineHeight de un elemento de texto multiplica su cuerpo (lineHeight: 1.05). En postext 1.4.1 una medida como pt(15) no da error: la altura de la apertura sale NaN, el espacio que reserva, minHeight incluido, se pierde sin aviso y el texto se superpone al título. Textos, filetes y cajas en los diseños de página →
Error frecuente
El arreglo de las líneas cortas puede apretar un interletraje que nunca se pinta
En postext 1.4.1, cuando un párrafo acaba en una línea corta, el motor lo compone con una línea menos: primero aprieta el espacio entre palabras y luego aplica hasta maxRuntTracking milésimas de em de interletraje negativo. Los renderizadores de canvas y PDF solo pintan el interletraje mayor que cero, así que el párrafo se imprime sin él: sus líneas justificadas pierden esa diferencia en los espacios entre palabras, que salen aplastados, y su última línea puede pasarse de la medida y quedar cortada en el borde de la columna. Pon bodyText.maxRuntTracking: 0, que conserva el arreglo por el espacio entre palabras, y reescribe los párrafos que vuelvan a acabar en una línea corta. Viudas, huérfanas y líneas cortas →
Error frecuente
Entrecomilla cada valor del frontmatter
YAML lee title: 1984 como un número y una fecha como un objeto Date, y los valores que no son cadenas se imprimen vacíos en los marcadores y dejan el PDF sin título. Entrecomilla cada valor: title: "1984". Metadatos del documento →
Error frecuente
Solo 8 idiomas tienen separación silábica, con el código exacto
La separación silábica existe para en-us, es, fr, de, it, pt, ca y nl, con el código exacto: 'es-ES' o cualquier otro idioma pasa sin aviso al inglés americano. Separación silábica e idioma del documento →
Error frecuente
Carga todas las fuentes antes de componer
La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde. Fuentes antes de componer →
Error frecuente
Una apertura que se queda en su columna se corta en la cabeza de la caja de texto
En postext 1.4.1, un título con diseño avanzado que se queda en su columna se recorta por el borde superior de la columna: una caja o una imagen ancladas a la página o a la sangre se pintan en los márgenes laterales, pero no en el de cabeza, y ningún aviso lo dice. Dale span: 'page' a ese título, aunque el libro tenga una sola columna: su diseño se pinta entonces entero, como banda de apertura de la página. Aperturas diseñadas →
Error frecuente
Las imágenes de una apertura no cuentan para la altura que reserva
En postext 1.4.1, un título con diseño avanzado mide la altura que reserva sin contar sus imágenes: sus textos, filetes y cajas cuentan, aunque estén anclados a la página, pero una imagen, como un dibujo a sangre en la cabeza de la página, no reserva nada, así que el texto puede empezar encima de ella. Fija con minHeight dónde debe empezar el texto. Aperturas diseñadas →
Error frecuente
Entre dos celdas de tabla rellenas asoma una línea clara en el canvas
En postext 1.4.1, el renderizador de canvas rellena cada celda de la tabla con un rectángulo propio en posiciones de píxel fraccionarias, así que donde se tocan dos celdas rellenas (las de una fila de cabecera en color o celdas del cuerpo con fondo) asoma entre ellas una línea finísima del color del papel. Rellena solo filas cuyas celdas estén combinadas en una, traza los filetes en el color del relleno o deja las celdas sin relleno. Estilo de tablas →
- Toda cursiva del diálogo toma
italicColor, también la de énfasis, y el es que subraya Lane sale en el gris de las acotaciones. El gris tiene que ir enbodyTextporque en la 1.4.1 un estilo de párrafo no puede dar a su cursiva un color propio; solo en redonda quedaría ese es en tinta. - En la edición inglesa, tres parlamentos de Wilde terminan en una palabra sola: information., that? y you. El script pone
bodyText.maxRuntTracking: 0por el error El arreglo de las líneas cortas puede apretar un interletraje que nunca se pinta. Con el valor por defecto, la 1.4.1 sube that? a la línea anterior con 2,0 mm de interletraje negativo que el lienzo no pinta, y la línea sale con los espacios entre palabras a la mitad de su ancho. Tocando solo el espacio entre palabras, that? sube únicamente con unminWordSpacingde 0,5, que aprieta la línea igual, o con una medida 1,5 mm más ancha, que deja diez líneas por encima de 1,5 veces el espacio normal en lugar de dos. Las tres líneas cortas se quedan, para no tocar el texto de Wilde. La traducción española se reescribió allí donde una línea acababa en una palabra de una letra o donde una sílaba quedaba sola. - Los nombres se dividen como cualquier otra palabra: en la página 5 de la edición inglesa, Au-gusta y Gwen-dolen se parten entre dos líneas. Un carácter de unión de palabras (U+2060) dentro de Augusta mantiene el nombre entero, pero estira la línea anterior hasta 1,71 veces el espacio normal, así que el texto inglés conserva los guiones.
- En el lienzo, Playfair Display SC dibuja fi como una ligadura en minúscula, así que first saldría con una fi minúscula entre versalitas. Por eso la banda del reparto inglés dice The original cast.
Créditos
- Receta
- Ignacio Ferro
- Texto
- The Importance of Being Earnest (La importancia de llamarse Ernesto, 1895): los personajes y los lugares de la obra, el reparto del estreno y el comienzo del primer acto · Oscar Wilde · dominio público
- La traducción al español del fragmento, del reparto y de los lugares de la acción · Postext Cookbook · original
- Imágenes
- El marco de la portada y el florón del clavel, dibujados en código con la paleta de la página · Ignacio Ferro · MIT
- Fuentes
- Libre Baskerville (SIL OFL 1.1) · Abril Fatface (SIL OFL 1.1) · Playfair Display SC (SIL OFL 1.1)
- Código
- MIT, como Postext


