# ブランドフォントを組版・PDF・バンドルで使う

> 架空の地下鉄のアイデンティティーマニュアルを、ブランド独自のフォントファイルで組みます。ファイルは一度だけ取得し、レイアウト、PDFのフォントプロバイダー、.postextバンドルで使い回します。

- HTML版: https://postext.dev/ja/cookbook/brand-fonts-identity-manual
- レシピ No. 040 · 出力と組み込み · 難易度 3 (上級) · 出力: Canvas, PDF, .postextバンドル
- ジャンル: マニュアル・ガイド・リファレンス
- 必要なもの 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/brand-fonts-identity-manual/en/p01.webp?v=db408b93), [2](https://postext.dev/cookbook/brand-fonts-identity-manual/en/p02.webp?v=db408b93), [3](https://postext.dev/cookbook/brand-fonts-identity-manual/en/p03.webp?v=db408b93), [4](https://postext.dev/cookbook/brand-fonts-identity-manual/en/p04.webp?v=db408b93)
- PDF: https://postext.dev/cookbook/brand-fonts-identity-manual/en/brand-fonts-identity-manual.pdf?v=db408b93
- Sandboxで開く: https://postext.dev/ja/sandbox#recipe=brand-fonts-identity-manual&lang=en (.postext: https://postext.dev/cookbook/brand-fonts-identity-manual/en/brand-fonts-identity-manual.postext)
- 最終更新: 2026-09-26
- 他の言語: [en](https://postext.dev/en/cookbook/brand-fonts-identity-manual.md), [es](https://postext.dev/es/cookbook/brand-fonts-identity-manual.md), [ca](https://postext.dev/ca/cookbook/brand-fonts-identity-manual.md), [zh](https://postext.dev/zh/cookbook/brand-fonts-identity-manual.md), [ar](https://postext.dev/ar/cookbook/brand-fonts-identity-manual.md)

## かんたんな説明

架空の地下鉄のスタイルガイドです。ブランド専用のフォントを一度だけダウンロードし、ページのプレビュー、PDF、保存したファイルのすべてで同じものを使う方法を示します。

## できあがり

5路線を持つ架空の地下鉄、Metro de Albaのアイデンティティーマニュアルを4ページ作ります。表紙では、Big Shoulders Displayで組んだ名称の下で、5本の路線が左下から1本の共有軌道で出発し、灰色の川を渡ったあと右へ枝分かれします。各節は墨色の帯の下で始まり、いずれかの路線の色で大きな2桁の番号が入ります。「01 Colour」では表の1列目が色そのもので、「02 Type」には見本行とホームの案内サインがあり、「03 Usage」は枠付きの路線図2点で終わります。規則どおりに描いたもの（Do）と、規則に反したもの（Don't）です。テキストはすべて6つのフォントファイルで組まれ、スクリプトは最初のレイアウトの前にそれらをダウンロードします。PDFも同じ6ファイルで描かれます。`.postext`ファイルが運ぶのは5つです。ディスプレイ書体は再配布不可と指定されているためです。

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

- 自前のフォントファイルでページを組み、同じファイルをPDFに埋め込むには？
- フォントを埋め込んだ本物のPDFをブラウザーで書き出すには？
- 見出し、太字、箇条書きの記号が青く出ないようにするには？
- 本文、キャプション、表の注に色見本（凡例の色）を加えるには？
- コードから.postextバンドルを作り、文書をSandboxやほかのプログラムに渡すには？

## 手短な答え

```js
// script.js, 行 16–54
// Your licensed files, one per face in FONTS. Fontsource's copies stand in for them here:
// point FONT_URL at your own server (same origin, or one that lets this page in by CORS).
const slug = (family) => family.toLowerCase().replaceAll(' ', '-');
const FONT_URL = ({ family, weight, style }) => `https://cdn.jsdelivr.net/npm/@fontsource/`
  + `${slug(family)}@5/files/${slug(family)}-latin-${weight}-${style}.woff2`;
const brandFaces = () => Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => {
  const weight = parseInt(spec, 10), style = spec.endsWith('i') ? 'italic' : 'normal';
  return { family, weight, style, fileId: `${slug(family)}-${weight}-${style}.woff2` };
}));
const fontFiles = new Map(); // fileId → the WOFF2 bytes, fetched once

// 1 · Layout measures with document.fonts: register every face before the first build.
async function loadBrandFonts() {
  await Promise.all(brandFaces().map(async ({ family, weight, style, fileId }) => {
    const res = await fetch(FONT_URL({ family, weight, style }));
    if (!res.ok) throw new Error(`No file for ${family} ${weight} ${style}`);
    fontFiles.set(fileId, new Uint8Array(await res.arrayBuffer()));
    const face = new FontFace(family, fontFiles.get(fileId), { weight: `${weight}`, style });
    document.fonts.add(await face.load());
  }));
}

// 2 · The PDF embeds the same bytes as TrueType. It also asks for faces no text uses (the
// display face's italic, the monospace's SemiBold): answer with the family's closest file.
// 1.4.1 writes an unused copy of that file for each of them (gotcha: pdf-font-copies).
async function brandFontProvider(family, weight, style) {
  const cost = (f) => (f.style === style ? 0 : 1000) + Math.abs(f.weight - weight);
  const own = brandFaces().filter((f) => f.family === family);
  if (!own.length) throw new Error(`${family} is not one of the brand's fonts`);
  return decompressWoff2(fontFiles.get(own.reduce((a, b) => (cost(b) < cost(a) ? b : a)).fileId));
}

