Saltar al contenido principal
Receta número 51

Recetario · Capítulo 7 · Figuras e imágenes

Fanzine riso a dos tintas: ilustración a una tinta

Fanzine para risógrafo de dos tambores: los dibujos a todo color salen en tonos de rosa, recoloreados en el canvas por el código y en el PDF por renderToPdf.

En esta página

pp. 2–3 de 4

  • Muestra en inglés: aún no hay edición en español
  • Formato 120 × 160 mm
  • 1 columna
  • Epilogue 9/13
  • Anton
  • Space Mono
  • 4 páginas
  • Nivel
  • Postext 1.4.1
  • Compuesto en 6 ms
  • 212 líneas de código

Lo que vas a componer

Night Buses es un fanzine gratuito, en inglés, sobre los autobuses nocturnos de una ciudad: cuatro páginas de 120 × 160 mm en una hoja doblada, para un risógrafo de dos tambores, azul y rosa fluorescente. Cada color de la configuración es una de las dos tintas, una trama del 25 % del rosa o el papel crema. Los dibujos, a todo color (un mapa en seis colores, una luna en grises, un rótulo de destino ámbar sobre negro), salen del tambor rosa en tonos más densos cuanto más oscuro era el color. El mapa se imprime en rosa, y más abajo, en la misma página, una miniatura conserva los colores originales. El título de la portada se compone dos veces, con la copia rosa 0,8 mm a la derecha de la azul y 0,5 mm más arriba. El código exporta además el PDF y una prueba en grises.

Esta receta responde a

  • ¿Cómo paso todos los diagramas a una sola tinta plana?

La respuesta corta

script.js · líneas 34–44en el código completo
// 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

Ingredientes

Tipografía
Epilogue, Anton, Space Mono (SIL OFL 1.1)
Recursos
Ninguno: todas las imágenes se dibujan en código

Elaboración

#1 · Todos los colores, de los dos tambores

script.js · líneas 18–30en el código completo
// The two drums: the blue for the type and the furniture, the fluorescent pink for the art.
const DRUMS = { ink: '#1d4fb8', spot: '#f0509a' };
const PAPER = '#f3efe6'; // cream stock: where no ink falls
// A screen prints a share of an ink's dots and lets the paper show between them.
const screen = (hex, share) => `#${[1, 3, 5].map((i) => Math.round(share
  * parseInt(hex.slice(i, i + 2), 16) + (1 - share) * parseInt(PAPER.slice(i, i + 2), 16))
  .toString(16).padStart(2, '0')).join('')}`;
const palette = { ...DRUMS, 'spot-25': screen(DRUMS.spot, 0.25), paper: PAPER };
// The hex rides with the id: design elements read the hex (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to main-color: aimed at the spot, none of them adds a third ink.
const colorPalette = [...Object.entries(palette), ['main-color', DRUMS.spot]]
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));

Un risógrafo imprime cada tinta con su propio tambor, así que un tercer color en cualquier parte de la configuración obligaría a montar un tercer tambor. screen() mezcla una parte de la tinta con el color del papel, igual que una trama imprime solo una parte de los puntos y deja ver el papel entre ellos. La trama del 25 % del rosa es #f2c7d3, el fondo de los chips y del pie. main-color apunta al rosa, de modo que cualquier valor por defecto que la configuración no toque se imprime con ese tambor y no con el azul que el motor trae de serie (#295AA3). col() escribe cada hex junto a su id porque la 1.4.1 aplica la paleta a los estilos de texto, pero no a los elementos de diseño.

#2 · Recolorear cada dibujo una vez por salida

El código es la respuesta corta de arriba. Con diagramStyle.singleInk, renderToPdf convierte cada color de un SVG en un tono de inkColor cuya intensidad es 1 menos la luminancia relativa del color (estilo de diagramas). El N12 azul marino sale al 84 % y el N50 amarillo, al 24 %. En la 1.4.1 el canvas pinta el SVG registrado tal cual, así que registerArt() registra una copia recoloreada con applySingleInkToSvg y guarda el dibujo original para resourceBytes. Tanto en el canvas como en el PDF rasterizado a 300 ppp, la línea del N12 tiene el color (242, 109, 171), el tono que sale de la fórmula con una intensidad de 0,836. Una segunda pasada la aclararía hasta el 44 %, y por eso el PDF nunca recibe la copia recoloreada.

#3 · Imprimir el título dos veces, fuera de registro

script.js · líneas 58–91en el código completo
const OFF = { x: 0.8, y: -0.5 }; // mm: where the pink drum lands against the blue one
// Array order is paint order: the pink copy goes down first and the blue covers all but a
// sliver. Canvas and PDF paint opaque colour, so the overlap stays blue, not riso purple.
const twice = (id, { placement: { offset: { x, y }, ...rest }, ...element }) => [
  { ...element, id: `${id}-spot`, color: col('spot'),
    placement: { ...rest, offset: { x: mm(x.value + OFF.x), y: mm(y.value + OFF.y) } } },
  { ...element, id, color: col('ink'), placement: { ...rest, offset: { x, y } } }];
const rule = (id, direction, x, y, size) => ({ kind: 'rule', id, direction, color: col('ink'),
  thickness: pt(id === 'pole' ? 3 : 0.8), placement: at('page', 'top-left', x, y, size) });
