# 7階層の節見出し

> 番号に合わせて幅の広がる琥珀色のピルに1.1から1.12の節番号を入れ、その下に4階層、さらに見出しスタイルで7番目の階層を作ります。

- HTML版: https://postext.dev/ja/cookbook/section-heads-field-manual
- レシピ No. 018 · 見出しと章扉 · 難易度 2 (中級) · 出力: Canvas
- ジャンル: マニュアル・ガイド・リファレンス
- 必要なもの postext ≥ 1.4.1 · テスト環境 1.4.1 ／テスト日 2026-09-26
- ページ: [1](https://postext.dev/cookbook/section-heads-field-manual/en/p01.webp?v=e0857f02), [2](https://postext.dev/cookbook/section-heads-field-manual/en/p02.webp?v=e0857f02), [3](https://postext.dev/cookbook/section-heads-field-manual/en/p03.webp?v=e0857f02)
- Sandboxで開く: https://postext.dev/ja/sandbox#recipe=section-heads-field-manual&lang=en (.postext: https://postext.dev/cookbook/section-heads-field-manual/en/section-heads-field-manual.postext)
- 最終更新: 2026-09-25
- 他の言語: [en](https://postext.dev/en/cookbook/section-heads-field-manual.md), [es](https://postext.dev/es/cookbook/section-heads-field-manual.md), [ca](https://postext.dev/ca/cookbook/section-heads-field-manual.md), [zh](https://postext.dev/zh/cookbook/section-heads-field-manual.md), [ar](https://postext.dev/ar/cookbook/section-heads-field-manual.md)

## かんたんな説明

登山道の整備作業員向けの現場マニュアルから、道の排水を扱う章です。見出しを7階層にし、それぞれに違う見た目を与えて、全体の構成が読者に見えるようにする方法がわかります。

## できあがり

登山道の整備作業員向けの現場マニュアルの第1章『Drainage』を、B5判3ページの2段組みで組みます。本文はIBM Plex Serif、見出しと節番号はIBM Plex Sans Condensed、ラベル、ノンブル、項番号はIBM Plex Monoです。章扉では、章番号を大きな琥珀色のピルに入れ、登山道の高低断面図の上に置きます。断面図では、琥珀色の点が新しい水切りを設ける12か所を示しています。小さなピルが節1.1から1.12に番号を付け、1.10で幅が広がります。見出し1.2.1、1.5.1、1.5.2は、緑の罫線の下に字間を広げたキャピタルで組みます。レベル4から6は番号を付けず、代わりに書体を変えます（セリフのイタリック、緑のコンデンスの太字、等幅のキャピタル）。灰色のイタリックの7番目の階層は道具名を示します。安全規則は緑の追い込みの用語で始まります。中抜きの四角を付けた番号なしのチェックリストが、1.、a)、i.と番号付けしたリストと2つのタスクボックスで章を締めくくります。

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

- 見出しを1.1や1.1.1と番号付けし、階層ごとにスタイルを変え、番号をピルに入れるにはどうしますか？
- タイトルの横に、番号が増えると幅が広がる番号バッジ（9 → 10）を作るには？
- 見出しのレベルが6つを超えるときはどうすればいいですか？
- リストをカスタマイズするには（レベルごとの記号、(a)/(i)の番号、タスクのチェックボックス、グリッドに乗る間隔）？

## 手短な答え

```js
// script.js, 行 34–51
const H2 = 13.5; // pt: the number and the title share one size and one line height,
const LH = 1.2; // so, under the same top padding, they share one baseline
// Every section head starts on a grid line, so the 3 pt the pill falls short of two lines
// is the gap the grid snap leaves between the pill and the text under it.
const PILL_H = 2 * LEAD - 3, PAD = (PILL_H - H2 * LH) / 2; // pt
const face = { fontFamily: DISPLAY, fontWeight: 700, fontSize: pt(H2), lineHeight: LH };
const pill = { kind: 'text', id: 'pill', content: '{number}', ...face, color: col('ink'),
  box: { backgroundColor: col('signal'), borderRadius: mm(3), // no width: the pill is its
    padding: { top: pt(PAD), bottom: pt(PAD), left: mm(1.8), right: mm(1.8) } }, // number
  placement: at('container', 'top-left') }; // plus its padding
// 'right-of' hangs the title on the pill's right edge and aligns its lines left, so a long
// title wraps beside the number, never under it (gotcha: overflow-ellipsis-default).
const sectionTitle = (from) => ({ kind: 'text', id: 'title', content: '{titleText}', ...face,
  color: col('ink'), overflow: 'wrap', box: { padding: { top: pt(PAD) } },
  placement: at(`#${from}`, 'right-of', mm(2.2)) });
// The H1 counter, a point, the H2 counter: 1.1 … 1.12 in the pill. h2 joins headings.levels.
const h2 = { level: 2, numberingTemplate: '{1}.{2}',
  advancedDesign: { enabled: true, slot: { elements: [pill, sectionTitle('pill')] } } };
```

## 材料

**学べること**

- [番号付き見出し](https://postext.dev/ja/docs/configuration.md#レベルごとの上書き): 階層ごとの番号のテンプレート（1、1.1、IV、A、01）です。章扉、柱、目次にも同じ番号が印刷されます。
- [ページデザインのテキスト・罫・ボックス](https://postext.dev/ja/docs/configuration.md#柱とノンブル): ヘッダー、フッター、章扉、部扉で共通の描画要素です。ピル形の背景を付けられるテキスト、水平と垂直の罫、塗りまたは枠線のボックスがあり、配列の順に描かれます。
- [デザイン要素の基準位置](https://postext.dev/ja/docs/configuration.md#要素の配置): 座標ではなく、コンテナー、ページ、裁ち落とし、または別の要素（right-of、below、align-*）を基準に要素を配置します。

**ほかに使うもの**

- [見出しの階層](https://postext.dev/ja/docs/configuration.md#レベルごとの上書き)
- [見出しスタイル](https://postext.dev/ja/docs/configuration.md#見出しスタイル)
- [番号のない章](https://postext.dev/ja/docs/configuration.md#見出しスタイル)
- [デザインした章扉](https://postext.dev/ja/docs/configuration.md#幅と詳細デザイン)
- [ページデザインの画像](https://postext.dev/ja/docs/configuration.md#画像要素)
- [見出しの属性](https://postext.dev/ja/docs/document-format.md#見出しの属性)
- [段落スタイル](https://postext.dev/ja/docs/configuration.md#段落スタイル)
- [太字・イタリックとその色](https://postext.dev/ja/docs/configuration.md#本文)
- [箇条書きとチェックリスト](https://postext.dev/ja/docs/configuration.md#箇条書きリスト)
- [番号付きリスト](https://postext.dev/ja/docs/configuration.md#番号付きリスト)
- [ベースライングリッド](https://postext.dev/ja/docs/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#幅と詳細デザイン)

**設定の一覧**

- [`bodyText`](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#レイアウト), [`locale`](https://postext.dev/ja/docs/configuration.md#ハイフネーション), [`orderedLists`](https://postext.dev/ja/docs/configuration.md#番号付きリスト), [`page`](https://postext.dev/ja/docs/configuration.md#ページ), [`paragraphStyles`](https://postext.dev/ja/docs/configuration.md#段落スタイル), [`unorderedLists`](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#ページをビットマップに描画する)

**書体**

- IBM Plex Serif (OFL-1.1), IBM Plex Sans Condensed (OFL-1.1), IBM Plex Mono (OFL-1.1)

## 作り方

### 1 · 番号とともに広がるピルに番号を入れる

コードは上の[手短な答え](#手短な答え)にあります。`numberingTemplate: '{1}.{2}'`は章と節のカウンターをつないで1.1から1.12にします（[レベルごとの上書き](/ja/docs/configuration#レベルごとの上書き)）。高度なデザインを持つレベルは、タイトルの前に番号を印刷しなくなるので、デザインが`{number}`を自分で置きます。ここでは番号を、塗りと角丸のある幅指定なしの`box`を持つテキスト要素に入れているので、ピルの幅は番号とそのパディングの合計になります。1.9なら10.1 mm、1.10なら12.7 mmです。`'right-of'`はタイトルをピルの右端に吊るし、行を左そろえにします。1.5の長いタイトルが番号の脇で折り返すのはそのためです（[要素の配置](/ja/docs/configuration#要素の配置)）。タイトルには`overflow: 'wrap'`も必要です。既定では、収まらないデザインテキストは省略記号で切られるからです。ピルはグリッド2行分より3 pt短く、節の見出しはどれもグリッド線から始まるので、見出しが段の頭に来ても段落の後に来ても、その3 ptがピルとその下の本文の間隔になります。

![Page 3: 1.8 Lead-off ditches.](https://postext.dev/cookbook/section-heads-field-manual/en/p03.webp?v=e0857f02)

*3ページ目。ピルは1.9から1.10で幅が広がり、タイトルは増えた1桁の幅だけ右へ移ります。*

### 2 · 3番目の階層に罫線を引き、字間を広げる

```js
// script.js, 行 55–67
// Headings have no letterSpacing of their own; design text has, so this head is a design.
const small = { fontSize: pt(8.4), lineHeight: LH };
const DROP = 6; // pt: the rule drops this far toward the number, which keeps its grid line
const h3 = { level: 3, numberingTemplate: '{1}.{2}.{3}', // 1.5.1: restarts under every H2
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.75), color: col('band'),
      placement: { ...at('container', 'top-left', mm(0), pt(DROP)), size: { width: 'fill' } } },
    { kind: 'text', id: 'num', content: '{number}', fontFamily: LABEL, fontWeight: 500, ...small,
      color: col('band'), placement: at('#rule', 'below', mm(0), pt(LEAD - DROP)) },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontWeight: 600,
      ...small, letterSpacing: pt(1.35), textTransform: 'uppercase', color: col('ink'),
      overflow: 'wrap', placement: at('#num', 'right-of', mm(2)) },
  ] } } };