// 3 · The bundle: customFonts names each face's file by its fileId; createBundle packs the bytes
// of every family it may hand on. Layout never reads it (gotcha: custom-fonts-declarative).
const customFonts = () => Object.keys(FONTS).map((name) => ({
  name, redistributable: name !== DISPLAY, // the display face reaches suppliers another way
  variants: brandFaces().filter((f) => f.family === name)
    .map(({ weight, style, fileId }) => ({ weight, style, fileId, format: 'woff2' })),
}));
```

## 材料

**学べること**

- [独自のフォント](https://postext.dev/ja/docs/configuration.md#カスタムフォント): customFontsで宣言するブランド用やライセンス品のフォントファミリーです。レイアウト用に登録され、同じバイト列からPDFに埋め込まれます。
- [セマンティックカラーパレット](https://postext.dev/ja/docs/configuration.md#カラーパレット): すべての設定がidで参照する名前付きの色です。1つの色見本を変えるだけで文書全体の色が変わります。既定の色はmain-colorから導かれます。
- [セルの塗り](https://postext.dev/ja/docs/document-format.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/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#本文)
- [1段組みと2段組み](https://postext.dev/ja/docs/configuration.md#レイアウトの種類)
- [レイアウト前のフォント読み込み](https://postext.dev/ja/docs/configuration.md#計測キャッシュ)
- [PDFの書き出し](https://postext.dev/ja/docs/configuration.md#pdfの生成)
- [.postextバンドル](https://postext.dev/ja/docs/configuration.md#バンドルpostextファイル)
- [キャンバス上のページ](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/document-format.md#見出しの属性)
- [見出しスタイル](https://postext.dev/ja/docs/configuration.md#見出しスタイル)
- [「図」と「表」を文書の言語で](https://postext.dev/ja/docs/configuration.md#リソースの種類)
- [部ごとの色](https://postext.dev/ja/docs/configuration.md#partコンテナー)
- [PDFに埋め込むフォント](https://postext.dev/ja/docs/configuration.md#なぜフォントプロバイダーが必要か)
- [節ごとの柱](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#キャプションスタイル), [`colorPalette`](https://postext.dev/ja/docs/configuration.md#カラーパレット), [`customFonts`](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#リソースの種類), [`tableStyles`](https://postext.dev/ja/docs/configuration.md#名前付きの表スタイル)

**API**

- [`buildDocument`](https://postext.dev/ja/docs/configuration.md#文書のビルド), [`clearMeasurementCache`](https://postext.dev/ja/docs/configuration.md#計測キャッシュ), [`createBundle`](https://postext.dev/ja/docs/configuration.md#バンドルを作成する), [`decompressWoff2`](https://postext.dev/ja/docs/configuration.md#ブラウザー向けフォントプロバイダーfontsource--woff2), [`defaultResourceTypes`](https://postext.dev/ja/docs/configuration.md#リソースの種類), [`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の生成), [`setCellBackground`](https://postext.dev/ja/docs/document-format.md#ブロック埋め込み任意本文中への明示的な配置)

**書体**

- Public Sans (OFL-1.1), Big Shoulders Display (OFL-1.1), Spline Sans Mono (OFL-1.1)

## 作り方

### 1 · ブランドの書体を登録して組み、PDFに同じバイト列を渡す

```js
// script.js, 行 451–456
await loadBrandFonts(); // the answer, step 1 (gotcha: fonts-first)
for (const [fileId, markup] of Object.entries(ART)) await loadSvg(fileId, markup);
const doc = buildDocument({ markdown, resources }, config());
showPages(doc, { title: 'Metro de Alba · Identity manual' });
offerPdf(() => renderToPdf(doc, { fontProvider: brandFontProvider, resourceBytes: imageBytes }),
  `${RECIPE}.pdf`); // step 2: the provider hands renderToPdf the same files
```

レイアウトはすべての語を`document.fonts`にある書体で計測します。そのため[手短な答え](#手短な答え)の`loadBrandFonts()`は、各ファイルを一度だけ取得してバイト列を保持し、そのバイト列から作った`FontFace`を`buildDocument`の実行前に登録します。キットの`loadFonts`はFontsourceからしか取得しませんが、ブランドは自社のサーバーでファイルを配信しています。postext-pdf 1.4.1の`renderToPdf`は10の書体を要求し、そのうち4つはどのテキストにも使われません（ディスプレイ書体のイタリックと、等幅書体のイタリック、SemiBold、SemiBoldイタリック）。`brandFontProvider`は各要求に、同じファミリーで最も近いファイルを返します（[なぜフォントプロバイダーが必要か](/ja/docs/configuration#なぜフォントプロバイダーが必要か)）。ページが使うのはCanvasが計測した6ファイルだけですが、1.4.1はこの4つの要求それぞれについて、代わりに返したファイルの使われないコピーも書き込みます。

### 2 · 色の体系は1つのリスト

```js
// script.js, 行 60–85
const COLOURS = [ // id, name, screen, print (coated stock)
  ['line-1', 'Line 1 · Tile red', '#e4572e', '0 75 85 0'],
  ['line-2', 'Line 2 · Harbour teal', '#17bebb', '75 0 32 0'],
  ['line-3', 'Line 3 · Broom yellow', '#ffc914', '0 22 95 0'],
  ['line-4', 'Line 4 · Heather', '#6c4f9e', '65 75 0 0'],
  ['line-5', 'Line 5 · Pine', '#3f9b4a', '76 12 90 2'],
  ['signal-red', 'Signal red', '#c0391b', '10 88 100 2'],
  ['signal-green', 'Signal green', '#2b7d3c', '84 25 95 10'],
  ['ink', 'Ink', '#1f2124', '72 62 55 78'],
  ['rule', 'Rule grey', '#d9d9d4', '14 10 14 0'],
];
const palette = { ...Object.fromEntries(COLOURS.map(([id, , hex]) => [id, hex])),
  paper: '#ffffff', section: '#e4572e' }; // section: the line colour of the current section
// col() writes the hex too: 1.4.1 designs read it, not the link (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [...Object.entries(palette), ['main-color', palette.ink]]
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));

const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)).join(' ');
const swatchTable = () => COLOURS.reduce( // an empty first cell, filled with the colour itself
  (model, [id], i) => setCellBackground(model, { row: i + 1, col: 0 }, col(id)),
  { headerRowCount: 1, columnWidths: [22, 34, 14, 15, 15], rows: [
    ['COLOUR', 'NAME', 'HEX', 'RGB', 'CMYK'].map((content) => ({ content, isHeader: true })),
    ...COLOURS.map(([, name, hex, cmyk]) =>
      ['', name, hex.toUpperCase(), rgb(hex), cmyk].map((content) => ({ content }))),
  ] });
```

パレットと表1.1は同じ9行から作られるので、ページで使わない値が表に載ることはありません。`setCellBackground`は各行の空いた1列目のセルをその行の色で塗り、パレットにリンクします。本文では`:swatch{color="line-2"}`がパレットのidを受け取り、テキストの色で縁取った正方形を描きます。`main-color`にはInkの16進値が入っているので、既定でこれにリンクしている見出しや太字・イタリックの文字列は、エンジンの青ではなくInkで印刷されます。参照は1.4.1では`main-color`の値にかかわらず青のままです。`bodyText.referenceColor: col('ink')`を指定すると参照もInkになります（[カラーパレット](/ja/docs/configuration#カラーパレット)）。

### 3 · 扉は1つ、路線の色は3つ

```js
// script.js, 行 94–112
// Texts wrap (gotcha: overflow-ellipsis-default); lineHeight is a multiple (gotcha:
// design-lineheight-multiple). The body starts ten grid lines down, in both columns.
const opener = { enabled: true, minHeight: pt(10 * LEAD), slot: { elements: [
  { kind: 'text', id: 'number', content: '{number}', fontFamily: DISPLAY, fontWeight: 800,
    fontSize: pt(130), lineHeight: 1, color: col('section'), align: 'left', overflow: 'wrap',
    // Nudged by eye on the capture: the cap tops of number and title on one line.
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { x: mm(-1.5), y: mm(1.3) } } },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontWeight: 800,
    fontSize: pt(34), lineHeight: 1, color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { x: mm(COL + GUTTER), y: mm(1) }, size: { width: mm(COL) } } },
  { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: TEXT, fontSize: pt(12.5),
    lineHeight: 1.36, color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { anchor: { to: '#title', edge: 'below' }, offset: { y: mm(3) },
      size: { width: mm(COL) } } },
] } };
// Each section sets `section` to its line's colour; the number and the head square use it.
const section = (id) => ({ id, palette: { section: palette[id] } });
```

`numberingTemplate: '{1:01}'`はカウンターを2桁で書き、扉の`{number}`は01、02、03を印刷します。表紙の見出しは`numbered: false`なので番号が付きません。3つの節のスタイルの違いはパレット項目`section`だけで、それぞれが自分の路線の色をそこに設定します。番号と帯の中の正方形は`section`にリンクしているので、1つの扉のデザインで3つの節をまかなえます。`minHeight`は14 ptのグリッドの10行分を扉に確保するので、どの節の本文もページの同じグリッド線から始まります。

### 4 · DoとDon'tは図の種類

```js
// script.js, 行 161–166
const example = (id, name, colour) => ({ id, name, shortLabel: name, captionPrefix: name,
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  captionStyle: { labelColor: col(colour) } });
const resourceTypes = [...defaultResourceTypes(LANG),
  example('do', 'Do', 'signal-green'), example('dont', 'Don’t', 'signal-red')];
const FOOT = { position: 'bottom' }; // cited in one paragraph: its page's foot, one per column
```

リソースの種類には部分的な`captionStyle`を持たせることができます。この2つが設定するのはラベルの色だけで、DoはSignal green、Don'tはSignal redです（[リソースの種類](/ja/docs/configuration#リソースの種類)）。2つのシグナル色は白地に対して5.1:1と5.5:1に達しますが、路線1、2、3、5の色は小さな文字に必要な4.5:1に届きません。どちらの図も`position: 'bottom'`でフロートし、見出しDrawing the mapの直後の同じ段落で引用されます。そのため2点は4ページの地を分け合い、Doが左の段、Don'tが右の段に入ります。

### 5 · バンドルには共有してよいフォントを収める

```js
// script.js, 行 460–474
const pack = Object.assign(document.createElement('button'), { type: 'button',
  textContent: 'Build the .postext' });
pack.addEventListener('click', async () => {
  const { bytes, manifest, warnings } = await createBundle({
    name: 'Metro de Alba identity manual', locale: LANG, markdown, config: config(), resources,
    files: new Map([...Object.entries(ART), ...fontFiles]), // fileId → SVG markup or font bytes
  });
  const size = `${Math.round(bytes.length / 1024)} KB`;
  const packed = `fonts inside: ${manifest.fonts.map((font) => font.name).join(', ')}`;
  kitStatus(['.postext', size, packed, ...warnings].join(' · ')); // warnings: what stayed out
  pack.replaceWith(Object.assign(document.createElement('a'), { download: `${RECIPE}.postext`,
    href: URL.createObjectURL(new Blob([bytes], { type: 'application/zip' })),
    textContent: `Download ${RECIPE}.postext · ${size}` }));
});
document.getElementById('pt-actions').append(pack);
```

`createBundle`は`config.customFonts`を読み、各バリアントのバイト列を`fileId`をもとに`files`から探して`fonts/`の下に書き込み、マニフェストに`fonts`項目を加えます。ディスプレイ書体のファミリーには`redistributable: false`が付いています。これはファイルを他者に渡すことを禁じるライセンスの代役です（Big Shoulders Display自体はOFLです）。そのファイルはバンドルに入らず、`warnings`がファミリー名を挙げます。81 KBのファイルには、章、設定、3点の図、5つのフォントファイルが収まります。レイアウトは`customFonts`を読まないので、これがなくてもページは同じに仕上がります（[バンドルを作成する](/ja/docs/configuration#バンドルを作成する)）。

## レシピの全体

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

- レシピのフォルダー: https://github.com/drnachio/postext/tree/main/cookbook/brand-fonts-identity-manual

### script.js

```js
// ═══ Postext Cookbook · Nº 040 · Brand fonts in layout, PDF and bundle ════════════
// https://postext.dev/en/cookbook/brand-fonts-identity-manual
// Code: MIT · Text and drawings: original (CC BY 4.0) · Metro de Alba is a fictional network
// Fonts: Public Sans, Big Shoulders Display, Spline Sans Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  defaultResourceTypes, setCellBackground, createBundle,
} 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 = 'brand-fonts-identity-manual';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region answer: the brand's font files, fetched once for the layout, the PDF and the bundle
// Your licensed files, one per face in FONTS. Fontsource's copies stand in for them here:
// point FONT_URL at your own server (same origin, or one that lets this page in by CORS).
const slug = (family) => family.toLowerCase().replaceAll(' ', '-');
const FONT_URL = ({ family, weight, style }) => `https://cdn.jsdelivr.net/npm/@fontsource/`
  + `${slug(family)}@5/files/${slug(family)}-latin-${weight}-${style}.woff2`;
const brandFaces = () => Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => {
  const weight = parseInt(spec, 10), style = spec.endsWith('i') ? 'italic' : 'normal';
  return { family, weight, style, fileId: `${slug(family)}-${weight}-${style}.woff2` };
}));
const fontFiles = new Map(); // fileId → the WOFF2 bytes, fetched once

// 1 · Layout measures with document.fonts: register every face before the first build.
async function loadBrandFonts() {
  await Promise.all(brandFaces().map(async ({ family, weight, style, fileId }) => {
    const res = await fetch(FONT_URL({ family, weight, style }));
    if (!res.ok) throw new Error(`No file for ${family} ${weight} ${style}`);
    fontFiles.set(fileId, new Uint8Array(await res.arrayBuffer()));
    const face = new FontFace(family, fontFiles.get(fileId), { weight: `${weight}`, style });
    document.fonts.add(await face.load());
  }));
}

// 2 · The PDF embeds the same bytes as TrueType. It also asks for faces no text uses (the
// display face's italic, the monospace's SemiBold): answer with the family's closest file.
// 1.4.1 writes an unused copy of that file for each of them (gotcha: pdf-font-copies).
async function brandFontProvider(family, weight, style) {
  const cost = (f) => (f.style === style ? 0 : 1000) + Math.abs(f.weight - weight);
  const own = brandFaces().filter((f) => f.family === family);
  if (!own.length) throw new Error(`${family} is not one of the brand's fonts`);
  return decompressWoff2(fontFiles.get(own.reduce((a, b) => (cost(b) < cost(a) ? b : a)).fileId));
}

// 3 · The bundle: customFonts names each face's file by its fileId; createBundle packs the bytes
// of every family it may hand on. Layout never reads it (gotcha: custom-fonts-declarative).
const customFonts = () => Object.keys(FONTS).map((name) => ({
  name, redistributable: name !== DISPLAY, // the display face reaches suppliers another way
  variants: brandFaces().filter((f) => f.family === name)
    .map(({ weight, style, fileId }) => ({ weight, style, fileId, format: 'woff2' })),
}));
// #endregion

const TEXT = 'Public Sans', DISPLAY = 'Big Shoulders Display', MONO = 'Spline Sans Mono';

// #region colours: the colour system, from which the palette and Table 1.1 are both built
const COLOURS = [ // id, name, screen, print (coated stock)
  ['line-1', 'Line 1 · Tile red', '#e4572e', '0 75 85 0'],
  ['line-2', 'Line 2 · Harbour teal', '#17bebb', '75 0 32 0'],
  ['line-3', 'Line 3 · Broom yellow', '#ffc914', '0 22 95 0'],
  ['line-4', 'Line 4 · Heather', '#6c4f9e', '65 75 0 0'],
  ['line-5', 'Line 5 · Pine', '#3f9b4a', '76 12 90 2'],
  ['signal-red', 'Signal red', '#c0391b', '10 88 100 2'],
  ['signal-green', 'Signal green', '#2b7d3c', '84 25 95 10'],
  ['ink', 'Ink', '#1f2124', '72 62 55 78'],
  ['rule', 'Rule grey', '#d9d9d4', '14 10 14 0'],
];
const palette = { ...Object.fromEntries(COLOURS.map(([id, , hex]) => [id, hex])),
  paper: '#ffffff', section: '#e4572e' }; // section: the line colour of the current section
// col() writes the hex too: 1.4.1 designs read it, not the link (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [...Object.entries(palette), ['main-color', palette.ink]]
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));