// span: 'page' lets the design paint outside the text block: kept in the column, the
// issue line above it and the pole below it are cut off at the column's edges.
const cover = { id: 'cover', span: 'page', header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'moon', resourceId: 'moon',
      placement: at('page', 'top-left', 28, 14, { width: mm(92), height: mm(92) }) },
    rule('wire-1', 'horizontal', 0, 30, { width: mm(TRIM.width) }),
    rule('wire-2', 'horizontal', 0, 34.5, { width: mm(TRIM.width) }),
    rule('pole', 'vertical', 98, 62, { height: mm(TRIM.height - 62) }), // off the foot
    { kind: 'box', id: 'flag', style: { backgroundColor: col('ink'), borderRadius: mm(1) },
      placement: at('page', 'top-left', 89, 62, { width: mm(18), height: mm(21) }) },
    // One word a line: the box is narrower than two of them (gotcha: design-text-newline).
    { kind: 'text', id: 'stops', content: '{attr.stops}', ...mono, fontSize: pt(9),
      lineHeight: 1.25, align: 'center', color: col('paper'), overflow: 'wrap',
      placement: at('#flag', 'top-left', 3, 2.5, { width: mm(12) }) },
    { kind: 'text', id: 'issue', content: '{attr.issue}', ...mono,
      placement: at('page', 'top-left', MARGIN.inner, 8) },
    { kind: 'text', id: 'line', content: '{attr.line}', fontFamily: 'Epilogue', fontWeight: 700,
      fontSize: pt(9), color: col('ink'), align: 'left', overflow: 'wrap',
      placement: at('page', 'top-left', MARGIN.inner, 13, { width: mm(46) }) },
    ...twice('title', { kind: 'text', content: '{titleText}', fontFamily: 'Anton', fontSize: pt(86),
      lineHeight: 0.9, // a multiple (gotcha: design-lineheight-multiple)
      textTransform: 'uppercase', align: 'left',
      overflow: 'wrap', placement: at('page', 'top-left', MARGIN.inner, 94, { width: mm(76) }) }),
  ] } } };

Un risógrafo imprime un tambor detrás de otro, y el papel nunca entra dos veces exactamente en el mismo sitio. twice() devuelve dos copias de un elemento de texto: primero la rosa, 0,8 mm a la derecha y 0,5 mm más arriba, y después la azul. Como el orden del array es el orden de pintado, la azul tapa la rosa salvo una franja fina en los bordes superior y derecho de cada letra. El canvas y el PDF pintan colores opacos, así que donde se solapan queda azul; en un risógrafo saldría morado. El diseño ocupa toda la página porque la línea del número queda por encima de la caja de texto y el poste baja más allá de su pie; si el diseño se queda dentro de la columna, la 1.4.1 corta los dos por los bordes de esta.

#4 · El texto pequeño, en la tinta oscura

script.js · líneas 95–112en el código completo
const opener = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...mono,
    placement: at('container', 'top-left', 0, 0.5) },
  ...twice('title', { kind: 'text', content: '{titleText}', fontFamily: 'Anton',
    fontSize: pt(34), lineHeight: 0.95, textTransform: 'uppercase', align: 'left',
    overflow: 'wrap', placement: at('#kicker', 'below', 0, 2.5, { width: mm(MEASURE) }) }),
] } };
const route = { id: 'route', background: col('spot-25'), borderColor: col('spot'),
  borderWidth: pt(0.75), borderRadius: pt(1.2), fontFamily: 'Space Mono', bold: true,
  fontSize: em(0.86), color: col('ink') };
const captionStyle = { fontFamily: 'Epilogue', fontSize: pt(8), color: col('ink'),
  backgroundEnabled: true, background: col('spot-25'), padding: mm(1.6) };
const quote = { id: 'quote', backgroundEnabled: false, marginTop: pt(LEAD), marginBottom: pt(4),
  stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('spot') },
  padding: { top: mm(2.6), right: pt(0), bottom: pt(0), left: pt(0) },
  titleStyle: { ...label, gap: mm(1.2) }, // the speaker, above the words
  body: { fontFamily: 'Anton', fontSize: pt(14), lineHeight: pt(17), color: col('ink'),
    textAlign: 'left', firstLineIndent: pt(0) } };

Sobre el papel crema, el azul da un contraste de 6,4:1 y el rosa fluorescente, de 2,9:1, poco para letra de 8 o 9 pt. Por eso todas las palabras que compone el motor salen del tambor azul. Los chips llevan Space Mono azul sobre la trama del 25 % (4,85:1), con un contorno rosa, y el pie va en Epilogue azul sobre la misma trama. Del tambor rosa salen el filete sobre la cita, el contorno de los chips, las viñetas, los cuadrados de los folios y las copias desplazadas de los títulos. Las letras de los dibujos forman parte de la ilustración y salen en rosa con ella. El nombre de quien habla es el título del recuadro, en mayúsculas de Space Mono negrita de 7,5 pt, encima de la cita.

#5 · La miniatura junto a su nota

script.js · líneas 116–141en el código completo
// The inset keeps the map's own colours. Single ink touches SVG only, so the map goes in
// as a PNG snapshot, which neither the canvas nor the PDF recolours.
async function registerSnapshot(fileId, svg, width, height) {
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  const canvas = new OffscreenCanvas(width, height);
  canvas.getContext('2d').drawImage(img, 0, 0, width, height);
  registerResourceImage(fileId, await createImageBitmap(canvas));
  printFiles.set(fileId, new Uint8Array(await (await canvas.convertToBlob()).arrayBuffer()));
}
const INSET = 30; // mm: the snapshot's width; its height follows the map's 100 × 69.5
const drawn = { id: 'drawn', marginTop: pt(LEAD), advancedDesign: { enabled: true,
    // A floor at the picture's height, which a design's images do not reserve (gotcha:
    // opener-image-no-reserve). The note is about as tall today; with a shorter note the
    // next block would run over the picture.
    minHeight: mm(INSET * 0.695 + 1),
    slot: { elements: [
      { kind: 'image', id: 'inset', resourceId: 'as-drawn',
        placement: at('container', 'top-left', 0, 0.5, { width: mm(INSET), height: 'auto' }) },
      { kind: 'text', id: 'label', content: '{titleText}', ...mono,
        placement: at('#inset', 'right-of', 5, 0) },
      { kind: 'text', id: 'note', content: '{attr.note}', fontFamily: 'Epilogue',
        fontSize: pt(8.5), lineHeight: 1.4, color: col('ink'), align: 'left', overflow: 'wrap',
        placement: at('#label', 'below', 0, 1.5, { width: mm(MEASURE - INSET - 5) }) },
    ] } } };

En una sola columna, un flotante ocupa todo el ancho de su franja, así que una imagen pequeña flotada dejaría vacío el resto. Por eso la miniatura es un diseño de título: ### As drawn {style="drawn" note="…"} pinta la imagen y, a su derecha, el título como rótulo con la nota debajo. Como las imágenes de un diseño no reservan altura, minHeight fija un mínimo igual a la altura de la imagen. Hoy la nota mide más o menos lo mismo; si fuera más corta, el bloque siguiente pisaría la imagen. registerSnapshot() pasa el mismo mapa de network() a un PNG y, como la tinta única solo recolorea SVG, la miniatura conserva sus seis colores en pantalla y en el PDF.

