# つめ見出しが下がっていく辞書

> ループで文字ごとに見出しスタイルを作ります。各スタイルのヘッダーは、その文字の紺のつめを淡い索引の上に、小口で1段ずつ下げて刷ります。

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

## かんたんな説明

古い船乗りの辞書の4ページです。各ページの外側の端に文字のつめが並び、いまの文字のつめは濃い色で、アルファベットが進むにつれて下へずれていきます。

## できあがり

スマイス提督の『Sailor’s Word-Book』（1867年）の新版から、AからCまでの4ページを150 × 200 mmのページに組みます。項目は8 ptのAlegreyaで両端そろえの2段に組み、細い罫で段を分けます。見出し語は紺の太字で、2行目以降は1 em下げます。文字の区切りは段の中で始まり、真鍮色の罫の上に4行ぶんの紺の頭文字を置きます。項目は改ページせずに続きます。小口には、どのページにもつめ見出し全体、22個のつめを淡い色で刷ります。いまの文字のつめは紺で、ほかより3.2 mm幅が広く、文字が進むごとに1段ずつ下がります。スペイン語版はスマイスの英語の見出し語を残し、項目を訳しています。

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

- 辞書のつめ見出しを、文字が進むごとに小口で1段ずつ下げるには？
- 参考文献や用語集を組むには（ぶら下げインデント、小さめの文字）？
- 本文や囲みの中で、キーワード（太字やイタリック）に色を付けるには？
- 柱を設定するには（左ページに書名、右ページに章タイトル、ノンブルは小口側）？
- 章扉や白ページで柱を隠したり、空白の偶数ページを部の色で塗ったりするには？

## 手短な答え

```js
// script.js, 行 36–66
// 22 tabs share the 44 lines of the text block, two lines each; letters with few words
// share a tab, as in most thumb-indexed dictionaries.
const TABS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P',
  'QR', 'S', 'T', 'UV', 'W', 'XYZ'];
const STEP = 2 * LEAD * PT; // mm: 7.34
const EDGE = { odd: 'top-right', even: 'top-left' }; // the fore-edge: right on a recto
// A cell of the index, `width` mm inside the trim and 3 mm past it, so that only its
// inner corners show their rounding; its label is centred on the part inside the trim.
const cell = (id, label, i, parity, width, fill, ink, size) => {
  const y = mm(TOP + i * STEP + 0.3); // a 0.6 mm gap between neighbours
  const at = (x, w) => ({ anchor: { to: 'page', edge: EDGE[parity] }, offset: { x: mm(x), y },
    size: { width: mm(w), height: mm(STEP - 0.6) } });
  return [
    { kind: 'box', id, parity, style: { backgroundColor: col(fill), borderRadius: mm(1.2) },
      placement: at(parity === 'odd' ? 3 : -3, width + 3) },
    { kind: 'text', id: `${id}-label`, parity, content: label, fontFamily: LABEL, fontWeight: 700,
      fontSize: pt(size), lineHeight: 1, color: col(ink), align: 'center',
      verticalAlign: 'middle', placement: at(0, width) },
  ];
};
const sides = (make) => ['odd', 'even'].flatMap(make);
// Every page prints the whole index, pale; the section's own letter stands out of it.
const ladder = sides((p) => TABS.flatMap((label, i) =>
  cell(`index-${i}-${p}`, label, i, p, 5.8, 'tint', 'slate', 7.5)));
const letterStyles = () => TABS.flatMap((label, i) => [...label].map((letter) => ({
  id: letter,
  // '# B {style="B"}' opens the section of B: its pages take this header, and a page
  // where A ends and B begins takes B's (gotcha: section-last-wins).
  header: { elements: [...runningHeads, ...ladder,
    ...sides((p) => cell(`tab-${p}`, label, i, p, 9, 'navy', 'paper', 8.5))] },
})));
```

## 材料

**学べること**