const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)).join(' ');
const swatchTable = () => COLOURS.reduce( // an empty first cell, filled with the colour itself
  (model, [id], i) => setCellBackground(model, { row: i + 1, col: 0 }, col(id)),
  { headerRowCount: 1, columnWidths: [22, 34, 14, 15, 15], rows: [
    ['COLOUR', 'NAME', 'HEX', 'RGB', 'CMYK'].map((content) => ({ content, isHeader: true })),
    ...COLOURS.map(([, name, hex, cmyk]) =>
      ['', name, hex.toUpperCase(), rgb(hex), cmyk].map((content) => ({ content }))),
  ] });
// #endregion

const PAGE = { width: 210, height: 280 }; // mm
const MARGIN = { top: 24, bottom: 22, inner: 18, outer: 18 };
const GUTTER = 8, COL = (PAGE.width - MARGIN.inner - MARGIN.outer - GUTTER) / 2; // 83 mm
const BAND = 13, LEAD = 14; // mm: the ink band at the head of each page; pt: the leading

// #region sections: a giant zero-padded number in the section's line colour
// Texts wrap (gotcha: overflow-ellipsis-default); lineHeight is a multiple (gotcha:
// design-lineheight-multiple). The body starts ten grid lines down, in both columns.
const opener = { enabled: true, minHeight: pt(10 * LEAD), slot: { elements: [
  { kind: 'text', id: 'number', content: '{number}', fontFamily: DISPLAY, fontWeight: 800,
    fontSize: pt(130), lineHeight: 1, color: col('section'), align: 'left', overflow: 'wrap',
    // Nudged by eye on the capture: the cap tops of number and title on one line.
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { x: mm(-1.5), y: mm(1.3) } } },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontWeight: 800,
    fontSize: pt(34), lineHeight: 1, color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { x: mm(COL + GUTTER), y: mm(1) }, size: { width: mm(COL) } } },
  { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: TEXT, fontSize: pt(12.5),
    lineHeight: 1.36, color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { anchor: { to: '#title', edge: 'below' }, offset: { y: mm(3) },
      size: { width: mm(COL) } } },
] } };
// Each section sets `section` to its line's colour; the number and the head square use it.
const section = (id) => ({ id, palette: { section: palette[id] } });
// #endregion

