En poques paraules
Una petita revista plegada sobre els autobusos nocturns, pensada per a una màquina d'imprimir que només fa servir dues tintes. Els dibuixos en color es converteixen en tons d'una tinta rosa.
Què compondràs
Night Buses és un fanzín gratuït, en anglès, sobre els autobusos nocturns d'una ciutat: quatre pàgines de 120 × 160 mm en un full plegat, per a un risògraf de dos tambors, blau i rosa fluorescent. Cada color de la configuració és una de les dues tintes, una trama del 25 % del rosa o el paper crema. Els dibuixos, a tot color (un mapa en sis colors, una lluna en grisos, un rètol de destinació ambre sobre negre), surten del tambor rosa en tons més densos com més fosc era el color. El mapa s'imprimeix en rosa, i més avall, a la mateixa pàgina, una miniatura conserva els colors originals. El títol de la portada es compon dues vegades, amb la còpia rosa 0,8 mm a la dreta de la blava i 0,5 mm més amunt. El codi exporta a més el PDF i una prova en grisos.
Aquesta recepta respon a
- Com passo tots els diagrames a una sola tinta plana?
La resposta curta
// renderToPdf reads the ink from the config and recolours every SVG it is handed; the canvas
// paints an SVG as registered (gotcha: single-ink-canvas). So the screen gets a recoloured
// copy and the PDF the drawing as drawn: one pass each, as a second pass lightens it again.
const diagramStyle = { singleInk: true, inkColor: col('spot') };
const printFiles = new Map(); // fileId → the bytes renderToPdf embeds
async function registerArt(fileId, svg) {
await loadSvg(fileId, applySingleInkToSvg(svg, diagramStyle.inkColor.hex)); // the canvas
printFiles.set(fileId, new TextEncoder().encode(svg)); // the PDF, recoloured there
}
const pdfOptions = { fontProvider: fontsourceProvider,
resourceBytes: (id) => printFiles.get(id) }; // the drawings as drawn, the PNG as it is
Ingredients
- Funcions
- Diagrames a una tintaPaleta de color semànticaPDF en CMYK i en escala de grisosExportació a PDFPàgines en un canvasChips en líniaEstil dels peusRequadresEstils de títolAtributs de títolObertures dissenyadesTextos, filets i caixes als dissenys de pàginaImatges als dissenys de pàginaCobertes, portades i colofonsFigures i taules com a recursosTipus de recurs propisCol·locació de figuresColor del paper
- Tipografia
- Epilogue, Anton, Space Mono (SIL OFL 1.1)
- Recursos
- Cap: totes les imatges es dibuixen en codi
Elaboració
#1 · Tots els colors, dels dos tambors
// The two drums: the blue for the type and the furniture, the fluorescent pink for the art.
const DRUMS = { ink: '#1d4fb8', spot: '#f0509a' };
const PAPER = '#f3efe6'; // cream stock: where no ink falls
// A screen prints a share of an ink's dots and lets the paper show between them.
const screen = (hex, share) => `#${[1, 3, 5].map((i) => Math.round(share
* parseInt(hex.slice(i, i + 2), 16) + (1 - share) * parseInt(PAPER.slice(i, i + 2), 16))
.toString(16).padStart(2, '0')).join('')}`;
const palette = { ...DRUMS, 'spot-25': screen(DRUMS.spot, 0.25), paper: PAPER };
// The hex rides with the id: design elements read the hex (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to main-color: aimed at the spot, none of them adds a third ink.
const colorPalette = [...Object.entries(palette), ['main-color', DRUMS.spot]]
.map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
Un risògraf imprimeix cada tinta amb el seu propi tambor, així que un tercer color en qualsevol lloc de la configuració obligaria a muntar un tercer tambor. screen() barreja una part de la tinta amb el color del paper, igual que una trama imprimeix només una part dels punts i deixa veure el paper entre ells. La trama del 25 % del rosa és #f2c7d3, el fons dels xips i del peu. main-color apunta al rosa, de manera que qualsevol valor per defecte que la configuració no toqui s'imprimeix amb aquest tambor i no amb el blau que el motor porta de sèrie (#295AA3). col() escriu cada hex al costat del seu id perquè la 1.4.1 aplica la paleta als estils de text, però no als elements de disseny.
#2 · Recolorir cada dibuix una vegada per sortida
El codi és la resposta curta de dalt. Amb diagramStyle.singleInk, renderToPdf converteix cada color d'un SVG en un to d'inkColor la intensitat del qual és 1 menys la luminància relativa del color (estil de diagrames). L'N12 blau marí surt al 84 % i l'N50 groc, al 24 %. A la 1.4.1 el canvas pinta l'SVG registrat tal com és, així que registerArt() registra una còpia recolorida amb applySingleInkToSvg i guarda el dibuix original per a resourceBytes. Tant al canvas com al PDF rasteritzat a 300 ppp, la línia de l'N12 té el color (242, 109, 171), el to que surt de la fórmula amb una intensitat de 0,836. Una segona passada l'aclariria fins al 44 %, i per això el PDF no rep mai la còpia recolorida.
#3 · Imprimir el títol dues vegades, fora de registre
const OFF = { x: 0.8, y: -0.5 }; // mm: where the pink drum lands against the blue one
// Array order is paint order: the pink copy goes down first and the blue covers all but a
// sliver. Canvas and PDF paint opaque colour, so the overlap stays blue, not riso purple.
const twice = (id, { placement: { offset: { x, y }, ...rest }, ...element }) => [
{ ...element, id: `${id}-spot`, color: col('spot'),
placement: { ...rest, offset: { x: mm(x.value + OFF.x), y: mm(y.value + OFF.y) } } },
{ ...element, id, color: col('ink'), placement: { ...rest, offset: { x, y } } }];
const rule = (id, direction, x, y, size) => ({ kind: 'rule', id, direction, color: col('ink'),
thickness: pt(id === 'pole' ? 3 : 0.8), placement: at('page', 'top-left', x, y, size) });
// span: 'page' lets the design paint outside the text block: kept in the column, the
// issue line above it and the pole below it are cut off at the column's edges.
const cover = { id: 'cover', span: 'page', header: { elements: [] }, footer: { elements: [] },
advancedDesign: { enabled: true, slot: { elements: [
{ kind: 'image', id: 'moon', resourceId: 'moon',
placement: at('page', 'top-left', 28, 14, { width: mm(92), height: mm(92) }) },
rule('wire-1', 'horizontal', 0, 30, { width: mm(TRIM.width) }),
rule('wire-2', 'horizontal', 0, 34.5, { width: mm(TRIM.width) }),
rule('pole', 'vertical', 98, 62, { height: mm(TRIM.height - 62) }), // off the foot
{ kind: 'box', id: 'flag', style: { backgroundColor: col('ink'), borderRadius: mm(1) },
placement: at('page', 'top-left', 89, 62, { width: mm(18), height: mm(21) }) },
// One word a line: the box is narrower than two of them (gotcha: design-text-newline).
{ kind: 'text', id: 'stops', content: '{attr.stops}', ...mono, fontSize: pt(9),
lineHeight: 1.25, align: 'center', color: col('paper'), overflow: 'wrap',
placement: at('#flag', 'top-left', 3, 2.5, { width: mm(12) }) },
{ kind: 'text', id: 'issue', content: '{attr.issue}', ...mono,
placement: at('page', 'top-left', MARGIN.inner, 8) },
{ kind: 'text', id: 'line', content: '{attr.line}', fontFamily: 'Epilogue', fontWeight: 700,
fontSize: pt(9), color: col('ink'), align: 'left', overflow: 'wrap',
placement: at('page', 'top-left', MARGIN.inner, 13, { width: mm(46) }) },
...twice('title', { kind: 'text', content: '{titleText}', fontFamily: 'Anton', fontSize: pt(86),
lineHeight: 0.9, // a multiple (gotcha: design-lineheight-multiple)
textTransform: 'uppercase', align: 'left',
overflow: 'wrap', placement: at('page', 'top-left', MARGIN.inner, 94, { width: mm(76) }) }),
] } } };
Un risògraf imprimeix un tambor darrere l'altre, i el paper no entra mai dues vegades exactament al mateix lloc. twice() retorna dues còpies d'un element de text: primer la rosa, 0,8 mm a la dreta i 0,5 mm més amunt, i després la blava. Com que l'ordre de l'array és l'ordre de pintat, la blava tapa la rosa llevat d'una franja fina a les vores superior i dreta de cada lletra. El canvas i el PDF pinten colors opacs, així que on se superposen queda blau; en un risògraf sortiria morat. El disseny ocupa tota la pàgina perquè la línia del número queda per sobre de la caixa de text i el pal baixa més enllà del seu peu; si el disseny es queda dins de la columna, la 1.4.1 talla tots dos per les vores d'aquesta.
#4 · El text petit, en la tinta fosca
const opener = { enabled: true, slot: { elements: [
{ kind: 'text', id: 'kicker', content: '{attr.kicker}', ...mono,
placement: at('container', 'top-left', 0, 0.5) },
...twice('title', { kind: 'text', content: '{titleText}', fontFamily: 'Anton',
fontSize: pt(34), lineHeight: 0.95, textTransform: 'uppercase', align: 'left',
overflow: 'wrap', placement: at('#kicker', 'below', 0, 2.5, { width: mm(MEASURE) }) }),
] } };
const route = { id: 'route', background: col('spot-25'), borderColor: col('spot'),
borderWidth: pt(0.75), borderRadius: pt(1.2), fontFamily: 'Space Mono', bold: true,
fontSize: em(0.86), color: col('ink') };
const captionStyle = { fontFamily: 'Epilogue', fontSize: pt(8), color: col('ink'),
backgroundEnabled: true, background: col('spot-25'), padding: mm(1.6) };
const quote = { id: 'quote', backgroundEnabled: false, marginTop: pt(LEAD), marginBottom: pt(4),
stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('spot') },
padding: { top: mm(2.6), right: pt(0), bottom: pt(0), left: pt(0) },
titleStyle: { ...label, gap: mm(1.2) }, // the speaker, above the words
body: { fontFamily: 'Anton', fontSize: pt(14), lineHeight: pt(17), color: col('ink'),
textAlign: 'left', firstLineIndent: pt(0) } };
Sobre el paper crema, el blau dona un contrast de 6,4:1 i el rosa fluorescent, de 2,9:1, poc per a lletra de 8 o 9 pt. Per això totes les paraules que compon el motor surten del tambor blau. Els xips porten Space Mono blava sobre la trama del 25 % (4,85:1), amb un contorn rosa, i el peu va en Epilogue blava sobre la mateixa trama. Del tambor rosa surten el filet sobre la cita, el contorn dels xips, les vinyetes, els quadrats dels folis i les còpies desplaçades dels títols. Les lletres dels dibuixos formen part de la il·lustració i surten en rosa amb ella. El nom de qui parla és el títol del requadre, en majúscules de Space Mono negreta de 7,5 pt, damunt de la cita.
#5 · La miniatura al costat de la seva nota
// The inset keeps the map's own colours. Single ink touches SVG only, so the map goes in
// as a PNG snapshot, which neither the canvas nor the PDF recolours.
async function registerSnapshot(fileId, svg, width, height) {
const img = new Image();
img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
await img.decode();
const canvas = new OffscreenCanvas(width, height);
canvas.getContext('2d').drawImage(img, 0, 0, width, height);
registerResourceImage(fileId, await createImageBitmap(canvas));
printFiles.set(fileId, new Uint8Array(await (await canvas.convertToBlob()).arrayBuffer()));
}
const INSET = 30; // mm: the snapshot's width; its height follows the map's 100 × 69.5
const drawn = { id: 'drawn', marginTop: pt(LEAD), advancedDesign: { enabled: true,
// A floor at the picture's height, which a design's images do not reserve (gotcha:
// opener-image-no-reserve). The note is about as tall today; with a shorter note the
// next block would run over the picture.
minHeight: mm(INSET * 0.695 + 1),
slot: { elements: [
{ kind: 'image', id: 'inset', resourceId: 'as-drawn',
placement: at('container', 'top-left', 0, 0.5, { width: mm(INSET), height: 'auto' }) },
{ kind: 'text', id: 'label', content: '{titleText}', ...mono,
placement: at('#inset', 'right-of', 5, 0) },
{ kind: 'text', id: 'note', content: '{attr.note}', fontFamily: 'Epilogue',
fontSize: pt(8.5), lineHeight: 1.4, color: col('ink'), align: 'left', overflow: 'wrap',
placement: at('#label', 'below', 0, 1.5, { width: mm(MEASURE - INSET - 5) }) },
] } } };
En una sola columna, un flotant ocupa tota l'amplada de la seva franja, així que una imatge petita flotada en deixaria buida la resta. Per això la miniatura és un disseny de títol: ### As drawn {style="drawn" note="…"} pinta la imatge i, a la seva dreta, el títol com a rètol amb la nota a sota. Com que les imatges d'un disseny no reserven alçada, minHeight fixa un mínim igual a l'alçada de la imatge. Avui la nota fa més o menys el mateix; si fos més curta, el bloc següent trepitjaria la imatge. registerSnapshot() passa el mateix mapa de network() a un PNG i, com que la tinta única només recoloreix SVG, la miniatura conserva els seus sis colors en pantalla i al PDF.
La recepta completa
// ═══ Postext Cookbook · Nº 051 · Two-ink riso zine: art in one spot colour ═════════ // https://postext.dev/en/cookbook/riso-zine-single-ink // Code: MIT · Text: original (CC BY 4.0) · Pictures: drawn in code (CC BY 4.0) // Fonts: Epilogue, Anton, Space Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1 // A four-page zine for a risograph with a blue drum and a fluorescent pink one. The drawings // are made in full colour and printed from the pink drum as tints of pink. import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, applySingleInkToSvg, } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'riso-zine-single-ink'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region inks: two drums and the paper; every colour in the config links to one of them // The two drums: the blue for the type and the furniture, the fluorescent pink for the art. const DRUMS = { ink: '#1d4fb8', spot: '#f0509a' }; const PAPER = '#f3efe6'; // cream stock: where no ink falls // A screen prints a share of an ink's dots and lets the paper show between them. const screen = (hex, share) => `#${[1, 3, 5].map((i) => Math.round(share * parseInt(hex.slice(i, i + 2), 16) + (1 - share) * parseInt(PAPER.slice(i, i + 2), 16)) .toString(16).padStart(2, '0')).join('')}`; const palette = { ...DRUMS, 'spot-25': screen(DRUMS.spot, 0.25), paper: PAPER }; // The hex rides with the id: design elements read the hex (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); // The engine's defaults link to main-color: aimed at the spot, none of them adds a third ink. const colorPalette = [...Object.entries(palette), ['main-color', DRUMS.spot]] .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); // #endregion // #region answer: drawings in any colours, printed from the pink drum // renderToPdf reads the ink from the config and recolours every SVG it is handed; the canvas // paints an SVG as registered (gotcha: single-ink-canvas). So the screen gets a recoloured // copy and the PDF the drawing as drawn: one pass each, as a second pass lightens it again. const diagramStyle = { singleInk: true, inkColor: col('spot') }; const printFiles = new Map(); // fileId → the bytes renderToPdf embeds async function registerArt(fileId, svg) { await loadSvg(fileId, applySingleInkToSvg(svg, diagramStyle.inkColor.hex)); // the canvas printFiles.set(fileId, new TextEncoder().encode(svg)); // the PDF, recoloured there } const pdfOptions = { fontProvider: fontsourceProvider, resourceBytes: (id) => printFiles.get(id) }; // the drawings as drawn, the PNG as it is // #endregion const TRIM = { width: 120, height: 160 }; // mm: a 240 × 160 sheet folded once const MARGIN = { top: 13, bottom: 16, inner: 12, outer: 10 }; // mm const MEASURE = TRIM.width - MARGIN.inner - MARGIN.outer; // 98 mm, about 65 characters const LEAD = 13; // body leading in pt const label = { fontFamily: 'Space Mono', fontWeight: 700, fontSize: pt(7.5), textTransform: 'uppercase', color: col('ink') }; const mono = { ...label, align: 'left', overflow: 'clip' }; // a label as a design element const at = (to, edge, x = 0, y = 0, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) }, ...(size && { size }) }); // #region cover: a pink moon behind blue wires, and the title printed twice off register const OFF = { x: 0.8, y: -0.5 }; // mm: where the pink drum lands against the blue one // Array order is paint order: the pink copy goes down first and the blue covers all but a // sliver. Canvas and PDF paint opaque colour, so the overlap stays blue, not riso purple. const twice = (id, { placement: { offset: { x, y }, ...rest }, ...element }) => [ { ...element, id: `${id}-spot`, color: col('spot'), placement: { ...rest, offset: { x: mm(x.value + OFF.x), y: mm(y.value + OFF.y) } } }, { ...element, id, color: col('ink'), placement: { ...rest, offset: { x, y } } }]; const rule = (id, direction, x, y, size) => ({ kind: 'rule', id, direction, color: col('ink'), thickness: pt(id === 'pole' ? 3 : 0.8), placement: at('page', 'top-left', x, y, size) }); // span: 'page' lets the design paint outside the text block: kept in the column, the // issue line above it and the pole below it are cut off at the column's edges. const cover = { id: 'cover', span: 'page', header: { elements: [] }, footer: { elements: [] }, advancedDesign: { enabled: true, slot: { elements: [ { kind: 'image', id: 'moon', resourceId: 'moon', placement: at('page', 'top-left', 28, 14, { width: mm(92), height: mm(92) }) }, rule('wire-1', 'horizontal', 0, 30, { width: mm(TRIM.width) }), rule('wire-2', 'horizontal', 0, 34.5, { width: mm(TRIM.width) }), rule('pole', 'vertical', 98, 62, { height: mm(TRIM.height - 62) }), // off the foot { kind: 'box', id: 'flag', style: { backgroundColor: col('ink'), borderRadius: mm(1) }, placement: at('page', 'top-left', 89, 62, { width: mm(18), height: mm(21) }) }, // One word a line: the box is narrower than two of them (gotcha: design-text-newline). { kind: 'text', id: 'stops', content: '{attr.stops}', ...mono, fontSize: pt(9), lineHeight: 1.25, align: 'center', color: col('paper'), overflow: 'wrap', placement: at('#flag', 'top-left', 3, 2.5, { width: mm(12) }) }, { kind: 'text', id: 'issue', content: '{attr.issue}', ...mono, placement: at('page', 'top-left', MARGIN.inner, 8) }, { kind: 'text', id: 'line', content: '{attr.line}', fontFamily: 'Epilogue', fontWeight: 700, fontSize: pt(9), color: col('ink'), align: 'left', overflow: 'wrap', placement: at('page', 'top-left', MARGIN.inner, 13, { width: mm(46) }) }, ...twice('title', { kind: 'text', content: '{titleText}', fontFamily: 'Anton', fontSize: pt(86), lineHeight: 0.9, // a multiple (gotcha: design-lineheight-multiple) textTransform: 'uppercase', align: 'left', overflow: 'wrap', placement: at('page', 'top-left', MARGIN.inner, 94, { width: mm(76) }) }), ] } } }; // #endregion // #region inside: the article opener, route chips, the caption bar and the quote's stripe const opener = { enabled: true, slot: { elements: [ { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...mono, placement: at('container', 'top-left', 0, 0.5) }, ...twice('title', { kind: 'text', content: '{titleText}', fontFamily: 'Anton', fontSize: pt(34), lineHeight: 0.95, textTransform: 'uppercase', align: 'left', overflow: 'wrap', placement: at('#kicker', 'below', 0, 2.5, { width: mm(MEASURE) }) }), ] } }; const route = { id: 'route', background: col('spot-25'), borderColor: col('spot'), borderWidth: pt(0.75), borderRadius: pt(1.2), fontFamily: 'Space Mono', bold: true, fontSize: em(0.86), color: col('ink') }; const captionStyle = { fontFamily: 'Epilogue', fontSize: pt(8), color: col('ink'), backgroundEnabled: true, background: col('spot-25'), padding: mm(1.6) }; const quote = { id: 'quote', backgroundEnabled: false, marginTop: pt(LEAD), marginBottom: pt(4), stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('spot') }, padding: { top: mm(2.6), right: pt(0), bottom: pt(0), left: pt(0) }, titleStyle: { ...label, gap: mm(1.2) }, // the speaker, above the words body: { fontFamily: 'Anton', fontSize: pt(14), lineHeight: pt(17), color: col('ink'), textAlign: 'left', firstLineIndent: pt(0) } }; // #endregion // #region drawn: the inset beside its note, as a heading design (the heading is its label) // The inset keeps the map's own colours. Single ink touches SVG only, so the map goes in // as a PNG snapshot, which neither the canvas nor the PDF recolours. async function registerSnapshot(fileId, svg, width, height) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); const canvas = new OffscreenCanvas(width, height); canvas.getContext('2d').drawImage(img, 0, 0, width, height); registerResourceImage(fileId, await createImageBitmap(canvas)); printFiles.set(fileId, new Uint8Array(await (await canvas.convertToBlob()).arrayBuffer())); } const INSET = 30; // mm: the snapshot's width; its height follows the map's 100 × 69.5 const drawn = { id: 'drawn', marginTop: pt(LEAD), advancedDesign: { enabled: true, // A floor at the picture's height, which a design's images do not reserve (gotcha: // opener-image-no-reserve). The note is about as tall today; with a shorter note the // next block would run over the picture. minHeight: mm(INSET * 0.695 + 1), slot: { elements: [ { kind: 'image', id: 'inset', resourceId: 'as-drawn', placement: at('container', 'top-left', 0, 0.5, { width: mm(INSET), height: 'auto' }) }, { kind: 'text', id: 'label', content: '{titleText}', ...mono, placement: at('#inset', 'right-of', 5, 0) }, { kind: 'text', id: 'note', content: '{attr.note}', fontFamily: 'Epilogue', fontSize: pt(8.5), lineHeight: 1.4, color: col('ink'), align: 'left', overflow: 'wrap', placement: at('#label', 'below', 0, 1.5, { width: mm(MEASURE - INSET - 5) }) }, ] } } }; // #endregion // Folios at the foot, outside: a pink square on the baseline, then the folio and the zine's // name on a verso, the article and the folio on a recto. const FOLIO_Y = TRIM.height - 10; // mm from the top edge to the folio's box const feet = [['even', 'left', 1, '{pageNumber} · {title} · {subtitle}'], ['odd', 'right', -1, '{chapterTitle} · {pageNumber}']].flatMap(([parity, edge, s, content]) => [ { kind: 'box', id: `mark-${parity}`, parity, style: { backgroundColor: col('spot') }, placement: at('page', `top-${edge}`, s * MARGIN.outer, FOLIO_Y + 0.7, { width: mm(1.85), height: mm(1.85) }) }, // the label's cap height, standing on its baseline { kind: 'text', id: `folio-${parity}`, parity, content, ...mono, align: edge, placement: at('page', `top-${edge}`, s * (MARGIN.outer + 3.5), FOLIO_Y) }]); // The back cover: a destination blind across the top and the cover's moon going down. const back = { id: 'back', span: 'page', // the setting moon runs past the text block breakBefore: { enabled: true, parity: 'even' }, header: { elements: [] }, footer: { elements: [] }, // The blind is MEASURE / 4 tall; its room is set by hand (gotcha: opener-image-no-reserve). advancedDesign: { enabled: true, minHeight: mm(MEASURE / 4 + 6), slot: { elements: [ { kind: 'image', id: 'blind', resourceId: 'blind', placement: at('container', 'top-left', 0, 0, { width: mm(MEASURE), height: 'auto' }) }, { kind: 'image', id: 'moonset', resourceId: 'moon', placement: at('page', 'bottom-right', 30, 34, { width: mm(76), height: mm(76) }) }, ] } } }; const config = () => ({ // a factory: the engine caches resolved configs per object colorPalette, diagramStyle, resourceTypes: [drawing], page: { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true } }, layout: { layoutType: 'single' }, bodyText: { fontFamily: 'Epilogue', fontSize: pt(9), lineHeight: pt(LEAD), color: col('ink'), // Bold and italics default to main-color, the pink here: back to the blue drum. The // reference colour follows boldColor. boldColor: col('ink'), italicColor: col('ink'), textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true }, // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). headings: { fontFamily: 'Anton', fontWeight: 400, color: col('ink'), levels: [ { level: 1, breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener }, { level: 2, fontSize: pt(15), marginTop: pt(LEAD), marginBottom: pt(2) }] }, headingStyles: [cover, drawn, back], chipStyles: [route], captionStyle, calloutStyles: [quote], unorderedLists: { color: col('spot'), marginTop: pt(2), marginBottom: pt(0) }, paragraphStyles: [{ id: 'colophon', fontFamily: 'Space Mono', fontSize: pt(7.5), lineHeight: pt(11), color: col('ink'), textAlign: 'left', marginTop: pt(LEAD) }], header: { elements: [] }, footer: { elements: feet }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── // One unnumbered type for every picture: an empty prefix and template print no number. const drawing = { id: 'drawing', name: 'Drawing', shortLabel: 'drawing', captionPrefix: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' }; const svgFile = (id, width, height, extra) => ({ id, typeId: 'drawing', kind: 'svg', svg: { fileId: `${id}.svg`, width, height }, createdAt: 0, updatedAt: 0, ...extra }); const resources = [ // Cited on page 2, the map heads page 3: a top float waits for the next page's head // (gotcha: top-float-next-page). svgFile('network', 1000, 695, { placement: { position: 'top', width: 0.74, align: 'center' }, caption: '**The network from the pink drum.** :chip[N50]{style="route"} loops round the ' + 'rest. Terminals and changes only.', altText: 'Six night bus routes and a loop round the Corn Exchange, in tints of pink.' }), { id: 'as-drawn', typeId: 'drawing', kind: 'bitmap', createdAt: 0, updatedAt: 0, bitmap: { fileId: 'as-drawn.png', format: 'png', width: 600, height: 417 }, altText: 'The same map in its drawn colours: navy, red, teal, blue, orange, yellow.' }, svgFile('moon', 960, 960, { altText: 'A full moon: a pale pink disc with its seas in a coarse dot screen.' }), svgFile('blind', 1040, 260, { altText: 'A destination blind lit up: NOT IN SERVICE.' }), ]; const markdown = String.raw`---Mostra en Markdown · 38 línies · content.en.md
title: "Night Buses" subtitle: "No. 4 · Winter" author: "Ros Adeyemi, Tom Hale, Mei Lindqvist" --- # Night Buses {style="cover" issue="No. 4 · Winter · Free" stops="Night N12 N27 N41" line="Six routes after midnight, three ridden end to end"} # After the Last Train {kicker="Brackwater, 00:10 to 06:00"} The last train leaves Station Square at 23:52. Until the first one at 05:40, about four thousand people a night ride the night buses: nurses, cleaners, bakers, kitchen staff, students, and anyone who missed the train. Five routes leave the Corn Exchange together at ten past and twenty to the hour, so a change takes three minutes, and :chip[N50]{style="route"} loops round them. The :ref{id="network" text="map opposite"} shows all six. We rode three from end to end. :chip[N12]{style="route"} **Ashgrove Hospital to Harbour Gate.** Every quarter of an hour, and 34 minutes from one end to the other. The first after midnight takes the evening shift home past the fish market. The 05:25 from the harbour brings the day shift in and empties at Ashgrove in under a minute. :::callout{type="quote" title="Dee Okafor, driver on the N27 since 2011"} At ten past three there are five buses and forty people outside the Corn Exchange. By quarter past the square is empty. ::: :chip[N27]{style="route"} **Northfield Depot to St Bride’s.** Buses run every half hour and take 41 minutes from end to end. Kiln Street’s bakers ride out on the 01:10 to light their ovens at two, and at 05:20 the driver of the last N27 collects the first loaves for the depot canteen, which has not bought bread since 2019. :chip[N41]{style="route"} **Corn Exchange to the Airport.** Buses run every half hour and take 38 minutes to the terminal, with racks for luggage. Check-in for the first flights opens at 04:45, and the 04:10 is the busiest bus of the night. ### As drawn {style="drawn" note="Drawn with a colour per route. The drum prints each colour as a screen of pink, denser the darker it was: navy N12 at 84%, yellow N50 at 24%."} # Not in Service {style="back"} ## Issue 5 is out in March It follows the cleaners who get the buses ready at Northfield Depot between 05:30 and 07:00, when the day fleet goes out. Pick up a copy: - at the Corn Exchange kiosk, open from 23:00 - in the luggage rack of any N41 - at the Northfield Depot canteen, on the counter :::paragraphs{style="colophon"} Night Buses is written by Ros Adeyemi and Tom Hale and drawn by Mei Lindqvist. Timetables from Brackwater Transport’s winter night network. Printed on a two-drum risograph in Blue and Fluorescent Pink, 300 copies on 100 g cream paper. Set in Epilogue, Anton and Space Mono (SIL OFL) · Text and drawings: CC BY 4.0 :::`; // content.<lang>.md, inlined by the Cookbook // #region art: the map, the moon and the blind, drawn in full colour function mulberry32(seed) { 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 f2 = (n) => +n.toFixed(2); // A single-stroke capital alphabet on a 4 × 6 grid with 45° corners, like the map's lines. // SVG text in an image cannot reach web fonts (gotcha: svg-no-webfonts), so labels are paths. const GLYPHS = { A: ['0 6 0 1 1 0 3 0 4 1 4 6', '0 3.5 4 3.5'], B: ['0 0 3 0 4 1 4 2 3 3 0 3', '3 3 4 4 4 5 3 6 0 6 0 0'], C: ['4 1 3 0 1 0 0 1 0 5 1 6 3 6 4 5'], D: ['0 0 3 0 4 1 4 5 3 6 0 6 0 0'], E: ['4 0 0 0 0 6 4 6', '0 3 3 3'], G: ['4 1 3 0 1 0 0 1 0 5 1 6 3 6 4 5 4 3.5 2.5 3.5'], H: ['0 0 0 6', '4 0 4 6', '0 3 4 3'], I: ['0 0 0 6'], K: ['0 0 0 6', '4 0 0 4', '1.5 2.5 4 6'], L: ['0 0 0 6 4 6'], N: ['0 6 0 0 4 6 4 0'], O: ['1 0 3 0 4 1 4 5 3 6 1 6 0 5 0 1 1 0'], P: ['0 6 0 0 3 0 4 1 4 2 3 3 0 3'], Q: ['1 0 3 0 4 1 4 5 3 6 1 6 0 5 0 1 1 0', '2.5 4.5 4 6'], R: ['0 6 0 0 3 0 4 1 4 2 3 3 0 3', '2 3 4 6'], S: ['4 1 3 0 1 0 0 1 0 2 1 3 3 3 4 4 4 5 3 6 1 6 0 5'], T: ['0 0 4 0', '2 0 2 6'], U: ['0 0 0 5 1 6 3 6 4 5 4 0'], V: ['0 0 2 6 4 0'], X: ['0 0 4 6', '4 0 0 6'], Y: ['0 0 2 3 4 0', '2 3 2 6'], F: ['4 0 0 0 0 6', '0 3 3 3'], 0: ['1 0 3 0 4 1 4 5 3 6 1 6 0 5 0 1 1 0'], 1: ['0 1 1.5 0 1.5 6'], 2: ['0 1 1 0 3 0 4 1 4 2 0 6 4 6'], 3: ['0 0 4 0 2 2.5 3 2.5 4 3.5 4 5 3 6 1 6 0 5'], 4: ['3 6 3 0 0 4 4 4'], 5: ['4 0 0 0 0 2.5 3 2.5 4 3.5 4 5 3 6 0 6'], 7: ['0 0 4 0 1.5 6'], 8: ['1 0 3 0 4 1 4 2 3 3 1 3 0 2 0 1 1 0', '1 3 3 3 4 4 4 5 3 6 1 6 0 5 0 4 1 3'], "'": ['0.5 0 0 1.5'], }; const advance = (ch) => ({ ' ': 2.6, I: 1.6, 1: 3.2, "'": 1.6 })[ch] ?? 5.6; function letter(text, x, y, size, colour, anchor = 'start', weight = 0.17) { // size: cap height const k = size / 6; const width = [...text].reduce((w, ch) => w + advance(ch), 0) - 1.6; let cx = x - (anchor === 'middle' ? width / 2 : anchor === 'end' ? width : 0) * k; let d = ''; for (const ch of text) { for (const stroke of GLYPHS[ch] ?? []) { const n = stroke.split(' ').map(Number); for (let i = 0; i < n.length; i += 2) { d += `${i ? 'L' : 'M'}${f2(cx + n[i] * k)} ${f2(y - size + n[i + 1] * k)}`; } } cx += advance(ch) * k; } return `<path d="${d}" fill="none" stroke="${colour}" stroke-width="${f2(size * weight)}" ` + 'stroke-linecap="round" stroke-linejoin="round"/>'; } // The night network in mm, 100 × 69.5 (from y = 3), one colour per route as the designer drew it. const DARK = '#23272e'; const LINES = { N12: '#16296b', N27: '#c62a1f', N41: '#0b7d74', N8: '#3b8fd4', N3: '#ef8b1f', N50: '#f3c51a' }; function network() { const path = (nodes, colour, width, close = false) => `<path d="${nodes.map(([x, y], i) => `${i ? 'L' : 'M'}${x} ${y}`).join('')}${close ? 'Z' : ''}" fill="none" stroke="${colour}" ` + `stroke-width="${width}" stroke-linejoin="round" stroke-linecap="round"/>`; const river = path([[0, 48], [8, 48], [14, 54], [86, 54], [92, 48], [100, 48]], '#bfe0ee', 4.4); const loop = path([[32, 21], [68, 21], [74, 27], [74, 41], [68, 47], [32, 47], [26, 41], [26, 27]], LINES.N50, 1.8, true); const legs = [ // route, then its nodes from the hub outwards ['N12', [[50, 31], [50, 6]]], ['N12', [[60, 37], [85, 62], [86, 62]]], ['N27', [[40, 31], [21, 12], [14, 12]]], ['N27', [[64.5, 34], [86, 34]]], ['N3', [[35.5, 34], [14, 34]]], ['N3', [[50, 37], [50, 70]]], ['N41', [[60, 31], [79, 12], [86, 12]]], ['N8', [[40, 37], [15, 62], [14, 62]]], ].map(([id, nodes]) => path(nodes, LINES[id], 1.8)).join(''); const stops = [[50, 6], [86, 62], [14, 12], [86, 34], [14, 34], [50, 70], [86, 12], [14, 62], [50, 21], [50, 47], [26, 34], [74, 34], [69, 22], [31, 22], [69, 46], [31, 46]] .map(([x, y]) => `<circle cx="${x}" cy="${y}" r="0.95" fill="${DARK}"/>`).join(''); const hub = `<rect x="35.5" y="31" width="29" height="6" rx="3" fill="${DARK}"/>` + letter('CORN EXCHANGE', 50, 35, 2, '#ffffff', 'middle', 0.19); const badge = (id, x, y, side) => { // y: the line's axis; side: which way from the stop const w = id.length * 1.76 + 1.2; const x0 = side === 'left' ? x - 1.6 - w : side === 'right' ? x + 1.6 : x - w / 2; return `<rect x="${f2(x0)}" y="${y - 2.1}" width="${f2(w)}" height="4.2" rx="0.8" ` + `fill="${LINES[id]}"/>${letter(id, x0 + 0.62, y + 1.1, 2.2, id === 'N50' ? DARK : '#ffffff', 'start', 0.2)}`; }; const name = (text, x, y, anchor) => letter(text, x, y, 1.9, DARK, anchor); const labels = [ badge('N12', 50, 6, 'left'), name('ASHGROVE HOSPITAL', 53, 7), badge('N27', 14, 12, 'left'), name('NORTHFIELD DEPOT', 4.7, 8.2), badge('N41', 86, 12, 'right'), name('AIRPORT', 95.3, 8.2, 'end'), badge('N3', 14, 34, 'left'), name('CANAL BASIN', 4.7, 30.2), badge('N27', 86, 34, 'right'), name("ST BRIDE'S", 95.3, 30.2, 'end'), badge('N8', 14, 62, 'left'), name('UNIVERSITY', 4.7, 67.8), badge('N12', 86, 62, 'right'), name('HARBOUR GATE', 95.3, 67.8, 'end'), badge('N3', 50, 70, 'left'), name('STATION SQ', 53, 71), badge('N50', 40.7, 47, 'middle'), letter('RIVER BRACK', 32, 59.9, 1.7, '#1f5a85', 'middle', 0.19), ].join(''); return '<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="695" ' + `viewBox="0 3 100 69.5">${river}${loop}${legs}${stops}${hub}${labels}</svg>`; } // The cover's moon, 96 × 96 mm: a light grey disc, then a 45° dot screen whose dots grow and // darken with the tone over the near side's larger seas. function moon() { const rand = mulberry32(7); const cells = Array.from({ length: 9 * 9 }, rand); // value noise, 8 cells across const noise = (u, v) => { const [gx, gy] = [u * 8, v * 8]; const [i, j] = [Math.floor(gx), Math.floor(gy)]; const [s, t] = [gx - i, gy - j].map((a) => a * a * (3 - 2 * a)); const g = (a, b) => cells[Math.min(8, b) * 9 + Math.min(8, a)]; return (g(i, j) * (1 - s) + g(i + 1, j) * s) * (1 - t) + (g(i, j + 1) * (1 - s) + g(i + 1, j + 1) * s) * t; }; const R = 46; const seas = [ // north up: centre, half-axes and tilt, in moon radii [-0.58, -0.02, 0.2, 0.42, 0.3], [-0.3, -0.42, 0.27, 0.22, 0], // Procellarum, Imbrium [0.16, -0.4, 0.15, 0.14, 0], [0.36, -0.08, 0.19, 0.15, 0.4], // Serenitatis, Tranquillitatis [0.72, -0.28, 0.09, 0.11, 0], [0.6, 0.14, 0.09, 0.14, -0.3], // Crisium, Fecunditatis [0.38, 0.28, 0.08, 0.08, 0], [-0.2, 0.34, 0.15, 0.12, 0], // Nectaris, Nubium [-0.52, 0.38, 0.08, 0.08, 0], [-0.05, -0.76, 0.36, 0.06, 0.1], // Humorum, Frigoris [-0.02, -0.16, 0.08, 0.07, 0]]; // Vaporum const P = 2; // screen pitch in mm let dots = ''; for (let i = -34; i <= 34; i++) { for (let j = -34; j <= 34; j++) { const [x, y] = [(i - j) * P / Math.SQRT2, (i + j) * P / Math.SQRT2]; if (Math.hypot(x, y) > R - 0.6) continue; // the disc's edge stays a clean circle const [u, v] = [x / R, y / R]; let sea = 0; for (const [mx, my, rx, ry, a] of seas) { const [du, dv] = [u - mx, v - my]; const [p, q] = [du * Math.cos(a) + dv * Math.sin(a), dv * Math.cos(a) - du * Math.sin(a)]; sea = Math.max(sea, Math.exp(-(((p / rx) ** 2 + (q / ry) ** 2) ** 1.5))); } const m = Math.min(1, Math.max(0, (sea * (0.85 + 0.3 * noise((u + 1) / 2, (v + 1) / 2)) - 0.3) / 0.35)); // 0 on the highlands, 1 inside a sea, with a ragged shore let tone = 0.2 + 0.08 * noise((v + 1) / 2, (u + 1) / 2) + 0.55 * m * m * (3 - 2 * m); for (const [cx, cy, cr] of [[-0.12, 0.72, 0.05], [-0.32, -0.15, 0.035]]) { // bright craters if (Math.hypot(u - cx, v - cy) < cr) tone = 0.1; } const grey = Math.round(210 - 190 * tone).toString(16).padStart(2, '0'); dots += `<circle cx="${f2(x + 48)}" cy="${f2(y + 48)}" r="${f2(P * 0.6 * Math.sqrt(tone))}" ` + `fill="#${grey}${grey}${grey}"/>`; } } return '<svg xmlns="http://www.w3.org/2000/svg" width="960" height="960" viewBox="0 0 96 96">' + `<circle cx="48" cy="48" r="${R}" fill="#c8c8c8"/>${dots}</svg>`; } // The back cover's destination blind, 104 × 26 mm: amber lights on black, 5 × 7 letters. const LED = { N: ['10001', '11001', '10101', '10011', '10001', '10001', '10001'], O: ['01110', '10001', '10001', '10001', '10001', '10001', '01110'], T: ['11111', '00100', '00100', '00100', '00100', '00100', '00100'], I: ['111', '010', '010', '010', '010', '010', '111'], S: ['01111', '10000', '10000', '01110', '00001', '00001', '11110'], E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'], R: ['11110', '10001', '10001', '11110', '10100', '10010', '10001'], V: ['10001', '10001', '10001', '10001', '10001', '01010', '00100'], C: ['01110', '10001', '10000', '10000', '10000', '10001', '01110'], ' ': ['00', '00', '00', '00', '00', '00', '00'], }; function blind(text = 'NOT IN SERVICE') { const cols = [...text].flatMap((ch) => [...LED[ch][0]].map((_, c) => LED[ch].map((row) => row[c] === '1')).concat([Array(7).fill(false)])).slice(0, -1); const [W, H, P, ROWS] = [104, 26, 1.25, 13]; // the matrix has 13 rows; letters on rows 3–9 const n = Math.floor((W - 6) / P); const [x0, y0, first] = [(W - (n - 1) * P) / 2, (H - (ROWS - 1) * P) / 2, Math.floor((n - cols.length) / 2)]; let dots = ''; for (let c = 0; c < n; c++) { for (let r = 0; r < ROWS; r++) { const on = cols[c - first]?.[r - 3] ?? false; dots += `<circle cx="${f2(x0 + c * P)}" cy="${f2(y0 + r * P)}" r="${on ? 0.55 : 0.36}" ` + `fill="${on ? '#ffd35a' : '#3a3a3a'}"/>`; } } return `<svg xmlns="http://www.w3.org/2000/svg" width="1040" height="260" viewBox="0 0 ${W} ${ H}"><rect width="${W}" height="${H}" rx="2.5" fill="#161616"/>${dots}</svg>`; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Text, display and label faces, loaded before the build (gotcha: fonts-first). const FONTS = { Epilogue: ['400', '700'], Anton: ['400'], 'Space Mono': ['400', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); const map = network(); await Promise.all([registerArt('network.svg', map), registerArt('moon.svg', moon()), registerArt('blind.svg', blind()), registerSnapshot('as-drawn.png', map, 600, 417)]); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: 'Night Buses: a two-ink riso zine' }); offerPdf(() => renderToPdf(doc, pdfOptions), `${RECIPE}.pdf`); // A grey proof: the pages as a photocopier or a one-drum reprint would print them. offerPdf(() => renderToPdf(doc, { ...pdfOptions, colorSpace: 'grayscale' }), `${RECIPE}-grey-proof.pdf`);Kit · core, fonts, viewer, pdf, images: igual a totes les receptes · 310 línies
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · pdf v1 ── the same in every recipe that exports a PDF ────────────── /** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen * used, snapping to a weight the family ships and falling back to upright * when it has no italic: the PDF asks for every face a block could use. */ async function fontsourceProvider(family, weight, style) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); } /** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new * tab, since CodePen's preview frame cannot show PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── 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 ───────────────────────────────────────────────────────────────────────
L'script.js compost funciona tal com és: enganxa'l com a script de mòdul en qualsevol pàgina o obre la recepta a CodePen. Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)
Variants
#Carrega un tambor taronja
La lluna, el mapa, el rètol, els xips, el filet i les còpies desplaçades dels dos títols passen a taronja i el text continua en blau; el peu, la nota i el colofó continuaran parlant del tambor rosa fins que els canviïs.
-const DRUMS = { ink: '#1d4fb8', spot: '#f0509a' };
+const DRUMS = { ink: '#1d4fb8', spot: '#ff6c2f' };#Imprimeix un llibre de text a dos colors
Amb text negre i una tinta plana blava sobre paper blanc, els mateixos dibuixos surten en tons de blau, com els esquemes d'un llibre de text a dos colors; el peu, la nota i el colofó continuaran parlant del tambor rosa fins que els canviïs.
-const DRUMS = { ink: '#1d4fb8', spot: '#f0509a' };
-const PAPER = '#f3efe6'; // cream stock: where no ink falls
+const DRUMS = { ink: '#1f1f1f', spot: '#0078bf' };
+const PAPER = '#ffffff'; // white stock: where no ink fallsErrors freqüents
Error freqüent
renderPage no recoloreja els SVG; renderToPdf sí
renderToPdf aplica diagramStyle.singleInk, però el canvas pinta els SVG registrats tal com arriben. Recoloreja el marcatge amb applySingleInkToSvg abans de registrar-lo perquè pantalla i PDF coincideixin. Diagrames a una tinta →
Error freqüent
El text dins d'un SVG <img> no pot fer servir fonts web
Un SVG es dibuixa com a imatge, i una imatge no té accés a les fonts web de la pàgina, de manera que els seus rètols surten amb una font del sistema. Converteix el text en traçats, incrusta un subconjunt @font-face a l'SVG o porta els rètols al peu. Figures i taules com a recursos →
Error freqüent
Una paleta canviada no arriba als elements de disseny ni al color de les remissions
postext 1.4.1 aplica colorPalette als estils de text (cos, títols, llistes, peus, taules, requadres), però no als elements de capçaleres, peus de pàgina, obertures i portadelles, ni a bodyText.referenceColor: conserven l'hex escrit al costat del seu paletteId. Si canvies la paleta, per a una edició de pantalla fosca o per recolorejar, reescriu cada color enllaçat a partir de colorPalette abans de compondre. Paleta de color semàntica →
Error freqüent
Les imatges d'una obertura no compten per a l'alçada que reserva
A postext 1.4.1, un títol amb disseny avançat mesura l'alçada que reserva sense comptar-ne les imatges: els seus textos, filets i caixes compten, encara que estiguin ancorats a la pàgina, però una imatge, com un dibuix a sang al capdamunt de la pàgina, no reserva res, de manera que el text pot començar a sobre. Fixa amb minHeight on ha de començar el text. Obertures dissenyades →
Error freqüent
El lineHeight d'un text de disseny és un múltiple, mai una mesura
En una ranura de disseny, el lineHeight d'un element de text multiplica el seu cos (lineHeight: 1.05). A postext 1.4.1 una mesura com pt(15) no dona error: l'alçada de l'obertura surt NaN, l'espai que reserva, minHeight inclòs, es perd sense avís i el text se superposa al títol. Textos, filets i caixes als dissenys de pàgina →
Error freqüent
\n en un atribut només talla línies amb paragraphIndent > 0
En un element de text de disseny, un \n escrit al valor d'un atribut només obre una línia nova si paragraphIndent és més gran que zero o hi ha caplletra; si no, el text continua en una línia. Posa a paragraphIndent un valor mínim (0,01 pt) o fes servir un atribut per línia. Textos, filets i caixes als dissenys de pàgina →
Error freqüent
Un flotant 'top' no cau mai a la pàgina que el cita
Un flotant no va mai per sobre de la seva pròpia referència, així que un flotant 'top' a tota l'amplada citat a la pàgina N obre la pàgina N+1. Cita'l abans, o fes servir la posició 'auto' o 'bottom', que poden ocupar el peu de la pàgina que el cita. Col·locació de figures →
Error freqüent
El text en bandera pot deixar sola la puntuació al costat d'una negreta o un :ref
A postext 1.4.1, el text que no va justificat (cossos de requadre, paràgrafs en bandera) pot partir la línia entre una negreta, una cursiva o un :ref i el signe de puntuació que hi va enganxat: un punt pot obrir la línia següent i el «(» d'una remissió pot tancar l'anterior. El text justificat no es parteix mai en aquest punt. Revisa els requadres de cada edició i reescriu la frase afectada perquè aquest tram quedi a mitja línia. Negreta, cursiva i els seus colors →
Error freqüent
Qualsevol objecte headings desactiva el salt de pàgina de l'H1
Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració. Capítols que obren en pàgina senar →
Error freqüent
Carrega totes les fonts abans de compondre
La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard. Fonts abans de compondre →
- Passa a
renderToPdfel dibuix original. A la 1.4.1,renderToPdfrecoloreix qualsevol SVG que rebi, així que una còpia ja recolorida torna a passar per la fórmula i la línia de l'N12 baixa del 84 % al 44 %. - A la 1.4.1,
applySingleInkToSvgreescriu els colors hex,rgb()irgba()amb valors enters i les parauleswhiteiblack. Una forma que fa servirredohsl(), o que deixa l'emplenament en el negre per defecte, conserva el seu color, i aquest color demanaria un tercer tambor. Dona un valor hex a cada emplenament i a cada traç. colorSpace: 'grayscale'passa a grisos el que pinta postext i incrusta els mapes de bits tal com són, així que la miniatura PNG continua en color a la prova en grisos. La prova només la genera el segon botó en executar el codi; les pàgines de dalt són el canvas, i el PDF que es baixa en aquesta pàgina és el de color.- Amb l'espaiat entre paràgrafs activat, la 1.4.1 pot deixar una línia de més abans de la resta d'un paràgraf que continua a la pàgina següent sota un flotant superior. El text de la recepta no ho provoca. Si el canvies, mira la primera línia sota el mapa, perquè una línia de més pot portar la miniatura a una cinquena pàgina.
- En executar el codi, els dos botons diuen «Build the PDF». El primer genera el PDF en color i el segon la prova en grisos; quan el fitxer és a punt, el seu botó es canvia per un enllaç «Open the PDF ↗» i un altre de baixada amb el nom del fitxer.
Crèdits
- Recepta
- Ignacio Ferro
- Text
- Text original, CC BY 4.0
- Fonts
- Epilogue (SIL OFL 1.1) · Anton (SIL OFL 1.1) · Space Mono (SIL OFL 1.1)
- Codi
- MIT, com Postext
Editar aquest text ↗ (s'obre en una pestanya nova)Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)