La receta completa

// ═══ Postext Cookbook · Nº 051 · Two-ink riso zine: art in one spot colour ═════════
// https://postext.dev/en/cookbook/riso-zine-single-ink
// Code: MIT · Text: original (CC BY 4.0) · Pictures: drawn in code (CC BY 4.0)
// Fonts: Epilogue, Anton, Space Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// A four-page zine for a risograph with a blue drum and a fluorescent pink one. The drawings
// are made in full colour and printed from the pink drum as tints of pink.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  applySingleInkToSvg,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'riso-zine-single-ink';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region inks: two drums and the paper; every colour in the config links to one of them
// The two drums: the blue for the type and the furniture, the fluorescent pink for the art.
const DRUMS = { ink: '#1d4fb8', spot: '#f0509a' };
const PAPER = '#f3efe6'; // cream stock: where no ink falls
// A screen prints a share of an ink's dots and lets the paper show between them.
const screen = (hex, share) => `#${[1, 3, 5].map((i) => Math.round(share
  * parseInt(hex.slice(i, i + 2), 16) + (1 - share) * parseInt(PAPER.slice(i, i + 2), 16))
  .toString(16).padStart(2, '0')).join('')}`;
const palette = { ...DRUMS, 'spot-25': screen(DRUMS.spot, 0.25), paper: PAPER };
// The hex rides with the id: design elements read the hex (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to main-color: aimed at the spot, none of them adds a third ink.
const colorPalette = [...Object.entries(palette), ['main-color', DRUMS.spot]]
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion

// #region answer: drawings in any colours, printed from the pink drum
// renderToPdf reads the ink from the config and recolours every SVG it is handed; the canvas
// paints an SVG as registered (gotcha: single-ink-canvas). So the screen gets a recoloured
// copy and the PDF the drawing as drawn: one pass each, as a second pass lightens it again.
const diagramStyle = { singleInk: true, inkColor: col('spot') };
const printFiles = new Map(); // fileId → the bytes renderToPdf embeds
async function registerArt(fileId, svg) {
  await loadSvg(fileId, applySingleInkToSvg(svg, diagramStyle.inkColor.hex)); // the canvas
  printFiles.set(fileId, new TextEncoder().encode(svg)); // the PDF, recoloured there
}
const pdfOptions = { fontProvider: fontsourceProvider,
  resourceBytes: (id) => printFiles.get(id) }; // the drawings as drawn, the PNG as it is
// #endregion

const TRIM = { width: 120, height: 160 }; // mm: a 240 × 160 sheet folded once
const MARGIN = { top: 13, bottom: 16, inner: 12, outer: 10 }; // mm
const MEASURE = TRIM.width - MARGIN.inner - MARGIN.outer; // 98 mm, about 65 characters
const LEAD = 13; // body leading in pt
const label = { fontFamily: 'Space Mono', fontWeight: 700, fontSize: pt(7.5),
  textTransform: 'uppercase', color: col('ink') };
const mono = { ...label, align: 'left', overflow: 'clip' }; // a label as a design element
const at = (to, edge, x = 0, y = 0, size) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });

// #region cover: a pink moon behind blue wires, and the title printed twice off register
const OFF = { x: 0.8, y: -0.5 }; // mm: where the pink drum lands against the blue one
// Array order is paint order: the pink copy goes down first and the blue covers all but a
// sliver. Canvas and PDF paint opaque colour, so the overlap stays blue, not riso purple.
const twice = (id, { placement: { offset: { x, y }, ...rest }, ...element }) => [
  { ...element, id: `${id}-spot`, color: col('spot'),
    placement: { ...rest, offset: { x: mm(x.value + OFF.x), y: mm(y.value + OFF.y) } } },
  { ...element, id, color: col('ink'), placement: { ...rest, offset: { x, y } } }];
const rule = (id, direction, x, y, size) => ({ kind: 'rule', id, direction, color: col('ink'),
  thickness: pt(id === 'pole' ? 3 : 0.8), placement: at('page', 'top-left', x, y, size) });
// span: 'page' lets the design paint outside the text block: kept in the column, the
// issue line above it and the pole below it are cut off at the column's edges.
const cover = { id: 'cover', span: 'page', header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'moon', resourceId: 'moon',
      placement: at('page', 'top-left', 28, 14, { width: mm(92), height: mm(92) }) },
    rule('wire-1', 'horizontal', 0, 30, { width: mm(TRIM.width) }),
    rule('wire-2', 'horizontal', 0, 34.5, { width: mm(TRIM.width) }),
    rule('pole', 'vertical', 98, 62, { height: mm(TRIM.height - 62) }), // off the foot
    { kind: 'box', id: 'flag', style: { backgroundColor: col('ink'), borderRadius: mm(1) },
      placement: at('page', 'top-left', 89, 62, { width: mm(18), height: mm(21) }) },
    // One word a line: the box is narrower than two of them (gotcha: design-text-newline).
    { kind: 'text', id: 'stops', content: '{attr.stops}', ...mono, fontSize: pt(9),
      lineHeight: 1.25, align: 'center', color: col('paper'), overflow: 'wrap',
      placement: at('#flag', 'top-left', 3, 2.5, { width: mm(12) }) },
    { kind: 'text', id: 'issue', content: '{attr.issue}', ...mono,
      placement: at('page', 'top-left', MARGIN.inner, 8) },
    { kind: 'text', id: 'line', content: '{attr.line}', fontFamily: 'Epilogue', fontWeight: 700,
      fontSize: pt(9), color: col('ink'), align: 'left', overflow: 'wrap',
      placement: at('page', 'top-left', MARGIN.inner, 13, { width: mm(46) }) },
    ...twice('title', { kind: 'text', content: '{titleText}', fontFamily: 'Anton', fontSize: pt(86),
      lineHeight: 0.9, // a multiple (gotcha: design-lineheight-multiple)
      textTransform: 'uppercase', align: 'left',
      overflow: 'wrap', placement: at('page', 'top-left', MARGIN.inner, 94, { width: mm(76) }) }),
  ] } } };
// #endregion