// Running heads: an ink band across the head of the page, as on the platform signs.
const PT = 25.4 / 72; // mm in a point
const HEAD = 7.5, SQUARE = 4.5, STEP = 8; // pt: head size; mm: the square, and the spacing
const label = (size, colour) => ({ fontFamily: TEXT, fontSize: pt(size), fontWeight: 600,
  letterSpacing: pt(size * 0.16), textTransform: 'uppercase', color: col(colour) });
const inBand = (edge, x, height) => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm((BAND - height) / 2) } }); // centred in the band
const head = (id, content, parity, edge, x) => ({ kind: 'text', id, content, parity,
  lineHeight: 1, ...label(HEAD, 'paper'), placement: inBand(edge, x, HEAD * PT) });
const square = (id, parity, edge, x) => ({ kind: 'box', id, parity,
  style: { backgroundColor: col('section') }, // the section's line, as on its signs
  placement: { ...inBand(edge, x, SQUARE), size: { width: mm(SQUARE), height: mm(SQUARE) } } });
const header = { elements: [
  { kind: 'box', id: 'band', style: { backgroundColor: col('ink') },
    placement: { anchor: { to: 'page', edge: 'top-left' },
      size: { width: 'fill', height: mm(BAND) } } },
  square('verso-line', 'even', 'top-left', MARGIN.outer),
  head('verso-folio', '{pageNumber}', 'even', 'top-left', MARGIN.outer + STEP),
  head('verso-title', '{title} · {subtitle}', 'even', 'top-left', MARGIN.outer + 2 * STEP),
  square('recto-line', 'odd', 'top-right', -MARGIN.outer),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -(MARGIN.outer + STEP)),
  head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(MARGIN.outer + 2 * STEP)),
] };

// The cover: the network drawing fills the page; the heading and frontmatter give the texts.
const coverText = (id, content, placement, style) => ({ kind: 'text', id, content, placement,
  overflow: 'wrap', align: 'left', ...style });
const under = (id, gap) => ({ anchor: { to: `#${id}`, edge: 'below' }, offset: { y: mm(gap) },
  size: { width: mm(PAGE.width - 2 * MARGIN.outer) } });
const cover = { id: 'cover', numbered: false, span: 'page',
  header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'map', resourceId: 'network',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    coverText('kicker', '{attr.kicker}', { anchor: { to: 'page', edge: 'top-left' },
      offset: { x: mm(MARGIN.outer), y: mm(MARGIN.top - 2) } }, label(8, 'ink')),
    // At lineHeight 0.86 the capitals rise about 3 mm above the title's box: hence 6.5 mm.
    coverText('title', '{titleText}', under('kicker', 6.5), { fontFamily: DISPLAY,
      fontWeight: 800, fontSize: pt(88), lineHeight: 0.86, color: col('ink') }),
    coverText('subtitle', '{subtitle}', under('title', 3), { fontFamily: TEXT, fontWeight: 600,
      fontSize: pt(20), lineHeight: 1.2, color: col('ink') }),
    coverText('edition', '{attr.edition}', under('subtitle', 1.5), { fontFamily: MONO,
      fontSize: pt(9), lineHeight: 1.3, color: col('ink') }),
  ] } } };