- [裁ち落としの帯とツメ](https://postext.dev/ja/docs/configuration.md#要素の配置): ページまたは裁ち落としを基準に、端から端まで置く色面です。章ごとに位置が下がっていく小口のツメ（爪見出し）も含みます。
- [節ごとの柱](https://postext.dev/ja/docs/configuration.md#見出しスタイル): 見出しスタイルが独自のヘッダーとフッターを持つので、前付け、各節、辞書の見出し文字ごとに柱やノンブルを変えられます。
- [太字・イタリックとその色](https://postext.dev/ja/docs/configuration.md#本文): 太字とイタリックの部分と、その印刷色です。既定ではアクセントの青になるので、単色の本では本文の色に戻します。

**ほかに使うもの**

- [見出しスタイル](https://postext.dev/ja/docs/configuration.md#見出しスタイル)
- [デザインした章扉](https://postext.dev/ja/docs/configuration.md#幅と詳細デザイン)
- [デザイン要素の基準位置](https://postext.dev/ja/docs/configuration.md#要素の配置)
- [ページデザインの画像](https://postext.dev/ja/docs/configuration.md#画像要素)
- [見出しの属性](https://postext.dev/ja/docs/document-format.md#見出しの属性)
- [段落スタイル](https://postext.dev/ja/docs/configuration.md#段落スタイル)
- [参考文献と用語集](https://postext.dev/ja/docs/configuration.md#段落スタイル)
- [ページの役割ごとの柱](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/justification.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#リソース)

**設定の一覧**

- [`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#ハイフネーション), [`page`](https://postext.dev/ja/docs/configuration.md#ページ), [`paragraphStyles`](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#ページをビットマップに描画する)

**書体**

- Alegreya (OFL-1.1), Alegreya SC (OFL-1.1), Alegreya Sans SC (OFL-1.1)

## 作り方

### 1 · 文字ごとに、つめを持つ見出しスタイルを作る

コードは前の[手短な答え](#手短な答え)にあります。`TABS`には22個のつめを並べ、使用頻度の低い文字は1つのつめを共有します（QR、UV、XYZ）。どのつめも本文2行（7.34 mm）の高さで、合わせると版面の44行を埋めます。ループは文字ごとに見出しスタイルを作り、その`header`が柱と淡い索引に、その文字の紺のつめを前の文字より1段下に加えます（[見出しスタイル](/ja/docs/configuration#見出しスタイル)）。セルは`parity`ごとに1回ずつ、計2回書き、ヘッダー自身のコンテナーではなくページの外側の端に固定します。このコンテナーは天の余白で、幅は版面と同じ、高さは仕上がりの端から本文までしかありません。一方、つめは小口まで届き、44行すべてにわたって並ぶ必要があります。ページのヘッダーはそのページで始まる最後の節のものになるので、[2ページ目](https://postext.dev/cookbook/dictionary-thumb-index/en/p02.webp?v=261233ca)はAで始まりながらBのつめを示します。

### 2 · 柱を1ページ目に出さない

```js
// script.js, 行 70–86
const HEAD = 11; // mm from the trim's top to the heads' top
const head = (id, content, parity, edge, x, extra) => ({ kind: 'text', id, content, parity,
  pages: 'body', fontFamily: LABEL, fontSize: pt(8), fontWeight: 500, letterSpacing: pt(1),
  color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD) } }, ...extra });
const folio = { fontWeight: 700, color: col('ink'), letterSpacing: pt(0) };
const runningHeads = [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-title', '{title}', 'even', 'top-left', OUTER + 8),
  head('recto-letter', t({ en: 'Letter {chapterTitle}', es: 'Letra {chapterTitle}' }), 'odd',
    'top-right', -(OUTER + 8)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
];
// The first page drops its folio to the foot, under the text block.
const footer = { elements: [head('drop-folio', '{pageNumber}', 'all', 'top', 0, {
  ...folio, pages: 'opener',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(6) } } })] };
```

柱はページに固定したテキスト要素で、`parity`と`pages: 'body'`で絞り込みます（[テキスト要素](/ja/docs/configuration#テキスト要素)）。1ページ目の題名は両方の段にまたがるので、1ページ目は章扉の扱いになります。柱はこのページを飛ばし、ノンブルはフッターから来ます。フッターは`pages: 'opener'`を指定した要素で、版面の6 mm下に刷られます。奇数ページでは`{chapterTitle}`が、そのページまでで最後のレベル1見出しの文字、*Letter C*を刷ります。postext 1.5.0からは、`{firstMark.<key>}`と`{lastMark.<key>}`でそのページの最初と最後の見出し語を刷れます。多くの辞書が柱に入れる語です。このレシピは1.4.1で組んだもので、文字のままにしています。

### 3 · 見出し語だけに色を付ける

```js
// script.js, 行 90–101
// Bold and italic default to the engine's blue: the body sets them to ink, and only the
// entry style prints its bold, the headwords, in navy.
const BODY = 8; // pt: a reference size, about 50 characters to the 60 mm column
const bodyText = { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: mm(0),
  minWordSpacing: 0.7, maxWordSpacing: 1.7,
  maxRuntTracking: 0 }; // gotcha: runt-tracking-unpainted
const paragraphStyles = [
  { id: 'entry', hangingIndent: em(1), boldColor: col('navy') }, // :::paragraphs{style="entry"}
  { id: 'colophon', fontSize: pt(7), color: col('muted'), textAlign: 'center',
    marginTop: pt(LEAD) },
];
```

各項目は`:::paragraphs{style="entry"}`ブロックの1つの段落で、2行目以降を1 em下げます（[段落スタイル](/ja/docs/configuration#段落スタイル)）。本文の`boldColor`は1ページ目の「**Note.**」を墨のままにし、項目スタイルの`boldColor`は見出し語を紺で刷ります。段落スタイルには`italicColor`がないため、イタリックの下位項目は本文の墨のままです。

### 4 · 段の中に4行どりの頭文字を落とす

```js
// script.js, 行 105–118
// Its cap line meets the first body line's and it stands on the fourth baseline. Cap
// heights measured on the glyph H: 0.652 em in Alegreya SC 900, 0.646 em in Alegreya.
const DROP = 4; // body lines the letter spans
const LETTER = ((DROP - 1) * LEAD + 0.646 * BODY) / 0.652; // pt: 55.8
// A design line sets its baseline 0.8 of its height below its top; a body line does too.
const letterHead = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'letter', content: '{titleText}', fontFamily: DISPLAY, fontWeight: 900,
    fontSize: pt(LETTER), lineHeight: 1, color: col('navy'), align: 'left',
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { y: pt((DROP - 0.2) * LEAD - 0.8 * LETTER) } } },
  { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.6), color: col('brass'),
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { y: pt((DROP + 0.55) * LEAD) }, size: { width: 'fill' } } },
] } };
```

`LETTER`の55.8 ptは、Alegreya SC 900のキャピタル（高さ0.652 em）が4本目のベースラインに立ち、本文1行目のキャップラインに届くサイズです。文字と真鍮色の罫は5行目の中で終わり、見出しブロックは次のグリッド線まで下に吸着するので、各見出しは5行どりになり、その下の項目はグリッドに乗ったままです。[3ページ目](https://postext.dev/cookbook/dictionary-thumb-index/en/p03.webp?v=261233ca)では、第2段の頭のCが第1段の最初の4行と高さをそろえています。レベル1は`breakBefore: { enabled: false }`を指定し、文字のスタイルはそれを継承します。1.4.1では`headings`オブジェクトがあればレベル1の改ページはすでに外れますが、値を書いておけば、後のリリースでその改ページが戻っても文字は続けて組まれます。

### 5 · 題名を両方の段にまたがせる

```js
// script.js, 行 122–148
const centred = (id, content, family, size, lineHeight, y, width, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), lineHeight, color: col('navy'), align: 'center',
  overflow: 'wrap', ...extra, placement: { anchor: { to: 'container', edge: 'top' },
    offset: { y: mm(y) }, size: { width: width ? mm(width) : 'fill' } } });
