Lo que vas a componer
Las primeras páginas de una edición infantil italiana de Le avventure di Pinocchio, de Collodi, compuestas en Averia Serif Libre de 13/19 pt, en formato de 190 × 240 mm. Frente al capítulo I, en la página par, va una portada ilustrada: un muñeco de madera articulado, con un traje de papel floreado, baila en una colina toscana por encima del título en Fredericka the Great. Cada capítulo empieza bajo una viñeta en un óvalo azul cielo (el banco de maestro Ciliegia y el leño que habla en el capítulo I, la peluca amarilla de Geppetto y una olla de polenta en el II) y sigue con la línea del capítulo y el sumario de Collodi en cursiva centrada. Solo llevan cabecera las páginas 8 y 9, las dos sin apertura. Los folios van en discos color madera, y el capítulo I termina con dos cerezas sin pie ni número.
Esta receta responde a
- ¿Cómo doy a cada capítulo su propia foto, su color o una variante de apertura?
- ¿Cómo compongo ilustraciones sin numerar: adornos, viñetas, logotipos?
- ¿Cómo añado a una apertura una línea de autor, una entradilla o un primer párrafo con capitular?
- ¿Cómo oculto las cabeceras en las aperturas y las páginas en blanco, o pinto una página par en blanco con el color de la parte?
La respuesta corta
// # Capitolo I {style="ceppo" summary="Come andò che Maestro Ciliegia…"}
// An image element draws a resource by a fixed id, so each vignette gets a heading style;
// every style is the same opener with its own picture.
const VIGNETTE = { width: 84, height: 56 }; // mm, centred at the head of the text block
const SUMMARY = 124; // mm: the summary's measure, narrower than the text's
const opener = (vignette) => ({ enabled: true,
// A floor for the height the opener reserves, in whole lines, so the text under chapter I's
// two-line summary starts on the same grid line as the text under chapter II's three lines.
minHeight: pt(14 * LEAD),
slot: { elements: [
{ kind: 'image', id: 'vignette', resourceId: vignette, placement: at('container', 'top',
0, 0, { width: mm(VIGNETTE.width), height: mm(VIGNETTE.height) }) },
// Placed from the container, not from #vignette: images do not count in the height an
// opener reserves (gotcha: opener-image-no-reserve), the texts under them do.
text('chapter', '{titleText}', DISPLAY, 24, at('container', 'top-left', trail(2.4),
VIGNETTE.height + 6, { width: mm(MEASURE) }), { ...caps(2.4), lineHeight: 1 }),
text('summary', '{attr.summary}', TEXT, 12.5, at('#chapter', 'below',
(MEASURE - SUMMARY) / 2 - trail(2.4), 5, { width: mm(SUMMARY) }),
{ italic: true, lineHeight: 1.38 }), // a multiple (gotcha: design-lineheight-multiple)
] },
});
const VIGNETTES = ['ceppo', 'polenta']; // resource ids: the log, the wig and the polenta
const chapterStyles = VIGNETTES.map((id) => ({ id, advancedDesign: opener(id) }));
Ingredientes
- Funciones
- Aperturas diseñadasImágenes en los diseños de páginaCabeceras y foliosEstilos de títuloAtributos de títuloCapítulos que abren en página imparTextos, filetes y cajas en los diseños de páginaCabeceras según el tipo de páginaTipos de recurso propiosFiguras justo aquíFiguras y tablas como recursosSeparación silábica e idioma del documentoMárgenes simétricosPaleta de color semánticaPáginas en un canvas
- Tipografía
- Averia Serif Libre, Fredericka the Great, Quicksand (SIL OFL 1.1)
- Recursos
- La marioneta articulada de la portada, las dos viñetas de capítulo y el remate de las cerezas, dibujados en código con la paleta de la página (Ignacio Ferro, MIT)
Elaboración
#1 · Un estilo de título para cada viñeta
El código de este paso está en la respuesta corta. Un elemento de imagen toma su resourceId tal cual, sin sustituir marcadores {attr.…}, así que opener(id) crea un estilo de título por viñeta a partir de un mismo diseño, y cada capítulo elige el suyo en la línea de título: # Capitolo II {style="polenta" summary="…"} (elementos de imagen). La línea del capítulo se coloca desde la cabeza del contenedor, 6 mm por debajo de la caja de la viñeta, en lugar de anclarse a #vignette. Una imagen no reserva altura, de modo que los textos anclados a ella se miden como si el dibujo no estuviera y, sin minHeight, el texto del capítulo I empezaría a 62 mm del borde superior de la página, dentro del dibujo. Con minHeight como mínimo de 14 líneas, el texto del capítulo I empieza en la misma línea que el del II, cuyo sumario ocupa una línea más; sin él, el del capítulo I empieza 6,7 mm más arriba (span y diseño avanzado).
#2 · Dibuja con la paleta de la página
// A design slot or ::resource names a resource, the resource a file id: register each file.
const drawings = { burattino: puppet(), ceppo: log(), polenta: polenta(), ciliegie: cherries() };
for (const [id, markup] of Object.entries(drawings)) await loadSvg(`${id}.svg`, markup);
La región art construye cada dibujo como una cadena SVG con los valores hexadecimales de palette, de modo que el cielo de las viñetas es el mismo #cfe4ec de la caja que hay detrás del muñeco. Un generador aleatorio con semilla pone siempre en el mismo sitio las flores del prado y del traje y las líneas de la corteza del leño. La composición solo lee los tamaños que declaran los recursos; el lienzo pinta lo que loadSvg() haya registrado con cada identificador de archivo cuando dibuja la página. El muñeco y las viñetas los pintan elementos de imagen de los diseños; las cerezas, la línea ::resource{id="ciliegie"} del Markdown.
#3 · Haz de la portada un estilo de título
const SKY = 150; // mm from the top edge: the foot of the sky field
// # Pinocchio {style="frontespizio" kicker="Le avventure di"}; subtitle and author come from
// the frontmatter. Every element hangs from the page, so the field runs off three edges.
const frontispiece = {
id: 'frontespizio',
span: 'page', // or the field is cut at the top of the text block (gotcha: opener-clipped-at-top)
footer: { elements: [] }, // no folio; the running heads already skip openers
advancedDesign: { enabled: true, slot: { elements: [ // array order is paint order
{ kind: 'box', id: 'sky', style: { backgroundColor: col('sky') },
placement: at('page', 'top-left', 0, 0, { width: 'fill', height: mm(SKY) }) },
{ kind: 'image', id: 'puppet', resourceId: 'burattino',
placement: at('page', 'top-left', 0, 0, { width: 'fill', height: mm(SKY) }) },
text('kicker', '{attr.kicker}', LABEL, 11, at('page', 'top', trail(2.6), SKY + 11),
{ ...caps(2.6), fontWeight: 700 }),
text('title', '{titleText}', DISPLAY, 70, at('page', 'top', 0, SKY + 17),
{ lineHeight: 1, color: col('bark') }),
text('subtitle', '{subtitle}', TEXT, 15, at('page', 'top', 0, SKY + 44), { italic: true }),
text('author', '{author}', LABEL, 9, at('page', 'top', trail(2), SKY + 58),
{ ...caps(2), fontWeight: 600 }),
] } },
};
La portada es el primer título del Markdown, # Pinocchio {style="frontespizio" kicker="Le avventure di"}, y su estilo la pinta entera: la caja del cielo y el muñeco, de 150 mm de alto desde el borde superior de la página, y después el antetítulo del atributo, el título y, desde los metadatos del principio del archivo, el subtítulo y el autor. El estilo lleva span: 'page' aunque el libro tenga una sola columna, porque un diseño que se queda en la columna se recorta en la cabeza de la caja de texto, y los 22 mm superiores del cielo saldrían del color del papel. El footer vacío quita el disco del folio que llevan las aperturas de capítulo, y pages: 'body' ya deja las cabeceras fuera de esta página (estilos de encabezado).
#4 · Cabeceras en las páginas de texto, folios en discos
// The excerpt opens on book page 6, a verso, so the title page faces chapter I.
const continuation = { pageIndexOffset: 5, pageNumbering: { startAt: 6 } };
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre sits off the page's centre
const head = (id, content, parity, x) => text(id, content, LABEL, 8,
at('page', 'top', x + trail(1.8), 12), { ...caps(1.8), fontWeight: 600, parity, pages: 'body' });
const DISC = 7.4; // mm: the folio's disc
// A fixed square with a radius of half its side is a circle; the figures sit in its middle.
// lineHeight 1.17: the baseline falls at 0.8 of the line, 0.94 em down, so Quicksand's
// figures (0.70 em tall) leave 0.23 em above and below them, and 'middle' centres that line.
const disc = (id, parity, pages, edge, x) => text(id, '{pageNumber}', LABEL, 8.5,
at('page', edge, x, -(BOTTOM - DISC) / 2, { width: mm(DISC), height: mm(DISC) }),
{ fontWeight: 700, color: col('paper'), lineHeight: 1.17, verticalAlign: 'middle', parity,
pages, box: { backgroundColor: col('wood'), borderRadius: mm(DISC / 2) } });
const header = { elements: [
head('verso-title', '{title}', 'even', -SHIFT), // the book on the left-hand page
head('recto-chapter', '{chapterTitle}', 'odd', SHIFT), // the chapter on the right
] };
const footer = { elements: [
disc('verso-folio', 'even', 'body', 'bottom-left', OUTER),
disc('recto-folio', 'odd', 'body', 'bottom-right', -OUTER),
disc('opener-folio', 'all', 'opener', 'bottom', 0), // a chapter opens on either side
] };
continuation numera el extracto desde la página 6 del libro, que es par, así que la portada queda frente al capítulo I y el primer folio impreso es el 7. pages: 'body' deja las cabeceras fuera de la portada y de las dos aperturas, de modo que solo salen en las páginas 8 y 9: el título del libro en la par y el del capítulo en la impar (elementos de texto). El folio es un texto de diseño dentro de un cuadrado fijo de 7,4 mm cuyas esquinas tienen un radio de medio lado, es decir, un círculo; verticalAlign: 'middle' y la altura de línea que se calcula en el comentario centran en él las cifras. Las cifras, color papel, tienen un contraste de 4,98:1 sobre la madera. trail() desplaza cada rótulo en mayúsculas espaciadas a la derecha la mitad de su espaciado, porque 1.4.1 centra una línea espaciada como si el espacio que sigue a la última letra formara parte de ella.
#5 · Cierra el capítulo con un remate sin número
// No caption prefix and no caption, so the cherries that close chapter I print with no
// label or number. The drawings the design slots paint are of this type too.
const resourceTypes = [{ id: 'fregio', name: 'Fregio', namePlural: 'Fregi', shortLabel: 'Fregio',
captionPrefix: '', numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
defaultPlacement: { position: 'here', width: 0.25, align: 'center' } }];
const svgResource = (id, width, height, altText) => ({ id, typeId: 'fregio', kind: 'svg',
svg: { fileId: `${id}.svg`, width, height }, altText, createdAt: 0, updatedAt: 0 });
const resources = [ // alt text in the book's language
svgResource('ciliegie', 120, 80, 'Due ciliegie rosse su un picciolo, con una foglia.'),
svgResource('ceppo', 240, 160, 'Un pezzo di legno sul banco del falegname, tra i trucioli.'),
svgResource('polenta', 240, 160, 'La parrucca gialla di Geppetto e un paiolo di polenta.'),
svgResource('burattino', 190, 150, 'Un burattino di legno snodato balla su una collina.'),
];
Un tipo de recurso imprime su captionPrefix y su número delante del pie. Con el prefijo vacío y sin pie, bajo las cerezas no sale nada; con captionPrefix: 'Fregio', el mismo recurso lleva debajo Fregio 1. y crece 10 mm (tipos de recurso). El defaultPlacement del tipo coloca cada recurso donde está su línea ::resource, centrado y con un cuarto de la medida de ancho, así que en el Markdown basta con ::resource{id="ciliegie"} tras el último párrafo del capítulo. Los demás dibujos comparten el tipo, pero ningún :ref ni ::resource los nombra, así que nunca se colocan ni se cuentan.
La receta completa
// ═══ Postext Cookbook · Nº 066 · Storybook chapters with vignettes and summaries ═══ // https://postext.dev/en/cookbook/storybook-chapter-vignettes // Code: MIT · Text: C. Collodi, 1883 (PD, Gutenberg #52484) · Drawings: generated in code // Fonts: Averia Serif Libre, Fredericka the Great, Quicksand (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage } from 'https://esm.sh/postext'; const LANG = 'es'; // @lang: the language of the sample document (the sample is Italian in both) const RECIPE = 'storybook-chapter-vignettes'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { // every colour in the config, and in the drawings, comes from here ink: '#2b2118', // text: a brown near-black paper: '#fffaf0', // a warm book paper bark: '#6b4224', // chapter lines, running heads, the title wood: '#9a5f30', // the folio discs and the puppet cherry: '#b8433a', // the puppet's suit, the little voice, the cherries leaf: '#5d8a3c', // hills, cypresses, stems sky: '#cfe4ec', // the title field and the vignettes' medallions polenta: '#e3b448', // Geppetto's yellow wig }; // Each colour names its palette entry and carries its hex (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 defaults linked to main-color, bold among them, print in bark instead of blue. { id: 'main-color', name: 'bark (defaults)', value: { hex: palette.bark, model: 'hex' } }, ]; // Text and summaries; the book's title and the chapter lines; running heads and folios. const [TEXT, DISPLAY, LABEL] = ['Averia Serif Libre', 'Fredericka the Great', 'Quicksand']; const TRIM = { width: 190, height: 240 }; // mm const [TOP, INNER, OUTER] = [22, 22, 20]; // mm; mirrored, so the spine margin swaps sides const MEASURE = TRIM.width - INNER - OUTER; // 148 mm const [BODY, LEAD, LINES] = [13, 19, 29]; // pt, pt, and whole lines in the text block const BOTTOM = TRIM.height - TOP - (LINES * LEAD * 25.4) / 72; // mm: 23.6 // Design-slot shorthands: a placement from an anchor's edge (mm), and a design text. const at = (to, edge, x = 0, y = 0, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) }, ...(size && { size }) }); const text = (id, content, fontFamily, size, placement, extra = {}) => ({ kind: 'text', id, content, fontFamily, fontSize: pt(size), color: col('ink'), align: 'center', overflow: 'wrap', // wrap, never '…' (gotcha: overflow-ellipsis-default) placement, ...extra }); const caps = (tracking) => ({ textTransform: 'uppercase', letterSpacing: pt(tracking), color: col('bark') }); // Half a tracking (mm): 1.4.1 tracks after a centred line's last letter, shifting it left. const trail = (tracking) => (tracking * 25.4) / 72 / 2; // #region answer: a chapter opener: the vignette, the chapter line and Collodi's summary // # Capitolo I {style="ceppo" summary="Come andò che Maestro Ciliegia…"} // An image element draws a resource by a fixed id, so each vignette gets a heading style; // every style is the same opener with its own picture. const VIGNETTE = { width: 84, height: 56 }; // mm, centred at the head of the text block const SUMMARY = 124; // mm: the summary's measure, narrower than the text's const opener = (vignette) => ({ enabled: true, // A floor for the height the opener reserves, in whole lines, so the text under chapter I's // two-line summary starts on the same grid line as the text under chapter II's three lines. minHeight: pt(14 * LEAD), slot: { elements: [ { kind: 'image', id: 'vignette', resourceId: vignette, placement: at('container', 'top', 0, 0, { width: mm(VIGNETTE.width), height: mm(VIGNETTE.height) }) }, // Placed from the container, not from #vignette: images do not count in the height an // opener reserves (gotcha: opener-image-no-reserve), the texts under them do. text('chapter', '{titleText}', DISPLAY, 24, at('container', 'top-left', trail(2.4), VIGNETTE.height + 6, { width: mm(MEASURE) }), { ...caps(2.4), lineHeight: 1 }), text('summary', '{attr.summary}', TEXT, 12.5, at('#chapter', 'below', (MEASURE - SUMMARY) / 2 - trail(2.4), 5, { width: mm(SUMMARY) }), { italic: true, lineHeight: 1.38 }), // a multiple (gotcha: design-lineheight-multiple) ] }, }); const VIGNETTES = ['ceppo', 'polenta']; // resource ids: the log, the wig and the polenta const chapterStyles = VIGNETTES.map((id) => ({ id, advancedDesign: opener(id) })); // #endregion // #region frontispiece: the title page as a heading style: a sky field, the puppet, the title const SKY = 150; // mm from the top edge: the foot of the sky field // # Pinocchio {style="frontespizio" kicker="Le avventure di"}; subtitle and author come from // the frontmatter. Every element hangs from the page, so the field runs off three edges. const frontispiece = { id: 'frontespizio', span: 'page', // or the field is cut at the top of the text block (gotcha: opener-clipped-at-top) footer: { elements: [] }, // no folio; the running heads already skip openers advancedDesign: { enabled: true, slot: { elements: [ // array order is paint order { kind: 'box', id: 'sky', style: { backgroundColor: col('sky') }, placement: at('page', 'top-left', 0, 0, { width: 'fill', height: mm(SKY) }) }, { kind: 'image', id: 'puppet', resourceId: 'burattino', placement: at('page', 'top-left', 0, 0, { width: 'fill', height: mm(SKY) }) }, text('kicker', '{attr.kicker}', LABEL, 11, at('page', 'top', trail(2.6), SKY + 11), { ...caps(2.6), fontWeight: 700 }), text('title', '{titleText}', DISPLAY, 70, at('page', 'top', 0, SKY + 17), { lineHeight: 1, color: col('bark') }), text('subtitle', '{subtitle}', TEXT, 15, at('page', 'top', 0, SKY + 44), { italic: true }), text('author', '{author}', LABEL, 9, at('page', 'top', trail(2), SKY + 58), { ...caps(2), fontWeight: 600 }), ] } }, }; // #endregion // #region folios: running heads on body pages only, folios in wood-coloured discs // The excerpt opens on book page 6, a verso, so the title page faces chapter I. const continuation = { pageIndexOffset: 5, pageNumbering: { startAt: 6 } }; const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre sits off the page's centre const head = (id, content, parity, x) => text(id, content, LABEL, 8, at('page', 'top', x + trail(1.8), 12), { ...caps(1.8), fontWeight: 600, parity, pages: 'body' }); const DISC = 7.4; // mm: the folio's disc // A fixed square with a radius of half its side is a circle; the figures sit in its middle. // lineHeight 1.17: the baseline falls at 0.8 of the line, 0.94 em down, so Quicksand's // figures (0.70 em tall) leave 0.23 em above and below them, and 'middle' centres that line. const disc = (id, parity, pages, edge, x) => text(id, '{pageNumber}', LABEL, 8.5, at('page', edge, x, -(BOTTOM - DISC) / 2, { width: mm(DISC), height: mm(DISC) }), { fontWeight: 700, color: col('paper'), lineHeight: 1.17, verticalAlign: 'middle', parity, pages, box: { backgroundColor: col('wood'), borderRadius: mm(DISC / 2) } }); const header = { elements: [ head('verso-title', '{title}', 'even', -SHIFT), // the book on the left-hand page head('recto-chapter', '{chapterTitle}', 'odd', SHIFT), // the chapter on the right ] }; const footer = { elements: [ disc('verso-folio', 'even', 'body', 'bottom-left', OUTER), disc('recto-folio', 'odd', 'body', 'bottom-right', -OUTER), disc('opener-folio', 'all', 'opener', 'bottom', 0), // a chapter opens on either side ] }; // #endregion // #region tailpiece: an unnumbered resource type, set where ::resource stands // No caption prefix and no caption, so the cherries that close chapter I print with no // label or number. The drawings the design slots paint are of this type too. const resourceTypes = [{ id: 'fregio', name: 'Fregio', namePlural: 'Fregi', shortLabel: 'Fregio', captionPrefix: '', numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', defaultPlacement: { position: 'here', width: 0.25, align: 'center' } }]; const svgResource = (id, width, height, altText) => ({ id, typeId: 'fregio', kind: 'svg', svg: { fileId: `${id}.svg`, width, height }, altText, createdAt: 0, updatedAt: 0 }); const resources = [ // alt text in the book's language svgResource('ciliegie', 120, 80, 'Due ciliegie rosse su un picciolo, con una foglia.'), svgResource('ceppo', 240, 160, 'Un pezzo di legno sul banco del falegname, tra i trucioli.'), svgResource('polenta', 240, 160, 'La parrucca gialla di Geppetto e un paiolo di polenta.'), svgResource('burattino', 190, 150, 'Un burattino di legno snodato balla su una collina.'), ]; // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object locale: 'it', // Italian hyphenation, by its exact code (gotcha: hyphenation-locales) resourceTypes, colorPalette, header, footer, layout: { layoutType: 'single' }, page: { width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: true } }, bodyText: { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), italicColor: col('ink'), // *ohi* in ink, not in the defaults' bark // The book cites nothing, but a :ref added later prints in ink: main-color does not reach // this colour, which stays #295AA3 (gotcha: palette-skips-designs). referenceColor: col('ink'), firstLineIndent: mm(5), indentAfterHeading: false, maxWordSpacing: 1.45, // the default 2 lets 14 lines stretch past 1.45×; 3 still do, to 1.58× }, headings: { fontFamily: DISPLAY, fontWeight: 400, // the designs paint the titles: no Open Sans to load // Any headings object drops the H1 break (gotcha: headings-drop-h1-break): every // chapter on a new page, on either side, so chapter II opens on a verso. levels: [{ level: 1, breakBefore: { enabled: true, parity: 'any' } }], }, headingStyles: [frontispiece, ...chapterStyles], paragraphStyles: [{ id: 'colofon', fontFamily: LABEL, fontSize: pt(7.5), lineHeight: pt(11), color: col('bark'), textAlign: 'center', firstLineIndent: pt(0), marginTop: pt(LEAD) }], }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Muestra en Markdown · 74 líneas · content.es.md
title: "Le avventure di Pinocchio" subtitle: "Storia di un burattino" author: "Carlo Collodi" --- # Pinocchio {style="frontespizio" kicker="Le avventure di"} # Capitolo I {style="ceppo" summary="Come andò che Maestro Ciliegia, falegname, trovò un pezzo di legno che piangeva e rideva come un bambino."} — C’era una volta… — Un re! — diranno subito i miei piccoli lettori. — No, ragazzi, avete sbagliato. C’era una volta un pezzo di legno. Non era un legno di lusso, ma un semplice pezzo da catasta, di quelli che d’inverno si mettono nelle stufe e nei caminetti per accendere il fuoco e per riscaldare le stanze. Non so come andasse, ma il fatto gli è che un bel giorno questo pezzo di legno capitò nella bottega di un vecchio falegname, il quale aveva nome mastr’Antonio, se non che tutti lo chiamavano maestro Ciliegia, per via della punta del suo naso, che era sempre lustra e paonazza, come una ciliegia matura. Appena maestro Ciliegia ebbe visto quel pezzo di legno, si rallegrò tutto; e dandosi una fregatina di mani per la contentezza, borbottò a mezza voce: — Questo legno è capitato a tempo; voglio servirmene per fare una gamba di tavolino. — Detto fatto, prese subito l’ascia arrotata per cominciare a levargli la scorza e a digrossarlo; ma quando fu lì per lasciare andare la prima asciata, rimase col braccio sospeso in aria, perchè sentì una vocina sottile sottile, che disse raccomandandosi: — Non mi picchiar tanto forte! — Figuratevi come rimase quel buon vecchio di maestro Ciliegia! Girò gli occhi smarriti intorno alla stanza per vedere di dove mai poteva essere uscita quella vocina, e non vide nessuno! Guardò sotto il banco, e nessuno: guardò dentro un armadio che stava sempre chiuso, e nessuno; guardò nel corbello dei trucioli e della segatura, e nessuno; aprì l’uscio di bottega per dare un’occhiata anche sulla strada, e nessuno. O dunque?… — Ho capito; — disse allora ridendo e grattandosi la parrucca — si vede che quella vocina me la son figurata io. Rimettiamoci a lavorare. — E ripresa l’ascia in mano, tirò giù un solennissimo colpo sul pezzo di legno. — Ohi! tu m’hai fatto male! — gridò rammaricandosi la solita vocina. Questa volta maestro Ciliegia restò di stucco, cogli occhi fuori del capo per la paura, colla bocca spalancata e colla lingua giù ciondoloni fino al mento, come un mascherone da fontana. Appena riebbe l’uso della parola, cominciò a dire tremando e balbettando dallo spavento: — Ma di dove sarà uscita questa vocina che ha detto ohi?… Eppure qui non c’è anima viva. Che sia per caso questo pezzo di legno che abbia imparato a piangere e a lamentarsi come un bambino? Io non lo posso credere. Questo legno eccolo qui; è un pezzo di legno da caminetto, come tutti gli altri, e a buttarlo sul fuoco, c’è da far bollire una pentola di fagioli… O dunque? Che ci sia nascosto dentro qualcuno? Se c’è nascosto qualcuno, tanto peggio per lui. Ora l’accomodo io! — E così dicendo, agguantò con tutt’e due le mani quel povero pezzo di legno, e si pose a sbatacchiarlo senza carità contro le pareti della stanza. Poi si messe in ascolto, per sentire se c’era qualche vocina che si lamentasse. Aspettò due minuti, e nulla; cinque minuti, e nulla; dieci minuti, e nulla! — Ho capito — disse allora sforzandosi di ridere e arruffandosi la parrucca — si vede che quella vocina che ha detto *ohi*, me la son figurata io! Rimettiamoci a lavorare. — E perchè gli era entrato addosso una gran paura, si provò a canterellare per farsi un po’ di coraggio. Intanto, posata da una parte l’ascia, prese in mano la pialla, per piallare e tirare a pulimento il pezzo di legno; ma nel mentre che lo piallava in su e in giù, sentì la solita vocina che gli disse ridendo: — Smetti! tu mi fai il pizzicorino sul corpo! — Questa volta il povero maestro Ciliegia cadde giù come fulminato. Quando riaprì gli occhi, si trovò seduto per terra. Il suo viso pareva trasfigurito, e perfino la punta del naso, di paonazza come era quasi sempre, gli era diventata turchina dalla gran paura. ::resource{id="ciliegie"} # Capitolo II {style="polenta" summary="Maestro Ciliegia regala il pezzo di legno al suo amico Geppetto, il quale lo prende per fabbricarsi un burattino maraviglioso, che sappia ballare, tirar di scherma e fare i salti mortali."} In quel punto fu bussato alla porta. — Passate pure, — disse il falegname, senza aver la forza di rizzarsi in piedi. Allora entrò in bottega un vecchietto tutto arzillo, il quale aveva nome Geppetto; ma i ragazzi del vicinato, quando lo volevano far montare su tutte le furie, lo chiamavano col soprannome di Polendina, a motivo della sua parrucca gialla, che somigliava moltissimo alla polendina di granturco. Geppetto era bizzosissimo. Guai a chiamarlo Polendina! Diventava subito una bestia, e non c’era più verso di tenerlo. :::paragraphs{style="colofon"} Composto in Averia Serif Libre, Fredericka the Great e Quicksand (SIL Open Font License). Testo di Carlo Collodi (1883) secondo l’edizione Bemporad del 1902, Project Gutenberg n. 52484. Disegni realizzati in codice. :::`; // content.<lang>.md: Collodi's Italian in both editions // #region art: the puppet, two vignettes and a tailpiece, drawn in the palette function mulberry32(seed) { // a seeded PRNG: the same drawing on every run return () => { seed = (seed + 0x6d2b79f5) | 0; let r = Math.imul(seed ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; } const mix = (hex, other, k) => `#${[1, 3, 5].map((i) => Math.round( parseInt(hex.slice(i, i + 2), 16) * (1 - k) + parseInt(other.slice(i, i + 2), 16) * k) .toString(16).padStart(2, '0')).join('')}`; const P = palette; const n1 = (v) => +v.toFixed(1); const ink = (w) => `stroke="${P.ink}" stroke-width="${w}" stroke-linejoin="round" ` + 'stroke-linecap="round"'; const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 4}" ` + `height="${h * 4}" viewBox="0 0 ${w} ${h}">${body}</svg>`; const dot = (x, y, r, fill, extra = '') => `<circle cx="${n1(x)}" cy="${n1(y)}" r="${r}" ` + `fill="${fill}" ${extra}/>`; const shape = (d, fill, w = 1, extra = '') => `<path d="${d}" fill="${fill}" ${ink(w)} ${extra}/>`; // A stroke outlined in ink: a wide ink line under a narrower one in the fill colour. const cord = (d, w, fill, edge = 1) => `<path d="${d}" fill="none" ${ink(w + edge)}/>` + `<path d="${d}" fill="none" stroke="${fill}" stroke-width="${w}" stroke-linecap="round" ` + 'stroke-linejoin="round"/>'; const bar = (x1, y1, x2, y2, w, fill) => cord(`M${x1} ${y1}L${x2} ${y2}`, w, fill, 1.1); const cloud = (x, y, s) => [[0, 0, 7], [8, -3, 9], [17, 0, 7], [25, 2, 5], [-7, 2, 5]] .map(([dx, dy, r]) => dot(x + dx * s, y + dy * s, r * s, P.paper)).join(''); // A shaving: a ribbon of wood rolled into loops (a prolate cycloid), turned by `angle`. function shaving(x, y, s, angle, loops = 2) { const pts = []; for (let t = 0; t <= loops * 2 * Math.PI + 1.2; t += 0.25) { pts.push([0.95 * t - 1.9 * Math.sin(t), -1.9 * Math.cos(t)].map((v) => v * s)); } const a = (angle * Math.PI) / 180; const [cos, sin] = [Math.cos(a), Math.sin(a)]; const d = pts.map(([px, py], i) => `${i ? 'L' : 'M'}${n1(x + px * cos - py * sin)} ` + `${n1(y + px * sin + py * cos)}`).join(''); return cord(d, 1.5 * s, mix(P.wood, P.paper, 0.5), 0.8 * s); } // A medallion: the sky oval, a floor cut by its rim, the scene, then the rim on top. const FLOOR = (y) => { const half = n1(116 * Math.sqrt(1 - ((y - 80) / 76) ** 2)); return `M${n1(120 - half)} ${y}H${n1(120 + half)}A116 76 0 0 1 ${n1(120 - half)} ${y}Z`; }; const medallion = (body) => svg(240, 160, `<ellipse cx="120" cy="80" rx="116" ry="76" ` + `fill="${P.sky}"/><path d="${FLOOR(128)}" fill="${mix(P.wood, P.paper, 0.62)}"/>${body}` + `<ellipse cx="120" cy="80" rx="116" ry="76" fill="none" stroke="${P.wood}" ` + 'stroke-width="1.8"/>'); function puppet() { // the frontispiece: 190 × 150 mm, the size of the sky field const rand = mulberry32(1883); const light = mix(P.wood, P.paper, 0.45), crumb = mix(P.paper, P.wood, 0.14); const cypress = (x, base, h) => `<path d="M${x} ${base}C${x - h / 5} ${base - h / 3} ` + `${x - h / 9} ${base - h} ${x} ${base - h}C${x + h / 9} ${base - h} ${x + h / 5} ` + `${base - h / 3} ${x} ${base}Z" fill="${mix(P.leaf, P.ink, 0.35)}"/>`; const house = `<path d="M146 104h13v-7h-13z" fill="${mix(P.polenta, P.paper, 0.45)}"/>` + `<path d="M144.5 97.5L152.5 92.5L160.5 97.5z" fill="${mix(P.cherry, P.wood, 0.3)}"/>`; const flowers = Array.from({ length: 30 }, () => dot(8 + rand() * 174, 131 + rand() * 17, 0.7, rand() < 0.5 ? P.paper : P.polenta)).join(''); const blooms = Array.from({ length: 12 }, (_, i) => { // the paper suit's printed flowers const [x, y] = [89 + (i % 3) * 6 + rand() * 1.6, 66.5 + Math.floor(i / 3) * 6.4 + rand() * 1.4]; return [0, 1, 2, 3].map((k) => dot(x + 1.1 * Math.cos(k * 1.571), y + 1.1 * Math.sin(k * 1.571), 0.62, P.paper)).join('') + dot(x, y, 0.5, P.polenta); }).join(''); return svg(190, 150, cloud(22, 30, 1) + cloud(150, 20, 0.8) + cloud(132, 64, 0.5) + shape('M0 104C40 92 80 98 110 101S160 93 190 98V150H0Z', mix(P.leaf, P.sky, 0.66), 0) + shape('M0 114C30 104 58 108 84 111S140 101 190 107V150H0Z', mix(P.leaf, P.sky, 0.42), 0) + house + cypress(28, 110, 17) + cypress(34, 109.5, 12) + cypress(139, 106, 13) + cypress(165, 103.5, 16) + cypress(171, 103.5, 11) + `<path d="M0 132C40 118 70 121 95 121S150 118 190 128V150H0Z" fill="${P.leaf}"/>` + flowers // legs: the left straight, the right lifted in a dance step; shoes of bark + bar(91, 92, 90, 106, 4.6, P.wood) + bar(90, 106, 89, 119, 4.2, P.wood) + shape('M85.8 121.4C85.8 118.4 97.6 118.6 97.4 121.6C97.2 123.4 86 123.6 85.8 121.4Z', P.bark, 0.9) + bar(99, 92, 107, 103, 4.6, P.wood) + bar(107, 103, 104, 115, 4.2, P.wood) + shape('M100.8 118.8C99.8 115.6 110.4 112.4 111.2 115.2C111.8 117.2 101.6 120.6 100.8 118.8Z', P.bark, 0.9) // arms: the left waving, the right swinging; round joints at shoulder, elbow and hip + bar(86, 66, 77.5, 56, 4.2, P.wood) + bar(77.5, 56, 74, 45.5, 3.8, P.wood) + bar(104, 66, 111, 76.5, 4.2, P.wood) + bar(111, 76.5, 114.5, 86.5, 3.8, P.wood) // the suit of flowered paper + `<rect x="85" y="62" width="20" height="31" rx="5" fill="${P.cherry}" ${ink(1.1)}/>` + blooms + [[86, 66], [77.5, 56], [104, 66], [111, 76.5], [91, 92], [99, 92], [90, 106], [107, 103]] .map(([x, y]) => dot(x, y, 2.3, P.bark, ink(0.7))).join('') + dot(73.5, 43.5, 2.9, light, ink(0.9)) + dot(115.5, 89, 2.9, light, ink(0.9)) // neck, a paper collar, head, nose, face, and the cap of bread crumb + `<rect x="92.5" y="56" width="5" height="7" fill="${light}" ${ink(0.9)}/>` + [88.6, 92.2, 95.8, 99.4].map((x) => dot(x + 0.8, 62.6, 2.1, P.paper, ink(0.7))).join('') + dot(95, 50, 8.5, light, ink(1.1)) + shape('M102.6 47.8L117 46.2L102.8 51.6Z', light, 0.9) + dot(97.5, 47.5, 1.05, P.ink) + dot(92.5, 53.5, 1.9, P.cherry, 'opacity="0.55"') + `<path d="M95 54.8Q98.2 56.8 101 54.6" fill="none" ${ink(0.8)}/>` + shape('M86.6 46.5C86 36 92 26.5 104 21.5C99.5 29 101 38 103.6 45.2Z', crumb, 1.1) + dot(104.3, 21.2, 2.2, crumb, ink(0.9))); } function log() { // chapter I: the firewood on the carpenter's bench, a plane, the little voice const rand = mulberry32(1); const light = mix(P.wood, P.paper, 0.5), steel = mix(P.ink, P.sky, 0.5); const barkLines = [66, 72, 79, 86, 91].map((y) => `<path d="M${n1(80 + rand() * 8)} ${y}` + `C${n1(100 + rand() * 10)} ${n1(y - 1 + rand() * 2)} ${n1(120 + rand() * 10)} ` + `${n1(y - 1 + rand() * 2)} ${n1(138 + rand() * 12)} ${y}" fill="none" ${ink(0.7)} ` + 'opacity="0.55"/>').join(''); const rings = [13, 9, 5].map((r) => `<ellipse cx="66" cy="78" rx="${n1(r * 0.56)}" ry="${r}" ` + `fill="none" stroke="${P.wood}" stroke-width="0.9"/>`).join(''); const voice = [[50, 70, 8], [43, 70, 13], [36, 70, 18]].map(([x, y, h]) => `<path d="M${x} ` + `${y - h}Q${x - h * 0.55} ${y} ${x} ${y + h}" fill="none" stroke="${P.cherry}" ` + 'stroke-width="2" stroke-linecap="round"/>').join(''); return medallion( shape('M44 96H196V104H44Z', P.wood, 1.1) + shape('M50 104H190V111H50Z', P.bark, 1) + shape('M56 111H64V128H56Z', P.bark, 1) + shape('M176 111H184V128H176Z', P.bark, 1) + shape('M66 60H148Q160 78 148 96H66Z', P.bark, 1.1) + barkLines + `<ellipse cx="118" cy="76" rx="3.4" ry="2.2" fill="${P.ink}" opacity="0.45"/>` + `<ellipse cx="66" cy="78" rx="10" ry="18" fill="${light}" ${ink(1.1)}/>` + rings + dot(66, 78, 1.2, P.wood) + voice // the plane on the bench, a shaving curling up out of its mouth + shape('M162 96L165 86H195L198 96Z', light, 1) + shape('M186 86C186 77 189 73 195 73C198 73 199 75 198 77L194 86Z', P.bark, 0.9) + dot(170, 83.5, 3, P.bark, ink(0.9)) + shape('M176 87L182 77L185 78L180 87Z', steel, 0.8) + cord('M180 86C179 76 187 70 192 74C196 78 191 83 187 80C184 77 188 74 190 76', 2.4, light) // on the floor: the axe put down, and the shavings of the first strokes + cord('M96 137L146 133', 3.4, P.wood) + shape('M84 128L97 131L97 142L84 146Q80 137 84 128Z', steel, 1) + `<path d="M84.6 130Q81.6 137 84.6 144" fill="none" stroke="${P.sky}" ` + 'stroke-width="1.2"/>' + shaving(64, 140, 2, 8) + shaving(146, 142, 1.8, -6) + shaving(168, 134, 1.5, 10) // a saw on its nail on the workshop wall + '<g transform="translate(-26 8)">' + shape('M150 30L194 22L196 38L152 44Z', steel, 0.9) + `<path d="M152 44${Array.from({ length: 11 }, (_, i) => `L${n1(155.5 + i * 4)} ${n1(45.8 - i * 0.55)}L${n1(157.5 + i * 4)} ${n1(43.6 - i * 0.55)}`).join('')}" fill="${steel}" ` + `${ink(0.6)}/>` + shape('M194 22C204 18 212 24 210 32C208 40 200 40 196 38Z', P.wood, 1) + dot(201, 30, 2.6, P.sky, ink(0.8)) + dot(160, 32, 1.4, P.ink) + '</g>'); } function wig(x, y) { // Geppetto's periwig in profile: curls, two rolls, a queue with a bow // The crown: bumps along the top of an oval, so its outline reads as curled hair. const pts = Array.from({ length: 10 }, (_, i) => { const a = Math.PI * (1.08 + i * 0.093); return [n1(20 * Math.cos(a)), n1(6 + 22 * Math.sin(a))]; }); const crown = `M${pts[0].join(' ')}${pts.slice(1).map(([px, py]) => `A3.6 3.6 0 0 1 ${px} ${py}`) .join('')}C14 4 2 3 -6 9C-11 12 -17 12 -${-pts[0][0]} ${pts[0][1]}Z`; const strands = ['M14 -2C10 -11 -3 -14 -14 -5', 'M9 -12C2 -16 -8 -14 -13 -9'] .map((d) => `<path d="${d}" fill="none" ${ink(0.7)} opacity="0.5"/>`).join(''); // Two side rolls over the ear, below the eye line, each ending in a spiral curl. const curl = (cx, cy, r) => `<path d="M${Array.from({ length: 12 }, (_, i) => { const [a, k] = [i * 0.55, r * (1 - i / 14)]; return `${n1(cx + k * Math.cos(a))} ${n1(cy - k * Math.sin(a))}`; }).join('L')}" ` + `fill="none" ${ink(0.8)}/>`; const roll = (cy) => `<ellipse cx="-4.5" cy="${cy}" rx="8.5" ry="3.4" fill="${P.polenta}" ` + `${ink(0.9)}/>${curl(1.2, cy, 2.6)}`; return `<g transform="translate(${x} ${y}) rotate(-4) scale(1.4)">` + cord('M-17 10C-23 16 -25 26 -21 34', 3.8, P.polenta) + shape(crown, P.polenta, 1) + strands + roll(14) + roll(20.5) + shape('M-18 9L-24 5L-23.5 13ZM-18 9L-12.5 4.5L-12 13Z', P.cherry, 0.8) + '</g>'; } function polenta() { // chapter II: Geppetto's yellow wig on its stand, and the polenta const light = mix(P.wood, P.paper, 0.5), clay = mix(P.cherry, P.polenta, 0.3); const steam = [148, 160, 172].map((x, i) => `<path d="M${x} ${78 - i % 2 * 4}c-5 -7 5 -11 0 -18` + `s5 -11 0 -17" fill="none" stroke="${P.paper}" stroke-width="2.2" stroke-linecap="round"/>`) .join(''); const kernels = Array.from({ length: 18 }, (_, i) => `<rect x="${n1(120 + (i % 6) * 5.4)}" ` + `y="${n1(134 + Math.floor(i / 6) * 3.6)}" width="4.4" height="3" rx="1.4" ` + `fill="${P.polenta}" ${ink(0.5)}/>`).join(''); return medallion( // the stand: a turned post and a head of wood, the wig on top shape('M64 128L68 121H92L96 128Z', P.bark, 1) + shape('M77 121V98H83V121Z', P.wood, 1) + `<ellipse cx="82" cy="80" rx="15" ry="20" fill="${light}" ${ink(1.1)}/>` + wig(78, 62) // a red earthenware pot of polenta, a wooden spoon, and the steam + shape('M128 96H192C192 118 180 128 160 128C140 128 128 118 128 96Z', clay, 1.1) + `<ellipse cx="160" cy="96" rx="32" ry="6" fill="${P.polenta}" ${ink(1.1)}/>` + `<path d="M128 96C128 74 192 74 192 96" fill="none" ${ink(1.6)}/>` + cord('M168 94L184 62', 3, light) + `<ellipse cx="166" cy="97" rx="5" ry="2.4" ` + `fill="${light}" ${ink(0.9)}/>` + steam // an ear of maize on the floor, its husks open + shape('M116 138C124 128 150 130 156 138C150 146 124 148 116 138Z', P.polenta, 1) + kernels + shape('M118 138C112 128 104 126 98 130C104 132 110 136 118 138Z', P.leaf, 0.9) + shape('M118 138C110 144 102 146 96 144C102 140 110 138 118 138Z', P.leaf, 0.9)); } function cherries() { // the tailpiece: two cherries for Maestro Ciliegia const one = (x, y) => dot(x, y, 12, P.cherry, ink(1.1)) + `<ellipse cx="${x - 4}" cy="${y - 4.5}" rx="3" ry="2" fill="${P.paper}" opacity="0.7" ` + `transform="rotate(-30 ${x - 4} ${y - 4.5})"/>`; return svg(120, 80, `<path d="M61 12C54 24 46 36 44 50M61 12C66 26 74 38 78 52" fill="none" ` + `stroke="${P.leaf}" stroke-width="2.2" stroke-linecap="round"/>` + shape('M61 12C70 2 88 2 98 9C86 16 72 17 61 12Z', P.leaf, 1) + `<path d="M63 11.5Q80 8 96 9" fill="none" stroke="${P.paper}" stroke-width="0.8" ` + 'opacity="0.8"/>' + one(43, 58) + one(79, 60)); } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Averia Serif Libre': ['400', '400i'], 'Fredericka the Great': ['400'], Quicksand: ['400', '600', '700'] }; // every face, loaded before layout (gotcha: fonts-first) // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); // #region pictures: the drawings, registered under the file ids their resources name // A design slot or ::resource names a resource, the resource a file id: register each file. const drawings = { burattino: puppet(), ceppo: log(), polenta: polenta(), ciliegie: cherries() }; for (const [id, markup] of Object.entries(drawings)) await loadSvg(`${id}.svg`, markup); // #endregion const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), markdown); showPages(doc, { title: 'Le avventure di Pinocchio' });Kit · 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
#Abre cada capítulo en página impar
Con 'odd', el capítulo II pasa a la página 11 y la 10 queda en blanco; la portada desactiva su propio salto, porque hereda el del nivel y, si no, pasaría a la página 7 tras una página 6 en blanco.
@@ el estilo de la portada @@
id: 'frontespizio',
+ breakBefore: { enabled: false },
@@ headings, en config() @@
- levels: [{ level: 1, breakBefore: { enabled: true, parity: 'any' } }],
+ levels: [{ level: 1, breakBefore: { enabled: true, parity: 'odd' } }],Errores frecuentes
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
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
Valores de atributo: sin { ni }; comillas simples si llevan "
Un valor de atributo termina en la llave de cierre, así que no puede contener { ni }. Un valor que lleve comillas dobles va entre comillas simples; el signo de dólar no da problemas. Atributos de título →
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
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
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 desbordamiento del texto de diseño es 'ellipsis-end' por defecto
Un elemento de texto de diseño que no cabe en su ancho termina en puntos suspensivos por defecto. Pon overflow: 'wrap' en los títulos que deban pasar a más líneas. Textos, filetes y cajas en los diseños de página →
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 →
- En el Markdown, un espacio de no separación (U+00A0) une cada raya de diálogo de Collodi a su palabra, para que una raya de cierre nunca abra línea; otros dos mantienen juntos di legno e in piedi al final de sus párrafos. En 1.4.1, si el párrafo lleva negrita o cursiva, la línea todavía puede partirse en un espacio de no separación. El único párrafo con estos espacios y una palabra en cursiva, el del ohi de la página 9, tiene cuatro, y en la captura todos caen a media línea o al final del párrafo.
Créditos
- Receta
- Ignacio Ferro
- Texto
- Le avventure di Pinocchio (1883): el capítulo I y el comienzo del II con los sumarios de Collodi, según la edición Bemporad de 1902, con apóstrofos, rayas y puntos suspensivos tipográficos; el sumario del capítulo I lleva, como el índice de la edición, la coma tras «falegname» que falta al comienzo del capítulo · Carlo Collodi · dominio público
- Imágenes
- La marioneta articulada de la portada, las dos viñetas de capítulo y el remate de las cerezas, dibujados en código con la paleta de la página · Ignacio Ferro · MIT
- Fuentes
- Averia Serif Libre (SIL OFL 1.1) · Fredericka the Great (SIL OFL 1.1) · Quicksand (SIL OFL 1.1)
- Código
- MIT, como Postext