```

Postext 1.4.1では見出しのレベルに`letterSpacing`がありませんが、デザインテキストにはあるので、レベル3もデザインで描きます。0.75 ptの緑の罫線、IBM Plex Monoの番号、その脇に字間を広げたタイトルです。`'{1}.{2}.{3}'`の3番目のカウンターは節ごとにリセットされるので、1.2.1も1.5.1も1で終わります。`DROP`は罫線だけを下げます。番号は罫線の`LEAD - DROP`下に置くので、`DROP`がいくつでも、番号とタイトルは見出しの上端からグリッド1行下にとどまります。罫線が上の段落より番号に近いので、見出しの一部として読めます。

### 3 · レベル4から6へ段階的に下げる

```js
// script.js, 行 96–107
const headings = { fontFamily: DISPLAY, color: col('ink'), // every head sits on the grid,
  lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0), // a line above, none below
  levels: [
    // Any headings object drops the H1 page break: restated (gotcha: headings-drop-h1-break).
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
      numberingTemplate: '{1}', advancedDesign: opener },
    h2, h3,
    // No template below level 3, so no number: each level changes face, colour or case.
    { level: 4, fontFamily: 'IBM Plex Serif', fontWeight: 400, italic: true, fontSize: pt(11) },
    { level: 5, fontSize: pt(9.4), color: col('band') },
    { level: 6, fontFamily: LABEL, fontWeight: 600, fontSize: pt(7.8), textTransform: 'uppercase' },
  ] };
