What you'll build
Night Buses is a free zine about a city's night buses: four pages of 120 × 160 mm on one sheet folded once, for a risograph with two drums, blue and fluorescent pink. Every colour in the config is one of the two inks, a 25% screen of the pink or the cream paper. The drawings are made in full colour (a route map in six colours, a moon in greys, an amber destination blind on black) and print from the pink drum as tints of pink, darker colours as denser tints. The map prints in pink, and a small inset lower on its page keeps the six colours as drawn. The cover title is set twice, with the pink copy 0.8 mm right of the blue one and 0.5 mm higher. The pen also builds a colour PDF and a grey proof.
This recipe answers
- How do I recolour every diagram to a single spot ink?
The short answer
// 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
Drawings in any colours, printed from the pink drum
Ingredients
- Features
- Single-ink diagramsSemantic colour paletteCMYK and grayscale PDFsPDF exportPages on a canvasInline chipsCaption styleCallout boxesHeading stylesHeading attributesDesigned openersText, rules and boxes in page designsPictures in page designsCovers, title pages and colophonsFigures and tables as resourcesCustom resource typesFigure placementPaper colour
- Also uses
- Citations that place figuresParagraph stylesFonts embedded in the PDFRunning heads per section
- Type
- Epilogue, Anton, Space Mono (SIL OFL 1.1)
- Assets
- None: every picture is drawn in code
Method
#1 · Two drums, and every colour 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' } }));
A risograph prints each ink from its own drum, so a third colour anywhere in the config would need a third drum. screen() mixes a share of an ink with the paper colour, the way a screen prints part of the dots and leaves paper between them. A 25% screen of the pink is #f2c7d3, the ground of the chips and the caption. main-color points at the pink, so any default the config leaves alone prints from that drum and not in the engine's own blue, #295AA3. col() writes each hex beside its id because 1.4.1 applies the palette to the text styles but not to design elements.
#2 · Recolour each drawing once per output
The code is the short answer above. With diagramStyle.singleInk, renderToPdf turns every colour of an SVG into a tint of inkColor whose strength is 1 minus the colour's relative luminance (diagram style), so the navy N12 prints at 84% and the yellow N50 at 24%. In 1.4.1 the canvas paints a registered SVG unchanged, so registerArt() registers a copy recoloured by applySingleInkToSvg and keeps the original drawing for resourceBytes. Sampled on the canvas and in the PDF rasterised at 300 dpi, the N12 line is (242, 109, 171), the tint the formula gives for a strength of 0.836. A second pass would lighten it to 44%, so the PDF is never handed the recoloured copy.
#3 · Print the title 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) }) }),
] } } };
A risograph prints one drum after the other, and the sheet never lands twice in exactly the same place. twice() returns two copies of a text element, the pink one first, 0.8 mm to the right and 0.5 mm up. Array order is paint order, so the blue copy covers the pink except for a sliver along the top and right edges of each letter. Canvas and PDF paint opaque colour, so the overlap stays blue where a riso would print purple. The design spans the page because the issue line sits above the text block and the pole runs past its foot; in a design kept in the column, 1.4.1 clips both at the column's edges.
#4 · Keep small type in the dark ink
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) } };
On the cream paper the blue measures 6.4:1 and the fluorescent pink 2.9:1, too faint for type at 8 or 9 pt. Every word the engine sets therefore prints from the blue drum. The chips are blue Space Mono on the 25% screen (4.85:1) inside a pink outline, and the caption is blue Epilogue on the same screen. The pink prints the stripe over the quote, the chip outlines, the bullets, the folio squares and the offset copies of the titles. The lettering inside the drawings belongs to the art and prints pink with it. The speaker's name is the box title, set in bold 7.5 pt Space Mono capitals above the quote.
#5 · Set the inset beside its note
// 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) }) },
] } } };
In one column a float takes the whole width of its band, so a small picture floated there would leave the rest of the band empty. The inset is a heading design instead: ### As drawn {style="drawn" note="…"} prints the picture and, to its right, the heading as a label with the note under it. A design's images reserve no height, so minHeight sets a floor at the picture's height. The note is about as tall today; with a shorter note, the next block would run over the picture. registerSnapshot() draws the same network() map into a PNG, and since single ink recolours only SVG, the inset keeps its six colours on screen and in the PDF.
The whole recipe
// ═══ 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`---Markdown sample · 38 lines · 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: the same in every recipe · 310 lines
// ─── 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 ───────────────────────────────────────────────────────────────────────
The composed script.js runs as it is: paste it into any page’s module script, or open the recipe on CodePen. Recipe folder on GitHub ↗
Variations
#Load an orange drum
The moon, the map, the blind, the chips, the stripe and the offset copies of both titles turn orange and the type stays blue; the caption, the note and the colophon go on naming the pink drum until you edit them.
-const DRUMS = { ink: '#1d4fb8', spot: '#f0509a' };
+const DRUMS = { ink: '#1d4fb8', spot: '#ff6c2f' };#Print a two-colour textbook
With black type and a blue spot on white paper, the same drawings print as tints of the blue, like the diagrams in a two-colour schoolbook; the caption, the note and the colophon still name the pink drum until you edit them.
-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 fallsPitfalls
Pitfall
renderPage does not recolour SVGs; renderToPdf does
diagramStyle.singleInk is applied by renderToPdf, but the canvas paints registered SVGs as given. Recolour the markup with applySingleInkToSvg before registering it, so screen and PDF match. Single-ink diagrams →
Pitfall
Text inside an SVG <img> cannot use web fonts
An SVG is drawn as an image, and an image has no access to the page's web fonts, so its labels fall back to a system face. Outline the text, embed an @font-face subset in the SVG, or move the labels to the caption. Figures and tables as resources →
Pitfall
A swapped palette misses design elements and the reference colour
postext 1.4.1 reads colorPalette into the text styles (body, headings, lists, captions, tables, boxes) but not into the elements of headers, footers, openers and part pages, nor into bodyText.referenceColor: they keep the hex written beside their paletteId. When you swap the palette, for a dark screen edition or a retint, rewrite every linked colour from colorPalette before the build. Semantic colour palette →
Pitfall
An opener's images never count towards the height it reserves
In postext 1.4.1 an advanced-design heading measures the height it reserves without its images: its texts, rules and boxes count, even when anchored to the page, but an image, such as a picture bled across the head of the page, reserves nothing, so the text can start on top of it. Set minHeight to where the text should begin. Designed openers →
Pitfall
A design text's lineHeight is a multiple, never a dimension
In a design slot, a text element's lineHeight multiplies its font size (lineHeight: 1.05). In postext 1.4.1 a dimension such as pt(15) is not rejected: the opener's height measures as NaN, the room it reserves, minHeight included, is dropped without a warning and the text runs under the title. Text, rules and boxes in page designs →
Pitfall
\n in an attribute breaks lines only with paragraphIndent > 0
In a design text element, a \n written in an attribute value starts a new line only when paragraphIndent is above zero or a drop cap is set; otherwise the text stays on one line. Set paragraphIndent to a hair (0.01 pt), or use one attribute per line. Text, rules and boxes in page designs →
Pitfall
A 'top' float never lands on its citing page
A float never goes above its own reference, so a page-wide 'top' float cited on page N opens page N+1. Cite it earlier, or use position 'auto' or 'bottom', which can take the foot of the citing page. Figure placement →
Pitfall
Ragged text can strand punctuation next to bold or a :ref
In postext 1.4.1 text that is not justified (box bodies, ragged paragraphs) can break a line between a bold or italic run, or a :ref, and the punctuation touching it: a full stop can open the next line, and the '(' before a reference can end the line above. Justified text never breaks there. Read the boxes of every edition and reword any sentence where it happens, so the run sits mid-line. Bold, italic and their colours →
Pitfall
Any headings object switches off the H1 page break
By default an H1 breaks to a recto (always-odd), but passing any headings object resets that default, so chapters run on and span: 'page' does nothing. Restate headings.levels[0].breakBefore: { enabled: true, parity } in every config. Chapters that open on a recto →
Pitfall
Load every face before layout
Layout measures text with the faces the browser has loaded and caches the widths, so a face that arrives after the first build leaves wrong line breaks and a PDF that no longer matches the screen. Load every weight and style first, and call clearMeasurementCache() before rebuilding when one arrives late. Fonts before layout →
- Give
renderToPdfthe original drawing. In 1.4.1 it recolours whatever SVG it receives, so a copy that is already recoloured goes through the formula again and the N12 line drops from 84% to 44%. - In 1.4.1
applySingleInkToSvgrewrites hex colours,rgb()andrgba()with whole-number channels, and the keywordswhiteandblack. A shape that namesred, useshsl()or leaves its fill at the default black keeps that colour, which would need a third drum. Give every fill and stroke a hex value. colorSpace: 'grayscale'greys what postext paints and embeds bitmaps as they are, so the PNG inset stays in colour in the grey proof. Only the pen's second button builds the proof; the pages above are the canvas, and the PDF offered on this page is the colour one.- With paragraph spacing on, 1.4.1 can add a line of space before the rest of a paragraph that continues on the next page under a top float. The text here does not trigger it. After you edit the copy, check the first line under the map, since one extra line can push the inset to a fifth page.
- In the pen both buttons read "Build the PDF". The first builds the colour PDF and the second the grey proof; once a file is built, its button gives way to an "Open the PDF ↗" link and a download link with the file's name.
Credits
- Recipe
- Ignacio Ferro
- Text
- Original prose, CC BY 4.0
- Fonts
- Epilogue (SIL OFL 1.1) · Anton (SIL OFL 1.1) · Space Mono (SIL OFL 1.1)