// #region inside: the article opener, route chips, the caption bar and the quote's stripe
const opener = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...mono,
    placement: at('container', 'top-left', 0, 0.5) },
  ...twice('title', { kind: 'text', content: '{titleText}', fontFamily: 'Anton',
    fontSize: pt(34), lineHeight: 0.95, textTransform: 'uppercase', align: 'left',
    overflow: 'wrap', placement: at('#kicker', 'below', 0, 2.5, { width: mm(MEASURE) }) }),
] } };
const route = { id: 'route', background: col('spot-25'), borderColor: col('spot'),
  borderWidth: pt(0.75), borderRadius: pt(1.2), fontFamily: 'Space Mono', bold: true,
  fontSize: em(0.86), color: col('ink') };
const captionStyle = { fontFamily: 'Epilogue', fontSize: pt(8), color: col('ink'),
  backgroundEnabled: true, background: col('spot-25'), padding: mm(1.6) };
const quote = { id: 'quote', backgroundEnabled: false, marginTop: pt(LEAD), marginBottom: pt(4),
  stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('spot') },
  padding: { top: mm(2.6), right: pt(0), bottom: pt(0), left: pt(0) },
  titleStyle: { ...label, gap: mm(1.2) }, // the speaker, above the words
  body: { fontFamily: 'Anton', fontSize: pt(14), lineHeight: pt(17), color: col('ink'),
    textAlign: 'left', firstLineIndent: pt(0) } };
// #endregion

// #region drawn: the inset beside its note, as a heading design (the heading is its label)
// The inset keeps the map's own colours. Single ink touches SVG only, so the map goes in
// as a PNG snapshot, which neither the canvas nor the PDF recolours.
async function registerSnapshot(fileId, svg, width, height) {
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  const canvas = new OffscreenCanvas(width, height);
  canvas.getContext('2d').drawImage(img, 0, 0, width, height);
  registerResourceImage(fileId, await createImageBitmap(canvas));
  printFiles.set(fileId, new Uint8Array(await (await canvas.convertToBlob()).arrayBuffer()));
}
const INSET = 30; // mm: the snapshot's width; its height follows the map's 100 × 69.5
const drawn = { id: 'drawn', marginTop: pt(LEAD), advancedDesign: { enabled: true,
    // A floor at the picture's height, which a design's images do not reserve (gotcha:
    // opener-image-no-reserve). The note is about as tall today; with a shorter note the
    // next block would run over the picture.
    minHeight: mm(INSET * 0.695 + 1),
    slot: { elements: [
      { kind: 'image', id: 'inset', resourceId: 'as-drawn',
        placement: at('container', 'top-left', 0, 0.5, { width: mm(INSET), height: 'auto' }) },
      { kind: 'text', id: 'label', content: '{titleText}', ...mono,
        placement: at('#inset', 'right-of', 5, 0) },
      { kind: 'text', id: 'note', content: '{attr.note}', fontFamily: 'Epilogue',
        fontSize: pt(8.5), lineHeight: 1.4, color: col('ink'), align: 'left', overflow: 'wrap',
        placement: at('#label', 'below', 0, 1.5, { width: mm(MEASURE - INSET - 5) }) },
    ] } } };
// #endregion

// Folios at the foot, outside: a pink square on the baseline, then the folio and the zine's
// name on a verso, the article and the folio on a recto.
const FOLIO_Y = TRIM.height - 10; // mm from the top edge to the folio's box
const feet = [['even', 'left', 1, '{pageNumber} · {title} · {subtitle}'],
  ['odd', 'right', -1, '{chapterTitle} · {pageNumber}']].flatMap(([parity, edge, s, content]) => [
  { kind: 'box', id: `mark-${parity}`, parity, style: { backgroundColor: col('spot') },
    placement: at('page', `top-${edge}`, s * MARGIN.outer, FOLIO_Y + 0.7, { width: mm(1.85),
      height: mm(1.85) }) }, // the label's cap height, standing on its baseline
  { kind: 'text', id: `folio-${parity}`, parity, content, ...mono, align: edge,
    placement: at('page', `top-${edge}`, s * (MARGIN.outer + 3.5), FOLIO_Y) }]);

// The back cover: a destination blind across the top and the cover's moon going down.
const back = { id: 'back', span: 'page', // the setting moon runs past the text block
  breakBefore: { enabled: true, parity: 'even' },
  header: { elements: [] }, footer: { elements: [] },
  // The blind is MEASURE / 4 tall; its room is set by hand (gotcha: opener-image-no-reserve).
  advancedDesign: { enabled: true, minHeight: mm(MEASURE / 4 + 6), slot: { elements: [
    { kind: 'image', id: 'blind', resourceId: 'blind',
      placement: at('container', 'top-left', 0, 0, { width: mm(MEASURE), height: 'auto' }) },
    { kind: 'image', id: 'moonset', resourceId: 'moon',
      placement: at('page', 'bottom-right', 30, 34, { width: mm(76), height: mm(76) }) },
  ] } } };

const config = () => ({ // a factory: the engine caches resolved configs per object
  colorPalette, diagramStyle, resourceTypes: [drawing],
  page: { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
    backgroundColor: col('paper'), margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom),
      left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true } },
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: 'Epilogue', fontSize: pt(9), lineHeight: pt(LEAD), color: col('ink'),
    // Bold and italics default to main-color, the pink here: back to the blue drum. The
    // reference colour follows boldColor.
    boldColor: col('ink'), italicColor: col('ink'),
    textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true },
  // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
  headings: { fontFamily: 'Anton', fontWeight: 400, color: col('ink'), levels: [
    { level: 1, breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener },
    { level: 2, fontSize: pt(15), marginTop: pt(LEAD), marginBottom: pt(2) }] },
  headingStyles: [cover, drawn, back],
  chipStyles: [route], captionStyle, calloutStyles: [quote],
  unorderedLists: { color: col('spot'), marginTop: pt(2), marginBottom: pt(0) },
  paragraphStyles: [{ id: 'colophon', fontFamily: 'Space Mono', fontSize: pt(7.5),
    lineHeight: pt(11), color: col('ink'), textAlign: 'left', marginTop: pt(LEAD) }],
  header: { elements: [] }, footer: { elements: feet },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