const rule = (id, y, thickness) => ({ kind: 'rule', id, direction: 'horizontal',
  thickness: pt(thickness), color: col('navy'), placement: { anchor: { to: 'container',
    edge: 'top-left' }, offset: { y: mm(y) }, size: { width: 'fill' } } });
const titleBand = { enabled: true, slot: { elements: [
  // The column rule starts at the top of the text block, under the band too: a field of
  // paper down to the thin rule covers it, so it hangs from the double rule.
  { kind: 'box', id: 'field', style: { backgroundColor: col('paper') }, placement: {
    anchor: { to: 'container', edge: 'top-left' }, size: { width: 'fill', height: mm(45) } } },
  { kind: 'image', id: 'anchor', resourceId: 'anchor', placement: {
    anchor: { to: 'container', edge: 'top' }, size: { width: 'auto', height: mm(18) } } },
  centred('name', '{titleText}', DISPLAY, 22, 1.1, 20, 0, { fontWeight: 900 }),
  centred('subtitle', '{attr.subtitle}', TEXT, 9, 1.25, 29.5, 92, { italic: true,
    color: col('ink') }),
  centred('byline', '{attr.byline}', LABEL, 7, 1.2, 38.8, 0, { fontWeight: 500,
    letterSpacing: pt(1.2), color: col('muted') }),
  rule('thick', 43.6, 1.2),
  rule('thin', 45, 0.4),
] } };
// '# The Sailor’s Word-Book {style="title" subtitle="…" byline="…"}' opens page 1. The band's
// {titleText} joins the hidden heading's wrapped lines with a space, and a page-span heading
// wraps at the column width: at the default H1 size the band printed 'Word- Book'. At body
// size the hidden title fits one line of the 60 mm column.
const titleStyle = { id: 'title', span: 'page', advancedDesign: titleBand, fontSize: pt(BODY) };
```

題名のスタイルはページ幅にまたがる帯を持ち、帯には錨、書名、見出しの属性から読む副題と著者名、二重罫が入ります（[幅と詳細デザイン](/ja/docs/configuration#幅と詳細デザイン)）。細い罫は版面の上端から45.1 mm下で終わるので、帯はグリッド13行、47.7 mmに吸着します。ページ幅の見出しの下でも段間罫は版面の上端から始まり、そのままでは題名を横切ります。紙と同じ色のボックスで細い罫まで帯を覆うと、段間罫はその下にだけ見えます。`{titleText}`は隠れた見出しの折り返した行をスペースでつなぎ、1.4.1はページ幅の見出しを段の幅で折り返します。H1の初期サイズでは帯に*Word- Book*と刷られたため、題名のスタイルは隠れた見出しを本文サイズにして、書名を60 mmの段の1行に収めています。

## レシピの全体

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

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

### script.js

```js
// ═══ Postext Cookbook · Nº 046 · Dictionary with a moving thumb index ═══════════════
// https://postext.dev/en/cookbook/dictionary-thumb-index
// Code: MIT · Text: W. H. Smyth, 1867 (PD); Spanish translation CC BY 4.0 · Pictures: code
// Fonts: Alegreya, Alegreya SC, Alegreya Sans SC (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 = 'dictionary-thumb-index';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: ink and navy on a warm paper, brass for the rules and the rope
const palette = {
  ink: '#1b1f24', // text
  navy: '#1f3a5f', // headwords, letters and tabs
  brass: '#a4834f', // hairlines and the rope of the vignette
  rule: '#cfccc3', // the column rule
  tint: '#e4e9f0', // the pale cells of the index
  slate: '#56657b', // their letters
  muted: '#62666b', // running heads
  paper: '#fbfaf6', // the page, and the letter reversed out of each tab
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries(palette)
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
const TEXT = 'Alegreya', DISPLAY = 'Alegreya SC', LABEL = 'Alegreya Sans SC';
const PT = 25.4 / 72; // mm in a point
const LEAD = 10.4; // pt: the body's leading, the grid every column is set on
const LINES = 44; // lines in a full column
const TOP = 18, INNER = 12, OUTER = 14; // mm; the foot margin makes the block whole lines
const BOTTOM = 200 - TOP - LINES * LEAD * PT;

// #region answer: one heading style per letter, each with its tab one step down the fore-edge
// 22 tabs share the 44 lines of the text block, two lines each; letters with few words
// share a tab, as in most thumb-indexed dictionaries.
const TABS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P',
  'QR', 'S', 'T', 'UV', 'W', 'XYZ'];
