# 引用した位置へフロートする図

> 番号付きの図が7点ある2段組みの章。6点は最初の:refから、配置の指定が許す最初の場所へフロートし、1点は::resourceを置いた位置に入ります。

- HTML版: https://postext.dev/ja/cookbook/figures-float-where-cited
- レシピ No. 009 · 図と画像 · 難易度 3 (上級) · 出力: Canvas
- ジャンル: 教科書
- 必要なもの postext ≥ 1.4.1 · テスト環境 1.8.3 ／テスト日 2026-09-28
- ページ: [27](https://postext.dev/cookbook/figures-float-where-cited/es/p01.webp?v=c28c3021), [28](https://postext.dev/cookbook/figures-float-where-cited/es/p02.webp?v=c28c3021), [29](https://postext.dev/cookbook/figures-float-where-cited/es/p03.webp?v=c28c3021), [30](https://postext.dev/cookbook/figures-float-where-cited/es/p04.webp?v=c28c3021)
- Sandboxで開く: https://postext.dev/ja/sandbox#recipe=figures-float-where-cited&lang=es (.postext: https://postext.dev/cookbook/figures-float-where-cited/es/figures-float-where-cited.postext)
- 最終更新: 2026-09-28
- 他の言語: [en](https://postext.dev/en/cookbook/figures-float-where-cited.md), [es](https://postext.dev/es/cookbook/figures-float-where-cited.md), [ca](https://postext.dev/ca/cookbook/figures-float-where-cited.md), [zh](https://postext.dev/zh/cookbook/figures-float-where-cited.md), [ar](https://postext.dev/ar/cookbook/figures-float-where-cited.md)

## かんたんな説明

山の地形を扱う教科書の1章を、番号付きの図7点とともに2段で組みます。本文が初めて図にふれたあとの最初の空いた場所へ、各図が移っていくしくみを示します。

## できあがり

200 × 250 mmのページに組む地形学の教科書『Mountain Landforms』の第2章です。章扉では、章番号を入れた青いリボンが天から垂れ下がり、その横に表題の氷青色の板が置かれます。続いてFaustinaの両端そろえの2段が流れます。拡散モデルで生成した絵にコードでラベルを付けた7点の図には、初出順に番号が振られます。6点は、最初に引用した段落から数えて、配置の指定が許す最初の空いた場所へフロートします。`auto`の図2.1は章扉の下部に、図2.3は28ページの右段の先頭に、図2.2は同じページの下部に収まります。図2.4と図2.5は29ページの2つの段の先頭に入ります。図2.6は本文が埋め込んだ位置に組まれます。図2.7は30ページで引用される`top`のフロートなので31ページを待つはずですが、フロートは章の外に出られないため、30ページの下部に入ります。

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

- 図に番号を振って引用し、ページの上、下、段抜き、その場のどこに置くかを決めるには？
- 図の位置を制御するには（ページの上端、2段をまたぐ、まさにこの位置、余白）？
- 「図」や「表」のラベルを文書の言語で表示するには？
- 画像や表を、Markdownの![]()ではなくコードから（リソースとして）追加するには？

## 手短な答え

```js
// script.js, 行 262–298
// In the Markdown, :ref{id="valleys" case="lower"} prints 'fig. 2.1' and places Figure 2.1.
// Captions, credits and alt texts come from content.figures.<lang>.md.
const figure = (id, height, placement) => {
  if (!TEXTS[id]) throw new Error(`content.figures has no caption block for "${id}"`);
  const [caption, note, altText] = TEXTS[id];
  // An SVG fills the width of its slot (a column or the text block, or a fraction of
  // either), so its width and height only give its shape.
  const width = (placement.span === 'page' ? MEASURE : COLUMN) * (placement.width ?? 1);
  return { id, typeId: 'figure', kind: 'svg', caption, note, altText,
    svg: { fileId: `${id}.svg`, width, height }, placement, createdAt: 0, updatedAt: 0 };
};
// In any order: the first mention of each one in the text, a :ref or a ::resource line,
// decides its number.
const resources = [
  // Cited on the opener page: 'auto' may take that page's foot band, where 'top'
  // could only open the next page (gotcha: top-float-next-page).
  figure('valleys', 56, { position: 'auto', span: 'page' }),
  // Across both columns, but only in a foot band: the page it is cited on, if both
  // columns still have room there, else the foot of the next page.
  figure('profile', 60, { position: 'bottom', span: 'page' }),
  // A column figure that takes only a column head: the next one still empty after its
  // citation, here the right column of the same page, above the text that follows it.
  figure('cirque', 48, { position: 'top' }),
  // Cited in the same sentence, the two take the next two column heads, side by side.
  figure('abrasion', 48, { position: 'top' }),
  figure('plucking', 48, { position: 'top' }),
  // No float: set exactly where ::resource{id="roche"} stands. In postext 1.4.1 an inline
  // figure gets a grid line above it but only the grid snap below, so the Markdown follows
  // it with :::space{lines=1} (gotcha: here-figure-no-space-after).
  figure('roche', 42, { position: 'here' }),
  // A band of its own, half the text width and centred. It is cited on the chapter's last
  // page, where a 'top' float would wait for the next page; a float cannot leave its
  // chapter, so this one goes to the foot of the last page. A float is queued where its
  // citing paragraph starts, so that paragraph starts on the last page
  // (gotcha: float-queues-at-paragraph).
  figure('moraines', 50, { position: 'top', span: 'page', width: 0.5, align: 'center' }),
];
```

## 材料

**学べること**

- [図を配置する参照](https://postext.dev/ja/docs/document-format.md#インライン参照基本の形): :refはリソースを参照し（「図3.2参照」）、最初の参照がその位置を決めます。図は参照の後の最初の空き位置にフロートします。
- [図の配置](https://postext.dev/ja/docs/document-format.md#配置): 上、下、自動、その場のいずれかを、段の中かページ幅で、幅の割合も指定して、リソースごとまたは種類ごとに選びます。同じ番号系列のフロートが互いを追い越すことはありません。
- [番号付きキャプション](https://postext.dev/ja/docs/document-format.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/configuration.md#キャプションスタイル)
- [リソースとしての図と表](https://postext.dev/ja/docs/document-format.md#リソース)
- [独自のリソースの種類](https://postext.dev/ja/docs/configuration.md#リソースの種類)
- [デザインした章扉](https://postext.dev/ja/docs/configuration.md#幅と詳細デザイン)
- [段抜きの章見出し帯](https://postext.dev/ja/docs/configuration.md#幅と詳細デザイン)
- [見出しの属性](https://postext.dev/ja/docs/document-format.md#見出しの属性)
- [番号付き見出し](https://postext.dev/ja/docs/configuration.md#レベルごとの上書き)
- [ハイフネーションと文書の言語](https://postext.dev/ja/docs/justification.md#対応するロケール)
- [柱とノンブル](https://postext.dev/ja/docs/configuration.md#柱とノンブル)
- [ページの役割ごとの柱](https://postext.dev/ja/docs/configuration.md#テキスト要素)
- [セマンティックカラーパレット](https://postext.dev/ja/docs/configuration.md#カラーパレット)
- [明示的な縦のアキ](https://postext.dev/ja/docs/document-format.md#space)
- [段末そろえ](https://postext.dev/ja/docs/configuration.md#段末そろえ)
- citations
- [段落スタイル](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#柱とノンブル), [`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#リソースの種類), [`unorderedLists`](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#リソースの種類), [`parseMarkdown`](https://postext.dev/ja/docs/configuration.md#解析), [`registerResourceImage`](https://postext.dev/ja/docs/architecture.md#apiの概要), [`renderPageToCanvas`](https://postext.dev/ja/docs/configuration.md#ページをビットマップに描画する)

**書体**

- Faustina (OFL-1.1), Montserrat (OFL-1.1), IBM Plex Sans Condensed (OFL-1.1)

## 作り方

### 1 · ページと図にひとつのパレット

```js
// script.js, 行 19–37
const palette = {
  ink: '#1b2227', // text: a cold near-black
  glacier: '#34729a', // the accent: kicker, ribbon, caption labels, references, folios, water
  ice: '#e3f1f8', // the opener slab
  rock: '#5b5a57', // bedrock in the drawings
  moss: '#7d8f4e', // valley floors and pines
  rule: '#c6d3db', // the hairline under the running heads
  muted: '#5d6a72', // running heads, credit notes, the colophon
  paper: '#ffffff',
};
// A linked colour carries its hex too: postext 1.4.1 design slots and referenceColor read
// the hex, not the palette (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to 'main-color' (#295aa3): pointing it at the accent keeps
  // that second blue off the page.
  { id: 'main-color', name: 'glacier (defaults)', value: { hex: palette.glacier, model: 'hex' } },
];
```

設定のどの色もこのパレットのいずれかの項目にリンクしています。絵も同じスレート、氷河の青、苔の色で生成したので、図はページの色になじみます。絵は自分の色を保ちます。`glacier`の値を変えると、リボン、板、ラベルの色は変わりますが、図の中の氷の色は変わりません。アクセントの色は小さな文字にも使える暗さです。キャプションのラベルと引用では白に対して5.2:1、キッカーでは氷青色の板に対して4.5:1です。各図は、絵をJPEGのデータURLとして埋め込み、その上に細い白の縁取り付きの文字でラベルを重ねたSVGです。こうするとラベルはくっきりと保たれ、版の言語に従います。画像として読み込んだSVGはページのWebフォントを使えないので、ラベルのIBM Plex Sans Condensedは各SVGに埋め込んでいます。

### 2 · 図の名前を読み手の言語で

```js
// script.js, 行 48–57
const captions = () => ({
  // config.locale sets hyphenation, not captions (gotcha: resource-types-locale):
  // 'Figura 2.3' and 'Fig. 2.3' come from the localised types, numbered {h1}.{n} per chapter.
  resourceTypes: defaultResourceTypes(LANG),
  captionStyle: { // the text colour follows bodyText; the note is 0.85 × the caption size
    fontFamily: LABEL, fontSize: pt(8.3), gap: mm(2.2),
    labelColor: col('glacier'), descriptionItalic: true, // the label is bold by default
    note: { color: col('muted'), gap: mm(0.6) }, // the credit line
  },
});
```

`defaultResourceTypes(LANG)`はサンプルの言語で種類に名前を付けます。キャプションは、英語版ではFigure 2.3、スペイン語版ではFigura 2.3となります。`locale: 'es'`だけでは本文はスペイン語でハイフネーションされても、キャプションはすべて英語のままです。各引用は、その文に必要な形をとります。`case="lower"`で（fig. 2.1）、さらに`style="full"`を加えて（figure 2.2）、複数形のあとでは`style="number"`（「figures 2.4 and 2.5」）、そして`text="…"`で、番号を印字しない「the chapter’s first figure」のような語句になります。この語句は、すでに配置された図を指し戻すためのものです。初出として使うと、やはり図に番号が振られ、配置されます。

### 3 · 章扉のリボンと氷の板

```js
// script.js, 行 61–113
const at = (to, edge, x, y, width, height) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) },
  ...(width && { size: { width: mm(width), height: height ? mm(height) : 'auto' } }) });
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), color: col(color), placement,
  align: 'left', ...extra });
const caps = (size) => ({ fontWeight: 600, textTransform: 'uppercase',
  letterSpacing: pt(size * 0.18) }); // capitals tracked 0.18 em
// Opener texts break onto more lines instead of ending in '…' (gotcha: overflow-ellipsis-default).
const wrap = { overflow: 'wrap' };
const [SLAB, RIBBON, RIBBON_END] = [64, 30, 70]; // mm: slab height; ribbon width and length
const [TEXT_X, KICKER_Y] = [RIBBON + 8, 10]; // mm: the opener texts start 8 mm right of the ribbon
const [TITLE_W, LEAD_W] = [118, 112]; // mm: the title's measure, and a shorter standfirst
const opener = {
  enabled: true,
  // At least 5 mm under the slab; the reserve then rounds up to whole 13.4 pt grid lines,
  // so here 69 mm becomes 15 lines (70.9 mm) and the text starts about 7 mm under the slab.
  minHeight: mm(SLAB + 5),
  slot: { elements: [
    { kind: 'box', id: 'slab', style: { backgroundColor: col('ice') }, // runs off the fore-edge
      placement: at('container', 'top-left', 0, 0, MEASURE + OUTER, SLAB) },
    { kind: 'box', id: 'ribbon', style: { backgroundColor: col('glacier') }, // hangs from the head
      placement: at('page', 'top-left', INNER, 0, RIBBON, RIBBON_END) },
    text('numeral', '{chapterNumber}', DISPLAY, 80, 'paper', // an 80 pt line box is 28 mm tall:
      at('page', 'top-left', INNER, RIBBON_END - 31, RIBBON), // it ends 3 mm above the foot
      { fontWeight: 800, lineHeight: 1, align: 'center' }),
    text('kicker', t({ en: 'Chapter {chapterNumber} · {attr.topic}',
      es: 'Capítulo {chapterNumber} · {attr.topic}' }), LABEL, 8.5, 'glacier',
    at('container', 'top-left', TEXT_X, KICKER_Y), { ...caps(8.5), ...wrap }),
    text('title', '{titleText}', DISPLAY, 27, 'ink', at('#kicker', 'below', 0, 2.6, TITLE_W),
      { fontWeight: 800, lineHeight: 1.06, ...wrap }),
    text('lead', '{attr.lead}', TEXT, 10.6, 'ink', at('#title', 'below', 0, 4.2, LEAD_W),
      { italic: true, lineHeight: 1.38, hyphenate: true, ...wrap }),
  ] },
};
const HAIRLINE = TOP - 5; // mm from the top edge: the rule under the running heads
const HEAD_Y = HAIRLINE - 4.4; // the running heads' line box, 4.4 mm above the hairline
const head = (id, content, parity, edge, x, extra) => text(id, content, LABEL, 7.6, 'muted',
  at('page', edge, x, HEAD_Y), { ...caps(7.6), parity, pages: 'body', ...extra });
const folio = (id, parity, edge, x, extra) => text(id, '{pageNumber}', DISPLAY, 8.5, 'glacier',
  at('page', edge, x, HEAD_Y), { fontWeight: 800, parity, pages: 'body', ...extra });
const header = { elements: [ // outer corners, over a hairline; never on the opener
  folio('verso-folio', 'even', 'top-left', OUTER),
  head('verso-title', '{title}', 'even', 'top-left', OUTER + 8),
  head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 8), { align: 'right' }),
  folio('recto-folio', 'odd', 'top-right', -OUTER, { align: 'right' }),
  { kind: 'rule', id: 'hairline', pages: 'body', direction: 'horizontal', color: col('rule'),
    thickness: pt(0.5), placement: { ...at('container', 'top-left', 0, HAIRLINE),
      size: { width: 'fill', height: 'auto' } } },
] };
const footer = { elements: [ // the drop folio: on the opener only, centred 9 mm under the text
  text('drop-folio', '{pageNumber}', DISPLAY, 8.5, 'glacier', at('container', 'top', 0, 9),
    { fontWeight: 800, align: 'center', pages: 'opener' })] };
```

章扉は2つのボックスと4つの文字でできています。天から垂れ下がり下端に章番号を入れたリボン、小口の外へはみ出す氷の板、見出しの`topic`属性から取るキッカー、その下に連ねた表題とリード文です。`minHeight`は板と、その下の少なくとも5 mmを確保し、グリッドの行単位に切り上げます（ここでは約7 mm）。こうして図2.1も同じページの下部の帯に収まります。柱は小口側の隅に、`rule`の色の細罫の上に置かれ、`pages: 'body'`で章扉には付けません。章扉には代わりにページの下にノンブルを入れます。

### 4 · ビルドの前に未知のidをはじく

```js
// script.js, 行 302–326
// An unknown :ref prints '?' and a figure nobody names is never placed, and postext 1.4.1
// warns about neither (gotcha: unknown-ref-silent). The engine's own parser lists the
// mentions exactly as numbering and placement read them; an embed needs double quotes
// (gotcha: resource-double-quotes).
function checkFigures() {
  const [named, embedded] = [[], new Set()];
  for (const block of parseMarkdown(markdown)) {
    if (block.type === 'resourceBlock' && block.resourceId) {
      named.push(block.resourceId);
      embedded.add(block.resourceId);
    }
    for (const span of block.spans) if (span.ref?.resourceId) named.push(span.ref.resourceId);
  }
  const ids = resources.map((r) => r.id);
  const types = new Set(captions().resourceTypes.map((type) => type.id));
  const problems = [
    ...[...new Set(named)].filter((id) => !ids.includes(id)).map((id) => `unknown id "${id}"`),
    ...ids.filter((id, i) => ids.indexOf(id) !== i).map((id) => `"${id}" is defined twice`),
    ...ids.filter((id) => !named.includes(id)).map((id) => `"${id}" is never cited`),
    ...resources.filter((r) => r.placement.position === 'here' && !embedded.has(r.id))
      .map((r) => `"${r.id}" is placed 'here' but no ::resource line embeds it`),
    ...resources.filter((r) => !types.has(r.typeId)).map((r) => `"${r.id}": no type ${r.typeId}`),
  ];
  if (problems.length) throw new Error(`Figures: ${problems.join('; ')}`);
}
```

どのリソースにもないidを指す`:ref`は「?」と印字され、だれも言及しない図は配置されません。Sandboxは前者について警告しますが、postext 1.4.1はCodePenのサンプルにどちらの警告も出しません。このチェックはエンジン自身のパーサーである`parseMarkdown`でMarkdownを読むので、番号付けと配置が読むすべての`:ref`と`::resource`を見つけられます。未知のidや重複したid、引用されない図、`::resource`の行がない`here`の図、存在しない種類を、最初のページを組む前に1つのエラーにまとめます。

### 5 · 本の現在位置から数え始める

```js
// script.js, 行 451–463
const face = await labelFace();
for (const { id, svg: { fileId, width, height } } of resources) { // each under its svg.fileId
  const art = await dataUrl(PAINTINGS[id]);
  await loadSvg(fileId, svg(width, height, face, `<image href="${art}" width="${n2(width)}" `
    + `height="${n2(height)}" preserveAspectRatio="none"/>${DRAWINGS[id](width, height)}`));
}
// One chapter came before: figures number 2.1, 2.2… and the folios start at 27.
const continuation = { pageNumbering: { startAt: 27 }, // odd, to match the recto of page 1
  headings: { h1: 1, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } }; // the next # is chapter 2
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources, continuation }, config()), words);
showPages(doc, { title: t({ en: 'Figures that float to where you cite them',
  es: 'Figuras que flotan hasta donde las citas' }) });
```

これは長い本の第2章なので、continuationには前に1章あることを記録します。`{h1}.{n}`で番号を振る図は2.1から、ノンブルは27から始まります。[28ページ](https://postext.dev/cookbook/figures-float-where-cited/es/p02.webp?v=c28c3021)では図2.3が図2.2の上に立ちます。図2.2の番号が小さいのは、本文がそちらを先に引用しているからです。Markdownの`![]()`は取り除かれるので、図はすべてリソースで、そのSVGはビルドの前に`svg.fileId`の名前で登録します。各図の配置は引用される位置から決めます。図2.1は板が章扉の上部を埋めるので`auto`、縦断面図は引用したページの下部にまだ入れるので`bottom`、段の図は次に空いている段の先頭を占めるので`top`です。図2.7も`top`ですが、フロートは章の中にとどまるので、30ページの下部に収まります。

## レシピの全体

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

- レシピのフォルダー: https://github.com/drnachio/postext/tree/main/cookbook/figures-float-where-cited

### script.js

```js
// ═══ Postext Cookbook · Nº 009 · Figures that float to where you cite them ═════════
// https://postext.dev/en/cookbook/figures-float-where-cited
// Code: MIT · Text: original (CC BY 4.0) · Figures: diffusion models, labels in code
// Fonts: Faustina, Montserrat, IBM Plex Sans Condensed (SIL OFL 1.1) · Needs postext ≥ 1.4.1
//
// Chapter 2 of a geomorphology textbook. Six of its seven figures float, each to the first
// free slot its placement allows, counting from the paragraph that first cites it. Figure 2.6
// is set where ::resource embeds it. The figures are numbered in order of first mention.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  defaultResourceTypes, parseMarkdown,
} from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document ('es' | 'en')
const RECIPE = 'figures-float-where-cited';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: eight named colours; the paintings were made to match them
const palette = {
  ink: '#1b2227', // text: a cold near-black
  glacier: '#34729a', // the accent: kicker, ribbon, caption labels, references, folios, water
  ice: '#e3f1f8', // the opener slab
  rock: '#5b5a57', // bedrock in the drawings
  moss: '#7d8f4e', // valley floors and pines
  rule: '#c6d3db', // the hairline under the running heads
  muted: '#5d6a72', // running heads, credit notes, the colophon
  paper: '#ffffff',
};
// A linked colour carries its hex too: postext 1.4.1 design slots and referenceColor read
// the hex, not the palette (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to 'main-color' (#295aa3): pointing it at the accent keeps
  // that second blue off the page.
  { id: 'main-color', name: 'glacier (defaults)', value: { hex: palette.glacier, model: 'hex' } },
];
// #endregion
const TEXT = 'Faustina'; // one family each for text, display and labels
const DISPLAY = 'Montserrat';
const LABEL = 'IBM Plex Sans Condensed';
const LEAD = 13.4; // body leading in pt: the grid every float band snaps to
const [PAGE_W, PAGE_H, TOP, BOTTOM, INNER, OUTER, GUTTER] = [200, 250, 22, 20, 18, 14, 6]; // mm
const MEASURE = PAGE_W - INNER - OUTER; // 168 mm: the text block, and a page-wide figure
const COLUMN = (MEASURE - GUTTER) / 2; // 81 mm: a column, and a column figure

// #region captions: the type name in the document's language; bold label, italic description
const captions = () => ({
  // config.locale sets hyphenation, not captions (gotcha: resource-types-locale):
  // 'Figura 2.3' and 'Fig. 2.3' come from the localised types, numbered {h1}.{n} per chapter.
  resourceTypes: defaultResourceTypes(LANG),
  captionStyle: { // the text colour follows bodyText; the note is 0.85 × the caption size
    fontFamily: LABEL, fontSize: pt(8.3), gap: mm(2.2),
    labelColor: col('glacier'), descriptionItalic: true, // the label is bold by default
    note: { color: col('muted'), gap: mm(0.6) }, // the credit line
  },
});
// #endregion

// #region furniture: an ice slab off the fore-edge, a ribbon from the head, running heads
const at = (to, edge, x, y, width, height) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) },
  ...(width && { size: { width: mm(width), height: height ? mm(height) : 'auto' } }) });
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), color: col(color), placement,
  align: 'left', ...extra });
const caps = (size) => ({ fontWeight: 600, textTransform: 'uppercase',
  letterSpacing: pt(size * 0.18) }); // capitals tracked 0.18 em
// Opener texts break onto more lines instead of ending in '…' (gotcha: overflow-ellipsis-default).
const wrap = { overflow: 'wrap' };
const [SLAB, RIBBON, RIBBON_END] = [64, 30, 70]; // mm: slab height; ribbon width and length
const [TEXT_X, KICKER_Y] = [RIBBON + 8, 10]; // mm: the opener texts start 8 mm right of the ribbon
const [TITLE_W, LEAD_W] = [118, 112]; // mm: the title's measure, and a shorter standfirst
const opener = {
  enabled: true,
  // At least 5 mm under the slab; the reserve then rounds up to whole 13.4 pt grid lines,
  // so here 69 mm becomes 15 lines (70.9 mm) and the text starts about 7 mm under the slab.
  minHeight: mm(SLAB + 5),
  slot: { elements: [
    { kind: 'box', id: 'slab', style: { backgroundColor: col('ice') }, // runs off the fore-edge
      placement: at('container', 'top-left', 0, 0, MEASURE + OUTER, SLAB) },
    { kind: 'box', id: 'ribbon', style: { backgroundColor: col('glacier') }, // hangs from the head
      placement: at('page', 'top-left', INNER, 0, RIBBON, RIBBON_END) },
    text('numeral', '{chapterNumber}', DISPLAY, 80, 'paper', // an 80 pt line box is 28 mm tall:
      at('page', 'top-left', INNER, RIBBON_END - 31, RIBBON), // it ends 3 mm above the foot
      { fontWeight: 800, lineHeight: 1, align: 'center' }),
    text('kicker', t({ en: 'Chapter {chapterNumber} · {attr.topic}',
      es: 'Capítulo {chapterNumber} · {attr.topic}' }), LABEL, 8.5, 'glacier',
    at('container', 'top-left', TEXT_X, KICKER_Y), { ...caps(8.5), ...wrap }),
    text('title', '{titleText}', DISPLAY, 27, 'ink', at('#kicker', 'below', 0, 2.6, TITLE_W),
      { fontWeight: 800, lineHeight: 1.06, ...wrap }),
    text('lead', '{attr.lead}', TEXT, 10.6, 'ink', at('#title', 'below', 0, 4.2, LEAD_W),
      { italic: true, lineHeight: 1.38, hyphenate: true, ...wrap }),
  ] },
};
const HAIRLINE = TOP - 5; // mm from the top edge: the rule under the running heads
const HEAD_Y = HAIRLINE - 4.4; // the running heads' line box, 4.4 mm above the hairline
const head = (id, content, parity, edge, x, extra) => text(id, content, LABEL, 7.6, 'muted',
  at('page', edge, x, HEAD_Y), { ...caps(7.6), parity, pages: 'body', ...extra });
const folio = (id, parity, edge, x, extra) => text(id, '{pageNumber}', DISPLAY, 8.5, 'glacier',
  at('page', edge, x, HEAD_Y), { fontWeight: 800, parity, pages: 'body', ...extra });
const header = { elements: [ // outer corners, over a hairline; never on the opener
  folio('verso-folio', 'even', 'top-left', OUTER),
  head('verso-title', '{title}', 'even', 'top-left', OUTER + 8),
  head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 8), { align: 'right' }),
  folio('recto-folio', 'odd', 'top-right', -OUTER, { align: 'right' }),
  { kind: 'rule', id: 'hairline', pages: 'body', direction: 'horizontal', color: col('rule'),
    thickness: pt(0.5), placement: { ...at('container', 'top-left', 0, HAIRLINE),
      size: { width: 'fill', height: 'auto' } } },
] };
const footer = { elements: [ // the drop folio: on the opener only, centred 9 mm under the text
  text('drop-folio', '{pageNumber}', DISPLAY, 8.5, 'glacier', at('container', 'top', 0, 9),
    { fontWeight: 800, align: 'center', pages: 'opener' })] };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  ...captions(),
  colorPalette,
  page: { width: mm(PAGE_W), height: mm(PAGE_H), dpi: 150, // a compact textbook trim
    margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER),
      mirror: true } },
  layout: { layoutType: 'double', gutterWidth: mm(GUTTER) },
  bodyText: { // justified serif; first lines indented 4 mm, except after a heading
    fontFamily: TEXT, fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'),
    referenceColor: col('glacier'), // citations in the accent, like the caption labels they name
    firstLineIndent: mm(4), indentAfterHeading: false },
  headings: {
    fontFamily: DISPLAY, fontWeight: 800, color: col('ink'),
    // Columns end flush by adding grid lines above the H2s. Beside a float band a column can
    // come up several lines short; one line per heading (the default is 4) keeps a section
    // head from floating in a gap, and the balancer's other levers take what is left.
    balancing: { maxLinesPerHeading: 1 },
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      { level: 1, fontSize: pt(27), span: 'page', breakBefore: { enabled: true, parity: 'odd' },
        marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener },
      { level: 2, fontSize: pt(11.5), lineHeight: pt(LEAD), numberingTemplate: '{1}.{2}',
        marginTop: pt(LEAD), marginBottom: pt(0) }, // one grid line above, none below
    ],
  },
  unorderedLists: { color: col('glacier'), marginTop: pt(0), marginBottom: pt(0) },
  paragraphStyles: [{ id: 'colophon', fontFamily: LABEL, fontSize: pt(7.2), lineHeight: pt(10),
    color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }],
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Relieves de montaña"
subtitle: "Introducción a la geomorfología"
---

# Cómo esculpen los glaciares el paisaje {topic="Geomorfología glaciar" lead="El hielo baja por el valle unas decenas de metros al año, demasiado despacio para verlo, pero en unas pocas glaciaciones convierte la estrecha V de un río en una ancha U. La roca guarda cada etapa, del circo a las morrenas."}

Quien sube a un valle de alta montaña después de haber recorrido otro excavado solo por un río nota enseguida la diferencia. El valle fluvial es estrecho y tiene forma de V: el río ahonda su cauce y las laderas se desmoronan tras él. El valle por el que pasó un glaciar es ancho, de fondo plano y paredes casi verticales, con la forma de una U (:ref{id="valleys" case="lower"}). El hielo llena el valle de pared a pared y lo lima a la vez por el fondo y por los lados.

Hace unos veinte mil años, en el momento de máxima extensión de la última glaciación, el hielo cubría buena parte del norte de Europa y bajaba por los valles del Pirineo hasta cotas inferiores a los mil metros. También había glaciares en los Picos de Europa, en Gredos y en Sierra Nevada. Casi todos han desaparecido, pero el relieve conserva sus huellas con tanta nitidez que se puede reconstruir el tamaño de un glaciar que se fundió hace milenios. Este capítulo explica cuáles son esas huellas y cómo se leen.

## El hielo que fluye

Un glaciar nace donde cae más nieve de la que se funde. Año tras año, cada capa queda enterrada bajo la siguiente y su peso expulsa el aire de entre los copos. La nieve recién caída pesa unos cien kilogramos por metro cúbico; al compactarse se convierte en neviza, un material granuloso, y después en hielo compacto y azulado, que supera los ochocientos. En los Alpes la transformación dura unas décadas; en la Antártida, donde nieva tan poco que cada año suma apenas unos centímetros, puede llevar siglos.

Todo glaciar tiene dos mitades (:ref{id="profile" style="full" case="lower"}). En la parte alta, la zona de acumulación, cada invierno deja más nieve de la que el verano consigue fundir. En la parte baja, la zona de ablación, ocurre lo contrario: el hielo se pierde y el glaciar solo se mantiene porque le llega hielo de arriba. La frontera entre ambas, la línea de equilibrio, se reconoce a finales del verano como el límite de la nieve del año sobre el hielo desnudo. Si el clima se enfría, la línea baja y el frente avanza; si se calienta, la línea sube y el frente retrocede.

Cuando el hielo del circo de cabecera (:ref{id="cirque" case="lower"}) alcanza unas decenas de metros de espesor, empieza a fluir bajo su propio peso. El hielo se deforma despacio y sin romperse, como lo haría una masa de brea, mientras los treinta metros de arriba, con demasiado poco peso encima para fluir, se rompen en grietas. Donde el lecho está húmedo, el glaciar además resbala sobre una película de agua de fusión. Así avanzan los glaciares de valle, entre unas decenas y unos cientos de metros al año, más deprisa en el centro que junto a las paredes, donde el rozamiento los frena. Louis Agassiz lo comprobó en la década de 1840 con una hilera de estacas clavada de un borde a otro del glaciar del Unteraar, en Suiza: con los años, la hilera se curvó valle abajo por el centro. Incluso un glaciar en retirada sigue fluyendo hacia abajo. Si su frente retrocede es porque cada verano se funde allí más hielo del que llega.

## Donde nacen los glaciares

Un circo es una hondonada con forma de sillón excavada en la cabecera del valle. El hielo se acumula en el fondo, gira pendiente abajo como en una cuchara y rebaja el suelo por debajo del borde; cuando el glaciar se funde, la cubeta se llena de agua y nace un lago. Para ahondarla, el hielo trabaja con dos herramientas complementarias, las de las figuras :ref{id="abrasion" style="number"} y :ref{id="plucking" style="number"}. La pared del fondo, mientras tanto, retrocede: dos circos que crecen espalda con espalda afilan entre ellos una arista, y tres o más que atacan una misma cumbre la dejan convertida en un pico piramidal, como el Cervino. En el Pirineo, la mayoría de los circos miran al norte o al este, donde la nieve dura más, y muchos guardan uno de esos lagos, que en Aragón llaman ibones.

## Las herramientas del hielo

El hielo es más blando que casi cualquier roca y por sí solo apenas la rayaría; erosiona su lecho con dos herramientas. La primera herramienta es la abrasión. Los cantos incrustados en la base del glaciar rayan el lecho como una lija y dejan estrías paralelas que señalan, milenios después, la dirección en que se movía el hielo; el polvo que producen, la harina de roca, da a los lagos glaciares su color turquesa lechoso. La segunda es el arranque: el agua de fusión se cuela en las grietas del lecho, vuelve a helarse y suelda los bloques al hielo, que se los lleva al avanzar.

Las dos herramientas actúan a la vez sobre cualquier resalte del lecho, y el resultado es una de las formas más características del paisaje glaciar, la roca aborregada:

::resource{id="roche"}

:::space{lines=1}

Su cara de aguas arriba, pulida por abrasión, es suave y tendida; la de aguas abajo, donde el glaciar arrancó bloques, es abrupta y rugosa. Basta mirar hacia dónde apunta la cara áspera para saber hacia dónde iba el hielo. El nombre francés, *roche moutonnée*, lo acuñó el naturalista ginebrino Horace-Bénédict de Saussure a finales del siglo XVIII.

## Valles en artesa

El trabajo de esas herramientas, sumado durante decenas de miles de años, transforma el valle entero. El glaciar endereza el valle sinuoso del río y trunca los espolones que separaban sus curvas. Ensancha y ahonda el fondo hasta darle el perfil en U de :ref{id="valleys" text="la primera figura del capítulo"}, escalonado en cubetas y umbrales, y el valle de Ordesa, en el Pirineo aragonés, es una artesa de manual. Como un glaciar grueso excava más que uno delgado, el valle principal se hunde más que los de sus afluentes: cuando el hielo desaparece, los valles laterales quedan colgados a cientos de metros sobre el principal y sus arroyos saltan en cascada, como en el valle de Yosemite, en California. Donde el mar ha invadido una artesa se forma un fiordo; el de Sogn, en Noruega, penetra más de doscientos kilómetros tierra adentro y supera los mil trescientos metros de profundidad.

## Lo que deja el glaciar

Todo lo que el glaciar arranca acaba depositado en algún sitio. Los derrubios que caen de las laderas viajan sobre los bordes del hielo y forman morrenas laterales; donde dos glaciares se unen, sus morrenas laterales se funden en una morrena central que recorre el hielo como una franja oscura. En el frente, el glaciar suelta su carga como una cinta transportadora y levanta un arco de derrubios, la morrena frontal, que señala hasta dónde llegó la lengua de hielo en su máximo avance.

Las morrenas son mezclas caóticas de arcilla, arena, cantos y bloques de todos los tamaños, sin la clasificación que el agua impone a sus sedimentos. Algunos bloques, los erráticos, viajaron decenas de kilómetros y descansan hoy sobre rocas de naturaleza muy distinta.

Muchas morrenas frontales retienen lagos (:ref{id="moraines" case="lower"}). El de Sanabria, en Zamora, el mayor lago de origen glaciar de la península, está represado por las morrenas del glaciar que bajaba de la sierra Segundera.

## Cómo leer un paisaje glaciar

Bastan cuatro huellas para reconocer el paso de un glaciar por un valle que hoy no tiene hielo:

- **El perfil.** Una artesa en U, de fondo plano y paredes abruptas, como la de la :ref{id="valleys" style="full" case="lower"}.
- **Los valles colgados.** Afluentes que terminan a media ladera y cuyos arroyos caen en cascada.
- **La roca.** Superficies pulidas y estriadas y rocas aborregadas, con la cara abrupta vuelta valle abajo (:ref{id="roche" case="lower"}).
- **Los depósitos.** Morrenas sin clasificar, bloques erráticos y los lagos que represan.

Ninguna de esas huellas basta por sí sola: un río también pule los cantos, y un desprendimiento deja derrubios caóticos al pie de una ladera. Juntas, y repetidas valle tras valle, prueban que allí hubo un glaciar.


## Glaciares en retirada

Los glaciares que quedan en la península son pequeños y están todos en el Pirineo, en las caras norte de sus cumbres más altas: el Aneto, la Maladeta, el Monte Perdido. Han perdido la mayor parte de su superficie desde mediados del siglo XIX, cuando terminó la Pequeña Edad de Hielo, y varios se han reducido a heleros, masas de hielo que ya no fluyen. En muchos veranos, la línea de equilibrio sube hoy por encima de sus cumbres: el glaciar entero queda en la zona de ablación de la :ref{id="profile" style="full" case="lower"} y el hielo que pierde ya no se repone. Lo que queda se refugia a la sombra de las paredes norte, alimentado tanto por los aludes y la nieve que arrastra el viento como por la que cae. Los glaciólogos siguen ese retroceso con los métodos de Agassiz y con otros nuevos: estacas de ablación que cada verano asoman un poco más, fotografías repetidas desde los mismos puntos y modelos del terreno levantados con láser y con drones, que comparados año tras año dan el volumen de hielo perdido.

Cuando desaparezca el último, el macizo de la Maladeta se parecerá a la sierra de Gredos, que perdió sus glaciares hace más de diez mil años y conserva lagunas en los circos y morrenas que cierran los valles.

:::paragraphs{style="colophon"}
Compuesto en Faustina, Montserrat e IBM Plex Sans Condensed (SIL Open Font License) · Texto: CC BY 4.0 · Figuras: modelos de difusión
:::
`; // content.<lang>.md, inlined by the Cookbook

// Caption, credit note ('-' for none) and alt text of each figure, one block per figure.
const figureTexts = String.raw`valleys
Un río abre un valle en V; un glaciar lo ensancha en U. A trazos, la V que borró el hielo.
Secciones esquemáticas, sin escala.
Dos secciones de valle: a la izquierda, un valle fluvial en V con un río en el fondo; a la derecha, un valle glaciar en U lleno de hielo, con el antiguo perfil en V a trazos.

profile
Perfil de un glaciar: el hielo nacido sobre la línea de equilibrio baja a fundirse bajo ella.
Exageración vertical ×2.
Sección longitudinal de un glaciar desde el circo hasta el frente, con la zona de acumulación nevada, la línea de equilibrio, flechas de flujo y la morrena frontal.

cirque
Circo glaciar en sección. El hielo gira en la cubeta y la ahonda por debajo del umbral.
-
Sección de un circo: pared abrupta, rimaya, hielo que gira en una cubeta y umbral rocoso aguas abajo.

abrasion
Abrasión: los cantos presos en la base del hielo rayan el lecho.
-
Detalle de la base de un glaciar: cantos incrustados en el hielo rayan la roca y dejan estrías y harina de roca.

plucking
Arranque: el agua se hiela en las diaclasas y el hielo se lleva los bloques.
-
Detalle del lado de aguas abajo de un resalte rocoso: agua helada en las diaclasas y un bloque que el hielo arranca.

roche
Roca aborregada. El hielo pulió la cara tendida y arrancó bloques de la abrupta.
El hielo iba de izquierda a derecha.
Perfil de una roca aborregada: una cara suave y tendida a la izquierda y otra escalonada y abrupta a la derecha.

moraines
Dos glaciares se unen: sus morrenas laterales forman la central, y la frontal represa un lago.
Vista en planta, sin escala.
Plano de dos lenguas de hielo que confluyen, con morrenas laterales y central; bajo el frente, el arco de la morrena frontal retiene un lago que solo cruza su arroyo de desagüe.
`;
const TEXTS = Object.fromEntries(figureTexts.trim().split(/\n\s*\n/)
  .map((block) => block.split('\n').map((line) => line.trim()))
  .map(([id, caption, note, alt]) => [id, [caption, note === '-' ? undefined : note, alt]]));

// #region answer: six figures float to the first slot their placement allows; one stays put
// In the Markdown, :ref{id="valleys" case="lower"} prints 'fig. 2.1' and places Figure 2.1.
// Captions, credits and alt texts come from content.figures.<lang>.md.
const figure = (id, height, placement) => {
  if (!TEXTS[id]) throw new Error(`content.figures has no caption block for "${id}"`);
  const [caption, note, altText] = TEXTS[id];
  // An SVG fills the width of its slot (a column or the text block, or a fraction of
  // either), so its width and height only give its shape.
  const width = (placement.span === 'page' ? MEASURE : COLUMN) * (placement.width ?? 1);
  return { id, typeId: 'figure', kind: 'svg', caption, note, altText,
    svg: { fileId: `${id}.svg`, width, height }, placement, createdAt: 0, updatedAt: 0 };
};
// In any order: the first mention of each one in the text, a :ref or a ::resource line,
// decides its number.
const resources = [
  // Cited on the opener page: 'auto' may take that page's foot band, where 'top'
  // could only open the next page (gotcha: top-float-next-page).
  figure('valleys', 56, { position: 'auto', span: 'page' }),
  // Across both columns, but only in a foot band: the page it is cited on, if both
  // columns still have room there, else the foot of the next page.
  figure('profile', 60, { position: 'bottom', span: 'page' }),
  // A column figure that takes only a column head: the next one still empty after its
  // citation, here the right column of the same page, above the text that follows it.
  figure('cirque', 48, { position: 'top' }),
  // Cited in the same sentence, the two take the next two column heads, side by side.
  figure('abrasion', 48, { position: 'top' }),
  figure('plucking', 48, { position: 'top' }),
  // No float: set exactly where ::resource{id="roche"} stands. In postext 1.4.1 an inline
  // figure gets a grid line above it but only the grid snap below, so the Markdown follows
  // it with :::space{lines=1} (gotcha: here-figure-no-space-after).
  figure('roche', 42, { position: 'here' }),
  // A band of its own, half the text width and centred. It is cited on the chapter's last
  // page, where a 'top' float would wait for the next page; a float cannot leave its
  // chapter, so this one goes to the foot of the last page. A float is queued where its
  // citing paragraph starts, so that paragraph starts on the last page
  // (gotcha: float-queues-at-paragraph).
  figure('moraines', 50, { position: 'top', span: 'page', width: 0.5, align: 'center' }),
];
// #endregion

// #region check: every cited id exists and every figure gets placed, before the build
// An unknown :ref prints '?' and a figure nobody names is never placed, and postext 1.4.1
// warns about neither (gotcha: unknown-ref-silent). The engine's own parser lists the
// mentions exactly as numbering and placement read them; an embed needs double quotes
// (gotcha: resource-double-quotes).
function checkFigures() {
  const [named, embedded] = [[], new Set()];
  for (const block of parseMarkdown(markdown)) {
    if (block.type === 'resourceBlock' && block.resourceId) {
      named.push(block.resourceId);
      embedded.add(block.resourceId);
    }
    for (const span of block.spans) if (span.ref?.resourceId) named.push(span.ref.resourceId);
  }
  const ids = resources.map((r) => r.id);
  const types = new Set(captions().resourceTypes.map((type) => type.id));
  const problems = [
    ...[...new Set(named)].filter((id) => !ids.includes(id)).map((id) => `unknown id "${id}"`),
    ...ids.filter((id, i) => ids.indexOf(id) !== i).map((id) => `"${id}" is defined twice`),
    ...ids.filter((id) => !named.includes(id)).map((id) => `"${id}" is never cited`),
    ...resources.filter((r) => r.placement.position === 'here' && !embedded.has(r.id))
      .map((r) => `"${r.id}" is placed 'here' but no ::resource line embeds it`),
    ...resources.filter((r) => !types.has(r.typeId)).map((r) => `"${r.id}": no type ${r.typeId}`),
  ];
  if (problems.length) throw new Error(`Figures: ${problems.join('; ')}`);
}
// #endregion

// #region art: seven paintings (JPEGs in assets/) under vector labels in the book's language
// Each figure is an SVG: the painting, embedded as a data URL at the figure's printed size in
// mm, then its labels as text, so they stay sharp and follow the edition's language.
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 C = { ink: palette.ink, flow: mix(palette.glacier, palette.ink, 0.4), snow: palette.paper };
const n2 = (v) => +v.toFixed(2);
// A hairline, drawn over a wider white one so it reads on rock and ice alike.
const hairline = ([x1, y1], [x2, y2]) => [['#ffffff', 0.55], [C.ink, 0.18]].map(([c, w]) =>
  `<path d="M${n2(x1)} ${n2(y1)}L${n2(x2)} ${n2(y2)}" stroke="${c}" stroke-width="${w}" `
  + 'stroke-linecap="round"/>').join('');
// A label with a thin white halo, and an optional leader to the point it names.
function label(x, y, words, { anchor = 'start', to, bold = false, color = C.ink } = {}) {
  const halo = color === C.snow ? C.ink : '#ffffff';
  const leader = to ? hairline([to[0], to[1]], [to[2] ?? x, to[3] ?? y - 0.9]) : '';
  return `${leader}<text x="${n2(x)}" y="${n2(y)}" text-anchor="${anchor}" fill="${color}"`
    + ` stroke="${halo}" stroke-width="0.5" stroke-linejoin="round" paint-order="stroke"`
    + `${bold ? ' font-weight="600"' : ''}>${words}</text>`;
}
const L = (en, es) => t({ en, es });
// An SVG loaded as an <img> has no access to the page's web fonts (gotcha: svg-no-webfonts),
// so each drawing embeds the two weights its labels use. The latin subsets cover the English
// and Spanish labels.
const LABEL_MM = 2.45; // the label size in the drawings' millimetres: about 7 pt in print
async function labelFace() {
  const id = fontsourceId(LABEL);
  const faces = await Promise.all(['400', '600'].map(async (weight) => {
    const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${weight}-`
      + '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;font-weight:${weight};`
      + `src:url(data:font/woff2;base64,${btoa(bin)}) format('woff2')}`;
  }));
  return `${faces.join('')}text{font-family:L;font-size:${LABEL_MM}px}`;
}
// The viewBox is the figure's printed size in mm; the SVG's own size is set in mm too.
const svg = (w, h, face, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${n2(w)}mm" `
  + `height="${n2(h)}mm" viewBox="0 0 ${n2(w)} ${n2(h)}"><style>${face}</style>${body}</svg>`;

// The labels of each figure, in its millimetres, placed on its painting.
const DRAWINGS = {
  valleys: () => label(14, 5, L('River valley', 'Valle fluvial'), { bold: true })
    + label(100, 5, L('Glacial valley', 'Valle glaciar'), { bold: true })
    + label(47, 50.4, L('river', 'río'), { to: [43.4, 46.6, 46.8, 49.4] })
    + label(126, 21, L('ice', 'hielo'), { anchor: 'middle', bold: true })
    + label(125.5, 53.2, L('earlier V-shaped valley', 'antiguo valle en V'), { anchor: 'middle',
      to: [125.5, 48, 125.5, 51.3] }),
  profile: () => label(48, 8, L('accumulation zone', 'zona de acumulación'),
    { anchor: 'middle', bold: true, color: C.flow })
    + label(100, 20, L('ablation zone', 'zona de ablación'), { anchor: 'middle', bold: true,
      color: C.flow })
    + label(57, 19.6, L('equilibrium line', 'línea de equilibrio'), { to: [52.8, 27, 56.6, 20.2] })
    + label(108, 43.3, L('ice flow', 'flujo del hielo'), { bold: true, color: C.flow })
    + label(166, 41.6, L('terminal moraine', 'morrena frontal'), { anchor: 'end',
      to: [146, 45.5, 150, 42.2] })
    + label(4, 57.4, L('bedrock', 'lecho rocoso')),
  cirque: () => label(2.5, 30, L('back wall', 'pared'))
    + label(26, 6.6, L('bergschrund', 'rimaya'), { to: [20, 12.5, 25.6, 7.2] })
    + label(30, 22.6, L('rotation', 'rotación'), { bold: true, color: C.flow })
    + label(33, 40, L('basin', 'cubeta'))
    + label(66, 17.4, L('rock lip', 'umbral'), { anchor: 'middle', to: [60.5, 22.8, 64.4, 18.3] }),
  abrasion: () => label(55.5, 8.3, L('ice moves', 'el hielo avanza'), { bold: true, color: C.flow })
    + label(24, 16.6, L('stones in the ice', 'cantos presos en el hielo'),
      { to: [38.5, 22.6, 38, 17.4] })
    + label(20, 37, L('striations', 'estrías'), { anchor: 'end', to: [24, 29.5, 18, 35.8] })
    + label(40.5, 37, L('rock flour', 'harina de roca'), { to: [32, 26, 40.5, 35.8] }),
  plucking: () => label(30, 9.3, L('ice moves', 'el hielo avanza'), { bold: true, color: C.flow })
    + label(63.5, 18.5, L('plucked block', 'bloque arrancado'), { to: [60, 21, 63.3, 19.2] })
    + label(4, 43, L('ice in the joints', 'hielo en las diaclasas'), { to: [25.2, 32, 14, 41.3] }),
  roche: () => label(4, 5, L('ice, long gone', 'el hielo, hoy fundido'), { bold: true,
    color: C.flow })
    + label(25, 16.5, L('abrasion: smooth', 'abrasión: pulida'), { anchor: 'end',
      to: [30, 18.8, 25.5, 16.9] })
    + label(63, 11.2, L('plucking: rough', 'arranque: rugosa'), { to: [57.5, 15.5, 63.2, 11.9] }),
  // Placed on a 100.8 × 60 mm drawing; k scales the positions, not the type, to the figure.
  moraines: (w) => {
    const k = w / 100.8;
    const at = (x, y, words, o = {}) => label(x * k, y * k, words,
      { ...o, ...(o.to && { to: o.to.map((v) => v * k) }) });
    return at(4, 25.5, L('lateral moraine', 'morrena lateral'), { to: [37, 16, 21, 24.3] })
      + at(62, 33, L('medial moraine', 'morrena central'), { to: [50.5, 33, 61.5, 32.3] })
      + at(50, 45, L('lake', 'lago'), { anchor: 'middle', bold: true, color: C.snow })
      + at(97.5, 55, L('terminal moraine', 'morrena frontal'), { anchor: 'end',
        to: [60, 49, 78, 54] });
  },
};
// A painting as a data URL: an SVG drawn as an image cannot fetch anything itself.
async function dataUrl(url) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Painting 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 `data:image/jpeg;base64,${btoa(bin)}`;
}
const PAINTINGS = { // each figure's painting, a file in assets/, named by its width in pixels
  valleys: asset('valleys-1536.jpg'), profile: asset('profile-1400.jpg'),
  cirque: asset('cirque-1080.jpg'), abrasion: asset('abrasion-1080.jpg'),
  plucking: asset('plucking-1080.jpg'), roche: asset('roche-1080.jpg'),
  moraines: asset('moraines-1080.jpg') };
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the layout uses, loaded before the build (gotcha: fonts-first)
  Faustina: ['400', '400i', '700'], // text
  Montserrat: ['800'], // display: title, section heads, numeral, folios
  'IBM Plex Sans Condensed': ['400', '400i', '600', '700'], // labels: kicker, heads, captions
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const words = `${markdown}\n${figureTexts}`; // captions too: their letters decide the subsets
await loadFonts(FONTS, words);
checkFigures(); // a wrong id stops here, and the viewer's bar says why
// #region build: register the drawings, then set chapter 2 of a longer book
const face = await labelFace();
for (const { id, svg: { fileId, width, height } } of resources) { // each under its svg.fileId
  const art = await dataUrl(PAINTINGS[id]);
  await loadSvg(fileId, svg(width, height, face, `<image href="${art}" width="${n2(width)}" `
    + `height="${n2(height)}" preserveAspectRatio="none"/>${DRAWINGS[id](width, height)}`));
}
// One chapter came before: figures number 2.1, 2.2… and the folios start at 27.
const continuation = { pageNumbering: { startAt: 27 }, // odd, to match the recto of page 1
  headings: { h1: 1, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } }; // the next # is chapter 2
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources, continuation }, config()), words);
showPages(doc, { title: t({ en: 'Figures that float to where you cite them',
  es: 'Figuras que flotan hasta donde las citas' }) });
// #endregion

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

## アレンジ

### 章番号を付けずに図番号を振る

番号から章を外すと、図は1〜7と印字されます。本全体で1つの通し番号にするには、章を`buildBundle`で組むか、各章に前の章の`continuationAfter()`を渡します。

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

### キャプションを上に、帯に載せて

キャプションは図の上に移り、パレットの氷青色の帯に載ります。クレジットの行は図の下に残ります。こうするとどちらの版も5ページ目にはみ出すので、本文の分量を調整し直してください。

```diff
   captionStyle: { // the text colour follows bodyText; the note is 0.85 × the caption size
+    position: 'above', backgroundEnabled: true, background: col('ice'),
     fontFamily: LABEL, fontSize: pt(8.3), gap: mm(2.2),
```

### 図を余白に置く

段と半段のページなら、本文が入らない小口側の溝を図とキャプションに与えられます。[余白に段を設けた教科書](https://postext.dev/ja/cookbook/textbook-margin-column.md)を参照してください。

### 表も同じようにフロートさせる

表もリソースで、同じ規則で引用され配置されます。長い表はページをまたいで分割されます。[技術データシート](https://postext.dev/ja/cookbook/technical-datasheet.md)を参照してください。

## よくあるつまずき

- **'top'のフロートは参照したページには入らない.** フロートが自分の参照より上に置かれることはありません。そのため、Nページで参照したページ幅の'top'フロートはN+1ページの頭に置かれます。もっと前で参照するか、参照したページの下端に入れる位置'auto'または'bottom'を使ってください。
- **図は参照する段落の始まりで待ち行列に入る.** 図が待ち行列に入るのは、:refがある行ではなく、図を参照する段落が始まった時点です。その段落があるページの下端で始まり、参照が次のページに来ると、'top'の図は参照する文より上、次のページの頭に置かれることがあります。:refは段落の前のほうに置くか、:refで新しい段落を始めてください。
- **インラインの図は上に空きがあり、下にはない.** postext 1.4.1では、::resourceで位置'here'に置いた図は、上にグリッド1行分の空きを取りますが、下には次の行がベースライングリッドにそろうときの残りしか取りません。1行分からほぼゼロまでばらつくため、次の段落がキャプションのすぐ下から始まることがあります。::resourceの行のあとに:::space{lines=1}を置いてください。ほかの:::spaceと同じく、段の頭では捨てられます。
- **不明なidの:refは'?'を印字し、エンジンは警告しない.** どのリソースにもないidへの:refは「?」を印字して何も配置せず、警告するのはSandboxだけです。参照するidがすべて存在するか確認してください。
- **Figure/TableはdefaultResourceTypes(locale)でローカライズする.** 設定のlocaleが決めるのはハイフネーションで、キャプションではありません。resourceTypesがなければ、組み込みの種類は英語でFigureとTableと表示されます。スペイン語ならresourceTypes: defaultResourceTypes('es')を渡し、それ以外の言語ではresourceTypesに名前を自分で書いてください。
- **::resource{id="…"}は二重引用符でしか書けない.** ブロックの埋め込みとして認識されるのは、二重引用符を使った::resource{id="…"}の形だけです。ほかの書き方は、目に見える1行として本文に残ります。
- **警告の多くはSandboxにしかない.** 不明なid、スタイル、ディレクティブ、欠けたフォント、ゆるい行を検査するのはSandboxで、エンジンではありません。CodePenのサンプルで得られるのはdoc.warningsとparseMarkdownWithIssuesだけです。不明なスタイルは何の知らせもなくフォールバックし、不明なディレクティブはテキストとして印字されるので、idを確認してください。
- **SVGの<img>内のテキストはWebフォントを使えない.** SVGは画像として描かれ、画像はページのWebフォントにアクセスできないため、ラベルはシステムの書体にフォールバックします。テキストをアウトライン化するか、SVGに@font-faceのサブセットを埋め込むか、ラベルをキャプションに移してください。
- **SVGの図に<marker>やフィルターを使わない（ラスターにフォールバックする）.** SVGの図がPDFでベクターのまま残るのは、<marker>、フィルター、マスクを使っていない場合だけです。使っているとラスターにフォールバックし、深く入れ子にしたフィルターはChromeで図を真っ白にすることがあります。矢じりはパスで描いてください。
- **ハイフネーションできるのは8つのロケールだけで、コードは完全一致.** ハイフネーションが用意されているのはen-us、es、fr、de、it、pt、ca、nlで、コードは完全一致で照合されます。'es-ES'やほかの言語は、何の知らせもなくアメリカ英語にフォールバックします。
- **headingsオブジェクトを渡すとH1の改ページが消える.** 既定ではH1は奇数ページへ改ページします（always-odd）。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。
- **デザインのテキストのoverflowの既定値は'ellipsis-end'.** 幅に収まらないデザインのテキスト要素は、既定では省略記号で終わります。複数行に折り返したいタイトルにはoverflow: 'wrap'を設定してください。
- **パレットを差し替えても、デザイン要素と参照色は変わらない.** postext 1.4.1はcolorPaletteをテキストのスタイル（本文、見出し、リスト、キャプション、表、囲み）には反映しますが、ヘッダー、フッター、章扉、部扉の要素と、bodyText.referenceColorには反映しません。これらはpaletteIdの横に書いた16進の色のままです。画面用のダーク版や色替えのためにパレットを差し替えるときは、ビルドの前に、リンクしたすべての色をcolorPaletteから書き直してください。
- **レイアウトの前にすべてのフォントを読み込む.** レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。
- **組版の警告: 不明なリソース** (`unknownResourceId`). :refまたは::resourceが、どのリソースにもないidを指定しています。参照は「?」と印字され、何も配置されません。 解決策: idを修正する（二重引用符のみ使えます）か、リソースを追加してください。 ([ドキュメント](https://postext.dev/ja/docs/document-format.md#インライン参照基本の形))
- **組版の警告: 不明なリソースの種類** (`danglingTypeRef`). リソースのtypeIdが、resourceTypesにもう定義されていない種類を指しているため、既定の種類が使われます。 解決策: 種類を定義するか、既存の種類をリソースに指定してください。 ([ドキュメント](https://postext.dev/ja/docs/configuration.md#リソースの種類))

- [30ページ](https://postext.dev/cookbook/figures-float-where-cited/es/p04.webp?v=c28c3021)では、湖についての段落がその最初の文で図2.7を引用しています。図は引用した段落が始まる位置で順番待ちに入るからです。その文が29ページから始まる前の段落の最後にあったら、図は30ページの先頭、それを引用する行より上に置かれていたはずです。

## クレジット

- レシピ: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- 画像: Figure 2.1: a V-shaped river valley and a U-shaped glacial valley: Generated With Diffusion Models, オリジナル
- 画像: Figure 2.2: long profile of a valley glacier: Generated With Diffusion Models, オリジナル
- 画像: Figure 2.3: a glacial cirque in section: Generated With Diffusion Models, オリジナル
- 画像: Figure 2.4: abrasion at the base of the ice: Generated With Diffusion Models, オリジナル
- 画像: Figure 2.5: plucking on a rock step: Generated With Diffusion Models, オリジナル
- 画像: Figure 2.6: a roche moutonnée: Generated With Diffusion Models, オリジナル
- 画像: Figure 2.7: lateral, medial and terminal moraines in plan: Generated With Diffusion Models, オリジナル
- 書体: Faustina (OFL-1.1), Montserrat (OFL-1.1), IBM Plex Sans Condensed (OFL-1.1)
- コード: MIT · サンプルの内容: CC-BY-4.0

## 関連レシピ

- [No. 003 · 裁ち落としの帯を敷いた章扉](https://postext.dev/ja/cookbook/chapter-opener-bleed-band.md): レベル1見出しにadvancedDesignの章扉を付けます。裁ち落としの帯、その下端に立つ章番号、見出しの属性から取るキッカーとリード文。 · 難易度 3 (上級) · 教科書
- [No. 001 · 余白に段を設けた教科書](https://postext.dev/ja/cookbook/textbook-margin-column.md): 小口側の段にフロートだけを入れる1段半組みのページです。span 'side'の図と傍注がそこに積み重なり、captionSideでほかの図のキャプションもそこへ移します。 · 難易度 3 (上級) · 教科書
- [No. 010 · データシート：データから作る表とセル結合](https://postext.dev/ja/cookbook/technical-datasheet.md): TSVで貼り付けた表をparseTSVで読み、mergeCells、setAlignment、setCellBackgroundで整えます。レジスターマップは自動でページをまたいで分割されます。 · 難易度 3 (上級) · マニュアル・ガイド・リファレンス
