# 書簡集：日付行と署名

> 書簡を改ページせずに続けて組みます。日付行は見出しの属性から取り、呼びかけ、署名、追伸は段落スタイルで組みます。

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

## かんたんな説明

プロイセン王とヴォルテールが交わした3通の手紙を収めた小さな本です。手紙ごとに日付、呼びかけ、署名を組み、新しいページを起こさずに手紙を続けて組む方法を示します。

## できあがり

14 × 21cmの版『Mon sort est changé』の5ページで、フランス語の書簡3通を収めます。フリードリヒ2世がプロイセン王になって6日後と12日後、1740年6月にヴォルテールに宛てた2通と、ヴォルテールが死の2か月前、1778年4月1日に書いた手紙です。表紙では、拡散モデルで生成した、折りたたんだ2通の手紙と赤い封蝋の写真が、コードで描いた緑のモロッコ革の上、金の二重罫の内側に置かれています。本文では、手紙は改ページせずに続きます。各手紙の見出しは赤いスモールキャピタルの番号と、Fellのイタリックで組んだ差出人と宛先からなり、その下に場所と日付を右そろえで置きます。呼びかけは独立した1行を取り、スモールキャピタルの署名が右の余白で手紙を締めくくります。フリードリヒの追伸は署名のあとに1段小さく続きます。各奇数ページの柱には手紙の日付が入ります。

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

- 書簡集で、手紙ごとの呼びかけ、右そろえの署名、追伸を組むには？
- スペイン語・フランス語・ドイツ語のテキストで、きれいな両端そろえとハイフネーションを得るには？
- 柱を設定するには（左ページに書名、右ページに章タイトル、ノンブルは小口側）？

## 手短な答え

```js
// script.js, 行 42–66
// Each letter is a level-1 heading that carries its place and date as attributes:
//   # Frédéric à Voltaire {place="À Charlottembourg" date="6 juin 1740"}
// (a value holds no { or }, and one with " goes in single quotes: gotcha attr-values)
const letterHead = { enabled: true, slot: { elements: [
  // {number} prints numberingTemplate '{1:I}' (gotcha: heading-number-placeholders).
  { kind: 'text', id: 'number', content: 'Lettre {number}', ...sc, fontSize: pt(9),
    letterSpacing: pt(1.8), color: col('seal'), placement: at('container', 'top-left') },
  { kind: 'text', id: 'title', content: '{titleText}', ...fell, fontSize: pt(15),
    color: col('ink'),
    placement: at('#number', 'below', 0, 1) },
  // The dateline spans the measure under the title and sets its words flush right.
  { kind: 'text', id: 'dateline', content: '{attr.place}, le {attr.date}.', ...crimson,
    italic: true, fontSize: pt(10.4), color: col('ink'), align: 'right',
    placement: { ...at('#title', 'below', 0, 1.5), size: { width: 'fill' } } },
] } };
// The salutation, the signature and the postscript, each a :::paragraphs{style="…"} container:
//   :::paragraphs{style="signature"}
//   Fédéric.
//   :::
const letterParts = [
  { id: 'vedette', firstLineIndent: pt(0) }, // 'Sire,' on a line of its own, flush left
  { id: 'signature', ...sc, fontSize: pt(10.5), textAlign: 'right', marginTop: pt(LEAD / 2) },
  { id: 'postscript', fontSize: pt(9), lineHeight: pt(12.6), marginTop: pt(LEAD / 2) },
];
// Hooked up below: letterHead designs the level-1 heading, letterParts joins paragraphStyles.
```

## 材料

**学べること**