const STEP = 2 * LEAD * PT; // mm: 7.34
const EDGE = { odd: 'top-right', even: 'top-left' }; // the fore-edge: right on a recto
// A cell of the index, `width` mm inside the trim and 3 mm past it, so that only its
// inner corners show their rounding; its label is centred on the part inside the trim.
const cell = (id, label, i, parity, width, fill, ink, size) => {
  const y = mm(TOP + i * STEP + 0.3); // a 0.6 mm gap between neighbours
  const at = (x, w) => ({ anchor: { to: 'page', edge: EDGE[parity] }, offset: { x: mm(x), y },
    size: { width: mm(w), height: mm(STEP - 0.6) } });
  return [
    { kind: 'box', id, parity, style: { backgroundColor: col(fill), borderRadius: mm(1.2) },
      placement: at(parity === 'odd' ? 3 : -3, width + 3) },
    { kind: 'text', id: `${id}-label`, parity, content: label, fontFamily: LABEL, fontWeight: 700,
      fontSize: pt(size), lineHeight: 1, color: col(ink), align: 'center',
      verticalAlign: 'middle', placement: at(0, width) },
  ];
};
const sides = (make) => ['odd', 'even'].flatMap(make);
// Every page prints the whole index, pale; the section's own letter stands out of it.
const ladder = sides((p) => TABS.flatMap((label, i) =>
  cell(`index-${i}-${p}`, label, i, p, 5.8, 'tint', 'slate', 7.5)));
const letterStyles = () => TABS.flatMap((label, i) => [...label].map((letter) => ({
  id: letter,
  // '# B {style="B"}' opens the section of B: its pages take this header, and a page
  // where A ends and B begins takes B's (gotcha: section-last-wins).
  header: { elements: [...runningHeads, ...ladder,
    ...sides((p) => cell(`tab-${p}`, label, i, p, 9, 'navy', 'paper', 8.5))] },
})));
// #endregion

// #region running-heads: the book on the verso, the letter on the recto, folios outside
const HEAD = 11; // mm from the trim's top to the heads' top
const head = (id, content, parity, edge, x, extra) => ({ kind: 'text', id, content, parity,
  pages: 'body', fontFamily: LABEL, fontSize: pt(8), fontWeight: 500, letterSpacing: pt(1),
  color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD) } }, ...extra });
const folio = { fontWeight: 700, color: col('ink'), letterSpacing: pt(0) };
const runningHeads = [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-title', '{title}', 'even', 'top-left', OUTER + 8),
  head('recto-letter', t({ en: 'Letter {chapterTitle}', es: 'Letra {chapterTitle}' }), 'odd',
    'top-right', -(OUTER + 8)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
];
// The first page drops its folio to the foot, under the text block.
const footer = { elements: [head('drop-folio', '{pageNumber}', 'all', 'top', 0, {
  ...folio, pages: 'opener',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(6) } } })] };
// #endregion

// #region entries: one paragraph per entry, the headword bold in navy, the turnovers hanging
// Bold and italic default to the engine's blue: the body sets them to ink, and only the
// entry style prints its bold, the headwords, in navy.
const BODY = 8; // pt: a reference size, about 50 characters to the 60 mm column
const bodyText = { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: mm(0),
  minWordSpacing: 0.7, maxWordSpacing: 1.7,
  maxRuntTracking: 0 }; // gotcha: runt-tracking-unpainted
const paragraphStyles = [
  { id: 'entry', hangingIndent: em(1), boldColor: col('navy') }, // :::paragraphs{style="entry"}
  { id: 'colophon', fontSize: pt(7), color: col('muted'), textAlign: 'center',
    marginTop: pt(LEAD) },
];
// #endregion

// #region letter-heads: a four-line initial over a brass hairline, in the column, running on
// Its cap line meets the first body line's and it stands on the fourth baseline. Cap
// heights measured on the glyph H: 0.652 em in Alegreya SC 900, 0.646 em in Alegreya.
const DROP = 4; // body lines the letter spans
const LETTER = ((DROP - 1) * LEAD + 0.646 * BODY) / 0.652; // pt: 55.8
// A design line sets its baseline 0.8 of its height below its top; a body line does too.
const letterHead = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'letter', content: '{titleText}', fontFamily: DISPLAY, fontWeight: 900,
    fontSize: pt(LETTER), lineHeight: 1, color: col('navy'), align: 'left',
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { y: pt((DROP - 0.2) * LEAD - 0.8 * LETTER) } } },
  { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.6), color: col('brass'),
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { y: pt((DROP + 0.55) * LEAD) }, size: { width: 'fill' } } },
] } };
// #endregion

// #region title: the book's name across both columns, under a fouled anchor
const centred = (id, content, family, size, lineHeight, y, width, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), lineHeight, color: col('navy'), align: 'center',
  overflow: 'wrap', ...extra, placement: { anchor: { to: 'container', edge: 'top' },
    offset: { y: mm(y) }, size: { width: width ? mm(width) : 'fill' } } });
const rule = (id, y, thickness) => ({ kind: 'rule', id, direction: 'horizontal',
  thickness: pt(thickness), color: col('navy'), placement: { anchor: { to: 'container',
    edge: 'top-left' }, offset: { y: mm(y) }, size: { width: 'fill' } } });