// #region do-dont: two figure types whose captions carry their own label colour
const example = (id, name, colour) => ({ id, name, shortLabel: name, captionPrefix: name,
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  captionStyle: { labelColor: col(colour) } });
const resourceTypes = [...defaultResourceTypes(LANG),
  example('do', 'Do', 'signal-green'), example('dont', 'Don’t', 'signal-red')];
const FOOT = { position: 'bottom' }; // cited in one paragraph: its page's foot, one per column
// #endregion

const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity)
  colorPalette, resourceTypes, customFonts: customFonts(),
  page: { width: mm(PAGE.width), height: mm(PAGE.height), dpi: 150,
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true } },
  layout: { layoutType: 'double', gutterWidth: mm(GUTTER) },
  bodyText: { fontFamily: TEXT, fontSize: pt(9.8), lineHeight: pt(LEAD), color: col('ink'),
    referenceColor: col('ink'), // references skip the palette (gotcha: palette-skips-designs)
    boldFontWeight: 600, // the brand has no Bold: its SemiBold sets **emphasis**
    textAlign: 'left', firstLineIndent: mm(0), paragraphSpacing: true },
  headings: { fontFamily: DISPLAY, fontWeight: 800, levels: [ // ink: main-color
    // span: 'page' breaks already; restated in case it goes (gotcha: headings-drop-h1-break)
    { level: 1, span: 'page', numberingTemplate: '{1:01}', marginBottom: pt(0),
      breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener },
    { level: 2, fontSize: pt(17), lineHeight: pt(2 * LEAD), marginTop: pt(0), // two lines,
      marginBottom: pt(0) }, // so a column that opens with one starts on the same line
  ] },
  headingStyles: [cover, section('line-1'), section('line-4'), section('line-5')],
  paragraphStyles: [
    { id: 'specimen', fontSize: pt(15), lineHeight: pt(LEAD * 1.5), marginBottom: pt(LEAD) },
    { id: 'specimen-mono', fontFamily: MONO, fontSize: pt(11), lineHeight: pt(LEAD * 1.5),
      marginBottom: pt(LEAD) },
    { id: 'colophon', fontSize: pt(7.5), lineHeight: pt(10.5) },
  ],
  calloutStyles: [
    { id: 'sign', span: 'page', placement: 'bottom', background: col('ink'),
      padding: { top: mm(6), right: mm(8), bottom: mm(6), left: mm(8) },
      titleStyle: { fontFamily: DISPLAY, fontWeight: 800, fontSize: pt(60), gap: mm(2),
        color: col('paper') }, body: { fontSize: pt(16), lineHeight: pt(LEAD * 1.5),
        color: col('paper'), boldColor: col('paper') } },
  ],
  tableStyles: [
    { id: 'swatches', rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
      headerBackground: col('ink'), headerColor: col('paper'), headerFontSize: pt(7.5),
      bodyFontFamily: MONO, bodyFontSize: pt(8.5), cellPadding: mm(2) },
  ],
  captionStyle: { fontSize: pt(8.3) }, // face and ink from bodyText; the label in SemiBold
  header, footer: { elements: [] }, // the folio sits in the band
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Metro de Alba"
subtitle: "Identity manual"
author: "Metro de Alba brand office"
---

# Metro de Alba {style="cover" kicker="Brand office · Lines and stations" edition="Edition 3 · September 2026"}

# Colour {style="line-1" lead="Riders learn the colour of a line before its number. These values are fixed for print, screen and enamel."}

## Five lines, five colours

The network has five lines, and each owns one colour, listed in :ref{id="colours" style="full"}. The colour marks the line wherever it appears: the stripe on a platform wall, the band along a train, the line’s badge and its path on the map. It marks nothing else, so a leaflet about fares is printed in ink on paper and red always means line 1.

Take the values from the table, never from a screenshot, an old sign or a colour picker. Screens use the HEX value, and print uses the CMYK recipe on coated stock; for uncoated paper and newsprint, ask the brand office for the matching recipe. Enamel panels and vinyl are matched to the printed swatch card that the brand office keeps.

## Colour and contrast

Line colours are for fills. A line badge is a square in the line colour with the number inside it: white on lines :swatch{color="line-1"} 1, :swatch{color="line-4"} 4 and :swatch{color="line-5"} 5, ink on lines :swatch{color="line-2"} 2 and :swatch{color="line-3"} 3, whose teal and yellow measure 2.3:1 and 1.5:1 against white, below the 3:1 that large type needs.

Small text is never set in a line colour. Warnings and confirmations use :swatch{color="signal-red"} Signal red and :swatch{color="signal-green"} Signal green, which reach 5.5:1 and 5.1:1 on white.

## Neutrals

Ink, a blue-black, sets text and outlines. Paper is plain white, since a tint would dull the yellow of line 3. Rule grey draws rules and the river.

# Type {style="line-4" lead="Names and headings are set in a condensed display face. Running text is set in a sans, and a monospace takes the figures that have to line up."}

## Big Shoulders Display

Station names, line names and headings are set in Big Shoulders Display ExtraBold. It is narrow, so a long name such as Puerta del Mercado still fits a platform sign at a size that reads from the far end of the platform. Set it in capitals and lowercase, never in capitals alone, and never below 14 pt, where its narrow counters start to fill in.

A platform sign is an ink band 400 mm deep. The station name has a cap height of 150 mm and sits on a 100 mm margin. Under it, a square in the colour of each line that stops there comes before the line numbers, set in Public Sans SemiBold.

:::callout{type="sign" title="Puerta del Mercado"}
:swatch{color="line-2"} :swatch{color="line-3"} :swatch{color="line-5"} **Lines 2, 3 and 5**
:::

## Public Sans

Anything people read in sentences, such as a notice or this manual, is set in Public Sans. Text is 9.8 pt on a 14 pt line, ragged right, with no indents and a space between paragraphs.

Captions in print are 8.3 pt, and nothing a rider needs to read is set smaller. On screen, text starts at 16 px and never drops below 14 px.

## Weights

Public Sans is used in four faces. There is no Bold, and the SemiBold takes its place:

:::paragraphs{style="specimen"}
Regular and *Italic*

**SemiBold** and ***SemiBold Italic***
:::

Emphasis is **SemiBold, in ink**, as in this sentence; a heavier weight would compete with the station names. Italic marks the titles of documents and words in another language, such as *andén* on a bilingual sign.

## Spline Sans Mono

Departure times, platform codes and colour values are set in Spline Sans Mono, so that the figures on a departure board line up in columns without tabs:

:::paragraphs{style="specimen-mono"}
07:42 · 07:46 · 07:51 · #E4572E
:::

Use it for values only: at the same size, a sentence in it runs about 30% wider than in Public Sans.

# Usage {style="line-5" lead="The network map is the drawing we print most. The rules below apply to every copy of it, printed or on screen."}

## Drawing the map

