# ポケット版の古典：追い込みで続く短い章

> ポケット版の『Dom Casmurro』。短い章を段の中に描いた見出しの下で追い込みで続け、どの改ページでも1行や見出しが取り残されません。

- HTML版: https://postext.dev/ja/cookbook/short-chapters-run-on
- レシピ No. 065 · 見出しと章扉 · 難易度 2 (中級) · 出力: Canvas
- ジャンル: 小説・戯曲・文芸
- 必要なもの postext ≥ 1.4.1 · テスト環境 1.9.2 ／テスト日 2026-10-01
- ページ: [1](https://postext.dev/cookbook/short-chapters-run-on/en/p01.webp?v=8e5bc37f), [212](https://postext.dev/cookbook/short-chapters-run-on/en/p02.webp?v=8e5bc37f), [213](https://postext.dev/cookbook/short-chapters-run-on/en/p03.webp?v=8e5bc37f), [214](https://postext.dev/cookbook/short-chapters-run-on/en/p04.webp?v=8e5bc37f), [215](https://postext.dev/cookbook/short-chapters-run-on/en/p05.webp?v=8e5bc37f), [216](https://postext.dev/cookbook/short-chapters-run-on/en/p06.webp?v=8e5bc37f), [217](https://postext.dev/cookbook/short-chapters-run-on/en/p07.webp?v=8e5bc37f)
- Sandboxで開く: https://postext.dev/ja/sandbox#recipe=short-chapters-run-on&lang=en (.postext: https://postext.dev/cookbook/short-chapters-run-on/en/short-chapters-run-on.postext)
- 最終更新: 2026-09-26
- 他の言語: [en](https://postext.dev/en/cookbook/short-chapters-run-on.md), [es](https://postext.dev/es/cookbook/short-chapters-run-on.md), [ca](https://postext.dev/ca/cookbook/short-chapters-run-on.md), [zh](https://postext.dev/zh/cookbook/short-chapters-run-on.md), [ar](https://postext.dev/ar/cookbook/short-chapters-run-on.md)

## かんたんな説明

ブラジルの古典小説の5ページを小さなポケット版として組みます。短い章はどれも前の章のすぐ後から始まり、1行だけでページが始まったり終わったりすることはありません。

## できあがり

マシャード・ジ・アシスが1899年に発表した小説『Dom Casmurro』の終わり近くから本文5ページを、ポルトガル語の原文のまま、架空の叢書「Coleção Casuarina」の110×178mmのポケット版として組みます。プラム、クリーム、プラムの帯で構成した表紙にはAbril Fatfaceの題と叢書のサフラン色の丸い紋章が載り、拡散モデルで生成したフラメンゴ沖の重い海を描いた口絵が213ページと向かい合います。本文は第CXVIII章の終わりから第CXXIII章の終わりまで続き、ページの頭で始まる章はひとつもありません。各章は前の章の終わりから2行あけて（213ページを埋めるためCXIXだけは3行）、プラムのローマ数字、字間を広げた大文字の題、短いサフラン色の罫の下から始まります。214ページには章見出しが2つ、215ページと216ページには1つずつあります。最後のページを除き、本文のどのページも33行目で終わり、1行だけで始まるページはありません。

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

- 短い章を追い込みで続け、見出しを本文から離さず、オーファンや一語だけの最終行を避けるにはどうしますか？
- 段の下端をそろえ、最終ページの段の長さを均等にするには（垂直方向の両端そろえ）？
- 見出しに番号（1、1.1、1.1.1）を振り、レベルごとに異なるスタイルにするには？
- 柱を設定するには（左ページに書名、右ページに章タイトル、ノンブルは小口側）？

## 手短な答え

```js
// script.js, 行 36–59
// Machado's chapters run a page or two, so none opens a page. The level's break is off
// (1.4.1 drops it anyway, gotcha: headings-drop-h1-break, but a release that keeps the H1
// default would open each chapter on a recto) and a chapter starts two lines under the last.
const [NUMERAL, TITLE, TRACK, GAP] = [14, 7.5, 1.3, 1.6]; // pt, pt, pt, mm
const RULE_Y = NUMERAL * PT + GAP + TITLE * 1.2 * PT + GAP; // mm: under the title's line
const chapterHead = { enabled: true, slot: { elements: [ // no span: the head stays in the text
  { kind: 'text', id: 'numeral', content: '{number}', fontFamily: DISPLAY, fontSize: pt(NUMERAL),
    lineHeight: 1, color: col('plum'), align: 'center',
    placement: { ...at('container', 'top'), size: { width: 'fill' } } },
  { kind: 'text', id: 'title', content: '{titleText}', ...caps(TITLE, TRACK), color: col('ink'),
    align: 'center', overflow: 'wrap', // a long title wraps instead of ending in '…'
    placement: { anchor: { to: '#numeral', edge: 'below' }, // centred tracked text sits
      offset: { x: pt(TRACK / 2), y: mm(GAP) }, size: { width: 'fill' } } }, // left by TRACK / 2
  { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1), color: col('saffron'),
    placement: { ...at('container', 'top', 0, RULE_Y), size: { width: mm(8) } } },
] } }; // 11.7 mm deep: the head takes three lines, and the text under it stays on the grid
const chapters = { level: 1, numberingTemplate: '{1:I}', // {number} prints CXIX, CXX…
  fontSize: pt(TITLE), // the hidden heading line, measured in the headings' face
  breakBefore: { enabled: false }, marginTop: pt(2 * LEAD), advancedDesign: chapterHead };
// The keep rules are on by default. avoidWidows keeps widowMinLines (2) lines of a paragraph
// at the foot of a page, and headings.keepWithNext takes the head along when the paragraph
// moves on; avoidOrphans keeps two lines at the head of the next page; avoidRunts weighs a
// last line shorter than about 20 characters as a fault. Column balancing adds lines above
// a head so that the page ends on line 33.
```

## 材料

**学べること**

- [ウィドウ、オーファン、ラント](https://postext.dev/ja/docs/configuration.md#オーファンウィドウラント分割禁止の規則): 段の先頭と末尾に孤立した1行が来ないようにし、段落の最終行が1語だけにならないようにします。見出しはその本文と、コロンで終わる段落はそれが導くリストと離しません。
- [デザインした章扉](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/configuration.md#見出しスタイル)
- [番号のない章](https://postext.dev/ja/docs/configuration.md#見出しスタイル)
- [表紙、扉、奥付](https://postext.dev/ja/docs/configuration.md#見出しスタイル)
- [改ページと改段](https://postext.dev/ja/docs/document-format.md#pagebreak)
- [ページデザインの画像](https://postext.dev/ja/docs/configuration.md#画像要素)
- [ページデザインのテキスト・罫・ボックス](https://postext.dev/ja/docs/configuration.md#柱とノンブル)
- [見出しの属性](https://postext.dev/ja/docs/document-format.md#見出しの属性)
- [見開きの余白](https://postext.dev/ja/docs/configuration.md#見開きの余白)
- [フロートする囲み](https://postext.dev/ja/docs/configuration.md#calloutコンテナー)
- [文書のメタデータ](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/document-format.md#リソース)
- [ローマ数字の前付け](https://postext.dev/ja/docs/document-format.md#numbering)
- [タイトル内の改行](https://postext.dev/ja/docs/document-format.md#タイトル内の改行)

**設定の一覧**

- [`bodyText`](https://postext.dev/ja/docs/configuration.md#本文), [`calloutStyles`](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#ページ)

**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#ページをビットマップに描画する)

**書体**

- Tinos (Apache-2.0), Abril Fatface (OFL-1.1), League Spartan (OFL-1.1)

## 作り方

### 1 · 章を追い込みで続け、見出しを本文から離さない

```js
// script.js, 行 36–59
// Machado's chapters run a page or two, so none opens a page. The level's break is off
// (1.4.1 drops it anyway, gotcha: headings-drop-h1-break, but a release that keeps the H1
// default would open each chapter on a recto) and a chapter starts two lines under the last.
const [NUMERAL, TITLE, TRACK, GAP] = [14, 7.5, 1.3, 1.6]; // pt, pt, pt, mm
const RULE_Y = NUMERAL * PT + GAP + TITLE * 1.2 * PT + GAP; // mm: under the title's line
const chapterHead = { enabled: true, slot: { elements: [ // no span: the head stays in the text
  { kind: 'text', id: 'numeral', content: '{number}', fontFamily: DISPLAY, fontSize: pt(NUMERAL),
    lineHeight: 1, color: col('plum'), align: 'center',
    placement: { ...at('container', 'top'), size: { width: 'fill' } } },
  { kind: 'text', id: 'title', content: '{titleText}', ...caps(TITLE, TRACK), color: col('ink'),
    align: 'center', overflow: 'wrap', // a long title wraps instead of ending in '…'
    placement: { anchor: { to: '#numeral', edge: 'below' }, // centred tracked text sits
      offset: { x: pt(TRACK / 2), y: mm(GAP) }, size: { width: 'fill' } } }, // left by TRACK / 2
  { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1), color: col('saffron'),
    placement: { ...at('container', 'top', 0, RULE_Y), size: { width: mm(8) } } },
] } }; // 11.7 mm deep: the head takes three lines, and the text under it stays on the grid
const chapters = { level: 1, numberingTemplate: '{1:I}', // {number} prints CXIX, CXX…
  fontSize: pt(TITLE), // the hidden heading line, measured in the headings' face
  breakBefore: { enabled: false }, marginTop: pt(2 * LEAD), advancedDesign: chapterHead };
// The keep rules are on by default. avoidWidows keeps widowMinLines (2) lines of a paragraph
// at the foot of a page, and headings.keepWithNext takes the head along when the paragraph
// moves on; avoidOrphans keeps two lines at the head of the next page; avoidRunts weighs a
// last line shorter than about 20 characters as a fault. Column balancing adds lines above
// a head so that the page ends on line 33.
```

章のレベルでは改ページをオフにし、`marginTop`で各見出しを前の章の最終行から2行下に置きます。そのデザインには`span`がないので、見出しは段の中に3行分の深さのブロックとして描かれ、その下の本文はグリッドに乗ったままです（[前で改ページ](/ja/docs/configuration#前で改ページ)、[幅と詳細デザイン](/ja/docs/configuration#幅と詳細デザイン)）。ページをまたぐときの保持の規則は既定値のままです。CXIXとCXXIの下にある4行の段落は、それぞれページの下端に2行を残し、2行を次のページへ送ります。`avoidOrphans: false`にすると3行と1行に分かれ、214ページと215ページは1行だけで始まります（[オーファン、ウィドウ、ラント](/ja/docs/configuration#オーファンウィドウラント分割禁止の規則)）。段末そろえがCXIXの上に3行目の空きを加え、213ページが33行目で終わるようにします（[段末そろえ](/ja/docs/configuration#段末そろえ)）。

![Page 213: CXIX Não faça isso, querida.](https://postext.dev/cookbook/short-chapters-run-on/en/p03.webp?v=8e5bc37f)

*213ページ。第CXIX章はCXVIIIの終わりから3行下で始まり、ページの下端に本文の2行を保ちます。*

### 2 · ポルトガル語のハイフネーションで33行のページを組む

```js
// script.js, 行 63–73
const bodyText = { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD),
  color: col('ink'), referenceColor: col('ink'), // for a :ref added later: main-color does
  // not reach it in 1.4.1, and it would print blue
  firstLineIndent: mm(5), // every paragraph indented, the first after a head too
  maxRuntTracking: 0 }; // 1.4.1 measures a runt fix's tracking but never paints it
// (gotcha: runt-tracking-unpainted); the fix keeps its word spacing
const page = { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
  backgroundColor: col('paper'),
  margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - LINES * LEAD * PT), // 16.3 mm
    left: mm(INNER), right: mm(OUTER), mirror: true } };
const LOCALE = 'pt'; // the Portuguese patterns, by their exact code (gotcha: hyphenation-locales)
```

地の余白は16.3mmです。178mmの高さから天の余白15mmと12.6ptの33行を引いた残りなので、すべての満ページが同じ行で終わります。Times New Romanと同じ字幅を持つTinosを9.5ptで組むと、84mmの行長に1行約59字が入ります。`locale: 'pt'`はポルトガル語のパターンでハイフネーションします。「at-tracção」「af-fluencia」「genealogica-mente」のように分かれます（[対応するロケール](/ja/docs/justification#対応するロケール)）。ポルトガルやブラジルの版の多くと同じく、見出しの直後の最初の段落も含めてすべての段落を字下げするので、`indentAfterHeading`は既定値のままです。

### 3 · 偶数ページの柱に書名、奇数ページの柱に章名

```js
// script.js, 行 77–90
const SHIFT = (INNER - OUTER) / 2; // mm: the text block sits off the page's centre
const [HEAD_Y, FOLIO_Y] = [8.5, 167]; // mm below the top edge
const head = (id, content, parity, x) => ({ kind: 'text', id, content, parity,
  pages: 'body', // never on the cover or the plate, which are opener pages
  ...caps(7.5, TRACK), color: col('muted'), align: 'center',
  placement: at('page', 'top', x + (TRACK / 2) * PT, HEAD_Y) }); // tracking, as in the answer
const folio = (id, parity, edge, x) => ({ kind: 'text', id, content: '{pageNumber}', parity,
  pages: 'body', ...caps(7.5, 0), color: col('ink'), placement: at('page', edge, x, FOLIO_Y) });
const header = { elements: [
  head('verso-title', '{title}', 'even', -SHIFT), // the title in the frontmatter
  head('recto-chapter', '{chapterTitle}', 'odd', SHIFT), // last chapter begun on or before it
] };
const footer = { elements: [folio('verso-folio', 'even', 'top-left', OUTER),
  { ...folio('recto-folio', 'odd', 'top-right', -OUTER), align: 'right' }] };
```

`{title}`はフロントマターの題を、`{chapterTitle}`はそのページかそれより前で始まった最後の章を印刷します。そのため217ページの柱は216ページで始まったCXXIIIの名を示します（[テキスト要素](/ja/docs/configuration#テキスト要素)）。213ページの柱は、最初の25行でCXVIIIを締めくくっているにもかかわらず、下端で始まるCXIXの名を示します。1.4.1には、ページの頭が属する章を表すプレースホルダーがありません。`SHIFT`は柱を版面の中央へ移します。のどの余白が小口より2mm広いので、ページの中央から1mmずれます。

### 4 · 表紙と口絵を見出しとして描く

```js
// script.js, 行 94–139
// span: 'page', in one column too, paints their art whole and keeps the \\ in the title
// (gotcha: opener-clipped-at-top). A :::pagebreak follows each in the text (gotcha:
// cover-pagebreak): 1.4.1 drops the cover's reserved room, since its foot band runs past the
// column (gotcha: opener-taller-than-column), and the plate reserves room down to its caption
// only, since pictures do not count (gotcha: opener-image-no-reserve).
const onPage = (y, size) => ({ ...at('page', 'top', 0, y), ...(size && { size }) });
const cover = { id: 'cover', numbered: false, span: 'page',
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'box', id: 'top-band', style: { backgroundColor: col('plum') },
      placement: { ...at('page', 'top-left'), size: { width: 'fill', height: mm(62) } } },
    { kind: 'text', id: 'author', content: '{author}', ...caps(10, 2.4), color: col('paper'),
      align: 'center', placement: at('page', 'top', 1.2 * PT, 44) },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(44),
      lineHeight: 1, color: col('plum'), align: 'center', overflow: 'wrap',
      placement: onPage(72, { width: 'fill' }) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1.5),
      color: col('saffron'), placement: onPage(111, { width: mm(12) }) },
    { kind: 'box', id: 'foot-band', style: { backgroundColor: col('plum') },
      placement: { ...at('page', 'top-left', 0, 124), size: { width: 'fill', height: mm(54) } } },
    { kind: 'image', id: 'roundel', resourceId: 'roundel',
      placement: onPage(133, { width: mm(20) }) },
    { kind: 'text', id: 'series', content: 'Coleção Casuarina', ...caps(7.5, 1.8),
      color: col('saffron'), align: 'center', placement: at('page', 'top', 0.9 * PT, 159) },
  ] } } };
// The plate faces the first page of text: the morning sea off Flamengo, captioned with the
// line of chapter CXXIII it illustrates, which the heading carries in its quote attribute.
const plate = { id: 'plate', numbered: false, span: 'page',
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'sea', resourceId: 'sea',
      placement: { ...at('page', 'top-left'), size: { width: 'fill', height: 'fill' } } },
    { kind: 'text', id: 'label', content: '{titleText}', ...caps(7.5, 1.6), color: col('saffron'),
      align: 'center', placement: at('page', 'top', 0.8 * PT, 146) },
    { kind: 'text', id: 'quote', content: '{attr.quote}', fontFamily: TEXT, italic: true,
      fontSize: pt(8.6), lineHeight: 1.35, color: col('paper'), align: 'center', overflow: 'wrap',
      placement: onPage(152, { width: mm(78) }) },
  ] } } };
const resources = [
  { id: 'roundel', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
    svg: { fileId: 'roundel.svg', width: 60, height: 60 },
    altText: 'The series mark: a casuarina tree in a ring.' },
  // The plate is a painting, a JPEG in assets/ cut to the trim, declared at its pixels.
  { id: 'sea', typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0,
    bitmap: { fileId: 'plate-1100.jpg', format: 'jpeg', width: 1100, height: 1780 },
    altText: 'A heavy morning sea under the Sugarloaf, a low sun, and a lone swimmer among '
      + 'the breakers.' },
];
```

どちらも`numbered: false`を付けた[見出しスタイル](/ja/docs/configuration#見出しスタイル)なので、最初の番号付きの章はやはりCXIXです。`span: 'page'`でそれぞれを扉のページにします。これがないと、表紙の帯は版面の上端と下端で止まり、柱とノンブル1が表紙に印刷され、題は1行で組まれます。口絵のキャプションは見出しから作られます。CAPÍTULO CXXIIIが見出しのテキストで、イタリックの引用はその`quote`属性です（[見出しの属性](/ja/docs/document-format#見出しの属性)）。1.4.1は表紙の下端の帯や口絵の絵の場所を確保しないので、それぞれの後の`:::pagebreak`で本文が絵に重ならないようにします。

### 5 · 抜粋に本の中での位置を伝える

```js
// script.js, 行 286–286
const continuation = { headings: { h1: 118, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } };
```

`continuation.headings`は、抜粋が始まる時点の見出しのカウンターを与えます。`h1: 118`で最初の番号付き見出しが第CXIX章になります。これがないと5つの章はIからVと番号が振られます。章のレベルの`numberingTemplate: '{1:I}'`がその番号をローマ数字にし、デザインは`{number}`でそれを印刷します。表紙の改ページの後にある`:::numbering{startAt=212}`は、口絵を212、本文のページを213から217とします（[番号付け](/ja/docs/document-format#numbering)）。

## レシピの全体

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

- レシピのフォルダー: https://github.com/drnachio/postext/tree/main/cookbook/short-chapters-run-on

### script.js

```js
// ═══ Postext Cookbook · Nº 065 · Pocket classic: short chapters that run on ═══════════
// https://postext.dev/en/cookbook/short-chapters-run-on
// Code: MIT · Text: Machado de Assis, Dom Casmurro, 1899 (PD, Gutenberg #55752) · Plate: painted
// Fonts: Tinos (Apache 2.0), Abril Fatface, League Spartan (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 viewer's title; the sample is Portuguese
const RECIPE = 'short-chapters-run-on';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = {
  ink: '#24202a', // the text: a violet near-black
  paper: '#f6f1e6', // the pocket book's paper
  plum: '#4f2a49', // the series colour: cover bands, the chapter numerals
  saffron: '#d9a03c', // the second ink: rules and drawings, never text on paper (2.1:1)
  muted: '#6b616e', // the running heads and the colophon (5.2:1 on paper)
};
// Design slots read the hex, not the id, in 1.4.1 (gotcha: palette-skips-designs).
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' } })),
  // The default bold and italic colours link to main-color, set here to the ink, not blue.
  { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];
const [TEXT, DISPLAY, LABEL] = ['Tinos', 'Abril Fatface', 'League Spartan'];
const TRIM = { width: 110, height: 178 }; // mm: a pocket book
const [TOP, INNER, OUTER] = [15, 14, 12]; // mm; mirrored, so the inner margin is at the spine
const [BODY, LEAD, LINES] = [9.5, 12.6, 33]; // pt, pt, and the lines of a full page
const PT = 25.4 / 72; // mm per point
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const caps = (size, track) => ({ fontFamily: LABEL, fontWeight: 600, fontSize: pt(size),
  letterSpacing: pt(track), textTransform: 'uppercase' });

// #region answer: chapters that run on, each under a centred head drawn in the column
// Machado's chapters run a page or two, so none opens a page. The level's break is off
// (1.4.1 drops it anyway, gotcha: headings-drop-h1-break, but a release that keeps the H1
// default would open each chapter on a recto) and a chapter starts two lines under the last.
const [NUMERAL, TITLE, TRACK, GAP] = [14, 7.5, 1.3, 1.6]; // pt, pt, pt, mm
const RULE_Y = NUMERAL * PT + GAP + TITLE * 1.2 * PT + GAP; // mm: under the title's line
const chapterHead = { enabled: true, slot: { elements: [ // no span: the head stays in the text
  { kind: 'text', id: 'numeral', content: '{number}', fontFamily: DISPLAY, fontSize: pt(NUMERAL),
    lineHeight: 1, color: col('plum'), align: 'center',
    placement: { ...at('container', 'top'), size: { width: 'fill' } } },
  { kind: 'text', id: 'title', content: '{titleText}', ...caps(TITLE, TRACK), color: col('ink'),
    align: 'center', overflow: 'wrap', // a long title wraps instead of ending in '…'
    placement: { anchor: { to: '#numeral', edge: 'below' }, // centred tracked text sits
      offset: { x: pt(TRACK / 2), y: mm(GAP) }, size: { width: 'fill' } } }, // left by TRACK / 2
  { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1), color: col('saffron'),
    placement: { ...at('container', 'top', 0, RULE_Y), size: { width: mm(8) } } },
] } }; // 11.7 mm deep: the head takes three lines, and the text under it stays on the grid
const chapters = { level: 1, numberingTemplate: '{1:I}', // {number} prints CXIX, CXX…
  fontSize: pt(TITLE), // the hidden heading line, measured in the headings' face
  breakBefore: { enabled: false }, marginTop: pt(2 * LEAD), advancedDesign: chapterHead };
// The keep rules are on by default. avoidWidows keeps widowMinLines (2) lines of a paragraph
// at the foot of a page, and headings.keepWithNext takes the head along when the paragraph
// moves on; avoidOrphans keeps two lines at the head of the next page; avoidRunts weighs a
// last line shorter than about 20 characters as a fault. Column balancing adds lines above
// a head so that the page ends on line 33.
// #endregion

// #region text: a pocket page of 33 lines, in Portuguese
const bodyText = { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD),
  color: col('ink'), referenceColor: col('ink'), // for a :ref added later: main-color does
  // not reach it in 1.4.1, and it would print blue
  firstLineIndent: mm(5), // every paragraph indented, the first after a head too
  maxRuntTracking: 0 }; // 1.4.1 measures a runt fix's tracking but never paints it
// (gotcha: runt-tracking-unpainted); the fix keeps its word spacing
const page = { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
  backgroundColor: col('paper'),
  margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - LINES * LEAD * PT), // 16.3 mm
    left: mm(INNER), right: mm(OUTER), mirror: true } };
const LOCALE = 'pt'; // the Portuguese patterns, by their exact code (gotcha: hyphenation-locales)
// #endregion

// #region heads: the book's title over the verso, the chapter over the recto, folios at the foot
const SHIFT = (INNER - OUTER) / 2; // mm: the text block sits off the page's centre
const [HEAD_Y, FOLIO_Y] = [8.5, 167]; // mm below the top edge
const head = (id, content, parity, x) => ({ kind: 'text', id, content, parity,
  pages: 'body', // never on the cover or the plate, which are opener pages
  ...caps(7.5, TRACK), color: col('muted'), align: 'center',
  placement: at('page', 'top', x + (TRACK / 2) * PT, HEAD_Y) }); // tracking, as in the answer
const folio = (id, parity, edge, x) => ({ kind: 'text', id, content: '{pageNumber}', parity,
  pages: 'body', ...caps(7.5, 0), color: col('ink'), placement: at('page', edge, x, FOLIO_Y) });
const header = { elements: [
  head('verso-title', '{title}', 'even', -SHIFT), // the title in the frontmatter
  head('recto-chapter', '{chapterTitle}', 'odd', SHIFT), // last chapter begun on or before it
] };
const footer = { elements: [folio('verso-folio', 'even', 'top-left', OUTER),
  { ...folio('recto-folio', 'odd', 'top-right', -OUTER), align: 'right' }] };
// #endregion

// #region cover: the cover and the plate, heading styles that fill a page each
// span: 'page', in one column too, paints their art whole and keeps the \\ in the title
// (gotcha: opener-clipped-at-top). A :::pagebreak follows each in the text (gotcha:
// cover-pagebreak): 1.4.1 drops the cover's reserved room, since its foot band runs past the
// column (gotcha: opener-taller-than-column), and the plate reserves room down to its caption
// only, since pictures do not count (gotcha: opener-image-no-reserve).
const onPage = (y, size) => ({ ...at('page', 'top', 0, y), ...(size && { size }) });
const cover = { id: 'cover', numbered: false, span: 'page',
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'box', id: 'top-band', style: { backgroundColor: col('plum') },
      placement: { ...at('page', 'top-left'), size: { width: 'fill', height: mm(62) } } },
    { kind: 'text', id: 'author', content: '{author}', ...caps(10, 2.4), color: col('paper'),
      align: 'center', placement: at('page', 'top', 1.2 * PT, 44) },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(44),
      lineHeight: 1, color: col('plum'), align: 'center', overflow: 'wrap',
      placement: onPage(72, { width: 'fill' }) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1.5),
      color: col('saffron'), placement: onPage(111, { width: mm(12) }) },
    { kind: 'box', id: 'foot-band', style: { backgroundColor: col('plum') },
      placement: { ...at('page', 'top-left', 0, 124), size: { width: 'fill', height: mm(54) } } },
    { kind: 'image', id: 'roundel', resourceId: 'roundel',
      placement: onPage(133, { width: mm(20) }) },
    { kind: 'text', id: 'series', content: 'Coleção Casuarina', ...caps(7.5, 1.8),
      color: col('saffron'), align: 'center', placement: at('page', 'top', 0.9 * PT, 159) },
  ] } } };
// The plate faces the first page of text: the morning sea off Flamengo, captioned with the
// line of chapter CXXIII it illustrates, which the heading carries in its quote attribute.
const plate = { id: 'plate', numbered: false, span: 'page',
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'sea', resourceId: 'sea',
      placement: { ...at('page', 'top-left'), size: { width: 'fill', height: 'fill' } } },
    { kind: 'text', id: 'label', content: '{titleText}', ...caps(7.5, 1.6), color: col('saffron'),
      align: 'center', placement: at('page', 'top', 0.8 * PT, 146) },
    { kind: 'text', id: 'quote', content: '{attr.quote}', fontFamily: TEXT, italic: true,
      fontSize: pt(8.6), lineHeight: 1.35, color: col('paper'), align: 'center', overflow: 'wrap',
      placement: onPage(152, { width: mm(78) }) },
  ] } } };
const resources = [
  { id: 'roundel', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
    svg: { fileId: 'roundel.svg', width: 60, height: 60 },
    altText: 'The series mark: a casuarina tree in a ring.' },
  // The plate is a painting, a JPEG in assets/ cut to the trim, declared at its pixels.
  { id: 'sea', typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0,
    bitmap: { fileId: 'plate-1100.jpg', format: 'jpeg', width: 1100, height: 1780 },
    altText: 'A heavy morning sea under the Sugarloaf, a low sun, and a lone swimmer among '
      + 'the breakers.' },
];
// #endregion

// The colophon floats to the foot of the last page, beside the series roundel.
const colophon = { id: 'colophon', placement: 'bottom', backgroundEnabled: false,
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) }, marginBottom: pt(0),
  icon: { kind: 'resource', resourceId: 'roundel', size: mm(10) }, titleStyle: { gap: mm(3) },
  body: { fontFamily: TEXT, fontSize: pt(7.6), lineHeight: pt(LEAD * 0.8), color: col('muted'),
    italicColor: col('muted'), textAlign: 'left', firstLineIndent: pt(0) } };

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: LOCALE,
  colorPalette,
  page,
  layout: { layoutType: 'single' },
  bodyText,
  // The heading's own line is hidden under its design but still measured, in this face.
  headings: { fontFamily: LABEL, fontWeight: 600, levels: [chapters] },
  headingStyles: [cover, plate],
  calloutStyles: [colophon],
  header,
  footer,
});

// #region art: the series roundel, drawn in code in the book's two inks
let seed = 1871; // Mulberry32, seeded: never Math.random() in a recipe
const rand = () => {
  let r = Math.imul((seed = (seed + 0x6d2b79f5) | 0) ^ (seed >>> 15), 1 | seed);
  r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
  return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
const n = (v) => v.toFixed(2);
const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w}mm" `
  + `height="${h}mm" viewBox="0 0 ${w} ${h}">${body}</svg>`;
const line = (d, stroke, width, extra = '') => `<path d="${d}" fill="none" stroke="${stroke}" `
  + `stroke-width="${n(width)}" stroke-linecap="round" stroke-linejoin="round"${extra}/>`;

// The casuarina of Bento's garden (chapter II): a leaning trunk, and branches that arch out
// on alternate sides and let their needles hang.
function roundelSvg() {
  const { plum, saffron } = palette;
  const out = [`<circle cx="30" cy="30" r="29" fill="${saffron}"/>`,
    line('M30 30m-25.5 0a25.5 25.5 0 1 0 51 0a25.5 25.5 0 1 0 -51 0', plum, 0.9),
    line('M28.6 50Q30.6 33 30.2 10.5', plum, 1.6), line('M18.5 50.2H41.5', plum, 1.3)];
  for (let k = 0; k < 9; k++) {
    const side = k % 2 ? 1 : -1;
    const y0 = 12.5 + k * 3.7;
    const reach = (3 + k * 1.55) * (0.8 + rand() * 0.35);
    const x1 = 30.2 + side * reach;
    const y1 = y0 + 1.2 + rand() * 1.4;
    out.push(line(`M30.2 ${n(y0)}Q${n(30.2 + side * reach * 0.5)} ${n(y0 - 2.2)} ${n(x1)} ${n(y1)}`,
      plum, 0.95));
    const strands = 3 + Math.round(reach / 1.6);
    for (let j = 1; j <= strands; j++) { // needles hang from the arch
      const u = j / (strands + 0.5);
      const x = 30.2 + side * reach * u;
      const y = y0 + (y1 - y0) * u ** 2 - 2.2 * 2 * u * (1 - u) + 0.3;
      out.push(line(`M${n(x)} ${n(y)}q${n(side * 0.4)} ${n(2)} ${n(side * 0.1)} `
        + `${n(3 + rand() * 2.4)}`, plum, 0.7));
    }
  }
  return svg(60, 60, out.join(''));
}

// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
// Dom Casmurro in Portuguese, in the first edition's spelling: the cover, the plate, then
// pages 213 to 217 (:::numbering in the text), from the end of chapter CXVIII.
const markdown = String.raw`---
title: "Dom Casmurro"
author: "Machado de Assis"
---

# Dom \\ Casmurro {style="cover"}

:::pagebreak

:::numbering{startAt=212}

# Capítulo CXXIII {style="plate" quote="«…grandes e abertos, como a vaga do mar lá fóra, como se quizesse tragar tambem o nadador da manhã.»"}

:::pagebreak

O retrato de Escobar, que eu tinha alli, ao pé do de minha mãe, falou-me como se fosse a propria pessoa. Combati sinceramente os impulsos que trazia do Flamengo; rejeitei a figura da mulher do meu amigo, e chamei-me desleal. Demais, quem me affirmava que houvesse alguma intenção daquella especie no gesto da despedida e nos anteriores? Tudo podia ligar-se ao interesse da nossa viagem. Sancha e Capitú eram tão amigas que seria um prazer mais para ellas irem juntas. Quando houvesse alguma intenção sexual, quem me provaria que não era mais que uma sensação fulgurante, destinada a morrer com a noite e o somno? Ha remorsos que não nascem de outro peccado, nem tem maior duração. Agarrei-me a esta hypothese que se conciliava com a mão de Sancha, que eu sentia de memoria dentro da minha mão, quente e demorada, apertada e apertando…

Sinceramente, eu achava-me mal entre um amigo e a attracção. A timidez póde ser que fosse outra causa daquella crise; não é só o ceu que dá as nossas virtudes, a timidez tambem, não contando o acaso, mas o acaso é um méro accidente; a melhor origem dellas é o ceu. Entretanto, como a timidez vem do ceu, que nos dá a compleição, a virtude, filha della é, genealogicamente, o mesmo sangue celestial. Assim reflectiria, se pudesse; mas a principio vaguei á tôa. Paixão não era nem inclinação. Capricho seria ou quê? Ao fim de vinte minutos era nada, inteiramente nada. O retrato de Escobar pareceu falar-me; vi-lhe a altitude franca e simples, sacudi a cabeça e fui deitar-me.

# Não faça isso, querida

A leitora, que é minha amiga e abriu este livro com o fim de descançar da cavatina de hontem para a valsa de hoje, quer fechal-o ás pressas, ao ver que beiramos um abysmo. Não faça isso, querida; eu mudo de rumo.

# Os autos

Na manhã seguinte accordei livre das abominações da vespera; chamei-lhes allucinações, tomei café, percorri os jornaes e fui estudar uns autos. Capitú e prima Justina sairam para a missa das nove, na Lapa. A figura de Sancha desappareceu inteiramente no meio das allegações da parte adversa, que eu ia lendo nos autos, allegações falsas, inadmissiveis, sem apoio na lei nem nas praxes. Vi que era facil ganhar a demanda; consultei Dalloz, Pereira e Souza…

Uma só vez olhei para o retrato de Escobar. Era uma bella photographia tirada um anno antes. Estava de pé, sobrecasaca abotoada, a mão esquerda no dorso de uma cadeira, a direita mettida ao peito, o olhar ao longe para a esquerda do espectador. Tinha garbo e naturalidade. A moldura que lhe mandei pôr não encobria a dedicatoria, escripta embaixo, não nas costas do cartão: «Ao meu querido Bentinho o seu querido Escobar 20-4-70.» Estas palavras fortaleceram-me os pensamentos daquella manhã, e espancaram de todo as recordações da vespera. Naquelle tempo a minha vista era boa; eu podia lel-as do logar em que estava. Tornei aos autos.

# A catastrophe

No melhor delles, ouvi passos precipitados na escada, a campainha soou, soaram palmas, golpes na cancella, vozes, acudiram todos, acudi eu mesmo. Era um escravo da casa de Sancha que me chamava:

—Para ir lá… sinhô nadando, sinhô morrendo.

Não disse mais nada, ou eu não lhe ouvi o resto. Vesti-me, deixei recado a Capitú e corri ao Flamengo.

Em caminho, fui adivinhando a verdade. Escobar metteu-se a nadar, como usava fazer, arriscou-se um pouco mais fóra que de costume, apesar do mar bravio, foi enrolado e morreu. As canoas que acudiram mal puderam trazer-lhe o cadaver.

# O enterro

A viuva… Poupo-vos as lagrimas da viuva, as minhas, as da outra gente. Sai de lá cerca de onze horas; Capitú e prima Justina esperavam-me, uma com o parecer abatido e estupido, outra enfastiada apenas.

—Vão fazer companhia a pobre Sanchinha; eu vou cuidar do enterro.

Assim fizemos. Quiz que o enterro fosse pomposo, e a affluencia dos amigos foi numerosa. Praia, ruas, praça da Gloria, tudo eram carros, muitos delles particulares. A casa, não sendo grande, não podiam lá caber todos; muitos estavam na praia, falando do desastre, apontando o logar em que Escobar fallecèra, ouvindo referir a chegada do morto. José Dias ouviu tambem falar dos negocios do finado, divergindo alguns na avaliação dos bens, mas havendo accordo em que o passivo devia ser pequeno. Elogiavam as qualidades de Escobar. Um ou outro discutia o recente gabinete Rio Branco; estavamos em Março de 1871. Nunca me esqueceu o mez nem o anno.

Como eu houvesse resolvido falar no cemiterio, escrevi algumas linhas e mostrei-as em casa a José Dias, que as achou realmente dignas do morto e de mim. Pediu-me o papel, recitou lentamente o discurso, pesando as palavras, e confirmou a primeira opinião; no Flamengo espalhou a noticia. Alguns conhecidos vieram interrogar-me:

—Então, vamos ouvil-o?

—Quatro palavras.

Poucas mais seriam. Tinha-as escripto com receio de que a emoção me impedisse de improvisar. No tilbury em que andei uma ou duas horas, não fizera mais que recordar o tempo do seminario, as relações de Escobar, as nossas sympathias, a nossa amizade, começada, continuada e nunca interrompida, até que um lance da fortuna fez separar para sempre duas creaturas que promettiam ficar por muito tempo unidas. De quando em quando enxugava os olhos. O cocheiro aventurou duas ou tres perguntas sobre a minha situação moral; não me arrancando nada, continuou o seu officio. Chegando a casa, deitei aquellas emoções ao papel; tal seria o discurso.

# Olhos de ressaca

Emfim, chegou a hora da encommendação e da partida. Sancha quiz despedir-se do marido, e o desespero daquelle lance consternou a todos. Muitos homens choravam tambem, as mulheres todas. Só Capitú, amparando a viuva, parecia vencer-se a si mesma. Consolava a outra, queria arrancal-a dalli. A confusão era geral. No meio della, Capitú olhou alguns instantes para o cadaver tão fixa, tão apaixonadamente fixa, que não admira lhe saltassem algumas lagrimas poucas e caladas…

As minhas cessaram logo. Fiquei a ver as della; Capitú enxugou-as depressa, olhando a furto para a gente que estava na sala. Redobrou de caricias para a amiga, e quiz leval-a; mas o cadaver parece que a retinha tambem. Momento houve em que os olhos de Capitú fitaram o defuncto, quaes os da viuva, sem o pranto nem palavras desta, mas grandes e abertos, como a vaga do mar lá fóra, como se quizesse tragar tambem o nadador da manhã.

:::callout{type="colophon"}
Do fim do capítulo CXVIII ao fim do CXXIII de *Dom Casmurro* (1899), com a ortografia da edição Garnier transcrita pelo Project Gutenberg (n.º 55752); corrigiu-se um erro de transcrição no capítulo CXXIII. Ilustração pintada e marca da coleção feitas para esta edição. Composto em Tinos (Apache 2.0), Abril Fatface e League Spartan (SIL OFL).
:::
`;

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first)
  Tinos: ['400', '400i'], // text, colophon; italic: the plate's quote, the colophon's title
  'Abril Fatface': ['400'], // chapter numerals and the cover title
  'League Spartan': ['600'], // chapter titles, running heads, folios, the cover's capitals
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await loadSvg('roundel.svg', roundelSvg());
await loadImage('plate-1100.jpg', asset('plate-1100.jpg'));
// #region excerpt: these pages continue a book: chapter CXVIII is under way, the next is CXIX
const continuation = { headings: { h1: 118, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } };
// #endregion
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources, continuation }, config()), markdown);
showPages(doc, { title: t({ en: 'Dom Casmurro, a pocket edition',
  es: 'Dom Casmurro, edición de bolsillo' }) });

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

## アレンジ

### 章ごとに新しいページで始める

各章の前で改ページすると、本文の5ページは7ページになります。214ページにはCXIXの見出しと4行しか入らず、章が始まらない213ページは口絵の題CAPÍTULO CXXIIIを柱にします。

```diff
-  breakBefore: { enabled: false }, marginTop: pt(2 * LEAD), advancedDesign: chapterHead };
+  breakBefore: { enabled: true, parity: 'any' }, marginTop: pt(2 * LEAD),
+  advancedDesign: chapterHead };
```

### 奇数ページの柱に章番号を入れる

`{chapterNumber}`は、題を取るのと同じ章の数字を印刷します。「CXIX · NÃO FAÇA ISSO, QUERIDA」となります。

```diff
-  head('recto-chapter', '{chapterTitle}', 'odd', SHIFT),
+  head('recto-chapter', '{chapterNumber} · {chapterTitle}', 'odd', SHIFT),
```

## よくあるつまずき

- **avoidWidowsは段の末尾を、avoidOrphansは段の頭を守る.** Postextは2種類の孤立行に独自の名前を付けています。avoidWidows（widowMinLines、widowPenalty）は段落の1行目が段の末尾に1行だけ残るのを防ぎ、avoidOrphans（orphanMinLines、orphanPenalty）は段落の最終行が次の段の頭に1行だけ残るのを防ぎます。多くの組版の手引きはこの2つの名前を逆に使っているので、設定は効く位置で選んでください。どちらも既定で有効で、ペナルティとして働きます。レイアウトは、それぞれを、従った場合に生じる空行と比べて判断します。
- **headingsオブジェクトを渡すとH1の改ページが消える.** 既定ではH1は奇数ページへ改ページします（always-odd）。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。
- **{number}/{chapterNumber}はH1の番号を出す。{numberRoman}は部だけ.** {number}と{chapterNumber}は見出しの書式付きの番号を出力しますが、{numberRoman}、{numberDecimal}などの数値のバリエーションが埋まるのは部扉だけです。章番号の書式はnumberingTemplate（{1:I}）で指定するか、属性として渡してください。
- **ハイフネーションできるのは8つのロケールだけで、コードは完全一致.** ハイフネーションが用意されているのはen-us、es、fr、de、it、pt、ca、nlで、コードは完全一致で照合されます。'es-ES'やほかの言語は、何の知らせもなくアメリカ英語にフォールバックします。
- **ラントの修正が、描かれないトラッキングを詰めることがある.** postext 1.4.1では、段落がラント（最終行に残った短い一語）で終わると、レイアウトはその段落を1行短く組みます。まず語間を詰め、次にmaxRuntTracking（1000分の1 em単位）までの負のトラッキングを使います。CanvasとPDFのレンダラーは0より大きいトラッキングしか描かないため、トラッキングを詰めた段落はトラッキングなしで印字されます。両端そろえの行はその差の分だけ語間を失って詰まって見え、最終行は行長を超えて段の端で切れることがあります。bodyText.maxRuntTracking: 0を設定すれば語間による修正は残るので、それでも出るラントは文を書き換えてください。
- **全面の表紙のあとに:::pagebreakを置く.** 段組みのレイアウトでは、表紙のような全面の章扉のあと、次のブロックが同じページの2段目、つまり絵の上から始まってしまいます。表紙の見出しの直後に:::pagebreakを置けばそのページで終わり、白ページもできません。
- **段より高い章扉は確保した高さをすべて失う.** postext 1.4.1では、詳細デザインの見出しが確保する高さ（minHeight、または最も下のデザイン要素）が始まる段より高いと、見出しは警告なしに自分のタイトル行の高さしか確保せず、本文がデザインに重なって組まれます。全面の図版にはページと同じ高さの段が必要です。見出しスタイルの余白を0にし、minHeightをページの高さに設定してください。
- **章扉の画像は確保する高さに数えられない.** postext 1.4.1では、詳細デザインの見出しは、確保する高さを画像抜きで計測します。テキスト、罫、箱はページに固定していても数えられますが、ページの頭に裁ち落としで置いた写真のような画像は何も確保しないため、本文がその上から始まることがあります。minHeightを本文が始まるべき位置に設定してください。
- **段内にとどまる章扉は版面の頭で切れる.** postext 1.4.1では、段内にとどまる詳細デザインの見出しは段の上端でクリップされます。ページや裁ち落としに固定した箱や画像は左右の余白には描かれますが、天の余白には描かれず、警告もありません。こうした見出しには、1段組みの本でもspan: 'page'を与えてください。デザインがページの章扉の帯として、欠けずに描かれます。
- **:::numberingは次のページから効く.** :::numberingは、現在のページではなく、次に始まるページから番号を変えます。:::pagebreak（第1章の前ならparity odd）の直後に置けば、新しい番号付けが意図した位置から始まります。
- **パレットを差し替えても、デザイン要素と参照色は変わらない.** postext 1.4.1はcolorPaletteをテキストのスタイル（本文、見出し、リスト、キャプション、表、囲み）には反映しますが、ヘッダー、フッター、章扉、部扉の要素と、bodyText.referenceColorには反映しません。これらはpaletteIdの横に書いた16進の色のままです。画面用のダーク版や色替えのためにパレットを差し替えるときは、ビルドの前に、リンクしたすべての色をcolorPaletteから書き直してください。
- **デザインのテキストのoverflowの既定値は'ellipsis-end'.** 幅に収まらないデザインのテキスト要素は、既定では省略記号で終わります。複数行に折り返したいタイトルにはoverflow: 'wrap'を設定してください。
- **フロントマターの値はすべて引用符で囲む.** YAMLはtitle: 1984を数値として、日付をDateオブジェクトとして読みます。文字列でない値はプレースホルダーに空で出力され、PDFにもタイトルが付きません。値はすべて引用符で囲んでください（title: "1984"）。
- **レイアウトの前にすべてのフォントを読み込む.** レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。

- 1.4.1で`widowMinLines`を3に上げると、CXIXとCXXIはページの下端で見出しの下に3行を保ち、4行の段落は3行と1行に分かれます。`orphanPenalty`の値にかかわらず、214ページと215ページは1行だけで始まります。
- CXVIIIが数行増えると、CXIXの見出しの下には1行分の場所しか残りません。すると`keepWithNext`が見出しを214ページへ送り、213ページは4、5行足りないまま終わります。`keepWithNext: false`にすると、見出しが本文を伴わずに213ページの最後に来ます。
- 口絵はH1なので、`{chapterTitle}`は口絵に戻ってしまいます。CXIXが先へ移ったときの213ページのように、口絵の後でまだ章が始まっていない本文のページは、CAPÍTULO CXXIIIを柱に印刷します。
- `avoidRunts`をオフにすると、本の最後の行は「nadador da manhã.」ではなく「manhã.」だけになります。
- 表紙の後の`:::pagebreak`がなく、口絵もない場合、第CXVIII章は表紙の上端から19.5mm、3mmの見出し行の下から始まります。口絵の後の改ページがない場合は、キャプションが94mmまで上がった時点で、本文の11行が海の上に印刷されます。
- これらのページでは、前接語のハイフン（「Vesti-me」「metteu-se」）で改行する行はありません。CXXIIIの最初の語「Emfim」をイタリックにすると、1.4.1はその段落をインラインの装飾を含むテキスト用の行分割処理に回し、「vencer-」で行を終えて次の行を「se a si mesma」で始めます。ポルトガル語の正書法が行頭で繰り返すハイフンは付きません。
- これらの改ページが成り立つのは、このテキストを9.5/12.6ptで84mmの行長に組んだときだけです。テキスト、サイズ、行長を変えたら、すべてのページの下端とすべての見出しの上の空きを見直してください。

## クレジット

- レシピ: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- テキスト: Dom Casmurro (1899), from the end of chapter CXVIII to the end of chapter CXXIII, in the spelling of the Garnier edition, with one transcription slip corrected (homens, chapter CXXIII): Machado de Assis ([出典](https://www.gutenberg.org/ebooks/55752)), パブリックドメイン
- テキスト: The colophon, and the series name Coleção Casuarina (invented for this recipe): Postext Cookbook, オリジナル
- 画像: The plate of the sea off Flamengo under the Sugarloaf, a painting: Generated With Diffusion Models, オリジナル
- 画像: The casuarina roundel, drawn in code in the book’s palette: Ignacio Ferro, MIT
- 書体: Tinos (Apache-2.0), Abril Fatface (OFL-1.1), League Spartan (OFL-1.1)
- コード: MIT · サンプルの内容: MIT

## 関連レシピ

- [No. 016 · スペイン語を両端そろえで組むポケット判の小説](https://postext.dev/ja/cookbook/spanish-pocket-novel.md): 『Marianela』第I章の冒頭をポケット判で。スペイン語のハイフネーション、1.7倍未満の語間、ラントなし、上げたイニシャル、地図のFigura 1を組みます。 · 難易度 2 (中級) · 小説・戯曲・文芸
- [No. 017 · 1冊の本に5種類の章扉](https://postext.dev/ja/cookbook/five-chapter-openers.md): レベル1に章扉を1つ、Markdownで名前を指す見出しスタイルを4つ用意します。どのスタイルもアクセントの色を差し替え、余白や段組み、ノンブルを変えるものもあります。 · 難易度 3 (上級) · すべてのジャンル
- [No. 014 · 狭い両端そろえの段で語間を均一にする](https://postext.dev/ja/cookbook/justification-lab.md): 語間を通常の0.8〜1.6倍に抑えたKnuth–Plassの改行を、貪欲法の対照版と並べます。対照版のゆるい行、孤立した行、ラントはVDTから読み取って印を付けます。 · 難易度 3 (上級) · 雑誌とジン