```

`numberingTemplate`のないレベルは番号を印刷しないので、レベル4以下の見出しは、代わりに書体、色、大文字小文字で区別します。レベル4はセリフのイタリック、5は緑のディスプレイ書体、6はセミボールドの等幅のキャピタルです。`headings`自体に設定した行の高さと余白はすべてのレベルに及び、各見出しを上に空行1行、下には空行なしでグリッドに載せます。`headings`オブジェクトがあると章の前の既定の改ページがなくなるので、レベル1でそれを指定し直しています。

### 4 · スタイルで7番目の階層と番号なしの節を作る

```js
// script.js, 行 111–130
const headingStyles = [
  // Markdown stops at ######, and a heading drops *marks* (gotcha: heading-marks-dropped):
  // '###### Rock bar {style="level7"}' stays level 6, set in lower case, lighter and grey.
  { id: 'level7', fontFamily: DISPLAY, fontWeight: 500, italic: true, fontSize: pt(8.4),
    textTransform: 'none', color: col('muted') },
  // numbered: false: no number, and the H2 counter does not move. An empty {number} would
  // still paint the amber pill, so the style draws a hollow square in its place.
  { id: 'checklist', numbered: false, advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'box', id: 'box', style: { borderColor: col('signal'), borderWidth: pt(1.8),
      borderRadius: mm(1.5) }, placement: { ...at('container', 'top-left'),
      size: { width: pt(PILL_H), height: pt(PILL_H) } } },
    sectionTitle('box'),
  ] } } },
];
const paragraphStyles = [
  // Run-in heads: the bold term opening each rule prints in the accent, not in body ink.
  { id: 'rules', boldColor: col('band'), firstLineIndent: pt(0) },
  { id: 'colophon', fontFamily: LABEL, fontSize: pt(6.8), lineHeight: pt(9),
    color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
];
```

Markdownは`######`で終わり、見出しは太字とイタリックの記号を落とすので、`###### *Rock bar*`はもう1つのレベル6として印刷されます。見出しスタイル`'level7'`は道具名を、レベル6の600より軽い、ウェイト500の灰色のコンデンスのイタリックで組み、`textTransform: 'none'`で、本来なら引き継ぐはずのキャピタルを解除します。8.4 ptなので上の等幅のラベルの7.8 ptより大きいものの、それより1段下に読めます（[見出しスタイル](/ja/docs/configuration#見出しスタイル)）。`numbered: false`はチェックリストを番号の数から外すので、その後に節を置いても1.13のままです。空の`{number}`でも琥珀色のピルは塗られるので、このスタイルは専用のデザインを持ち、ピルの位置に中抜きの四角を置きます。安全規則の緑の追い込みの用語は、段落スタイルの`boldColor`から来ています。

### 5 · 深さに応じてリストの記号を変える

```js
// script.js, 行 134–141
// Zero margins keep lists on the grid; a '- [ ]' item's bullet becomes taskCheckboxChar, '☐'.
const unorderedLists = { gap: mm(2), marginTop: pt(0), marginBottom: pt(0), color: col('band'),
  levels: [{ level: 2, bulletChar: '–', color: col('sage') }] }; // '•' stays at level 1
// Level 1 keeps the defaults: 'arabic', never CSS's 'decimal' (gotcha: numbering-vocabularies).
const orderedLists = { fontFamily: DISPLAY, color: col('band'), gap: mm(1.6),
  marginTop: pt(0), marginBottom: pt(0), levels: [
    { level: 2, numberFormat: 'lower-alpha', separator: ')' },
    { level: 3, numberFormat: 'lower-roman', color: col('muted') }] };
```

深さごとに、記号、色、区切りを`levels`から取ります。チェックリストは1.、a)、i.と数え（[番号付きリストのレベル別上書き](/ja/docs/configuration#番号付きリストのレベル別上書き)）、行頭記号は緑からセージ色へ薄くなります（[箇条書きリストのレベル別上書き](/ja/docs/configuration#箇条書きリストのレベル別上書き)）。レベル1は既定の書式`'arabic'`のままです。CSSで使う語の`'decimal'`にすると、「undefined」と印刷されます。チェックリストを締めくくる2つの`- [ ]`項目は、行頭記号の代わりに`taskCheckboxChar`（既定は☐）を、第1階層の行頭記号と同じ緑で印刷します（[タスクリストの拡張](/ja/docs/configuration#タスクリストの拡張)）。上下の余白を0にしているので、リストはどれもベースライングリッドに載ったままです。

### 6 · 章を登山道の断面図で始める

```js
// script.js, 行 71–92
const DEPTH = 96; // mm: the profile's foot, measured from the top of the page
const CLEAR = 6; // mm: the least room between the profile's foot and the text under it
const LEGEND = 7; // mm: how far the legend's top sits above the profile's foot
const big = { ...face, fontSize: pt(54), lineHeight: 1, color: col('ink') };
// A picture reserves no height in an opener (gotcha: opener-image-no-reserve), so minHeight
// reaches past the profile: the text starts on the first grid line CLEAR mm or more under it.
const opener = { enabled: true, minHeight: mm(DEPTH - TOP + CLEAR), slot: { elements: [
  { kind: 'image', id: 'profile', resourceId: 'profile',
    placement: { ...at('page', 'top-left'), size: { width: 'fill' } } },
  { kind: 'text', id: 'num', content: '{number}', ...big, box: { backgroundColor: col('signal'),
    borderRadius: mm(4), padding: { top: pt(4), bottom: pt(4), left: mm(4), right: mm(4) } },
    placement: at('container', 'top-left') },
  { kind: 'text', id: 'title', content: '{titleText}', ...big, box: { padding: { top: pt(4) } },
    placement: at('#num', 'right-of', mm(4)) },
  { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: 'IBM Plex Serif', italic: true,
    fontSize: pt(11.5), lineHeight: 1.3, color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { ...at('#num', 'below', mm(0), mm(5)), size: { width: mm(100) } } },
  // Design text: an SVG drawn as an image cannot use web fonts (gotcha: svg-no-webfonts).
  { kind: 'text', id: 'legend', content: '{attr.profile}', fontFamily: LABEL, fontWeight: 500,
    fontSize: pt(7), color: col('tint'), placement: at('page', 'top-right', mm(-OUTER),
      mm(DEPTH - LEGEND)) },
] } };
```

断面図は章扉の画像要素で、ページにアンカーし、ページと同じ幅にしています。ページ幅の図を天にフロートさせて1ページ目で参照すると、2ページ目の頭に置かれていたはずです（[画像要素](/ja/docs/configuration#画像要素)）。章扉では画像は高さを確保しないので、`minHeight`が、画像の6 mm以上下にある最初のグリッド線から本文を始めます。緑の上の凡例はデザインテキストです。画像として描くSVGはページのフォントを使えないからです。大きなピルは節のピルの書体と塗りを再利用し、`{number}`は`numberingTemplate: '{1}'`から章自身の番号を印刷します。

## レシピの全体

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

- レシピのフォルダー: https://github.com/drnachio/postext/tree/main/cookbook/section-heads-field-manual

### script.js

```js
// ═══ Postext Cookbook · Nº 018 · Section heads seven levels deep ═════════════════
// https://postext.dev/en/cookbook/section-heads-field-manual
// Code: MIT · Text: original (CC BY 4.0) · Picture: drawn in code (MIT)
// Fonts: IBM Plex Serif, Sans Condensed, Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'section-heads-field-manual';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // forest green for structure, a signal amber for numbers
  ink: '#1d2320', // text: a green-black
  band: '#2f6b3f', // the accent: rules, run-in terms, bullets, numbers, folios (6.4:1)
  signal: '#e0a526', // the number pills, with ink on them (7.3:1)
  sage: '#7a9e80', // the second bullet and the profile's upper contours
  tint: '#e9f0e6', // the opener's sky; the legend on the green (5.5:1)
  muted: '#5f6a62', // running heads, level 7, roman list numbers, the colophon (5.6:1)
};
// The hex as well as the id: design slots read only the hex (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// Defaults this config does not restate link to 'main-color', so it points at the accent.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.band })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const TRIM = { width: 176, height: 250 }; // mm: ISO B5, a common size for field manuals
const [TOP, INNER, OUTER] = [22, 16, 14]; // mm: margins; the running heads align to OUTER
const LEAD = 13.2; // pt: the body leading, the pitch of the baseline grid
const LINES = 44; // grid lines in the text block, so every full column ends on one baseline
const [DISPLAY, LABEL] = ['IBM Plex Sans Condensed', 'IBM Plex Mono']; // with the serif text
const at = (to, edge, x, y) => ({ anchor: { to, edge }, offset: { x, y } });

// #region answer: section numbers 1.1 … 1.12 in an amber pill that widens with the number
const H2 = 13.5; // pt: the number and the title share one size and one line height,
const LH = 1.2; // so, under the same top padding, they share one baseline
// Every section head starts on a grid line, so the 3 pt the pill falls short of two lines
// is the gap the grid snap leaves between the pill and the text under it.
const PILL_H = 2 * LEAD - 3, PAD = (PILL_H - H2 * LH) / 2; // pt
const face = { fontFamily: DISPLAY, fontWeight: 700, fontSize: pt(H2), lineHeight: LH };
const pill = { kind: 'text', id: 'pill', content: '{number}', ...face, color: col('ink'),
  box: { backgroundColor: col('signal'), borderRadius: mm(3), // no width: the pill is its
    padding: { top: pt(PAD), bottom: pt(PAD), left: mm(1.8), right: mm(1.8) } }, // number
  placement: at('container', 'top-left') }; // plus its padding
// 'right-of' hangs the title on the pill's right edge and aligns its lines left, so a long
// title wraps beside the number, never under it (gotcha: overflow-ellipsis-default).
const sectionTitle = (from) => ({ kind: 'text', id: 'title', content: '{titleText}', ...face,
  color: col('ink'), overflow: 'wrap', box: { padding: { top: pt(PAD) } },
  placement: at(`#${from}`, 'right-of', mm(2.2)) });
// The H1 counter, a point, the H2 counter: 1.1 … 1.12 in the pill. h2 joins headings.levels.
const h2 = { level: 2, numberingTemplate: '{1}.{2}',
  advancedDesign: { enabled: true, slot: { elements: [pill, sectionTitle('pill')] } } };
// #endregion

// #region ruled: level 3, a green rule over the number and a tracked capital title
// Headings have no letterSpacing of their own; design text has, so this head is a design.
const small = { fontSize: pt(8.4), lineHeight: LH };
const DROP = 6; // pt: the rule drops this far toward the number, which keeps its grid line
const h3 = { level: 3, numberingTemplate: '{1}.{2}.{3}', // 1.5.1: restarts under every H2
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.75), color: col('band'),
      placement: { ...at('container', 'top-left', mm(0), pt(DROP)), size: { width: 'fill' } } },
    { kind: 'text', id: 'num', content: '{number}', fontFamily: LABEL, fontWeight: 500, ...small,
      color: col('band'), placement: at('#rule', 'below', mm(0), pt(LEAD - DROP)) },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontWeight: 600,
      ...small, letterSpacing: pt(1.35), textTransform: 'uppercase', color: col('ink'),
      overflow: 'wrap', placement: at('#num', 'right-of', mm(2)) },
  ] } } };
