In breve
Una piccola rivista piegata sugli autobus notturni, fatta per una macchina da stampa che usa solo due inchiostri. I disegni a colori diventano sfumature di un solo inchiostro rosa.
Che cosa comporrai
Night Buses è una fanzine gratuita sugli autobus notturni di una città: quattro pagine di 120 × 160 mm su un foglio piegato una volta, per una risograph con due tamburi, blu e rosa fluorescente. Ogni colore della configurazione è uno dei due inchiostri, un retino al 25% del rosa o la carta crema. I disegni sono fatti a colori pieni (una mappa delle linee in sei colori, una luna in grigi, un indicatore di destinazione ambra su nero) e si stampano dal tamburo rosa come tinte di rosa, con i colori più scuri come tinte più dense. La mappa si stampa in rosa, e un piccolo riquadro più in basso nella stessa pagina conserva i sei colori del disegno. Il titolo di copertina è composto due volte, con la copia rosa 0,8 mm a destra di quella blu e 0,5 mm più in alto. Il pen costruisce anche un PDF a colori e una prova in grigio.
Questa ricetta risponde a
- Come ricoloro tutti i diagrammi con un unico inchiostro spot?
La risposta breve
// renderToPdf reads the ink from the config and recolours every SVG it is handed; the canvas
// does the same to a picture registered with singleInk: true. Both get the drawing as drawn.
const diagramStyle = { singleInk: true, inkColor: col('spot') };
// The kit's loadSvg hands the flag to registerResourceImage, so the canvas tints the drawing
// as it paints, and keeps the markup for imageBytes: the PDF recolours those bytes.
const loadArt = (map) => Promise.all([
loadSvg('network.svg', map, { singleInk: true }),
loadSvg('moon.svg', moon(), { singleInk: true }),
loadSvg('blind.svg', blind(), { singleInk: true }),
]);
const pdfOptions = { fontProvider: fontsourceProvider, resourceBytes: imageBytes };
Ingredienti
- Funzionalità
- Diagrammi a un solo inchiostroTavolozza di colori semanticaPDF in CMYK e in scala di grigiEsportazione in PDFPagine su un canvasChip in lineaStile delle didascalieRiquadriStili di titoloAttributi dei titoliAperture progettateTesto, filetti e riquadri nei progetti di paginaImmagini nei progetti di paginaCopertine, frontespizi e colophonFigure e tabelle come risorseTipi di risorsa personalizzatiCollocazione delle figureColore della carta
- Usa anche
- Citazioni che collocano le figureStili di paragrafoFont incorporati nel PDFTestatine per sezione
- Tipo
- Epilogue, Anton, Space Mono (SIL OFL 1.1)
- Risorse
- Nessuna: ogni immagine è disegnata nel codice
Preparazione
#1 · Due tamburi, e ogni colore è uno dei due
// 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 };
// Every colour is linked to its palette entry by id.
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' } }));
Una risograph stampa ogni inchiostro dal suo tamburo, quindi un terzo colore in qualsiasi punto della configurazione richiederebbe un terzo tamburo. screen() mescola una quota di un inchiostro con il colore della carta, come fa un retino che stampa una parte dei punti e lascia carta tra l’uno e l’altro. Un retino al 25% del rosa è #f2c7d3, il fondo dei chip e della didascalia. main-color punta al rosa, quindi ogni valore predefinito che la configurazione non tocca si stampa da quel tamburo e non nel blu del motore, #295AA3.
#2 · Ricolora ogni disegno una volta per output
Il codice è la risposta breve qui sopra. Con diagramStyle.singleInk, renderToPdf trasforma ogni colore di un SVG in una tinta di inkColor la cui intensità è 1 meno la luminanza relativa del colore (stile dei diagrammi), quindi la N12 blu notte si stampa all’84% e la N50 gialla al 24%. Il canvas tinge un’immagine allo stesso modo quando è registrata con singleInk: true, quindi il loadSvg(fileId, svg, { singleInk: true }) del kit registra ogni disegno così com’è con quel flag e conserva lo stesso markup, che imageBytes passa a resourceBytes. Campionata sul canvas e nel PDF rasterizzato a 300 dpi, la linea N12 è (242, 109, 171), la tinta che la formula dà per un’intensità di 0,836. Ogni output ricolora il disegno una volta sola: passata due volte per la formula, la linea si schiarirebbe al 44%.
#3 · Stampa il titolo due volte, fuori registro
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 run past the text block's foot: kept in the column, the
// pole would be cut off there.
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.
{ 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,
textTransform: 'uppercase', align: 'left',
overflow: 'wrap', placement: at('page', 'top-left', MARGIN.inner, 94, { width: mm(76) }) }),
] } } };
Una risograph stampa un tamburo dopo l’altro, e il foglio non cade mai due volte esattamente nello stesso punto. twice() restituisce due copie di un elemento di testo, prima quella rosa, 0,8 mm a destra e 0,5 mm più in alto. L’ordine dell’array è l’ordine di pittura, quindi la copia blu copre quella rosa tranne una sottile striscia lungo i bordi superiore e destro di ogni lettera. Canvas e PDF dipingono colore opaco, quindi la sovrapposizione resta blu dove una riso stamperebbe viola. Il progetto grafico occupa tutta la pagina perché il palo va oltre il piede della gabbia del testo, dove un progetto grafico tenuto nella sua colonna verrebbe tagliato.
#4 · Tieni il testo piccolo nell’inchiostro scuro
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) } };
Sulla carta crema il blu misura 6,4:1 e il rosa fluorescente 2,9:1, troppo debole per un testo a 8 o 9 pt. Ogni parola composta dal motore si stampa quindi dal tamburo blu. I chip sono in Space Mono blu sul retino al 25% (4,85:1) dentro un contorno rosa, e la didascalia è in Epilogue blu sullo stesso retino. Il rosa stampa la striscia sopra la citazione, i contorni dei chip, i punti elenco, i quadrati dei folio e le copie sfalsate dei titoli. Le scritte dentro i disegni fanno parte dell’illustrazione e si stampano in rosa con essa. Il nome di chi parla è il titolo del riquadro, composto in maiuscole Space Mono neretto da 7,5 pt sopra la citazione.
#5 · Metti il riquadro accanto alla sua 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 canvas = new OffscreenCanvas(width, height);
canvas.getContext('2d').drawImage(await decodeSvgImage(svg), 0, 0, width, height);
const png = URL.createObjectURL(await canvas.convertToBlob());
await loadImage(fileId, png); // the kit registers the PNG and keeps its bytes for the PDF
URL.revokeObjectURL(png);
}
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,
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) }) },
] } } };
In una colonna sola un elemento flottante prende tutta la larghezza della sua fascia, quindi una piccola immagine fatta flottare lì lascerebbe vuoto il resto della fascia. Il riquadro è invece un progetto grafico di titolo: ### As drawn {style="drawn" note="…"} stampa l’immagine e, alla sua destra, il titolo come etichetta con la nota sotto. L’immagine conta per l’altezza che il progetto grafico riserva, quindi il blocco successivo comincia sotto di essa anche quando la nota è più corta. registerSnapshot() disegna la stessa mappa network() in un PNG, e poiché l’inchiostro singolo ricolora solo gli SVG, il riquadro conserva i suoi sei colori a schermo e nel PDF.
La ricetta 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.25.0 // 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 { buildDocumentWithFonts, renderPageToCanvas, registerResourceImage, decodeSvgImage, } 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 }; // Every colour is linked to its palette entry by id. 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 // does the same to a picture registered with singleInk: true. Both get the drawing as drawn. const diagramStyle = { singleInk: true, inkColor: col('spot') }; // The kit's loadSvg hands the flag to registerResourceImage, so the canvas tints the drawing // as it paints, and keeps the markup for imageBytes: the PDF recolours those bytes. const loadArt = (map) => Promise.all([ loadSvg('network.svg', map, { singleInk: true }), loadSvg('moon.svg', moon(), { singleInk: true }), loadSvg('blind.svg', blind(), { singleInk: true }), ]); const pdfOptions = { fontProvider: fontsourceProvider, resourceBytes: imageBytes }; // #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 run past the text block's foot: kept in the column, the // pole would be cut off there. 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. { 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, 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 canvas = new OffscreenCanvas(width, height); canvas.getContext('2d').drawImage(await decodeSvgImage(svg), 0, 0, width, height); const png = URL.createObjectURL(await canvas.convertToBlob()); await loadImage(fileId, png); // the kit registers the PNG and keeps its bytes for the PDF URL.revokeObjectURL(png); } 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, 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: the floor leaves 6 mm of air under it. 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', reserve: false, // the text runs above it placement: at('page', 'bottom-right', 30, 34, { width: mm(76), height: mm(76) }) }, ] } } }; const config = () => ({ colorPalette, diagramStyle, resourceTypes: [drawing], locale: 'en-us', // the zine is written in English 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 }, // parity 'any': the article opens the next page, whichever side it falls on. 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`---Esempio Markdown · 38 righe · 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. // The labels are paths, stroked like the lines and recoloured with them. 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. const FONTS = { Epilogue: ['400', '700'], Anton: ['400'], 'Space Mono': ['400', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const map = network(); await Promise.all([loadArt(map), registerSnapshot('as-drawn.png', map, 600, 417)]); const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS)); 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: uguale in ogni ricetta · 315 righe
// ─── 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 v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}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().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { 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, { ...render, 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 v3 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType, with their range and * place: the nearest weight the family ships, upright with no italic; * latin, then what its letters need (kitSubsetsFor). */ async function fontsourceProvider(family, weight, style, request) { 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 text = String.fromCodePoint(...(request?.codePoints ?? [])); const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => { const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())), { unicodeRange: kitRanges()[subset], order }); })); return files.length === 1 ? files[0] : files; } /** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame * shows no 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 v3 ── 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 as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** 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 ───────────────────────────────────────────────────────────────────────
Lo script.js composto funziona così com’è: incollalo nello script modulo di qualsiasi pagina, oppure apri la ricetta su CodePen. Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)
Varianti
#Monta un tamburo arancione
La luna, la mappa, l’indicatore di destinazione, i chip, la striscia e le copie sfalsate di entrambi i titoli diventano arancioni e il testo resta blu; la didascalia, la nota e il colophon continuano a nominare il tamburo rosa finché non li modifichi.
-const DRUMS = { ink: '#1d4fb8', spot: '#f0509a' };
+const DRUMS = { ink: '#1d4fb8', spot: '#ff6c2f' };#Stampa un manuale a due colori
Con testo nero e uno spot blu su carta bianca, gli stessi disegni si stampano come tinte del blu, come i diagrammi di un libro scolastico a due colori; la didascalia, la nota e il colophon continuano a nominare il tamburo rosa finché non li modifichi.
-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 fallsErrori frequenti
Errore frequente
Un elemento flottante 'top' va alla pagina dopo la sua citazione, a meno che non possa aprire la pagina che lo cita
Per impostazione predefinita un elemento flottante non va mai sopra il proprio riferimento, quindi un flottante 'top' a tutta larghezza citato a pagina N apre la pagina N+1. Imposta placement.citingPage: true sulla risorsa o sul defaultPlacement del suo tipo (o layout.floatsAtCitingPage per tutto il libro, da postext 1.25) e aprirà invece la pagina N, con il testo sopra il riferimento che scende sotto di esso, quando entra entro layout.maxTopFraction e la riga che lo cita resta su quella pagina. Non apre mai la prima pagina di un capitolo né una pagina dopo un’interruzione forzata: lì apre ancora la pagina successiva, e 'auto' o 'bottom' possono prendere il piede della pagina che lo cita. Collocazione delle figure →
- Nel PDF la ricolorazione riscrive il markup: colori esadecimali,
rgb(),rgba(),hsl()ehsla(), e le parole chiavewhiteeblack. Una forma che nominaredo lascia il riempimento sul nero predefinito conserva lì quel colore, che richiederebbe un terzo tamburo. Dai a ogni riempimento e a ogni tratto un valore esadecimale. colorSpace: 'grayscale'rende grigio ciò che dipinge postext e incorpora le bitmap così come sono, quindi il riquadro PNG resta a colori nella prova in grigio. Solo il secondo pulsante del pen costruisce la prova; le pagine qui sopra sono il canvas, e il PDF offerto in questa pagina è quello a colori.- Il bilanciamento delle colonne può lasciare una riga di spazio sotto un elemento flottante in testa, prima del resto di un paragrafo che continua dalla pagina precedente;
headings.balancing.stretchAfterFloats: falsetiene il testo subito sotto l’elemento flottante. Il testo qui non lo provoca. Dopo aver modificato il testo, controlla la prima riga sotto la mappa, perché una riga in più può spingere il riquadro su una quinta pagina. - Nel pen entrambi i pulsanti dicono «Build the PDF». Il primo costruisce il PDF a colori e il secondo la prova in grigio; una volta costruito un file, il suo pulsante lascia il posto a un link «Open the PDF ↗» e a un link di download con il nome del file.
Crediti
- Ricetta
- Ignacio Ferro
- Testo
- Testo originale, CC BY 4.0
- Font
- Epilogue (SIL OFL 1.1) · Anton (SIL OFL 1.1) · Space Mono (SIL OFL 1.1)
- Codice
- MIT, come Postext
Modifica questo testo ↗ (si apre in una nuova scheda)Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)