Lines run horizontally, vertically or at 45 degrees, and change direction on a curve three line widths in radius, as in :ref{id="do-grid"}. Lines that share track run side by side at an equal spacing, in the order of their numbers, and bend round one centre. A station is a white dot with an ink rim, and an interchange is a single white capsule drawn across every line that stops there. :ref{id="dont-grid"} breaks each of these rules: free angles, curves of any radius, uneven spacing and stations in the line colour.

## Names on the map

Station names are set in Public Sans SemiBold, always horizontal, on the side of the line that has no other line. An interchange carries its name once, beside the capsule.

## Geography

Stations are evenly spaced. The one geographical feature on the map is the river, a band of Rule grey.

## Line width and scale

The line width sets the scale of the whole drawing: 6 mm on the platform map, 1.5 mm on the pocket map and 4 px on screen at the default zoom. A station dot is 1.4 line widths across with a rim of a quarter of a line width, and a capsule is a dot stretched across the lines it serves.

## Files for suppliers

Suppliers receive this manual as a PDF with its typefaces embedded, and as an editable source file that carries Public Sans and Spline Sans Mono. The display face is sent separately from the brand office’s type folder, so that every supplier sets names from the same version of it. Map artwork keeps station names as live text, never as outlines, so that a station renamed by the city can be corrected in every file.

:::paragraphs{style="colophon"}
Metro de Alba is a fictional network. Set in Public Sans, Big Shoulders Display and Spline Sans Mono, under the SIL Open Font License 1.1. Text and drawings: CC BY 4.0.
:::
`; // content.<lang>.md, inlined by the Cookbook
// Its frontmatter fills {title} · {subtitle} in the band and the PDF's title and author:
// every value is quoted (gotcha: quote-frontmatter).

const ART = {}; // fileId → SVG markup: registered for the pages, packed into the bundle
const drawing = (id, typeId, fileId, [w, h], caption, altText, placement) => ({ id, typeId,
  kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId, width: w * 10, height: h * 10 },
  caption, altText, placement });
const resources = [
  { id: 'colours', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
    placement: { span: 'page' },
    caption: 'The colour system. Line colours are fills; only Ink and the two signals set text.',
    note: 'HEX and RGB for screens; CMYK recipes for coated stock.',
    table: { styleId: 'swatches', model: swatchTable() } },
  drawing('network', 'figure', 'network.svg', [PAGE.width, PAGE.height], '',
    'Five coloured metro lines share a track from the lower left, then fan out to the right.'),
  drawing('do-grid', 'do', 'do-grid.svg', [COL, 43.5],
    'Lines at 0°, 45° and 90°, bent together at an even spacing; one capsule for the interchange.',
    'Three parallel lines bend at 45 degrees together; white stations with ink rims.', FOOT),
  drawing('dont-grid', 'dont', 'dont-grid.svg', [COL, 43.5],
    'Free angles, mixed radii, uneven spacing and stations drawn in the line colour.',
    'The same three lines drawn at free angles with coloured station dots.', FOOT),
];

// #region art: the drawings, made by the rules of section 03
const f = (n) => +n.toFixed(2);
const P = ([x, y]) => `${f(x)} ${f(y)}`;
const sub = (a, b) => [a[0] - b[0], a[1] - b[1]];
const add = (a, b, k = 1) => [a[0] + b[0] * k, a[1] + b[1] * k];
const unit = (v) => { const l = Math.hypot(v[0], v[1]); return [v[0] / l, v[1] / l]; };
const dotp = (a, b) => a[0] * b[0] + a[1] * b[1];
const left = ([x, y]) => [y, -x]; // the normal on the left of a direction (y runs down)
const HEADING = { E: [1, 0], NE: [1, -1], SE: [1, 1], S: [0, 1] };
/** From a point, a run of moves such as ['NE', 40]: 0°, 45° and 90° only. */
const walk = (from, moves) => moves.reduce((pts, [h, len]) =>
  [...pts, add(pts[pts.length - 1], unit(HEADING[h]), len)], [from]);
const dirs = (pts, i) => {
  const a = unit(sub(pts[i], pts[i - 1] ?? pts[i])), b = unit(sub(pts[i + 1] ?? pts[i], pts[i]));
  return [Number.isNaN(a[0]) ? b : a, Number.isNaN(b[0]) ? a : b];
};
/** A polyline moved d mm to its left, mitred at each bend, so parallel lines stay parallel. */
const shift = (pts, d) => pts.map((p, i) => {
  const [n1, n2] = dirs(pts, i).map(left);
  return add(p, add(n1, n2), d / (1 + dotp(n1, n2)));
});
/** A path through the points, each bend rounded: radius R, or R ± d for a line d mm off a
 *  shared centre line, so that the bends of parallel lines stay concentric. */
function track(pts, R, offsets = []) {
  let out = `M${P(pts[0])}`;
  for (let i = 1; i < pts.length - 1; i++) {
    const [a, b] = dirs(pts, i);
    const turn = Math.acos(Math.max(-1, Math.min(1, dotp(a, b))));
    const r = R + (offsets[i] ?? 0) * Math.sign(a[0] * b[1] - a[1] * b[0]);
    const cut = r * Math.tan(turn / 2);
    out += ` L${P(add(pts[i], a, -cut))} Q${P(pts[i])} ${P(add(pts[i], b, cut))}`;
  }
  return `${out} L${P(pts[pts.length - 1])}`;
}
/** The point `dist` mm along a polyline. */
function along(pts, dist) {
  for (let i = 1; i < pts.length; i++) {
    const len = Math.hypot(...sub(pts[i], pts[i - 1]));
    if (dist <= len) return add(pts[i - 1], unit(sub(pts[i], pts[i - 1])), dist);
    dist -= len;
  }
  return pts[pts.length - 1];
}
const stroke = (d, colour, w, cap = 'butt') => `<path d="${d}" fill="none" stroke="${colour}" `
  + `stroke-width="${f(w)}" stroke-linecap="${cap}" stroke-linejoin="round"/>`;
/** A station: a white dot with an ink rim. An interchange: one capsule from a to b. */
const station = (p, w) => `<circle cx="${f(p[0])}" cy="${f(p[1])}" r="${f(0.7 * w)}" `
  + `fill="${palette.paper}" stroke="${palette.ink}" stroke-width="${f(w / 4)}"/>`;
const capsule = (a, b, w) => stroke(`M${P(a)} L${P(b)}`, palette.ink, 1.65 * w, 'round')
  + stroke(`M${P(a)} L${P(b)}`, palette.paper, 1.15 * w, 'round'); // a dot's cross-section
const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" `
  + `height="${h * 10}" viewBox="0 0 ${w} ${h}"><clipPath id="frame"><rect width="${w}" `
  + `height="${h}"/></clipPath><g clip-path="url(#frame)">${body}</g></svg>`;
const LINES = ['line-1', 'line-2', 'line-3', 'line-4', 'line-5'];