// #endregion

// #region opener: the chapter number in the section pill, scaled up, over the trail's profile
const DEPTH = 96; // mm: the profile's foot, measured from the top of the page
const CLEAR = 6; // mm: the least room between the profile's foot and the text under it
const LEGEND = 7; // mm: how far the legend's top sits above the profile's foot
const big = { ...face, fontSize: pt(54), lineHeight: 1, color: col('ink') };
// A picture reserves no height in an opener (gotcha: opener-image-no-reserve), so minHeight
// reaches past the profile: the text starts on the first grid line CLEAR mm or more under it.
const opener = { enabled: true, minHeight: mm(DEPTH - TOP + CLEAR), slot: { elements: [
  { kind: 'image', id: 'profile', resourceId: 'profile',
    placement: { ...at('page', 'top-left'), size: { width: 'fill' } } },
  { kind: 'text', id: 'num', content: '{number}', ...big, box: { backgroundColor: col('signal'),
    borderRadius: mm(4), padding: { top: pt(4), bottom: pt(4), left: mm(4), right: mm(4) } },
    placement: at('container', 'top-left') },
  { kind: 'text', id: 'title', content: '{titleText}', ...big, box: { padding: { top: pt(4) } },
    placement: at('#num', 'right-of', mm(4)) },
  { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: 'IBM Plex Serif', italic: true,
    fontSize: pt(11.5), lineHeight: 1.3, color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { ...at('#num', 'below', mm(0), mm(5)), size: { width: mm(100) } } },
  // Design text: an SVG drawn as an image cannot use web fonts (gotcha: svg-no-webfonts).
  { kind: 'text', id: 'legend', content: '{attr.profile}', fontFamily: LABEL, fontWeight: 500,
    fontSize: pt(7), color: col('tint'), placement: at('page', 'top-right', mm(-OUTER),
      mm(DEPTH - LEGEND)) },
] } };
// #endregion