const titleBand = { enabled: true, slot: { elements: [
  // The column rule starts at the top of the text block, under the band too: a field of
  // paper down to the thin rule covers it, so it hangs from the double rule.
  { kind: 'box', id: 'field', style: { backgroundColor: col('paper') }, placement: {
    anchor: { to: 'container', edge: 'top-left' }, size: { width: 'fill', height: mm(45) } } },
  { kind: 'image', id: 'anchor', resourceId: 'anchor', placement: {
    anchor: { to: 'container', edge: 'top' }, size: { width: 'auto', height: mm(18) } } },
  centred('name', '{titleText}', DISPLAY, 22, 1.1, 20, 0, { fontWeight: 900 }),
  centred('subtitle', '{attr.subtitle}', TEXT, 9, 1.25, 29.5, 92, { italic: true,
    color: col('ink') }),
  centred('byline', '{attr.byline}', LABEL, 7, 1.2, 38.8, 0, { fontWeight: 500,
    letterSpacing: pt(1.2), color: col('muted') }),
  rule('thick', 43.6, 1.2),
  rule('thin', 45, 0.4),
] } };
// '# The Sailor’s Word-Book {style="title" subtitle="…" byline="…"}' opens page 1. The band's
// {titleText} joins the hidden heading's wrapped lines with a space, and a page-span heading
// wraps at the column width: at the default H1 size the band printed 'Word- Book'. At body
// size the hidden title fits one line of the 60 mm column.
const titleStyle = { id: 'title', span: 'page', advancedDesign: titleBand, fontSize: pt(BODY) };
// #endregion

const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity)
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  colorPalette, footer,
  header: { elements: [] }, // every page takes the header of its letter's style
  page: { sizePreset: 'custom', width: mm(150), height: mm(200), dpi: 150,
    backgroundColor: col('paper'),
    margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER),
      mirror: true } },
  layout: { gutterWidth: mm(4), // two columns is the default layout
    columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.4) } },
  bodyText, paragraphStyles,
  // The designs paint every heading; the hidden ones are still measured, and in the face
  // FONTS loads, or the kit would fetch Open Sans 700 for text nobody sees.
  headings: { fontFamily: DISPLAY, fontWeight: 900, levels: [
    // Letters run on in the column. Any headings object already drops the H1 break
    // (gotcha: headings-drop-h1-break); stating it keeps them running on if that default
    // returns, and the letter styles inherit it (gotcha: style-inherits-break).
    { level: 1, breakBefore: { enabled: false }, marginTop: pt(LEAD), marginBottom: pt(0),
      advancedDesign: letterHead },
  ] },
  headingStyles: [
    titleStyle,
    ...letterStyles(),
  ],
});

