Pular para o conteúdo principal
Receita número 51

Receitas · Capítulo 7 · Figuras e imagens

Zine riso em duas cores: a arte numa cor especial

Um zine de quatro páginas para risografia de dois tambores: os desenhos coloridos saem em tons de rosa, recoloridos pelo pen no canvas e por renderToPdf no PDF.

Nesta página
Saída
Canvas · PDF
Nível
Avançado
Postext
Testada com o Postext 1.19.1
Requer ≥ 1.4.1 · postext-pdf ≥ 1.4.1
Licença
Atualizada em 6 de out. de 2026
Código MIT · Texto CC BY 4.0
  • Amostra em inglês: ainda sem edição em português
  • Refile 120 × 160 mm
  • 1 coluna
  • Epilogue 9/13
  • Anton
  • Space Mono
  • 4 páginas
  • Nível
  • Postext 1.19.1
  • Diagramado em 10 ms
  • 212 linhas de código

Em poucas palavras

Uma pequena revista dobrada sobre os ônibus noturnos, feita para uma máquina de impressão que usa só duas tintas. Os desenhos coloridos viram tons de uma única tinta rosa.

O que você vai compor

Night Buses é um zine gratuito sobre os ônibus noturnos de uma cidade: quatro páginas de 120 × 160 mm numa folha dobrada uma vez, para um risógrafo de dois tambores, azul e rosa fluorescente. Cada cor da configuração é uma das duas tintas, uma retícula de 25% do rosa ou o papel creme. Os desenhos são feitos em cores (um mapa de linhas em seis cores, uma lua em cinzas, um letreiro de destino âmbar sobre preto) e saem do tambor rosa em tons de rosa, com as cores mais escuras em tons mais densos. O mapa sai em rosa, e uma pequena miniatura mais abaixo na mesma página mantém as seis cores como foram desenhadas. O título da capa é composto duas vezes, com a cópia rosa 0,8 mm à direita da azul e 0,5 mm mais alta. O pen também gera um PDF em cores e uma prova em tons de cinza.

Esta receita responde a

  • Como passo todos os diagramas para uma única cor especial?

A resposta curta

script.js · linhas 34–44no 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

Tipografia
Epilogue, Anton, Space Mono (SIL OFL 1.1)
Materiais
Nenhum: todas as imagens são desenhadas em código

Preparo

#1 · Dois tambores, e cada cor é um deles

script.js · linhas 18–30no 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' } }));

Um risógrafo imprime cada tinta com o seu próprio tambor, então uma terceira cor em qualquer lugar da configuração exigiria um terceiro tambor. screen() mistura uma parte de uma tinta com a cor do papel, do jeito que uma retícula imprime parte dos pontos e deixa papel entre eles. Uma retícula de 25% do rosa é #f2c7d3, o fundo dos chips e da legenda. main-color aponta para o rosa, e assim qualquer valor padrão que a configuração não mexe sai desse tambor, e não no azul do próprio motor, #295AA3. col() escreve cada hex ao lado do seu id porque a 1.4.1 aplica a paleta aos estilos de texto, mas não aos elementos de design.

#2 · Recolora cada desenho uma vez por saída

O código é a resposta curta logo acima. Com diagramStyle.singleInk, renderToPdf transforma cada cor de um SVG num tom de inkColor cuja intensidade é 1 menos a luminância relativa da cor (estilo de diagramas), então o N12 azul-marinho sai a 84% e o N50 amarelo a 24%. Na 1.4.1 o canvas pinta um SVG registrado sem alteração, então registerArt() registra uma cópia recolorida por applySingleInkToSvg e guarda o desenho original para resourceBytes. Amostrada no canvas e no PDF rasterizado a 300 dpi, a linha do N12 é (242, 109, 171), o tom que a fórmula dá para uma intensidade de 0,836. Uma segunda passada a clarearia para 44%, e por isso o PDF nunca recebe a cópia recolorida.

#3 · Imprima o título duas vezes, fora de registro

script.js · linhas 58–91no 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) }) }),
  ] } } };

Um risógrafo imprime um tambor depois do outro, e a folha nunca cai duas vezes exatamente no mesmo lugar. twice() devolve duas cópias de um elemento de texto, a rosa primeiro, 0,8 mm para a direita e 0,5 mm para cima. A ordem do array é a ordem de pintura, então a cópia azul cobre a rosa, exceto por um filete ao longo das bordas superior e direita de cada letra. O canvas e o PDF pintam cor opaca, então a sobreposição fica azul onde uma riso imprimiria roxo. O design ocupa a página porque a linha da edição fica acima da mancha e o poste passa do pé dela; num design mantido na coluna, a 1.4.1 corta os dois nas bordas da coluna.

