Kurz gesagt
Ein kleines gefaltetes Heft über Nachtbusse, gemacht für eine Druckmaschine, die nur zwei Farben druckt. Farbige Zeichnungen werden zu Abstufungen einer einzigen pinken Farbe.
Was Sie setzen
Night Buses ist ein kostenloses Zine über die Nachtbusse einer Stadt: vier Seiten im Format 120 × 160 mm auf einem einmal gefalzten Bogen, für einen Risographen mit zwei Trommeln, Blau und fluoreszierendes Pink. Jede Farbe der Konfiguration ist eine der beiden Druckfarben, ein 25-%-Raster des Pinks oder das cremefarbene Papier. Die Zeichnungen sind vollfarbig angelegt (eine Linienkarte in sechs Farben, ein Mond in Grautönen, ein bernsteinfarbenes Zielschild auf Schwarz) und drucken von der pinken Trommel als Pinktöne, dunklere Farben als dichtere Töne. Die Karte druckt in Pink, und ein kleiner Einschub weiter unten auf ihrer Seite behält die sechs Farben der Vorlage. Der Titel auf dem Umschlag ist zweimal gesetzt, die pinke Kopie 0,8 mm rechts von der blauen und 0,5 mm höher. Der Pen erzeugt außerdem ein farbiges PDF und einen Graustufen-Proof.
Dieses Rezept beantwortet
- Wie färbe ich jedes Diagramm in eine einzige Schmuckfarbe um?
Die kurze Antwort
// 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 };
Zutaten
- Funktionen
- Einfarbige DiagrammeSemantische FarbpalettePDFs in CMYK und GraustufenPDF-ExportSeiten auf einem CanvasInline-ChipsFormat der BildunterschriftKästenÜberschriftenformateÜberschriftenattributeGestaltete AuftakteText, Linien und Kästen in SeitengestaltungenBilder in SeitengestaltungenUmschläge, Titelseiten und ImpressumAbbildungen und Tabellen als RessourcenEigene RessourcentypenPlatzierung von AbbildungenPapierfarbe
- Nutzt außerdem
- Verweise, die Abbildungen platzierenAbsatzformateIm PDF eingebettete SchriftenKolumnentitel je Abschnitt
- Schrift
- Epilogue, Anton, Space Mono (SIL OFL 1.1)
- Dateien
- Keine: Jedes Bild wird im Code gezeichnet
Zubereitung
#1 · Zwei Trommeln, und jede Farbe ist eine davon
// 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' } }));
Ein Risograph druckt jede Farbe von einer eigenen Trommel, eine dritte Farbe irgendwo in der Konfiguration bräuchte also eine dritte Trommel. screen() mischt einen Anteil einer Druckfarbe mit der Papierfarbe, so wie ein Raster nur einen Teil der Punkte druckt und dazwischen Papier stehen lässt. Ein 25-%-Raster des Pinks ist #f2c7d3, der Grund der Chips und der Bildunterschrift. main-color zeigt auf das Pink, sodass jede Voreinstellung, die die Konfiguration nicht ändert, von dieser Trommel druckt und nicht im eigenen Blau der Engine, #295AA3.
#2 · Jede Zeichnung einmal pro Ausgabe umfärben
Der Code ist die kurze Antwort oben. Mit diagramStyle.singleInk macht renderToPdf aus jeder Farbe eines SVG eine Tönung von inkColor, deren Stärke 1 minus die relative Leuchtdichte der Farbe ist (Diagrammstil); so druckt die marineblaue N12 mit 84 % und die gelbe N50 mit 24 %. Der Canvas tönt ein Bild auf dieselbe Weise, wenn es mit singleInk: true registriert ist; deshalb registriert loadSvg(fileId, svg, { singleInk: true }) aus dem Kit jede Zeichnung so, wie sie gezeichnet ist, mit diesem Flag und behält dasselbe Markup, das imageBytes an resourceBytes übergibt. Im Canvas und im mit 300 dpi gerasterten PDF gemessen, hat die Linie der N12 den Wert (242, 109, 171), die Tönung, die die Formel für eine Stärke von 0,836 ergibt. Jede Ausgabe färbt die Zeichnung einmal um: Zweimal durch die Formel geschickt, würde die Linie auf 44 % aufhellen.
#3 · Den Titel zweimal drucken, versetzt
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) }) }),
] } } };
Ein Risograph druckt eine Trommel nach der anderen, und der Bogen landet nie zweimal genau an derselben Stelle. twice() gibt zwei Kopien eines Textelements zurück, die pinke zuerst, 0,8 mm nach rechts und 0,5 mm nach oben verschoben. Die Reihenfolge im Array ist die Zeichenreihenfolge, also deckt die blaue Kopie die pinke bis auf einen schmalen Saum an der Ober- und der rechten Kante jedes Buchstabens. Canvas und PDF zeichnen deckende Farbe, deshalb bleibt die Überlappung blau, wo ein Riso Violett drucken würde. Die Gestaltung reicht über die ganze Seitenbreite, weil der Mast über den Fuß des Satzspiegels hinausläuft, wo eine in ihrer Spalte gehaltene Gestaltung abgeschnitten wird.
#4 · Kleine Schrift in der dunklen Farbe halten
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) } };
Auf dem cremefarbenen Papier erreicht das Blau 6,4:1 und das fluoreszierende Pink 2,9:1, zu schwach für Schrift in 8 oder 9 pt. Jedes Wort, das die Engine setzt, druckt deshalb von der blauen Trommel. Die Chips sind blaue Space Mono auf dem 25-%-Raster (4,85:1) in einer pinken Kontur, die Bildunterschrift blaue Epilogue auf demselben Raster. Das Pink druckt den Streifen über dem Zitat, die Konturen der Chips, die Aufzählungszeichen, die Quadrate an den Seitenzahlen und die versetzten Kopien der Titel. Die Schrift in den Zeichnungen gehört zur Grafik und druckt mit ihr in Pink. Der Name des Sprechers ist der Titel des Kastens, in fetten Versalien der Space Mono in 7,5 pt über dem Zitat gesetzt.
#5 · Den Einschub neben seine Notiz setzen
// 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 einer Spalte nimmt ein Gleitobjekt die ganze Breite seines Bandes ein, ein kleines Bild würde dort also den Rest des Bandes leer lassen. Der Einschub ist deshalb eine Überschriftengestaltung: ### As drawn {style="drawn" note="…"} druckt das Bild und rechts daneben die Überschrift als Label mit der Notiz darunter. Das Bild zählt zu der Höhe, die die Gestaltung reserviert, sodass der nächste Block auch bei kürzerer Notiz darunter beginnt. registerSnapshot() zeichnet dieselbe Karte network() in ein PNG, und weil die Einfarbigkeit nur SVG umfärbt, behält der Einschub am Bildschirm und im PDF seine sechs Farben.
Das ganze Rezept
// ═══ 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`---Markdown-Beispiel · 38 Zeilen · 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: in jedem Rezept gleich · 315 Zeilen
// ─── 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 ───────────────────────────────────────────────────────────────────────
Das zusammengesetzte script.js läuft unverändert: Fügen Sie es in das Modulskript einer beliebigen Seite ein, oder öffnen Sie das Rezept auf CodePen. Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)
Varianten
#Eine orange Trommel einlegen
Mond, Karte, Zielschild, Chips, Streifen und die versetzten Kopien beider Titel werden orange, die Schrift bleibt blau; Bildunterschrift, Notiz und Impressum nennen weiter die pinke Trommel, bis Sie sie bearbeiten.
-const DRUMS = { ink: '#1d4fb8', spot: '#f0509a' };
+const DRUMS = { ink: '#1d4fb8', spot: '#ff6c2f' };#Ein zweifarbiges Lehrbuch drucken
Mit schwarzer Schrift und einer blauen Sonderfarbe auf weißem Papier drucken dieselben Zeichnungen als Blautöne, wie die Diagramme in einem zweifarbigen Schulbuch; Bildunterschrift, Notiz und Impressum nennen weiterhin die pinke Trommel, bis Sie sie bearbeiten.
-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 fallsHäufige Fehler
Häufiger Fehler
Ein 'top'-Gleitobjekt kommt auf die Seite nach seinem Verweis, außer es darf die verweisende Seite eröffnen
Standardmäßig steht ein Gleitobjekt nie über seinem eigenen Verweis, sodass ein seitenbreites 'top'-Gleitobjekt, auf das auf Seite N verwiesen wird, Seite N+1 eröffnet. Setzen Sie placement.citingPage: true an der Ressource oder am defaultPlacement ihres Typs (oder layout.floatsAtCitingPage für das ganze Buch, seit postext 1.25), dann steht es stattdessen oben auf Seite N, und der Text über dem Verweis rückt darunter, sofern es in layout.maxTopFraction passt und die verweisende Zeile auf dieser Seite bleibt. Es eröffnet nie die erste Seite eines Kapitels oder eine Seite nach einem erzwungenen Umbruch: dort eröffnet es weiterhin die nächste Seite, und 'auto' oder 'bottom' können den Fuß der verweisenden Seite einnehmen. Platzierung von Abbildungen →
- Im PDF schreibt die Umfärbung das Markup um: Hex-Farben,
rgb(),rgba(),hsl()undhsla()sowie die Schlüsselwörterwhiteundblack. Eine Form, dierednennt oder ihre Füllung beim voreingestellten Schwarz belässt, behält dort diese Farbe, die eine dritte Trommel bräuchte. Geben Sie jeder Füllung und jeder Kontur einen Hex-Wert. colorSpace: 'grayscale'setzt in Grau, was postext zeichnet, und bettet Bitmaps unverändert ein, deshalb bleibt der PNG-Einschub im Graustufen-Proof farbig. Nur der zweite Button des Pens erzeugt den Proof; die Seiten oben stammen aus dem Canvas, und das auf dieser Seite angebotene PDF ist das farbige.- Der Spaltenausgleich kann unter einem Gleitobjekt oben eine Zeile Leerraum lassen, vor dem Rest eines Absatzes, der von der vorigen Seite weiterläuft;
headings.balancing.stretchAfterFloats: falsehält den Text direkt unter dem Gleitobjekt. Der Text hier löst das nicht aus. Prüfen Sie nach Änderungen am Text die erste Zeile unter der Karte, denn eine zusätzliche Zeile kann den Einschub auf eine fünfte Seite schieben. - Im Pen heißen beide Buttons „Build the PDF“. Der erste erzeugt das farbige PDF, der zweite den Graustufen-Proof; sobald eine Datei erzeugt ist, weicht ihr Button einem Link „Open the PDF ↗“ und einem Download-Link mit dem Namen der Datei.
Nachweise
- Rezept
- Ignacio Ferro
- Text
- Eigener Text, CC BY 4.0
- Schriften
- Epilogue (SIL OFL 1.1) · Anton (SIL OFL 1.1) · Space Mono (SIL OFL 1.1)
- Code
- MIT, wie Postext
Diesen Text bearbeiten ↗ (öffnet in neuem Tab)Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)


