# 詩を1行ずつ組む

> 詩の各行を1段落とし、折り返しを4 em下げてぶら下げます。1918年版のインデントはemスペースで保ち、:::spaceで連の間に1行空けます。

- HTML版: https://postext.dev/ja/cookbook/poetry-collection
- レシピ No. 015 · 文字と本文 · 難易度 2 (中級) · 出力: Canvas
- ジャンル: 詩歌
- 必要なもの postext ≥ 1.4.1 · テスト環境 1.9.2 ／テスト日 2026-10-01
- ページ: [1](https://postext.dev/cookbook/poetry-collection/en/p01.webp?v=a486ccb9), [2](https://postext.dev/cookbook/poetry-collection/en/p02.webp?v=a486ccb9), [3](https://postext.dev/cookbook/poetry-collection/en/p03.webp?v=a486ccb9), [4](https://postext.dev/cookbook/poetry-collection/en/p04.webp?v=a486ccb9), [5](https://postext.dev/cookbook/poetry-collection/en/p05.webp?v=a486ccb9), [6](https://postext.dev/cookbook/poetry-collection/en/p06.webp?v=a486ccb9), [7](https://postext.dev/cookbook/poetry-collection/en/p07.webp?v=a486ccb9), [8](https://postext.dev/cookbook/poetry-collection/en/p08.webp?v=a486ccb9)
- Sandboxで開く: https://postext.dev/ja/sandbox#recipe=poetry-collection&lang=en (.postext: https://postext.dev/cookbook/poetry-collection/en/poetry-collection.postext)
- 最終更新: 2026-09-25
- 他の言語: [en](https://postext.dev/en/cookbook/poetry-collection.md), [es](https://postext.dev/es/cookbook/poetry-collection.md), [ca](https://postext.dev/ca/cookbook/poetry-collection.md), [zh](https://postext.dev/zh/cookbook/poetry-collection.md), [ar](https://postext.dev/ar/cookbook/poetry-collection.md)

## かんたんな説明

Gerard Manley Hopkinsの自然を詠んだ詩4編を収めた小さな詩集。詩の各行を独立させ、詩人の元のインデントと連の間の空行を保つ方法を示します。

## できあがり

Gerard Manley Hopkinsの自然を詠んだ詩4編を収めた小冊子を、140 × 216 mmの判型で、1918年の初版の本文から組みます。小扉の後、シダとナナカマドのフォトグラムの口絵が、Italianaで組んだ扉と向かい合います。目次は各詩を数字とイタリックの題で示し、間隔を空けた点がノンブルまで続きます。各詩はページの頭から始まり、上にセージ色の数字とイタリックの題を置きます。詩の各行は1つの段落で、1918年版どおりにインデントします。『Pied Beauty』の行は韻に応じて段階的に下がり、『The Windhover』の長い行は折り返して、インデントの2倍の深さの4 emでぶら下がります。連の間は1行空け、Hopkins自身が分けた箇所を除いて語は分割しません。日付は各詩の下に右そろえで置き、最後の詩の後にはナナカマドの小枝を添えます。詩のページには、下端中央にノンブルを入れます。

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

- 詩をインデント、連の区切り、ぶら下げた折り返し付きで、ハイフネーションなしに1行ずつ組むには？
- 空行を入れても効かないとき、2つのブロックの間に縦のスペースを追加するには？
- 自動で更新される目次（リーダー、ノンブル、著者名、部の行）を追加するには？
- エピグラフ、献辞、署名、大きな引用符の付いたプルクオートを組むには？
- 番号のないアートワーク（オーナメント、カット、ロゴ）を組むには？

## 手短な答え

```js
// script.js, 行 31–54
// A poem is one :::paragraphs{style="verse"} container with a paragraph per line, so no line
// runs on into the next. An indented line starts with spaces, two to an em, and a :::space
// line leaves one line of the grid between stanzas (blank lines only separate paragraphs):
//   :::paragraphs{style="verse"}
//   The world is charged with the grandeur of God.
//
//       It will flame out, like shining from shook foil;
//
//   :::space
//
//   And for all this, nature is never spent;
//   :::
const verse = {
  id: 'verse',
  textAlign: 'left', // ragged: a line that turns over is not stretched to the measure
  hangingIndent: em(4), // a turned line hangs past the 1 and 2 em indents
  // Verse is never hyphenated; ragged text is not in 1.4.1 either (gotcha: ragged-no-hyphenation).
  hyphenation: false,
};
// A paragraph loses its leading spaces, and hangingIndent overrides firstLineIndent, so in verse
// each pair of leading spaces becomes an em space behind a word joiner, where the trim stops.
const indentVerse = (md) => md.replace(/:::paragraphs\{style="verse"\}\n[\s\S]*?\n:::\n/g,
  (poem) => poem.replace(/^((?: {2})+)(?=\S)/gm, (s) => `\u2060${'\u2003'.repeat(s.length / 2)}`));
// Hook-up: paragraphStyles: [verse, …] and buildDocument({ markdown: indentVerse(markdown) }).
```

## 材料

**学べること**

- [段落スタイル](https://postext.dev/ja/docs/configuration.md#段落スタイル): 本文とは別に組む一連の段落に付ける名前付きのスタイルです。詩、エピグラフ、献辞、署名、小さな注記などに使います。
- [明示的な縦のアキ](https://postext.dev/ja/docs/document-format.md#space): 2つのブロックのあいだに、行単位（端数も可）のアキを加えます。空行を入れても何も加わりません。段の先頭では無視されます。
- [目次](https://postext.dev/ja/docs/configuration.md#目次): 見出しから作る:::tocです。点線のリーダー、印刷どおりのページラベル、著者名の行、色付きの部の行、クリックできるPDFの項目を持ちます。

**ほかに使うもの**

- [字下げ、行そろえ、段落間隔](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/document-format.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/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/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#レイアウト), [`page`](https://postext.dev/ja/docs/configuration.md#ページ), [`paragraphStyles`](https://postext.dev/ja/docs/configuration.md#段落スタイル), [`resourceTypes`](https://postext.dev/ja/docs/configuration.md#リソースの種類), [`toc`](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#ページをビットマップに描画する)

**書体**

- Sorts Mill Goudy (OFL-1.1), Italiana (OFL-1.1), Marcellus SC (OFL-1.1)

## 作り方

### 1 · 詩の1行は1段落

コードは上の[手短な答え](#手短な答え)にあります。PostextはMarkdownの段落内の行を1つにつなげるので、詩には1行ごとに1段落が必要です。詩のスタイルはその段落を左そろえ（行末不ぞろい）で組みます。両端そろえにすると、折り返した行の前半が行長いっぱいに引き伸ばされるからです。行の残りは4 em下げてぶら下げ、韻を踏む行の1 em、2 emのインデントより深くするので、折り返しが独立した1行には見えません（[段落スタイル](/ja/docs/configuration#段落スタイル)）。段落は行頭のスペースを、emスペースも含めて失います。またぶら下げインデントを持つスタイルは1行目のインデントを無視します。そこで`indentVerse`は、行頭のスペース2つごとに、ワードジョイナーの後ろにemスペースを1つ書きます。ワードジョイナーは幅のない文字で、削除はそこで止まります。互いに韻を踏む行は、1918年版と同じく同じインデントを保ちます。空行は空きを足しません。連の区切りはどれもコンテナー内の`:::space`の行で、グリッド1行分を空け、ページの頭では捨てられます（[`:::space`](/ja/docs/document-format#space)）。

![Opening page 6: II The Windhover.](https://postext.dev/cookbook/poetry-collection/en/p06.webp?v=a486ccb9)

*『The Windhover』、6ページ目。-idingで韻を踏む行は2 em下げて始まり、ページに収まらない4つの長い行は折り返して4 em下げてぶら下がります。*

### 2 · 詩の周りの行にもスタイルがある

```js
// script.js, 行 58–62
const lineStyles = [
  { id: 'dedication', fontSize: pt(9.5), marginBottom: pt(LEAD) }, // under The Windhover
  { id: 'date', fontFamily: 'Marcellus SC', fontSize: pt(8), color: col('muted'),
    textAlign: 'right', marginTop: pt(LEAD) }, // a line of space above, flush right
];
```

『The Windhover』の上の献辞と、各詩の下の場所と日付は詩の行ではありません。そこでそれぞれを1段落だけの`:::paragraphs`コンテナーにし、独自のスタイルで組みます。どちらのスタイルも行送りを設定せず、マージンは1行分（15 pt）なので、詩は本文のグリッドに乗ったままです。日付は詩の1行下に右そろえで、ノンブルと同じ書体、8 ptのMarcellus SCで組みます。

### 3 · 各詩は番号の下でページを始める

```js
// script.js, 行 66–81
// '{1:I}' numbers the poems I to IV and {number} prints it (gotcha: heading-number-placeholders).
// The head is HEAD_LINES grid lines (minHeight, no bottom margin), so each poem starts on the same
// line; numeral and title fill 4, or 5 if the title wraps (gotcha: overflow-ellipsis-default).
const HEAD_LINES = 6;
const poemHead = { enabled: true, minHeight: pt(LEAD * HEAD_LINES), slot: { elements: [
  { kind: 'text', id: 'numeral', content: '{number}', fontFamily: 'Italiana', fontSize: pt(22),
    color: col('sage'), align: 'left',
    placement: { anchor: { to: 'container', edge: 'top-left' } } },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Sorts Mill Goudy',
    italic: true, fontSize: pt(17), color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { anchor: { to: '#numeral', edge: 'below' }, offset: { y: mm(2) },
      size: { width: 'fill' } } },
] } };
// Parity 'any': the next page, recto or verso (restated: gotcha headings-drop-h1-break).
const poems = { level: 1, numberingTemplate: '{1:I}', advancedDesign: poemHead,
  marginBottom: pt(0), breakBefore: { enabled: true, parity: 'any' } };
```

見出しはテキストの代わりに、段の中にデザインを印刷します。そのデザインの中で、`{number}`は`numberingTemplate: '{1:I}'`が書式化した数（IからIV）、`{titleText}`は題です（[幅と詳細デザイン](/ja/docs/configuration#幅と詳細デザイン)）。レベルには下マージンがなく、`minHeight`ですべての見出しをグリッド6行分（31.75 mm）の高さにします。数字と1行の題はそのうち4行を、折り返す題は5行を埋めるので、どの詩もページの同じ行から始まります。[6ページ目](https://postext.dev/cookbook/poetry-collection/en/p06.webp?v=a486ccb9)では、その行に献辞が入ります。パリティ`'any'`は奇数ページか偶数ページかを問わず次のページへ改ページするので、詩の間に白ページはできません。

### 4 · 前付けは見出しでできている

```js
// script.js, 行 85–115
// Headings too, each on a page of its own, with no number, contents entry or folio.
// A style restates the break, or inherits the poems' (gotcha: style-inherits-break).
const leaf = { numbered: false, toc: false, span: 'page', footer: { elements: [] },
  breakBefore: { enabled: true, parity: 'any' } };
const onPage = (y, width) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) },
  ...(width && { size: { width: mm(width) } }) }); // centred, y mm below the trim
const face = (id, content, font, size, y, extra = {}) => ({ kind: 'text', id, content,
  fontFamily: font, fontSize: pt(size), color: col('ink'), align: 'center',
  placement: onPage(y), ...extra });
const image = (id, placement) => ({ kind: 'image', id, resourceId: id, placement });
const design = (...elements) => ({ enabled: true, slot: { elements } });
const front = [
  { id: 'half-title', ...leaf,
    advancedDesign: design(face('title', '{titleText}', 'Italiana', 20, 60)) },
  { id: 'plate', ...leaf, advancedDesign: design( // span 'page': a column clips its design
    image('plate', { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } }),
    face('caption', '{attr.caption}', 'Sorts Mill Goudy', 8.5, 203,
      { italic: true, color: col('muted') })) },
  { id: 'title-page', ...leaf, advancedDesign: design(
    face('author', '{author}', 'Marcellus SC', 10, 46, // {author}, {title}: the frontmatter
      { letterSpacing: pt(2.4), textTransform: 'uppercase' }),
    // A multiple of the size, never pt() (gotcha: design-lineheight-multiple).
    face('title', '{title}', 'Italiana', 54, 56, { lineHeight: 1 }),
    face('subtitle', '{subtitle}', 'Sorts Mill Goudy', 13, 80,
      { italic: true, color: col('sage') }),
    image('sprig', onPage(96, 30)),
    face('press', 'The Herbarium Press', 'Marcellus SC', 8.5, 182,
      { letterSpacing: pt(1.8), color: col('muted') })) },
  { id: 'contents', ...leaf, span: 'column', advancedDesign: { enabled: false },
    fontFamily: 'Italiana', fontSize: pt(24), lineHeight: pt(LEAD * 2), marginBottom: pt(LEAD) },
];
```

見出しスタイルは新しいページへ改ページし、そこにデザインを描けます。そのため小扉、口絵、扉はそれぞれ1つの見出しで、それぞれ独自のスタイルを持ちます。`numbered: false`で数から外すので、最初の詩はIのままです。`toc: false`で目次から外し、空のフッターでそれらのページにはノンブルを入れません（[見出しスタイル](/ja/docs/configuration#見出しスタイル)）。スタイルがページ全体にまたがるのは、段の中のデザインは版面でクリップされ、裁ち落としの図版が余白で切られてキャプションも切れてしまうからです。キャプションは見出しの`caption`属性から、扉の著者、題、副題はフロントマターから取ります。

### 5 · 目次はひとりでに埋まる

```js
// script.js, 行 119–126
// The toc centres each numeral on its line, not on the baseline (gotcha: toc-number-baseline);
// left at the titles' size, these Italiana numerals land on it, where 9 pt ones rode high.
const contents = {
  levels: [{ level: 1, italic: true, marginBottom: pt(LEAD), numberFontFamily: 'Italiana',
    numberFontWeight: 400, numberColor: col('sage'), numberWidth: mm(6), numberGap: mm(3) }],
  pageNumber: { fontFamily: 'Marcellus SC', fontSize: pt(9), color: col('muted'), width: mm(6) },
  leader: { char: '. ', gap: mm(2) }, // spaced dots, right-aligned so they line up
};
```

`:::toc`は、スタイルが許すすべての見出しを、同じテンプレートによる数字と、見出しが置かれたページのラベルとともに並べます。組版は、それらのページラベルが変わらなくなるまで本を組み直します（[目次](/ja/docs/configuration#目次)）。このページ自身の見出しContentsは、`numbered: false`のスタイルを持ちます。これがないと、Contentsが詩のIになってしまいます。リーダー`'. '`は各点の後にスペースを入れます。点は右そろえなので項目から項目へと縦にそろいます。`toc.leader`には色の設定がないので、点はノンブルと同じ書体と色で印刷されます。

### 6 · 飾りはラベルのないリソースの種類

```js
// script.js, 行 130–134
// No caption prefix and no caption: the sprig prints bare, where ::resource{id="sprig"} puts
// it (double quotes: gotcha resource-double-quotes), a fifth of the measure wide, centred.
const ornament = { id: 'ornament', name: 'Ornament', shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  defaultPlacement: { position: 'here', width: 0.2, align: 'center' } };
```

本文中の図版はリソースで、リソースにはすべて種類があります。飾りの種類はキャプションのプレフィックスが空で、小枝にはキャプションがないので、下に「Figure 1」のようなラベルは印刷されません（[リソースの種類](/ja/docs/configuration#リソースの種類)）。種類の`defaultPlacement`は小枝を`::resource{id="sprig"}`の位置に、中央そろえで行長の5分の1の幅で置きます。そのため『Inversnaid』の下の尾飾りはMarkdown1行で済みます。`resourceTypes`は既定のリストを置き換えるので、この本にはFigureやTableの種類がありません。残したいときは、飾りの前に`defaultResourceTypes()`を展開します。

## レシピの全体

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

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

### script.js

```js
// ═══ Postext Cookbook · Nº 015 · Poems set line by line ═════════════════════════════
// https://postext.dev/en/cookbook/poetry-collection
// Code: MIT · Text: G. M. Hopkins, Poems, 1918 (PD) · Plate: diffusion models · Sprig: code
// Fonts: Sorts Mill Goudy, Italiana, Marcellus 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 = 'poetry-collection';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // every colour in the config links to one of these
  ink: '#26221f', // the text: a warm near-black
  sage: '#56673f', // the one accent: numerals, the subtitle, the ornament's leaves
  sepia: '#8c5f3a', // used for the rowan berries only
  muted: '#746a60', // dates, folios, leaders and the colophon
  paper: '#fbf8f2', // the page
};
// The hex travels with the id: designs do not read the palette (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 engine's defaults link to 'main-color': point it at the accent, so nothing prints blue.
  { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.sage, model: 'hex' } },
];
const TRIM_W = 140, TRIM_H = 216; // mm: a poetry trim, tall enough for a sonnet's turnovers
const LEAD = 15; // pt: the body's leading, the grid every line of verse sits on

// #region answer: verse: a paragraph per line, indents kept, turnovers that hang, stanza space
// A poem is one :::paragraphs{style="verse"} container with a paragraph per line, so no line
// runs on into the next. An indented line starts with spaces, two to an em, and a :::space
// line leaves one line of the grid between stanzas (blank lines only separate paragraphs):
//   :::paragraphs{style="verse"}
//   The world is charged with the grandeur of God.
//
//       It will flame out, like shining from shook foil;
//
//   :::space
//
//   And for all this, nature is never spent;
//   :::
const verse = {
  id: 'verse',
  textAlign: 'left', // ragged: a line that turns over is not stretched to the measure
  hangingIndent: em(4), // a turned line hangs past the 1 and 2 em indents
  // Verse is never hyphenated; ragged text is not in 1.4.1 either (gotcha: ragged-no-hyphenation).
  hyphenation: false,
};
// A paragraph loses its leading spaces, and hangingIndent overrides firstLineIndent, so in verse
// each pair of leading spaces becomes an em space behind a word joiner, where the trim stops.
const indentVerse = (md) => md.replace(/:::paragraphs\{style="verse"\}\n[\s\S]*?\n:::\n/g,
  (poem) => poem.replace(/^((?: {2})+)(?=\S)/gm, (s) => `\u2060${'\u2003'.repeat(s.length / 2)}`));
// Hook-up: paragraphStyles: [verse, …] and buildDocument({ markdown: indentVerse(markdown) }).
// #endregion

// #region lines: the lines around a poem: a dedication above it, a place and a date below
const lineStyles = [
  { id: 'dedication', fontSize: pt(9.5), marginBottom: pt(LEAD) }, // under The Windhover
  { id: 'date', fontFamily: 'Marcellus SC', fontSize: pt(8), color: col('muted'),
    textAlign: 'right', marginTop: pt(LEAD) }, // a line of space above, flush right
];
// #endregion

// #region poem-head: every poem opens a page under its numeral and its title
// '{1:I}' numbers the poems I to IV and {number} prints it (gotcha: heading-number-placeholders).
// The head is HEAD_LINES grid lines (minHeight, no bottom margin), so each poem starts on the same
// line; numeral and title fill 4, or 5 if the title wraps (gotcha: overflow-ellipsis-default).
const HEAD_LINES = 6;
const poemHead = { enabled: true, minHeight: pt(LEAD * HEAD_LINES), slot: { elements: [
  { kind: 'text', id: 'numeral', content: '{number}', fontFamily: 'Italiana', fontSize: pt(22),
    color: col('sage'), align: 'left',
    placement: { anchor: { to: 'container', edge: 'top-left' } } },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Sorts Mill Goudy',
    italic: true, fontSize: pt(17), color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { anchor: { to: '#numeral', edge: 'below' }, offset: { y: mm(2) },
      size: { width: 'fill' } } },
] } };
// Parity 'any': the next page, recto or verso (restated: gotcha headings-drop-h1-break).
const poems = { level: 1, numberingTemplate: '{1:I}', advancedDesign: poemHead,
  marginBottom: pt(0), breakBefore: { enabled: true, parity: 'any' } };
// #endregion

// #region front: the half-title, the frontispiece, the title page and the contents
// Headings too, each on a page of its own, with no number, contents entry or folio.
// A style restates the break, or inherits the poems' (gotcha: style-inherits-break).
const leaf = { numbered: false, toc: false, span: 'page', footer: { elements: [] },
  breakBefore: { enabled: true, parity: 'any' } };
const onPage = (y, width) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) },
  ...(width && { size: { width: mm(width) } }) }); // centred, y mm below the trim
const face = (id, content, font, size, y, extra = {}) => ({ kind: 'text', id, content,
  fontFamily: font, fontSize: pt(size), color: col('ink'), align: 'center',
  placement: onPage(y), ...extra });
const image = (id, placement) => ({ kind: 'image', id, resourceId: id, placement });
const design = (...elements) => ({ enabled: true, slot: { elements } });
const front = [
  { id: 'half-title', ...leaf,
    advancedDesign: design(face('title', '{titleText}', 'Italiana', 20, 60)) },
  { id: 'plate', ...leaf, advancedDesign: design( // span 'page': a column clips its design
    image('plate', { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } }),
    face('caption', '{attr.caption}', 'Sorts Mill Goudy', 8.5, 203,
      { italic: true, color: col('muted') })) },
  { id: 'title-page', ...leaf, advancedDesign: design(
    face('author', '{author}', 'Marcellus SC', 10, 46, // {author}, {title}: the frontmatter
      { letterSpacing: pt(2.4), textTransform: 'uppercase' }),
    // A multiple of the size, never pt() (gotcha: design-lineheight-multiple).
    face('title', '{title}', 'Italiana', 54, 56, { lineHeight: 1 }),
    face('subtitle', '{subtitle}', 'Sorts Mill Goudy', 13, 80,
      { italic: true, color: col('sage') }),
    image('sprig', onPage(96, 30)),
    face('press', 'The Herbarium Press', 'Marcellus SC', 8.5, 182,
      { letterSpacing: pt(1.8), color: col('muted') })) },
  { id: 'contents', ...leaf, span: 'column', advancedDesign: { enabled: false },
    fontFamily: 'Italiana', fontSize: pt(24), lineHeight: pt(LEAD * 2), marginBottom: pt(LEAD) },
];
// #endregion

// #region contents: what :::toc prints for each poem: numeral, italic title, leader, page
// The toc centres each numeral on its line, not on the baseline (gotcha: toc-number-baseline);
// left at the titles' size, these Italiana numerals land on it, where 9 pt ones rode high.
const contents = {
  levels: [{ level: 1, italic: true, marginBottom: pt(LEAD), numberFontFamily: 'Italiana',
    numberFontWeight: 400, numberColor: col('sage'), numberWidth: mm(6), numberGap: mm(3) }],
  pageNumber: { fontFamily: 'Marcellus SC', fontSize: pt(9), color: col('muted'), width: mm(6) },
  leader: { char: '. ', gap: mm(2) }, // spaced dots, right-aligned so they line up
};
// #endregion

// #region ornament: a resource type for artwork that prints no caption and no label
// No caption prefix and no caption: the sprig prints bare, where ::resource{id="sprig"} puts
// it (double quotes: gotcha resource-double-quotes), a fifth of the measure wide, centred.
const ornament = { id: 'ornament', name: 'Ornament', shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  defaultPlacement: { position: 'here', width: 0.2, align: 'center' } };
// #endregion

const proseStyles = [ // the note on the text and the colophon, under the contents
  { id: 'note', fontSize: pt(9.5), lineHeight: pt(13) },
  { id: 'colophon', fontSize: pt(8), lineHeight: pt(11), color: col('muted'), textAlign: 'left',
    firstLineIndent: pt(0), marginTop: pt(LEAD) },
];
const folio = (pages) => ({ kind: 'text', id: `folio-${pages}`, content: '{pageNumber}', pages,
  fontFamily: 'Marcellus SC', fontSize: pt(9), color: col('muted'), align: 'center',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(10) } } });

const config = () => ({ // a factory: the engine caches resolved configs per object
  colorPalette,
  // The list replaces Figure and Table; a book with figures spreads defaultResourceTypes() in.
  resourceTypes: [ornament],
  page: { // mirror: left is the inner margin; 150 dpi is for the screen
    sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150,
    backgroundColor: col('paper'),
    margins: { top: mm(22), bottom: mm(24), left: mm(22), right: mm(18), mirror: true },
  },
  layout: { layoutType: 'single' },
  bodyText: { // the note's prose: justified, hyphenated (en-us), spaces 0.8–1.6 of normal
    fontFamily: 'Sorts Mill Goudy', fontSize: pt(11), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: mm(4),
    indentAfterHeading: false, minWordSpacing: 0.8, maxWordSpacing: 1.6,
  },
  headings: {
    fontFamily: 'Sorts Mill Goudy', fontWeight: 400, color: col('ink'),
    levels: [poems, { level: 2, fontFamily: 'Marcellus SC', fontSize: pt(9), lineHeight: pt(LEAD),
      color: col('sage'), marginTop: pt(0), marginBottom: pt(0) }],
  },
  headingStyles: front,
  toc: contents,
  paragraphStyles: [verse, ...lineStyles, ...proseStyles],
  header: { elements: [] }, // no running heads: each poem starts a page under its own title
  footer: { elements: [folio('opener'), folio('body')] }, // centred; never on a blank page
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Pied Beauty"
subtitle: "Four poems of the natural world"
author: "Gerard Manley Hopkins"
---

# Pied Beauty {style="half-title"}

# Fern and Rowan {style="plate" caption="Fern and rowan: the ‘flitches of fern’ and the ‘beadbonny ash’ of Inversnaid"}

# Pied Beauty {style="title-page"}

# Contents {style="contents"}

:::toc

:::space{lines=2}

## A note on the text

:::paragraphs{style="note"}
This selection follows the first edition of the poems, edited by Robert Bridges in 1918, nearly thirty years after Hopkins’s death. Its spelling, accents and indents are kept, and lines that rhyme with one another start the same distance in. A line too long for the page is turned over, and the turnover is set deeper than the lines around it, so that it reads as part of the line above. No word is broken unless Hopkins broke it, as he did at the end of the first line of *The Windhover*. Under each poem is the date from Bridges’s notes, with the place when he gives one.
:::

:::paragraphs{style="colophon"}
Set in Sorts Mill Goudy, Italiana and Marcellus SC (SIL Open Font License). Text from Project Gutenberg eBook 22403, accents and indents from Wikisource. Plate made with diffusion models; ornament drawn for this edition.
:::

# God’s Grandeur

:::paragraphs{style="verse"}
The world is charged with the grandeur of God.

    It will flame out, like shining from shook foil;

    It gathers to a greatness, like the ooze of oil

Crushed. Why do men then now not reck his rod?

Generations have trod, have trod, have trod;

    And all is seared with trade; bleared, smeared with toil;

    And wears man’s smudge and shares man’s smell: the soil

Is bare now, nor can foot feel, being shod.

:::space

And for all this, nature is never spent;

    There lives the dearest freshness deep down things;

And though the last lights off the black West went

    Oh, morning, at the brown brink eastward, springs—

Because the Holy Ghost over the bent

    World broods with warm breast and with ah! bright wings.
:::

:::paragraphs{style="date"}
23 February 1877
:::

# The Windhover

:::paragraphs{style="dedication"}
*To Christ our Lord*
:::

:::paragraphs{style="verse"}
I caught this morning morning’s minion, king-

    dom of daylight’s dauphin, dapple-dawn-drawn Falcon, in his riding

    Of the rolling level underneath him steady air, and striding

High there, how he rung upon the rein of a wimpling wing

In his ecstacy! then off, off forth on swing,

    As a skate’s heel sweeps smooth on a bow-bend: the hurl and gliding

    Rebuffed the big wind. My heart in hiding

Stirred for a bird,—the achieve of, the mastery of the thing!

:::space

Brute beauty and valour and act, oh, air, pride, plume, here

    Buckle! AND the fire that breaks from thee then, a billion

Times told lovelier, more dangerous, O my chevalier!

:::space

    No wonder of it: shéer plód makes plough down sillion

Shine, and blue-bleak embers, ah my dear,

    Fall, gall themselves, and gash gold-vermillion.
:::

:::paragraphs{style="date"}
St Beuno’s, 30 May 1877
:::

# Pied Beauty

:::paragraphs{style="verse"}
Glory be to God for dappled things—

  For skies of couple-colour as a brinded cow;

    For rose-moles all in stipple upon trout that swim:

Fresh-firecoal chestnut-falls; finches’ wings;

  Landscape plotted and pieced—fold, fallow, and plough;

    And áll trádes, their gear and tackle and trim.

:::space

All things counter, original, spare, strange;

  Whatever is fickle, freckled (who knows how?)

    With swift, slow; sweet, sour; adazzle, dim;

He fathers-forth whose beauty is past change:

                Praise him.
:::

:::paragraphs{style="date"}
St Beuno’s, Tremeirchion, summer 1877
:::

# Inversnaid

:::paragraphs{style="verse"}
This darksome burn, horseback brown,

His rollrock highroad roaring down,

In coop and in comb the fleece of his foam

Flutes and low to the lake falls home.

:::space

A windpuff-bonnet of fáwn-fróth

Turns and twindles over the broth

Of a pool so pitchblack, féll-frówning,

It rounds and rounds Despair to drowning.

:::space

Degged with dew, dappled with dew

Are the groins of the braes that the brook treads through,

Wiry heathpacks, flitches of fern,

And the beadbonny ash that sits over the burn.

:::space

What would the world be, once bereft

Of wet and of wildness? Let them be left,

O let them be left, wildness and wet;

Long live the weeds and the wilderness yet.
:::

:::paragraphs{style="date"}
28 September 1881
:::

::resource{id="sprig"}
`; // content.<lang>.md, inlined by the Cookbook

// #region art: a rowan sprig for the ornament, drawn in code; the plate is a JPEG in assets/
const f = (n) => n.toFixed(1);
const path = (d, fill, a = 1) => `<path d="${d}" fill="${fill}" fill-opacity="${a}"/>`;
const ring = (pts) => `M${pts.map(([x, y]) => `${f(x)} ${f(y)}`).join('L')}Z`;
const disk = (x, y, r, fill, a = 1) => `<circle cx="${f(x)}" cy="${f(y)}" r="${f(r)}" `
  + `fill="${fill}" fill-opacity="${a}"/>`;
const PX = 10; // a drawing w × h mm has a viewBox in tenths of a millimetre, w·PX × h·PX
const svgOf = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * PX}" `
  + `height="${h * PX}" viewBox="0 0 ${w * PX} ${h * PX}">${body}</svg>`;
// A cubic Bézier and its unit normal at t.
const bez = ([p0, p1, p2, p3], t) => {
  const u = 1 - t;
  const coord = (i) => u * u * u * p0[i] + 3 * u * u * t * p1[i] + 3 * u * t * t * p2[i]
    + t * t * t * p3[i];
  const dt = (i) => 3 * u * u * (p1[i] - p0[i]) + 6 * u * t * (p2[i] - p1[i])
    + 3 * t * t * (p3[i] - p2[i]);
  const [dx, dy] = [dt(0), dt(1)];
  const len = Math.hypot(dx, dy) || 1;
  return { x: coord(0), y: coord(1), a: Math.atan2(dy, dx), nx: -dy / len, ny: dx / len };
};
// A stalk: the curve drawn as a filled band tapering from w0 to w1.
const stalk = (curve, w0, w1) => {
  const left = [];
  const right = [];
  for (let i = 0; i <= 40; i++) {
    const p = bez(curve, i / 40);
    const w = (w0 + (w1 - w0) * (i / 40)) / 2;
    left.push([p.x + p.nx * w, p.y + p.ny * w]);
    right.unshift([p.x - p.nx * w, p.y - p.ny * w]);
  }
  return ring([...left, ...right]);
};
// A leaf blade from its base (x, y) along angle a: length l, half-width w, `teeth` serrations.
const blade = (x, y, a, l, w, teeth = 0) => {
  const [c, s] = [Math.cos(a), Math.sin(a)];
  const at = (u, v) => [x + u * c - v * s, y + u * s + v * c];
  const side = (sign) => Array.from({ length: 25 }, (_, i) => {
    const t = i / 24;
    let h = w * Math.sin(Math.PI * t ** 0.85) ** 0.9;
    if (teeth && t > 0.25) h *= 1 - 0.18 * ((t * teeth) % 1);
    return at(l * t, sign * h);
  });
  return ring([...side(1), ...side(-1).reverse()]);
};
// The ornament: a rowan leaf laid flat, with a bunch of berries at its tip.
function sprig() {
  const [w, h] = [36, 12];
  const rib = [[16, 66], [90, 56], [170, 56], [236, 60]];
  const d = [stalk(rib, 6, 3)];
  for (let j = 0; j < 5; j++) {
    const q = bez(rib, 0.12 + j * 0.19);
    const size = 50 - j * 3;
    for (const sgn of [1, -1]) d.push(blade(q.x, q.y, q.a + sgn * 1.1, size, size * 0.22, 8));
  }
  const tip = bez(rib, 1);
  d.push(blade(tip.x, tip.y, tip.a, 44, 10.5, 8));
  const out = [path(d.join(''), palette.sage)];
  for (const [dx, dy, r] of [[34, -16, 9], [44, 2, 10], [30, 14, 9], [52, -12, 8.5],
    [58, 10, 8], [22, -2, 8]]) {
    const [bx, by] = [262 + dx, 60 + dy];
    out.push(path(stalk([[240, 64], [252, 66], [bx - 10, by + 2], [bx, by]], 2.6, 1.8),
      palette.sage), disk(bx, by, r, palette.sepia));
  }
  return svgOf(w, h, out.join(''));
}

await loadSvg('sprig.svg', sprig());
// The frontispiece is a photogram: the whole 140 × 216 mm page, its torn coating and the paper
// under it in one JPEG, declared at its pixels.
await loadImage('plate-1120.jpg', asset('plate-1120.jpg'));

// Nothing cites them: designs show the plate and the sprig, ::resource sets the tailpiece.
const svgResource = (id, w, h, altText) => ({ id, typeId: 'ornament', kind: 'svg', altText,
  createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: w * PX, height: h * PX } });
const resources = [
  { id: 'plate', typeId: 'ornament', kind: 'bitmap', createdAt: 0, updatedAt: 0,
    bitmap: { fileId: 'plate-1120.jpg', format: 'jpeg', width: 1120, height: 1728 },
    altText: 'A photogram of a fern frond and a sprig of rowan in berry, left white on a '
      + 'brushed sage ground.' },
  svgResource('sprig', 36, 12, 'Ornament: a rowan leaf with a bunch of berries.'),
];
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Loaded before the first build (gotcha: fonts-first). None of the three ships a bold.
const FONTS = { 'Sorts Mill Goudy': ['400', '400i'], Italiana: ['400'], 'Marcellus SC': ['400'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
// :::toc lists the page each poem lands on: the build lays out again until those settle.
const verses = indentVerse(markdown); // the leading spaces of verse, as em spaces
const doc = await buildWithFonts(() => buildDocument({ markdown: verses, resources }, config()),
  markdown);
showPages(doc, { title: 'Pied Beauty · four poems by Gerard Manley Hopkins' });

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

## アレンジ

### 折り返しをもっと深くぶら下げる

ぶら下げインデントを深くすると、折り返した行の残りが余白からさらに離れます。行が頻繁に折り返す詩に向きます。

```diff
-  hangingIndent: em(4), // a turned line hangs past the 1 and 2 em indents
+  hangingIndent: em(6), // a turned line hangs past the 1 and 2 em indents
```

### すべての行を余白にそろえる

`indentVerse`がなければ、Postextは行頭のスペースを削り、詩のどの行も左端から始まります。ぶら下がるのは折り返しだけです。

```diff
-const verses = indentVerse(markdown); // the leading spaces of verse, as em spaces
+const verses = markdown; // leading spaces left to the trim
```

### どの詩も右ページから始める

パリティを`'odd'`にすると、各詩は次の奇数ページから始まります。本は8ページから11ページに増えます。3つの白い偶数ページにノンブルは印刷されません。ノンブルをページの役割で絞り込んでいるからです。

```diff
 const poems = { level: 1, numberingTemplate: '{1:I}', advancedDesign: poemHead,
-  marginBottom: pt(0), breakBefore: { enabled: true, parity: 'any' } };
+  marginBottom: pt(0), breakBefore: { enabled: true, parity: 'odd' } };
```

### 詩の番号を算用数字にする

テンプレートのカウンターのスタイルを変えると、詩のページと目次の数字が同時に変わります。

```diff
-const poems = { level: 1, numberingTemplate: '{1:I}', advancedDesign: poemHead,
+const poems = { level: 1, numberingTemplate: '{1}', advancedDesign: poemHead,
```

## よくあるつまずき

- **行末不ぞろいのテキストはハイフネーションされない.** ハイフネーションが効くのは両端そろえのテキストだけです。左そろえのテキストは語と語の間で改行するため、幅の狭い左そろえの段では行末の凹凸が大きくなります。その箇所を両端そろえにするか、行長を広げてください。
- **段落の頭の'1998. 'や'- 'はリストになる.** 数字、ピリオド、スペースで始まる段落や、ハイフンとスペースで始まる段落はリスト項目になります。数字の前にワードジョイナー（U+2060）を入れ、会話文はemダッシュで書いてください。
- **目次の番号が項目のベースラインより少し上に出る.** postext 1.4.1の:::tocは、各項目の番号をテキストのベースラインではなく行の中央に合わせて描くため、章番号がタイトルの横でわずかに浮きます。16 ptのタイトルの横で約0.7 mmで、書体や大きさを変えても同じです。これを動かすtocのオプションはまだないので、印刷する前に目次のページを原寸で確認してください。
- **{number}/{chapterNumber}はH1の番号を出す。{numberRoman}は部だけ.** {number}と{chapterNumber}は見出しの書式付きの番号を出力しますが、{numberRoman}、{numberDecimal}などの数値のバリエーションが埋まるのは部扉だけです。章番号の書式はnumberingTemplate（{1:I}）で指定するか、属性として渡してください。
- **見出しスタイルはその階層の改ページを受け継ぐ.** headingStylesの項目は、指定しなかったフィールドをすべて見出しの階層から受け継ぎ、breakBeforeも例外ではありません。:::pagebreakのあとのH1に付けた目次や奥付のスタイルはparity 'odd'を受け継ぎ、白ページの後ろに置かれます。こうしたスタイルにはbreakBefore: { enabled: false }を指定してください。
- **headingsオブジェクトを渡すとH1の改ページが消える.** 既定ではH1は奇数ページへ改ページします（always-odd）。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。
- **::resource{id="…"}は二重引用符でしか書けない.** ブロックの埋め込みとして認識されるのは、二重引用符を使った::resource{id="…"}の形だけです。ほかの書き方は、目に見える1行として本文に残ります。
- **パレットを差し替えても、デザイン要素と参照色は変わらない.** postext 1.4.1はcolorPaletteをテキストのスタイル（本文、見出し、リスト、キャプション、表、囲み）には反映しますが、ヘッダー、フッター、章扉、部扉の要素と、bodyText.referenceColorには反映しません。これらはpaletteIdの横に書いた16進の色のままです。画面用のダーク版や色替えのためにパレットを差し替えるときは、ビルドの前に、リンクしたすべての色をcolorPaletteから書き直してください。
- **デザインのテキストのlineHeightは倍率で、寸法ではない.** デザインのスロットでは、テキスト要素のlineHeightはフォントサイズに掛ける倍率です（lineHeight: 1.05）。postext 1.4.1ではpt(15)のような寸法を指定しても拒否されず、章扉の高さがNaNと計測されて、minHeightを含め確保する高さが警告なしに失われ、本文がタイトルに重なって組まれます。
- **デザインのテキストのoverflowの既定値は'ellipsis-end'.** 幅に収まらないデザインのテキスト要素は、既定では省略記号で終わります。複数行に折り返したいタイトルにはoverflow: 'wrap'を設定してください。
- **フロントマターの値はすべて引用符で囲む.** YAMLはtitle: 1984を数値として、日付をDateオブジェクトとして読みます。文字列でない値はプレースホルダーに空で出力され、PDFにもタイトルが付きません。値はすべて引用符で囲んでください（title: "1984"）。
- **レイアウトの前にすべてのフォントを読み込む.** レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。

- 詩の各行の後には空行を入れます。間に空行のない2行は1つの段落になり、詩が散文のように流し込まれます。
- 詩のスタイルには独自の`textAlign`を与えます。段落スタイルは本文の設定を継承し、両端そろえの詩では、折り返したすべての行の前半が行長いっぱいに引き伸ばされます。
- 「1877. 」のように年号とピリオドで始まる詩の行や、ハイフンとスペースで始まる行は、すべての行が段落なのでリストの項目になります。`indentVerse`がインデントの前に置くのと同じく、ワードジョイナー（U+2060）を前に置きます。
- 2語を一緒に折り返すには、5ページ目の「the soil」や「bright wings.」のように、ノーブレークスペース（U+00A0）でつなぎます。プレーンテキストの行では効きますが、イタリックや太字を含む行では、エンジンはそこで改行してしまいます。
- `pages`を`'all'`のままにしたフッター要素は、白ページにも印刷されます。ここではノンブルを扉ページ用と本文ページ用の2つの要素に分けているので、白い偶数ページは白のままです。

## クレジット

- レシピ: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- テキスト: “God’s Grandeur”, “The Windhover”, “Pied Beauty” and “Inversnaid”, with the places and dates from the editor’s notes, in the first edition of Poems of Gerard Manley Hopkins (1918); accents and indents checked against the proofread Wikisource transcription of that edition: Gerard Manley Hopkins; Robert Bridges (editor) ([出典](https://www.gutenberg.org/ebooks/22403)), パブリックドメイン
- テキスト: The note on the text, the plate’s caption and the colophon: Ignacio Ferro, CC-BY-4.0
- 画像: The frontispiece photogram of fern and rowan: Generated With Diffusion Models, オリジナル
- 画像: The rowan ornament, drawn in code in the page’s palette: Ignacio Ferro, CC-BY-4.0
- 書体: Sorts Mill Goudy (OFL-1.1), Italiana (OFL-1.1), Marcellus SC (OFL-1.1)
- コード: MIT · サンプルの内容: CC-BY-4.0

## 関連レシピ

- [No. 044 · 校訂版：行番号と行に対応する注](https://postext.dev/ja/cookbook/critical-edition-line-numbers.md): 1645年版の本文によるミルトンの『Lycidas』。スクリプトが行を数えて5行ごとに傍らのボックスを加え、各注はチップで組んだ行番号から始まります。 · 難易度 3 (上級) · 詩歌
- [No. 043 · 対訳の詩を連ごとにそろえる](https://postext.dev/ja/cookbook/bilingual-facing-verse.md): 詩を1篇ずつ枠のない2段の囲みに組みます。固定の段区切りで英語を題から始め、:::spaceで各連を原詩の連と同じ高さにそろえます。 · 難易度 2 (中級) · 詩歌
- [No. 062 · 形の詩：ハーバートの『Easter Wings』を1行ずつ中央に](https://postext.dev/ja/cookbook/shaped-verse.md): ジョージ・ハーバートの『Easter Wings』と『The Altar』を、詩の1行を1段落として本文で中央そろえに組み、行の長さで2つの形を描きます。 · 難易度 1 (初級) · 詩歌