// One unnumbered type for every picture: an empty prefix and template print no number.
const drawing = { id: 'drawing', name: 'Drawing', shortLabel: 'drawing', captionPrefix: '',
  numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' };
const svgFile = (id, width, height, extra) => ({ id, typeId: 'drawing', kind: 'svg',
  svg: { fileId: `${id}.svg`, width, height }, createdAt: 0, updatedAt: 0, ...extra });
const resources = [
  // Cited on page 2, the map heads page 3: a top float waits for the next page's head
  // (gotcha: top-float-next-page).
  svgFile('network', 1000, 695, { placement: { position: 'top', width: 0.74, align: 'center' },
    caption: '**The network from the pink drum.** :chip[N50]{style="route"} loops round the '
      + 'rest. Terminals and changes only.',
    altText: 'Six night bus routes and a loop round the Corn Exchange, in tints of pink.' }),
  { id: 'as-drawn', typeId: 'drawing', kind: 'bitmap', createdAt: 0, updatedAt: 0,
    bitmap: { fileId: 'as-drawn.png', format: 'png', width: 600, height: 417 },
    altText: 'The same map in its drawn colours: navy, red, teal, blue, orange, yellow.' },
  svgFile('moon', 960, 960,
    { altText: 'A full moon: a pale pink disc with its seas in a coarse dot screen.' }),
  svgFile('blind', 1040, 260, { altText: 'A destination blind lit up: NOT IN SERVICE.' }),
];

const markdown = String.raw`---
Muestra en Markdown · 38 líneas · content.en.mdtitle: "Night Buses" subtitle: "No. 4 · Winter" author: "Ros Adeyemi, Tom Hale, Mei Lindqvist" --- # Night Buses {style="cover" issue="No. 4 · Winter · Free" stops="Night N12 N27 N41" line="Six routes after midnight, three ridden end to end"} # After the Last Train {kicker="Brackwater, 00:10 to 06:00"} The last train leaves Station Square at 23:52. Until the first one at 05:40, about four thousand people a night ride the night buses: nurses, cleaners, bakers, kitchen staff, students, and anyone who missed the train. Five routes leave the Corn Exchange together at ten past and twenty to the hour, so a change takes three minutes, and :chip[N50]{style="route"} loops round them. The :ref{id="network" text="map opposite"} shows all six. We rode three from end to end. :chip[N12]{style="route"} **Ashgrove Hospital to Harbour Gate.** Every quarter of an hour, and 34 minutes from one end to the other. The first after midnight takes the evening shift home past the fish market. The 05:25 from the harbour brings the day shift in and empties at Ashgrove in under a minute. :::callout{type="quote" title="Dee Okafor, driver on the N27 since 2011"} At ten past three there are five buses and forty people outside the Corn Exchange. By quarter past the square is empty. ::: :chip[N27]{style="route"} **Northfield Depot to St Bride’s.** Buses run every half hour and take 41 minutes from end to end. Kiln Street’s bakers ride out on the 01:10 to light their ovens at two, and at 05:20 the driver of the last N27 collects the first loaves for the depot canteen, which has not bought bread since 2019. :chip[N41]{style="route"} **Corn Exchange to the Airport.** Buses run every half hour and take 38 minutes to the terminal, with racks for luggage. Check-in for the first flights opens at 04:45, and the 04:10 is the busiest bus of the night. ### As drawn {style="drawn" note="Drawn with a colour per route. The drum prints each colour as a screen of pink, denser the darker it was: navy N12 at 84%, yellow N50 at 24%."} # Not in Service {style="back"} ## Issue 5 is out in March It follows the cleaners who get the buses ready at Northfield Depot between 05:30 and 07:00, when the day fleet goes out. Pick up a copy: - at the Corn Exchange kiosk, open from 23:00 - in the luggage rack of any N41 - at the Northfield Depot canteen, on the counter :::paragraphs{style="colophon"} Night Buses is written by Ros Adeyemi and Tom Hale and drawn by Mei Lindqvist. Timetables from Brackwater Transport’s winter night network. Printed on a two-drum risograph in Blue and Fluorescent Pink, 300 copies on 100 g cream paper. Set in Epilogue, Anton and Space Mono (SIL OFL) · Text and drawings: CC BY 4.0 :::
`; // content.<lang>.md, inlined by the Cookbook // #region art: the map, the moon and the blind, drawn in full colour function mulberry32(seed) { return () => { seed = (seed + 0x6d2b79f5) | 0; let r = Math.imul(seed ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; } const f2 = (n) => +n.toFixed(2); // A single-stroke capital alphabet on a 4 × 6 grid with 45° corners, like the map's lines. // SVG text in an image cannot reach web fonts (gotcha: svg-no-webfonts), so labels are paths. const GLYPHS = { A: ['0 6 0 1 1 0 3 0 4 1 4 6', '0 3.5 4 3.5'], B: ['0 0 3 0 4 1 4 2 3 3 0 3', '3 3 4 4 4 5 3 6 0 6 0 0'], C: ['4 1 3 0 1 0 0 1 0 5 1 6 3 6 4 5'], D: ['0 0 3 0 4 1 4 5 3 6 0 6 0 0'], E: ['4 0 0 0 0 6 4 6', '0 3 3 3'], G: ['4 1 3 0 1 0 0 1 0 5 1 6 3 6 4 5 4 3.5 2.5 3.5'], H: ['0 0 0 6', '4 0 4 6', '0 3 4 3'], I: ['0 0 0 6'], K: ['0 0 0 6', '4 0 0 4', '1.5 2.5 4 6'], L: ['0 0 0 6 4 6'], N: ['0 6 0 0 4 6 4 0'], O: ['1 0 3 0 4 1 4 5 3 6 1 6 0 5 0 1 1 0'], P: ['0 6 0 0 3 0 4 1 4 2 3 3 0 3'], Q: ['1 0 3 0 4 1 4 5 3 6 1 6 0 5 0 1 1 0', '2.5 4.5 4 6'], R: ['0 6 0 0 3 0 4 1 4 2 3 3 0 3', '2 3 4 6'], S: ['4 1 3 0 1 0 0 1 0 2 1 3 3 3 4 4 4 5 3 6 1 6 0 5'], T: ['0 0 4 0', '2 0 2 6'], U: ['0 0 0 5 1 6 3 6 4 5 4 0'], V: ['0 0 2 6 4 0'], X: ['0 0 4 6', '4 0 0 6'], Y: ['0 0 2 3 4 0', '2 3 2 6'], F: ['4 0 0 0 0 6', '0 3 3 3'], 0: ['1 0 3 0 4 1 4 5 3 6 1 6 0 5 0 1 1 0'], 1: ['0 1 1.5 0 1.5 6'], 2: ['0 1 1 0 3 0 4 1 4 2 0 6 4 6'], 3: ['0 0 4 0 2 2.5 3 2.5 4 3.5 4 5 3 6 1 6 0 5'], 4: ['3 6 3 0 0 4 4 4'], 5: ['4 0 0 0 0 2.5 3 2.5 4 3.5 4 5 3 6 0 6'], 7: ['0 0 4 0 1.5 6'], 8: ['1 0 3 0 4 1 4 2 3 3 1 3 0 2 0 1 1 0', '1 3 3 3 4 4 4 5 3 6 1 6 0 5 0 4 1 3'], "'": ['0.5 0 0 1.5'], }; const advance = (ch) => ({ ' ': 2.6, I: 1.6, 1: 3.2, "'": 1.6 })[ch] ?? 5.6; function letter(text, x, y, size, colour, anchor = 'start', weight = 0.17) { // size: cap height const k = size / 6; const width = [...text].reduce((w, ch) => w + advance(ch), 0) - 1.6; let cx = x - (anchor === 'middle' ? width / 2 : anchor === 'end' ? width : 0) * k; let d = ''; for (const ch of text) { for (const stroke of GLYPHS[ch] ?? []) { const n = stroke.split(' ').map(Number); for (let i = 0; i < n.length; i += 2) { d += `${i ? 'L' : 'M'}${f2(cx + n[i] * k)} ${f2(y - size + n[i + 1] * k)}`; } } cx += advance(ch) * k; } return `<path d="${d}" fill="none" stroke="${colour}" stroke-width="${f2(size * weight)}" ` + 'stroke-linecap="round" stroke-linejoin="round"/>'; } // The night network in mm, 100 × 69.5 (from y = 3), one colour per route as the designer drew it. const DARK = '#23272e'; const LINES = { N12: '#16296b', N27: '#c62a1f', N41: '#0b7d74', N8: '#3b8fd4', N3: '#ef8b1f', N50: '#f3c51a' }; function network() { const path = (nodes, colour, width, close = false) => `<path d="${nodes.map(([x, y], i) => `${i ? 'L' : 'M'}${x} ${y}`).join('')}${close ? 'Z' : ''}" fill="none" stroke="${colour}" ` + `stroke-width="${width}" stroke-linejoin="round" stroke-linecap="round"/>`; const river = path([[0, 48], [8, 48], [14, 54], [86, 54], [92, 48], [100, 48]], '#bfe0ee', 4.4); const loop = path([[32, 21], [68, 21], [74, 27], [74, 41], [68, 47], [32, 47], [26, 41], [26, 27]], LINES.N50, 1.8, true); const legs = [ // route, then its nodes from the hub outwards ['N12', [[50, 31], [50, 6]]], ['N12', [[60, 37], [85, 62], [86, 62]]], ['N27', [[40, 31], [21, 12], [14, 12]]], ['N27', [[64.5, 34], [86, 34]]], ['N3', [[35.5, 34], [14, 34]]], ['N3', [[50, 37], [50, 70]]], ['N41', [[60, 31], [79, 12], [86, 12]]], ['N8', [[40, 37], [15, 62], [14, 62]]], ].map(([id, nodes]) => path(nodes, LINES[id], 1.8)).join(''); const stops = [[50, 6], [86, 62], [14, 12], [86, 34], [14, 34], [50, 70], [86, 12], [14, 62], [50, 21], [50, 47], [26, 34], [74, 34], [69, 22], [31, 22], [69, 46], [31, 46]] .map(([x, y]) => `<circle cx="${x}" cy="${y}" r="0.95" fill="${DARK}"/>`).join(''); const hub = `<rect x="35.5" y="31" width="29" height="6" rx="3" fill="${DARK}"/>` + letter('CORN EXCHANGE', 50, 35, 2, '#ffffff', 'middle', 0.19); const badge = (id, x, y, side) => { // y: the line's axis; side: which way from the stop const w = id.length * 1.76 + 1.2; const x0 = side === 'left' ? x - 1.6 - w : side === 'right' ? x + 1.6 : x - w / 2; return `<rect x="${f2(x0)}" y="${y - 2.1}" width="${f2(w)}" height="4.2" rx="0.8" ` + `fill="${LINES[id]}"/>${letter(id, x0 + 0.62, y + 1.1, 2.2, id === 'N50' ? DARK : '#ffffff', 'start', 0.2)}`; }; const name = (text, x, y, anchor) => letter(text, x, y, 1.9, DARK, anchor); const labels = [ badge('N12', 50, 6, 'left'), name('ASHGROVE HOSPITAL', 53, 7), badge('N27', 14, 12, 'left'), name('NORTHFIELD DEPOT', 4.7, 8.2), badge('N41', 86, 12, 'right'), name('AIRPORT', 95.3, 8.2, 'end'), badge('N3', 14, 34, 'left'), name('CANAL BASIN', 4.7, 30.2), badge('N27', 86, 34, 'right'), name("ST BRIDE'S", 95.3, 30.2, 'end'), badge('N8', 14, 62, 'left'), name('UNIVERSITY', 4.7, 67.8), badge('N12', 86, 62, 'right'), name('HARBOUR GATE', 95.3, 67.8, 'end'), badge('N3', 50, 70, 'left'), name('STATION SQ', 53, 71), badge('N50', 40.7, 47, 'middle'), letter('RIVER BRACK', 32, 59.9, 1.7, '#1f5a85', 'middle', 0.19), ].join(''); return '<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="695" ' + `viewBox="0 3 100 69.5">${river}${loop}${legs}${stops}${hub}${labels}</svg>`; } // The cover's moon, 96 × 96 mm: a light grey disc, then a 45° dot screen whose dots grow and // darken with the tone over the near side's larger seas. function moon() { const rand = mulberry32(7); const cells = Array.from({ length: 9 * 9 }, rand); // value noise, 8 cells across const noise = (u, v) => { const [gx, gy] = [u * 8, v * 8]; const [i, j] = [Math.floor(gx), Math.floor(gy)]; const [s, t] = [gx - i, gy - j].map((a) => a * a * (3 - 2 * a)); const g = (a, b) => cells[Math.min(8, b) * 9 + Math.min(8, a)]; return (g(i, j) * (1 - s) + g(i + 1, j) * s) * (1 - t) + (g(i, j + 1) * (1 - s) + g(i + 1, j + 1) * s) * t; }; const R = 46; const seas = [ // north up: centre, half-axes and tilt, in moon radii [-0.58, -0.02, 0.2, 0.42, 0.3], [-0.3, -0.42, 0.27, 0.22, 0], // Procellarum, Imbrium [0.16, -0.4, 0.15, 0.14, 0], [0.36, -0.08, 0.19, 0.15, 0.4], // Serenitatis, Tranquillitatis [0.72, -0.28, 0.09, 0.11, 0], [0.6, 0.14, 0.09, 0.14, -0.3], // Crisium, Fecunditatis [0.38, 0.28, 0.08, 0.08, 0], [-0.2, 0.34, 0.15, 0.12, 0], // Nectaris, Nubium [-0.52, 0.38, 0.08, 0.08, 0], [-0.05, -0.76, 0.36, 0.06, 0.1], // Humorum, Frigoris [-0.02, -0.16, 0.08, 0.07, 0]]; // Vaporum const P = 2; // screen pitch in mm let dots = ''; for (let i = -34; i <= 34; i++) { for (let j = -34; j <= 34; j++) { const [x, y] = [(i - j) * P / Math.SQRT2, (i + j) * P / Math.SQRT2]; if (Math.hypot(x, y) > R - 0.6) continue; // the disc's edge stays a clean circle const [u, v] = [x / R, y / R]; let sea = 0; for (const [mx, my, rx, ry, a] of seas) { const [du, dv] = [u - mx, v - my]; const [p, q] = [du * Math.cos(a) + dv * Math.sin(a), dv * Math.cos(a) - du * Math.sin(a)]; sea = Math.max(sea, Math.exp(-(((p / rx) ** 2 + (q / ry) ** 2) ** 1.5))); } const m = Math.min(1, Math.max(0, (sea * (0.85 + 0.3 * noise((u + 1) / 2, (v + 1) / 2)) - 0.3) / 0.35)); // 0 on the highlands, 1 inside a sea, with a ragged shore let tone = 0.2 + 0.08 * noise((v + 1) / 2, (u + 1) / 2) + 0.55 * m * m * (3 - 2 * m); for (const [cx, cy, cr] of [[-0.12, 0.72, 0.05], [-0.32, -0.15, 0.035]]) { // bright craters if (Math.hypot(u - cx, v - cy) < cr) tone = 0.1; } const grey = Math.round(210 - 190 * tone).toString(16).padStart(2, '0'); dots += `<circle cx="${f2(x + 48)}" cy="${f2(y + 48)}" r="${f2(P * 0.6 * Math.sqrt(tone))}" ` + `fill="#${grey}${grey}${grey}"/>`; } } return '<svg xmlns="http://www.w3.org/2000/svg" width="960" height="960" viewBox="0 0 96 96">' + `<circle cx="48" cy="48" r="${R}" fill="#c8c8c8"/>${dots}</svg>`; } // The back cover's destination blind, 104 × 26 mm: amber lights on black, 5 × 7 letters. const LED = { N: ['10001', '11001', '10101', '10011', '10001', '10001', '10001'], O: ['01110', '10001', '10001', '10001', '10001', '10001', '01110'], T: ['11111', '00100', '00100', '00100', '00100', '00100', '00100'], I: ['111', '010', '010', '010', '010', '010', '111'], S: ['01111', '10000', '10000', '01110', '00001', '00001', '11110'], E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'], R: ['11110', '10001', '10001', '11110', '10100', '10010', '10001'], V: ['10001', '10001', '10001', '10001', '10001', '01010', '00100'], C: ['01110', '10001', '10000', '10000', '10000', '10001', '01110'], ' ': ['00', '00', '00', '00', '00', '00', '00'], }; function blind(text = 'NOT IN SERVICE') { const cols = [...text].flatMap((ch) => [...LED[ch][0]].map((_, c) => LED[ch].map((row) => row[c] === '1')).concat([Array(7).fill(false)])).slice(0, -1); const [W, H, P, ROWS] = [104, 26, 1.25, 13]; // the matrix has 13 rows; letters on rows 3–9 const n = Math.floor((W - 6) / P); const [x0, y0, first] = [(W - (n - 1) * P) / 2, (H - (ROWS - 1) * P) / 2, Math.floor((n - cols.length) / 2)]; let dots = ''; for (let c = 0; c < n; c++) { for (let r = 0; r < ROWS; r++) { const on = cols[c - first]?.[r - 3] ?? false; dots += `<circle cx="${f2(x0 + c * P)}" cy="${f2(y0 + r * P)}" r="${on ? 0.55 : 0.36}" ` + `fill="${on ? '#ffd35a' : '#3a3a3a'}"/>`; } } return `<svg xmlns="http://www.w3.org/2000/svg" width="1040" height="260" viewBox="0 0 ${W} ${ H}"><rect width="${W}" height="${H}" rx="2.5" fill="#161616"/>${dots}</svg>`; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Text, display and label faces, loaded before the build (gotcha: fonts-first). const FONTS = { Epilogue: ['400', '700'], Anton: ['400'], 'Space Mono': ['400', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); const map = network(); await Promise.all([registerArt('network.svg', map), registerArt('moon.svg', moon()), registerArt('blind.svg', blind()), registerSnapshot('as-drawn.png', map, 600, 417)]); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: 'Night Buses: a two-ink riso zine' }); offerPdf(() => renderToPdf(doc, pdfOptions), `${RECIPE}.pdf`); // A grey proof: the pages as a photocopier or a one-drum reprint would print them. offerPdf(() => renderToPdf(doc, { ...pdfOptions, colorSpace: 'grayscale' }), `${RECIPE}-grey-proof.pdf`);
Kit · core, fonts, viewer, pdf, images: igual en todas las recetas · 310 líneas// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · 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 ───────────────────────────────────────────────────────────────────────

El script.js compuesto funciona tal cual: pégalo como script de módulo en cualquier página o abre la receta en CodePen. Carpeta de la receta en GitHub ↗

Variantes

#Carga un tambor naranja

La luna, el mapa, el rótulo, los chips, el filete y las copias desplazadas de los dos títulos pasan a naranja y el texto sigue en azul; el pie, la nota y el colofón seguirán hablando del tambor rosa hasta que los cambies.

-const DRUMS = { ink: '#1d4fb8', spot: '#f0509a' };
+const DRUMS = { ink: '#1d4fb8', spot: '#ff6c2f' };

#Imprime un libro de texto a dos colores

Con texto negro y una tinta plana azul sobre papel blanco, los mismos dibujos salen en tonos de azul, como los esquemas de un libro de texto a dos colores; el pie, la nota y el colofón seguirán hablando del tambor rosa hasta que los cambies.

-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 falls

Errores frecuentes

Error frecuente

renderPage no recolorea los SVG; renderToPdf sí

renderToPdf aplica diagramStyle.singleInk, pero el canvas pinta los SVG registrados tal como llegan. Recolorea el marcado con applySingleInkToSvg antes de registrarlo para que pantalla y PDF coincidan. Diagramas a una tinta →

Error frecuente

El texto dentro de un SVG <img> no puede usar fuentes web

Un SVG se dibuja como imagen, y una imagen no tiene acceso a las fuentes web de la página, así que sus rótulos salen con una fuente del sistema. Convierte el texto en trazados, incrusta un subconjunto @font-face en el SVG o lleva los rótulos al pie. Figuras y tablas como recursos →

Error frecuente

Una paleta cambiada no llega a los elementos de diseño ni al color de las remisiones

postext 1.4.1 aplica colorPalette a los estilos de texto (cuerpo, títulos, listas, pies, tablas, recuadros), pero no a los elementos de cabeceras, pies de página, aperturas y portadillas, ni a bodyText.referenceColor: conservan el hex escrito junto a su paletteId. Si cambias la paleta, para una edición de pantalla oscura o para recolorear, reescribe cada color enlazado a partir de colorPalette antes de componer. Paleta de color semántica →

Error frecuente

Las imágenes de una apertura no cuentan para la altura que reserva

En postext 1.4.1, un título con diseño avanzado mide la altura que reserva sin contar sus imágenes: sus textos, filetes y cajas cuentan, aunque estén anclados a la página, pero una imagen, como un dibujo a sangre en la cabeza de la página, no reserva nada, así que el texto puede empezar encima de ella. Fija con minHeight dónde debe empezar el texto. Aperturas diseñadas →

Error frecuente

El lineHeight de un texto de diseño es un múltiplo, nunca una medida

En una ranura de diseño, el lineHeight de un elemento de texto multiplica su cuerpo (lineHeight: 1.05). En postext 1.4.1 una medida como pt(15) no da error: la altura de la apertura sale NaN, el espacio que reserva, minHeight incluido, se pierde sin aviso y el texto se superpone al título. Textos, filetes y cajas en los diseños de página →

Error frecuente

\n en un atributo solo corta líneas con paragraphIndent > 0

En un elemento de texto de diseño, un \n escrito en el valor de un atributo solo abre línea nueva si paragraphIndent es mayor que cero o hay capitular; si no, el texto sigue en una línea. Pon en paragraphIndent un valor mínimo (0,01 pt) o usa un atributo por línea. Textos, filetes y cajas en los diseños de página →

Error frecuente

Un flotante 'top' nunca cae en la página que lo cita

Un flotante nunca va por encima de su propia referencia, así que un flotante 'top' a todo el ancho citado en la página N abre la página N+1. Cítalo antes, o usa la posición 'auto' o 'bottom', que pueden ocupar el pie de la página que lo cita. Colocación de figuras →

Error frecuente

El texto en bandera puede dejar sola la puntuación junto a una negrita o un :ref

En postext 1.4.1, el texto que no va justificado (cuerpos de recuadro, párrafos en bandera) puede partir la línea entre una negrita, una cursiva o un :ref y el signo de puntuación pegado a ellos: un punto puede abrir la línea siguiente y el «(» de una remisión puede cerrar la anterior. El texto justificado nunca se parte ahí. Revisa los recuadros de cada edición y reescribe la frase afectada para que ese tramo quede en mitad de la línea. Negrita, cursiva y sus colores →

Error frecuente

Cualquier objeto headings desactiva el salto de página del H1

Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración. Capítulos que abren en página impar →

Error frecuente

Carga todas las fuentes antes de componer

La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde. Fuentes antes de componer →

  • Pasa a renderToPdf el dibujo original. En la 1.4.1, renderToPdf recolorea cualquier SVG que reciba, así que una copia ya recoloreada vuelve a pasar por la fórmula y la línea del N12 baja del 84 % al 44 %.
  • En la 1.4.1, applySingleInkToSvg reescribe los colores hex, rgb() y rgba() con valores enteros y las palabras white y black. Una forma que usa red o hsl(), o que deja el relleno en el negro por defecto, conserva su color, y ese color pediría un tercer tambor. Da un valor hex a cada relleno y a cada trazo.
  • colorSpace: 'grayscale' pasa a grises lo que pinta postext e incrusta los mapas de bits tal cual, así que la miniatura PNG sigue en color en la prueba en grises. La prueba solo la genera el segundo botón al ejecutar el código; las páginas de arriba son el canvas, y el PDF que se descarga en esta página es el de color.
  • Con el espaciado entre párrafos activado, la 1.4.1 puede dejar una línea de más antes del resto de un párrafo que sigue en la página siguiente bajo un flotante superior. El texto de la receta no lo provoca. Si lo cambias, mira la primera línea bajo el mapa, porque una línea de más puede llevar la miniatura a una quinta página.
  • Al ejecutar el código, los dos botones dicen «Build the PDF». El primero genera el PDF en color y el segundo la prueba en grises; cuando el archivo está listo, su botón se cambia por un enlace «Open the PDF ↗» y otro de descarga con el nombre del archivo.

Créditos

Texto
Texto original, CC BY 4.0
Fuentes
Epilogue (SIL OFL 1.1) · Anton (SIL OFL 1.1) · Space Mono (SIL OFL 1.1)
PDF