function networkArt(W, H) {
  const w = 6.5, gap = 9.5, R = 3 * w; // line width, spacing and corner radius, in mm
  const trunk = walk([-20, 272], [['NE', 100], ['E', 72]]); // shared track from the lower left
  const offsets = LINES.map((_, i) => (2 - i) * gap); // line 1 on the left, line 5 on the right
  const branches = [[['E', 8], ['NE', 95], ['E', 60]], [['E', 34], ['NE', 38], ['E', 60]],
    [['E', 110]], [['E', 26], ['SE', 36], ['E', 60]], [['E', 4], ['SE', 44], ['S', 60]]];
  const river = walk([-10, 148], [['E', 40], ['SE', 52], ['S', 120]]);
  let out = `<rect width="${W}" height="${H}" fill="${palette.paper}"/>`
    + stroke(track(river, 28), palette.rule, 15);
  const lines = LINES.map((id, i) => {
    const own = shift(trunk, offsets[i]);
    const branch = walk(own[own.length - 1], branches[i]);
    const d = [...own.map(() => offsets[i]), ...branches[i].map(() => 0)];
    out += stroke(track([...own, ...branch.slice(1)], R, d), palette[id], w);
    return branch;
  });
  const across = (p, dir) => [add(p, left(dir), 2 * gap + 0.05 * w),
    add(p, left(dir), -2 * gap - 0.05 * w)];
  out += capsule(...across(along(trunk, 62), unit(HEADING.NE)), w)
    + capsule(...across(along(trunk, 158), HEADING.E), w);
  const stops = [[45, 80], [12, 90], [34, 68], [44, 82], [32, 88]]; // on straights only
  lines.forEach((branch, i) => { for (const s of stops[i]) out += station(along(branch, s), w); });
  return svg(W, H, out);
}

