# 並製本の小説：下げた章頭と改丁

> 『The Awakening』を140 × 216 mmの並製本に組みます。柱のない彩色の表紙と奥付に続き、各章は奇数ページから、イタリックの章番号の下へ下げて始まります。

- HTML版: https://postext.dev/ja/cookbook/trade-paperback-novel
- レシピ No. 038 · 完成した出版物 · 難易度 2 (中級) · 出力: Canvas
- ジャンル: 小説・戯曲・文芸
- 必要なもの postext ≥ 1.4.1 · テスト環境 1.9.2 ／テスト日 2026-10-01
- ページ: [1](https://postext.dev/cookbook/trade-paperback-novel/en/p01.webp?v=de19147c), [2](https://postext.dev/cookbook/trade-paperback-novel/en/p02.webp?v=de19147c), [3](https://postext.dev/cookbook/trade-paperback-novel/en/p03.webp?v=de19147c), [4](https://postext.dev/cookbook/trade-paperback-novel/en/p04.webp?v=de19147c), [5](https://postext.dev/cookbook/trade-paperback-novel/en/p05.webp?v=de19147c), [6](https://postext.dev/cookbook/trade-paperback-novel/en/p06.webp?v=de19147c), [7](https://postext.dev/cookbook/trade-paperback-novel/en/p07.webp?v=de19147c), [8](https://postext.dev/cookbook/trade-paperback-novel/en/p08.webp?v=de19147c)
- Sandboxで開く: https://postext.dev/ja/sandbox#recipe=trade-paperback-novel&lang=en (.postext: https://postext.dev/cookbook/trade-paperback-novel/en/trade-paperback-novel.postext)
- 最終更新: 2026-09-26
- 他の言語: [en](https://postext.dev/en/cookbook/trade-paperback-novel.md), [es](https://postext.dev/es/cookbook/trade-paperback-novel.md), [ca](https://postext.dev/ca/cookbook/trade-paperback-novel.md), [zh](https://postext.dev/zh/cookbook/trade-paperback-novel.md), [ar](https://postext.dev/ar/cookbook/trade-paperback-novel.md)

## かんたんな説明

アメリカの小説の最初の2章を、並製本の本にした例です。どの章も右ページの少し下から、大きな章番号の下で始まります。

## できあがり

140 × 216 mmの並製本の8ページに、Kate Chopinの『The Awakening』（1899）の最初の2章を収めます。表紙は拡散モデルで生成し、JPEGとして読み込んだ絵で、Grand IsleのLebrun家のコテージから見た湾を描いています。ウォーターオークのあいだの水平線にラガー船が浮かび、白い日傘がカモミールの中を上ってきます。奥付はその裏の偶数ページの地に置きます。各章は奇数ページから始まり、本文は81 mm下がった位置から始まります。その上に48 ptのイタリックの章番号、18 mmのさび色の細罫、スモールキャピタルで組んだ章の最初の一文が並び、この一文は意味の切れ目で2行に分けて中央にそろえます。第I章は5ページでカモミールの尾飾りとともに終わるので、6ページは柱もノンブルもない白ページになり、第II章は7ページから始まります。柱は偶数ページに著者名、奇数ページにイタリックの書名を載せます。

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

- 並製本のように、どの章も右ページから、ページの3分の1ほど下げて始めるには？
- 章扉に著者名の行、リード文、ドロップキャップ付きの導入文を加えるには？
- 意図的に白ページを入れたり、節を新しい見開きから始めたりするには？
- 柱を設定するには（左ページに書名、右ページに章タイトル、ノンブルは小口側）？
- 表紙、小扉、扉、奥付を作るには？
- 空行を入れても効かないとき、2つのブロックの間に縦のスペースを追加するには？
- 番号のないアートワーク（オーナメント、カット、ロゴ）を組むには？

## 手短な答え

```js
// script.js, 行 47–79
// # I {lead="A green and yellow parrot, … kept repeating over and over:"}
// The heading's text is the numeral, printed by {titleText}; the chapter's first sentence
// travels as the heading's lead attribute, printed by {attr.lead} in small capitals and
// centred, because design text is never justified (gotcha: design-text-ragged).
const [LEAD_AT, BODY_AT] = [9, 12]; // grid lines: the lead's top; where the text starts
const NUMERAL = 48; // pt
const RULE_Y = (LEAD_AT - 1.5) * LEAD * MM_PER_PT; // mm: a line and a half above the lead
const NUMERAL_Y = RULE_Y - NUMERAL * MM_PER_PT - 3; // mm: the numeral's box ends 3 mm above it
const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y } });
const opener = {
  enabled: true,
  // The two-line lead ends on line LEAD_AT + 2; minHeight leaves a blank line under it and
  // starts the text on line BODY_AT (a one-line lead would leave two).
  minHeight: line(BODY_AT),
  slot: { elements: [
    { kind: 'text', id: 'numeral', content: '{titleText}', fontFamily: DISPLAY, italic: true,
      fontWeight: 500, fontSize: pt(NUMERAL), lineHeight: 1, color: col('ink'),
      align: 'center', placement: centred(mm(NUMERAL_Y)) },
    { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.6),
      color: col('rubric'), placement: { ...centred(mm(RULE_Y)), size: { width: mm(18) } } },
    { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: LABEL, fontWeight: 600,
      fontSize: pt(9.5), letterSpacing: pt(0.6), color: col('ink'), align: 'center',
      lineHeight: LEAD / 9.5, // a multiple, never pt() (gotcha: design-lineheight-multiple)
      overflow: 'wrap', // wrap, not '…' (gotcha: overflow-ellipsis-default)
      paragraphIndent: pt(0.01), // a \n in the lead breaks the line (gotcha: design-text-newline)
      placement: { ...centred(line(LEAD_AT)), size: { width: mm(MEASURE) } } },
  ] },
};
// Every chapter opens on a recto: 'odd' adds a blank verso only after a chapter that ends on
// a recto (restated: gotcha headings-drop-h1-break). marginBottom 0: the level's default
// 0.5 em would start the text a line lower.
const chapter = { level: 1, breakBefore: { enabled: true, parity: 'odd' },
  marginBottom: pt(0), italic: true, advancedDesign: opener };
```

## 材料

**学べること**

- [デザインした章扉](https://postext.dev/ja/docs/configuration.md#幅と詳細デザイン): テキスト、罫、ボックス、画像を自由に組み合わせて描く見出しです。本文の上に必要な高さを確保します。
- [奇数ページから始まる章](https://postext.dev/ja/docs/configuration.md#前で改ページ): 見出しの階層ごとに新しいページから始めます。次のページ、次の奇数ページ、次の偶数ページのいずれかを選び、必要なら白ページを加えます。
- [節ごとの柱](https://postext.dev/ja/docs/configuration.md#見出しスタイル): 見出しスタイルが独自のヘッダーとフッターを持つので、前付け、各節、辞書の見出し文字ごとに柱やノンブルを変えられます。

**ほかに使うもの**

- [見開きの余白](https://postext.dev/ja/docs/configuration.md#見開きの余白)
- [見出しスタイル](https://postext.dev/ja/docs/configuration.md#見出しスタイル)
- [表紙、扉、奥付](https://postext.dev/ja/docs/configuration.md#見出しスタイル)
- [見出しの属性](https://postext.dev/ja/docs/document-format.md#見出しの属性)
- [ページデザインのテキスト・罫・ボックス](https://postext.dev/ja/docs/configuration.md#柱とノンブル)
- [ページデザインの画像](https://postext.dev/ja/docs/configuration.md#画像要素)
- [節ごとのページ構成](https://postext.dev/ja/docs/configuration.md#見出しスタイル)
- [ページの役割ごとの柱](https://postext.dev/ja/docs/configuration.md#テキスト要素)
- [柱とノンブル](https://postext.dev/ja/docs/configuration.md#柱とノンブル)
- [文書のメタデータ](https://postext.dev/ja/docs/document-format.md#フロントマター)
- [段落スタイル](https://postext.dev/ja/docs/configuration.md#段落スタイル)
- [明示的な縦のアキ](https://postext.dev/ja/docs/document-format.md#space)
- [リソースとしての図と表](https://postext.dev/ja/docs/document-format.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#オーファンウィドウラント分割禁止の規則)

**設定の一覧**

- [`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#リソースの種類)

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

**書体**

- Crimson Pro (OFL-1.1), Cormorant Garamond (OFL-1.1), Cormorant SC (OFL-1.1)

## 作り方

### 1 · 章扉は見出しとその属性から作る

コードは上の[手短な答え](#手短な答え)にあります。Chopinの章には題がないので、各見出しには番号だけを書き（`# I`）、それを`{titleText}`が印刷します。`{attr.lead}`は見出しの`lead`属性を印刷します。この属性には章の最初の一文が入り、見出しの下のMarkdownの段落はその次の文から始まります（[見出しの属性](/ja/docs/document-format#見出しの属性)）。デザインのテキストは両端そろえにできないので、最初の一文は中央そろえにし、各属性の`\n`で意味の切れ目、つまり*cage*の後と「*bright;*」の後で改行します。`\n`が改行になるのは`paragraphIndent`が0より大きいときだけで、0のときはこの2文字がそのまま印刷されます。最初の一文のボックスは版面の上端からグリッド線9本分下で始まり、`minHeight`を12行にするとその下に空き行が1行残ります。`minHeight`がなければ、本文は最初の一文のすぐ下から始まります（[幅と詳細デザイン](/ja/docs/configuration#幅と詳細デザイン)）。`marginBottom: pt(0)`は、このレベルの既定の半emの下マージンを置き換えます。既定のままでは本文が1行下に押し下げられます。

`breakBefore`のparityを`'odd'`にすると、どの章も奇数ページに送られます。設定でこれをあらためて書いているのは、`headings`オブジェクトを書くと、そのレベルの既定の改ページが消えるからです（[前で改ページ](/ja/docs/configuration#前で改ページ)）。見出しのない箇所で奇数ページに送るには、Markdownに`:::pagebreak{parity="odd"}`と書きます。2段以上のレイアウトでは、`:::columnbreak`を使うと本文が次の段へ移ります（[`:::pagebreak`](/ja/docs/document-format#pagebreak)）。

### 2 · 行の整数倍でできた並製本のページ

```js
// script.js, 行 33–43
const TRIM = { width: 140, height: 216 }; // mm: 5½ × 8½ in
const [TOP, INNER, OUTER] = [22, 20, 15.5]; // mm; mirrored, so INNER is left on a recto
const LINES = 35; // every full page ends on the same line
const MEASURE = TRIM.width - INNER - OUTER; // 104.5 mm: about 69 characters of Crimson Pro
const MM_PER_PT = 25.4 / 72;
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 * MM_PER_PT),
    left: mm(INNER), right: mm(OUTER), mirror: true },
};
```

下の余白は、216 mmの仕上がりから天の余白と14 ptの行35行分を引いた残りです。そのため、3ページと4ページのように、組み切ったページはどれも同じ行で終わります。`mirror: true`にすると、20 mmののどの余白が奇数ページでも偶数ページでも背の側に来ます（[見開きの余白](/ja/docs/configuration#見開きの余白)）。104.5 mmの行長には、10.5 ptのCrimson Proがおよそ69字入ります。本文は語間を通常の0.7倍から1.9倍のあいだに保ちます。既定の最小値0.6のままでは、このページの1行が0.62まで詰まります。本文では`maxRuntTracking: 0`も指定しています。既定値のままでは、「*nodding good-by to him.*」で終わる段落が負のトラッキングで1行短く組まれ、Canvasはその負のトラッキングを描画しないため、語間が詰まったまま印刷されます。

### 3 · 章扉と白ページを飛ばす柱

```js
// script.js, 行 83–103
// {author} and {title} come from the frontmatter, every value quoted (gotcha: quote-frontmatter).
const HEAD_Y = 12; // mm from the top edge to the top of the running heads
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
const head = (id, content, parity, edge, x, style) => ({
  kind: 'text', id, content, parity, pages: 'body', // never on openers or blank pages
  fontSize: pt(9), color: col('muted'), ...style,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD_Y) } },
});
const smallCaps = { fontFamily: LABEL, fontWeight: 600, letterSpacing: pt(1.2) };
const italic = { fontFamily: DISPLAY, italic: true, fontWeight: 500, fontSize: pt(9.75) };
const folio = { fontFamily: TEXT, color: col('ink') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-author', '{author}', 'even', 'top', -SHIFT, smallCaps),
  head('recto-title', '{title}', 'odd', 'top', SHIFT, italic),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
// Openers carry a drop folio instead, centred under the text block.
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
  pages: 'opener', fontFamily: TEXT, fontSize: pt(9), color: col('muted'), align: 'center',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(7) } } }] };
```

`parity: 'even'`は偶数ページにCormorant SCのスモールキャピタルで著者名を、`'odd'`は奇数ページにイタリックで書名を置きます。ノンブルは版面の小口側の端に立ちます。`pages: 'body'`は、この4つの要素を章扉からも、パリティの改ページが加える白ページである6ページからも外します。`pages: 'all'`にすると、そのページに*6*と*Kate Chopin*が印刷されます。章扉には代わりに下ノンブルを付けます。これはフッターの要素で、`pages: 'opener'`で章扉だけに限っています（[テキスト要素](/ja/docs/configuration#テキスト要素)）。

### 4 · 表紙は柱のない節

```js
// script.js, 行 107–131
// Page 1 is a recto (gotcha: parity-page1-recto): the cover, the colophon on its verso, and
// chapter I on page 3. # The Awakening {style="cover"} opens the section; chapter I closes it.
// The colophon: 2 + 2 + 1 lines of 11 pt and two gaps of 14.5 pt make 84 pt, six grid lines.
const COLOPHON_LINES = 6;
const COLOPHON_TOP = TOP + (LINES - COLOPHON_LINES) * LEAD * MM_PER_PT; // mm: the section's margin
const onPage = (y) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } }); // y mm down
const cover = {
  id: 'cover',
  span: 'page', // kept in the column, the design is clipped at the column top, 165 mm down
  header: { elements: [] }, footer: { elements: [] }, // no running heads on p. 1 or p. 2
  margins: { top: mm(COLOPHON_TOP) },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'art', resourceId: 'cover',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    { kind: 'text', id: 'title', content: '{title}', fontFamily: DISPLAY, italic: true,
      fontWeight: 500, fontSize: pt(50), lineHeight: 1, color: col('ink'), align: 'center',
      placement: onPage(155) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6),
      color: col('rubric'), placement: { ...onPage(179), size: { width: mm(18) } } },
    { kind: 'text', id: 'author', content: '{author}', ...smallCaps, fontSize: pt(12),
      letterSpacing: pt(2.4), color: col('rubric'), align: 'center', placement: onPage(184) },
  ] } },
};
const colophon = { id: 'colophon', fontSize: pt(8), lineHeight: pt(11), color: col('muted'),
  textAlign: 'left', firstLineIndent: pt(0), spaceBetween: pt(14.5) };
```

表紙は`# The Awakening {style="cover"}`という見出しです。そのスタイルが上部134 mmに絵を置き、フロントマターの書名を50 ptで、著者名をスモールキャピタルで組みます。スタイルはこの見出しが始める節、つまり第I章までの1ページと2ページにも適用されます。スタイルの`header`と`footer`を空にしてあるので、表紙には下ノンブルが、奥付には柱が付きません。スタイルの天の余白は奥付を版面の最後の6本のグリッド線に置くので、奥付の最終行は3ページの最終行と高さがそろいます（[見出しスタイル](/ja/docs/configuration#見出しスタイル)）。1段組みでも、このスタイルには`span: 'page'`が必要です。段の中に収まるデザインは段の上端、ここでは165 mm下の位置で切り取られるため、`span: 'page'`がないと絵は印刷されず、書名の上の部分も欠けます。

![Page 2.](https://postext.dev/cookbook/trade-paperback-novel/en/p02.webp?v=de19147c)

*2ページ：表紙の裏の地に置いた奥付。柱もノンブルもありません。*

### 5 · 章の終わりに尾飾りを置く

```js
// script.js, 行 135–152
// At a chapter's end: :::space, then ::resource{id="tailpiece-1"} (double quotes: gotcha
// resource-double-quotes). The space adds a line to the gap the embed keeps above it.
const ornament = { id: 'ornament', name: 'Ornament', shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  defaultPlacement: { position: 'here', width: 0.17, align: 'center' } };
const svg = (id, fileId, width, height, altText) => ({ id, typeId: 'ornament', kind: 'svg',
  svg: { fileId, width, height }, altText, createdAt: 0, updatedAt: 0 });
// The cover is a painting, a JPEG in assets/ cut to the art's 140 × 134 mm, declared at its pixels.
const painting = { id: 'cover', typeId: 'ornament', kind: 'bitmap', createdAt: 0, updatedAt: 0,
  bitmap: { fileId: 'cover-1190.jpg', format: 'jpeg', width: 1190, height: 1139 },
  altText: 'The gulf seen between the trunks of water-oaks, a lugger on the horizon; a white '
    + 'sunshade comes up from the beach through the camomile.' };
const resources = [
  painting,
  // ::resource places each ornament once, so each chapter end has its own id.
  ...['I', 'II'].map((n, i) => svg(`tailpiece-${i + 1}`, 'fleuron.svg', 40, 12,
    `A camomile flower between two sprigs closes chapter ${n}.`)),
];
```

ornamentの種類にはキャプションの接頭辞がないので、カモミールの花はラベルなしで、行長の17%の幅で、`::resource{id="tailpiece-1"}`を書いた位置に印刷されます（[リソースの種類](/ja/docs/configuration#リソースの種類)）。行内に埋め込んだリソースは上にグリッド線1本分の空きを保ち、その前の`:::space`が章の最終行と花のあいだにもう1本分を加えます（[`:::space`](/ja/docs/document-format#space)）。リソースは1か所にしか置けないので、2つの尾飾りは同じファイル`fleuron.svg`を2つのidで描いています。

## レシピの全体

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

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

### script.js

```js
// ═══ Postext Cookbook · Nº 038 · Trade paperback: sunk openers and recto chapters ═══
// https://postext.dev/en/cookbook/trade-paperback-novel
// Code: MIT · Text: Kate Chopin, The Awakening, 1899 (PD, Gutenberg #160) · Cover: diffusion models
// Fonts: Crimson Pro, Cormorant Garamond, Cormorant 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 (this recipe is English only)
const RECIPE = 'trade-paperback-novel';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // every colour in the config links to one of these
  ink: '#231d18', // the text: a warm near-black
  rubric: '#8e3b22', // the one accent: the opener's hairline, the author, the tailpiece
  muted: '#6e655b', // running heads, folios on openers, the colophon
  paper: '#fbf7ef', // a cream book paper
};
// Each colour names its palette entry and carries its hex (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 (headings, lists, callouts, captions) link to 'main-color', the rubric.
  { id: 'main-color', name: 'rubric (defaults)', value: { hex: palette.rubric, model: 'hex' } },
];
const TEXT = 'Crimson Pro'; // the text face
const DISPLAY = 'Cormorant Garamond'; // italic numerals and the cover's title
const LABEL = 'Cormorant SC'; // small capitals: the lead, the author's name
const [BODY, LEAD] = [10.5, 14]; // pt: the body size and its leading, the grid's pitch
const line = (n) => pt(n * LEAD); // n grid lines

// #region page: a 140 × 216 mm trade page whose text block holds 35 whole lines
const TRIM = { width: 140, height: 216 }; // mm: 5½ × 8½ in
const [TOP, INNER, OUTER] = [22, 20, 15.5]; // mm; mirrored, so INNER is left on a recto
const LINES = 35; // every full page ends on the same line
const MEASURE = TRIM.width - INNER - OUTER; // 104.5 mm: about 69 characters of Crimson Pro
const MM_PER_PT = 25.4 / 72;
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 * MM_PER_PT),
    left: mm(INNER), right: mm(OUTER), mirror: true },
};
// #endregion

// #region answer: a sunk opener: the numeral, a hairline, and the first sentence in small caps
// # I {lead="A green and yellow parrot, … kept repeating over and over:"}
// The heading's text is the numeral, printed by {titleText}; the chapter's first sentence
// travels as the heading's lead attribute, printed by {attr.lead} in small capitals and
// centred, because design text is never justified (gotcha: design-text-ragged).
const [LEAD_AT, BODY_AT] = [9, 12]; // grid lines: the lead's top; where the text starts
const NUMERAL = 48; // pt
const RULE_Y = (LEAD_AT - 1.5) * LEAD * MM_PER_PT; // mm: a line and a half above the lead
const NUMERAL_Y = RULE_Y - NUMERAL * MM_PER_PT - 3; // mm: the numeral's box ends 3 mm above it
const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y } });
const opener = {
  enabled: true,
  // The two-line lead ends on line LEAD_AT + 2; minHeight leaves a blank line under it and
  // starts the text on line BODY_AT (a one-line lead would leave two).
  minHeight: line(BODY_AT),
  slot: { elements: [
    { kind: 'text', id: 'numeral', content: '{titleText}', fontFamily: DISPLAY, italic: true,
      fontWeight: 500, fontSize: pt(NUMERAL), lineHeight: 1, color: col('ink'),
      align: 'center', placement: centred(mm(NUMERAL_Y)) },
    { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.6),
      color: col('rubric'), placement: { ...centred(mm(RULE_Y)), size: { width: mm(18) } } },
    { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: LABEL, fontWeight: 600,
      fontSize: pt(9.5), letterSpacing: pt(0.6), color: col('ink'), align: 'center',
      lineHeight: LEAD / 9.5, // a multiple, never pt() (gotcha: design-lineheight-multiple)
      overflow: 'wrap', // wrap, not '…' (gotcha: overflow-ellipsis-default)
      paragraphIndent: pt(0.01), // a \n in the lead breaks the line (gotcha: design-text-newline)
      placement: { ...centred(line(LEAD_AT)), size: { width: mm(MEASURE) } } },
  ] },
};
// Every chapter opens on a recto: 'odd' adds a blank verso only after a chapter that ends on
// a recto (restated: gotcha headings-drop-h1-break). marginBottom 0: the level's default
// 0.5 em would start the text a line lower.
const chapter = { level: 1, breakBefore: { enabled: true, parity: 'odd' },
  marginBottom: pt(0), italic: true, advancedDesign: opener };
// #endregion

// #region heads: the author on the verso, the title on the recto, folios outside
// {author} and {title} come from the frontmatter, every value quoted (gotcha: quote-frontmatter).
const HEAD_Y = 12; // mm from the top edge to the top of the running heads
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
const head = (id, content, parity, edge, x, style) => ({
  kind: 'text', id, content, parity, pages: 'body', // never on openers or blank pages
  fontSize: pt(9), color: col('muted'), ...style,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD_Y) } },
});
const smallCaps = { fontFamily: LABEL, fontWeight: 600, letterSpacing: pt(1.2) };
const italic = { fontFamily: DISPLAY, italic: true, fontWeight: 500, fontSize: pt(9.75) };
const folio = { fontFamily: TEXT, color: col('ink') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-author', '{author}', 'even', 'top', -SHIFT, smallCaps),
  head('recto-title', '{title}', 'odd', 'top', SHIFT, italic),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
// Openers carry a drop folio instead, centred under the text block.
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
  pages: 'opener', fontFamily: TEXT, fontSize: pt(9), color: col('muted'), align: 'center',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(7) } } }] };
// #endregion

// #region cover: a section of its own: art, title, author, no heads, a colophon on its verso
// Page 1 is a recto (gotcha: parity-page1-recto): the cover, the colophon on its verso, and
// chapter I on page 3. # The Awakening {style="cover"} opens the section; chapter I closes it.
// The colophon: 2 + 2 + 1 lines of 11 pt and two gaps of 14.5 pt make 84 pt, six grid lines.
const COLOPHON_LINES = 6;
const COLOPHON_TOP = TOP + (LINES - COLOPHON_LINES) * LEAD * MM_PER_PT; // mm: the section's margin
const onPage = (y) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } }); // y mm down
const cover = {
  id: 'cover',
  span: 'page', // kept in the column, the design is clipped at the column top, 165 mm down
  header: { elements: [] }, footer: { elements: [] }, // no running heads on p. 1 or p. 2
  margins: { top: mm(COLOPHON_TOP) },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'art', resourceId: 'cover',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    { kind: 'text', id: 'title', content: '{title}', fontFamily: DISPLAY, italic: true,
      fontWeight: 500, fontSize: pt(50), lineHeight: 1, color: col('ink'), align: 'center',
      placement: onPage(155) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6),
      color: col('rubric'), placement: { ...onPage(179), size: { width: mm(18) } } },
    { kind: 'text', id: 'author', content: '{author}', ...smallCaps, fontSize: pt(12),
      letterSpacing: pt(2.4), color: col('rubric'), align: 'center', placement: onPage(184) },
  ] } },
};
const colophon = { id: 'colophon', fontSize: pt(8), lineHeight: pt(11), color: col('muted'),
  textAlign: 'left', firstLineIndent: pt(0), spaceBetween: pt(14.5) };
// #endregion

// #region tailpiece: an ornament type that prints no caption, where ::resource stands
// At a chapter's end: :::space, then ::resource{id="tailpiece-1"} (double quotes: gotcha
// resource-double-quotes). The space adds a line to the gap the embed keeps above it.
const ornament = { id: 'ornament', name: 'Ornament', shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  defaultPlacement: { position: 'here', width: 0.17, align: 'center' } };
const svg = (id, fileId, width, height, altText) => ({ id, typeId: 'ornament', kind: 'svg',
  svg: { fileId, width, height }, altText, createdAt: 0, updatedAt: 0 });
// The cover is a painting, a JPEG in assets/ cut to the art's 140 × 134 mm, declared at its pixels.
const painting = { id: 'cover', typeId: 'ornament', kind: 'bitmap', createdAt: 0, updatedAt: 0,
  bitmap: { fileId: 'cover-1190.jpg', format: 'jpeg', width: 1190, height: 1139 },
  altText: 'The gulf seen between the trunks of water-oaks, a lugger on the horizon; a white '
    + 'sunshade comes up from the beach through the camomile.' };
const resources = [
  painting,
  // ::resource places each ornament once, so each chapter end has its own id.
  ...['I', 'II'].map((n, i) => svg(`tailpiece-${i + 1}`, 'fleuron.svg', 40, 12,
    `A camomile flower between two sprigs closes chapter ${n}.`)),
];
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  colorPalette,
  resourceTypes: [ornament],
  page,
  layout: { layoutType: 'single' },
  bodyText: {
    fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'),
    firstLineIndent: mm(4), indentAfterHeading: false,
    minWordSpacing: 0.7, maxWordSpacing: 1.9, // at the default 0.6 a line closes to 0.62
    maxRuntTracking: 0, // gotcha: runt-tracking-unpainted
  },
  // The designs paint every heading; the level's own face is the numeral's (500 italic), so the
  // kit has no unused face to load.
  headings: { fontFamily: DISPLAY, fontWeight: 500, levels: [chapter] },
  headingStyles: [cover],
  paragraphStyles: [colophon],
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "The Awakening"
author: "Kate Chopin"
---

# The Awakening {style="cover"}

:::paragraphs{style="colophon"}
First published by Herbert S. Stone & Company, Chicago, in April 1899. Chapters I and II follow Project Gutenberg eBook #160, with Chopin’s spelling and punctuation.

Set in Crimson Pro, Cormorant Garamond and Cormorant SC (SIL Open Font License). The cover, a painting, shows the gulf from the Lebrun cottages on Grand Isle.

A Postext Cookbook edition. The text is in the public domain.
:::

# I {lead="A green and yellow parrot, which hung in a cage\noutside the door, kept repeating over and over:"}

“*Allez vous-en! Allez vous-en! Sapristi!* That’s all right!”

He could speak a little Spanish, and also a language which nobody understood, unless it was the mocking-bird that hung on the other side of the door, whistling his fluty notes out upon the breeze with maddening persistence.

Mr. Pontellier, unable to read his newspaper with any degree of comfort, arose with an expression and an exclamation of disgust.

He walked down the gallery and across the narrow “bridges” which connected the Lebrun cottages one with the other. He had been seated before the door of the main house. The parrot and the mocking-bird were the property of Madame Lebrun, and they had the right to make all the noise they wished. Mr. Pontellier had the privilege of quitting their society when they ceased to be entertaining.

He stopped before the door of his own cottage, which was the fourth one from the main building and next to the last. Seating himself in a wicker rocker which was there, he once more applied himself to the task of reading the newspaper. The day was Sunday; the paper was a day old. The Sunday papers had not yet reached Grand Isle. He was already acquainted with the market reports, and he glanced restlessly over the editorials and bits of news which he had not had time to read before quitting New Orleans the day before.

Mr. Pontellier wore eye-glasses. He was a man of forty, of medium height and rather slender build; he stooped a little. His hair was brown and straight, parted on one side. His beard was neatly and closely trimmed.

Once in a while he withdrew his glance from the newspaper and looked about him. There was more noise than ever over at the house. The main building was called “the house,” to distinguish it from the cottages. The chattering and whistling birds were still at it. Two young girls, the Farival twins, were playing a duet from “Zampa” upon the piano. Madame Lebrun was bustling in and out, giving orders in a high key to a yard-boy whenever she got inside the house, and directions in an equally high voice to a dining-room servant whenever she got outside. She was a fresh, pretty woman, clad always in white with elbow sleeves. Her starched skirts crinkled as she came and went. Farther down, before one of the cottages, a lady in black was walking demurely up and down, telling her beads. A good many persons of the *pension* had gone over to the *Chênière Caminada* in Beaudelet’s lugger to hear mass. Some young people were out under the water-oaks playing croquet. Mr. Pontellier’s two children were there—sturdy little fellows of four and five. A quadroon nurse followed them about with a faraway, meditative air.

Mr. Pontellier finally lit a cigar and began to smoke, letting the paper drag idly from his hand. He fixed his gaze upon a white sunshade that was advancing at snail’s pace from the beach. He could see it plainly between the gaunt trunks of the water-oaks and across the stretch of yellow camomile. The gulf looked far away, melting hazily into the blue of the horizon. The sunshade continued to approach slowly. Beneath its pink-lined shelter were his wife, Mrs. Pontellier, and young Robert Lebrun. When they reached the cottage, the two seated themselves with some appearance of fatigue upon the upper step of the porch, facing each other, each leaning against a supporting post.

“What folly! to bathe at such an hour in such heat!” exclaimed Mr. Pontellier. He himself had taken a plunge at daylight. That was why the morning seemed long to him.

“You are burnt beyond recognition,” he added, looking at his wife as one looks at a valuable piece of personal property which has suffered some damage. She held up her hands, strong, shapely hands, and surveyed them critically, drawing up her fawn sleeves above the wrists. Looking at them reminded her of her rings, which she had given to her husband before leaving for the beach. She silently reached out to him, and he, understanding, took the rings from his vest pocket and dropped them into her open palm. She slipped them upon her fingers; then clasping her knees, she looked across at Robert and began to laugh. The rings sparkled upon her fingers. He sent back an answering smile.

“What is it?” asked Pontellier, looking lazily and amused from one to the other. It was some utter nonsense; some adventure out there in the water, and they both tried to relate it at once. It did not seem half so amusing when told. They realized this, and so did Mr. Pontellier. He yawned and stretched himself. Then he got up, saying he had half a mind to go over to Klein’s hotel and play a game of billiards.

“Come go along, Lebrun,” he proposed to Robert. But Robert admitted quite frankly that he preferred to stay where he was and talk to Mrs. Pontellier.

“Well, send him about his business when he bores you, Edna,” instructed her husband as he prepared to leave.

“Here, take the umbrella,” she exclaimed, holding it out to him. He accepted the sunshade, and lifting it over his head descended the steps and walked away.

“Coming back to dinner?” his wife called after him. He halted a moment and shrugged his shoulders. He felt in his vest pocket; there was a ten-dollar bill there. He did not know; perhaps he would return for the early dinner and perhaps he would not. It all depended upon the company which he found over at Klein’s and the size of “the game.” He did not say this, but she understood it, and laughed, nodding good-by to him.

Both children wanted to follow their father when they saw him starting out. He kissed them and promised to bring them back bonbons and peanuts.

:::space

::resource{id="tailpiece-1"}

# II {lead="Mrs. Pontellier’s eyes were quick and bright;\nthey were a yellowish brown, about the color of her hair."}

She had a way of turning them swiftly upon an object and holding them there as if lost in some inward maze of contemplation or thought.

Her eyebrows were a shade darker than her hair. They were thick and almost horizontal, emphasizing the depth of her eyes. She was rather handsome than beautiful. Her face was captivating by reason of a certain frankness of expression and a contradictory subtle play of features. Her manner was engaging.

Robert rolled a cigarette. He smoked cigarettes because he could not afford cigars, he said. He had a cigar in his pocket which Mr. Pontellier had presented him with, and he was saving it for his after-dinner smoke.

This seemed quite proper and natural on his part. In coloring he was not unlike his companion. A clean-shaved face made the resemblance more pronounced than it would otherwise have been. There rested no shadow of care upon his open countenance. His eyes gathered in and reflected the light and languor of the summer day.

Mrs. Pontellier reached over for a palm-leaf fan that lay on the porch and began to fan herself, while Robert sent between his lips light puffs from his cigarette. They chatted incessantly: about the things around them; their amusing adventure out in the water—it had again assumed its entertaining aspect; about the wind, the trees, the people who had gone to the *Chênière;* about the children playing croquet under the oaks, and the Farival twins, who were now performing the overture to “The Poet and the Peasant.”

Robert talked a good deal about himself. He was very young, and did not know any better. Mrs. Pontellier talked a little about herself for the same reason. Each was interested in what the other said. Robert spoke of his intention to go to Mexico in the autumn, where fortune awaited him. He was always intending to go to Mexico, but some way never got there. Meanwhile he held on to his modest position in a mercantile house in New Orleans, where an equal familiarity with English, French and Spanish gave him no small value as a clerk and correspondent.

He was spending his summer vacation, as he always did, with his mother at Grand Isle. In former times, before Robert could remember, “the house” had been a summer luxury of the Lebruns. Now, flanked by its dozen or more cottages, which were always filled with exclusive visitors from the “*Quartier Français*,” it enabled Madame Lebrun to maintain the easy and comfortable existence which appeared to be her birthright.

Mrs. Pontellier talked about her father’s Mississippi plantation and her girlhood home in the old Kentucky blue-grass country. She was an American woman, with a small infusion of French which seemed to have been lost in dilution. She read a letter from her sister, who was away in the East, and who had engaged herself to be married. Robert was interested, and wanted to know what manner of girls the sisters were, what the father was like, and how long the mother had been dead.

When Mrs. Pontellier folded the letter it was time for her to dress for the early dinner.

“I see Léonce isn’t coming back,” she said, with a glance in the direction whence her husband had disappeared. Robert supposed he was not, as there were a good many New Orleans club men over at Klein’s.

When Mrs. Pontellier left him to enter her room, the young man descended the steps and strolled over toward the croquet players, where, during the half-hour before dinner, he amused himself with the little Pontellier children, who were very fond of him.

:::space

::resource{id="tailpiece-2"}
`; // content.en.md, inlined by the Cookbook

// #region art: the fleuron, drawn in millimetres in the page's colour
function drawFleuron(color) { // a camomile head between two leafy sprigs
  const petals = Array.from({ length: 10 }, (_, i) => `<ellipse cx="22.1" cy="6" rx="1.55" `
    + `ry="0.62" transform="rotate(${i * 36} 20 6)" fill="${color}"/>`).join('');
  const sprig = `<path d="M17.2 6.3C14 7.4 10 7.2 6.4 5.6C5.2 5.1 4.2 5.2 3.4 5.9" fill="none" `
    + `stroke="${color}" stroke-width="0.55" stroke-linecap="round"/>`
    + `<path d="M13.4 6.9C12.6 4.9 10.8 4 9 4.2C10 5.6 11.4 6.6 13.4 6.9Z" fill="${color}"/>`
    + `<path d="M9.2 6.5C8.7 7.9 7.4 8.7 5.9 8.8C6.6 7.5 7.7 6.7 9.2 6.5Z" fill="${color}"/>`
    + `<circle cx="2.8" cy="6.3" r="0.7" fill="${color}"/>`;
  return '<svg xmlns="http://www.w3.org/2000/svg" width="40mm" height="12mm" '
    + `viewBox="0 0 40 12">${petals}<circle cx="20" cy="6" r="1.05" fill="${color}"/>${sprig}`
    + `<g transform="translate(40 0) scale(-1 1)">${sprig}</g></svg>`;
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first)
  'Crimson Pro': ['400', '400i'], // text, colophon, folios
  'Cormorant Garamond': ['500i'], // numerals, cover title, the recto's running head
  'Cormorant SC': ['600'], // the leads, the author on the cover and the verso
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await loadImage(painting.bitmap.fileId, asset(painting.bitmap.fileId));
await loadSvg('fleuron.svg', drawFleuron(palette.rubric));
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: 'Trade paperback: sunk openers and recto chapters' });

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

## アレンジ

### 第II章を次のページから始める

parityを`'any'`にすると、第II章は偶数ページの6ページから始まり、本は7ページで終わります。下ノンブルはページではなく版面の中央にそろうので、その偶数ページでも本文の中央の下に来ます。

```diff
-const chapter = { level: 1, breakBefore: { enabled: true, parity: 'odd' },
+const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },
```

### 章の中で場面の区切りを示す

同じ花を2つの`:::space`の行で挟むと、場面の区切りになります。`indentAfterHeading`がオフなので、その後の段落は字下げなしで始まります。「*seemed long to him.*」の後に置くと、花は4ページの地に入り、2つ目の空きはそこに収まらずに捨てられます。区切りを入れると4行増え、第I章の尾飾りだけが柱の付いた6ページへ押し出されます。区切りを加えたら、章の行数をもう一度調整してください。

```diff
 const resources = [
+  svg('break-1', 'fleuron.svg', 40, 12, 'A scene break.'),
```

```md
That was why the morning seemed long to him.

:::space

::resource{id="break-1"}

:::space

“You are burnt beyond recognition,” he added, …
```

## よくあるつまずき

- **headingsオブジェクトを渡すとH1の改ページが消える.** 既定ではH1は奇数ページへ改ページします（always-odd）。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。
- **1ページは奇数ページ。ページは物理的な番号で計画する.** 1ページは右ページで、2ページが最初の偶数ページです。見開きは物理的なページ番号で計画してください。偶数ページの章扉は、その次の奇数ページと向かい合います。
- **属性内の\nが改行になるのはparagraphIndent > 0のときだけ.** デザインのテキスト要素で、属性値に書いた\nが改行になるのは、paragraphIndentが0より大きいか、ドロップキャップを設定したときだけです。それ以外では1行のままになります。paragraphIndentをごくわずかな値（0.01 pt）にするか、1行ごとに属性を分けてください。
- **デザインのテキストのlineHeightは倍率で、寸法ではない.** デザインのスロットでは、テキスト要素のlineHeightはフォントサイズに掛ける倍率です（lineHeight: 1.05）。postext 1.4.1ではpt(15)のような寸法を指定しても拒否されず、章扉の高さがNaNと計測されて、minHeightを含め確保する高さが警告なしに失われ、本文がタイトルに重なって組まれます。
- **デザインのテキストは両端そろえにならず、ドロップキャップのリードは行末がそろわない.** postext 1.4.1のデザインのテキスト要素は左そろえ、中央、右そろえのいずれかで、語単位で折り返します。両端そろえはなく、hyphenate: trueは1行に収まらない長い語を分割するだけです。そのため、dropCapの横に組むリードの行は、両端そろえの本文の横で行末がそろいません。リードは頭文字の横の行だけにして、手で収めてください。lines: 1（上がり頭文字）ならリードは1行で、dropCapの間隔で行末をぴったり合わせられます。段落の続きはMarkdownに書きます。
- **デザインのテキストのoverflowの既定値は'ellipsis-end'.** 幅に収まらないデザインのテキスト要素は、既定では省略記号で終わります。複数行に折り返したいタイトルにはoverflow: 'wrap'を設定してください。
- **ラントの修正が、描かれないトラッキングを詰めることがある.** postext 1.4.1では、段落がラント（最終行に残った短い一語）で終わると、レイアウトはその段落を1行短く組みます。まず語間を詰め、次にmaxRuntTracking（1000分の1 em単位）までの負のトラッキングを使います。CanvasとPDFのレンダラーは0より大きいトラッキングしか描かないため、トラッキングを詰めた段落はトラッキングなしで印字されます。両端そろえの行はその差の分だけ語間を失って詰まって見え、最終行は行長を超えて段の端で切れることがあります。bodyText.maxRuntTracking: 0を設定すれば語間による修正は残るので、それでも出るラントは文を書き換えてください。
- **パレットを差し替えても、デザイン要素と参照色は変わらない.** postext 1.4.1はcolorPaletteをテキストのスタイル（本文、見出し、リスト、キャプション、表、囲み）には反映しますが、ヘッダー、フッター、章扉、部扉の要素と、bodyText.referenceColorには反映しません。これらはpaletteIdの横に書いた16進の色のままです。画面用のダーク版や色替えのためにパレットを差し替えるときは、ビルドの前に、リンクしたすべての色をcolorPaletteから書き直してください。
- **::resource{id="…"}は二重引用符でしか書けない.** ブロックの埋め込みとして認識されるのは、二重引用符を使った::resource{id="…"}の形だけです。ほかの書き方は、目に見える1行として本文に残ります。
- **フロントマターの値はすべて引用符で囲む.** YAMLはtitle: 1984を数値として、日付をDateオブジェクトとして読みます。文字列でない値はプレースホルダーに空で出力され、PDFにもタイトルが付きません。値はすべて引用符で囲んでください（title: "1984"）。
- **レイアウトの前にすべてのフォントを読み込む.** レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。
- **組版の警告: 奇偶指定の連鎖** (`parityCascade`). 奇偶指定の改ページが重なり、白ページが3ページ以上続いています。 解決策: always-oddの改ページとbreakBeforeの奇偶指定を見直してください。短い章では、parityをanyにすると白ページを避けられることが多くあります。 ([ドキュメント](https://postext.dev/ja/docs/configuration.md#前で改ページ))

- `'always-odd'`にすると、パリティのための白ページを入れる前に、各節の後に区切りの白ページが1枚入ります。そのため第I章は2枚の白ページの後の5ページへ移り、本は10ページになります。
- このページは行数を調整して組んであります。小口の余白を15.5 mmから16 mmにすると、7ページでは*after-din-ner*が2つ目のハイフンで分割されて1行短く終わり、8ページでは段落が切れ端の「*spondent.*」で終わり、2つ目の尾飾りは9ページ目に落ちます。行長、文字サイズ、章頭の下げを変えたら、すべてのページの地を確かめてください。
- 「*Mr.*」と「*Mrs.*」は、ノーブレークスペース（U+00A0）で名前とつないであります。1.4.1では、これは普通の段落では効きますが、イタリックを含む段落では普通のスペースと同じように改行されます。「*seemed long to him.*」で終わる段落では普通のスペースのままにしてあります。そこで名前をつなぐと、段落の1行目の語間が通常の2.2倍まで開くからです。

## クレジット

- レシピ: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- テキスト: The Awakening (1899), chapters I and II: Kate Chopin ([出典](https://www.gutenberg.org/ebooks/160)), パブリックドメイン
- テキスト: The colophon: Postext Cookbook, オリジナル
- 画像: The cover’s view of the gulf, a painting: Generated With Diffusion Models, オリジナル
- 画像: The camomile tailpiece, drawn in code in the page’s palette: Ignacio Ferro, MIT
- 書体: Crimson Pro (OFL-1.1), Cormorant Garamond (OFL-1.1), Cormorant SC (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. 006 · 前付けはローマ数字、本文は1ページから](https://postext.dev/ja/cookbook/front-matter-roman-to-arabic.md): 表紙と前付けは番号なしの見出しで、小文字のローマ数字で数えます。:::numberingで、小説が始まる奇数ページから数え直して1にします。 · 難易度 3 (上級) · 小説・戯曲・文芸