// #region art: a fouled anchor, the Admiralty's badge, for the title band
// Drawn in the page's navy and brass; the rope's lay is a dashed navy stroke over it.
const anchorSvg = () => {
  const { navy, brass } = palette;
  const rope = 'M66.5 19 C88 26 86 44 60 52 C34 60 32 76 60 82 C88 88 86 104 60 110 '
    + 'C44 114 38 122 42 136';
  const fluke = (s) => `<path transform="translate(60 0) scale(${s} 1)" fill="${navy}"
    d="M-44 92 L-50 78 L-36 70 L-34 88 Z"/>`;
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 150" width="120" height="150">
  <circle cx="60" cy="13" r="8.5" fill="none" stroke="${navy}" stroke-width="4"/>
  <path d="M56.5 21 L63.5 21 L64.5 124 L55.5 124 Z" fill="${navy}"/>
  <rect x="20" y="27" width="80" height="6.5" rx="3.2" fill="${navy}"/>
  <circle cx="19" cy="30.2" r="5" fill="${navy}"/>
  <circle cx="101" cy="30.2" r="5" fill="${navy}"/>
  <path d="M16 84 Q20 132 60 134 Q100 132 104 84" fill="none" stroke="${navy}"
    stroke-width="7" stroke-linecap="round"/>
  ${fluke(1)}${fluke(-1)}
  <path d="M60 126 L66 134 L60 142 L54 134 Z" fill="${navy}"/>
  <path d="${rope}" fill="none" stroke="${brass}" stroke-width="4.2" stroke-linecap="round"/>
  <path d="${rope}" fill="none" stroke="${navy}" stroke-opacity="0.5" stroke-width="4.2"
    stroke-dasharray="1 1.8"/>
</svg>`;
};
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "The Sailor’s Word-Book"
author: "W. H. Smyth"
---

# The Sailor’s Word-Book {style="title" subtitle="An alphabetical digest of nautical terms, including some more especially military and scientific, but useful to seamen" byline="Admiral W. H. Smyth · London, 1867"}

**Note.** Admiral William Henry Smyth died on 8 September 1865 and left this word-book in manuscript. Sir Edward Belcher, a vice-admiral, revised it for the press, and Blackie and Son published it in London in 1867. This selection prints entries from the letters A, B and C in Smyth’s wording, some of them shortened, among them words now in everyday English: A1, *taken aback*, *aloof*, the *bitter end*, a wide *berth*, *clear the decks*, *cut and run*. Cross-references follow *see*; the entries they name are not in this selection.

# A {style="A"}

:::paragraphs{style="entry"}
**A.** The highest class of the excellence of merchant ships on Lloyd’s books, subdivided into A1 and A2, after which they descend by the vowels: A1 being the very best of the first class. Formerly a river-built (Thames) ship took the first rate for 12 years, a Bristol one for 11, and those of the northern ports 10. Some of the out-port built ships keep their rating 6 to 8 years, and inferior ones only 4. But improvements in ship-building, and the large introduction of iron, are now claiming longer life.

**Aback.** The situation of a ship’s sails when the wind bears against their front surfaces. They are *laid aback*, when this is purposely effected to deaden her way by rounding in the weather-braces; and *taken aback*, when brought to by an unexpected change of wind, or by inattention in the helmsman. – *All aback forward*, the notice given from the forecastle, when the head-sails are pressed aback by a sudden change in the wind. (*See* Work aback.) – *Taken aback*, a colloquialism for being suddenly surprised or found out.

**Abeam.** In a line at right angles to the vessel’s length; opposite the centre of a ship’s side.

**About.** Circularly; the situation of a ship after she has gone round, and trimmed sails on the opposite tack. – *Ready about!* and *About-ship!* are orders to the ship’s company to prepare for tacking by being at their stations.

**Adrift.** Floating at random; the state of a boat or vessel broken from her moorings, and driven to and fro without control by the winds and waves. Cast loose; cut adrift.

**Afloat.** Borne up and supported by the water; buoyed clear of the ground; also used for being on board ship.

**Ahead.** A term especially referable to any object farther onward, or immediately before the ship, or in the course steered, and therefore opposed to *astern*. – *Ahead of the reckoning*, is sailing beyond the estimated position of the ship. – *Ahead* is also used for progress; as, *cannot get ahead*, and is generally applied to forward, in advance.

**A-hull.** A ship under bare poles and her helm a-lee, driving from wind and sea, stern foremost. Also a ship deserted, and exposed to the tempestuous winds.

**A-lee.** The contrary of *a-weather*: the position of the helm when its tiller is borne over to the lee-side of the ship, in order to go about or put her head to windward. – *Hard a-lee!* or *luff a-lee!* is said to the steersman to put the helm down. – *Helm’s a-lee!* the word of command given on putting the helm down, and causing the head-sails to shake in the wind.

**Aloof.** The old word for “keep your luff,” in the act of sailing to the wind. (*See* Luff.) – *Keep aloof*, at a distance.

**Anchor.** A large and heavy instrument in use from the earliest times for holding and retaining ships, which it executes with admirable force. With few exceptions it consists of a long iron shank, having at one end a ring, to which the cable is attached, and the other branching out into two arms, with flukes or palms at their bill or extremity. A stock of timber or iron is fixed at right angles to the arms, and serves to guide the flukes perpendicularly to the surface of the ground. According to their various form and size, anchors obtain the epithets of the *sheet*, *best bower*, *small bower*, *spare*, *stream*, *kedge*, and *grapling* (which see under their respective heads).

**Apple-pie order.** A strange but not uncommon term for a ship in excellent condition and well looked to. Neat and orderly. Absurdly said to be a corruption of *du pol au pied*.

**Ashore.** Aground, on land. – To *go ashore*, to disembark from a boat. Opposed to *aboard*.

**Astern.** Any distance behind a vessel; in the after-part of the ship; in the direction of the stern, and therefore the opposite of *ahead*. – *To drop astern*, is to be left behind – when abaft a right angle to the keel at the main-mast, she drops astern.

**Athwart.** The transverse direction; anything extending or across the line of a ship’s course. – *Athwart hawse*, a vessel, boat, or floating lumber accidentally drifted across the stem of a ship, the transverse position of the drift being understood. – *Athwart the fore-foot*, just before the stem; ships fire a shot in this direction to arrest a stranger, and make her bring-to. – *Athwart ships*, in the direction of the beam; from side to side: in opposition to *fore-and-aft*.

**Avast.** The order to stop, hold, cease, or stay, in any operation: its derivation from the Italian *basta* is more plausible than *have fast*.

**Awning.** A cover or canvas canopy suspended by a crow-foot and spread over a ship, boat, or other vessel, to protect the decks and crew from the sun and weather. (*See* Euphroe.) Also that part of the poop-deck which is continued forward beyond the bulk-head of the cabin.

**Azure.** The deep blue colour of the sky, when perfectly cloudless.

:::

# B {style="B"}

:::paragraphs{style="entry"}
**Ballast.** As a verb, signifies to steady; as a substantive, a comprehensive mind. A man is said to “lose his ballast” when his judgment fails him, or he becomes top-heavy from conceit.

**Beam-ends.** A ship is said to be on her beam-ends when she has heeled over so much on one side that her beams approach to a vertical position; hence also a person lying down is metaphorically said to be on his beam-ends.

**Berth.** The station in which a ship rides at anchor, either alone, or in a fleet; as, she lies in a good berth, *i.e.* in good anchoring ground, well sheltered from the wind and sea, and at a proper distance from the shore and other vessels. – *Snug berth*, a place, situation, or establishment. A sleeping berth. – *To berth a vessel*, is to fix upon, and put her into the place she is to occupy. – *To berth a ship’s company*, to allot to each man the space in which his hammock is to be hung, giving the customary 14 inches in width. – *To give a berth*, to keep clear of, as to give a point of land a wide berth, is to keep at a due distance from it.

**Bitter-end.** That part of the cable which is abaft the bitts, and therefore within board when the ship rides at anchor. They say, “Bend to the bitter-end” when they would have that end bent to the anchor, and when a chain or rope is paid out to the bitter-end, no more remains to be let go. The bitter-end is the clinching end – sometimes that end is bent to the anchor, because it has never been used, and is more trustworthy. The first 40 fathoms of a cable of 115 fathoms is generally worn out when the inner end is comparatively new.

**Booby.** A well-known tropical sea-bird, *Sula fusca*, of the family *Pelecanidae*. It is fond of resting out of the water at night, even preferring an unstable perch on the yard of a ship. The name is derived from the way in which it allows itself to be caught immediately after settling. The direction in which they fly as evening comes on often shows where land may be found.

**Brace of shakes.** A moment: taken from the flapping of a sail. I will be with you before it shakes thrice.

**Bring up with a round turn.** Suddenly arresting a running rope by taking a round turn round a bollard, bitt-head, or cleat. Said of doing a thing effectually though abruptly. It is used to bring one up to his senses by a severe rating.

**Broach-to, to.** To fly up into the wind. It generally happens when a ship is carrying a press of canvas with the wind on the quarter, and a good deal of after-sail set. The masts are endangered by the course being so altered, as to bring it more in opposition to, and thereby increasing the pressure of the wind. In extreme cases the sails are caught flat aback, when the masts would be likely to give way, or the ship might go down stern foremost.

**Broadside.** The whole array, or the simultaneous discharge of the artillery on one side of a ship of war above and below. It also implies the whole of that side of a ship above the water which is situate between the bow and quarter, and is in a position nearly perpendicular to the horizon. Also, a name given to the old folio sheets whereon ballads and proclamations were printed of old (broad-sheet).

**By the board.** Over the ship’s side. When a mast is carried away near the deck it is said to go by the board.

:::

# C {style="C"}

:::paragraphs{style="entry"}
**Cabin.** A room or compartment partitioned off in a ship, where the officers and passengers reside. In a man-of-war, the principal cabin, in which the captain or admiral lives, is the upper after-part of the vessel.

**Cabin-boy.** A boy whose duty is to attend and serve the officers and passengers in the cabin.

**Cable’s length.** A measure of about 100 fathoms, by which the distances of ships in a fleet are frequently estimated. This term is frequently misunderstood. In all marine charts a cable is deemed 607.56 feet, or one-tenth of a sea mile. In rope-making the cable varies from 100 to 115 fathoms; cablet, 120 fathoms; hawser-laid, 130 fathoms, as determined by the admiralty in 1830.

**Caboose.** The cook-room or kitchen of merchantmen on deck; a diminutive substitute for the galley of a man-of-war. It is generally furnished with cast-iron apparatus for cooking.

**Careen, to.** A ship is said to careen when she inclines to one side, or lies over when sailing on a wind; off her keel or carina.

**Cat o’ nine tails.** An instrument of punishment used on board ships in the navy; it is commonly of nine pieces of line or cord, about half a yard long, fixed upon a piece of thick rope for a handle, and having three knots on each, at small intervals, nearest one end; with this the seamen who transgress are flogged upon the bare back.

**Caulk, to.** (*See* Caulking.) To lie down on deck and sleep, with clothes on.

**Chart.** A hydrographical map, or a projection of some part of the earth’s superficies *in plano*, for the use of navigators, further distinguished as plane-charts, Mercator’s charts, globular charts, and the bottle or current chart, to aid in the investigation of surface currents (all which see). A selenographic chart represents the moon, especially as seen by the aid of photography and Mr. De la Rue’s arrangement.

**Cheer, to.** To salute a ship *en passant*, by the people all coming on deck and huzzahing three times; it also implies to encourage or animate. (*See also* Hearty and Man ship!)

**Chock-a-block, or chock and block.** Is the same with *block-a-block* and *two-blocks* (which see). When the lower block of a tackle is run close up to the upper one, so that you can hoist no higher, the blocks being together.

**Clear, to.** Has several significations, particularly to escape from, to unload, to empty, to prepare, &c., as: – *To clear for action.* To prepare for action. – *To clear away* for this or that, is to get obstructions out of the way. – *To clear the decks.* To remove lumber, put things in their places, and coil down the ropes. Also, to take the things off a table after a meal. – *To clear goods.* To pay the custom-house dues and duties. – *To clear the land.* To escape from the land. – *To clear a lighter, or the hold.* To empty either.

**Coil.** A certain quantity of rope laid up in ring fashion. The manner in which all ropes are disposed of on board ship for convenience of stowage. They are laid up round, one fake over another, or by concentric turns, termed *Flemish coil*, forming but one tier, and lying flat on the deck, the end being in the middle of it, as a snake or worm coils itself.

**Convoy.** A fleet of merchant ships similarly bound, protected by an armed force. Also, the ship or ships appointed to conduct and defend them on their passage. Also, a guard of troops to escort a supply of stores to a detached force.

**Cross-trees.** Certain timbers supported by the cheeks and trestle-trees at the upper ends of the lower and top masts, athwart which they are laid to sustain the frame of the tops on the one, and to extend the top-gallant shrouds on the other.

**Cuddy.** A sort of cabin or cook-room, generally in the fore-part, but sometimes near the stern of lighters and barges of burden. In the oceanic traders it is a cabin abaft, under the round-house or poop-deck, for the commander and his passengers. Also, the little cabin of a boat.

**Cut and run, to.** To cut the cable for an escape. Also, to move off quickly; to quit occupation; to be gone.

:::

:::paragraphs{style="colophon"}
Type: Alegreya, Alegreya SC and Alegreya Sans SC (SIL OFL)

Text: W. H. Smyth, 1867 (Project Gutenberg eBook 26000)

Note and anchor: Postext Cookbook (CC BY 4.0)
:::
`; // content.<lang>.md, inlined by the Cookbook

// No paragraph cites the anchor, so it is never placed as a figure: only the title band's
// image element draws it.
const resources = [{ id: 'anchor', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  altText: t({ en: 'An anchor with a rope wound round its shank.',
    es: 'Un ancla con un cabo enrollado en la caña.' }),
  svg: { fileId: 'anchor.svg', width: 120, height: 150 } }];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = {
  Alegreya: ['400', '400i', '700'],
  'Alegreya SC': ['900'],
  'Alegreya Sans SC': ['500', '700'],
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await loadSvg('anchor.svg', anchorSvg());
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: t({ en: 'The Sailor’s Word-Book', es: 'Vocabulario del marinero' }) });

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