/** Three lines and their stops, drawn by the rules (good) or against every one of them. */
function gridArt(W, H, good) {
  const w = 3.6, gap = 5.4, R = 3 * w;
  const ids = ['line-1', 'line-4', 'line-5'];
  const dot = (p, id) => `<circle cx="${f(p[0])}" cy="${f(p[1])}" r="${f(0.75 * w)}" `
    + `fill="${palette[id]}"/>`;
  let out = `<rect width="${W}" height="${H}" fill="${palette.paper}"/>`;
  if (good) {
    const trunk = walk([-4, 11], [['E', 30], ['SE', 20]]);
    const lines = [['E', 70], ['E', 70], ['S', 40]].map((move, i) =>
      shift([...trunk, ...walk(trunk[2], [move]).slice(1)], (1 - i) * gap));
    lines.forEach((pts, i) => {
      const shared = (k) => k === 1 || (k === 2 && i < 2); // bends two or three lines share
      out += stroke(track(pts, R, pts.map((_, k) => (shared(k) ? (1 - i) * gap : 0))),
        palette[ids[i]], w);
    });
    const hub = along(trunk, 12); // the interchange, on the shared straight
    out += capsule(add(hub, [0, -gap - 0.05 * w]), add(hub, [0, gap + 0.05 * w]), w);
    for (const [i, s] of [[0, 72], [1, 84], [2, 52]]) out += station(along(lines[i], s), w);
  } else { // free angles, radii and spacing; stops as coloured dots, three dots for the hub
    const lines = [[[-4, 7], [22, 7], [44, 19], [90, 17]], [[-4, 15], [30, 14], [46, 31], [90, 31]],
      [[-4, 20], [24, 21], [40, 35], [45, 60]]];
    lines.forEach((pts, i) => { out += stroke(track(pts, [2, 15, 6][i]), palette[ids[i]], w); });
    lines.forEach((pts, i) => { out += dot(along(pts, 12), ids[i]); });
    for (const [i, s] of [[0, 72], [1, 84], [2, 58]]) out += dot(along(lines[i], s), ids[i]);
  }
  const frame = `<rect x="0.15" y="0.15" width="${W - 0.3}" height="${H - 0.3}" fill="none" `
    + `stroke="${palette.rule}" stroke-width="0.3"/>`; // a hairline in Rule grey
  return svg(W, H, out + frame);
}
ART['network.svg'] = networkArt(PAGE.width, PAGE.height);
ART['do-grid.svg'] = gridArt(COL, 43.5, true);
ART['dont-grid.svg'] = gridArt(COL, 43.5, false);
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// The brand's files and no others: no Bold (the SemiBold stands in), the display face in
// ExtraBold only. The PDF shows Fontsource's names ('PublicSansThin-SemiBold'); yours show theirs.
const FONTS = {
  'Public Sans': ['400', '400i', '600', '600i'],
  'Big Shoulders Display': ['800'],
  'Spline Sans Mono': ['400'],
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region build: the brand's faces first, then the pages, then a PDF from the same bytes
await loadBrandFonts(); // the answer, step 1 (gotcha: fonts-first)
for (const [fileId, markup] of Object.entries(ART)) await loadSvg(fileId, markup);
const doc = buildDocument({ markdown, resources }, config());
showPages(doc, { title: 'Metro de Alba · Identity manual' });
offerPdf(() => renderToPdf(doc, { fontProvider: brandFontProvider, resourceBytes: imageBytes }),
  `${RECIPE}.pdf`); // step 2: the provider hands renderToPdf the same files
// #endregion

// #region bundle: one .postext file with the text, the design, the drawings and the fonts
const pack = Object.assign(document.createElement('button'), { type: 'button',
  textContent: 'Build the .postext' });
pack.addEventListener('click', async () => {
  const { bytes, manifest, warnings } = await createBundle({
    name: 'Metro de Alba identity manual', locale: LANG, markdown, config: config(), resources,
    files: new Map([...Object.entries(ART), ...fontFiles]), // fileId → SVG markup or font bytes
  });
  const size = `${Math.round(bytes.length / 1024)} KB`;
  const packed = `fonts inside: ${manifest.fonts.map((font) => font.name).join(', ')}`;
  kitStatus(['.postext', size, packed, ...warnings].join(' · ')); // warnings: what stayed out
  pack.replaceWith(Object.assign(document.createElement('a'), { download: `${RECIPE}.postext`,
    href: URL.createObjectURL(new Blob([bytes], { type: 'application/zip' })),
    textContent: `Download ${RECIPE}.postext · ${size}` }));
});
document.getElementById('pt-actions').append(pack);
// #endregion

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

## アレンジ

### ディスプレイ書体も収める

ライセンスがファイルの再配布を認めているなら、フラグを外します。バンドルは3つのファミリーすべてを96 KBで運びます。

```diff
-  name, redistributable: name !== DISPLAY, // the display face reaches suppliers another way
+  name,
```

### 本物のBoldを使う

ブランドにBoldがないため、`boldFontWeight: 600`が太字の文字列、表の見出し、キャプションのラベルをSemiBoldで組んでいます。ファミリーにBoldがあるなら、そのファイルを`FONTS`に加えて設定を削除し、サンプルの見本行とそこにある「There is no Bold」を書き直してください。Boldを加えると、この一文は事実でなくなります。

```diff
-    boldFontWeight: 600, // the brand has no Bold: its SemiBold sets **emphasis**
-  'Public Sans': ['400', '400i', '600', '600i'],
+  'Public Sans': ['400', '400i', '600', '600i', '700', '700i'],
```

## よくあるつまずき

- **レイアウトの前にすべてのフォントを読み込む.** レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。
- **customFontsはフォントを読み込まない.** config.customFontsはフォントファイルをfileIdで名指しするだけです。buildDocumentとCanvas、HTML、PDFのレンダラーはこれを読まないため、ここに挙げても登録していないファミリーは代替フォントで計測され、描かれます。各フォントは最初のビルドの前に自分で登録し（バイト列からFontFaceを作る）、同じバイト列をPDFのフォントプロバイダーにも渡してください。この一覧を読むのは、ファイルを.postextに詰めるcreateBundleと、フォントを登録するSandboxです。
- **PDFはすべてのファミリーのすべてのウェイトとスタイルを要求する.** renderToPdfは、ブロックが使う可能性のあるすべてのファミリーについて、実際には印字されないものも含め、ボールド、イタリック、ボールドイタリックのフォントをフォントプロバイダーに求めます。1つでも拒否されると書き出しが止まります。プロバイダーは、そのファミリーが持つ最も近いウェイトを返し、イタリックがなければ立体にフォールバックする必要があります。
- **ファミリーにないボールドやイタリックは、画面では擬似的に作られ、PDFでは作られない.** ファミリーが持たないウェイトやスタイル（1書体しかないラベル用フォントでの太字の表ヘッダー、イタリックのないサンセリフでのイタリック）を求めると、ブラウザーはCanvasとHTMLでそれを合成し、同じ幅のまま立体を太らせたり傾けたりします。PDFは実在するフォントしか埋め込まないため、そこではプロバイダーが返す最も近いフォントで通常の形のまま印字されます。フォントの一覧にはファミリーが持つフォントだけを入れ、tableStyle.headerBold: falseのように、すべてのスタイルをそれに合わせてください。
- **Fontsourceのlatinファイルにはラテン文字以外のグリフがない.** PDFプロバイダーが埋め込むのはFontsourceのlatinファイルです。スペイン語や西欧語のテキストは収まりますが、→、≈、✓、★、ギリシャ文字、中欧語の文字は含まれず、PDFではそれらのグリフが欠けます。PDFのテキストはlatinの範囲に収めてください。
- **PDFは、要求したフォントごとにフォントのコピーを持つ.** postext-pdf 1.4.1は、フォントプロバイダーに求めたファミリー、ウェイト、スタイルごとにフォントプログラムを1つずつ書き込みます。プロバイダーがそのいくつかに同じファイルを返し、そのフォントで描くページが1つもなくても同じです。そのため、欠けたイタリックやウェイトの代役を立てるたびに、借りたファイルのコピーが1つ増え、そのフォントで組むテキストがなければ使われないまま残ります。pdffontsはページが使うフォントしか表示しませんが、コピーはファイル内に確かにあり、CodePenのサンプル側でそれを取り除く手段はありません。
- **パレットを差し替えても、デザイン要素と参照色は変わらない.** postext 1.4.1はcolorPaletteをテキストのスタイル（本文、見出し、リスト、キャプション、表、囲み）には反映しますが、ヘッダー、フッター、章扉、部扉の要素と、bodyText.referenceColorには反映しません。これらはpaletteIdの横に書いた16進の色のままです。画面用のダーク版や色替えのためにパレットを差し替えるときは、ビルドの前に、リンクしたすべての色をcolorPaletteから書き直してください。
- **デザインのテキストのlineHeightは倍率で、寸法ではない.** デザインのスロットでは、テキスト要素のlineHeightはフォントサイズに掛ける倍率です（lineHeight: 1.05）。postext 1.4.1ではpt(15)のような寸法を指定しても拒否されず、章扉の高さがNaNと計測されて、minHeightを含め確保する高さが警告なしに失われ、本文がタイトルに重なって組まれます。
- **デザインのテキストのoverflowの既定値は'ellipsis-end'.** 幅に収まらないデザインのテキスト要素は、既定では省略記号で終わります。複数行に折り返したいタイトルにはoverflow: 'wrap'を設定してください。
- **設定はオブジェクトの同一性でキャッシュされる。毎回新しいオブジェクトを作る.** エンジンは解決済みの設定をオブジェクトの同一性でキャッシュします。そのため、設定をその場で書き換えて再ビルドすると前の結果が再利用されます。ビルドのたびに新しいオブジェクトを作ってください。レシピの設定がファクトリー関数config()になっているのはこのためです。
- **フロントマターの値はすべて引用符で囲む.** YAMLはtitle: 1984を数値として、日付をDateオブジェクトとして読みます。文字列でない値はプレースホルダーに空で出力され、PDFにもタイトルが付きません。値はすべて引用符で囲んでください（title: "1984"）。
- **ノーブレークスペースでも改行される.** postext 1.4.1の改行処理はU+00A0を通常のスペースとして扱うため、0.08 %、2.006 s、Section 2などが2行に分かれることがあります。間を詰める（0.08%）か、文を書き換えてください。

`createBundle`には、`customFonts`が与える`fileId`をキーにして各フォントのバイト列を渡してください。バイト列のないバリアントは「missing file, skipped」という警告とともに除外され、すべてのバリアントを失ったファミリーはマニフェストから消えます。

## クレジット

- レシピ: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- テキスト: The Metro de Alba identity manual, a fictional network: Ignacio Ferro, CC-BY-4.0
- 画像: The network drawing on the cover and the two map examples, drawn in code: Ignacio Ferro, CC-BY-4.0
- 書体: Public Sans (OFL-1.1), Big Shoulders Display (OFL-1.1), Spline Sans Mono (OFL-1.1)
- コード: MIT · サンプルの内容: CC-BY-4.0

## 関連レシピ

- [No. 025 · 同じフォントを埋め込んだ本物のPDF](https://postext.dev/ja/cookbook/pdf-with-embedded-fonts.md): PDFに書き出すリサイタルのプログラム。各書体はFontFace用とPDF用を兼ねて1回だけ取得し、見出しはそれぞれしおりになります。 · 難易度 2 (中級) · 一枚物とエフェメラ
- [No. 026 · 組版前にすべてのフォントを読み込む書体見本](https://postext.dev/ja/cookbook/fonts-before-layout.md): 4ページの書体見本。1回目の組版でページが使う書体の名前がすべてそろい、サンプルはそれを読み込み、幅のキャッシュを消してから組み直します。 · 難易度 2 (中級) · カタログ
- [No. 019 · ひとつの属性で部ごとに色を変える](https://postext.dev/ja/cookbook/parts-in-colour.md): ポケット判の野外図鑑。:::partごとにパレットの1項目を設定し、部扉、色を敷いた偶数ページ、つめ見出し、識別点、目次の行をその色で刷ります。 · 難易度 3 (上級) · マニュアル・ガイド・リファレンス
