# 2色リソのZine：図版を1色の特色で

> 2ドラムのリソグラフ用の4ページのZineです。フルカラーの線画はピンクの濃淡で印刷され、CanvasではCodePenのサンプルが、PDFではrenderToPdfが色を置き換えます。

- HTML版: https://postext.dev/ja/cookbook/riso-zine-single-ink
- レシピ No. 051 · 図と画像 · 難易度 3 (上級) · 出力: Canvas, PDF
- ジャンル: 雑誌とジン
- 必要なもの postext ≥ 1.4.1, postext-pdf ≥ 1.4.1 · テスト環境 1.4.1, postext-pdf 1.4.1 ／テスト日 2026-09-26
- ページ: [1](https://postext.dev/cookbook/riso-zine-single-ink/en/p01.webp?v=024aa742), [2](https://postext.dev/cookbook/riso-zine-single-ink/en/p02.webp?v=024aa742), [3](https://postext.dev/cookbook/riso-zine-single-ink/en/p03.webp?v=024aa742), [4](https://postext.dev/cookbook/riso-zine-single-ink/en/p04.webp?v=024aa742)
- PDF: https://postext.dev/cookbook/riso-zine-single-ink/en/riso-zine-single-ink.pdf?v=024aa742
- Sandboxで開く: https://postext.dev/ja/sandbox#recipe=riso-zine-single-ink&lang=en (.postext: https://postext.dev/cookbook/riso-zine-single-ink/en/riso-zine-single-ink.postext)
- 最終更新: 2026-09-26
- 他の言語: [en](https://postext.dev/en/cookbook/riso-zine-single-ink.md), [es](https://postext.dev/es/cookbook/riso-zine-single-ink.md), [ca](https://postext.dev/ca/cookbook/riso-zine-single-ink.md), [zh](https://postext.dev/zh/cookbook/riso-zine-single-ink.md), [ar](https://postext.dev/ar/cookbook/riso-zine-single-ink.md)

## かんたんな説明

深夜バスを取り上げた、折りたたみの小さな冊子です。2色のインクしか使えない印刷機向けに作り、カラーの絵を1色のピンクインクの濃淡に変えます。

## できあがり

『Night Buses』は、ある町の深夜バスを取り上げた無料のZineです。120 × 160 mmの4ページで、1枚の紙を一度折って作ります。青と蛍光ピンクの2ドラムを持つリソグラフで刷ります。設定の色はどれも2色のインクのどちらか、ピンクの25%の網、またはクリーム色の紙のどれかです。線画はフルカラーで描かれています（6色の路線図、グレーの月、黒地に琥珀色の行先表示幕）が、ピンクのドラムからピンクの濃淡で印刷され、暗い色ほど濃い網になります。[地図](https://postext.dev/cookbook/riso-zine-single-ink/en/p03.webp?v=024aa742)はピンクで印刷され、同じページの下の小さな挿図は描いたとおりの6色を保ちます。表紙の表題は2回組み、ピンクの複製を青より0.8 mm右、0.5 mm上に置きます。CodePenのサンプルは、カラーのPDFとグレーの校正刷りも生成します。

**このレシピが答える問い:**

- すべてのSVG線画を1色の特色の濃淡で、ほかの色をすべて2色目で印刷するには？

## 手短な答え

```js
// script.js, 行 34–44
// 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
```

## 材料

**学べること**

- [単色刷りのダイアグラム](https://postext.dev/ja/docs/configuration.md#ダイアグラムスタイル): すべてのSVGを輝度に応じて1色のインクの濃淡に塗り替えます。特色印刷や2色刷りの本に使います。
- [セマンティックカラーパレット](https://postext.dev/ja/docs/configuration.md#カラーパレット): すべての設定がidで参照する名前付きの色です。1つの色見本を変えるだけで文書全体の色が変わります。既定の色はmain-colorから導かれます。

**ほかに使うもの**

- [CMYKとグレースケールのPDF](https://postext.dev/ja/docs/configuration.md#pdf生成設定)
- [PDFの書き出し](https://postext.dev/ja/docs/configuration.md#pdfの生成)
- [キャンバス上のページ](https://postext.dev/ja/docs/configuration.md#ページをビットマップに描画する)
- [インラインのチップ](https://postext.dev/ja/docs/configuration.md#チップスタイル)
- [キャプションのスタイル](https://postext.dev/ja/docs/configuration.md#キャプションスタイル)
- [囲み](https://postext.dev/ja/docs/configuration.md#囲みスタイル)
- [見出しスタイル](https://postext.dev/ja/docs/configuration.md#見出しスタイル)
- [見出しの属性](https://postext.dev/ja/docs/document-format.md#見出しの属性)
- [デザインした章扉](https://postext.dev/ja/docs/configuration.md#幅と詳細デザイン)
- [ページデザインのテキスト・罫・ボックス](https://postext.dev/ja/docs/configuration.md#柱とノンブル)
- [ページデザインの画像](https://postext.dev/ja/docs/configuration.md#画像要素)
- [表紙、扉、奥付](https://postext.dev/ja/docs/configuration.md#見出しスタイル)
- [リソースとしての図と表](https://postext.dev/ja/docs/document-format.md#リソース)
- [独自のリソースの種類](https://postext.dev/ja/docs/configuration.md#リソースの種類)
- [図の配置](https://postext.dev/ja/docs/document-format.md#配置)
- [用紙の色](https://postext.dev/ja/docs/configuration.md#ページ)
- citations
- [段落スタイル](https://postext.dev/ja/docs/configuration.md#段落スタイル)
- [PDFに埋め込むフォント](https://postext.dev/ja/docs/configuration.md#なぜフォントプロバイダーが必要か)
- [節ごとの柱](https://postext.dev/ja/docs/configuration.md#見出しスタイル)

**設定の一覧**

- [`bodyText`](https://postext.dev/ja/docs/configuration.md#本文), [`calloutStyles`](https://postext.dev/ja/docs/configuration.md#囲みスタイル), [`captionStyle`](https://postext.dev/ja/docs/configuration.md#キャプションスタイル), [`chipStyles`](https://postext.dev/ja/docs/configuration.md#チップスタイル), [`colorPalette`](https://postext.dev/ja/docs/configuration.md#カラーパレット), [`diagramStyle`](https://postext.dev/ja/docs/configuration.md#ダイアグラムスタイル), [`footer`](https://postext.dev/ja/docs/configuration.md#柱とノンブル), [`header`](https://postext.dev/ja/docs/configuration.md#柱とノンブル), [`headingStyles`](https://postext.dev/ja/docs/configuration.md#見出しスタイル), [`headings`](https://postext.dev/ja/docs/configuration.md#見出し), [`layout`](https://postext.dev/ja/docs/configuration.md#レイアウト), [`page`](https://postext.dev/ja/docs/configuration.md#ページ), [`paragraphStyles`](https://postext.dev/ja/docs/configuration.md#段落スタイル), [`resourceTypes`](https://postext.dev/ja/docs/configuration.md#リソースの種類), [`unorderedLists`](https://postext.dev/ja/docs/configuration.md#箇条書きリスト)

**API**

- [`applySingleInkToSvg`](https://postext.dev/ja/docs/configuration.md#単色刷りの仕組み), [`buildDocument`](https://postext.dev/ja/docs/configuration.md#文書のビルド), [`clearMeasurementCache`](https://postext.dev/ja/docs/configuration.md#計測キャッシュ), [`decompressWoff2`](https://postext.dev/ja/docs/configuration.md#ブラウザー向けフォントプロバイダーfontsource--woff2), [`registerResourceImage`](https://postext.dev/ja/docs/architecture.md#apiの概要), [`renderPageToCanvas`](https://postext.dev/ja/docs/configuration.md#ページをビットマップに描画する), [`renderToPdf`](https://postext.dev/ja/docs/configuration.md#pdfの生成)

**書体**

- Epilogue (OFL-1.1), Anton (OFL-1.1), Space Mono (OFL-1.1)

## 作り方

### 1 · 2つのドラムと、そのどちらかに属するすべての色

```js
// script.js, 行 18–30
// 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' } }));
```

リソグラフはインクごとに別のドラムで刷るので、設定のどこかに3色目があれば3つ目のドラムが必要になります。`screen()`はインクの一定割合を紙の色と混ぜます。網が点の一部だけを刷り、そのあいだに紙を残すのと同じ考え方です。ピンクの25%の網は`#f2c7d3`で、チップとキャプションの地色です。`main-color`はピンクを指すので、設定で触れていない既定値もエンジン本来の青`#295AA3`ではなく、このドラムから印刷されます。1.4.1はパレットを文字スタイルには適用しますが、デザイン要素には適用しないため、`col()`は各IDの横に16進値を書きます。

### 2 · 出力ごとに一度だけ線画の色を置き換える

コードは前出の[手短な答え](#手短な答え)にあります。`diagramStyle.singleInk`を指定すると、`renderToPdf`はSVGのすべての色を`inkColor`の濃淡に変えます。濃度は1からその色の相対輝度を引いた値です（[ダイアグラムスタイル](/ja/docs/configuration#ダイアグラムスタイル)）。そのため濃紺のN12は84%、黄色のN50は24%で印刷されます。1.4.1のCanvasは登録されたSVGをそのまま描くので、`registerArt()`は`applySingleInkToSvg`で色を置き換えた複製を登録し、元の線画は`resourceBytes`のために残しておきます。Canvasと、300 dpiでラスタライズしたPDFで色を採取すると、N12の線は(242, 109, 171)で、濃度0.836に対して式が与える濃淡と一致します。置き換えを二度かけると44%まで薄くなるので、置き換えた複製はPDFには渡しません。

### 3 · 表題を見当をずらして2回刷る

```js
// script.js, 行 58–91
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) }) }),
  ] } } };
```

リソグラフはドラムを1つずつ順に刷り、紙が二度とまったく同じ位置に来ることはありません。`twice()`はテキスト要素の複製を2つ返し、ピンクを先にして、0.8 mm右、0.5 mm上に置きます。配列の順が描画の順なので、青の複製がピンクを覆い、各文字の上辺と右辺に沿った細い縁だけが残ります。CanvasとPDFは不透明な色で描くため、リソなら紫に刷られる重なりの部分も青のままです。号数の行は版面の上にあり、ポールは版面の下端より下まで伸びるので、デザインはページ全体に広げます。段の中に置いたデザインでは、1.4.1はその両方を段の端で切り取ります。

### 4 · 小さな文字は濃いインクで刷る

```js
// script.js, 行 95–112
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) } };
```

クリーム色の紙の上では、青のコントラスト比は6.4:1、蛍光ピンクは2.9:1で、8 ptや9 ptの文字には薄すぎます。そこでエンジンが組む語はすべて青のドラムから印刷します。チップは25%の網（4.85:1）の上に青のSpace Monoを置き、ピンクの輪郭で囲みます。キャプションも同じ網の上の青のEpilogueです。ピンクで刷るのは、引用の上の帯、チップの輪郭、行頭記号、ノンブルの正方形、表題のずらした複製です。線画の中の文字は絵の一部なので、絵と一緒にピンクで刷られます。話し手の名前は囲みの見出しで、引用の上に7.5 ptの太字のSpace Monoの大文字で組みます。

### 5 · 挿図を注記の横に置く

```js
// script.js, 行 116–141
// 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) }) },
    ] } } };
```

1段組みではフロートはその帯の幅全体を占めるので、小さな絵をそこにフロートとして配置すると帯の残りが空いてしまいます。そこで挿図は見出しのデザインにします。`### As drawn {style="drawn" note="…"}`は絵を印刷し、その右に見出しをラベルとして、その下に注記を置きます。デザインの画像は高さを確保しないため、`minHeight`で絵の高さを下限にします。いまは注記がほぼ同じ高さですが、注記が短くなると、次のブロックが絵に重なります。`registerSnapshot()`は同じ`network()`の地図をPNGに描きます。単色化はSVGの色しか置き換えないので、挿図は画面でもPDFでも6色を保ちます。

## レシピの全体

レシピのフォルダーから合成した1つのファイルで、サンプルのテキストとレシピ集の共通キットを埋め込んであり、自分でページを組み立てます。動かすには、空のページの`<script type="module">`に入れるか、新しいCodePenのpenのJSパネルに貼り付けます（モジュールとして）。esm.shからpostextを読み込むので、インストールもビルドも要りません。

- レシピのフォルダー: https://github.com/drnachio/postext/tree/main/cookbook/riso-zine-single-ink

### script.js

```js
// ═══ 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`---
title: "Night Buses"
subtitle: "No. 4 · Winter"
author: "Ros Adeyemi, Tom Hale, Mei Lindqvist"
---

# Night Buses {style="cover" issue="No. 4 · Winter · Free" stops="Night N12 N27 N41" line="Six routes after midnight, three ridden end to end"}

# After the Last Train {kicker="Brackwater, 00:10 to 06:00"}

The last train leaves Station Square at 23:52. Until the first one at 05:40, about four thousand people a night ride the night buses: nurses, cleaners, bakers, kitchen staff, students, and anyone who missed the train. Five routes leave the Corn Exchange together at ten past and twenty to the hour, so a change takes three minutes, and :chip[N50]{style="route"} loops round them. The :ref{id="network" text="map opposite"} shows all six. We rode three from end to end.

:chip[N12]{style="route"} **Ashgrove Hospital to Harbour Gate.** Every quarter of an hour, and 34 minutes from one end to the other. The first after midnight takes the evening shift home past the fish market. The 05:25 from the harbour brings the day shift in and empties at Ashgrove in under a minute.

:::callout{type="quote" title="Dee Okafor, driver on the N27 since 2011"}
At ten past three there are five buses and forty people outside the Corn Exchange. By quarter past the square is empty.
:::

:chip[N27]{style="route"} **Northfield Depot to St Bride’s.** Buses run every half hour and take 41 minutes from end to end. Kiln Street’s bakers ride out on the 01:10 to light their ovens at two, and at 05:20 the driver of the last N27 collects the first loaves for the depot canteen, which has not bought bread since 2019.

:chip[N41]{style="route"} **Corn Exchange to the Airport.** Buses run every half hour and take 38 minutes to the terminal, with racks for luggage. Check-in for the first flights opens at 04:45, and the 04:10 is the busiest bus of the night.

### As drawn {style="drawn" note="Drawn with a colour per route. The drum prints each colour as a screen of pink, denser the darker it was: navy N12 at 84%, yellow N50 at 24%."}

# Not in Service {style="back"}

## Issue 5 is out in March

It follows the cleaners who get the buses ready at Northfield Depot between 05:30 and 07:00, when the day fleet goes out. Pick up a copy:

- at the Corn Exchange kiosk, open from 23:00
- in the luggage rack of any N41
- at the Northfield Depot canteen, on the counter

:::paragraphs{style="colophon"}
Night Buses is written by Ros Adeyemi and Tom Hale and drawn by Mei Lindqvist. Timetables from Brackwater Transport’s winter night network. Printed on a two-drum risograph in Blue and Fluorescent Pink, 300 copies on 100 g cream paper.

Set in Epilogue, Anton and Space Mono (SIL OFL) · Text and drawings: CC BY 4.0
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region art: the map, the moon and the blind, drawn in full colour
function mulberry32(seed) {
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let r = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
    return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
  };
}
const f2 = (n) => +n.toFixed(2);
// A single-stroke capital alphabet on a 4 × 6 grid with 45° corners, like the map's lines.
// SVG text in an image cannot reach web fonts (gotcha: svg-no-webfonts), so labels are paths.
const GLYPHS = {
  A: ['0 6 0 1 1 0 3 0 4 1 4 6', '0 3.5 4 3.5'], B: ['0 0 3 0 4 1 4 2 3 3 0 3',
    '3 3 4 4 4 5 3 6 0 6 0 0'], C: ['4 1 3 0 1 0 0 1 0 5 1 6 3 6 4 5'],
  D: ['0 0 3 0 4 1 4 5 3 6 0 6 0 0'], E: ['4 0 0 0 0 6 4 6', '0 3 3 3'],
  G: ['4 1 3 0 1 0 0 1 0 5 1 6 3 6 4 5 4 3.5 2.5 3.5'], H: ['0 0 0 6', '4 0 4 6', '0 3 4 3'],
  I: ['0 0 0 6'], K: ['0 0 0 6', '4 0 0 4', '1.5 2.5 4 6'], L: ['0 0 0 6 4 6'],
  N: ['0 6 0 0 4 6 4 0'], O: ['1 0 3 0 4 1 4 5 3 6 1 6 0 5 0 1 1 0'],
  P: ['0 6 0 0 3 0 4 1 4 2 3 3 0 3'], Q: ['1 0 3 0 4 1 4 5 3 6 1 6 0 5 0 1 1 0', '2.5 4.5 4 6'],
  R: ['0 6 0 0 3 0 4 1 4 2 3 3 0 3', '2 3 4 6'],
  S: ['4 1 3 0 1 0 0 1 0 2 1 3 3 3 4 4 4 5 3 6 1 6 0 5'], T: ['0 0 4 0', '2 0 2 6'],
  U: ['0 0 0 5 1 6 3 6 4 5 4 0'], V: ['0 0 2 6 4 0'], X: ['0 0 4 6', '4 0 0 6'],
  Y: ['0 0 2 3 4 0', '2 3 2 6'], F: ['4 0 0 0 0 6', '0 3 3 3'],
  0: ['1 0 3 0 4 1 4 5 3 6 1 6 0 5 0 1 1 0'], 1: ['0 1 1.5 0 1.5 6'],
  2: ['0 1 1 0 3 0 4 1 4 2 0 6 4 6'], 3: ['0 0 4 0 2 2.5 3 2.5 4 3.5 4 5 3 6 1 6 0 5'],
  4: ['3 6 3 0 0 4 4 4'], 5: ['4 0 0 0 0 2.5 3 2.5 4 3.5 4 5 3 6 0 6'],
  7: ['0 0 4 0 1.5 6'], 8: ['1 0 3 0 4 1 4 2 3 3 1 3 0 2 0 1 1 0',
    '1 3 3 3 4 4 4 5 3 6 1 6 0 5 0 4 1 3'], "'": ['0.5 0 0 1.5'],
};
const advance = (ch) => ({ ' ': 2.6, I: 1.6, 1: 3.2, "'": 1.6 })[ch] ?? 5.6;
function letter(text, x, y, size, colour, anchor = 'start', weight = 0.17) { // size: cap height
  const k = size / 6;
  const width = [...text].reduce((w, ch) => w + advance(ch), 0) - 1.6;
  let cx = x - (anchor === 'middle' ? width / 2 : anchor === 'end' ? width : 0) * k;
  let d = '';
  for (const ch of text) {
    for (const stroke of GLYPHS[ch] ?? []) {
      const n = stroke.split(' ').map(Number);
      for (let i = 0; i < n.length; i += 2) {
        d += `${i ? 'L' : 'M'}${f2(cx + n[i] * k)} ${f2(y - size + n[i + 1] * k)}`;
      }
    }
    cx += advance(ch) * k;
  }
  return `<path d="${d}" fill="none" stroke="${colour}" stroke-width="${f2(size * weight)}" `
    + 'stroke-linecap="round" stroke-linejoin="round"/>';
}

// The night network in mm, 100 × 69.5 (from y = 3), one colour per route as the designer drew it.
const DARK = '#23272e';
const LINES = { N12: '#16296b', N27: '#c62a1f', N41: '#0b7d74', N8: '#3b8fd4', N3: '#ef8b1f',
  N50: '#f3c51a' };
function network() {
  const path = (nodes, colour, width, close = false) => `<path d="${nodes.map(([x, y], i) =>
    `${i ? 'L' : 'M'}${x} ${y}`).join('')}${close ? 'Z' : ''}" fill="none" stroke="${colour}" `
    + `stroke-width="${width}" stroke-linejoin="round" stroke-linecap="round"/>`;
  const river = path([[0, 48], [8, 48], [14, 54], [86, 54], [92, 48], [100, 48]], '#bfe0ee', 4.4);
  const loop = path([[32, 21], [68, 21], [74, 27], [74, 41], [68, 47], [32, 47], [26, 41],
    [26, 27]], LINES.N50, 1.8, true);
  const legs = [ // route, then its nodes from the hub outwards
    ['N12', [[50, 31], [50, 6]]], ['N12', [[60, 37], [85, 62], [86, 62]]],
    ['N27', [[40, 31], [21, 12], [14, 12]]], ['N27', [[64.5, 34], [86, 34]]],
    ['N3', [[35.5, 34], [14, 34]]], ['N3', [[50, 37], [50, 70]]],
    ['N41', [[60, 31], [79, 12], [86, 12]]], ['N8', [[40, 37], [15, 62], [14, 62]]],
  ].map(([id, nodes]) => path(nodes, LINES[id], 1.8)).join('');
  const stops = [[50, 6], [86, 62], [14, 12], [86, 34], [14, 34], [50, 70], [86, 12], [14, 62],
    [50, 21], [50, 47], [26, 34], [74, 34], [69, 22], [31, 22], [69, 46], [31, 46]]
    .map(([x, y]) => `<circle cx="${x}" cy="${y}" r="0.95" fill="${DARK}"/>`).join('');
  const hub = `<rect x="35.5" y="31" width="29" height="6" rx="3" fill="${DARK}"/>`
    + letter('CORN EXCHANGE', 50, 35, 2, '#ffffff', 'middle', 0.19);
  const badge = (id, x, y, side) => { // y: the line's axis; side: which way from the stop
    const w = id.length * 1.76 + 1.2;
    const x0 = side === 'left' ? x - 1.6 - w : side === 'right' ? x + 1.6 : x - w / 2;
    return `<rect x="${f2(x0)}" y="${y - 2.1}" width="${f2(w)}" height="4.2" rx="0.8" `
      + `fill="${LINES[id]}"/>${letter(id, x0 + 0.62, y + 1.1, 2.2,
        id === 'N50' ? DARK : '#ffffff', 'start', 0.2)}`;
  };
  const name = (text, x, y, anchor) => letter(text, x, y, 1.9, DARK, anchor);
  const labels = [
    badge('N12', 50, 6, 'left'), name('ASHGROVE HOSPITAL', 53, 7),
    badge('N27', 14, 12, 'left'), name('NORTHFIELD DEPOT', 4.7, 8.2),
    badge('N41', 86, 12, 'right'), name('AIRPORT', 95.3, 8.2, 'end'),
    badge('N3', 14, 34, 'left'), name('CANAL BASIN', 4.7, 30.2),
    badge('N27', 86, 34, 'right'), name("ST BRIDE'S", 95.3, 30.2, 'end'),
    badge('N8', 14, 62, 'left'), name('UNIVERSITY', 4.7, 67.8),
    badge('N12', 86, 62, 'right'), name('HARBOUR GATE', 95.3, 67.8, 'end'),
    badge('N3', 50, 70, 'left'), name('STATION SQ', 53, 71), badge('N50', 40.7, 47, 'middle'),
    letter('RIVER BRACK', 32, 59.9, 1.7, '#1f5a85', 'middle', 0.19),
  ].join('');
  return '<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="695" '
    + `viewBox="0 3 100 69.5">${river}${loop}${legs}${stops}${hub}${labels}</svg>`;
}

// The cover's moon, 96 × 96 mm: a light grey disc, then a 45° dot screen whose dots grow and
// darken with the tone over the near side's larger seas.
function moon() {
  const rand = mulberry32(7);
  const cells = Array.from({ length: 9 * 9 }, rand); // value noise, 8 cells across
  const noise = (u, v) => {
    const [gx, gy] = [u * 8, v * 8];
    const [i, j] = [Math.floor(gx), Math.floor(gy)];
    const [s, t] = [gx - i, gy - j].map((a) => a * a * (3 - 2 * a));
    const g = (a, b) => cells[Math.min(8, b) * 9 + Math.min(8, a)];
    return (g(i, j) * (1 - s) + g(i + 1, j) * s) * (1 - t)
      + (g(i, j + 1) * (1 - s) + g(i + 1, j + 1) * s) * t;
  };
  const R = 46;
  const seas = [ // north up: centre, half-axes and tilt, in moon radii
    [-0.58, -0.02, 0.2, 0.42, 0.3], [-0.3, -0.42, 0.27, 0.22, 0], // Procellarum, Imbrium
    [0.16, -0.4, 0.15, 0.14, 0], [0.36, -0.08, 0.19, 0.15, 0.4], // Serenitatis, Tranquillitatis
    [0.72, -0.28, 0.09, 0.11, 0], [0.6, 0.14, 0.09, 0.14, -0.3], // Crisium, Fecunditatis
    [0.38, 0.28, 0.08, 0.08, 0], [-0.2, 0.34, 0.15, 0.12, 0], // Nectaris, Nubium
    [-0.52, 0.38, 0.08, 0.08, 0], [-0.05, -0.76, 0.36, 0.06, 0.1], // Humorum, Frigoris
    [-0.02, -0.16, 0.08, 0.07, 0]]; // Vaporum
  const P = 2; // screen pitch in mm
  let dots = '';
  for (let i = -34; i <= 34; i++) {
    for (let j = -34; j <= 34; j++) {
      const [x, y] = [(i - j) * P / Math.SQRT2, (i + j) * P / Math.SQRT2];
      if (Math.hypot(x, y) > R - 0.6) continue; // the disc's edge stays a clean circle
      const [u, v] = [x / R, y / R];
      let sea = 0;
      for (const [mx, my, rx, ry, a] of seas) {
        const [du, dv] = [u - mx, v - my];
        const [p, q] = [du * Math.cos(a) + dv * Math.sin(a), dv * Math.cos(a) - du * Math.sin(a)];
        sea = Math.max(sea, Math.exp(-(((p / rx) ** 2 + (q / ry) ** 2) ** 1.5)));
      }
      const m = Math.min(1, Math.max(0, (sea * (0.85 + 0.3 * noise((u + 1) / 2, (v + 1) / 2))
        - 0.3) / 0.35)); // 0 on the highlands, 1 inside a sea, with a ragged shore
      let tone = 0.2 + 0.08 * noise((v + 1) / 2, (u + 1) / 2) + 0.55 * m * m * (3 - 2 * m);
      for (const [cx, cy, cr] of [[-0.12, 0.72, 0.05], [-0.32, -0.15, 0.035]]) { // bright craters
        if (Math.hypot(u - cx, v - cy) < cr) tone = 0.1;
      }
      const grey = Math.round(210 - 190 * tone).toString(16).padStart(2, '0');
      dots += `<circle cx="${f2(x + 48)}" cy="${f2(y + 48)}" r="${f2(P * 0.6 * Math.sqrt(tone))}" `
        + `fill="#${grey}${grey}${grey}"/>`;
    }
  }
  return '<svg xmlns="http://www.w3.org/2000/svg" width="960" height="960" viewBox="0 0 96 96">'
    + `<circle cx="48" cy="48" r="${R}" fill="#c8c8c8"/>${dots}</svg>`;
}

// The back cover's destination blind, 104 × 26 mm: amber lights on black, 5 × 7 letters.
const LED = {
  N: ['10001', '11001', '10101', '10011', '10001', '10001', '10001'],
  O: ['01110', '10001', '10001', '10001', '10001', '10001', '01110'],
  T: ['11111', '00100', '00100', '00100', '00100', '00100', '00100'],
  I: ['111', '010', '010', '010', '010', '010', '111'],
  S: ['01111', '10000', '10000', '01110', '00001', '00001', '11110'],
  E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'],
  R: ['11110', '10001', '10001', '11110', '10100', '10010', '10001'],
  V: ['10001', '10001', '10001', '10001', '10001', '01010', '00100'],
  C: ['01110', '10001', '10000', '10000', '10000', '10001', '01110'],
  ' ': ['00', '00', '00', '00', '00', '00', '00'],
};
function blind(text = 'NOT IN SERVICE') {
  const cols = [...text].flatMap((ch) => [...LED[ch][0]].map((_, c) =>
    LED[ch].map((row) => row[c] === '1')).concat([Array(7).fill(false)])).slice(0, -1);
  const [W, H, P, ROWS] = [104, 26, 1.25, 13]; // the matrix has 13 rows; letters on rows 3–9
  const n = Math.floor((W - 6) / P);
  const [x0, y0, first] = [(W - (n - 1) * P) / 2, (H - (ROWS - 1) * P) / 2,
    Math.floor((n - cols.length) / 2)];
  let dots = '';
  for (let c = 0; c < n; c++) {
    for (let r = 0; r < ROWS; r++) {
      const on = cols[c - first]?.[r - 3] ?? false;
      dots += `<circle cx="${f2(x0 + c * P)}" cy="${f2(y0 + r * P)}" r="${on ? 0.55 : 0.36}" `
        + `fill="${on ? '#ffd35a' : '#3a3a3a'}"/>`;
    }
  }
  return `<svg xmlns="http://www.w3.org/2000/svg" width="1040" height="260" viewBox="0 0 ${W} ${
    H}"><rect width="${W}" height="${H}" rx="2.5" fill="#161616"/>${dots}</svg>`;
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Text, display and label faces, loaded before the build (gotcha: fonts-first).
const FONTS = { Epilogue: ['400', '700'], Anton: ['400'], 'Space Mono': ['400', '700'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
const map = network();
await Promise.all([registerArt('network.svg', map), registerArt('moon.svg', moon()),
  registerArt('blind.svg', blind()), registerSnapshot('as-drawn.png', map, 600, 417)]);
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: 'Night Buses: a two-ink riso zine' });
offerPdf(() => renderToPdf(doc, pdfOptions), `${RECIPE}.pdf`);
// A grey proof: the pages as a photocopier or a one-drum reprint would print them.
offerPdf(() => renderToPdf(doc, { ...pdfOptions, colorSpace: 'grayscale' }),
  `${RECIPE}-grey-proof.pdf`);

// ─── Kit ── 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 ───────────────────────────────────────────────────────────────────────
```

## アレンジ

### オレンジのドラムを入れる

月、地図、表示幕、チップ、帯、2つの表題のずらした複製がオレンジになり、文字は青のままです。キャプション、注記、奥付は、書き換えるまでピンクのドラムの名前を挙げたままです。

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

### 2色刷りの教科書を刷る

白い紙に黒の文字と青の特色を使うと、同じ線画が2色刷りの教科書の図のように青の濃淡で印刷されます。キャプション、注記、奥付は、書き換えるまでピンクのドラムの名前を挙げたままです。

```diff
-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
```

## よくあるつまずき

- **renderPageはSVGの色を変えない。renderToPdfは変える.** diagramStyle.singleInkを適用するのはrenderToPdfで、Canvasは登録されたSVGをそのまま描きます。登録する前にapplySingleInkToSvgでマークアップの色を変えておけば、画面とPDFが一致します。
- **SVGの<img>内のテキストはWebフォントを使えない.** SVGは画像として描かれ、画像はページのWebフォントにアクセスできないため、ラベルはシステムの書体にフォールバックします。テキストをアウトライン化するか、SVGに@font-faceのサブセットを埋め込むか、ラベルをキャプションに移してください。
- **パレットを差し替えても、デザイン要素と参照色は変わらない.** postext 1.4.1はcolorPaletteをテキストのスタイル（本文、見出し、リスト、キャプション、表、囲み）には反映しますが、ヘッダー、フッター、章扉、部扉の要素と、bodyText.referenceColorには反映しません。これらはpaletteIdの横に書いた16進の色のままです。画面用のダーク版や色替えのためにパレットを差し替えるときは、ビルドの前に、リンクしたすべての色をcolorPaletteから書き直してください。
- **章扉の画像は確保する高さに数えられない.** postext 1.4.1では、詳細デザインの見出しは、確保する高さを画像抜きで計測します。テキスト、罫、箱はページに固定していても数えられますが、ページの頭に裁ち落としで置いた写真のような画像は何も確保しないため、本文がその上から始まることがあります。minHeightを本文が始まるべき位置に設定してください。
- **デザインのテキストのlineHeightは倍率で、寸法ではない.** デザインのスロットでは、テキスト要素のlineHeightはフォントサイズに掛ける倍率です（lineHeight: 1.05）。postext 1.4.1ではpt(15)のような寸法を指定しても拒否されず、章扉の高さがNaNと計測されて、minHeightを含め確保する高さが警告なしに失われ、本文がタイトルに重なって組まれます。
- **属性内の\nが改行になるのはparagraphIndent > 0のときだけ.** デザインのテキスト要素で、属性値に書いた\nが改行になるのは、paragraphIndentが0より大きいか、ドロップキャップを設定したときだけです。それ以外では1行のままになります。paragraphIndentをごくわずかな値（0.01 pt）にするか、1行ごとに属性を分けてください。
- **'top'のフロートは参照したページには入らない.** フロートが自分の参照より上に置かれることはありません。そのため、Nページで参照したページ幅の'top'フロートはN+1ページの頭に置かれます。もっと前で参照するか、参照したページの下端に入れる位置'auto'または'bottom'を使ってください。
- **行末不ぞろいのテキストでは、太字や:refと隣の約物の間で改行されることがある.** postext 1.4.1では、両端そろえでないテキスト（囲みの本文、左そろえの段落）は、太字やイタリックの部分、あるいは:refと、それに接する約物の間で改行されることがあります。ピリオドが次の行の頭に来たり、参照の前の「(」が前の行の末尾に残ったりします。両端そろえのテキストではそこで改行されません。すべての版の囲みを読み、そうなった文は、その部分が行の途中に来るように書き換えてください。
- **headingsオブジェクトを渡すとH1の改ページが消える.** 既定ではH1は奇数ページへ改ページします（always-odd）。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。
- **レイアウトの前にすべてのフォントを読み込む.** レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。

- `renderToPdf`には元の線画を渡してください。1.4.1は受け取ったSVGを何であれ置き換えるので、すでに置き換えた複製は式をもう一度通り、N12の線は84%から44%に下がります。
- 1.4.1の`applySingleInkToSvg`が書き換えるのは、16進の色、チャンネルが整数の`rgb()`と`rgba()`、キーワードの`white`と`black`です。`red`と名指しした図形、`hsl()`を使う図形、塗りを既定の黒のままにした図形はその色を保ち、3つ目のドラムが必要になります。塗りと線にはすべて16進値を指定してください。
- `colorSpace: 'grayscale'`はpostextが描くものをグレーにし、ビットマップはそのまま埋め込むので、PNGの挿図はグレーの校正刷りでもカラーのままです。校正刷りを生成するのはCodePenのサンプルの2つ目のボタンだけです。上のページはCanvasで、このページで提供しているPDFはカラーのほうです。
- 段落間隔をオンにすると、1.4.1は上部のフロートの下で次のページへ続く段落の残りの前に、1行分の空きを加えることがあります。ここの本文ではそれが起きません。本文を書き換えたら地図の下の最初の行を確かめてください。1行増えるだけで挿図が5ページ目に押し出されることがあります。
- CodePenのサンプルでは、どちらのボタンにも「Build the PDF」と書かれています。1つ目がカラーのPDFを、2つ目がグレーの校正刷りを生成します。ファイルができると、そのボタンは「Open the PDF ↗」リンクと、ファイル名の付いたダウンロードリンクに置き換わります。

## クレジット

- レシピ: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- 書体: Epilogue (OFL-1.1), Anton (OFL-1.1), Space Mono (OFL-1.1)
- コード: MIT · サンプルの内容: CC-BY-4.0

## 関連レシピ

- [No. 024 · 入稿用PDF：裁ち落とし、トンボ、CMYK](https://postext.dev/ja/cookbook/print-ready-pdf.md): 3mmの裁ち落としとトンボを付けて組んだ展覧会のリーフレットを、地図と平面図を印刷用マスターから取るCMYKのPDFとして書き出します。 · 難易度 3 (上級) · 一枚物とエフェメラ
- [No. 054 · 1つのパレットで文書全体の色を変える](https://postext.dev/ja/cookbook/live-palette-retint.md): 設定の各色がパレットのidを持つので、1つの関数が新しい設定を返し、音楽祭のプログラムを赤、ティール、紫、砂色のどれでも組めます。 · 難易度 2 (中級) · 一枚物とエフェメラ
- [No. 023 · 雑誌の表紙とセクション別の目次](https://postext.dev/ja/cookbook/magazine-cover-and-contents.md): 写真の表紙では表紙コピーが題字から吊り下がり、目次はセクションごとに色の行を立てて生成されます。各セクションは、ページを開かない部です。 · 難易度 3 (上級) · 雑誌とジン