#4 · Deixe o texto pequeno na tinta escura

script.js · linhas 95–112no 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 o papel creme, o azul dá um contraste de 6,4:1 e o rosa fluorescente de 2,9:1, fraco demais para letra de 8 ou 9 pt. Por isso, todas as palavras que o motor compõe saem do tambor azul. Os chips são Space Mono azul sobre a retícula de 25% (4,85:1) dentro de um contorno rosa, e a legenda é Epilogue azul sobre a mesma retícula. O rosa imprime a tarja sobre a citação, os contornos dos chips, os marcadores, os quadrados dos fólios e as cópias deslocadas dos títulos. As letras dentro dos desenhos fazem parte da arte e saem em rosa com ela. O nome de quem fala é o título do box, composto em maiúsculas de Space Mono negrito de 7,5 pt acima da citação.

#5 · Ponha a miniatura ao lado da sua nota

script.js · linhas 116–141no 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) }) },
    ] } } };

Numa só coluna, uma flutuante ocupa toda a largura da sua faixa, então uma imagem pequena flutuando ali deixaria o resto da faixa vazio. A miniatura, então, é um design de título: ### As drawn {style="drawn" note="…"} imprime a imagem e, à direita dela, o título como rótulo com a nota embaixo. As imagens de um design não reservam altura, então minHeight define um piso na altura da imagem. Hoje a nota tem mais ou menos essa altura; com uma nota mais curta, o bloco seguinte passaria por cima da imagem. registerSnapshot() desenha o mesmo mapa de network() num PNG, e como a tinta única só recolore SVG, a miniatura mantém as seis cores na tela e no PDF.

A receita completa