- [見出しの属性](https://postext.dev/ja/docs/document-format.md#見出しの属性): 見出しに付けるキーと値の組（author、kicker、lead）です。章扉、柱、目次は{attr.name}でこれを印刷します。
- [デザイン要素の基準位置](https://postext.dev/ja/docs/configuration.md#要素の配置): 座標ではなく、コンテナー、ページ、裁ち落とし、または別の要素（right-of、below、align-*）を基準に要素を配置します。
- [段落スタイル](https://postext.dev/ja/docs/configuration.md#段落スタイル): 本文とは別に組む一連の段落に付ける名前付きのスタイルです。詩、エピグラフ、献辞、署名、小さな注記などに使います。

**ほかに使うもの**

- [デザインした章扉](https://postext.dev/ja/docs/configuration.md#幅と詳細デザイン)
- [番号付き見出し](https://postext.dev/ja/docs/configuration.md#レベルごとの上書き)
- [奇数ページから始まる章](https://postext.dev/ja/docs/configuration.md#前で改ページ)
- [見出しスタイル](https://postext.dev/ja/docs/configuration.md#見出しスタイル)
- [表紙、扉、奥付](https://postext.dev/ja/docs/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#pagebreak)
- [ハイフネーションと文書の言語](https://postext.dev/ja/docs/justification.md#対応するロケール)
- [上付きと下付き](https://postext.dev/ja/docs/document-format.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#見出しスタイル)

**設定の一覧**

- [`bodyText`](https://postext.dev/ja/docs/configuration.md#本文), [`colorPalette`](https://postext.dev/ja/docs/configuration.md#カラーパレット), [`footer`](https://postext.dev/ja/docs/configuration.md#柱とノンブル), [`header`](https://postext.dev/ja/docs/configuration.md#柱とノンブル), [`headingStyles`](https://postext.dev/ja/docs/configuration.md#見出しスタイル), [`headings`](https://postext.dev/ja/docs/configuration.md#見出し), [`layout`](https://postext.dev/ja/docs/configuration.md#レイアウト), [`locale`](https://postext.dev/ja/docs/configuration.md#ハイフネーション), [`page`](https://postext.dev/ja/docs/configuration.md#ページ), [`paragraphStyles`](https://postext.dev/ja/docs/configuration.md#段落スタイル)

**API**

- [`buildDocument`](https://postext.dev/ja/docs/configuration.md#文書のビルド), [`clearMeasurementCache`](https://postext.dev/ja/docs/configuration.md#計測キャッシュ), [`registerResourceImage`](https://postext.dev/ja/docs/architecture.md#apiの概要), [`renderPageToCanvas`](https://postext.dev/ja/docs/configuration.md#ページをビットマップに描画する)

**書体**

- Crimson Pro (OFL-1.1), IM Fell French Canon (OFL-1.1), IM Fell DW Pica SC (OFL-1.1)

## 作り方

### 1 · 日付行を見出しから読む

コードは上の[手短な答え](#手短な答え)にあります。手紙はそれぞれレベル1の見出しで、その属性に場所と日付を持ち、見出しのデザインがそれを`{attr.place}, le {attr.date}.`として印刷します（[見出しの属性](/ja/docs/document-format#見出しの属性)）。日付行の箱は段の右端まで届き（`width: 'fill'`）、`align: 'right'`が語をその端にそろえます。これがないと、デザインのテキストは中央にそろいます。呼びかけ、署名、追伸は`:::paragraphs{style="…"}`で適用する[段落スタイル](/ja/docs/configuration#段落スタイル)です。1.4.1の段落スタイルにはイタリックや大文字小文字の設定がないので、署名には、小文字をスモールキャピタルとして作ったファミリーIM Fell DW Pica SCを使います。

### 2 · 手紙を続けて組む

```js
// script.js, 行 70–77
const letters = { level: 1, numberingTemplate: '{1:I}', advancedDesign: letterHead,
  // Written out: 1.4.1 drops the H1 page break for any headings object (gotcha:
  // headings-drop-h1-break), and a fixed engine would put each letter on a recto.
  breakBefore: { enabled: false }, marginTop: pt(2 * LEAD),
  // The hidden heading line is measured in the heading face: italic keeps it the IM Fell cut
  // that FONTS loads (gotcha: fonts-first). Upright, it would need the roman, which FONTS
  // leaves out; the layout would change only for a title long enough to wrap.
  italic: true };
```

`breakBefore`を切ると、手紙は前の手紙が終わるグリッド線の2本下（`marginTop`）から始まります。[4ページ](https://postext.dev/cookbook/letters-edition/en/p04.webp?v=c0103497)では、12.6ptの行送りで組んだ追伸がグリッド線のあいだで終わるので、第3の手紙の上に約3行分の白が立ちます。2ページでは第1の手紙がページの末尾まで続き、第2の手紙が3ページを始めます。この設定を書き出しているのは、1.4.1では設定に`headings`オブジェクトがあるだけでレベル1の改ページがなくなるためで、修正されたエンジンならどの手紙も奇数ページから始めることになります（[前で改ページ](/ja/docs/configuration#前で改ページ)）。`numberingTemplate: '{1:I}'`で手紙にIからIIIの番号が振られ、見出しは`{number}`でその数字を印刷します。

### 3 · 奇数ページに手紙の日付を入れる

```js
// script.js, 行 81–94
const HEAD_Y = 12; // mm from the top edge
const head = (id, content, parity, placement, look = {}) => ({ kind: 'text', id, content,
  parity, pages: 'body', ...sc, fontSize: pt(8.5), letterSpacing: pt(0.9), color: col('muted'),
  placement, ...look });
const folio = { ...crimson, fontSize: pt(9), letterSpacing: pt(0), color: col('ink') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', at('page', 'top-left', MARGIN.outer, HEAD_Y), folio),
  head('verso-names', '{author}', 'even', at('page', 'top-left', MARGIN.outer + 8, HEAD_Y)),
  // {attr.date} reads the last letter that starts on or before the page.
  head('recto-date', '{attr.date}', 'odd', at('page', 'top-right', -(MARGIN.outer + 8), HEAD_Y),
    { ...crimson, italic: true, fontSize: pt(9.5), letterSpacing: pt(0) }),
  head('recto-folio', '{pageNumber}', 'odd', at('page', 'top-right', -MARGIN.outer, HEAD_Y),
    folio),
] };
```

ページのヘッダーでは、`{attr.date}`は、そのページかそれより前で始まる最後のレベル1の見出しの属性を取ります。3ページには12 juin 1740が入り、4ページから続くヴォルテールの手紙の5ページには1er avril 1778が入ります（[テキスト要素](/ja/docs/configuration#テキスト要素)）。柱はページの上端から12mm下に置き、ノンブルは小口側の余白に、語はそこから8mm内側に置きます。`pages: 'body'`は柱を表紙から外します。表紙は見出しがページ全幅にまたがるので、章扉として数えられます。日付は、それが繰り返す日付行と同じCrimson Proのイタリックで組み、偶数ページの名前は手紙の番号と同じスモールキャピタルで組みます。

### 4 · 表紙を見出しにし、そのページを閉じる

```js
// script.js, 行 98–121
// The Markdown: # Mon sort \\ est changé {style="cover"}, then :::pagebreak, or the headnote
// and the first letter start on the cover (gotcha: cover-pagebreak).
const onCover = (y) => at('page', 'top', 0, y); // centred, y mm below the top edge
const LETTERS = { x: 12, y: 72, w: 116, h: 120 }; // mm: the photograph, inside the fillet
// numbered: false keeps the cover out of the count, so the first letter is I.
const cover = { id: 'cover', numbered: false,
  // span: 'page' although the book has one column. Kept in the column, the design is clipped
  // to the column's top and bottom (paper above and below the leather, no names) and its title
  // loses the \\ break; page 1 would also count as a 'body' page and print the running heads.
  span: 'page', advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'binding', resourceId: 'binding',
      placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } } },
    { kind: 'image', id: 'letters', resourceId: 'letters', placement: { ...at('page', 'top-left',
      LETTERS.x, LETTERS.y), size: { width: mm(LETTERS.w), height: mm(LETTERS.h) } } },
    { kind: 'text', id: 'names', content: '{author}', ...sc, fontSize: pt(9.5),
      letterSpacing: pt(2), color: col('gilt'), placement: onCover(18) },
    // \\ in the heading breaks the title here; lineHeight is a multiple (gotcha:
    // design-lineheight-multiple), and 'wrap' keeps the ellipsis off (overflow-ellipsis-default).
    { kind: 'text', id: 'title', content: '{titleText}', ...fell, fontSize: pt(50),
      lineHeight: 1, color: col('paper'), align: 'center', overflow: 'wrap',
      placement: onCover(24) },
    { kind: 'text', id: 'subtitle', content: '{subtitle}', ...crimson, italic: true,
      fontSize: pt(12), color: col('paper'), placement: onCover(62) },
  ] } } };
```

表紙は最初の見出しで、`cover`の[見出しスタイル](/ja/docs/configuration#見出しスタイル)を使います。裁ち落としにアンカーした画像要素が描いた装丁を載せ、2つ目の画像要素が72mm下に写真を置き、題、名前、副題は見出しとフロントマターから取ります。`numbered: false`で表紙は番号の数に入らないので、フリードリヒの最初の手紙がIになります。デザインが確保するのは一番下のテキストである副題までで、画像の分は確保しません。見出しのあとに`:::pagebreak`がなければ、解題と第1の手紙が1ページで、写真の手紙と封蝋の上から始まってしまいます。

### 5 · フランス語をハイフネーションし、語間を開いておく

```js
// script.js, 行 125–139
// Justification, hyphenation, whole-paragraph line breaking and the widow, orphan and runt
// rules are defaults; locale 'fr' (in the config) picks the French patterns.
// The letters keep the transcription's unspaced ; : ? and !, because a narrow no-break
// space is a place to break the line in 1.4.1 (gotcha: nbsp-breaks).
const bodyText = { fontFamily: 'Crimson Pro', fontSize: pt(10), lineHeight: pt(LEAD),
  color: col('ink'), firstLineIndent: mm(5),
  // No :ref here, but 1.4.1 leaves this one blue whatever main-color says (gotcha:
  // palette-skips-designs), and the default-skin check reads it.
  referenceColor: col('ink'),
  // A word space never shrinks below 75 % of the font's. At the default 60 %, the tightest
  // line on page 5 sets its spaces at 0.70 (each VDT line carries its justifiedSpaceRatio).
  minWordSpacing: 0.75,
  // A runt fix may add tracking 1.4.1 measures but never paints (gotcha:
  // runt-tracking-unpainted); no paragraph here needs one, edited text might.
  maxRuntTracking: 0 };
```

`locale: 'fr'`はフランス語のパターンでハイフネーションします（2ページのou-vrage、4ページのreprésen-tation）。両端そろえ、段落全体での改行、ウィドウ、オーファン、ラントの規則は既定で有効です。`minWordSpacing: 0.75`は、語間がCrimson Proの語間の4分の3より縮まないようにします。既定の0.6では、5ページで最も詰まった両端そろえの行の語間が、その幅の0.70になります。14.4ptの行送りでページには33行が入り、地の余白はその下に残る20.4mmです。

### 6 · すべての色をパレットにつなぐ

```js
// script.js, 行 13–28
const palette = {
  ink: '#2a2320', // the text: a warm near-black
  paper: '#f6efe2', // the page, and the lettering on the cover
  seal: '#9c2b24', // the letter numbers
  leather: '#2a4536', // the cover: a green morocco binding
  gilt: '#d0b67c', // its tooled border and the names on it
  muted: '#75695d', // the running heads
};
// A design element paints the hex written beside its paletteId (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 default colours, the italic of the headnote among them, link to 'main-color';
  // here it is the ink, not the default blue.
  { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];
```

`col()`は各色のhexをパレットのidと並べて書きます。1.4.1ではパレットはテキストのスタイルには届きますが、デザインの要素には届かず、デザインの要素はそのhexで描かれます。手紙の見出し、柱、表紙がそうです。解題と奥付のイタリックは既定のイタリックの色を使い、これは`main-color`につながっています。この項目を墨色にすると、イタリックはエンジンの青（#295AA3）ではなく墨色で印刷されます。

## レシピの全体

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

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

### script.js

```js
// ═══ Postext Cookbook · Nº 064 · Letters edition: datelines and signatures ════════════
// https://postext.dev/en/cookbook/letters-edition
// Code: MIT · Text: Frederick II and Voltaire, 1740 and 1778 (PD) · Cover photo: diffusion models
// Fonts: Crimson Pro, IM Fell French Canon, IM Fell DW Pica SC (SIL OFL) · 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 = 'letters-edition';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: iron-gall ink on cream paper, wax red, green morocco and gilt
const palette = {
  ink: '#2a2320', // the text: a warm near-black
  paper: '#f6efe2', // the page, and the lettering on the cover
  seal: '#9c2b24', // the letter numbers
  leather: '#2a4536', // the cover: a green morocco binding
  gilt: '#d0b67c', // its tooled border and the names on it
  muted: '#75695d', // the running heads
};
// A design element paints the hex written beside its paletteId (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 default colours, the italic of the headnote among them, link to 'main-color';
  // here it is the ink, not the default blue.
  { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];
// #endregion
const TRIM = { width: 140, height: 210 }; // the French 14 × 21 format
const LEAD = 14.4; // pt: the body's leading, the grid every letter starts on
const LINES = 33; // lines of text on a full page
const TOP = 22; // mm: the top margin
const MARGIN = { top: TOP, inner: 17.5, outer: 14.5, // mm, mirrored
  bottom: TRIM.height - TOP - (LINES * LEAD * 25.4) / 72 }; // ends the page on line 33
const sc = { fontFamily: 'IM Fell DW Pica SC' }; // its lower case is cut as small capitals
const fell = { fontFamily: 'IM Fell French Canon', italic: true }; // the display italic
const crimson = { fontFamily: 'Crimson Pro' }; // the text face
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });

// #region answer: a letter head read from the heading, and styles for the letter's parts
// Each letter is a level-1 heading that carries its place and date as attributes:
//   # Frédéric à Voltaire {place="À Charlottembourg" date="6 juin 1740"}
// (a value holds no { or }, and one with " goes in single quotes: gotcha attr-values)
const letterHead = { enabled: true, slot: { elements: [
  // {number} prints numberingTemplate '{1:I}' (gotcha: heading-number-placeholders).
  { kind: 'text', id: 'number', content: 'Lettre {number}', ...sc, fontSize: pt(9),
    letterSpacing: pt(1.8), color: col('seal'), placement: at('container', 'top-left') },
  { kind: 'text', id: 'title', content: '{titleText}', ...fell, fontSize: pt(15),
    color: col('ink'),
    placement: at('#number', 'below', 0, 1) },
  // The dateline spans the measure under the title and sets its words flush right.
  { kind: 'text', id: 'dateline', content: '{attr.place}, le {attr.date}.', ...crimson,
    italic: true, fontSize: pt(10.4), color: col('ink'), align: 'right',
    placement: { ...at('#title', 'below', 0, 1.5), size: { width: 'fill' } } },
] } };
// The salutation, the signature and the postscript, each a :::paragraphs{style="…"} container:
//   :::paragraphs{style="signature"}
//   Fédéric.
//   :::
const letterParts = [
  { id: 'vedette', firstLineIndent: pt(0) }, // 'Sire,' on a line of its own, flush left
  { id: 'signature', ...sc, fontSize: pt(10.5), textAlign: 'right', marginTop: pt(LEAD / 2) },
  { id: 'postscript', fontSize: pt(9), lineHeight: pt(12.6), marginTop: pt(LEAD / 2) },
];
// Hooked up below: letterHead designs the level-1 heading, letterParts joins paragraphStyles.
// #endregion

// #region letters: numbered I, II, III and run on, two grid lines apart
const letters = { level: 1, numberingTemplate: '{1:I}', advancedDesign: letterHead,
  // Written out: 1.4.1 drops the H1 page break for any headings object (gotcha:
  // headings-drop-h1-break), and a fixed engine would put each letter on a recto.
  breakBefore: { enabled: false }, marginTop: pt(2 * LEAD),
  // The hidden heading line is measured in the heading face: italic keeps it the IM Fell cut
  // that FONTS loads (gotcha: fonts-first). Upright, it would need the roman, which FONTS
  // leaves out; the layout would change only for a title long enough to wrap.
  italic: true };
// #endregion

// #region running-heads: the correspondents on the verso, the date of the letter on the recto
const HEAD_Y = 12; // mm from the top edge
const head = (id, content, parity, placement, look = {}) => ({ kind: 'text', id, content,
  parity, pages: 'body', ...sc, fontSize: pt(8.5), letterSpacing: pt(0.9), color: col('muted'),
  placement, ...look });
const folio = { ...crimson, fontSize: pt(9), letterSpacing: pt(0), color: col('ink') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', at('page', 'top-left', MARGIN.outer, HEAD_Y), folio),
  head('verso-names', '{author}', 'even', at('page', 'top-left', MARGIN.outer + 8, HEAD_Y)),
  // {attr.date} reads the last letter that starts on or before the page.
  head('recto-date', '{attr.date}', 'odd', at('page', 'top-right', -(MARGIN.outer + 8), HEAD_Y),
    { ...crimson, italic: true, fontSize: pt(9.5), letterSpacing: pt(0) }),
  head('recto-folio', '{pageNumber}', 'odd', at('page', 'top-right', -MARGIN.outer, HEAD_Y),
    folio),
] };
// #endregion

// #region cover: page 1 is a heading style with the art and the title; :::pagebreak ends it
// The Markdown: # Mon sort \\ est changé {style="cover"}, then :::pagebreak, or the headnote
// and the first letter start on the cover (gotcha: cover-pagebreak).
const onCover = (y) => at('page', 'top', 0, y); // centred, y mm below the top edge
const LETTERS = { x: 12, y: 72, w: 116, h: 120 }; // mm: the photograph, inside the fillet
// numbered: false keeps the cover out of the count, so the first letter is I.
const cover = { id: 'cover', numbered: false,
  // span: 'page' although the book has one column. Kept in the column, the design is clipped
  // to the column's top and bottom (paper above and below the leather, no names) and its title
  // loses the \\ break; page 1 would also count as a 'body' page and print the running heads.
  span: 'page', advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'binding', resourceId: 'binding',
      placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } } },
    { kind: 'image', id: 'letters', resourceId: 'letters', placement: { ...at('page', 'top-left',
      LETTERS.x, LETTERS.y), size: { width: mm(LETTERS.w), height: mm(LETTERS.h) } } },
    { kind: 'text', id: 'names', content: '{author}', ...sc, fontSize: pt(9.5),
      letterSpacing: pt(2), color: col('gilt'), placement: onCover(18) },
    // \\ in the heading breaks the title here; lineHeight is a multiple (gotcha:
    // design-lineheight-multiple), and 'wrap' keeps the ellipsis off (overflow-ellipsis-default).
    { kind: 'text', id: 'title', content: '{titleText}', ...fell, fontSize: pt(50),
      lineHeight: 1, color: col('paper'), align: 'center', overflow: 'wrap',
      placement: onCover(24) },
    { kind: 'text', id: 'subtitle', content: '{subtitle}', ...crimson, italic: true,
      fontSize: pt(12), color: col('paper'), placement: onCover(62) },
  ] } } };
// #endregion

// #region text: Crimson Pro at 10/14.4 pt, set in French
// Justification, hyphenation, whole-paragraph line breaking and the widow, orphan and runt
// rules are defaults; locale 'fr' (in the config) picks the French patterns.
// The letters keep the transcription's unspaced ; : ? and !, because a narrow no-break
// space is a place to break the line in 1.4.1 (gotcha: nbsp-breaks).
const bodyText = { fontFamily: 'Crimson Pro', fontSize: pt(10), lineHeight: pt(LEAD),
  color: col('ink'), firstLineIndent: mm(5),
  // No :ref here, but 1.4.1 leaves this one blue whatever main-color says (gotcha:
  // palette-skips-designs), and the default-skin check reads it.
  referenceColor: col('ink'),
  // A word space never shrinks below 75 % of the font's. At the default 60 %, the tightest
  // line on page 5 sets its spaces at 0.70 (each VDT line carries its justifiedSpaceRatio).
  minWordSpacing: 0.75,
  // A runt fix may add tracking 1.4.1 measures but never paints (gotcha:
  // runt-tracking-unpainted); no paragraph here needs one, edited text might.
  maxRuntTracking: 0 };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: 'fr', // the exact code of the bundled patterns (gotcha: hyphenation-locales)
  colorPalette,
  page: { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
    backgroundColor: col('paper'), margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom),
      left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true } }, // left = inner
  layout: { layoutType: 'single' },
  bodyText,
  // A heading's own line is hidden under its design but still measured, in this face and weight.
  headings: { fontFamily: 'IM Fell French Canon', fontWeight: 400, levels: [letters] },
  headingStyles: [cover],
  paragraphStyles: [...letterParts,
    // Frederick's verses, one paragraph per line (a one-line paragraph is never stretched), with
    // a line of space above and below; the two closing alexandrines start 7 mm further left.
    { id: 'verse', firstLineIndent: mm(14), marginTop: pt(LEAD) },
    { id: 'verse-long', firstLineIndent: mm(7), marginBottom: pt(LEAD) },
    // The editor's headnote at 9.6 on 13 pt, italic through *…* in the Markdown, since a
    // paragraph style has no italic setting.
    { id: 'headnote', fontSize: pt(9.6), lineHeight: pt(13), firstLineIndent: pt(0) },
    { id: 'colophon', fontSize: pt(7.8), lineHeight: pt(10.8), textAlign: 'left',
      firstLineIndent: pt(0), marginTop: pt(3 * LEAD) }],
  header,
  footer: { elements: [] }, // the folios ride in the header
});

// #region art: the binding drawn in code, and the photograph of the letters laid on it
const W = TRIM.width;
const H = TRIM.height;
// The binding: green leather to the edges, a gilt double fillet and a lozenge at each corner.
function bindingSvg() {
  const tooling = [6, 7.6].map((inset, i) => `<rect x="${inset}" y="${inset}" `
    + `width="${W - 2 * inset}" height="${H - 2 * inset}" fill="none" stroke="${palette.gilt}" `
    + `stroke-width="${i ? 0.25 : 0.7}"/>`).join('') + [[6, 6], [W - 6, 6], [6, H - 6],
    [W - 6, H - 6]].map(([x, y]) => `<path d="M${x} ${y - 2.4} L${x + 2.4} ${y} L${x} ${y + 2.4} `
    + `L${x - 2.4} ${y} Z" fill="${palette.gilt}"/>`).join('');
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W * 10}" height="${H * 10}" `
    + `viewBox="0 0 ${W} ${H}"><rect width="${W}" height="${H}" fill="${palette.leather}"/>`
    + `${tooling}</svg>`;
}
// The design's image elements name these by id; loadSvg() and loadImage() below register the
// files. The photograph is a JPEG in assets/ cut to LETTERS' 116 × 120 mm, declared at its
// pixels; its edges fade into the leather's green, so it sits on the drawn binding unseen.
const resources = [{ id: 'binding', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'binding.svg', width: W * 10, height: H * 10 },
  altText: 'A green leather cover with a gilt double fillet and a lozenge at each corner.' },
{ id: 'letters', typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0,
  bitmap: { fileId: 'letters-1160.jpg', format: 'jpeg', width: 1160, height: 1200 },
  altText: 'Two folded letters on the green leather: the upper one lies face down, its flap '
    + 'closed by a red wax seal; the lower one shows an address in brown ink.' }];
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Mon sort est changé"
subtitle: "Trois lettres, 1740 et 1778"
author: "Frédéric II et Voltaire"
---

# Mon sort \\ est changé {style="cover"}

:::pagebreak

:::paragraphs{style="headnote"}
*Frédéric-Guillaume I^er^, roi de Prusse, meurt à Potsdam le 31 mai 1740. Six jours plus tard, son fils, qui règne désormais sous le nom de Frédéric II, écrit de Charlottembourg à Voltaire, son correspondant depuis août 1736. Les deux premières lettres de ce choix sont de ce mois de juin. La troisième, écrite de Paris le 1^er^ avril 1778, ferme le volume de 1889 d’où viennent ces textes. Voltaire meurt à Paris le 30 mai suivant. On garde l’orthographe de l’édition; la vedette et la signature sont détachées du texte.*
:::

# Frédéric à Voltaire {place="À Charlottembourg" date="6 juin 1740"}

:::paragraphs{style="vedette"}
Mon cher ami,
:::

Mon sort est changé, et j’ai assisté aux derniers moments d’un roi, à son agonie, à sa mort. En parvenant à la royauté, je n’avais pas besoin assurément de cette leçon pour être dégoûté de la vanité des grandeurs humaines.

J’avais projeté un petit ouvrage de métaphysique; il s’est changé en un ouvrage de politique. Je croyais joûter avec l’aimable Voltaire, et il me faut escrimer avec Machiavel. Enfin, mon cher Voltaire, nous ne sommes point maîtres de notre sort. Le tourbillon des événements nous entraîne, et il faut se laisser entraîner. Ne voyez en moi, je vous prie, qu’un citoyen zélé, un philosophe un peu sceptique, mais un ami véritablement fidèle. Pour dieu, ne m’écrivez qu’en homme, et méprisez avec moi les titres, les noms, et tout l’éclat extérieur.

Jusqu’à présent il me reste à peine le temps de me reconnaître; j’ai des occupations infinies: je m’en donne encore de surplus; mais malgré tout ce travail, il me reste toujours du temps assez pour admirer vos ouvrages et pour puiser chez vous des instructions et des délassements.

Assurez la marquise de mon estime. Je l’admire autant que ses vastes connaissances et la rare capacité de son esprit le méritent.

Adieu, mon cher Voltaire; si je vis, je vous verrai, et même dès cette année. Aimez-moi toujours, et soyez toujours sincère ami avec votre ami

:::paragraphs{style="signature"}
Fédéric.
:::

# Frédéric à Voltaire {place="À Charlottembourg" date="12 juin 1740"}

:::paragraphs{style="verse"}
Non, ce n’est plus du mont Rémus,

Douce et studieuse retraite

D’où mes vers vous sont parvenus,

Que je date ces vers confus:

Car dans ce moment le poète

Et le prince sont confondus.

Désormais mon peuple que j’aime

Est l’unique Dieu que je sers:

Adieu les vers et les concerts.

Tous les plaisirs. Voltaire même;

Mon devoir est mon Dieu suprême.

Qu’il entraîne de soins divers!

Quel fardeau que le diadème!

Quand ce dieu sera satisfait,

Alors dans vos bras, cher Voltaire,

Je volerai, plus prompt qu’un trait,
:::

:::paragraphs{style="verse-long"}
Puiser, dans les leçons de mon ami sincère,

Quel doit être d’un roi le sacré caractère.
:::

Vous voyez, mon cher ami, que le changement du sort ne m’a pas tout à fait guéri de la métromanie, et que peut-être je n’en guérirai jamais. J’estime trop l’art d’Horace et de Voltaire pour y renoncer; et je suis du sentiment que chaque chose de la vie a son temps.

J’avais commencé une épître sur les abus de la mode et de la coutume, lors même que la coutume de la primogéniture m’obligeait de monter sur le trône et de quitter mon épître pour quelque temps. J’aurais volontiers changé mon épître en satire contre cette même mode, si je ne savais que la satire doit être bannie de la bouche des princes.

Enfin, mon cher Voltaire, je flotte entre vingt occupations, et je ne déplore que la brièveté des jours, qui me paraissent trop courts de vingt-quatre heures.

Je vous avoue que la vie d’un homme qui n’existe que pour réfléchir et pour lui-même, me semble infiniment préférable à la vie d’un homme dont l’unique occupation doit être de faire le bonheur des autres.

Vos vers sont charmants. Je n’en dirai rien, car ils sont trop flatteurs.

Mon cher Voltaire, ne vous refusez pas plus longtemps à l’empressement que j’ai de vous voir. Faites en ma faveur tout ce que vous croyez que votre humanité comporte. J’irai à la fin d’auguste à Vesel, et peut-être plus loin. Promettez-moi de me joindre, car je ne saurais vivre heureux ni mourir tranquille sans vous avoir embrassé. Adieu.

:::paragraphs{style="signature"}
Fédéric.
:::

:::paragraphs{style="postscript"}
Mille compliments à la marquise. Je travaille des deux mains; d’un côté à l’armée, de l’autre au peuple et aux beaux-arts.
:::

# Voltaire à Frédéric {place="À Paris" date="1er avril 1778"}

:::paragraphs{style="vedette"}
Sire,
:::

Le gentilhomme français qui rendra cette lettre à Votre Majesté, et qui passe pour être digne de paraître devant Elle, pourra vous dire que si je n’ai pas eu l’honneur de vous écrire depuis longtemps, c’est que j’ai été occupé à éviter deux choses qui me poursuivaient dans Paris: les sifflets et la mort.

Il est plaisant qu’à quatre-vingt-quatre ans j’aie échappé à deux maladies mortelles. Voilà ce que c’est que de vous être consacré: je me suis renommé de vous, et j’ai été sauvé.

J’ai vu avec surprise et avec une satisfaction bien douce, à la représentation d’une tragédie nouvelle, que le public, qui regardait il y a trente ans Constantin et Théodose comme les modèles des princes, et même des saints, a applaudi avec des transports inouïs à des vers qui disent que Constantin et Théodose n’ont été que des tyrans superstitieux. J’ai vu vingt preuves pareilles du progrès que la philosophie a fait enfin dans toutes les conditions. Je ne désespérerais pas de faire prononcer dans un mois le panégyrique de l’empereur Julien: et assurément si les Parisiens se souviennent qu’il a rendu chez eux la justice comme Caton, et qu’il a combattu pour eux comme César, ils lui doivent une éternelle reconnaissance.

Il est donc vrai, Sire, qu’à la fin les hommes s’éclairent, et que ceux qui se croient payés pour les aveugler ne sont pas toujours les maîtres de leur crever les yeux! Grâces en soient rendus à Votre Majesté! Vous avez vaincu les préjugés comme vos autres ennemis: vous jouissez de vos établissements en tout genre. Vous êtes le vainqueur de la superstition, ainsi que le soutien de la liberté germanique.

Vivez plus longtemps que moi, pour affermir tous les empires que vous avez fondés. Puisse Frédéric le Grand être Frédéric l’immortel!

Daignez agréer le profond respect et l’inviolable attachement de

:::paragraphs{style="signature"}
Voltaire.
:::

:::paragraphs{style="colophon"}
*Correspondance de Voltaire avec le roi de Prusse* (Paris, Librairie de la Bibliothèque nationale, 1889), texte du Project Gutenberg, nº 25734. Composé en Crimson Pro et IM Fell (licence SIL OFL).
:::
`; // content.<lang>.md, inlined by the Cookbook

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // text, display and label faces (gotcha: fonts-first)
  'Crimson Pro': ['400', '400i'], 'IM Fell French Canon': ['400i'], 'IM Fell DW Pica SC': ['400'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadSvg('binding.svg', bindingSvg());
await loadImage('letters-1160.jpg', asset('letters-1160.jpg'));
await loadFonts(FONTS, markdown);
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: t({ en: 'Letters edition', es: 'Edición de cartas' }) });

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

## アレンジ

### 柱に場所も入れる

同じ属性を使えば、奇数ページに日付行全体、「À Charlottembourg, le 12 juin 1740」を入れられます。

```diff
-  head('recto-date', '{attr.date}', 'odd', at('page', 'top-right', -(MARGIN.outer + 8), HEAD_Y),
+  head('recto-date', '{attr.place}, le {attr.date}', 'odd',
+    at('page', 'top-right', -(MARGIN.outer + 8), HEAD_Y),
```

## よくあるつまずき

- **属性値には{も}も書けない。"を含む値は一重引用符で囲む.** 属性値は閉じ波かっこで終わるため、{や}を含められません。二重引用符を含む値は一重引用符で囲みます。ドル記号は問題ありません。
- **headingsオブジェクトを渡すとH1の改ページが消える.** 既定ではH1は奇数ページへ改ページします（always-odd）。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。
- **{number}/{chapterNumber}はH1の番号を出す。{numberRoman}は部だけ.** {number}と{chapterNumber}は見出しの書式付きの番号を出力しますが、{numberRoman}、{numberDecimal}などの数値のバリエーションが埋まるのは部扉だけです。章番号の書式はnumberingTemplate（{1:I}）で指定するか、属性として渡してください。
- **全面の表紙のあとに:::pagebreakを置く.** 段組みのレイアウトでは、表紙のような全面の章扉のあと、次のブロックが同じページの2段目、つまり絵の上から始まってしまいます。表紙の見出しの直後に:::pagebreakを置けばそのページで終わり、白ページもできません。
- **ハイフネーションできるのは8つのロケールだけで、コードは完全一致.** ハイフネーションが用意されているのはen-us、es、fr、de、it、pt、ca、nlで、コードは完全一致で照合されます。'es-ES'やほかの言語は、何の知らせもなくアメリカ英語にフォールバックします。
- **ノーブレークスペースでも改行される.** postext 1.4.1の改行処理はU+00A0を通常のスペースとして扱うため、0.08 %、2.006 s、Section 2などが2行に分かれることがあります。間を詰める（0.08%）か、文を書き換えてください。
- **パレットを差し替えても、デザイン要素と参照色は変わらない.** 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'を設定してください。
- **ラントの修正が、描かれないトラッキングを詰めることがある.** postext 1.4.1では、段落がラント（最終行に残った短い一語）で終わると、レイアウトはその段落を1行短く組みます。まず語間を詰め、次にmaxRuntTracking（1000分の1 em単位）までの負のトラッキングを使います。CanvasとPDFのレンダラーは0より大きいトラッキングしか描かないため、トラッキングを詰めた段落はトラッキングなしで印字されます。両端そろえの行はその差の分だけ語間を失って詰まって見え、最終行は行長を超えて段の端で切れることがあります。bodyText.maxRuntTracking: 0を設定すれば語間による修正は残るので、それでも出るラントは文を書き換えてください。
- **レイアウトの前にすべてのフォントを読み込む.** レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。
- **デザインのテキストではインラインの^sup^や**bold**が使えない.** デザインのテキスト要素はプレーンテキストを印字するため、属性内の^1^や**bold**はそのまま出ます。Unicodeの上付き数字（¹ ² ³はlatinサブセットに含まれます）を使うか、別のウェイトの要素をもう1つ置いてください。

- 奇数ページは、そのページかそれより前で*始まる*最後の手紙の日付を取ります。ある手紙が終わり次の手紙が始まるページには、新しい手紙の日付が入ります。辞書の柱がそのページの最後の見出し語を示すのと同じです。
- 属性のない見出しは空文字列を印刷し、警告も出ません。`place="…"`のない手紙では、日付行がコンマから始まります。すべての手紙の見出しを確認してください。
- 手紙は翻刻のとおり、; : ? !の前にアキを入れていません。フランス語の慣行ではその前に細いノーブレークスペースを入れますが、1.4.1はそのスペースでもほかのスペースと同じように改行するので、約物が行頭に来るおそれがあります。
- Crimson Proには修飾文字のᵉ（U+1D49）とʳ（U+02B3）がないので、Unicodeで綴った1ᵉʳは代替フォントで印刷されます。本文である解題は`1^er^`で上付きにしています。日付行と奇数ページの柱はデザインのテキストなので、序数の部分も本文と同じ大きさのまま1erと印刷されます。

## クレジット

- レシピ: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- テキスト: Frederick II’s letters to Voltaire of 6 and 12 June 1740 and Voltaire’s letter to him of 1 April 1778, in Correspondance de Voltaire avec le roi de Prusse (Paris, Librairie de la Bibliothèque nationale, 1889), one misprint corrected (ouvragés), the salutations and signatures set apart: Frédéric II · Voltaire ([出典](https://www.gutenberg.org/ebooks/25734)), パブリックドメイン
- テキスト: The headnote and the colophon: Ignacio Ferro, CC-BY-4.0
- 画像: The cover: two folded letters and a wax seal, a photograph: Generated With Diffusion Models, オリジナル
- 画像: The cover’s green morocco and gilt fillet, drawn in code: Ignacio Ferro, CC-BY-4.0
- 書体: Crimson Pro (OFL-1.1), IM Fell French Canon (OFL-1.1), IM Fell DW Pica SC (OFL-1.1)
- コード: MIT · サンプルの内容: CC-BY-4.0

## 関連レシピ

- [No. 030 · 著者名入りのアンソロジー](https://postext.dev/ja/cookbook/anthology-with-bylines.md): 歩くことをめぐる3編の随筆。見出しに著者・年・掲載誌を持たせ、章扉はその3つを、偶数ページの柱と目次は著者名を印字します。 · 難易度 2 (中級) · 小説・戯曲・文芸
- [No. 015 · 詩を1行ずつ組む](https://postext.dev/ja/cookbook/poetry-collection.md): 詩の各行を1段落とし、折り返しを4 em下げてぶら下げます。1918年版のインデントはemスペースで保ち、:::spaceで連の間に1行空けます。 · 難易度 2 (中級) · 詩歌
- [No. 058 · DIN 5008に従うビジネスレター](https://postext.dev/ja/cookbook/din-business-letter.md): A4で2ページのドイツ語の見積書です。件名をH1にし、そのデザインでレターヘッド、宛名、情報ブロックを書式Bの位置に固定します。 · 難易度 2 (中級) · 一枚物とエフェメラ