## アレンジ

### いまの文字のつめだけを刷る

印刷物のつめ見出しは、たいていいまの文字のつめだけを示します。ヘッダーから`ladder`を外しても、紺のつめはAからCへ下がっていきます。

```diff
-  header: { elements: [...runningHeads, ...ladder,
+  header: { elements: [...runningHeads,
```

## よくあるつまずき

- **ページのヘッダーは、そのページで始まる最後の節のものになる.** 1つのページで2つの見出しスタイルが始まると、そのページのヘッダー、フッター、パレットは後のほうのものになります。区切りの白ページは前の章に、奇数・偶数合わせの白ページは後の章に属します。
- **見出しスタイルはその階層の改ページを受け継ぐ.** 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単位のオフセットを明示するか、幅を固定してください。
- **パレットを差し替えても、デザイン要素と参照色は変わらない.** postext 1.4.1はcolorPaletteをテキストのスタイル（本文、見出し、リスト、キャプション、表、囲み）には反映しますが、ヘッダー、フッター、章扉、部扉の要素と、bodyText.referenceColorには反映しません。これらはpaletteIdの横に書いた16進の色のままです。画面用のダーク版や色替えのためにパレットを差し替えるときは、ビルドの前に、リンクしたすべての色をcolorPaletteから書き直してください。
- **ノーブレークスペースでも改行される.** postext 1.4.1の改行処理はU+00A0を通常のスペースとして扱うため、0.08 %、2.006 s、Section 2などが2行に分かれることがあります。間を詰める（0.08%）か、文を書き換えてください。
- **ラントの修正が、描かれないトラッキングを詰めることがある.** postext 1.4.1では、段落がラント（最終行に残った短い一語）で終わると、レイアウトはその段落を1行短く組みます。まず語間を詰め、次にmaxRuntTracking（1000分の1 em単位）までの負のトラッキングを使います。CanvasとPDFのレンダラーは0より大きいトラッキングしか描かないため、トラッキングを詰めた段落はトラッキングなしで印字されます。両端そろえの行はその差の分だけ語間を失って詰まって見え、最終行は行長を超えて段の端で切れることがあります。bodyText.maxRuntTracking: 0を設定すれば語間による修正は残るので、それでも出るラントは文を書き換えてください。
- **ハイフネーションできるのは8つのロケールだけで、コードは完全一致.** ハイフネーションが用意されているのはen-us、es、fr、de、it、pt、ca、nlで、コードは完全一致で照合されます。'es-ES'やほかの言語は、何の知らせもなくアメリカ英語にフォールバックします。
- **フロントマターの値はすべて引用符で囲む.** YAMLはtitle: 1984を数値として、日付をDateオブジェクトとして読みます。文字列でない値はプレースホルダーに空で出力され、PDFにもタイトルが付きません。値はすべて引用符で囲んでください（title: "1984"）。

