# ツメ付きのまち歩きガイド

> コースごとに見出しスタイルを1つ持つポケットガイド。そのパレットが、小口のツメ、立ち寄り地点の番号、囲みの地色、コース前の白の偶数ページの色を変えます。

- HTML版: https://postext.dev/ja/cookbook/walking-guide-thumb-tabs
- レシピ No. 047 · 柱とノンブル · 難易度 3 (上級) · 出力: Canvas
- ジャンル: マニュアル・ガイド・リファレンス
- 必要なもの postext ≥ 1.4.1 · テスト環境 1.4.1 ／テスト日 2026-09-26
- ページ: [1](https://postext.dev/cookbook/walking-guide-thumb-tabs/en/p01.webp?v=f51ab6de), [2](https://postext.dev/cookbook/walking-guide-thumb-tabs/en/p02.webp?v=f51ab6de), [3](https://postext.dev/cookbook/walking-guide-thumb-tabs/en/p03.webp?v=f51ab6de), [4](https://postext.dev/cookbook/walking-guide-thumb-tabs/en/p04.webp?v=f51ab6de), [5](https://postext.dev/cookbook/walking-guide-thumb-tabs/en/p05.webp?v=f51ab6de), [6](https://postext.dev/cookbook/walking-guide-thumb-tabs/en/p06.webp?v=f51ab6de), [7](https://postext.dev/cookbook/walking-guide-thumb-tabs/en/p07.webp?v=f51ab6de)
- Sandboxで開く: https://postext.dev/ja/sandbox#recipe=walking-guide-thumb-tabs&lang=en (.postext: https://postext.dev/cookbook/walking-guide-thumb-tabs/en/walking-guide-thumb-tabs.postext)
- 最終更新: 2026-09-26
- 他の言語: [en](https://postext.dev/en/cookbook/walking-guide-thumb-tabs.md), [es](https://postext.dev/es/cookbook/walking-guide-thumb-tabs.md), [ca](https://postext.dev/ca/cookbook/walking-guide-thumb-tabs.md), [zh](https://postext.dev/zh/cookbook/walking-guide-thumb-tabs.md), [ar](https://postext.dev/ar/cookbook/walking-guide-thumb-tabs.md)

## かんたんな説明

リスボン周辺を歩くポケットガイドです。コースごとに色があり、ページの端のツメにその色が出るので、親指でコースを探せます。

## できあがり

120 × 200 mmのポケットガイド『Lisbon on Foot』の最初の7ページです。表紙は青と白のアズレージョの壁で、タイトルはリスボンの街路表示板のような枠のパネルに載り、2ページ目ではコースの地図とともに3つのコースを紹介します。コースにはそれぞれ色があり、Alfamaはテラコッタ色、Baixaは黄色です。小口のツメがその色とコースの番号を、コースのどのページでも同じ高さに示します。章扉の帯、番号付きの立ち寄り地点、奇数ページの柱、「Don’t miss」の囲みの地色も、コースに合わせて変わります。コース2を奇数ページへ送る白の偶数ページは、コース2の黄色で塗り、その数字を印刷します。本文はAlbert Sansの左そろえ、名前はDM Serif Display、ラベルはAsap Condensedです。

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

- 章扉と白ページから柱を外し、白の偶数ページを節の色で塗るにはどうしますか？
- 柱を設定するには（左ページに書名、右ページに章タイトル、ノンブルは小口側）？
- 番号付きのタブ（「BOX 1-1」）、角のアイコン、罫線付きの余白のアイコンを加えるには？
- 本文、キャプション、表の注に色見本（凡例の色）を加えるには？

## 手短な答え

```js
// script.js, 行 61–82
// '# Alfama {style="alfama"}' opens a section that runs to the next level-1 heading. Its
// palette gives band, bandInk, onBand and wash the walk's values on the section's pages,
// in the page furniture and in the text alike.
const walkStyles = Object.keys(WALKS).map((id) => ({ id, palette: WALKS[id] }));
// The tab has one size and one place on every page, hanging from the foot of the opener
// band; only its colour and its number change from walk to walk.
const TAB = { y: BAND, width: 7, height: 24 }; // mm
const tab = (parity, edge) => text(`tab-${parity}`, '{chapterNumber}', {
  ...label, fontSize: pt(11), fontWeight: 700, color: col('onBand'), align: 'center',
  verticalAlign: 'middle', overflow: 'clip', box: { backgroundColor: col('band') },
  parity, pages: 'all', // the walk's first page is an 'opener' page: the tab goes there too
}, { ...at('bleed', edge, 0, TAB.y), ...size(mm(TAB.width), mm(TAB.height)) });
const tabs = [tab('odd', 'top-right'), tab('even', 'top-left')]; // on the fore-edge
// The blank verso that pushes a walk onto a recto belongs to that walk (gotcha:
// section-last-wins), so the field takes the next walk's colour and its numeral the number.
const blankPage = [
  box('field', { backgroundColor: col('band') },
    { ...at('bleed', 'top-left', 0, 0), ...size('fill', 'fill') }),
  tileImage('field-tiles', 'tiles-page', 'top-left', 'fill', 'auto'),
  text('field-number', '{chapterNumber}', { ...display, fontSize: pt(220), lineHeight: 1,
    color: col('onBand'), overflow: 'clip' }, at('page', 'bottom-left', MARGIN.outer, -24)),
].map((el) => ({ ...el, pages: 'blank' }));
```

## 材料

**学べること**

- [裁ち落としの帯とツメ](https://postext.dev/ja/docs/configuration.md#要素の配置): ページまたは裁ち落としを基準に、端から端まで置く色面です。章ごとに位置が下がっていく小口のツメ（爪見出し）も含みます。
- [部ごとの色](https://postext.dev/ja/docs/configuration.md#partコンテナー): 部や見出しスタイルがパレットの項目を上書きするので、帯、柱、本文のアクセントがその節の色になります。
- [ページの役割ごとの柱](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/configuration.md#チップスタイル)
- [囲みのアイコンと角のバッジ](https://postext.dev/ja/docs/configuration.md#囲みスタイル)
- [色見本](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/configuration.md#幅と詳細デザイン)
- [用紙の色](https://postext.dev/ja/docs/configuration.md#ページ)
- [改ページと改段](https://postext.dev/ja/docs/document-format.md#pagebreak)
- [段落スタイル](https://postext.dev/ja/docs/configuration.md#段落スタイル)
- [リソースとしての図と表](https://postext.dev/ja/docs/document-format.md#リソース)
- [タイトル内の改行](https://postext.dev/ja/docs/document-format.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#カラーパレット), [`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#リソースの種類)

**API**

- [`buildDocument`](https://postext.dev/ja/docs/configuration.md#文書のビルド), [`clearMeasurementCache`](https://postext.dev/ja/docs/configuration.md#計測キャッシュ), [`registerResourceImage`](https://postext.dev/ja/docs/architecture.md#apiの概要), [`renderPageToCanvas`](https://postext.dev/ja/docs/configuration.md#ページをビットマップに描画する)

**書体**

- Albert Sans (OFL-1.1), DM Serif Display (OFL-1.1), Asap Condensed (OFL-1.1)

## 作り方

### 1 · コースで変わる色に名前を付ける

```js
// script.js, 行 16–41
const palette = {
  ink: '#1b2430', // text: a blue-black
  paper: '#fffdf8', // the page
  band: '#1f4e9c', // the section colour: tile blue until a walk replaces it
  bandInk: '#1d4a94', // the section colour for type on paper
  onBand: '#ffffff', // type set on the band
  wash: '#e8eef8', // the section's box tint
  muted: '#5e6875', // running heads, notes, chip outlines
};
// Each walk's values for those four. Baixa yellow is too light for white type, so its
// onBand is the ink. The text is recoloured by value, so the four base values must differ.
const WALKS = {
  alfama: { band: '#b9533a', bandInk: '#a1432b', onBand: '#ffffff', wash: '#f7e7e0' },
  baixa: { band: '#e2b23d', bandInk: '#7d5a0f', onBand: '#1b2430', wash: '#fbf0d5' },
  belem: { band: '#2b7a78', bandInk: '#236866', onBand: '#ffffff', wash: '#e1eeed' },
};
const NAMES = { alfama: 'Alfama', baixa: 'Baixa', belem: 'Belém' };
// A walk's palette follows the paletteId; the hex is written out too, since the document
// palette never reaches design elements (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id] ?? WALKS[id].band, model: 'hex', paletteId: id });
const entry = (id, hex) => ({ id, name: id, value: { hex, model: 'hex' } });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => entry(id, hex)),
  ...Object.entries(WALKS).map(([id, w]) => entry(id, w.band)), // for :swatch{color="alfama"}
  entry('main-color', palette.ink), // the engine's defaults link here: nothing prints blue
];
```

見出しスタイルの`palette`は、その節のページでパレットの項目をidで置き換えます（[見出しスタイル](/ja/docs/configuration#見出しスタイル)）。そこで、コースに従うべき色はすべて4つの項目のどれかにリンクします。色面、ツメ、立ち寄り地点の丸には`band`、紙の上の文字には`bandInk`、色面の上の文字には`onBand`、囲みには`wash`です。Baixaの黄色の上の白はコントラスト比が2:1しかないので、そのコースの`onBand`はインクの色で、8:1になります。デザイン要素はidに従い、文字は値で色が変わります。項目の基本の16進値と同じ色はコースの値を取るので、4つの基本の値はすべて違う値にしてあります。4ページ目の囲みのタイトルと、5ページ目でコース2へ案内する行は`bandInk`の文字で、どちらもテラコッタ色で印刷されます。`col()`がidの横に16進値を書くのは、Postext 1.4.1では文書のパレットがデザイン要素に届かないからです。

### 2 · 柱を本文ページだけにする

```js
// script.js, 行 86–108
const head = (id, content, parity, placement, style = {}) => ({ ...text(id, content, {
  ...label, fontSize: pt(7.8), letterSpacing: pt(1.3), color: col('muted'), overflow: 'clip',
  ...style }, placement), parity, pages: 'body' }); // never on openers or blank pages
const folio = { fontSize: pt(8.5), fontWeight: 700, color: col('ink') };
// Anchored to the page: from its top-right corner a negative x runs inwards
// (gotcha: negative-offsets).
const heads = [
  head('verso-folio', '{pageNumber}', 'even', at('page', 'top-left', MARGIN.outer, HEAD.y), folio),
  head('verso-title', '{title}', 'even', at('page', 'top-left', MARGIN.outer + HEAD.gap, HEAD.y)),
  head('recto-title', `${t({ en: 'Walk', es: 'Paseo' })} {chapterNumber} · {chapterTitle}`, 'odd',
    at('page', 'top-right', -(MARGIN.outer + HEAD.gap), HEAD.y), { color: col('bandInk') }),
  head('recto-folio', '{pageNumber}', 'odd', at('page', 'top-right', -MARGIN.outer, HEAD.y), folio),
];
// Array order is paint order: the blank page's field comes last and covers the tab there.
const header = { elements: [...heads, ...tabs, ...blankPage] };
// A walk's first page has no running head: its folio drops to the foot.
const footer = { elements: [{ ...head('drop-folio', '{pageNumber}', 'all',
  at('container', 'top', 0, 7), folio), pages: 'opener', align: 'center' }] };
// Page 2 comes before Walk 1, where {chapterNumber} is empty, so its header has no tabs. With the
// level's break off (gotcha: style-inherits-break) and in the column, it is a 'body' page.
const introStyle = { id: 'intro', numbered: false, header: { elements: heads },
  span: 'column', breakBefore: { enabled: false }, advancedDesign: { enabled: false },
  fontSize: pt(24), lineHeight: pt(2 * LEAD), marginBottom: pt(LEAD / 2) };
```

レイアウトの後、各ページには役割が付きます（[テキスト要素](/ja/docs/configuration#テキスト要素)）。コースの最初のページは、見出しが改ページするので`opener`です。奇数ページの前の埋め草のページは`blank`、残りは`body`です。柱は`pages: 'body'`を取るので、3ページ目と7ページ目の章扉と白の6ページ目には付かず、下ノンブルは`'opener'`を取ります。偶数ページにはフロントマターの`{title}`、奇数ページには`bandInk`で`Walk {chapterNumber} · {chapterTitle}`を印刷し、5ページ目ではテラコッタ色になります。手短な答えのツメは`'all'`を取ります。白ページの色面は配列の中でその後にあり、配列の順番が描画の順番なので、6ページ目ではツメを覆います。序文の2ページ目はコース1より前にあるので、そこでは`{chapterNumber}`が空です。そのため序文のスタイルは専用の`header`を持ち、4つの柱だけでツメを持ちません。これがないと、2ページ目に空の青いツメが印刷されます。

### 3 · 各コースを帯の下で始め、立ち寄り地点に番号を付ける

```js
// script.js, 行 112–140
const walkLevel = { level: 1, breakBefore: { enabled: true, parity: 'odd' }, // a recto
  span: 'page', // kept in the column, the band would be cut at the top margin, 18 mm down
  // The band ends 30 mm into the text area; a floor of 8 grid lines, with nothing added
  // under it, starts the text 7.3 mm below the band.
  marginBottom: pt(0), advancedDesign: { enabled: true, minHeight: pt(8 * LEAD), slot: {
    elements: [
      box('band', { backgroundColor: col('band') },
        { ...at('bleed', 'top-left', 0, 0), ...size('fill', mm(BAND)) }),
      tileImage('tiles', 'tiles-band', 'top-right', 'auto', mm(BAND)),
      text('kicker', `${t({ en: 'Walk', es: 'Paseo' })} {chapterNumber}`, { ...label,
        fontSize: pt(9), letterSpacing: pt(2), color: col('onBand') },
      at('page', 'top-left', MARGIN.inner, 17)), // walks open on rectos: inner is left
      text('title', '{titleText}', { ...display, fontSize: pt(34), lineHeight: 1,
        color: col('onBand') }, { ...at('#kicker', 'below', 0, 1), ...size(mm(70), 'auto') }),
      text('route', '{attr.route}', { fontFamily: 'Albert Sans', italic: true, fontSize: pt(10),
        color: col('onBand') }, { ...at('#title', 'below', 0, 1.5), ...size(mm(70), 'auto') }),
    ] } } };
const STOP = 5.4; // mm: the diameter of a stop's circle
const stopLevel = { level: 3, numberingTemplate: '{3}', // restarts at every walk
  // A grid line above a stop and no margin under it, so the circle can sit 3.2 mm down its
  // two grid lines: about 8.3 mm of white above a stop and 2.1 under it, 6.4 between paragraphs.
  marginTop: pt(LEAD), marginBottom: pt(0), advancedDesign: { enabled: true, slot: { elements: [
    text('number', '{number}', { ...label, fontSize: pt(9), fontWeight: 700, align: 'center',
      verticalAlign: 'middle', color: col('onBand'), overflow: 'clip',
      box: { backgroundColor: col('band'), borderRadius: mm(STOP / 2) } },
    { ...at('container', 'top-left', 0, 3.2), ...size(mm(STOP), mm(STOP)) }),
    text('name', '{titleText}', { ...display, fontSize: pt(13.5), lineHeight: 1.1,
      color: col('ink') }, { ...at('#number', 'right-of', 2.4, 0.2), ...size('fill', 'auto') }),
  ] } } };
```

帯は裁ち落とし領域（ここでは仕上がり線と同じ）にアンカーし、版面の中へ30 mm入り込みます。これには`span: 'page'`が必要です。Postext 1.4.1では段の中にとどまるデザインは天の余白で切られ、帯が18 mm下から始まってしまうからです。デザインは帯で終わり、`minHeight`はグリッド8行分、37.3 mmを確保します。章扉は大きいほうの深さを取るので、3ページ目と7ページ目では本文が帯の7.3 mm下から始まります。立ち寄り地点はどれもレベル3見出しで、そのデザインが`{number}`を`band`で塗った直径5.4 mmの丸に入れます。レベル1見出しは下位のカウンターをリセットするので、テンプレート`{3}`はコースごとに1から数え直し、地図のピンも同じ番号を持ちます。立ち寄り地点の見出しは上にグリッド1行分の余白を持ち、下には持たず、丸は見出しが占めるグリッド2行の中で3.2 mm下に置かれます。これで丸の上に約8.3 mm、下に2.1 mmの白が残ります。段落の間隔は6.4 mmです。

### 4 · 囲みにタイルを掛け、チップはインクの色にする

```js
// script.js, 行 168–183
const calloutStyles = [{ id: 'dontmiss', title: t({ en: 'Don’t miss', es: 'No te lo pierdas' }),
  background: col('wash'), // one device: the walk's tint
  // The tile hangs half off the box's outer side, flush with its top: the left side on a verso.
  // With 6.5 mm of padding on both sides the title stays in line with the box's text there too.
  // The tile's colours are drawn into the SVG, so it stays tile blue in every walk.
  padding: { top: mm(3.4), right: mm(6.5), bottom: mm(3.4), left: mm(6.5) },
  icon: { kind: 'resource', resourceId: 'tile', size: mm(10), position: 'corner',
    cornerSide: 'outer' },
  titleStyle: { ...label, fontSize: pt(8.5), fontWeight: 700, letterSpacing: pt(1.5),
    color: col('bandInk') },
  body: { fontSize: pt(9), lineHeight: pt(12.4), color: col('ink') } }];
// A walk's palette does not reach chips: one linked to 'band' would stay tile blue in every
// walk, so the facts are ink on paper (gotcha: section-palette-skips-chips).
const chipStyles = [{ id: 'fact', background: col('paper'), borderColor: col('muted'),
  borderWidth: pt(0.6), borderRadius: mm(0.8), paddingX: mm(1.4), paddingY: mm(0.5),
  fontFamily: 'Asap Condensed', fontSize: em(0.92), color: col('ink'), bold: true }];
```

囲みには罫線も枠もなく、コースの地色と、角にコードで描いたタイルだけがあります（[囲みスタイル](/ja/docs/configuration#囲みスタイル)）。`cornerSide: 'outer'`はタイルを囲みの外側の辺に半分はみ出させ、上端にそろえて掛けます。偶数ページの4ページ目では、それはタイトルの始まる左側です。両側に6.5 mmのパディングがあるので、どちらのページでもタイトルはタイルを避けつつ、囲みの本文とそろいます。コースのパレットは地色とタイトルの色を変えますが、タイルは青のままです。その色はSVGに描き込まれているからです。Postext 1.4.1ではコースのパレットがチップに届きません。`band`にリンクすると、コースの情報はどちらのコースでもタイルの青のままになります。そこでチップは紙の上にインクの色で組み、`muted`の灰色の輪郭線を付けて、どのコースでも同じにしています。

### 5 · コースの色見本で地図の凡例を作る

```js
// script.js, 行 533–547
const map = svg('routes', MAP.width, MAP.height, { placement: { position: 'here' }, ...t({ en: {
  caption: 'The three walks. The numbered pins are the stops in the text.',
  note: ':swatch{color="alfama"} Walk 1, Alfama · :swatch{color="baixa"} Walk 2, Baixa · '
    + ':swatch{color="belem"} Walk 3, Belém, 6 km west (inset)',
  altText: 'Map of central Lisbon north of the river: the yellow route runs from the '
    + 'waterfront up the Baixa grid to Rossio; the terracotta route climbs east from the '
    + 'cathedral through Alfama. An inset shows the teal Belém route.',
}, es: {
  caption: 'Los tres paseos. Los pines numerados son las paradas del texto.',
  note: ':swatch{color="alfama"} Paseo 1, Alfama · :swatch{color="baixa"} Paseo 2, Baixa · '
    + ':swatch{color="belem"} Paseo 3, Belém, 6 km al oeste (en el detalle)',
  altText: 'Plano del centro de Lisboa al norte del río: la ruta amarilla sube desde el '
    + 'muelle por la cuadrícula de la Baixa hasta el Rossio; la terracota sube hacia el este '
    + 'desde la catedral por Alfama. En un detalle, la ruta verde azulada de Belém.',
} }) });
```

地図は`::resource{id="routes"}`の位置に置くSVGのリソースで、キャプションが「Map 1」となるように専用のリソースタイプを持ちます。注記は3つの色見本でコースを示します。`:swatch{color="alfama"}`と、`baixa`、`belem`の同様の指定です。色見本はパレットの項目のidを取ります（[インライン書式](/ja/docs/document-format#インライン書式)）。パレットは`WALKS`からコースごとに項目を1つ受け取り、地図もコースの色を同じオブジェクトから取るので、凡例はコースとツメに一致します。ピンの中の番号は線として描いています。画像として描くSVGはページのWebフォントを使えないからです。

## レシピの全体

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

- レシピのフォルダー: https://github.com/drnachio/postext/tree/main/cookbook/walking-guide-thumb-tabs

### script.js

```js
// ═══ Postext Cookbook · Nº 047 · Walking guide with thumb tabs ═══════════════════
// https://postext.dev/en/cookbook/walking-guide-thumb-tabs
// Code: MIT · Text: original (CC BY 4.0) · Map and tiles: drawn in code (CC BY 4.0)
// Fonts: Albert Sans, DM Serif Display, Asap Condensed (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// A pocket guide to Lisbon. Each walk is a heading style that sets four colours, and the tab,
// the stop numbers, the box's tint and title and the blank page before the walk take them.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'walking-guide-thumb-tabs';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: 'band' and three partners are the entries a walk overrides
const palette = {
  ink: '#1b2430', // text: a blue-black
  paper: '#fffdf8', // the page
  band: '#1f4e9c', // the section colour: tile blue until a walk replaces it
  bandInk: '#1d4a94', // the section colour for type on paper
  onBand: '#ffffff', // type set on the band
  wash: '#e8eef8', // the section's box tint
  muted: '#5e6875', // running heads, notes, chip outlines
};
// Each walk's values for those four. Baixa yellow is too light for white type, so its
// onBand is the ink. The text is recoloured by value, so the four base values must differ.
const WALKS = {
  alfama: { band: '#b9533a', bandInk: '#a1432b', onBand: '#ffffff', wash: '#f7e7e0' },
  baixa: { band: '#e2b23d', bandInk: '#7d5a0f', onBand: '#1b2430', wash: '#fbf0d5' },
  belem: { band: '#2b7a78', bandInk: '#236866', onBand: '#ffffff', wash: '#e1eeed' },
};
const NAMES = { alfama: 'Alfama', baixa: 'Baixa', belem: 'Belém' };
// A walk's palette follows the paletteId; the hex is written out too, since the document
// palette never reaches design elements (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id] ?? WALKS[id].band, model: 'hex', paletteId: id });
const entry = (id, hex) => ({ id, name: id, value: { hex, model: 'hex' } });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => entry(id, hex)),
  ...Object.entries(WALKS).map(([id, w]) => entry(id, w.band)), // for :swatch{color="alfama"}
  entry('main-color', palette.ink), // the engine's defaults link here: nothing prints blue
];
// #endregion
const TRIM = { width: 120, height: 200 }; // a pocket guide
// Mirrored, and tighter than a book's at top and foot: a pocket guide keeps 164 mm of text.
const MARGIN = { top: 18, bottom: 18, inner: 14, outer: 16 };
const MEASURE = TRIM.width - MARGIN.inner - MARGIN.outer; // 90 mm, about 55 characters
const LEAD = 13.2; // body leading in pt: the baseline grid
const BAND = 48; // mm: a walk opener's colour band, from the top edge
const HEAD = { y: 10, gap: 7 }; // running heads from the top edge; folio to title (mm)
const label = { fontFamily: 'Asap Condensed', fontWeight: 600, textTransform: 'uppercase' };
const display = { fontFamily: 'DM Serif Display', fontWeight: 400 };
const at = (to, edge, x, y) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const size = (width, height) => ({ size: { width, height } });
const text = (id, content, style, placement) => ({ kind: 'text', id, content, overflow: 'wrap',
  align: 'left', ...style, placement });
const box = (id, style, placement) => ({ kind: 'box', id, style, placement });
const tileImage = (id, resourceId, edge, width, height) => ({ kind: 'image', id, resourceId,
  placement: { ...at('bleed', edge, 0, 0), ...size(width, height) } });

// #region answer: one tab and one blank-page field, coloured by the walk they belong to
// '# Alfama {style="alfama"}' opens a section that runs to the next level-1 heading. Its
// palette gives band, bandInk, onBand and wash the walk's values on the section's pages,
// in the page furniture and in the text alike.
const walkStyles = Object.keys(WALKS).map((id) => ({ id, palette: WALKS[id] }));
// The tab has one size and one place on every page, hanging from the foot of the opener
// band; only its colour and its number change from walk to walk.
const TAB = { y: BAND, width: 7, height: 24 }; // mm
const tab = (parity, edge) => text(`tab-${parity}`, '{chapterNumber}', {
  ...label, fontSize: pt(11), fontWeight: 700, color: col('onBand'), align: 'center',
  verticalAlign: 'middle', overflow: 'clip', box: { backgroundColor: col('band') },
  parity, pages: 'all', // the walk's first page is an 'opener' page: the tab goes there too
}, { ...at('bleed', edge, 0, TAB.y), ...size(mm(TAB.width), mm(TAB.height)) });
const tabs = [tab('odd', 'top-right'), tab('even', 'top-left')]; // on the fore-edge
// The blank verso that pushes a walk onto a recto belongs to that walk (gotcha:
// section-last-wins), so the field takes the next walk's colour and its numeral the number.
const blankPage = [
  box('field', { backgroundColor: col('band') },
    { ...at('bleed', 'top-left', 0, 0), ...size('fill', 'fill') }),
  tileImage('field-tiles', 'tiles-page', 'top-left', 'fill', 'auto'),
  text('field-number', '{chapterNumber}', { ...display, fontSize: pt(220), lineHeight: 1,
    color: col('onBand'), overflow: 'clip' }, at('page', 'bottom-left', MARGIN.outer, -24)),
].map((el) => ({ ...el, pages: 'blank' }));
// #endregion

// #region running-heads: book title on the verso, walk on the recto, folios outside
const head = (id, content, parity, placement, style = {}) => ({ ...text(id, content, {
  ...label, fontSize: pt(7.8), letterSpacing: pt(1.3), color: col('muted'), overflow: 'clip',
  ...style }, placement), parity, pages: 'body' }); // never on openers or blank pages
const folio = { fontSize: pt(8.5), fontWeight: 700, color: col('ink') };
// Anchored to the page: from its top-right corner a negative x runs inwards
// (gotcha: negative-offsets).
const heads = [
  head('verso-folio', '{pageNumber}', 'even', at('page', 'top-left', MARGIN.outer, HEAD.y), folio),
  head('verso-title', '{title}', 'even', at('page', 'top-left', MARGIN.outer + HEAD.gap, HEAD.y)),
  head('recto-title', `${t({ en: 'Walk', es: 'Paseo' })} {chapterNumber} · {chapterTitle}`, 'odd',
    at('page', 'top-right', -(MARGIN.outer + HEAD.gap), HEAD.y), { color: col('bandInk') }),
  head('recto-folio', '{pageNumber}', 'odd', at('page', 'top-right', -MARGIN.outer, HEAD.y), folio),
];
// Array order is paint order: the blank page's field comes last and covers the tab there.
const header = { elements: [...heads, ...tabs, ...blankPage] };
// A walk's first page has no running head: its folio drops to the foot.
const footer = { elements: [{ ...head('drop-folio', '{pageNumber}', 'all',
  at('container', 'top', 0, 7), folio), pages: 'opener', align: 'center' }] };
// Page 2 comes before Walk 1, where {chapterNumber} is empty, so its header has no tabs. With the
// level's break off (gotcha: style-inherits-break) and in the column, it is a 'body' page.
const introStyle = { id: 'intro', numbered: false, header: { elements: heads },
  span: 'column', breakBefore: { enabled: false }, advancedDesign: { enabled: false },
  fontSize: pt(24), lineHeight: pt(2 * LEAD), marginBottom: pt(LEAD / 2) };
// #endregion

// #region levels: a walk opens under a band in its colour; its stops are numbered circles
const walkLevel = { level: 1, breakBefore: { enabled: true, parity: 'odd' }, // a recto
  span: 'page', // kept in the column, the band would be cut at the top margin, 18 mm down
  // The band ends 30 mm into the text area; a floor of 8 grid lines, with nothing added
  // under it, starts the text 7.3 mm below the band.
  marginBottom: pt(0), advancedDesign: { enabled: true, minHeight: pt(8 * LEAD), slot: {
    elements: [
      box('band', { backgroundColor: col('band') },
        { ...at('bleed', 'top-left', 0, 0), ...size('fill', mm(BAND)) }),
      tileImage('tiles', 'tiles-band', 'top-right', 'auto', mm(BAND)),
      text('kicker', `${t({ en: 'Walk', es: 'Paseo' })} {chapterNumber}`, { ...label,
        fontSize: pt(9), letterSpacing: pt(2), color: col('onBand') },
      at('page', 'top-left', MARGIN.inner, 17)), // walks open on rectos: inner is left
      text('title', '{titleText}', { ...display, fontSize: pt(34), lineHeight: 1,
        color: col('onBand') }, { ...at('#kicker', 'below', 0, 1), ...size(mm(70), 'auto') }),
      text('route', '{attr.route}', { fontFamily: 'Albert Sans', italic: true, fontSize: pt(10),
        color: col('onBand') }, { ...at('#title', 'below', 0, 1.5), ...size(mm(70), 'auto') }),
    ] } } };
const STOP = 5.4; // mm: the diameter of a stop's circle
const stopLevel = { level: 3, numberingTemplate: '{3}', // restarts at every walk
  // A grid line above a stop and no margin under it, so the circle can sit 3.2 mm down its
  // two grid lines: about 8.3 mm of white above a stop and 2.1 under it, 6.4 between paragraphs.
  marginTop: pt(LEAD), marginBottom: pt(0), advancedDesign: { enabled: true, slot: { elements: [
    text('number', '{number}', { ...label, fontSize: pt(9), fontWeight: 700, align: 'center',
      verticalAlign: 'middle', color: col('onBand'), overflow: 'clip',
      box: { backgroundColor: col('band'), borderRadius: mm(STOP / 2) } },
    { ...at('container', 'top-left', 0, 3.2), ...size(mm(STOP), mm(STOP)) }),
    text('name', '{titleText}', { ...display, fontSize: pt(13.5), lineHeight: 1.1,
      color: col('ink') }, { ...at('#number', 'right-of', 2.4, 0.2), ...size('fill', 'auto') }),
  ] } } };
// #endregion

// The cover: a wall of tiles, the title on a plaque like a Lisbon street sign, the walks' keys.
const plaque = size(mm(94), 'auto'); // the width of the texts on the plaque
const rim = (id, x, y, w, h, style) => box(id, { borderColor: col('band'), ...style },
  { ...at('page', 'top-left', x, y), ...size(mm(w), mm(h)) });
const cover = { enabled: true, slot: { elements: [
  tileImage('wall', 'tiles-cover', 'top-left', 'fill', 'auto'),
  rim('plaque', 13, 88, 94, 62, { backgroundColor: col('paper'), borderWidth: pt(1.6) }),
  rim('frame', 15.2, 90.2, 89.6, 57.6, { borderWidth: pt(0.5) }), // the inner frame line
  text('kicker', t({ en: 'A pocket guide', es: 'Guía de bolsillo' }), { ...label, align: 'center',
    fontSize: pt(8.5), letterSpacing: pt(2), color: col('bandInk') }, // centred text sits left
  { ...at('page', 'top-left', 13 + 0.35, 96), ...plaque }), // by half its tracking: 1 pt, 0.35 mm
  text('title', '{titleText}', { ...display, fontSize: pt(46), lineHeight: 0.98, align: 'center',
    color: col('band') }, { ...at('#kicker', 'below', 0, 2), ...plaque }),
  text('subtitle', '{subtitle}', { fontFamily: 'Albert Sans', italic: true, fontSize: pt(10.5),
    color: col('ink'), align: 'center' }, { ...at('#title', 'below', 0, 3), ...plaque }),
  ...Object.keys(WALKS).flatMap((id, i) => [text(`key-${id}`, String(i + 1), { ...label,
    fontSize: pt(10), fontWeight: 700, align: 'center', verticalAlign: 'middle', overflow: 'clip',
    color: col(WALKS[id].onBand === palette.ink ? 'ink' : 'onBand'),
    box: { backgroundColor: col(id) } }, { ...at('page', 'top-left', 17 + i * 30, 166),
    ...size(mm(7), mm(7)) }),
  text(`name-${id}`, NAMES[id], { ...display, fontSize: pt(13), color: col('ink') },
    at(`#key-${id}`, 'right-of', 2.2, 0.4))]),
] } };

// #region box-and-chips: a tile on the box's outer corner; chips that stay in ink
const calloutStyles = [{ id: 'dontmiss', title: t({ en: 'Don’t miss', es: 'No te lo pierdas' }),
  background: col('wash'), // one device: the walk's tint
  // The tile hangs half off the box's outer side, flush with its top: the left side on a verso.
  // With 6.5 mm of padding on both sides the title stays in line with the box's text there too.
  // The tile's colours are drawn into the SVG, so it stays tile blue in every walk.
  padding: { top: mm(3.4), right: mm(6.5), bottom: mm(3.4), left: mm(6.5) },
  icon: { kind: 'resource', resourceId: 'tile', size: mm(10), position: 'corner',
    cornerSide: 'outer' },
  titleStyle: { ...label, fontSize: pt(8.5), fontWeight: 700, letterSpacing: pt(1.5),
    color: col('bandInk') },
  body: { fontSize: pt(9), lineHeight: pt(12.4), color: col('ink') } }];
// A walk's palette does not reach chips: one linked to 'band' would stay tile blue in every
// walk, so the facts are ink on paper (gotcha: section-palette-skips-chips).
const chipStyles = [{ id: 'fact', background: col('paper'), borderColor: col('muted'),
  borderWidth: pt(0.6), borderRadius: mm(0.8), paddingX: mm(1.4), paddingY: mm(0.5),
  fontFamily: 'Asap Condensed', fontSize: em(0.92), color: col('ink'), bold: true }];
// #endregion

const MAP_WORD = t({ en: 'Map', es: 'Plano' }); // the caption reads 'Map 1'
const config = () => ({ // a factory: the engine caches resolved configs per object
  colorPalette,
  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' },
  // Ragged text for short lines of directions. It is never hyphenated (gotcha:
  // ragged-no-hyphenation) and never checked for runts (gotcha: ragged-runts).
  bodyText: { fontFamily: 'Albert Sans', fontSize: pt(9.4), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'left', firstLineIndent: mm(0), paragraphSpacing: true },
  // No lines added above the stops to fill a page: a guide's pages may end short.
  headings: { ...display, color: col('ink'), balancing: { enabled: false },
    // Any headings object drops the level-1 break: it is stated in walkLevel
    // (gotcha: headings-drop-h1-break).
    levels: [walkLevel, stopLevel] },
  headingStyles: [
    { id: 'cover', numbered: false, advancedDesign: cover,
      header: { elements: [] }, footer: { elements: [] } },
    introStyle, ...walkStyles,
  ],
  resourceTypes: [{ id: 'map', name: MAP_WORD, shortLabel: MAP_WORD, captionPrefix: MAP_WORD,
    numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }],
  captionStyle: { fontFamily: 'Asap Condensed', fontSize: pt(8.6), color: col('ink'),
    labelColor: col('bandInk'), gap: mm(2),
    note: { fontSize: pt(8), color: col('muted') } },
  calloutStyles,
  chipStyles,
  paragraphStyles: [{ id: 'colophon', fontFamily: 'Asap Condensed', fontSize: pt(7.8),
    lineHeight: pt(10.4), color: col('muted') }, // then the line that ends a walk, in its colour
  { id: 'onward', fontFamily: 'Asap Condensed', fontSize: pt(10), color: col('bandInk'),
    boldColor: col('bandInk'), marginTop: pt(2 * LEAD) }],
  header,
  footer,
});

// #region art: the tiles, the map and its pins, drawn in code and seeded: every run the same
let seed = 1755; // Mulberry32, a tiny seeded PRNG: never Math.random() in a recipe
const rand = () => {
  let r = Math.imul((seed = (seed + 0x6d2b79f5) | 0) ^ (seed >>> 15), 1 | seed);
  r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
  return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
const n = (v) => v.toFixed(2);
const sheet = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" `
  + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
const fill = (c, o = 1) => `fill="${c}"${o < 1 ? ` fill-opacity="${n(o)}"` : ''}`;
const line = (c, w, o = 1) => `fill="none" stroke="${c}" stroke-width="${n(w)}" `
  + `stroke-linecap="round" stroke-linejoin="round"${o < 1 ? ` stroke-opacity="${n(o)}"` : ''}`;
const shape = (pts, paint) => `<path d="M${pts.map(([x, y]) => `${n(x)} ${n(y)}`).join(' L')} Z" `
  + `${paint}/>`;
const put = (x, y, turn, body) => `<g transform="translate(${n(x)} ${n(y)}) rotate(${turn})">`
  + `${body}</g>`;
// A petal pointing up from the origin: `len` long, `wid` at its widest.
const petal = (len, wid) => `M0 0C${n(wid)} ${n(-len * 0.3)} ${n(wid * 0.7)} ${n(-len * 0.8)} `
  + `0 ${n(-len)}C${n(-wid * 0.7)} ${n(-len * 0.8)} ${n(-wid)} ${n(-len * 0.3)} 0 0Z`;
const flower = (x, y, [axis, axisW, diag = 0, diagW = 0], paint) => [0, 45, 90, 135, 180, 225,
  270, 315].filter((turn) => diag || turn % 90 === 0).map((turn) => put(x, y, turn,
  `<path d="${turn % 90 ? petal(diag, diagW) : petal(axis, axisW)}" ${paint}/>`)).join('');

// A pattern of s-mm tiles, as on a Lisbon façade: a circle round every corner where four
// tiles meet, and a flower in the diamond each tile keeps between the circles. `glaze`
// paints the tiles; without it only the motif is drawn, to lay over a colour.
function tiles(w, h, s, ink, { glaze, tint, o = 1, x0 = 0, y0 = 0 } = {}) {
  const [cols, rows] = [Math.ceil((w - x0) / s), Math.ceil((h - y0) / s)];
  let [back, front] = [glaze ? `<rect width="${w}" height="${h}" ${fill(glaze)}/>` : '', ''];
  for (let r = -1; r <= rows; r++) {
    for (let c = -1; c <= cols; c++) {
      const [x, y] = [x0 + c * s, y0 + r * s];
      back += `<circle cx="${n(x)}" cy="${n(y)}" r="${n(s * 0.47)}" `
        + `${fill(tint ?? ink, glaze ? 1 : o * 0.35)}/>`;
      front += `<circle cx="${n(x)}" cy="${n(y)}" r="${n(s * 0.47)}" ${line(ink, s * 0.035, o)}/>`
        + (glaze ? flower(x, y, [s * 0.2, s * 0.06], fill(ink, o)) : '') // glazed tiles only
        + flower(x + s / 2, y + s / 2, [s * 0.36, s * 0.075, s * 0.17, s * 0.05], fill(ink, o))
        + `<circle cx="${n(x + s / 2)}" cy="${n(y + s / 2)}" r="${n(s * 0.06)}" `
        + `${fill(glaze ?? ink, o)}/>`;
    }
  }
  return back + front;
}
const TILE = 15; // mm: one tile on the cover and on the bands
const BLUE = { tint: '#dde6f3', glaze: '#f8f5ee' };
function coverTiles() {
  const [w, h] = [TRIM.width, 112];
  let g = tiles(w, h, TILE, palette.band, { ...BLUE, y0: -4 });
  // The joints, through the flowers where four tiles meet.
  for (let k = 1; k < 8; k++) {
    g += `<path d="M${k * TILE} 0V${h}M0 ${k * TILE - 4}H${w}" ${line('#a9a292', 0.2, 0.6)}/>`;
  }
  // At the fore-edge one tile is missing and the plaster shows, in the shadow of the tiles
  // round it; the tile beside it has lost a jagged strip along their joint.
  const [x, y] = [7 * TILE, TILE - 4];
  const edge = [[x, y], [x - 2.6, y + 1.8], [x - 1.4, y + 4.6], [x - 3.4, y + 7.4],
    [x - 1.8, y + 10.2], [x - 2.4, y + 12.6], [x, y + 13.4], [x, y + TILE]];
  const hole = (d) => [[x + TILE, y + d], ...edge.map(([a, b]) => [a + d, Math.max(b, y + d)]),
    [x + TILE, y + TILE]];
  g += shape(hole(0), fill('#a89c83')) + shape(hole(0.8), fill('#d6ccb8'));
  for (let i = 0; i < 16; i++) {
    g += `<circle cx="${n(x - 2 + rand() * (TILE + 2))}" cy="${n(y + rand() * TILE)}" `
      + `r="${n(0.12 + rand() * 0.3)}" ${fill('#b3a78f', 0.8)}/>`;
  }
  return sheet(w, h, g);
}
// The white motif laid over a walk's band, and over the page before a walk.
const bandTiles = () => sheet(3 * TILE, BAND,
  tiles(3 * TILE, BAND, TILE, '#ffffff', { o: 0.3, y0: BAND - 3 * TILE }));
const pageTiles = () => sheet(TRIM.width, TRIM.height,
  tiles(TRIM.width, TRIM.height, TILE, '#ffffff', { o: 0.2, y0: BAND - 3 * TILE }));
// The corner badge of the 'Don't miss' box: one tile, framed.
const tileIcon = () => sheet(TILE, TILE, tiles(TILE, TILE, TILE, palette.band, BLUE)
  + `<rect x="0.35" y="0.35" width="${TILE - 0.7}" height="${TILE - 0.7}" `
  + `${line(palette.band, 0.7)}/>`);

// Digits drawn as strokes in a 6 × 10 box: an SVG drawn as an image cannot use the page's
// web fonts (gotcha: svg-no-webfonts).
const GLYPH = {
  0: 'M3 0C0.6 0 0 2.6 0 5S0.6 10 3 10 6 7.4 6 5 5.4 0 3 0Z',
  1: 'M1.2 2.2L3.6 0V10',
  2: 'M0.5 2.4C0.8 0.7 2 0 3.2 0C4.8 0 5.8 1.1 5.8 2.7C5.8 4.8 3.8 6 0.4 10H6',
  3: 'M0.5 1.3C1.2 0.4 2.2 0 3.2 0C4.8 0 5.8 1 5.8 2.5C5.8 4 4.5 4.8 2.6 4.8C4.6 4.8 6 5.8 6 7.4'
    + 'C6 9.1 4.8 10 3 10C1.9 10 0.9 9.6 0.2 8.6',
  4: 'M4.4 10V0L0 7H6.2',
  5: 'M5.6 0H1.2L0.7 4.6C1.4 4 2.3 3.8 3.1 3.8C4.9 3.8 6 5 6 6.9S4.8 10 3 10C1.9 10 0.9 9.6'
    + ' 0.3 8.8',
  N: 'M0 10V0L6 10V0',
  B: 'M0 10V0H3.4C4.9 0 5.6 1 5.6 2.4S4.9 4.8 3.4 4.8H0M3.4 4.8C5.1 4.8 6 5.8 6 7.4'
    + 'S5.1 10 3.4 10H0',
  E: 'M5.6 0H0V10H5.8M0 4.8H4.4',
  'É': 'M5.6 0H0V10H5.8M0 4.8H4.4M2.4 -1.6L3.8 -3.2',
  L: 'M0 0V10H5.6',
  M: 'M0 10V0L3 6.4L6 0V10',
  m: 'M0 10V4.6M0 5.6C0 4.6 0.8 4 1.6 4S3 4.6 3 5.6V10M3 5.6C3 4.6 3.7 4 4.5 4S6 4.6 6 5.6V10',
  ' ': '',
};
const glyphs = (str, x, y, h, c, w) => [...str].map((ch, i) => `<path transform="translate(`
  + `${n(x + i * h * 0.8)} ${n(y)}) scale(${n(h / 10)})" d="${GLYPH[ch]}" `
  + `${line(c, w / (h / 10))}/>`).join('');
// A pin: a disc in the walk's colour with its stop number, on a paper ring.
const pin = ([x, y], num, w) => `<circle cx="${n(x)}" cy="${n(y)}" r="2.4" ${fill(palette.paper)}/>`
  + `<circle cx="${n(x)}" cy="${n(y)}" r="2" ${fill(w.band)}/>`
  + glyphs(String(num), x - 0.95, y - 1.3, 2.6, w.onBand, 0.42);
const route = (pts, w) => {
  const d = `M${pts.map(([x, y]) => `${n(x)} ${n(y)}`).join(' L')}`;
  return `<path d="${d}" ${line(palette.paper, 1.9)}/><path d="${d}" ${line(w.band, 1.05)}/>`;
};

// Central Lisbon, north up, at 1:20 000 (1 km = 50 mm): the Baixa's grid on the line of
// Rua Augusta, Alfama's lanes round the castle hill, the river. Drawn on a 60 mm sheet
// and cropped to 56 mm, 3 mm off the top. The routes measure 0.77 km and 1.38 km.
const MAP = { width: MEASURE, height: 56 };
const SHORE = [[0, 57], [12, 56.5], [22, 55], [32, 51.5], [44, 46.5], [54, 41.5], [64, 35.5],
  [74, 29], [84, 23], [MAP.width, 20]];
const shoreY = (x) => {
  const i = Math.max(1, SHORE.findIndex(([sx]) => sx >= x));
  const [[x0, y0], [x1, y1]] = [SHORE[i - 1], SHORE[i]];
  return y0 + ((x - x0) / (x1 - x0)) * (y1 - y0);
};
// The Baixa frame: u runs up Rua Augusta from its arch, v runs east across it.
const ARCH = [21.7, 43.8];
const baixa = (u, v) => [ARCH[0] - 0.292 * u + 0.955 * v, ARCH[1] - 0.955 * u - 0.292 * v];
const inBaixa = ([x, y]) => {
  const [dx, dy] = [x - ARCH[0], y - ARCH[1]];
  const [u, v] = [-0.292 * dx - 0.955 * dy, 0.955 * dx - 0.292 * dy];
  return u > -10.5 && u < 44 && Math.abs(v) < 10.2;
};
const STOPS = {
  baixa: [baixa(-4.8, 0), baixa(0.9, 0), baixa(32.2, -2.75)],
  alfama: [[35.2, 36.6], [49.4, 27.8], [51.6, 23], [65.6, 6.4], [78.6, 11.8]],
  belem: [[76.5, 44.2], [78.6, 53], [59.8, 54.9]],
};
function mapArt() {
  const [w, h] = [MAP.width, 60];
  const [block, water, green] = ['#e5dccb', '#d4e1ee', '#d3dcc3'];
  const paint = fill(block);
  let g = `<rect width="${w}" height="${h}" ${fill(palette.paper)}/>`
    + shape([...SHORE, [w, h], [0, h]], fill(water));
  for (let i = 0; i < 44; i++) { // ripples on the river
    const x = rand() * w;
    const y = shoreY(x) + 1.6 + rand() * (h - shoreY(x));
    g += `<path d="M${n(x)} ${n(y)} q0.9 -0.45 1.8 0" ${line('#b3c7da', 0.22)}/>`;
  }
  // The Baixa: blocks between parallel streets, Rua Augusta (v = 0) up the middle.
  for (const v0 of [-9.15, -5.95, -2.75, 0.45, 3.65, 6.85]) {
    for (let u0 = 2.2; u0 < 44; u0 += 2.7) {
      if (u0 > 25.5 && u0 < 37.5 && v0 > -6 && v0 < 0) continue; // Rossio
      g += shape([baixa(u0, v0), baixa(u0, v0 + 2.3), baixa(u0 + 2, v0 + 2.3),
        baixa(u0 + 2, v0)], paint);
    }
  }
  // Praça do Comércio: arcades on three sides, the river on the fourth.
  for (const [u0, u1, v0, v1] of [[-8.8, 0.3, -8.4, -6.3], [-8.8, 0.3, 6.3, 8.4],
    [-0.4, 1.5, -8.4, -0.55], [-0.4, 1.5, 0.55, 8.4]]) {
    g += shape([baixa(u0, v0), baixa(u0, v1), baixa(u1, v1), baixa(u1, v0)], paint);
  }
  // Everywhere else, the older town: small, uneven blocks.
  const castle = [[30, 12], [34, 9.5], [40, 11], [41, 16], [36, 18.5], [30.5, 17]];
  for (let y = 1; y < h; y += 2.9) {
    for (let x = 0.5; x < w; x += 3) {
      const [bw, bh] = [1.3 + rand() * 1.3, 1.2 + rand() * 1.2];
      const [cx, cy] = [x + rand() * 0.6, y + rand() * 0.6];
      if (cy + bh + 0.8 > shoreY(cx + bw / 2) || inBaixa([cx + bw / 2, cy + bh / 2])) continue;
      if (Math.hypot(cx - 35.5, cy - 14) < 7) continue; // the castle's hill
      const j = () => (rand() - 0.5) * 0.5;
      g += shape([[cx + j(), cy + j()], [cx + bw + j(), cy + j()], [cx + bw + j(), cy + bh + j()],
        [cx + j(), cy + bh + j()]], paint);
    }
  }
  // São Jorge castle: its walls and towers among the pines.
  for (let i = 0; i < 26; i++) {
    const [a, d] = [rand() * Math.PI * 2, 5 + rand() * 1.8];
    g += `<circle cx="${n(35.5 + Math.cos(a) * d)}" cy="${n(14 + Math.sin(a) * d * 0.8)}" `
      + `r="${n(0.5 + rand() * 0.4)}" ${fill(green)}/>`;
  }
  g += shape(castle, `${fill('#ddd3bf')} stroke="#a89c83" stroke-width="0.35"`)
    + castle.map(([x, y]) => `<rect x="${n(x - 0.6)}" y="${n(y - 0.6)}" width="1.2" height="1.2" `
      + `${fill('#a89c83')}/>`).join('');
  // The two walks in town.
  const [a, b, c] = [WALKS.alfama, WALKS.baixa, WALKS.belem];
  const [se, luzia, sol, vicente, pantheon] = STOPS.alfama;
  g += route([STOPS.baixa[0], baixa(0, 0), baixa(27, 0), baixa(29, -2.75), STOPS.baixa[2]], b)
    + route([se, [38.4, 37.8], [41.4, 36.6], [42.4, 33.4], [45.2, 32.8], [46.6, 30.2],
      [48.8, 30.4], luzia, sol, [55.2, 21.8], [55.8, 18.4], [59.6, 17.4], [61.6, 15.4],
      [60.6, 12.6], [62.8, 11.6], [63.4, 8.6], vicente, [69.8, 4.8], [74.2, 5.4], [77.2, 7.4],
      [77.4, 9.2], pantheon], a)
    + STOPS.baixa.map((p, i) => pin(p, i + 1, b)).join('')
    + STOPS.alfama.map((p, i) => pin(p, i + 1, a)).join('');
  // Belém, 6 km west, in an inset on the river at 1:45 000: the monastery, its gardens, the
  // monument on the waterfront and the tower in the water.
  const inset = 'x="57" y="40.5" width="31" height="18"';
  g += `<rect ${inset} ${fill(palette.paper)}/>`
    + `<rect x="57" y="53.2" width="31" height="5.3" ${fill(water)}/>`
    + `<rect x="71.5" y="42.4" width="12.5" height="3.2" ${fill(block)}/>`
    + `<rect x="74" y="47" width="8.5" height="3.6" ${fill(green)}/>`
    + `<path d="M57 51.6 H88" fill="none" stroke="#ffffff" stroke-width="0.8"/>` // square ends
    + `<path d="M59 55.6 L59.8 53.2" ${line(block, 0.6)}/><rect x="58.3" y="55" width="1.6" `
    + `height="1.6" ${fill(block)}/>`
    + glyphs('BELÉM', 59, 42.9, 2.2, palette.ink, 0.3)
    + route([STOPS.belem[0], [77.6, 48.8], STOPS.belem[1], [70, 52.5], [63, 52.7],
      STOPS.belem[2]], c)
    + STOPS.belem.map((p, i) => pin(p, i + 1, c)).join('')
    // The frame goes on last, over the road and the river that run to its edges.
    + `<rect ${inset} fill="none" stroke="${palette.muted}" stroke-width="0.35"/>`;
  // North, and a scale bar of 200 m.
  g += `<path d="M5 4 L6.4 9 L5 8.2 L3.6 9 Z" ${fill(palette.ink)}/>`
    + glyphs('N', 3.95, 10.2, 2.2, palette.ink, 0.3)
    + `<path d="M3 56.8 V57.8 H13 V56.8" ${line(palette.ink, 0.3)}/>`
    + glyphs('200 m', 14.4, 56, 2, palette.ink, 0.28);
  return sheet(w, MAP.height, `<g transform="translate(0 -3)">${g}</g>`);
}
const drawings = () => ({
  'tiles-cover': coverTiles(), 'tiles-band': bandTiles(), 'tiles-page': pageTiles(),
  tile: tileIcon(), routes: mapArt(),
});
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Lisbon on Foot"
subtitle: "Three walks through the old city"
author: "Postext Cookbook"
---

# Lisbon \\ on Foot {style="cover"}

:::pagebreak

# Before you set out {style="intro"}

Lisbon climbs from the Tagus over seven steep hills, and in Alfama many of the streets are flights of steps. The three walks in this guide keep the climbs short. Each starts at a tram or metro stop, takes between 45 minutes and two hours with time to look around, and passes only public places. Wear shoes with some grip: the limestone paving is worn smooth and turns slippery after rain.

Each walk has a colour, and the tab on the edge of its pages is printed in it: terracotta for Alfama, yellow for the Baixa, teal for Belém.

::resource{id="routes"}

:::paragraphs{style="colophon"}
Set in Albert Sans, DM Serif Display and Asap Condensed (SIL Open Font License).

Text, map and tiles: CC BY 4.0.
:::

# Alfama {style="alfama" route="From the cathedral to the Pantheon"}

:chip[1.4 km]{style="fact"} :chip[1 h 30]{style="fact"} :chip[Uphill start]{style="fact"} :chip[Tram 28]{style="fact"}

Alfama is the oldest quarter of Lisbon and the one that came through the earthquake of 1755 best, so its lanes still follow the medieval plan. The walk climbs from the cathedral to two viewpoints, then follows the ridge east to São Vicente and the dome of the Pantheon. The steepest part is the first 400 metres.

### Sé de Lisboa

Work on the cathedral began in 1147, the year Afonso Henriques took the city from the Moors, on the site of the main mosque. With its two squat towers and battlements it looks as much like a fort as a church. Earthquakes in 1344 and 1755 brought down parts of it, and a restoration in the twentieth century removed later additions to bring back the Romanesque front you see today.

Stand across the street to take in the rose window above the door, then watch for tram 28 grinding round the corner of Rua Augusto Rosa.

### Miradouro de Santa Luzia

Follow the tram tracks uphill for about 400 metres. The terrace beside the small church of Santa Luzia looks over the roofs of Alfama to the river, with the dome of the Pantheon and the towers of São Vicente on the ridge to the left. A pergola shades the benches, and in summer it is covered in bougainvillea.

:::callout{type="dontmiss"}
The two tile panels on the south wall of the church. One shows Praça do Comércio before 1755, with the royal palace on the waterfront; the other shows Christian soldiers storming the castle in 1147. Walk 2 starts in the square the first panel shows.
:::

### Largo das Portas do Sol

A few steps on, this square takes its name from the Sun Gate, one of the gates in the Moorish city wall. The statue is São Vicente, patron saint of Lisbon, holding a boat with two ravens. The story goes that ravens kept watch over his body when it was brought to the city by sea in 1173; the boat and the birds are on Lisbon’s coat of arms to this day.

The view here is wider than at Santa Luzia: the whole of Alfama drops away below you to the docks, and on a clear day you can see the far shore of the estuary.

### São Vicente de Fora

Follow the tram tracks east along the ridge for about 500 metres. The white church ahead is Saint Vincent Outside the Walls, named for a monastery founded here in the twelfth century beyond the city wall. The building you see went up between 1582 and 1629.

Its twin towers frame a plain front. The cloisters inside are lined with eighteenth-century azulejos, among them a series of panels illustrating the fables of La Fontaine, and the monastery holds the tombs of the Braganza kings, who ruled Portugal from 1640 to 1910.

### Panteão Nacional

Walk down through Campo de Santa Clara, where the Feira da Ladra flea market fills the square on Tuesdays and Saturdays, to the domed church of Santa Engrácia. Building began in 1682 and the dome was finished only in 1966, which is why the Portuguese still call a job that never ends *obras de Santa Engrácia*. Presidents, writers and the fado singer Amália Rodrigues are buried here.

Stairs inside the church climb to a terrace that runs right round the dome, with the river on one side and the roofs of Alfama on the other.

From the Pantheon, Santa Apolónia station is five minutes downhill.

:::paragraphs{style="onward"}
**Walk 2 · Baixa.** It starts at Praça do Comércio, a little over a kilometre west along the river.
:::

# Baixa {style="baixa" route="From the river to Rossio"}

:chip[0.8 km]{style="fact"} :chip[45 min]{style="fact"} :chip[Flat]{style="fact"} :chip[Metro Terreiro do Paço]{style="fact"}

An earthquake, a tsunami and five days of fire destroyed the lower town on 1 November 1755. The Marquis of Pombal rebuilt it on a grid of straight streets, in houses framed with timber against the next tremor.

### Praça do Comércio

Until 1755 this was Terreiro do Paço, the yard of the royal palace, and many Lisboetas still call it that. Arcades run round three sides; on the fourth, the marble steps of Cais das Colunas go down into the river.

### Arco da Rua Augusta

The arch on the north side was finished in 1873: Glory crowns Genius and Valour at the top, over statues of Vasco da Gama and the Marquis of Pombal. Beyond it, Rua Augusta runs north between Rua do Ouro and Rua da Prata, named for the goldsmiths and the silversmiths.

### Rossio

Rua Augusta ends in Praça Dom Pedro IV, known to everyone as Rossio. The black and white waves of its paving were laid in the 1840s.
`; // content.<lang>.md, inlined by the Cookbook
// Every drawing is an SVG resource, sized in mm at 10 px per mm (as sheet() draws them).
const svg = (id, w, h, more) => ({ id, typeId: 'map', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, ...more });
// #region map: the map in the text, keyed by swatches of the walks' colours in its note
const map = svg('routes', MAP.width, MAP.height, { placement: { position: 'here' }, ...t({ en: {
  caption: 'The three walks. The numbered pins are the stops in the text.',
  note: ':swatch{color="alfama"} Walk 1, Alfama · :swatch{color="baixa"} Walk 2, Baixa · '
    + ':swatch{color="belem"} Walk 3, Belém, 6 km west (inset)',
  altText: 'Map of central Lisbon north of the river: the yellow route runs from the '
    + 'waterfront up the Baixa grid to Rossio; the terracotta route climbs east from the '
    + 'cathedral through Alfama. An inset shows the teal Belém route.',
}, es: {
  caption: 'Los tres paseos. Los pines numerados son las paradas del texto.',
  note: ':swatch{color="alfama"} Paseo 1, Alfama · :swatch{color="baixa"} Paseo 2, Baixa · '
    + ':swatch{color="belem"} Paseo 3, Belém, 6 km al oeste (en el detalle)',
  altText: 'Plano del centro de Lisboa al norte del río: la ruta amarilla sube desde el '
    + 'muelle por la cuadrícula de la Baixa hasta el Rossio; la terracota sube hacia el este '
    + 'desde la catedral por Alfama. En un detalle, la ruta verde azulada de Belém.',
} }) });
// #endregion
// The other drawings are used by the designs, never placed in the text.
const resources = [map, svg('tiles-cover', TRIM.width, 112), svg('tiles-band', 3 * TILE, BAND),
  svg('tiles-page', TRIM.width, TRIM.height), svg('tile', TILE, TILE)];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the design uses, loaded before the first build (gotcha: fonts-first).
const FONTS = {
  'Albert Sans': ['400', '400i'], // text, the route under a walk's name
  'DM Serif Display': ['400'], // walk names, stops, the cover
  'Asap Condensed': ['600', '700'], // labels, chips, tabs, folios, captions
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
for (const [id, art] of Object.entries(drawings())) await loadSvg(`${id}.svg`, art);
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: t({ en: 'Lisbon on Foot', es: 'Lisboa a pie' }) });

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

## アレンジ

### 白の偶数ページを塗らずに残す

色面がなければ、6ページ目にはツメが残ります。`pages: 'all'`は白ページにもツメを印刷し、コース2の黄色に2が入ります。

```diff
-const header = { elements: [...heads, ...tabs, ...blankPage] };
+const header = { elements: [...heads, ...tabs] };
```

### 3つ目のコースを足す

`WALKS`にはすでにBelémの色が、`walkStyles`にはそのスタイルがあるので、本文にコースの見出しを足すだけで済みます。コースは、ティール色に塗られ白い3が入った白の8ページ目の後、9ページ目で始まります。

```diff
+# Belém {style="belem" route="From the monastery to the tower"}
+
+Tram 15 runs from Praça da Figueira to Belém along the river.
```

## よくあるつまずき

- **ページのヘッダーは、そのページで始まる最後の節のものになる.** 1つのページで2つの見出しスタイルが始まると、そのページのヘッダー、フッター、パレットは後のほうのものになります。区切りの白ページは前の章に、奇数・偶数合わせの白ページは後の章に属します。
- **節や部のパレットはチップの色を変えない.** postext 1.4.1では、見出しスタイルや部のパレットは、そのページのテキスト、囲み、デザイン要素の色を置き換えますが、チップはchipStylesの項目の色のままです。節が上書きする項目にリンクしたチップは、どのページでも文書の値で表示されます。チップにはどの節も変えない色（紙の上の墨など）を使うか、節ごとに専用のチップスタイルを与えてください。
- **パレットを差し替えても、デザイン要素と参照色は変わらない.** postext 1.4.1はcolorPaletteをテキストのスタイル（本文、見出し、リスト、キャプション、表、囲み）には反映しますが、ヘッダー、フッター、章扉、部扉の要素と、bodyText.referenceColorには反映しません。これらはpaletteIdの横に書いた16進の色のままです。画面用のダーク版や色替えのためにパレットを差し替えるときは、ビルドの前に、リンクしたすべての色をcolorPaletteから書き直してください。
- **見出しスタイルはその階層の改ページを受け継ぐ.** headingStylesの項目は、指定しなかったフィールドをすべて見出しの階層から受け継ぎ、breakBeforeも例外ではありません。:::pagebreakのあとのH1に付けた目次や奥付のスタイルはparity 'odd'を受け継ぎ、白ページの後ろに置かれます。こうしたスタイルにはbreakBefore: { enabled: false }を指定してください。
- **headingsオブジェクトを渡すとH1の改ページが消える.** 既定ではH1は奇数ページへ改ページします（always-odd）。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。
- **コンテナー基準の負のオフセットでは何も描かれない.** 幅が自動のデザインのテキストはコンテナーの中に制限されるため、コンテナーからの負のオフセットで外に押し出されると何も描かれません。こうした要素はページか裁ち落としに固定してmm単位のオフセットを明示するか、幅を固定してください。
- **1ページは奇数ページ。ページは物理的な番号で計画する.** 1ページは右ページで、2ページが最初の偶数ページです。見開きは物理的なページ番号で計画してください。偶数ページの章扉は、その次の奇数ページと向かい合います。
- **行末不ぞろいのテキストはハイフネーションされない.** ハイフネーションが効くのは両端そろえのテキストだけです。左そろえのテキストは語と語の間で改行するため、幅の狭い左そろえの段では行末の凹凸が大きくなります。その箇所を両端そろえにするか、行長を広げてください。
- **行末不ぞろいのテキストはラントを検査されない.** optimalLineBreaking、avoidRunts、runtPenalty、runtMinCharactersはKnuth–Plassの改行処理に働きますが、postext 1.4.1がこれを使うのは両端そろえのテキストだけです。左そろえの段落は1行ずつ改行されるため、これらの設定にかかわらず短い一語で終わることがあります。左そろえのテキストの最終行を読み、ラントで終わる段落は書き換えてください。
- **SVGの<img>内のテキストはWebフォントを使えない.** SVGは画像として描かれ、画像はページのWebフォントにアクセスできないため、ラベルはシステムの書体にフォールバックします。テキストをアウトライン化するか、SVGに@font-faceのサブセットを埋め込むか、ラベルをキャプションに移してください。
- **レイアウトの前にすべてのフォントを読み込む.** レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。

- 6ページ目があるのは、コース1が奇数ページの5ページ目で終わるからです。偶数ページで終わるコースの後には白ページが残らず、次のコースの色はそのコース自身のページにしか出ません。
- 番号付きの最初のコースより前では`{chapterNumber}`が空なので、ツメのようにそれを印刷する要素は、そこでそれを外すスタイルが必要です。

## クレジット

- レシピ: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- 画像: The tiles, the map of the routes and its pins, drawn in code: Ignacio Ferro, CC-BY-4.0
- 書体: Albert Sans (OFL-1.1), DM Serif Display (OFL-1.1), Asap Condensed (OFL-1.1)
- コード: MIT · サンプルの内容: CC-BY-4.0

## 関連レシピ

- [No. 019 · ひとつの属性で部ごとに色を変える](https://postext.dev/ja/cookbook/parts-in-colour.md): ポケット判の野外図鑑。:::partごとにパレットの1項目を設定し、部扉、色を敷いた偶数ページ、つめ見出し、識別点、目次の行をその色で刷ります。 · 難易度 3 (上級) · マニュアル・ガイド・リファレンス
- [No. 005 · エッセイ集で奇数・偶数ページごとに柱を変える](https://postext.dev/ja/cookbook/running-heads-by-parity.md): ページの奇偶と役割で絞り込むヘッダー要素。偶数ページに書名、奇数ページに省略したエッセイの表題、余白にノンブルのタブ、章扉には地のノンブル。 · 難易度 2 (中級) · 小説・戯曲・文芸
- [No. 003 · 裁ち落としの帯を敷いた章扉](https://postext.dev/ja/cookbook/chapter-opener-bleed-band.md): レベル1見出しにadvancedDesignの章扉を付けます。裁ち落としの帯、その下端に立つ章番号、見出しの属性から取るキッカーとリード文。 · 難易度 3 (上級) · 教科書
