# スペイン語を両端そろえで組むポケット判の小説

> 『Marianela』第I章の冒頭をポケット判で。スペイン語のハイフネーション、1.7倍未満の語間、ラントなし、上げたイニシャル、地図のFigura 1を組みます。

- HTML版: https://postext.dev/ja/cookbook/spanish-pocket-novel
- レシピ No. 016 · 文字と本文 · 難易度 2 (中級) · 出力: Canvas
- ジャンル: 小説・戯曲・文芸
- 必要なもの postext ≥ 1.12.2 · テスト環境 1.12.2 ／テスト日 2026-10-02
- ページ: [8](https://postext.dev/cookbook/spanish-pocket-novel/es/p01.webp?v=69df0069), [9](https://postext.dev/cookbook/spanish-pocket-novel/es/p02.webp?v=69df0069), [10](https://postext.dev/cookbook/spanish-pocket-novel/es/p03.webp?v=69df0069), [11](https://postext.dev/cookbook/spanish-pocket-novel/es/p04.webp?v=69df0069), [12](https://postext.dev/cookbook/spanish-pocket-novel/es/p05.webp?v=69df0069)
- Sandboxで開く: https://postext.dev/ja/sandbox#recipe=spanish-pocket-novel&lang=es (.postext: https://postext.dev/cookbook/spanish-pocket-novel/es/spanish-pocket-novel.postext)
- 最終更新: 2026-10-02
- 他の言語: [en](https://postext.dev/en/cookbook/spanish-pocket-novel.md), [es](https://postext.dev/es/cookbook/spanish-pocket-novel.md), [ca](https://postext.dev/ca/cookbook/spanish-pocket-novel.md), [zh](https://postext.dev/zh/cookbook/spanish-pocket-novel.md), [ar](https://postext.dev/ar/cookbook/spanish-pocket-novel.md)

## かんたんな説明

1878年のスペインの小説の最初の数ページを、小さな判型の本にします。単語をスペイン語の正しい音節で分け、語と語のあいだのすきまを均等に保つ方法を示します。

## できあがり

Galdósの『Marianela』（1878年）の冒頭を、11.5 × 18 cmのポケット判で組みます。第I章の向かいには、ソカルテス鉱山の推定図を置きます。章のLibre Bodoniのイタリックの表題の上には、*capítulo primero*をスモールキャピタルで文字で書きます。Gentium Book Plusの本文はページの4分の1ほど下から、オックスブラッド色の上げたイニシャルで始まります。単語はスペイン語の音節で分かれ（*hie-rro*、*esta-blecimiento*）、語間は通常の幅の1.7倍未満に収まります。ハイフンで分けた単語の切れ端で終わる段落はありません。会話は*raya*で組みます。これは全角ダッシュで、語り手の*inciso*（挿入句）を開いたり閉じたりするのにも使います。旅人の合言葉は«comillas latinas»（山かっこ）に入れ、刊行者の注記が引用の中で引くところでは英語式の二重引用符に入れます。地図は*Figura 1*で、本文では[fig. 1]として引きます。Marcellus SCの柱は、偶数ページに著者名、奇数ページに書名を入れます。

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

- スペイン語を両端そろえとハイフネーションで組み、ダッシュ、«»、「Figura」を使うには？
- ウィドウ、オーファン、最終行の一語（ラント）を避け、見出しを本文と離さないようにするには？
- 会話のダッシュ、段落頭の年号、価格、記号そのものを、Markdownに誤読させずに書くには？
- 「図」や「表」のラベルを文書の言語で表示するには？
- 章扉に著者名の行、リード文、ドロップキャップ付きの導入文を加えるには？

## 手短な答え

```js
// script.js, 行 45–60
const LOCALE = 'es'; // config().locale; 'es-ES' gets US breaks (gotcha: hyphenation-locales)
const bodyText = { // config().bodyText
  fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  referenceBold: false, // '[fig. 1]' reads in roman, like the words around it
  firstLineIndent: mm(4), indentAfterHeading: false, // the lead's paragraph goes on flush
  // The defaults justify, hyphenate, break by Knuth–Plass and keep widows and orphans out.
  // Long Spanish words on an 86 mm measure need two more settings. Spaces under 1.7×: at
  // the default 2×, eight lines here open past 1.6×; at 1.7 it hyphenates hie-rro, ca-lles.
  // A last line under 26 space widths is a runt; at 20, 'siem- / pre adelante.' got through.
  maxWordSpacing: 1.7, runtMinCharacters: 26,
};
// config().resourceTypes, as the locale leaves captions in English (gotcha:
// resource-types-locale): 'Figura' and 'Fig.', numbered through the book, Figura 1.
const resourceTypes = defaultResourceTypes(LOCALE)
  .map((type) => ({ ...type, numberingTemplate: '{n}', resetOn: 'never' }));
```

## 材料

**学べること**

- [ハイフネーションと文書の言語](https://postext.dev/ja/docs/justification.md#対応するロケール): 8言語のTeXパターンによるハイフネーションです。言語は文書のロケールで決まり、ロケールは表の続き表示の文字列とタグ付きPDFの言語も決めます。
- [最適な改行（Knuth–Plass）](https://postext.dev/ja/docs/justification.md#knuth-plass段落全体を見渡す): 1行ずつ詰めていくのではなく、段落全体をまとめて改行位置を決め、語間の上下限の範囲内で語間を均等にします。
- [章扉のドロップキャップ](https://postext.dev/ja/docs/configuration.md#テキスト要素): デザインのテキスト要素の中に組むドロップキャップと字下げした段落です。章や記事の扉のリード文に使います。

**ほかに使うもの**

- [デザインした章扉](https://postext.dev/ja/docs/configuration.md#幅と詳細デザイン)
- [見出しの属性](https://postext.dev/ja/docs/document-format.md#見出しの属性)
- [ページデザインのテキスト・罫・ボックス](https://postext.dev/ja/docs/configuration.md#柱とノンブル)
- [ウィドウ、オーファン、ラント](https://postext.dev/ja/docs/configuration.md#オーファンウィドウラント分割禁止の規則)
- [見開きの余白](https://postext.dev/ja/docs/configuration.md#見開きの余白)
- [奇数ページから始まる章](https://postext.dev/ja/docs/configuration.md#前で改ページ)
- [ページの役割ごとの柱](https://postext.dev/ja/docs/configuration.md#テキスト要素)
- [柱とノンブル](https://postext.dev/ja/docs/configuration.md#柱とノンブル)
- [図を配置する参照](https://postext.dev/ja/docs/document-format.md#インライン参照基本の形)
- [番号付きキャプション](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/document-format.md#space)
- [段落スタイル](https://postext.dev/ja/docs/configuration.md#段落スタイル)
- [エスケープとそのままの文字](https://postext.dev/ja/docs/document-format.md#記述の決まりごと)
- [用紙の色](https://postext.dev/ja/docs/configuration.md#ページ)
- [独自のリソースの種類](https://postext.dev/ja/docs/configuration.md#リソースの種類)
- [リソースとしての図と表](https://postext.dev/ja/docs/document-format.md#リソース)
- [節ごとの柱](https://postext.dev/ja/docs/configuration.md#見出しスタイル)

**設定の一覧**

- [`bodyText`](https://postext.dev/ja/docs/configuration.md#本文), [`captionStyle`](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#段落スタイル), [`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#計測キャッシュ), [`defaultResourceTypes`](https://postext.dev/ja/docs/configuration.md#リソースの種類), [`registerResourceImage`](https://postext.dev/ja/docs/architecture.md#apiの概要), [`renderPageToCanvas`](https://postext.dev/ja/docs/configuration.md#ページをビットマップに描画する)

**書体**

- Gentium Book Plus (OFL-1.1), Libre Bodoni (OFL-1.1), Marcellus SC (OFL-1.1)

## 作り方

### 1 · スペイン語の音節で分け、語間を抑える

コードは上の[手短な答え](#手短な答え)にあります。`locale: 'es'`でスペイン語のハイフネーションパターンが選ばれます。選ばれるのはこのコードぴったりのときだけです。`'es-ES'`にすると、これらのページは米語の規則で*cabal-los*、*hi-erro*、*establec-imiento*と分かれ、ある1行の語間は2.13倍まで開きます（[対応するロケール](/ja/docs/justification#対応するロケール)）。両端そろえ、Knuth–Plass、ウィドウとオーファンの規則は既定で有効です。86 mmの行長では語間も抑える必要があります。既定の`maxWordSpacing: 2`では8行が1.6倍を超えて開き、いちばん広い行は1.89倍になります。1.7にすると、いちばん広い行は1.65倍です。改行処理が代わりに*hie-rro*と*ca-lles*をハイフネーションするからです（[語間の上限と下限](/ja/docs/justification#語間の上限と下限)）。上限を超えた行はコストがずっと高くなりますが、許されないわけではないので、行長がもっと狭く、ほかによい改行位置がなければ、上限を超えることもあります。`runtMinCharacters: 26`は、既定のスペース20個分では通ってしまう、ハイフンで分けた切れ端を捕まえます。「*siem- / pre adelante.*」は「*Adelante, / siempre adelante.*」に、「*So- / cartes [fig. 1].*」は「*de / Socartes [fig. 1].*」になります（[ラント](/ja/docs/configuration#オーファンウィドウラント分割禁止の規則)）。

### 2 · 行がきっちり収まるポケット判のページ

```js
// script.js, 行 33–41
const TRIM = { width: 115, height: 180 }; // mm: 11.5 × 18 cm
const [TOP, INNER, OUTER] = [16, 15, 14]; // mm: a pocket page keeps its margins tight
const LINES = 30; // lines of LEAD per page, so every full page ends on the same line
const MEASURE = TRIM.width - INNER - OUTER; // 86 mm: about 57 characters of Gentium at 10 pt
const page = {
  width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'),
  margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - (LINES * LEAD * 25.4) / 72),
    left: mm(INNER), right: mm(OUTER), mirror: true }, // left is inner on a recto
};
```

下余白は仕上がり、上余白、`LINES`から計算するので、版面には13.8ptの行がちょうど30行入り、[10ページと11ページ](https://postext.dev/cookbook/spanish-pocket-novel/es/p03.webp?v=69df0069)のように、文字で埋まったページはどれも同じベースラインで終わります。`mirror: true`にすると、見開きのどちらのページでも、15 mmの内側の余白がのどに、14 mmの外側の余白が小口に来ます（[見開きの余白](/ja/docs/configuration#見開きの余白)）。86 mmの行長には、10ptのGentium Book Plusで約57字が入ります。

### 3 · 文字で書く章番号と、上げたイニシャル

```js
// script.js, 行 64–97
const [LABEL_Y, RULE_Y, TITLE_Y] = [4, 10.5, 13.5]; // mm below the top of the text block
const SINK = 8; // lines of LEAD above the lead: the chapter drops a quarter of the page
const INITIAL = 3 * LEAD; // pt: the initial's size, three leads
// Design text is set ragged (gotcha: design-text-ragged): the lead is the one line beside
// the initial, fitted flush by the gap; the paragraph goes on in the Markdown.
const INITIAL_GAP = 0.55; // mm: the line ends 0.1 mm short of the measure
const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) } });
const opener = {
  enabled: true,
  slot: { elements: [
    // Numbering templates print numerals only (1, 01, I, i, A, a); a number in words comes
    // from the heading's own attribute: # Perdido {ordinal="primero" lead="Se puso el sol. …"}
    { kind: 'text', id: 'chapter', content: 'capítulo {attr.ordinal}', ...smallCaps,
      placement: centred(LABEL_Y) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6),
      color: col('oxblood'), placement: { ...centred(RULE_Y), size: { width: mm(9) } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, italic: true,
      fontSize: pt(26), lineHeight: 1.1, color: col('ink'), align: 'center',
      overflow: 'wrap', // longer titles wrap, not '…' (gotcha: overflow-ellipsis-default)
      placement: centred(TITLE_Y) },
    { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: TEXT, fontSize: pt(BODY),
      lineHeight: LEAD / BODY, // a multiple, never a pt (gotcha: design-lineheight-multiple)
      color: col('ink'), align: 'left', overflow: 'wrap', // a dropCap needs wrapping text
      dropCap: { lines: 1, fontFamily: DISPLAY, fontWeight: 700, fontSize: pt(INITIAL),
        color: col('oxblood'), gap: mm(INITIAL_GAP) }, // lines: 1, a raised initial
      placement: { anchor: { to: 'container', edge: 'top-left' },
        offset: { y: pt(SINK * LEAD) }, size: { width: mm(MEASURE) } } },
  ] },
};
// The break restated (gotcha: headings-drop-h1-break): the next page, as pocket books do.
// marginBottom replaces the level's default, a blank line of its own: the text goes on
// on the line under the lead.
const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },
  marginBottom: pt(0), advancedDesign: opener };
```

番号のテンプレートが印字するのは数字（1、01、I、i、A、a）だけで、言葉は出せません。そこで*capítulo primero*は見出し自身の`ordinal`属性から取ります（[見出しの属性](/ja/docs/document-format#見出しの属性)）。Postextがイニシャルを描くのはデザインテキストの中だけで、しかもそのテキストが`overflow: 'wrap'`のときに限られます。デザインテキストは不ぞろいで組まれます。その右端の不ぞろいを隠すため、イニシャルには1行だけを並べます。`lines: 1`でイニシャルを`lead`属性に入れた1行の横に上げ、`INITIAL_GAP`は、その行が行長より0.1 mm短いところでそろって終わるまで調整しました。段落の続きは字下げなしでMarkdownに書きます（[テキスト要素](/ja/docs/configuration#テキスト要素)）。章のレベルの`marginBottom: 0`は、空行をひとつ足してしまう既定の余白を置き換えるので、本文はリードのすぐ下の行から続きます。

![Página de apertura 9: Perdido.](https://postext.dev/cookbook/spanish-pocket-novel/es/p02.webp?v=69df0069)

*9ページ：章のラベルと表題、そして上げたイニシャル。その横の1行目は、下に続く両端そろえの本文と右端がそろっています。*

### 4 · 地図はFigura 1として章に向かい合う

```js
// script.js, 行 122–143
const MAP_H = 112; // mm: a frontispiece map, the measure wide
const resources = [{
  id: 'plano', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'plano.svg', width: MEASURE, height: MAP_H }, // the ratio: set column-wide
  placement: { position: 'here' }, // where ::resource{id="plano"} stands, not a float
  caption: 'El camino de Golfín: de Villafangosa, por la pasadera y el cerro, al talud de '
    + 'las minas de Socartes.',
  note: 'Plano conjetural dibujado para esta edición a partir del capítulo primero.',
  altText: 'Plano: abajo, la villa, el río y la pasadera; en medio, un cerro arbolado; arriba, '
    + 'las minas en gradas. Un punteado lleva de la villa al talud de las minas.',
}];
// The plate's heading, # Las minas de Socartes {style="lamina"}, opens a page the heads skip.
const frontispiece = {
  id: 'lamina', // its own break, or it takes the chapter's (gotcha: style-inherits-break)
  breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(0),
  footer: { elements: [] }, // no drop folio
  advancedDesign: { enabled: true, slot: { elements: [{ kind: 'text', id: 'name',
    content: '{titleText}', ...smallCaps, // a line down: the plate centres on the page
    placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: pt(LEAD) } } }] } },
};
const captionStyle = { fontFamily: TEXT, fontSize: pt(8.3), gap: mm(2),
  labelColor: col('oxblood'), descriptionItalic: true, note: { color: col('muted') } };
```

`position: 'here'`で、地図は専用の見出しの下、`::resource{id="plano"}`のある位置に置かれます。この見出しによって本の8ページは扉になり、柱は扉には入りません。見出しのスタイルはフッターを空にし、独自の改ページを設定します。こうして、章を奇数ページから始める設定にしても、図版は柱もノンブルもないまま第I章の向かいに残ります（[見出しスタイル](/ja/docs/configuration#見出しスタイル)）。手短な答えのスペイン語の種類は、地図の下に*Figura 1.*、本文に*Fig. 1*と印字します（`resourceTypes`がなければ、ロケールにかかわらず*Figure 1.1*になります）。Markdownの`:ref`に`case="lower"`を書くと、本文のほうは*fig. 1*になります。参照は角かっこに入れます。スペイン語の校訂版では、編者が著者の本文に加えたものを角かっこで示すからです。

### 5 · 偶数ページに著者、奇数ページに書名

```js
// script.js, 行 101–118
const [HEAD_Y, DROP_Y] = [9, -10]; // mm: heads from the top edge, drop folio from the foot
const head = (id, content, parity, edge, x, y = HEAD_Y, pages = 'body') => ({
  kind: 'text', id, content, parity, pages, // running heads skip the openers
  fontFamily: LABEL, fontSize: pt(8), letterSpacing: pt(1.2), color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } },
});
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
// Folios in the text face (Marcellus SC's 1 and 0 read as I and O), on the heads' baseline.
const folio = { fontFamily: TEXT, letterSpacing: pt(0), color: col('ink') };
const header = { elements: [
  { ...head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER), ...folio },
  head('verso-author', '{author}', 'even', 'top', -SHIFT),
  head('recto-title', '{title}', 'odd', 'top', SHIFT),
  { ...head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER), ...folio },
] };
const drop = (parity, x) => ({ ...head(`drop-${parity}`, '{pageNumber}', parity, 'bottom', x,
  DROP_Y, 'opener'), ...folio }); // a chapter can open on either side of the spread
const footer = { elements: [drop('odd', SHIFT), drop('even', -SHIFT)] };
```

`parity: 'even'`で偶数ページに著者名、`'odd'`で奇数ページに書名を置きます。`pages: 'body'`で、どちらも扉には入りません。扉に入る付き物は、`pages: 'opener'`で設定した地のノンブルだけです。柱はそれぞれページに固定し、`SHIFT`で版面の中央へ動かします。内側と外側の余白が違うので、版面の中央はページの中央から0.5 mmずれているからです。同じ理由で、地のノンブルも奇数・偶数の2通りを用意しています。Marcellus SCは1と0をIとOのように描くので、ノンブルはGentiumで組みます。

### 6 · Markdownの中のスペイン語の約物

```js
// script.js, 行 147–155
const paragraphStyles = [
  { id: 'asterismo', fontFamily: DISPLAY, fontSize: pt(11), color: col('oxblood'),
    textAlign: 'center' },
  // In ink, not muted: a style has no italic colour (gotcha: style-italic-colour).
  { id: 'nota', fontSize: pt(8.3), lineHeight: pt(LEAD * 0.8), firstLineIndent: pt(0),
    marginTop: pt(LEAD) },
  { id: 'colofon', fontSize: pt(7.5), color: col('muted'), textAlign: 'center',
    firstLineIndent: pt(0), marginTop: pt(LEAD / 2) },
];
```

最後の[12ページ](https://postext.dev/cookbook/spanish-pocket-novel/es/p05.webp?v=69df0069)は、3つの段落スタイルで終わります。オックスブラッド色のBodoniのアステリスクは、空行ではアキが入らないので、[`:::space{lines=2}`](/ja/docs/document-format#space)の後に中央そろえで置きます。刊行者の注記は8.3pt、行送り11pt、奥付は柱と同じグレーの7.5ptです。注記は墨のままにしています。段落スタイルにはイタリック用の色がないので、注記をグレーにすると、イタリックの*Marianela*がまわりの語より濃く刷られてしまいます。Markdownでは、会話はハイフンではなくraya（—）で始めます。行頭のハイフンとスペースは箇条書きを始めてしまうからです。注記の先頭の「*1878.*」は番号つきリストを始めてしまうので、年の前に単語結合子（U+2060）を置きます。アステリスクはエスケープします（`\*`）。エスケープしないと、最初のひとつが箇条書きを、残りの2つがイタリックを始め、その行は消えてしまいます。

## レシピの全体

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

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

### script.js

```js
// ═══ Postext Cookbook · Nº 016 · Justified Spanish in a pocket novel ═════════════════
// https://postext.dev/en/cookbook/spanish-pocket-novel
// Code: MIT · Text: B. Pérez Galdós, Marianela, 1878 (PD, Gutenberg #17340) · Map: drawn in code
// Fonts: Gentium Book Plus, Libre Bodoni, Marcellus SC (SIL OFL 1.1) · Needs postext ≥ 1.12.2
import { buildDocument, renderPageToCanvas, clearMeasurementCache, defaultResourceTypes,
  registerResourceImage } from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document (this recipe is Spanish only)
const RECIPE = 'spanish-pocket-novel';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = {
  ink: '#1e1b18', // text: a warm near-black
  oxblood: '#8b2e2a', // the one accent: chapter label, rule, initial, asterisks, route
  muted: '#6d645a', // running heads, the map's paths, the caption's credit
  paper: '#f7f2e8', // an ivory book paper
  slag: '#c8553d', moss: '#6f7a4f', water: '#58707f', // the map: mined earth, woods, river
};
// 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' } })),
  { id: 'main-color', name: 'oxblood (defaults)', value: { hex: palette.oxblood, model: 'hex' } },
];
const TEXT = 'Gentium Book Plus'; // the text face
const DISPLAY = 'Libre Bodoni'; // the chapter's title and the initial
const LABEL = 'Marcellus SC'; // small capitals: chapter label, running heads, map names
const [BODY, LEAD] = [10, 13.8]; // pt: the body size, and its leading: the grid's pitch
const smallCaps = { fontFamily: LABEL, fontSize: pt(9), letterSpacing: pt(1.6), // 2 labels
  color: col('oxblood'), align: 'center' };

// #region page: a pocket paperback; the text block holds 30 whole lines
const TRIM = { width: 115, height: 180 }; // mm: 11.5 × 18 cm
const [TOP, INNER, OUTER] = [16, 15, 14]; // mm: a pocket page keeps its margins tight
const LINES = 30; // lines of LEAD per page, so every full page ends on the same line
const MEASURE = TRIM.width - INNER - OUTER; // 86 mm: about 57 characters of Gentium at 10 pt
const page = {
  width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'),
  margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - (LINES * LEAD * 25.4) / 72),
    left: mm(INNER), right: mm(OUTER), mirror: true }, // left is inner on a recto
};
// #endregion

// #region answer: Spanish syllables, word spaces under 1.7×, captions that say Figura
const LOCALE = 'es'; // config().locale; 'es-ES' gets US breaks (gotcha: hyphenation-locales)
const bodyText = { // config().bodyText
  fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  referenceBold: false, // '[fig. 1]' reads in roman, like the words around it
  firstLineIndent: mm(4), indentAfterHeading: false, // the lead's paragraph goes on flush
  // The defaults justify, hyphenate, break by Knuth–Plass and keep widows and orphans out.
  // Long Spanish words on an 86 mm measure need two more settings. Spaces under 1.7×: at
  // the default 2×, eight lines here open past 1.6×; at 1.7 it hyphenates hie-rro, ca-lles.
  // A last line under 26 space widths is a runt; at 20, 'siem- / pre adelante.' got through.
  maxWordSpacing: 1.7, runtMinCharacters: 26,
};
// config().resourceTypes, as the locale leaves captions in English (gotcha:
// resource-types-locale): 'Figura' and 'Fig.', numbered through the book, Figura 1.
const resourceTypes = defaultResourceTypes(LOCALE)
  .map((type) => ({ ...type, numberingTemplate: '{n}', resetOn: 'never' }));
// #endregion

// #region opener: the chapter spelled out, its title, and the lead under a raised initial
const [LABEL_Y, RULE_Y, TITLE_Y] = [4, 10.5, 13.5]; // mm below the top of the text block
const SINK = 8; // lines of LEAD above the lead: the chapter drops a quarter of the page
const INITIAL = 3 * LEAD; // pt: the initial's size, three leads
// Design text is set ragged (gotcha: design-text-ragged): the lead is the one line beside
// the initial, fitted flush by the gap; the paragraph goes on in the Markdown.
const INITIAL_GAP = 0.55; // mm: the line ends 0.1 mm short of the measure
const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) } });
const opener = {
  enabled: true,
  slot: { elements: [
    // Numbering templates print numerals only (1, 01, I, i, A, a); a number in words comes
    // from the heading's own attribute: # Perdido {ordinal="primero" lead="Se puso el sol. …"}
    { kind: 'text', id: 'chapter', content: 'capítulo {attr.ordinal}', ...smallCaps,
      placement: centred(LABEL_Y) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6),
      color: col('oxblood'), placement: { ...centred(RULE_Y), size: { width: mm(9) } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, italic: true,
      fontSize: pt(26), lineHeight: 1.1, color: col('ink'), align: 'center',
      overflow: 'wrap', // longer titles wrap, not '…' (gotcha: overflow-ellipsis-default)
      placement: centred(TITLE_Y) },
    { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: TEXT, fontSize: pt(BODY),
      lineHeight: LEAD / BODY, // a multiple, never a pt (gotcha: design-lineheight-multiple)
      color: col('ink'), align: 'left', overflow: 'wrap', // a dropCap needs wrapping text
      dropCap: { lines: 1, fontFamily: DISPLAY, fontWeight: 700, fontSize: pt(INITIAL),
        color: col('oxblood'), gap: mm(INITIAL_GAP) }, // lines: 1, a raised initial
      placement: { anchor: { to: 'container', edge: 'top-left' },
        offset: { y: pt(SINK * LEAD) }, size: { width: mm(MEASURE) } } },
  ] },
};
// The break restated (gotcha: headings-drop-h1-break): the next page, as pocket books do.
// marginBottom replaces the level's default, a blank line of its own: the text goes on
// on the line under the lead.
const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },
  marginBottom: pt(0), advancedDesign: opener };
// #endregion

// #region heads: the author on the verso, the title on the recto; a drop folio on the opener
const [HEAD_Y, DROP_Y] = [9, -10]; // mm: heads from the top edge, drop folio from the foot
const head = (id, content, parity, edge, x, y = HEAD_Y, pages = 'body') => ({
  kind: 'text', id, content, parity, pages, // running heads skip the openers
  fontFamily: LABEL, fontSize: pt(8), letterSpacing: pt(1.2), color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } },
});
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
// Folios in the text face (Marcellus SC's 1 and 0 read as I and O), on the heads' baseline.
const folio = { fontFamily: TEXT, letterSpacing: pt(0), color: col('ink') };
const header = { elements: [
  { ...head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER), ...folio },
  head('verso-author', '{author}', 'even', 'top', -SHIFT),
  head('recto-title', '{title}', 'odd', 'top', SHIFT),
  { ...head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER), ...folio },
] };
const drop = (parity, x) => ({ ...head(`drop-${parity}`, '{pageNumber}', parity, 'bottom', x,
  DROP_Y, 'opener'), ...folio }); // a chapter can open on either side of the spread
const footer = { elements: [drop('odd', SHIFT), drop('even', -SHIFT)] };
// #endregion

// #region figure: the map faces chapter I as Figura 1; the text cites it as [fig. 1]
const MAP_H = 112; // mm: a frontispiece map, the measure wide
const resources = [{
  id: 'plano', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'plano.svg', width: MEASURE, height: MAP_H }, // the ratio: set column-wide
  placement: { position: 'here' }, // where ::resource{id="plano"} stands, not a float
  caption: 'El camino de Golfín: de Villafangosa, por la pasadera y el cerro, al talud de '
    + 'las minas de Socartes.',
  note: 'Plano conjetural dibujado para esta edición a partir del capítulo primero.',
  altText: 'Plano: abajo, la villa, el río y la pasadera; en medio, un cerro arbolado; arriba, '
    + 'las minas en gradas. Un punteado lleva de la villa al talud de las minas.',
}];
// The plate's heading, # Las minas de Socartes {style="lamina"}, opens a page the heads skip.
const frontispiece = {
  id: 'lamina', // its own break, or it takes the chapter's (gotcha: style-inherits-break)
  breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(0),
  footer: { elements: [] }, // no drop folio
  advancedDesign: { enabled: true, slot: { elements: [{ kind: 'text', id: 'name',
    content: '{titleText}', ...smallCaps, // a line down: the plate centres on the page
    placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: pt(LEAD) } } }] } },
};
const captionStyle = { fontFamily: TEXT, fontSize: pt(8.3), gap: mm(2),
  labelColor: col('oxblood'), descriptionItalic: true, note: { color: col('muted') } };
// #endregion

// #region styles: the asterisks that close the excerpt, the edition's note, the colophon
const paragraphStyles = [
  { id: 'asterismo', fontFamily: DISPLAY, fontSize: pt(11), color: col('oxblood'),
    textAlign: 'center' },
  // In ink, not muted: a style has no italic colour (gotcha: style-italic-colour).
  { id: 'nota', fontSize: pt(8.3), lineHeight: pt(LEAD * 0.8), firstLineIndent: pt(0),
    marginTop: pt(LEAD) },
  { id: 'colofon', fontSize: pt(7.5), color: col('muted'), textAlign: 'center',
    firstLineIndent: pt(0), marginTop: pt(LEAD / 2) },
];
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: LOCALE,
  resourceTypes,
  colorPalette,
  page,
  layout: { layoutType: 'single' },
  bodyText,
  headings: {
    fontFamily: DISPLAY, // the designs paint the titles; this keeps Open Sans unloaded
    levels: [chapter],
  },
  headingStyles: [frontispiece],
  paragraphStyles,
  captionStyle,
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Marianela"
author: "Benito Pérez Galdós"
---

# Las minas de Socartes {style="lamina"}

::resource{id="plano"}

# Perdido {ordinal="primero" lead="Se puso el sol. Tras el breve crepúsculo vino tranquila"}

y oscura la noche, en cuyo negro seno murieron poco a poco los últimos rumores de la tierra soñolienta, y el viajero siguió adelante en su camino, apresurando su paso a medida que avanzaba la noche. Iba por angosta vereda, de esas que sobre el césped traza el constante pisar de hombres y brutos, y subía sin cansancio por un cerro en cuyas vertientes se alzaban pintorescos grupos de guinderos, hayas y robles. (Ya se ve que estamos en el Norte de España.)

Era un hombre de mediana edad, de complexión recia, buena talla, ancho de espaldas, resuelto de ademanes, firme de andadura, basto de facciones, de mirar osado y vivo, ligero a pesar de su regular obesidad, y (dígase de una vez aunque sea prematuro) excelente persona por doquiera que se le mirara. Vestía el traje propio de los señores acomodados que viajan en verano, con el redondo sombrerete, que debe a su fealdad el nombre de hongo, gemelos de campo pendientes de una correa, y grueso bastón que, entre paso y paso, le servía para apalear las zarzas cuando extendían sus ramas llenas de afiladas uñas para atraparle la ropa.

Detúvose, y mirando a todo el círculo del horizonte, parecía impaciente y desasosegado. Sin duda no tenía gran confianza en la exactitud de su itinerario y aguardaba el paso de algún aldeano que le diese buenos informes topográficos para llegar pronto y derechamente a su destino.

—No puedo equivocarme —murmuró—. Me dijeron que atravesara el río por la pasadera… así lo hice. Después que marchara adelante, siempre adelante. En efecto, allá, detrás de mí queda esa apreciable villa, a quien yo llamaría *Villafangosa* por el buen surtido de lodos que hay en sus calles y caminos… De modo que por aquí, adelante, siempre adelante (me gusta esta frase, y si yo tuviera escudo no le pondría otra divisa) he de llegar a las famosas minas de Socartes [:ref{id="plano" case="lower"}].

Después de andar largo trecho, añadió:

—Me he perdido, no hay duda de que me he perdido… Aquí tienes, Teodoro Golfín, el resultado de tu «adelante, siempre adelante». Estos palurdos no conocen el valor de las palabras. O han querido burlarse de ti, o ellos mismos ignoran dónde están las minas de Socartes. Un gran establecimiento minero ha de anunciarse con edificios, chimeneas, ruido de arrastres, resoplido de hornos, relincho de caballos, trepidación de máquinas, y yo no veo, ni huelo, ni oigo nada… Parece que estoy en un desierto… ¡qué soledad! Si yo creyera en brujas, pensaría que mi destino me proporcionaba esta noche el honor de ser presentado a ellas… ¡Demonio!, ¿pero no hay gente en estos lugares?… Aún falta media hora para la salida de la luna. ¡Ah!, bribona, tú tienes la culpa de mi extravío… Si al menos pudiera conocer el sitio donde me encuentro… ¿Pero qué más da? (Al decir esto, hizo un gesto propio del hombre esforzado que desprecia los peligros). Golfín, tú que has dado la vuelta al mundo, ¿te acobardarás ahora?… ¡Ah!, los aldeanos tenían razón: adelante, siempre adelante. La ley universal de la locomoción no puede fallar en este momento.

Y puesta denodadamente en ejecución aquella osada ley, recorrió un kilómetro, siguiendo a capricho las veredas que le salían al paso y se cruzaban y se quebraban en ángulos mil, cual si quisiesen engañarle y confundirle más. Por grande que fuera su resolución e intrepidez, al fin tuvo que pararse. Las veredas, que al principio subían, luego empezaron a bajar, enlazándose; y al fin bajaron tanto, que nuestro viajero hallose en un talud, por el cual solo habría podido descender echándose a rodar.

—¡Bonita situación! —exclamó sonriendo y buscando en su buen humor lenitivo a la enojosa contrariedad—. ¿En dónde estás, querido Golfín? Esto parece un abismo. ¿Ves algo allá abajo? Nada, absolutamente nada… pero el césped ha desaparecido, el terreno está removido. Todo es aquí pedruscos y tierra sin vegetación, teñida por el óxido de hierro… Sin duda estoy en las minas… pero ni alma viviente, ni chimeneas humeantes, ni ruido, ni un tren que murmure a lo lejos, ni siquiera un perro que ladre… ¿Qué haré?, hay por aquí una vereda que vuelve a subir. ¿Seguirela? ¿Desandaré lo andado?… ¡Retroceder! ¡Qué absurdo! O yo dejo de ser quien soy, o llegaré esta noche a las famosas minas de Socartes y abrazaré a mi querido hermano. Adelante, siempre adelante.

Dio un paso y hundiose en la frágil tierra movediza.

—¿Esas tenemos, señor planeta?… ¿Conque quiere usted tragarme?… Si ese holgazán satélite quisiera alumbrar un poco, ya nos veríamos las caras usted y yo… Y a fe que por aquí abajo no hemos de ir a ningún paraíso. Parece esto el cráter de un volcán apagado… Hay que andar suavemente por tan delicioso precipicio. ¿Qué es esto? ¡Ah! Una piedra; magnífico asiento para echar un cigarro, esperando a que salga la luna.

El discreto Golfín se sentó tranquilamente como podría haberlo hecho en el banco de un paseo; y ya se disponía a fumar, cuando sintió una voz… sí, indudablemente era una voz humana que lejos sonaba, un quejido patético, mejor dicho, melancólico canto, formado de una sola frase, cuya última cadencia se prolongaba apianándose en la forma que los músicos llamaban *morendo*, y que se apagaba al fin en el plácido silencio de la noche, sin que el oído pudiera apreciar su vibración postrera.

:::space{lines=2}

:::paragraphs{style="asterismo"}
\*  \*  \*
:::

:::paragraphs{style="nota"}
⁠1878. Galdós publica *Marianela* en Madrid, en la Imprenta y Litografía de La Guirnalda. Seguimos el comienzo de su capítulo primero en el texto de dominio público de Project Gutenberg (eBook 17340), con la ortografía y la puntuación actuales: raya en los diálogos y comillas latinas para la consigna del viajero, que se vuelven inglesas cuando la cita va dentro de otra: «el resultado de tu “adelante, siempre adelante”». Los corchetes señalan lo que añade el editor.
:::

:::paragraphs{style="colofon"}
Compuesto en Gentium Book Plus, Libre Bodoni y Marcellus SC (SIL OFL)
:::
`; // content.es.md, inlined by the Cookbook

// #region art: the map of Socartes, in millimetres at its printed size, in the palette
function mulberry32(seed) { // a seeded PRNG: the same drawing on every run
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let r = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
    return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
  };
}
const mix = (hex, other, k) => `#${[1, 3, 5].map((i) => Math.round(
  parseInt(hex.slice(i, i + 2), 16) * (1 - k) + parseInt(other.slice(i, i + 2), 16) * k)
  .toString(16).padStart(2, '0')).join('')}`;
const n2 = (v) => +v.toFixed(2);
// A smooth path through the points (Catmull-Rom as cubic Béziers), open or closed.
function smooth(list, close = false) {
  const p = close ? [list.at(-1), ...list, list[0], list[1]] : [list[0], ...list, list.at(-1)];
  let d = `M${n2(p[1][0])} ${n2(p[1][1])}`;
  for (let i = 1; i < p.length - 2; i++) {
    const [a, b, c, e] = [p[i - 1], p[i], p[i + 1], p[i + 2]];
    d += `C${n2(b[0] + (c[0] - a[0]) / 6)} ${n2(b[1] + (c[1] - a[1]) / 6)} `
      + `${n2(c[0] - (e[0] - b[0]) / 6)} ${n2(c[1] - (e[1] - b[1]) / 6)} ${n2(c[0])} ${n2(c[1])}`;
  }
  return close ? `${d}Z` : d;
}
async function labelFace() { // Marcellus SC inside the SVG (gotcha: svg-no-webfonts)
  const url = 'https://cdn.jsdelivr.net/npm/@fontsource/marcellus-sc@5/files/'
    + 'marcellus-sc-latin-400-normal.woff2';
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Label face not found (${res.status}): ${url}`);
  const bytes = new Uint8Array(await res.arrayBuffer());
  let bin = '';
  for (let i = 0; i < bytes.length; i += 8192) {
    bin += String.fromCharCode(...bytes.subarray(i, i + 8192));
  }
  return `@font-face{font-family:L;src:url(data:font/woff2;base64,${btoa(bin)}) format('woff2')}`;
}
function drawMap(face, W, H) { // drawn for W 86 × H 112: the mines above, the town below
  const rnd = mulberry32(1878);
  const C = {
    ground: mix(palette.paper, palette.moss, 0.16), hill: mix(palette.moss, palette.paper, 0.45),
    wood: palette.moss, shade: mix(palette.moss, palette.ink, 0.35),
    earth: mix(palette.slag, palette.paper, 0.3), bench: palette.slag,
    deep: mix(palette.slag, palette.ink, 0.2), pit: mix(palette.slag, palette.ink, 0.45),
    edge: mix(palette.slag, palette.ink, 0.5),
    water: palette.water, ink: palette.ink, path: palette.muted, route: palette.oxblood,
    stream: mix(palette.water, palette.ink, 0.3), // the river's name: 5.7:1 on the ground
  };
  const out = [`<rect width="${W}" height="${H}" fill="${C.ground}"/>`];
  const fill = (d, color) => out.push(`<path d="${d}" fill="${color}"/>`);
  const stroke = (d, color, w, extra = '') => out.push(`<path d="${d}" fill="none" `
    + `stroke="${color}" stroke-width="${w}" stroke-linecap="round" `
    + `stroke-linejoin="round"${extra}/>`);
  // The names, set first so that no tree grows over them.
  const labels = [[33, 30.5, 'minas de', 'end'], [33, 34, 'Socartes', 'end'],
    [34.4, 19, 'talleres', 'end'],
    [43.6, 52.4, 'talud', 'end', C.route], [12, 70.5, 'cerro', 'middle'],
    [25.5, 96.4, 'pasadera'], [70, 94.5, 'río', 'middle', C.stream],
    [3, 109.6, 'Villafangosa'], [5, 11.4, 'N', 'middle']];
  const box = ([x, y, text, anchor = 'start']) => {
    const w = text.length * 1.45;
    const left = anchor === 'end' ? x - w : anchor === 'middle' ? x - w / 2 : x;
    return [left - 0.8, y - 2.6, left + w + 0.8, y + 0.8];
  };
  const clear = (x, y) => labels.map(box).every(([a, b, c, d]) => x < a || x > c || y < b || y > d);
  // The mines: iron-stained earth cut in benches down to the pit, the "extinct crater".
  const mine = [[40, 6], [58, 3], [76, 5], [84, 17], [83, 35], [74, 47], [58, 52], [45, 50],
    [36, 40], [35, 20]];
  [[1, C.earth], [0.76, C.bench], [0.52, C.deep], [0.28, C.pit]].forEach(([k, color], i) => {
    const [cx, cy] = [60 + i * 1.6, 27 - i * 1.4]; // each bench deeper, the pit off-centre
    const ring = mine.map(([x, y]) => [cx + (x - 60) * k + (rnd() - 0.5) * 2.4 * k,
      cy + (y - 27) * k + (rnd() - 0.5) * 2.4 * k]);
    out.push(`<path d="${smooth(ring, true)}" fill="${color}" stroke="${C.edge}" `
      + 'stroke-width="0.25"/>');
  });
  fill('M28.4 11.6h6.5v3.2h-6.5zM33 7h0.9v4.7h-0.9z', C.ink); // workshops, chimney, by the rim
  // The hill of cherry, beech and oak, and the paths that cross it at a thousand angles.
  out.push(`<path d="${smooth([[19, 73], [23, 61], [36, 56], [50, 59], [55, 72], [49, 85],
    [35, 90], [23, 84]], true)}" fill="${C.hill}" stroke="${C.shade}" stroke-width="0.25"/>`);
  for (let i = 0; i < 100; i++) {
    const a = rnd() * Math.PI * 2;
    const r = Math.sqrt(rnd()) * 16;
    const [x, y] = [37 + Math.cos(a) * r, 73 + Math.sin(a) * r];
    const [size, shade] = [0.55 + rnd() * 0.55, rnd() < 0.3 ? C.shade : C.wood];
    if (clear(x, y)) {
      out.push(`<circle cx="${n2(x)}" cy="${n2(y)}" r="${n2(size)}" fill="${shade}"/>`);
    }
  }
  [[[22, 80], [30, 74], [28, 64], [38, 58]], [[26, 62], [36, 70], [46, 64], [52, 70]],
    [[27, 87], [33, 79], [42, 82], [50, 76]], [[38, 57], [40, 67], [33, 76], [37, 89]]]
    .forEach((path) => stroke(smooth(path), C.path, 0.3, ' stroke-dasharray="1 0.8"'));
  // The river, the footbridge, and the town the traveller left behind.
  stroke(smooth([[0, 92], [12, 93], [22, 99], [34, 100], [50, 96], [66, 98], [W + 1, 102]]),
    C.water, 2.4);
  stroke('M20.4 95.2L18.6 101.2', C.ink, 0.7);
  for (let i = 0; i < 18; i++) {
    const [x, y] = [3 + (i % 6) * 2.2 + rnd() * 0.5, 98.6 + Math.floor(i / 6) * 2.3 + rnd() * 0.5];
    fill(`M${n2(x)} ${n2(y)}h1.5v1.2h-1.5z`, C.ink);
  }
  fill('M5.2 94.4h1v2.4h-1zM4 96.2h3.4v2h-3.4z', C.ink); // the church
  // Golfín's walk: over the footbridge, up the hill by its paths, down to the edge.
  stroke(smooth([[12, 101], [17, 99.4], [21, 94], [25, 88], [31, 84], [27, 78], [33, 72],
    [40, 75], [45, 68], [40, 62], [45.3, 53.6]]), C.route, 0.75,
  ' stroke-dasharray="0.05 1.3"');
  out.push(`<circle cx="46.4" cy="51.6" r="1.4" fill="none" stroke="${C.route}" `
    + 'stroke-width="0.6"/>');
  // North, up: a half-inked arrowhead over an N.
  fill('M5 3.2L6.6 8.2L5 7.2Z', C.ink);
  out.push(`<path d="M5 3.2L3.4 8.2L5 7.2Z" fill="none" stroke="${C.ink}" stroke-width="0.2"/>`);
  labels.forEach(([x, y, text, anchor = 'start', color = C.ink]) => out.push(`<text x="${x}" `
    + `y="${y}" text-anchor="${anchor}" fill="${color}">${text}</text>`));
  out.push(`<rect x="0.2" y="0.2" width="${W - 0.4}" height="${H - 0.4}" fill="none" `
    + `stroke="${C.ink}" stroke-width="0.4"/>`);
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}mm" height="${H}mm" `
    + `viewBox="0 0 ${W} ${H}"><style>${face}text{font-family:L;font-size:2.35px}</style>`
    + `${out.join('')}</svg>`;
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first)
  'Gentium Book Plus': ['400', '400i', '700'], // text, captions (700: 'Figura 1.'), the note
  'Libre Bodoni': ['400', '400i', '700'], // asterisks, the chapter's title, the initial
  'Marcellus SC': ['400'], // chapter label, plate name, running heads
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await loadSvg('plano.svg', drawMap(await labelFace(), MEASURE, MAP_H));
// The map is book page 8, a verso, facing chapter I: folios and parity follow the book.
const continuation = { pageIndexOffset: 7, pageNumbering: { startAt: 8 } };
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources, continuation }, config()), markdown);
showPages(doc, { title: 'Español justificado en una novela de bolsillo' });

// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ─────

// ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ─────────
function mm(value) { return { value, unit: 'mm' }; }
function pt(value) { return { value, unit: 'pt' }; }
function em(value) { return { value, unit: 'em' }; }
/** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */
function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; }
/** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */
function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; }

// ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ────────
// Postext measures text with the faces the browser has loaded, and caches the
// widths, so every face must be ready before the first build. Faces come from
// Fontsource: the same static files the PDF embeds, so screen and PDF agree.

/** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
  };
  const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin'];
  const jobs = [];
  let added = 0;
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const meta = optional ? await fontsourceMeta(family) : null;
    for (const spec of new Set(specs)) {
      const weight = parseInt(spec, 10);
      const style = spec.endsWith('i') ? 'italic' : 'normal';
      if (hasFace(family, weight, style)) continue;
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`;
        const face = new FontFace(family, `url(${url}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: ranges[subset] });
        jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return added;
}

/** Runs `build` (a buildDocument or buildBundle call) and checks the faces
 *  the pages use. A regular face missing from FONTS is loaded with a warning;
 *  bold and italic variants are loaded when the family ships them. Then the
 *  measurement caches are cleared and the build runs again. */
async function buildWithFonts(build, text = '') {
  const tried = new Set();
  for (let round = 0; round < 3; round++) {
    kitStatus('Laying out…');
    await new Promise(requestAnimationFrame);          // let the status paint first
    const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; });
    const wanted = { base: {}, variants: {} };
    for (const { font, base } of [result].flat().flatMap(fontStringsOf)) {
      const { family, weight, style } = parseFont(font);
      const key = `${family}|${weight}|${style}`;
      if (tried.has(key) || hasFace(family, weight, style)) continue;
      tried.add(key);
      (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`);
    }
    if (Object.keys(wanted.base).length) {
      console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`);
    }
    const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true });
    if (added === 0) return result;
    clearMeasurementCache();
  }
  throw new Error('The fonts did not settle after three builds.');
}

/** Every font string of the layout. `base` marks a block's own face; its
 *  bold, italic and bold-italic variants are listed whether or not used. */
function fontStringsOf(doc) {
  const found = new Map();
  const walk = (node) => {
    if (!node || typeof node !== 'object') return;
    if (Array.isArray(node)) { node.forEach(walk); return; }
    for (const [key, value] of Object.entries(node)) {
      if (typeof value === 'string' && /fontString$/i.test(key)) {
        found.set(value, found.get(value) || key === 'fontString');
      } else if (value && typeof value === 'object') walk(value);
    }
  };
  walk(doc.pages);
  walk(doc.blocks);
  return [...found].map(([font, base]) => ({ font, base }));
}

/** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }.
 *  A string with no weight ('95.8px Young Serif', from a design text) is 400. */
function parseFont(font) {
  const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim());
  if (!m) throw new Error(`Unexpected font string: ${font}`);
  const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]);
  return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' };
}

/** True when a loaded FontFace covers exactly this family, weight and style
 *  (document.fonts.check() is also true for families nobody declared). */
function hasFace(family, weight, style) {
  for (const face of document.fonts) {
    if (face.status !== 'loaded' || face.style !== style) continue;
    if (face.family.replace(/^["']|["']$/g, '') !== family) continue;
    const [low, high = low] = face.weight.split(' ').map(Number);
    if (weight >= low && weight <= high) return true;
  }
  return false;
}

/** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */
function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); }

/** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */
function fontsourceMeta(family) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

// ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ───────
/** Shows the pages as facing spreads on a dark desk: the first page is a
 *  recto on its own, then verso | recto pairs, as in a bound book. Pages
 *  are painted when they scroll near the screen. */
function showPages(docs, { title, width = 460 } = {}) {
  const root = viewer(title);
  const pages = [docs].flat().flatMap((doc) =>
    doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index })));
  const spreads = [];
  let verso = null;
  for (const p of pages) {
    if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; }
    else { spreads.push([verso, p]); verso = null; }
  }
  if (verso) spreads.push([verso, null]);
  const density = Math.min(window.devicePixelRatio || 1, 2);
  showPages.painter?.disconnect();
  const painter = new IntersectionObserver((entries) => {
    for (const { isIntersecting, target } of entries) {
      if (!isIntersecting) continue;
      painter.unobserve(target);
      const { doc, page } = target.postext;
      renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width });
    }
  }, { rootMargin: '800px' });
  showPages.painter = painter;
  root.replaceChildren(...spreads.map((pair) => {
    const spread = document.createElement('div');
    spread.className = 'pt-spread';
    for (const p of pair) {
      const figure = document.createElement('figure');
      if (p) {
        const label = p.page.pageLabel || String(p.n + 1);
        const canvas = document.createElement('canvas');
        canvas.postext = p;
        canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`;
        canvas.setAttribute('role', 'img');
        canvas.setAttribute('aria-label', `Page ${label}`);
        const folio = document.createElement('figcaption');
        folio.textContent = label;
        figure.append(canvas, folio);
        painter.observe(canvas);
      } else figure.className = 'pt-blank';
      spread.append(figure);
    }
    return spread;
  }));
  kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`);
  document.documentElement.dataset.postext = 'ready';
  return pages.length;
}

/** The desk, the bar and the error reporting, created once. */
function viewer(title) {
  if (!document.getElementById('pt-kit')) {
    document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit">
      :root { color-scheme: dark; }
      body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; }
      #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center;
        gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px);
        border-bottom: 1px solid #23262d; }
      #pt-bar strong { color: #f4f1ea; font-weight: 600; }
      #pt-actions { display: flex; gap: 12px; margin-left: auto; }
      #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
      #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; }
      .pt-spread { display: flex; }
      .pt-spread figure { margin: 0; width: min(460px, 44vw); }
      .pt-spread canvas { display: block; width: 100%; background: #fff;
        box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif;
        letter-spacing: .18em; text-transform: uppercase; color: #6c7079; }
      .pt-blank { visibility: hidden; }
      @media (max-width: 760px) {
        .pt-spread { flex-direction: column; gap: 32px; }
        .pt-spread figure { width: min(460px, 92vw); }
        .pt-blank { display: none; }
      }
    </style>`);
    document.body.insertAdjacentHTML('afterbegin',
      '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>');
    document.getElementById('pt-title').textContent = document.title || 'Postext';
    addEventListener('error', (event) => kitFail(event.error ?? event.message));
    addEventListener('unhandledrejection', (event) => kitFail(event.reason));
  }
  if (title) document.getElementById('pt-title').textContent = title;
  return document.getElementById('pages')
    ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' }));
}

function kitStatus(text) {
  viewer();
  document.getElementById('pt-status').textContent = text;
}

function kitFail(error) {
  document.documentElement.dataset.postext = 'error';
  kitStatus(`Error: ${error?.message ?? error}`);
}

// ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ──────────
/** Registers a photo or PNG for the canvas and keeps its bytes for the PDF.
 *  fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */
async function loadImage(fileId, url) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`);
  const bytes = new Uint8Array(await res.arrayBuffer());
  registerResourceImage(fileId, await createImageBitmap(new Blob([bytes])));
  (loadImage.bytes ??= new Map()).set(fileId, bytes);
}

/** Registers SVG markup (drawn in code, or fetched) as a vector image. */
async function loadSvg(fileId, svg) {
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** renderToPdf({ resourceBytes: imageBytes }) */
function imageBytes(fileId) { return loadImage.bytes?.get(fileId); }

/** renderToHtml({ resourceImageUrl: imageUrl }) */
function imageUrl(fileId) {
  const bytes = imageBytes(fileId);
  if (!bytes) return undefined;
  imageUrl.urls ??= new Map();
  if (!imageUrl.urls.has(fileId)) {
    const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg';
    imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type })));
  }
  return imageUrl.urls.get(fileId);
}

// ─── /Kit ───────────────────────────────────────────────────────────────────────
```

## アレンジ

### どの章も奇数ページから始める

`'odd'`にすると、偶数ページから始まるはずの章の前に白ページが入り、ゆとりのある版のようになります。第I章はもともと奇数ページから始まり、図版は自分のスタイルで独自の改ページを設定しているので、ページは変わりません。

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

### 図の番号を章ごとに振る

組み込みのスペイン語の種類をそのまま使うと、図の番号は第1レベルの見出しごとに振られます。地図は*Figura 1.1*、参照は*fig. 1.1*になります。

```diff
-const resourceTypes = defaultResourceTypes(LOCALE)
-  .map((type) => ({ ...type, numberingTemplate: '{n}', resetOn: 'never' }));
+const resourceTypes = defaultResourceTypes(LOCALE);
```

## よくあるつまずき

- **ハイフネーションできるのは8つのロケールだけで、コードは完全一致.** ハイフネーションが用意されているのはen-us、es、fr、de、it、pt、ca、nlで、コードは完全一致で照合されます。'es-ES'やほかの言語は、何の知らせもなくアメリカ英語にフォールバックします。
- **Figure/TableはdefaultResourceTypes(locale)でローカライズする.** 設定のlocaleが決めるのはハイフネーションで、キャプションではありません。resourceTypesがなければ、組み込みの種類は英語でFigureとTableと表示されます。スペイン語ならresourceTypes: defaultResourceTypes('es')を渡し、それ以外の言語ではresourceTypesに名前を自分で書いてください。
- **デザインのテキストは両端そろえにならず、ドロップキャップのリードは行末がそろわない.** postext 1.4.1のデザインのテキスト要素は左そろえ、中央、右そろえのいずれかで、語単位で折り返します。両端そろえはなく、hyphenate: trueは1行に収まらない長い語を分割するだけです。そのため、dropCapの横に組むリードの行は、両端そろえの本文の横で行末がそろいません。リードは頭文字の横の行だけにして、手で収めてください。lines: 1（上がり頭文字）ならリードは1行で、dropCapの間隔で行末をぴったり合わせられます。段落の続きはMarkdownに書きます。
- **段落の頭の'1998. 'や'- 'はリストになる.** 数字、ピリオド、スペースで始まる段落や、ハイフンとスペースで始まる段落はリスト項目になります。数字の前にワードジョイナー（U+2060）を入れ、会話文はemダッシュで書いてください。
- **属性値には{も}も書けない。"を含む値は一重引用符で囲む.** 属性値は閉じ波かっこで終わるため、{や}を含められません。二重引用符を含む値は一重引用符で囲みます。ドル記号は問題ありません。
- **headingsオブジェクトを渡すとH1の改ページが消える.** 既定ではH1は奇数ページへ改ページします（always-odd）。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。
- **デザインのテキストのlineHeightは倍率で、寸法ではない.** デザインのスロットでは、テキスト要素のlineHeightはフォントサイズに掛ける倍率です（lineHeight: 1.05）。postext 1.4.1ではpt(15)のような寸法を指定しても拒否されず、章扉の高さがNaNと計測されて、minHeightを含め確保する高さが警告なしに失われ、本文がタイトルに重なって組まれます。
- **デザインのテキストのoverflowの既定値は'ellipsis-end'.** 幅に収まらないデザインのテキスト要素は、既定では省略記号で終わります。複数行に折り返したいタイトルにはoverflow: 'wrap'を設定してください。
- **パレットを差し替えても、デザイン要素と参照色は変わらない.** postext 1.4.1はcolorPaletteをテキストのスタイル（本文、見出し、リスト、キャプション、表、囲み）には反映しますが、ヘッダー、フッター、章扉、部扉の要素と、bodyText.referenceColorには反映しません。これらはpaletteIdの横に書いた16進の色のままです。画面用のダーク版や色替えのためにパレットを差し替えるときは、ビルドの前に、リンクしたすべての色をcolorPaletteから書き直してください。
- **段落スタイルにはイタリックの色がない.** postext 1.4.1の段落スタイルはcolorとboldColorを設定できますが、italicColorはなく、イタリックの部分はbodyText.italicColorを使います。抑えた色のスタイル（小さな注記、出典の行）では、イタリックの書名が周りの文字より濃く印字されます。こうしたスタイルは本文と同じ色にするか、その中でイタリックを使わないでください。
- **見出しスタイルはその階層の改ページを受け継ぐ.** headingStylesの項目は、指定しなかったフィールドをすべて見出しの階層から受け継ぎ、breakBeforeも例外ではありません。:::pagebreakのあとのH1に付けた目次や奥付のスタイルはparity 'odd'を受け継ぎ、白ページの後ろに置かれます。こうしたスタイルにはbreakBefore: { enabled: false }を指定してください。
- **SVGの<img>内のテキストはWebフォントを使えない.** SVGは画像として描かれ、画像はページのWebフォントにアクセスできないため、ラベルはシステムの書体にフォールバックします。テキストをアウトライン化するか、SVGに@font-faceのサブセットを埋め込むか、ラベルをキャプションに移してください。
- **フロントマターの値はすべて引用符で囲む.** YAMLはtitle: 1984を数値として、日付をDateオブジェクトとして読みます。文字列でない値はプレースホルダーに空で出力され、PDFにもタイトルが付きません。値はすべて引用符で囲んでください（title: "1984"）。
- **レイアウトの前にすべてのフォントを読み込む.** レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。

- 章頭の下げを8行でなく9行にしたところ、11ページが1行足りなくなりました。`avoidWidows`がページの最終行で段落を始めさせなかったからです。設定や本文を変えたら、すべてのページの地を確かめてください。
- `lead`属性は文の途中（*…vino tranquila*）で終わり、そこからMarkdownの段落（*y oscura la noche*）が続きます。イニシャルの横に収まる語だけをちょうど入れてあるからです。書体、大きさ、行長、`INITIAL_GAP`のどれかを変えると右端がそろわなくなります。行がふたたびそろって終わるまで、2つのあいだで語を移してください。
- ここで使う書体はどれも、Fontsourceの欧文ファイルにアステリズム⁂（U+2042）を持っていないので、ブラウザーはシステムのフォントから取ってくることになります。いずれにせよ、スペイン語の本では、間を空けた3つのアステリスクで区切りを示します。

## クレジット

- レシピ: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- テキスト: Marianela (1878), the opening of chapter I, “Perdido”, with today’s spelling and punctuation: Benito Pérez Galdós ([出典](https://www.gutenberg.org/ebooks/17340)), パブリックドメイン
- テキスト: The edition’s note and the map’s caption: Postext Cookbook, オリジナル
- 画像: The conjectural map of the Socartes mines, drawn in code in the page’s palette: Ignacio Ferro, MIT
- 書体: Gentium Book Plus (OFL-1.1), Libre Bodoni (OFL-1.1), Marcellus SC (OFL-1.1)
- コード: MIT · サンプルの内容: MIT

## 関連レシピ

- [No. 014 · 狭い両端そろえの段で語間を均一にする](https://postext.dev/ja/cookbook/justification-lab.md): 語間を通常の0.8〜1.6倍に抑えたKnuth–Plassの改行を、貪欲法の対照版と並べます。対照版のゆるい行、孤立した行、ラントはVDTから読み取って印を付けます。 · 難易度 3 (上級) · 雑誌とジン
- [No. 038 · 並製本の小説：下げた章頭と改丁](https://postext.dev/ja/cookbook/trade-paperback-novel.md): 『The Awakening』を140 × 216 mmの並製本に組みます。柱のない彩色の表紙と奥付に続き、各章は奇数ページから、イタリックの章番号の下へ下げて始まります。 · 難易度 2 (中級) · 小説・戯曲・文芸
- [No. 065 · ポケット版の古典：追い込みで続く短い章](https://postext.dev/ja/cookbook/short-chapters-run-on.md): ポケット版の『Dom Casmurro』。短い章を段の中に描いた見出しの下で追い込みで続け、どの改ページでも1行や見出しが取り残されません。 · 難易度 2 (中級) · 小説・戯曲・文芸