Sandbox
// ═══ 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', reserve: false, // the text runs above it
      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`---
Amostra em Markdown · 38 linhas · 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 em todas as receitas · 316 linhas// ─── 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 v2 ── the same in every recipe · postext.dev/cookbook // Postext measures with the loaded faces and caches the widths: load every face // before the first build, from Fontsource, the files the PDF embeds too. /** 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. * 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', greek: 'U+0370-03FF', }; const jobs = []; let added = 0; 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 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 = `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` and loads any face the pages use that FONTS missed (a regular * one with a warning), then clears the measurement cache and builds 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. */ 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' }; } /** 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, '-'); } /** 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 v1 ── 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. */ 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 v2 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType: the nearest weight the * family ships, upright if it has no italic; latin, then what the face's * 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 more = kitSubsetsFor(text, meta); const files = await Promise.all(['latin', ...more].map(async (subset) => { const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); })); 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 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 ───────────────────────────────────────────────────────────────────────

O script.js montado funciona como está: cole-o como script de módulo em qualquer página ou abra a receita no CodePen. Pasta da receita no GitHub ↗ (abre em uma nova aba)

Variações

#Coloque um tambor laranja

A lua, o mapa, o letreiro, os chips, a tarja e as cópias deslocadas dos dois títulos ficam laranja, e o texto continua azul; a legenda, a nota e o colofão continuam falando do tambor rosa até que você os edite.

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

#Imprima um livro didático em duas cores

Com texto preto e uma cor especial azul sobre papel branco, os mesmos desenhos saem em tons de azul, como os esquemas de um livro didático em duas cores; a legenda, a nota e o colofão continuam falando do tambor rosa até que você os edite.

-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

Erros comuns

Erro comum

renderPage não recolore os SVGs; renderToPdf recolore

renderToPdf aplica diagramStyle.singleInk, mas o canvas pinta os SVGs registrados do jeito que chegam. Recolore a marcação com applySingleInkToSvg antes de registrá-la, para que tela e PDF fiquem iguais. Diagramas em uma cor →

Erro comum

O texto dentro de um SVG <img> não pode usar fontes web

Um SVG é desenhado como imagem, e uma imagem não tem acesso às fontes web da página, então os rótulos dele caem em uma fonte do sistema. Converta o texto em contornos, incorpore um subconjunto @font-face no SVG ou passe os rótulos para a legenda. Figuras e tabelas como recursos →

Erro comum

Uma paleta trocada não chega aos elementos de design nem à cor das referências

postext 1.4.1 aplica colorPalette aos estilos de texto (corpo, títulos, listas, legendas, tabelas, boxes), mas não aos elementos de cabeçalhos, rodapés, aberturas e páginas de parte, nem a bodyText.referenceColor: eles mantêm o hex escrito ao lado do seu paletteId. Se você trocar a paleta, para uma edição de tela escura ou para mudar as cores, reescreva cada cor vinculada a partir de colorPalette antes de compor. Paleta de cores semântica →

Erro comum

As imagens de uma abertura nunca contam para a altura que ela reserva

No postext 1.4.1, um título com design avançado mede a altura que reserva sem as imagens: textos, fios e caixas contam, mesmo quando ancorados na página, mas uma imagem, como uma ilustração sangrada no alto da página, não reserva nada, então o texto pode começar por cima dela. Defina com minHeight onde o texto deve começar. Aberturas desenhadas →

Erro comum

O lineHeight de um texto de design é um múltiplo, nunca uma medida

Num slot de design, o lineHeight de um elemento de texto multiplica o tamanho da fonte (lineHeight: 1.05). No postext 1.4.1, uma medida como pt(15) não é rejeitada: a altura da abertura dá NaN, o espaço que ela reserva, minHeight incluído, se perde sem aviso e o texto passa por baixo do título. Textos, fios e caixas nos designs de página →

Erro comum

\n em um atributo só quebra linha com paragraphIndent > 0

Em um elemento de texto de design, um \n escrito no valor de um atributo só abre uma linha nova quando paragraphIndent é maior que zero ou há capitular; caso contrário, o texto fica em uma linha só. Dê a paragraphIndent um valor mínimo (0,01 pt) ou use um atributo por linha. Textos, fios e caixas nos designs de página →

Erro comum

Um flutuante 'top' nunca cai na página que o cita

Um flutuante nunca fica acima da própria referência, então um flutuante 'top' na largura da página citado na página N abre a página N+1. Cite-o antes, ou use a posição 'auto' ou 'bottom', que podem ocupar o pé da página que o cita. Posicionamento de figuras →

Erro comum

Texto alinhado à esquerda pode isolar a pontuação ao lado de um negrito ou de um :ref

No postext 1.4.1, o texto que não é justificado (corpo de boxes, parágrafos em bandeira) pode quebrar a linha entre um trecho em negrito ou itálico, ou um :ref, e a pontuação colada nele: um ponto final pode abrir a linha seguinte, e o “(” antes de uma referência pode fechar a linha de cima. Texto justificado nunca quebra aí. Revise os boxes de cada edição e reescreva a frase onde isso acontecer, para que o trecho fique no meio da linha. Negrito, itálico e suas cores →

Erro comum

Qualquer objeto headings desativa a quebra de página do H1

Por padrão, um H1 salta para uma página ímpar (always-odd), mas passar qualquer objeto headings redefine esse padrão, então os capítulos ficam emendados e span: 'page' não faz nada. Declare de novo headings.levels[0].breakBefore: { enabled: true, parity } em toda configuração. Capítulos que abrem em página ímpar →

Erro comum

Carregue todas as fontes antes do layout

O motor de layout mede o texto com as fontes que o navegador carregou e guarda as larguras em cache, então uma fonte que chega depois da primeira composição deixa quebras de linha erradas e um PDF que não corresponde mais à tela. Carregue antes todos os pesos e estilos e chame clearMeasurementCache() antes de recompor quando alguma chegar atrasada. Fontes antes da diagramação →

  • Entregue a renderToPdf o desenho original. Na 1.4.1 ele recolore qualquer SVG que recebe, então uma cópia já recolorida passa de novo pela fórmula e a linha do N12 cai de 84% para 44%.
  • Na 1.4.1, applySingleInkToSvg reescreve cores hex, rgb() e rgba() com canais inteiros, e as palavras-chave white e black. Uma forma que use red, use hsl() ou deixe o preenchimento no preto padrão mantém essa cor, que exigiria um terceiro tambor. Dê um valor hex a cada preenchimento e a cada contorno.
  • colorSpace: 'grayscale' passa para cinza o que o postext pinta e incorpora os bitmaps como estão, então a miniatura PNG continua colorida na prova em tons de cinza. Só o segundo botão do pen gera a prova; as páginas acima são o canvas, e o PDF oferecido nesta página é o colorido.
  • Com o espaçamento entre parágrafos ligado, a 1.4.1 pode acrescentar uma linha de espaço antes do resto de um parágrafo que continua na página seguinte sob uma flutuante no topo. O texto daqui não provoca isso. Depois de editar o texto, confira a primeira linha sob o mapa, porque uma linha a mais pode empurrar a miniatura para uma quinta página.
  • No pen, os dois botões dizem “Build the PDF”. O primeiro gera o PDF em cores e o segundo a prova em tons de cinza; depois que um arquivo é gerado, o botão dá lugar a um link “Open the PDF ↗” e a um link de download com o nome do arquivo.

Créditos

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