// #region levels: numbers down to 1.1.1, then italic, bold and label faces for 4 to 6
const headings = { fontFamily: DISPLAY, color: col('ink'), // every head sits on the grid,
  lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0), // a line above, none below
  levels: [
    // Any headings object drops the H1 page break: restated (gotcha: headings-drop-h1-break).
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
      numberingTemplate: '{1}', advancedDesign: opener },
    h2, h3,
    // No template below level 3, so no number: each level changes face, colour or case.
    { level: 4, fontFamily: 'IBM Plex Serif', fontWeight: 400, italic: true, fontSize: pt(11) },
    { level: 5, fontSize: pt(9.4), color: col('band') },
    { level: 6, fontFamily: LABEL, fontWeight: 600, fontSize: pt(7.8), textTransform: 'uppercase' },
  ] };
// #endregion

// #region styles: a seventh level and an unnumbered section as heading styles; run-in terms
const headingStyles = [
  // Markdown stops at ######, and a heading drops *marks* (gotcha: heading-marks-dropped):
  // '###### Rock bar {style="level7"}' stays level 6, set in lower case, lighter and grey.
  { id: 'level7', fontFamily: DISPLAY, fontWeight: 500, italic: true, fontSize: pt(8.4),
    textTransform: 'none', color: col('muted') },
  // numbered: false: no number, and the H2 counter does not move. An empty {number} would
  // still paint the amber pill, so the style draws a hollow square in its place.
  { id: 'checklist', numbered: false, advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'box', id: 'box', style: { borderColor: col('signal'), borderWidth: pt(1.8),
      borderRadius: mm(1.5) }, placement: { ...at('container', 'top-left'),
      size: { width: pt(PILL_H), height: pt(PILL_H) } } },
    sectionTitle('box'),
  ] } } },
];
const paragraphStyles = [
  // Run-in heads: the bold term opening each rule prints in the accent, not in body ink.
  { id: 'rules', boldColor: col('band'), firstLineIndent: pt(0) },
  { id: 'colophon', fontFamily: LABEL, fontSize: pt(6.8), lineHeight: pt(9),
    color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
];
// #endregion

// #region lists: bullets that fade with depth; numbers 1. then a) then i.; task boxes
// Zero margins keep lists on the grid; a '- [ ]' item's bullet becomes taskCheckboxChar, '☐'.
const unorderedLists = { gap: mm(2), marginTop: pt(0), marginBottom: pt(0), color: col('band'),
  levels: [{ level: 2, bulletChar: '–', color: col('sage') }] }; // '•' stays at level 1
// Level 1 keeps the defaults: 'arabic', never CSS's 'decimal' (gotcha: numbering-vocabularies).
const orderedLists = { fontFamily: DISPLAY, color: col('band'), gap: mm(1.6),
  marginTop: pt(0), marginBottom: pt(0), levels: [
    { level: 2, numberFormat: 'lower-alpha', separator: ')' },
    { level: 3, numberFormat: 'lower-roman', color: col('muted') }] };
// #endregion

// Running heads, HEAD mm from the trim: folio and book on versos, chapter and folio on rectos.
const HEAD = 12; // mm; an opener keeps only a drop folio, HEAD mm above its foot
const FOLIO_GAP = 9; // mm from a folio to the title beside it
const runHead = { fontFamily: DISPLAY, fontWeight: 600, fontSize: pt(7.8), letterSpacing: pt(1.2),
  textTransform: 'uppercase', color: col('muted') };
const folio = { ...runHead, fontFamily: LABEL, color: col('band') };
const head = (id, content, parity, edge, x, style = runHead) => ({ kind: 'text', id, content,
  parity, pages: 'body', ...style, placement: at('page', edge, mm(x), mm(HEAD)) });

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  colorPalette,
  page: { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
    margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - (LINES * LEAD * 25.4) / 72),
      left: mm(INNER), right: mm(OUTER), mirror: true } },
  layout: { layoutType: 'double', gutterWidth: mm(6) },
  bodyText: { fontFamily: 'IBM Plex Serif', fontSize: pt(9.4), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false,
    minWordSpacing: 0.85, maxWordSpacing: 1.4, // a narrow band: an even grey, line to line
    maxRuntTracking: 0 }, // runt fixes tighten spaces only (gotcha: runt-tracking-unpainted)
  headings, headingStyles, paragraphStyles, unorderedLists, orderedLists,
  header: { elements: [head('v-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
    head('v-book', '{title}', 'even', 'top-left', OUTER + FOLIO_GAP),
    head('r-chapter', t({ en: 'Chapter {chapterNumber} · {chapterTitle}',
      es: 'Capítulo {chapterNumber} · {chapterTitle}' }), 'odd', 'top-right', -(OUTER + FOLIO_GAP)),
    head('r-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
  ] },
  footer: { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', pages: 'opener',
    ...folio, placement: at('page', 'bottom', mm(0), mm(-HEAD)) }] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Trail Crew Field Manual"
subtitle: "Maintenance with hand tools"
author: "Postext Cookbook"
---

# Drainage {lead="Where and how to build the drains of a trail, from an outsloped tread to a stone culvert." profile="Lookout Ridge Trail, km 0 to 4.2 · twelve sites flagged for new water bars"}

In one season, boots pack a new trail until its tread sheds rain like a metal roof, and the rain runs down it, picking up speed and soil. This chapter shows how to turn that water off the trail before it cuts a rut.

## Why water is the enemy

The faster water runs, the more soil it carries away, and it runs faster the steeper the grade and the longer the run. A sheet of water that barely moves on a flat tread turns into a cutting stream on a long, steep pitch. Once a rut forms, hikers walk beside it, the tread widens and each storm digs the rut deeper. Drainage breaks the run into short pieces, so the water never gets going.

## Reading the ground

Walk the section during a storm if you can, or straight after one. Water will show you where it wants to go. Look for these signs:

- silt fans below a steep pitch
- puddles that hikers step around, wearing a new path beside them
- a rut down the middle of the tread
  - shallower than a boot sole: reshape it
  - deeper: it needs a water bar
- roots and rocks standing proud of the tread

### Flag before you dig

Mark every site with flagging tape before the crew arrives, and record its station in the log: its distance from the trailhead, the grade and the structure you propose. When the section is walked and flagged, a crew leader can plan the day in minutes.

## Outslope first

The cheapest drain is a tread that tilts. Shape it to fall by about 5 per cent toward the downhill edge, 3 cm across a tread 60 cm wide, so that water crosses it in a thin sheet instead of running down its length. Rake off the berm of loose soil that builds up along the outer edge, since a berm turns the tread back into a gutter. Check the tilt with a short level across the tread; an outslope too slight to see still sheds water, and a steeper one only turns ankles.

## Grade dips

In a grade dip, the grade reverses for a short way: the trail drops, rises again for a few metres, then resumes its climb, and water leaves at the low point. Built into new trail, dips are almost invisible to hikers and need little upkeep. On an old trail you can often carve one with a grub hoe where the grade eases.

## Water bars: turning water off steep tread

Where the grade is too steep for a dip, a water bar turns the flow across the tread. It is a line of rock or timber set into the tread at an angle, its top a little above the surface, with an armoured outlet at its lower end.

### Laying out a bar

Skew the bar 30 to 45 degrees off the square, so the water keeps enough speed to carry its silt away; a bar laid straight across the tread fills with sediment after the first storm. Space the bars more closely as the grade steepens: on loose soil at 10 per cent, one every 25 or 30 metres, and closer still on a steeper pitch.

#### Choosing the spot

Place the bar where the water can leave with ease, in a natural hollow on the downhill side. Never let it drain onto a switchback or over a steep drop, where the outflow would cut into the slope below.

### Building a rock bar

Dig a trench across the tread at the angle you chose, two thirds as deep as your tallest rock is high. Set the rocks on edge and shoulder to shoulder, with at least two thirds of each one buried, and key the upper end 30 cm into the bank so that water cannot run around it.

#### The trench

Keep the trench walls vertical and its floor on firm mineral soil. Throw the spoil well downhill, clear of the tread, and keep the best for backfill. On loose soil, widen the trench and line its downhill side with smaller stones, so that the bar rests on something firm.

##### Tools for the trench

A grub hoe and a shovel open it, and two steel bars set the rocks.

###### Steel bars

Each is about 1.5 m long; the heavier weighs as much as a loaded daypack. Lay them down when not in use, never upright against a tree.

###### Rock bar {style="level7"}

The heavy one: a lever to pry rocks loose and walk them into place. Keep your fingers clear of the pivot rock and lift with your legs.

###### Tamping bar {style="level7"}

The lighter bar, with a flat tamping foot at one end. Backfill in layers no thicker than a hand and tamp each one hard: the first flow carries off loose fill behind a bar.

## Knicks

On flat or rolling tread where puddles gather, a knick drains water with no structure at all. It is a shallow half-moon about 3 metres long, shaved into the tread so its outer edge sits a hand’s depth below the rest.

## Check steps

Where the trail climbs a gully and the water cannot be turned aside, slow it down instead. Check steps are low risers of stone or timber set across the tread, each one holding back a level bed of soil, and the water loses speed at every landing. A rise of 15 to 20 cm makes an easy step with a pack on. Key every step well into the banks.

## Lead-off ditches

Water turned off the trail must go somewhere else. A lead-off ditch carries it from a bar or a dip to ground where it can spread out harmlessly. Dig it at least as wide as the outlet, give it an even fall, and end it where the plants are thick enough to catch the silt.

## Culverts

Where a spring or a small stream crosses the trail, carry its water under the tread. An open culvert, two lines of flat rocks with a gap between them, is easy to clean. A culvert roofed with stone slabs makes a smoother tread but needs its inlet cleared after every storm.

## Armouring outlets

Wherever water leaves the trail, it can start a gully of its own. Line the outlet of every bar, dip and culvert with a fan of stones the size of a fist, set into the soil, and carry the armour on until the flow meets plants or bedrock.

## Tool safety

The crew leader checks every tool at the trailhead, and these four rules hold all day:

:::paragraphs{style="rules"}
**Carry.** Edged tools travel by your side, blade down and in its guard, on the downhill side of the trail, and never on a shoulder.

**Spacing.** Keep two tool lengths between workers, and call out before every swing.

**Rock work.** Move rocks with a bar and gravity, not with your back. Nobody stands downhill of a rock that is moving.

**Protection.** A hard hat, gloves, eye protection and stiff-soled boots for the whole crew.
:::

## Recording your work

Log each structure you build or clean, with its station, type, material and condition. After a season, the log shows which drains fail first: redesign those rather than repair them.

## Checklist {style="checklist"}

After every big storm, walk the section with a hoe and a rock bar and work through this list:

1. Water bars
  1. Clear sediment from the channel.
  2. Check the outlet armour.
    1. Reset stones that have moved.
    2. Extend it to where plants begin.
2. Dips and knicks
  1. Restore the outslope.
  2. Clear the lead-off ditches.
3. Culverts
  1. Clear the inlet and the outlet.
  2. Rebuild any headwall that has settled.

- [ ] Flag any damage too big to fix today.
- [ ] Log every repair.

:::paragraphs{style="colophon"}
Text: CC BY 4.0, written for the Postext Cookbook · Set in IBM Plex Serif, IBM Plex Sans Condensed and IBM Plex Mono (SIL OFL)
:::
`; // content.<lang>.md, inlined by the Cookbook
// The profile is a resource that no :ref cites: only the opener's image element draws it.
const resources = [{ id: 'profile', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'profile.svg', width: TRIM.width * 10, height: DEPTH * 10 },
  altText: t({ en: 'A 376 m climb in 4.2 km; amber dots mark twelve sites flagged for water bars.',
    es: 'Subida de 376 m en 4,2 km; puntos ámbar en doce sitios balizados para desviadores.' }) }];

// #region art: the trail's elevation profile, drawn in code with a seeded PRNG
function profileSvg() {
  // Survey points, distance (km) and elevation (m): an easy valley, then the climb.
  const KM = 4.2;
  const pts = [[0, 1180], [0.8, 1190], [1.5, 1204], [2.1, 1226], [2.6, 1262], [3.0, 1330],
    [3.35, 1412], [3.7, 1486], [4.0, 1535], [4.2, 1556]];
  const Y0 = DEPTH - 12; // mm: where 1180 m sits in the picture
  const K = 50 / 376; // mm of picture per metre of climb
  const elev = (d) => { // smoothstep between survey points: monotone, no overshoot
    const next = pts.findIndex(([x]) => x > d);
    const i = next < 0 ? pts.length - 2 : Math.max(0, next - 1);
    const [[x0, e0], [x1, e1]] = [pts[i], pts[i + 1]];
    const u = Math.min(1, (d - x0) / (x1 - x0));
    return e0 + (e1 - e0) * u * u * (3 - 2 * u);
  };
  let seed = 18; // Mulberry32: the same wobble on every run
  const rand = () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let r = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
    return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
  };
  const N = 220;
  const crest = Array.from({ length: N + 1 }, (_, i) => [(TRIM.width * i) / N,
    Y0 - (elev((KM * i) / N) - 1180) * K + (rand() - 0.5) * 0.5]);
  const xy = (list) => list.map(([x, y]) => `${x.toFixed(2)} ${y.toFixed(2)}`).join('L');
  // Contour bands every 50 m, from the band green in the valley to sage on the ridge: each
  // band is the profile clipped between two contours.
  const mix = (a, b, u) => '#' + [1, 3, 5].map((i) => Math.round(parseInt(a.slice(i, i + 2), 16)
    * (1 - u) + parseInt(b.slice(i, i + 2), 16) * u).toString(16).padStart(2, '0')).join('');
  const bands = Array.from({ length: 8 }, (_, k) => {
    const floor = Y0 - k * 50 * K;
    const top = crest.map(([x, y]) => [x, Math.min(floor, Math.max(y, floor - 50 * K))]);
    return `<path d="M0 ${floor}L${xy(top)}L${TRIM.width} ${floor}Z" `
      + `fill="${mix(palette.band, palette.sage, k / 7)}"/>`;
  }).join('');
  // The twelve flagged sites, placed one per 32 m of climb: they crowd where it steepens.
  const dots = Array.from({ length: 12 }, (_, k) => {
    const target = 1180 + 32 * (k + 0.5);
    let [lo, hi] = [0, KM];
    for (let it = 0; it < 40; it++) {
      const mid = (lo + hi) / 2;
      if (elev(mid) < target) lo = mid; else hi = mid;
    }
    return `<circle cx="${((TRIM.width * lo) / KM).toFixed(2)}" `
      + `cy="${(Y0 - (target - 1180) * K).toFixed(2)}" r="1.9" fill="${palette.signal}" `
      + `stroke="${palette.ink}" stroke-width="0.35"/>`;
  }).join('');
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${TRIM.width * 10}" `
    + `height="${DEPTH * 10}" viewBox="0 0 ${TRIM.width} ${DEPTH}">`
    + `<rect width="${TRIM.width}" height="${DEPTH}" fill="${palette.tint}"/>`
    + `<path d="M0 ${DEPTH}L${xy(crest)}L${TRIM.width} ${DEPTH}Z" fill="${palette.band}"/>`
    + `${bands}<path d="M${xy(crest)}" fill="none" stroke="${palette.ink}" `
    + `stroke-width="0.7" stroke-linejoin="round"/>${dots}</svg>`;
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the pages paint, loaded before the first build (gotcha: fonts-first).
const FONTS = { 'IBM Plex Serif': ['400', '400i', '700'],
  'IBM Plex Sans Condensed': ['500i', '600', '700'], 'IBM Plex Mono': ['400', '500', '600'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await loadSvg('profile.svg', profileSvg());
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: t({ en: 'Section heads seven levels deep',
  es: 'Títulos de sección hasta siete niveles' }) });

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

## アレンジ

### ピルから章番号を外す

テンプレートから章のカウンターを外すと、ピルは1から12になり、10で幅が広がります。レベル3は、自分のテンプレートからも`{1}`を外すまで1.5.1と印刷します。

```diff
-const h2 = { level: 2, numberingTemplate: '{1}.{2}',
+const h2 = { level: 2, numberingTemplate: '{2}',
```

### すべてのピルを同じ幅にする

1.10の幅に固定すると、各番号が同じ幅のピルの中央に置かれ、タイトルが1列にそろいます。

```diff
-  placement: at('container', 'top-left') }; // plus its padding
+  placement: { ...at('container', 'top-left'), size: { width: mm(12.7) } } };
```

## よくあるつまずき

- **headingsオブジェクトを渡すとH1の改ページが消える.** 既定ではH1は奇数ページへ改ページします（always-odd）。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。
- **見出しは太字とイタリックの指定を落とす.** postext 1.4.1では、見出しの行はインラインの指定を失います。###### *Rock bar*は、アスタリスクもイタリックもなしに、6番目の階層の通常の書体でRock barと印字されます。7番目の階層や、タイトルの中で区別したい語には、見出しスタイル（{style="…"}）か詳細デザインを使ってください。
- **デザインのテキストのoverflowの既定値は'ellipsis-end'.** 幅に収まらないデザインのテキスト要素は、既定では省略記号で終わります。複数行に折り返したいタイトルにはoverflow: 'wrap'を設定してください。
- **パレットを差し替えても、デザイン要素と参照色は変わらない.** postext 1.4.1はcolorPaletteをテキストのスタイル（本文、見出し、リスト、キャプション、表、囲み）には反映しますが、ヘッダー、フッター、章扉、部扉の要素と、bodyText.referenceColorには反映しません。これらはpaletteIdの横に書いた16進の色のままです。画面用のダーク版や色替えのためにパレットを差し替えるときは、ビルドの前に、リンクしたすべての色をcolorPaletteから書き直してください。
- **章扉の画像は確保する高さに数えられない.** postext 1.4.1では、詳細デザインの見出しは、確保する高さを画像抜きで計測します。テキスト、罫、箱はページに固定していても数えられますが、ページの頭に裁ち落としで置いた写真のような画像は何も確保しないため、本文がその上から始まることがあります。minHeightを本文が始まるべき位置に設定してください。
- **'top'のフロートは参照したページには入らない.** フロートが自分の参照より上に置かれることはありません。そのため、Nページで参照したページ幅の'top'フロートはN+1ページの頭に置かれます。もっと前で参照するか、参照したページの下端に入れる位置'auto'または'bottom'を使ってください。
- **SVGの<img>内のテキストはWebフォントを使えない.** SVGは画像として描かれ、画像はページのWebフォントにアクセスできないため、ラベルはシステムの書体にフォールバックします。テキストをアウトライン化するか、SVGに@font-faceのサブセットを埋め込むか、ラベルをキャプションに移してください。
- **リストは'arabic'、リソースは'roman-upper'、ページは'upper-roman'.** 番号付けの設定は、それぞれ書式の綴りが異なります。リストのnumberFormatは'arabic'（'decimal'では「undefined」と印字されます）、リソースの種類のcounterFormatは'roman-upper'、ページと:::numberingは'upper-roman'です。
- **警告の多くはSandboxにしかない.** 不明なid、スタイル、ディレクティブ、欠けたフォント、ゆるい行を検査するのはSandboxで、エンジンではありません。CodePenのサンプルで得られるのはdoc.warningsとparseMarkdownWithIssuesだけです。不明なスタイルは何の知らせもなくフォールバックし、不明なディレクティブはテキストとして印字されるので、idを確認してください。
- **ノーブレークスペースでも改行される.** postext 1.4.1の改行処理はU+00A0を通常のスペースとして扱うため、0.08 %、2.006 s、Section 2などが2行に分かれることがあります。間を詰める（0.08%）か、文を書き換えてください。
- **フロントマターの値はすべて引用符で囲む.** YAMLはtitle: 1984を数値として、日付をDateオブジェクトとして読みます。文字列でない値はプレースホルダーに空で出力され、PDFにもタイトルが付きません。値はすべて引用符で囲んでください（title: "1984"）。
- **ハイフネーションできるのは8つのロケールだけで、コードは完全一致.** ハイフネーションが用意されているのはen-us、es、fr、de、it、pt、ca、nlで、コードは完全一致で照合されます。'es-ES'やほかの言語は、何の知らせもなくアメリカ英語にフォールバックします。
- **レイアウトの前にすべてのフォントを読み込む.** レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。
- **ラントの修正が、描かれないトラッキングを詰めることがある.** postext 1.4.1では、段落がラント（最終行に残った短い一語）で終わると、レイアウトはその段落を1行短く組みます。まず語間を詰め、次にmaxRuntTracking（1000分の1 em単位）までの負のトラッキングを使います。CanvasとPDFのレンダラーは0より大きいトラッキングしか描かないため、トラッキングを詰めた段落はトラッキングなしで印字されます。両端そろえの行はその差の分だけ語間を失って詰まって見え、最終行は行長を超えて段の端で切れることがあります。bodyText.maxRuntTracking: 0を設定すれば語間による修正は残るので、それでも出るラントは文を書き換えてください。
- **組版の警告: 見出し階層の飛び** (`headingHierarchy`). 見出しのレベルが1つ飛んでいます。たとえばH1の直後にH3が続いています。 解決策: 1つ下のレベルを使うか、レベルを飛ばす代わりに、意図したレベルのスタイルを変更してください。 ([ドキュメント](https://postext.dev/ja/docs/configuration.md#警告))

- 7番目の階層はスタイル`level7`を付けたレベル6の見出しなので、見本の中で、直前の見出しより2レベル以上下がる見出しはありません。「The trench」「Tools for the trench」「STEEL BARS」「Rock bar」は4、5、6、6と並びます。Sandboxの警告「見出し階層の飛び」はそうした飛びを報告するので、この章では出ません。
- コロンで終わる導入の行がリストと一緒にとどまるのは、残りのスペースに最初の項目が収まる場合だけです。Postext 1.4.1ではこの規則が1行分しか確かめないので、2行の最初の項目が空き1行に出会うと、その項目だけが次の段へ移り、コロンが取り残されます。節1.2は、どちらの版でも最初の項目を1行に収めています。

## クレジット

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

## 関連レシピ

- [No. 045 · 欄外見出し、ぶら下げ番号、追い込み見出し](https://postext.dev/ja/cookbook/side-heads-hanging-numbers.md): 設計競技の募集要項。節の題は余白の欄に本文のベースラインに合わせて立ち、小節の番号は段間にぶら下がり、追い込み見出しは赤で入ります。 · 難易度 3 (上級) · 報告書
- [No. 002 · 番号付きの数式を含む2段組みの論文](https://postext.dev/ja/cookbook/journal-article-with-maths.md): 2段組みの物理学論文。本文中の数式と7つの番号付き数式を?bundleビルドのMathJaxで組み、PDFでもベクターのまま保ちます。 · 難易度 3 (上級) · 論文・学術書
- [No. 003 · 裁ち落としの帯を敷いた章扉](https://postext.dev/ja/cookbook/chapter-opener-bleed-band.md): レベル1見出しにadvancedDesignの章扉を付けます。裁ち落としの帯、その下端に立つ章番号、見出しの属性から取るキッカーとリード文。 · 難易度 3 (上級) · 教科書