- postext 1.4.1では、帯がこの例のようにグリッドの整数行ぶんの高さでも、ページ幅の見出しの下の段間罫は版面の上端から始まります。デザインのほかの要素より下に、ページと同じ色のボックスを描いて覆います。
- 60 mmの段には約50字が入り、スマイスの英語は言い換えられません。キャプチャーでは、両端そろえの英語218行のうち18行（8 %）で、スペースが通常の1.7倍を超えて広がりました。原稿を段に合わせて訳したスペイン語では2 %未満です。英語は、組みが最も悪かった5項目（*Aground*、*Aloft*、*Belay*、*Cockpit*、*Crow’s nest*）をほかの項目に差し替える前は17 %でした。組みのよい項目を選ぶか、段を広げてください。

## クレジット

- レシピ: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- テキスト: The Sailor’s Word-Book (London: Blackie and Son, 1867), entries from A, B and C: W. H. Smyth; Sir Edward Belcher ([出典](https://www.gutenberg.org/ebooks/26000)), パブリックドメイン
- テキスト: Spanish translation, editor’s note and colophon: Postext Cookbook, オリジナル
- 書体: Alegreya (OFL-1.1), Alegreya SC (OFL-1.1), Alegreya Sans SC (OFL-1.1)
- コード: MIT · サンプルの内容: CC-BY-4.0

## 関連レシピ

- [No. 031 · 学位論文の後付け：付録、用語集、索引](https://postext.dev/ja/cookbook/thesis-back-matter.md): 白黒で組む学位論文の最後のページ。文字番号の付録、2段組みの用語集、APA式の文献一覧、本文中の印から作る索引。 · 難易度 3 (上級) · 論文・学術書
- [No. 029 · アンカー早見表：要素を連ねて組むポスター](https://postext.dev/ja/cookbook/anchoring-cheat-sheet.md): 要素を裁ち落とし、ページ、スロット、ほかの要素から吊るして組んだA3の講演会ポスター。2枚目では各要素を枠で囲み、13の要素にタグを付けます。 · 難易度 2 (中級) · 一枚物とエフェメラ
- [No. 019 · ひとつの属性で部ごとに色を変える](https://postext.dev/ja/cookbook/parts-in-colour.md): ポケット判の野外図鑑。:::partごとにパレットの1項目を設定し、部扉、色を敷いた偶数ページ、つめ見出し、識別点、目次の行をその色で刷ります。 · 難易度 3 (上級) · マニュアル・ガイド・リファレンス
