# 3か国語の子ども向け教科書の単元

> 小学3年生の社会科の単元9ページをスペイン語、カタルーニャ語、英語で。色地の見開き、文章に合わせて伸びるタブ付きの囲み、本文から地名を付けた描き下ろしの地図。

- HTML版: https://postext.dev/ja/cookbook/children-textbook-pages
- レシピ No. 156 · 完成した出版物 · 難易度 3 (上級) · 出力: Canvas, PDF
- ジャンル: 教科書
- 必要なもの postext ≥ 1.40.0, postext-pdf ≥ 1.40.0 · テスト環境 1.40.0, postext-pdf 1.40.0 ／テスト日 2026-10-17
- ページ: [1](https://postext.dev/cookbook/children-textbook-pages/es/p01.webp?v=94393f4a), [2](https://postext.dev/cookbook/children-textbook-pages/es/p02.webp?v=94393f4a), [3](https://postext.dev/cookbook/children-textbook-pages/es/p03.webp?v=94393f4a), [4](https://postext.dev/cookbook/children-textbook-pages/es/p04.webp?v=94393f4a), [5](https://postext.dev/cookbook/children-textbook-pages/es/p05.webp?v=94393f4a), [6](https://postext.dev/cookbook/children-textbook-pages/es/p06.webp?v=94393f4a), [7](https://postext.dev/cookbook/children-textbook-pages/es/p07.webp?v=94393f4a), [8](https://postext.dev/cookbook/children-textbook-pages/es/p08.webp?v=94393f4a), [9](https://postext.dev/cookbook/children-textbook-pages/es/p09.webp?v=94393f4a)
- PDF: https://postext.dev/cookbook/children-textbook-pages/es/children-textbook-pages.pdf?v=94393f4a
- Sandboxで開く: https://postext.dev/ja/sandbox#recipe=children-textbook-pages&lang=es (.postext: https://postext.dev/cookbook/children-textbook-pages/es/children-textbook-pages.postext)
- 最終更新: 2026-10-17
- 他の言語: [en](https://postext.dev/en/cookbook/children-textbook-pages.md), [es](https://postext.dev/es/cookbook/children-textbook-pages.md), [ca](https://postext.dev/ca/cookbook/children-textbook-pages.md), [de](https://postext.dev/de/cookbook/children-textbook-pages.md), [fr](https://postext.dev/fr/cookbook/children-textbook-pages.md), [it](https://postext.dev/it/cookbook/children-textbook-pages.md), [pt](https://postext.dev/pt/cookbook/children-textbook-pages.md), [zh](https://postext.dev/zh/cookbook/children-textbook-pages.md), [ar](https://postext.dev/ar/cookbook/children-textbook-pages.md)

## かんたんな説明

川を扱う教科書の単元で、全9ページ。各テーマは色地の見開きで、タイトルのタブ、本文、絵、囲みがあります。地図には文字を一つも描かず、地名はすべて本文から付けます。三つの言語は長さが違っても同じテンプレートに収まります。

## できあがり

架空の小学3年生の社会科の本の第4単元「Our river」を、210 × 280 mm のページで9ページ、スペイン語、カタルーニャ語、英語の3版で組みます。描いた谷が単元を開き、二つの部を並べた目次のページがあり、そのあとにテーマが3つ、見開きで続きます。地色は水の青、野の緑、日の砂色とテーマごとに違います。どのテーマにも番号とタイトルのタブがあり、本文は2段、囲みは文章に合わせて伸びるタブ付きです。2つ目のテーマは2ページにまたがる1枚の絵の地図で、川、村、三つの区間の名前を Markdown から載せます。三つの本文は5分の1以上も長さが違いますが、同じテンプレートに収まります。囲みは言葉に必要な高さを取って次の囲みを押し下げ、地図は文字より先に少しだけ縮みます。

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

- 同じページテンプレートで、3か国語の教科書の単元を組むには？
- ページの囲みを文章に合わせて伸ばし、下の囲みを押し下げるには？
- 地図の地名を本文の側から入れて、翻訳では絵に手を付けずに言葉だけを替えるには？

## 手短な答え

```js
// script.js, 行 77–114
// Ground, title tab and number are decoration anchored to the title frame, so the tab is as
// tall as the title needs. A topic extends the base and changes its colours and adds frames.
const ground = (f) => ({ kind: 'box', id: 'ground', decorative: true,
  placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } },
  style: { backgroundColor: col(`${f}-ground`) } });
const titleTab = (f, x = TAB) => ({ kind: 'box', id: 'tab', decorative: true,
  placement: { ...at('#titulo', 'top-left', -x), // the tab reaches out to the margin
    size: { width: mm(HALF), height: { to: '#titulo', edge: 'bottom' } } },
  style: { backgroundColor: col(f), borderRadius: mm(4) } });
const badge = (f) => ({ kind: 'text', id: 'number', content: '{attr.numero}', fontFamily: DISPLAY,
  fontSize: pt(22), fontWeight: 700, color: col(f), align: 'center',
  box: { backgroundColor: col('paper'), borderRadius: mm(6) },
  placement: { ...at('#titulo', 'top-left', 5 - TAB, 2.4),
    size: { width: mm(12), height: mm(12) } } });
const TAB = 22; // mm of the tab left of the title: the badge stands there, outside the frame
const title = { name: 'titulo', takes: 'h1', style: 'titulo', width: mm(HALF - TAB),
  placement: at('container', 'top-left', TAB) };

const base = {
  id: 'tema', spread: true, gutter: mm(12),
  decoration: { elements: [ground('agua'), titleTab('agua'), badge('agua')] },
  frames: [title, { name: 'texto', flow: true, columns: 2, columnGap: mm(7), width: mm(HALF),
    fit: { minScale: 0.9 } }],
  groups: [{ name: 'izquierda', kind: 'stack', gap: mm(6), frames: ['titulo', 'texto'] }],
};
const topic = (id, f, more) => ({ id, extends: 'tema',
  decoration: { elements: [{ id: 'ground', style: { backgroundColor: col(`${f}-ground`) } },
    { id: 'tab', style: { backgroundColor: col(f), borderRadius: mm(4) } },
    { id: 'number', color: col(f) }] },
  ...more });
const box = (name, style, minHeight, more) => ({ name, style, width: mm(HALF),
  minHeight: mm(minHeight), ...more });
const answer = (name, lines) => ({ name, optional: true, style: 'respuesta', width: mm(HALF),
  height: mm(LINE * lines), minHeight: mm(LINE * lines),
  rules: { every: mm(LINE), color: col('rule'), width: pt(0.75) } });
// A picture that sits at the corner of a box and pushes its text aside.
const corner = (name) => ({ name: `${name}-foto`, layer: 2, width: mm(56), exclude: 'push',
  placement: at(`#${name}`, 'top-right', -5, 8) });
```

## 材料

**学べること**

- [テンプレートページ](https://postext.dev/ja/docs/composed-pages.md#テンプレートページ): 設定に置いた名前付き枠のテンプレートから組むページ。::::page フェンスの中身が slot 名で各枠に流し込まれます。
- [グループの中で伸びる枠](https://postext.dev/ja/docs/composed-pages.md#文章が増えたとき): テンプレートページの枠を縦または横に並べるグループ。枠は文章の高さに合わせて伸び（minHeight 未満にはならない）、後ろの枠とそれに固定した要素を押し下げます。
- [番号付きの囲みのタブ](https://postext.dev/ja/docs/configuration-callouts.md#囲みスタイル): 囲みの縁に付くラベルのタブ（「BOX 1-1」）で、フェンスのlabel属性から設定します。

**ほかに使うもの**

- [図に載せるラベル](https://postext.dev/ja/docs/composed-pages.md#ラベル付きの図)
- [目次](https://postext.dev/ja/docs/configuration-notes-references.md#目次)
- [見開きのテンプレート](https://postext.dev/ja/docs/composed-pages.md#見開き)
- [囲み](https://postext.dev/ja/docs/configuration-callouts.md#囲みスタイル)
- [ページデザインのテキスト・罫・ボックス](https://postext.dev/ja/docs/configuration-headers-footers.md#柱とノンブル)
- [ページデザインの画像](https://postext.dev/ja/docs/configuration-headers-footers.md#画像要素)
- [デザイン要素の基準位置](https://postext.dev/ja/docs/configuration-headers-footers.md#要素の配置)
- [枠に固定した飾り](https://postext.dev/ja/docs/composed-pages.md#飾り柱レイヤー)
- [見開きの余白](https://postext.dev/ja/docs/configuration-page-layout.md#見開きの余白)
- [柱とノンブル](https://postext.dev/ja/docs/configuration-headers-footers.md#柱とノンブル)
- [見出しスタイル](https://postext.dev/ja/docs/configuration-styles.md#見出しスタイル)
- [セマンティックカラーパレット](https://postext.dev/ja/docs/configuration-fonts-colors-viewers.md#カラーパレット)
- [リソースとしての図と表](https://postext.dev/ja/docs/document-format-resources.md#リソース)
- [キャンバス上のページ](https://postext.dev/ja/docs/configuration-programmatic-usage.md#ページをビットマップに描画する)
- [PDFの書き出し](https://postext.dev/ja/docs/configuration-pdf-output.md#pdfの生成)
- [テキストを押しのける枠（矩形または輪郭）](https://postext.dev/ja/docs/composed-pages.md#テキストを押しのける枠)
- [見出しの属性](https://postext.dev/ja/docs/document-format.md#見出しの属性)
- [デザインした章扉](https://postext.dev/ja/docs/configuration-headings.md#幅と詳細デザイン)
- [部扉](https://postext.dev/ja/docs/configuration-styles.md#partコンテナー)
- [PDFに埋め込むフォント](https://postext.dev/ja/docs/configuration-pdf-output.md#なぜフォントプロバイダーが必要か)
- [独自のリソースの種類](https://postext.dev/ja/docs/configuration-resources.md#リソースの種類)
- [番号のない章](https://postext.dev/ja/docs/configuration-styles.md#見出しスタイル)

**設定の一覧**

- [`bodyText`](https://postext.dev/ja/docs/configuration-text.md#本文), [`calloutStyles`](https://postext.dev/ja/docs/configuration-callouts.md#囲みスタイル), [`captionStyle`](https://postext.dev/ja/docs/configuration-resources.md#キャプションスタイル), [`colorPalette`](https://postext.dev/ja/docs/configuration-fonts-colors-viewers.md#カラーパレット), [`footer`](https://postext.dev/ja/docs/configuration-headers-footers.md#柱とノンブル), [`header`](https://postext.dev/ja/docs/configuration-headers-footers.md#柱とノンブル), [`headingStyles`](https://postext.dev/ja/docs/configuration-styles.md#見出しスタイル), [`headings`](https://postext.dev/ja/docs/configuration-headings.md#見出し), [`labelStyles`](https://postext.dev/ja/docs/configuration-composed-pages.md#ラベルスタイル), [`layout`](https://postext.dev/ja/docs/configuration-page-layout.md#レイアウト), [`locale`](https://postext.dev/ja/docs/configuration-language.md#ハイフネーション), [`orderedLists`](https://postext.dev/ja/docs/configuration-lists-math.md#番号付きリスト), [`page`](https://postext.dev/ja/docs/configuration-page-layout.md#ページ), [`pageTemplates`](https://postext.dev/ja/docs/configuration-composed-pages.md#ページテンプレート), [`parts`](https://postext.dev/ja/docs/configuration-styles.md#部), [`resourceTypes`](https://postext.dev/ja/docs/configuration-resources.md#リソースの種類), [`toc`](https://postext.dev/ja/docs/configuration-notes-references.md#目次), [`unorderedLists`](https://postext.dev/ja/docs/configuration-lists-math.md#箇条書きリスト)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/ja/docs/configuration-programmatic-usage.md#レイアウトの前にフォントを読み込む), [`decompressWoff2`](https://postext.dev/ja/docs/configuration-pdf-output.md#ブラウザー向けフォントプロバイダーfontsource--woff2), [`registerResourceImage`](https://postext.dev/ja/docs/architecture.md#apiの概要), [`renderPageToCanvas`](https://postext.dev/ja/docs/configuration-programmatic-usage.md#ページをビットマップに描画する), [`renderToPdf`](https://postext.dev/ja/docs/configuration-pdf-output.md#pdfの生成)

**書体**

- Nunito (OFL-1.1), Fredoka (OFL-1.1)

## 作り方

### 1 · 囲みを `extends` で作る

```js
// script.js, 行 42–73
// Three steps of `extends`: the box (shape, type, tab), a family's colours on it, then the kind.
const tab = (f) => ({ position: 'top-left', background: col(f), color: col('paper'),
  fontFamily: DISPLAY, fontSize: pt(12), fontWeight: 600, height: mm(7), paddingX: mm(3.2),
  offset: mm(7), inset: mm(5), rule: { enabled: false, color: col(f) } });
const boxText = { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(17.5), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: pt(0), textAlign: 'left',
  paragraphSpacing: true };
const none = { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) }; // no padding
const calloutStyles = [
  { id: 'caja', backgroundEnabled: true, background: col('paper'), borderRadius: mm(3),
    border: { enabled: false }, padding: { top: mm(3.4), right: mm(5), bottom: mm(4), left: mm(5) },
    body: boxText, lists: { color: col('ink'), gap: mm(2), itemSpacing: mm(1.6) } },
  ...Object.keys(FAMILIES).flatMap((f) => [
    { id: `caja-${f}`, extends: 'caja', label: tab(f), lists: { color: col(f) } },
    { id: `dato-${f}`, extends: `caja-${f}`, background: col(`${f}-tint`) },
    { id: `palabras-${f}`, extends: `caja-${f}`,
      border: { enabled: true, color: col(f), width: pt(1.4) } },
    { id: `actividad-${f}`, extends: `caja-${f}`,
      stripe: { enabled: true, side: 'left', width: pt(4), color: col(f) } },
  ]),
  // Plain frames: the title (its tab is decoration), a picture's card and an answer space.
  { id: 'titulo', extends: 'caja', backgroundEnabled: false,
    padding: { top: mm(2.6), right: mm(6), bottom: mm(3.2), left: mm(0) } },
  { id: 'titulo-plano', extends: 'titulo', padding: { left: mm(8) } },
  { id: 'portada', extends: 'caja-agua', background: col('agua'), borderRadius: mm(4),
    padding: { top: mm(2.6), right: mm(8), bottom: mm(3.6), left: mm(8) } },
  { id: 'foto', extends: 'caja', borderRadius: mm(2),
    padding: { top: mm(1.8), right: mm(1.8), bottom: mm(2.2), left: mm(1.8) } },
  { id: 'respuesta', extends: 'caja', padding: none },
  { id: 'colofon', extends: 'caja', backgroundEnabled: false, padding: none,
    body: { ...boxText, fontSize: pt(8), lineHeight: pt(11), color: col('muted') } },
];
```

囲みのスタイルは3段階で作ります。`caja` が形、書体、タブの寸法を持ち、`caja-agua`、`caja-campo`、`caja-sol` がタブと箇条書きの記号に系統の色を付け、`dato-`、`palabras-`、`actividad-` が種類（地色の囲み、枠線の囲み、色帯の囲み）を決めます。系統を足すには `FAMILIES` に色を3つ加えるだけで、同じスタイルを二度書くことはありません。

### 2 · すべてのテーマに土台のテンプレートを引き継がせる

```js
// script.js, 行 77–114
// Ground, title tab and number are decoration anchored to the title frame, so the tab is as
// tall as the title needs. A topic extends the base and changes its colours and adds frames.
const ground = (f) => ({ kind: 'box', id: 'ground', decorative: true,
  placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } },
  style: { backgroundColor: col(`${f}-ground`) } });
const titleTab = (f, x = TAB) => ({ kind: 'box', id: 'tab', decorative: true,
  placement: { ...at('#titulo', 'top-left', -x), // the tab reaches out to the margin
    size: { width: mm(HALF), height: { to: '#titulo', edge: 'bottom' } } },
  style: { backgroundColor: col(f), borderRadius: mm(4) } });
const badge = (f) => ({ kind: 'text', id: 'number', content: '{attr.numero}', fontFamily: DISPLAY,
  fontSize: pt(22), fontWeight: 700, color: col(f), align: 'center',
  box: { backgroundColor: col('paper'), borderRadius: mm(6) },
  placement: { ...at('#titulo', 'top-left', 5 - TAB, 2.4),
    size: { width: mm(12), height: mm(12) } } });
const TAB = 22; // mm of the tab left of the title: the badge stands there, outside the frame
const title = { name: 'titulo', takes: 'h1', style: 'titulo', width: mm(HALF - TAB),
  placement: at('container', 'top-left', TAB) };

const base = {
  id: 'tema', spread: true, gutter: mm(12),
  decoration: { elements: [ground('agua'), titleTab('agua'), badge('agua')] },
  frames: [title, { name: 'texto', flow: true, columns: 2, columnGap: mm(7), width: mm(HALF),
    fit: { minScale: 0.9 } }],
  groups: [{ name: 'izquierda', kind: 'stack', gap: mm(6), frames: ['titulo', 'texto'] }],
};
const topic = (id, f, more) => ({ id, extends: 'tema',
  decoration: { elements: [{ id: 'ground', style: { backgroundColor: col(`${f}-ground`) } },
    { id: 'tab', style: { backgroundColor: col(f), borderRadius: mm(4) } },
    { id: 'number', color: col(f) }] },
  ...more });
const box = (name, style, minHeight, more) => ({ name, style, width: mm(HALF),
  minHeight: mm(minHeight), ...more });
const answer = (name, lines) => ({ name, optional: true, style: 'respuesta', width: mm(HALF),
  height: mm(LINE * lines), minHeight: mm(LINE * lines),
  rules: { every: mm(LINE), color: col('rule'), width: pt(0.75) } });
// A picture that sits at the corner of a box and pushes its text aside.
const corner = (name) => ({ name: `${name}-foto`, layer: 2, width: mm(56), exclude: 'push',
  placement: at(`#${name}`, 'top-right', -5, 8) });
```

土台は見開きのテンプレートで、裁ち落としまでの地色、番号付きのタイトルのタブ、2段の本文を持ちます。各テーマは `extends: 'tema'` でこれを引き継ぎ、3つの装飾要素の色を `id` で書き換え、自分のフレームを足します。タブと番号はタイトルのフレームに固定した装飾なので、タブの高さはその言語のタイトルに必要なだけになり、番号はどのフレームにも覆われないよう、フレームの左に置きます。

### 3 · グループに場所を分け合わせる

テーマにはグループが二つあります。左ページは、タイトル、本文、そして絵の帯か地図を積むスタックです。右ページは囲みを積むもう一つのスタックか、幅を分け合う3つの囲みの行です。フレームは言葉の分だけ高くなり、`minHeight` より低くなることはないので、スペイン語の本文は下にあるものをすべて押し下げ、テンプレートの位置は何も変わりません。ページが窮屈になると、[調整の手順](https://postext.dev/ja/docs/composed-pages.md#それでも収まらないとき)がまず空きを取り戻し、地図と絵を縮め、本文は最後に90 %まで縮めます。`pnpm cookbook bench` は、本文を30 %長くして3つの版をもう一度組みます。

### 4 · 地図の名前を本文から付ける

```js
// script.js, 行 241–268
// An anchor is a point of the painting (fractions of its width and height) and a label style;
// every language's ::label lines use the same anchors.
const halo = { color: col('paper'), width: pt(1.8) };
const labelStyles = [
  { id: 'sierra', fontFamily: DISPLAY, fontSize: pt(13), fontWeight: 600, color: col('ink'),
    textTransform: 'uppercase', letterSpacing: 0.14, halo },
  { id: 'agua', fontFamily: TEXT, fontSize: pt(11), fontWeight: 700, fontStyle: 'italic',
    color: { hex: '#0b3a5c', model: 'hex' }, halo },
  { id: 'mar', fontFamily: TEXT, fontSize: pt(12), fontWeight: 700, fontStyle: 'italic',
    color: col('paper'), halo: { color: { hex: '#0b3a5c', model: 'hex' }, width: pt(1.8) } },
  { id: 'pueblo', fontFamily: DISPLAY, fontSize: pt(12), fontWeight: 600, color: col('ink'), halo },
  { id: 'curso', fontFamily: DISPLAY, fontSize: pt(10.5), fontWeight: 600, color: col('paper'),
    textTransform: 'uppercase', letterSpacing: 0.08, background: col('campo'),
    padding: mm(1.6) },
];
const ANCHORS = [
  { id: 'sierra', x: 0.21, y: 0.055, style: 'sierra' },
  { id: 'nacimiento', x: 0.065, y: 0.115, style: 'agua', align: 'start' },
  { id: 'fontalba', x: 0.135, y: 0.255, style: 'pueblo', verticalAlign: 'top' },
  { id: 'embalse', x: 0.31, y: 0.155, style: 'agua', width: 0.12 },
  { id: 'molinar', x: 0.505, y: 0.35, style: 'pueblo', verticalAlign: 'bottom' },
  { id: 'aldamar', x: 0.845, y: 0.505, style: 'pueblo', verticalAlign: 'bottom' },
  { id: 'mar', x: 0.965, y: 0.28, style: 'mar' },
  { id: 'rio', x: 0.61, y: 0.715, style: 'agua', rotate: -3 },
  { id: 'alto', x: 0.17, y: 0.45, style: 'curso' },
  { id: 'medio', x: 0.4, y: 0.77, style: 'curso' },
  { id: 'bajo', x: 0.74, y: 0.865, style: 'curso' },
];
```

地図は文字が一つもない絵です。それぞれの場所は絵の上のアンカーで、幅と高さに対する割合の点とラベルのスタイルを持ち、各版がそのアンカーに自分の `::label` の行を書きます。*Penalta Reservoir*、*Pantà de Penyalta*、*Embalse de Peñalta* は同じ位置に立ち、長い名前はアンカーの `width` の中で折り返します。

### 5 · 目次の部をまとめておく

```js
// script.js, 行 207–237
// The heading style paints the title tab; the entries come from the headings and the parts.
const indiceOpener = { enabled: true, minHeight: mm(26), slot: { elements: [
  { kind: 'box', id: 'tab', decorative: true,
    placement: { ...at('container', 'top-left'), size: { width: mm(HALF), height: mm(18) } },
    style: { backgroundColor: col('agua'), borderRadius: mm(4) } },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(28),
    fontWeight: 600, color: col('paper'), align: 'left',
    placement: { ...at('container', 'top-left', 8, 3.2), size: { width: mm(HALF - 12) } } },
] } };
const toc = {
  levels: [
    { level: 1, fontFamily: DISPLAY, fontSize: pt(15), lineHeight: pt(22), fontWeight: 500,
      color: col('ink'), numberFontWeight: 600, numberColor: col('agua'), numberWidth: mm(9),
      marginTop: pt(4) },
    { level: 2, fontFamily: TEXT, fontSize: pt(11.5), lineHeight: pt(18), indent: mm(9),
      color: col('ink') },
  ],
  pageNumber: { fontFamily: DISPLAY, fontWeight: 500, width: mm(9), color: col('agua') },
  leader: { char: '.', gap: mm(1.5) },
  parts: { height: pt(34), marginTop: pt(12), marginBottom: pt(4), design: { elements: [
    { kind: 'box', id: 'band', decorative: true,
      placement: { ...at('container', 'top-left'), size: { width: 'fill', height: 'fill' } },
      style: { backgroundColor: col('paper'), borderRadius: mm(3) } },
    { kind: 'text', id: 'letter', content: '{number}', fontFamily: DISPLAY, fontSize: pt(16),
      fontWeight: 700, color: col('paper'), align: 'center',
      box: { backgroundColor: col('agua'), borderRadius: mm(4) },
      placement: { ...at('container', 'left', 2, 0), size: { width: mm(8), height: mm(8) } } },
    { kind: 'text', id: 'name', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(13),
      fontWeight: 600, color: col('agua'), placement: at('container', 'left', 14, 0) },
  ] } },
};
```

`:::toc{keep="part" levels="1-2"}` はテーマと節を並べ、部の行をその項目と離さずに保ちます。`parts.page: false` を付けた `:::part` のフェンスは、目次の一行と柱の区切りになるだけで、扉のページは増えません。目次はフレームの中ではなく流し込みのページにしています。フレームの中の `:::toc` は部の行を失うためです。

## レシピの全体

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

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

### script.js

```js
// ═══ Postext Cookbook · Nº 156 · A children's textbook unit in three languages ═══
// https://postext.dev/en/cookbook/children-textbook-pages
// Code: MIT · Text: original (CC BY 4.0) · Pictures: generated with diffusion models
// Fonts: Nunito, Fredoka (SIL OFL 1.1) · Needs postext ≥ 1.40.0
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'es'; // @lang: the language of the sample document ('es' | 'en' | 'ca')
const RECIPE = 'children-textbook-pages';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: one ink and three families (water, field, sun), each with an accent, a tint
// for its boxes and a ground for its pages
const FAMILIES = {
  agua: { main: '#1b6aa0', tint: '#e1f0fa', ground: '#d3e8f6' },
  campo: { main: '#336f27', tint: '#e5f1dc', ground: '#d9ebcb' },
  sol: { main: '#9a5209', tint: '#fcefd2', ground: '#f8e6b8' },
};
const palette = { ink: '#1d2f3f', muted: '#4f6272', rule: '#7e98ab', paper: '#ffffff' };
for (const [f, c] of Object.entries(FAMILIES)) {
  Object.assign(palette, { [f]: c.main, [`${f}-tint`]: c.tint, [`${f}-ground`]: c.ground });
}
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: 'water (defaults)', value: { hex: palette.agua, model: 'hex' } },
];
// #endregion

const [TEXT, DISPLAY] = ['Nunito', 'Fredoka'];
const PAGE = { w: 210, h: 280, top: 18, bottom: 18, inner: 14, outer: 12 }; // mm, mirrored
const HALF = PAGE.w - PAGE.inner - PAGE.outer; // one page's type area: 184 mm
const SHEET = 2 * HALF + 2 * PAGE.inner; // a spread's content area: 396 mm
const RIGHT = HALF + 2 * PAGE.inner; // where the right page's type area starts on the sheet
const LINE = 8; // mm between two answer lines
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const onRight = at('container', 'top-left', RIGHT); // every frame of the right page's stack

// #region boxes: callout styles that extend each other
// Three steps of `extends`: the box (shape, type, tab), a family's colours on it, then the kind.
const tab = (f) => ({ position: 'top-left', background: col(f), color: col('paper'),
  fontFamily: DISPLAY, fontSize: pt(12), fontWeight: 600, height: mm(7), paddingX: mm(3.2),
  offset: mm(7), inset: mm(5), rule: { enabled: false, color: col(f) } });
const boxText = { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(17.5), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: pt(0), textAlign: 'left',
  paragraphSpacing: true };
const none = { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) }; // no padding
const calloutStyles = [
  { id: 'caja', backgroundEnabled: true, background: col('paper'), borderRadius: mm(3),
    border: { enabled: false }, padding: { top: mm(3.4), right: mm(5), bottom: mm(4), left: mm(5) },
    body: boxText, lists: { color: col('ink'), gap: mm(2), itemSpacing: mm(1.6) } },
  ...Object.keys(FAMILIES).flatMap((f) => [
    { id: `caja-${f}`, extends: 'caja', label: tab(f), lists: { color: col(f) } },
    { id: `dato-${f}`, extends: `caja-${f}`, background: col(`${f}-tint`) },
    { id: `palabras-${f}`, extends: `caja-${f}`,
      border: { enabled: true, color: col(f), width: pt(1.4) } },
    { id: `actividad-${f}`, extends: `caja-${f}`,
      stripe: { enabled: true, side: 'left', width: pt(4), color: col(f) } },
  ]),
  // Plain frames: the title (its tab is decoration), a picture's card and an answer space.
  { id: 'titulo', extends: 'caja', backgroundEnabled: false,
    padding: { top: mm(2.6), right: mm(6), bottom: mm(3.2), left: mm(0) } },
  { id: 'titulo-plano', extends: 'titulo', padding: { left: mm(8) } },
  { id: 'portada', extends: 'caja-agua', background: col('agua'), borderRadius: mm(4),
    padding: { top: mm(2.6), right: mm(8), bottom: mm(3.6), left: mm(8) } },
  { id: 'foto', extends: 'caja', borderRadius: mm(2),
    padding: { top: mm(1.8), right: mm(1.8), bottom: mm(2.2), left: mm(1.8) } },
  { id: 'respuesta', extends: 'caja', padding: none },
  { id: 'colofon', extends: 'caja', backgroundEnabled: false, padding: none,
    body: { ...boxText, fontSize: pt(8), lineHeight: pt(11), color: col('muted') } },
];
// #endregion

// #region answer: one base template and a family of topics that extend it
// Ground, title tab and number are decoration anchored to the title frame, so the tab is as
// tall as the title needs. A topic extends the base and changes its colours and adds frames.
const ground = (f) => ({ kind: 'box', id: 'ground', decorative: true,
  placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } },
  style: { backgroundColor: col(`${f}-ground`) } });
const titleTab = (f, x = TAB) => ({ kind: 'box', id: 'tab', decorative: true,
  placement: { ...at('#titulo', 'top-left', -x), // the tab reaches out to the margin
    size: { width: mm(HALF), height: { to: '#titulo', edge: 'bottom' } } },
  style: { backgroundColor: col(f), borderRadius: mm(4) } });
const badge = (f) => ({ kind: 'text', id: 'number', content: '{attr.numero}', fontFamily: DISPLAY,
  fontSize: pt(22), fontWeight: 700, color: col(f), align: 'center',
  box: { backgroundColor: col('paper'), borderRadius: mm(6) },
  placement: { ...at('#titulo', 'top-left', 5 - TAB, 2.4),
    size: { width: mm(12), height: mm(12) } } });
const TAB = 22; // mm of the tab left of the title: the badge stands there, outside the frame
const title = { name: 'titulo', takes: 'h1', style: 'titulo', width: mm(HALF - TAB),
  placement: at('container', 'top-left', TAB) };

const base = {
  id: 'tema', spread: true, gutter: mm(12),
  decoration: { elements: [ground('agua'), titleTab('agua'), badge('agua')] },
  frames: [title, { name: 'texto', flow: true, columns: 2, columnGap: mm(7), width: mm(HALF),
    fit: { minScale: 0.9 } }],
  groups: [{ name: 'izquierda', kind: 'stack', gap: mm(6), frames: ['titulo', 'texto'] }],
};
const topic = (id, f, more) => ({ id, extends: 'tema',
  decoration: { elements: [{ id: 'ground', style: { backgroundColor: col(`${f}-ground`) } },
    { id: 'tab', style: { backgroundColor: col(f), borderRadius: mm(4) } },
    { id: 'number', color: col(f) }] },
  ...more });
const box = (name, style, minHeight, more) => ({ name, style, width: mm(HALF),
  minHeight: mm(minHeight), ...more });
const answer = (name, lines) => ({ name, optional: true, style: 'respuesta', width: mm(HALF),
  height: mm(LINE * lines), minHeight: mm(LINE * lines),
  rules: { every: mm(LINE), color: col('rule'), width: pt(0.75) } });
// A picture that sits at the corner of a box and pushes its text aside.
const corner = (name) => ({ name: `${name}-foto`, layer: 2, width: mm(56), exclude: 'push',
  placement: at(`#${name}`, 'top-right', -5, 8) });
// #endregion

const questions = [1, 2, 3].flatMap((n) => [box(`pregunta-${n}`, 'dato-agua', 14),
  answer(`respuesta-${n}`, 3)]);
const pageTemplates = [
  base,
  // Water: text, a strip of three pictures and a box; three boxes grow down the right page.
  topic('tema-agua', 'agua', {
    frames: [
      ...[1, 2, 3].map((n) => ({ name: `foto-${n}`, style: 'foto', width: mm((HALF - 10) / 3),
        ...(n === 1 && { placement: at('#texto', 'below', 0, 8) }) })),
      box('probar', 'palabras-agua', 46, { placement: at('#foto-1', 'below', 0, 10) }),
      { ...box('sabias', 'dato-agua', 40), placement: onRight },
      { ...box('palabras', 'palabras-agua', 62), placement: onRight },
      { ...box('actividades', 'actividad-agua', 58), placement: onRight },
    ],
    groups: [
      { name: 'fotos', kind: 'row', gap: mm(5), frames: ['foto-1', 'foto-2', 'foto-3'] },
      { name: 'derecha', kind: 'stack', gap: mm(12),
        frames: ['sabias', 'palabras', 'actividades'] },
    ],
  }),
  // Field: one map across the spread; under it a text, and three boxes that share a width.
  topic('tema-campo', 'campo', {
    frames: [
      { name: 'mapa', crossGutter: true, width: mm(SHEET) },
      box('actividad', 'actividad-campo', 24),
      ...[1, 2, 3].map((n) => ({ name: `curso-${n}`, style: 'dato-campo', width: mm(56),
        minHeight: mm(46), shrink: 1,
        ...(n === 1 && { placement: at('#mapa', 'below', RIGHT, 6) }) })),
    ],
    groups: [
      { name: 'izquierda', kind: 'stack', gap: mm(6),
        frames: ['titulo', 'mapa', 'texto', 'actividad'] },
      { name: 'cursos', kind: 'row', gap: mm(6), frames: ['curso-1', 'curso-2', 'curso-3'] },
    ],
  }),
  // Sun: boxes with a picture at their corner that pushes the text aside.
  topic('tema-sol', 'sol', {
    frames: [
      box('cuidar', 'palabras-sol', 64, { padding: { right: mm(64) } }), corner('cuidar'),
      box('turno', 'actividad-sol', 40),
      ...[1, 2, 3].flatMap((n) => [
        box(`uso-${n}`, 'dato-sol', 66, { padding: { right: mm(64) }, placement: onRight }),
        corner(`uso-${n}`),
      ]),
    ],
    groups: [
      { name: 'izquierda', kind: 'stack', gap: mm(12),
        frames: ['titulo', 'texto', 'cuidar', 'turno'] },
      { name: 'derecha', kind: 'stack', gap: mm(12), frames: ['uso-1', 'uso-2', 'uso-3'] },
    ],
  }),
  // Before the unit: questions, each with room to write.
  { id: 'saberes', header: false,
    decoration: { elements: [titleTab('agua', 0)] },
    frames: [{ ...title, style: 'titulo-plano', width: mm(HALF),
      placement: at('container', 'top-left') },
      { name: 'texto', flow: true, width: mm(HALF) }, ...questions],
    groups: [{ name: 'pagina', kind: 'stack', gap: mm(7),
      frames: ['titulo', 'texto', ...questions.map((q) => q.name)] }] },
  // The opener: a painting to the bleed, the title over its foot.
  { id: 'unidad', header: false, footer: false,
    decoration: { elements: [
      { kind: 'image', id: 'portada', resourceId: 'portada', decorative: true,
        placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(157.5) } } },
      { kind: 'text', id: 'kicker', content: '{attr.unidad}', fontFamily: DISPLAY,
        fontSize: pt(11), fontWeight: 600, letterSpacing: pt(1), textTransform: 'uppercase',
        color: col('agua'), box: { backgroundColor: col('paper'), borderRadius: mm(2) },
        placement: { ...at('#titulo', 'top-left', 0, -9), size: { height: mm(7) } } },
    ] },
    frames: [
      { name: 'titulo', takes: 'h1', style: 'portada', width: mm(120),
        placement: at('container', 'top-left', 0, 104) },
      { name: 'texto', flow: true, width: mm(160) },
      box('aprenderas', 'dato-agua', 36),
      { name: 'colofon', style: 'colofon', width: mm(HALF),
        placement: at('container', 'bottom-left') },
    ],
    groups: [{ name: 'pagina', kind: 'stack', gap: mm(7),
      frames: ['titulo', 'texto', 'aprenderas'] }] },
];

const head = (id, content, parity, edge, x) => ({ kind: 'text', id, content, parity,
  fontFamily: TEXT, fontSize: pt(8), fontWeight: 700, letterSpacing: pt(1), color: col('muted'),
  textTransform: 'uppercase', placement: at('page', edge, x, 9) });
const folio = (id, parity, edge, x) => ({ kind: 'text', id, content: '{pageNumber}', parity,
  fontFamily: DISPLAY, fontSize: pt(11), fontWeight: 600, color: col('paper'), align: 'center',
  box: { backgroundColor: col('agua'), borderRadius: mm(4) },
  placement: { ...at('page', edge, x, -8), size: { width: mm(8), height: mm(8) } } });

// #region contents: a flow page, because a :::toc cannot be laid out inside a frame yet
// The heading style paints the title tab; the entries come from the headings and the parts.
const indiceOpener = { enabled: true, minHeight: mm(26), slot: { elements: [
  { kind: 'box', id: 'tab', decorative: true,
    placement: { ...at('container', 'top-left'), size: { width: mm(HALF), height: mm(18) } },
    style: { backgroundColor: col('agua'), borderRadius: mm(4) } },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(28),
    fontWeight: 600, color: col('paper'), align: 'left',
    placement: { ...at('container', 'top-left', 8, 3.2), size: { width: mm(HALF - 12) } } },
] } };
const toc = {
  levels: [
    { level: 1, fontFamily: DISPLAY, fontSize: pt(15), lineHeight: pt(22), fontWeight: 500,
      color: col('ink'), numberFontWeight: 600, numberColor: col('agua'), numberWidth: mm(9),
      marginTop: pt(4) },
    { level: 2, fontFamily: TEXT, fontSize: pt(11.5), lineHeight: pt(18), indent: mm(9),
      color: col('ink') },
  ],
  pageNumber: { fontFamily: DISPLAY, fontWeight: 500, width: mm(9), color: col('agua') },
  leader: { char: '.', gap: mm(1.5) },
  parts: { height: pt(34), marginTop: pt(12), marginBottom: pt(4), design: { elements: [
    { kind: 'box', id: 'band', decorative: true,
      placement: { ...at('container', 'top-left'), size: { width: 'fill', height: 'fill' } },
      style: { backgroundColor: col('paper'), borderRadius: mm(3) } },
    { kind: 'text', id: 'letter', content: '{number}', fontFamily: DISPLAY, fontSize: pt(16),
      fontWeight: 700, color: col('paper'), align: 'center',
      box: { backgroundColor: col('agua'), borderRadius: mm(4) },
      placement: { ...at('container', 'left', 2, 0), size: { width: mm(8), height: mm(8) } } },
    { kind: 'text', id: 'name', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(13),
      fontWeight: 600, color: col('agua'), placement: at('container', 'left', 14, 0) },
  ] } },
};
// #endregion

// #region art-labels: the map's label styles and anchors; the words are in the Markdown
// An anchor is a point of the painting (fractions of its width and height) and a label style;
// every language's ::label lines use the same anchors.
const halo = { color: col('paper'), width: pt(1.8) };
const labelStyles = [
  { id: 'sierra', fontFamily: DISPLAY, fontSize: pt(13), fontWeight: 600, color: col('ink'),
    textTransform: 'uppercase', letterSpacing: 0.14, halo },
  { id: 'agua', fontFamily: TEXT, fontSize: pt(11), fontWeight: 700, fontStyle: 'italic',
    color: { hex: '#0b3a5c', model: 'hex' }, halo },
  { id: 'mar', fontFamily: TEXT, fontSize: pt(12), fontWeight: 700, fontStyle: 'italic',
    color: col('paper'), halo: { color: { hex: '#0b3a5c', model: 'hex' }, width: pt(1.8) } },
  { id: 'pueblo', fontFamily: DISPLAY, fontSize: pt(12), fontWeight: 600, color: col('ink'), halo },
  { id: 'curso', fontFamily: DISPLAY, fontSize: pt(10.5), fontWeight: 600, color: col('paper'),
    textTransform: 'uppercase', letterSpacing: 0.08, background: col('campo'),
    padding: mm(1.6) },
];
const ANCHORS = [
  { id: 'sierra', x: 0.21, y: 0.055, style: 'sierra' },
  { id: 'nacimiento', x: 0.065, y: 0.115, style: 'agua', align: 'start' },
  { id: 'fontalba', x: 0.135, y: 0.255, style: 'pueblo', verticalAlign: 'top' },
  { id: 'embalse', x: 0.31, y: 0.155, style: 'agua', width: 0.12 },
  { id: 'molinar', x: 0.505, y: 0.35, style: 'pueblo', verticalAlign: 'bottom' },
  { id: 'aldamar', x: 0.845, y: 0.505, style: 'pueblo', verticalAlign: 'bottom' },
  { id: 'mar', x: 0.965, y: 0.28, style: 'mar' },
  { id: 'rio', x: 0.61, y: 0.715, style: 'agua', rotate: -3 },
  { id: 'alto', x: 0.17, y: 0.45, style: 'curso' },
  { id: 'medio', x: 0.4, y: 0.77, style: 'curso' },
  { id: 'bajo', x: 0.74, y: 0.865, style: 'curso' },
];
// #endregion

const config = {
  locale: t({ es: 'es', en: 'en-gb', ca: 'ca' }),
  resourceTypes: [{ id: 'dibujo', name: t({ es: 'Dibujo', en: 'Picture', ca: 'Dibuix' }),
    shortLabel: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal',
    captionPrefix: '', defaultPlacement: { position: 'here' } }],
  colorPalette,
  page: { sizePreset: 'custom', width: mm(PAGE.w), height: mm(PAGE.h), dpi: 150,
    margins: { top: mm(PAGE.top), bottom: mm(PAGE.bottom), left: mm(PAGE.inner),
      right: mm(PAGE.outer), mirror: true } },
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(17.5), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true,
    hyphenation: { enabled: true }, optimalLineBreaking: true,
    avoidWidows: true, avoidOrphans: true, avoidRunts: true },
  headings: { fontFamily: DISPLAY, fontWeight: 600, color: col('ink'), levels: [
    { level: 1, fontSize: pt(28), lineHeight: pt(32), color: col('paper'), marginTop: pt(0),
      marginBottom: pt(0), breakBefore: { enabled: false } },
    { level: 2, fontSize: pt(15), lineHeight: pt(19), marginTop: pt(9), marginBottom: pt(3) },
  ] },
  // Headings that are not entries of the unit: no number, no row in the contents.
  headingStyles: [
    { id: 'portada', numbered: false, toc: false, fontSize: pt(46), lineHeight: pt(48) },
    { id: 'previo', numbered: false, toc: false },
    { id: 'indice', numbered: false, toc: false, fontSize: pt(28), advancedDesign: indiceOpener },
  ],
  toc,
  parts: { page: false }, // a part is a row of the contents, with no divider page
  unorderedLists: { bulletChar: '•', color: col('agua'), fontWeight: 700, itemSpacing: pt(3) },
  orderedLists: { numberFormat: 'arabic', fontFamily: DISPLAY, fontWeight: 600,
    color: col('agua'), itemSpacing: pt(4) },
  labelStyles,
  captionStyle: { fontFamily: TEXT, fontSize: pt(9), color: col('ink'), descriptionItalic: true,
    gap: pt(4) },
  calloutStyles,
  header: { elements: [
    head('materia', '{subtitle}', 'even', 'top-left', PAGE.outer),
    head('unidad', '{title}', 'odd', 'top-right', -PAGE.outer),
  ] },
  footer: { elements: [folio('folio-par', 'even', 'bottom-left', PAGE.outer),
    folio('folio-impar', 'odd', 'bottom-right', -PAGE.outer)] },
  pageTemplates,
};

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Nuestro río"
subtitle: "Ciencias sociales · 3.º de Primaria"
---

::::page{template="unidad" unidad="Unidad 4 · Ciencias sociales"}
# Nuestro río {style="portada"}

Todos los pueblos que conoces están cerca del agua. Esta unidad sigue a un río, el Aldara, desde la nieve donde nace hasta el mar donde termina. Vas a descubrir dónde nace un río, cómo cambia en su camino hacia abajo y cómo viven junto a él las personas de Fontalba, Molinar y Aldamar.

:::frame{slot="aprenderas" label="Vas a aprender"}
- Cómo un manantial se convierte en arroyo, y un arroyo en río.
- Qué son el curso alto, el curso medio y el curso bajo de un río.
- Cómo usan las personas el agua del río y cómo se cuida.
:::

:::frame{slot="colofon"}
Compuesto en Nunito y Fredoka (SIL OFL) · Texto original (CC BY 4.0) · Ilustraciones generadas con modelos de difusión
:::
::::

# En esta unidad {style="indice"}

:::toc{keep="part" levels="1-2"}

:::figure{resource="rio"}
::alt[Mapa pintado del valle del río Aldara, desde las montañas nevadas hasta el mar.]
::caption[El río Aldara, desde su nacimiento hasta el mar. El mapa está en las páginas 6 y 7.]
:::

::::page{template="saberes"}
# ¿Qué sabes de los ríos? {style="previo"}

Antes de empezar, piensa en los ríos que has visto. Escribe tus ideas en las líneas. Al final de la unidad vuelve a leerlas y mira qué ha cambiado.

:::frame{slot="pregunta-1" label="Piensa 1"}
¿Dónde crees que empieza un río?
:::

:::frame{slot="pregunta-2" label="Piensa 2"}
¿Para qué usan las personas un río?
:::

:::frame{slot="pregunta-3" label="Piensa 3"}
¿Qué animales y plantas viven en un río o junto a él?
:::
::::

:::part{number="A" title="Un río de la montaña al mar"}
:::

::::page{template="tema-agua" spread numero="1"}
# Dónde nace un río

Llueve y nieva en las montañas. Parte del agua baja por las laderas y parte se cuela en la tierra. Bajo tierra avanza despacio, hasta que choca con una roca que no puede atravesar. Entonces sale al aire libre. El lugar donde el agua brota de la tierra se llama **manantial**.

## Del manantial al arroyo

Un manantial da solo un hilo de agua. El hilo baja por la ladera, se encuentra con otros hilos y entre todos forman un **arroyo**. En una pendiente fuerte, el arroyo corre deprisa. Arrastra guijarros y los deja lisos de tanto rodarlos. Cuando se juntan varios arroyos, forman uno mayor. El río Aldara empieza así, entre la nieve de la sierra Blanca, a 1850 metros sobre el nivel del mar.

:::figure{resource="nacimiento" slot="foto-1"}
::alt[Agua clara que brota entre rocas cubiertas de musgo.]
::caption[Un manantial. El agua ha pasado meses bajo tierra.]
:::

:::figure{resource="arroyo" slot="foto-2"}
::alt[Un arroyo que salta sobre piedras redondas.]
::caption[Un arroyo, empinado y rápido, con guijarros pulidos.]
:::

:::figure{resource="cascada" slot="foto-3"}
::alt[Una pequeña cascada que cae en una poza verde.]
::caption[Una cascada, donde el arroyo cae por un escalón de roca.]
:::

:::frame{slot="probar" label="Pruébalo"}
Sostén una esponja mojada sobre una bandeja y apriétala con suavidad. ¿Por dónde sale el agua? Mira cómo gotea y luego corre. Así alimenta un manantial a un arroyo.
:::

:::frame{slot="sabias" label="¿Sabías que…?"}
En su nacimiento, el Aldara es un hilo de agua que se puede saltar. Después de 94 kilómetros es tan ancho como largo es un campo de fútbol.
:::

:::frame{slot="palabras" label="Palabras para aprender"}
**Manantial:** lugar donde el agua brota de la tierra.

**Arroyo:** río pequeño que baja rápido por una pendiente.

**Afluente:** arroyo o río que desemboca en otro mayor.

**Orilla:** la tierra que hay a los lados de un río.
:::

:::frame{slot="actividades" label="Actividades"}
1. Mira las tres imágenes. ¿Cuál muestra un manantial? ¿Cómo lo sabes?
2. ¿Por qué un arroyo corre más rápido en la montaña que en el valle?
3. ¿Cuál de las cuatro palabras del recuadro puedes ver en las imágenes?
:::
::::

::::page{template="tema-campo" spread numero="2"}
# De la montaña al mar

El río Aldara mide 94 kilómetros. Nace en la sierra Blanca, cruza un valle y una llanura y llega al mar en Aldamar. Los geógrafos dividen un río en tres partes. Cada parte se llama **curso** y cada una tiene un aspecto distinto.

:::figure{resource="mapa" slot="mapa"}
::alt[Un mapa pintado del valle: montañas nevadas y un nacimiento a la izquierda, el pueblo de Fontalba, un embalse con su presa, la ciudad de Molinar con su puente y su molino, campos de cultivo, el río que se curva por una llanura y el puerto de Aldamar junto al mar, a la derecha.]
::label[Sierra Blanca]{at="sierra"}
::label[Nacimiento]{at="nacimiento"}
::label[Fontalba]{at="fontalba"}
::label[Embalse de Peñalta]{at="embalse"}
::label[Molinar]{at="molinar"}
::label[Aldamar]{at="aldamar"}
::label[Mar]{at="mar"}
::label[Río Aldara]{at="rio"}
::label[Curso alto]{at="alto"}
::label[Curso medio]{at="medio"}
::label[Curso bajo]{at="bajo"}
:::

:::frame{slot="actividad" label="Mira el mapa"}
Sigue el río con el dedo desde el nacimiento hasta el mar. ¿Qué población está más cerca del mar?
:::

:::frame{slot="curso-1" label="Curso alto"}
La pendiente es fuerte. El agua es fría, clara y rápida, y excava la roca. Aquí está Fontalba.
:::

:::frame{slot="curso-2" label="Curso medio"}
El valle es más ancho y el río va más despacio. Los agricultores riegan sus campos, y Molinar creció junto a un puente y un molino.
:::

:::frame{slot="curso-3" label="Curso bajo"}
La tierra es casi llana. El río es ancho y lento, y dibuja grandes curvas. En Aldamar desemboca en el mar.
:::
::::

:::part{number="B" title="Las personas y el río"}
:::

::::page{template="tema-sol" spread numero="3"}
# Las personas y el río

Las personas siempre se han instalado cerca de los ríos. Necesitan agua para beber, para lavarse y para cultivar alimentos. Un río también da peces, un camino para las barcas y la fuerza que hace girar una rueda.

## Cuidamos el río

Un río solo se mantiene limpio si lo cuidamos. La basura, el aceite y el agua sucia lo enferman, y los peces y las plantas que viven en él lo sufren. Todo lo que cae por un desagüe o por la calle acaba en un río o en el mar.

:::frame{slot="cuidar" label="Cuidamos nuestro río"}
- Tiramos la basura a la papelera, nunca al agua.
- Gastamos el agua con cuidado y cerramos el grifo.
- Vamos por los caminos y no molestamos a plantas ni animales.
- Ayudamos a limpiar la orilla cuando lo organiza el colegio.
:::

:::frame{slot="turno" label="Te toca a ti"}
1. Dibuja un cartel con tres normas para mantener limpio nuestro río.
2. Pregunta en casa: ¿de dónde viene el agua de tu grifo?
:::

:::figure{resource="limpieza" slot="cuidar-foto"}
::alt[Niños con guantes y bolsas de papel recogiendo basura en la orilla de un río.]
:::

:::frame{slot="uso-1" label="1 · Agua para los campos"}
En el curso medio, los agricultores abren pequeñas acequias para que el agua del río corra entre los surcos. Sin ella, los tomates y las lechugas no crecerían en verano. Una sola acequia puede llevar agua a muchos kilómetros de distancia.
:::

:::figure{resource="riego" slot="uso-1-foto"}
::alt[Un agricultor levanta una compuerta de una acequia en un campo de hortalizas.]
:::

:::frame{slot="uso-2" label="2 · Molinos"}
Hace mucho tiempo, el agua al caer movía la rueda del molino de Molinar. La rueda hacía girar las piedras que molían el trigo para hacer harina para todo el valle. Hoy el viejo molino es un pequeño museo.
:::

:::figure{resource="molino" slot="uso-2-foto"}
::alt[Un viejo molino de piedra con una gran rueda de madera junto a un río.]
:::

:::frame{slot="uso-3" label="3 · Deporte y juegos"}
Ahora el curso bajo, tranquilo, es un lugar para las piraguas. En las mañanas de verano, los niños de Aldamar aprenden a remar con un monitor, y todos llevan chaleco salvavidas.
:::

:::figure{resource="piraguas" slot="uso-3-foto"}
::alt[Niños con chalecos salvavidas reman en dos piraguas por un río tranquilo.]
:::
::::
`; // content.<lang>.md, inlined by the Cookbook

// #region art: nine paintings (JPEGs in assets/), the pictures of the unit
// Painted with a diffusion model in a coloured-pencil and watercolour manner, cut to the shape
// of the frames that show them and declared with their pixels. Nothing is written on them: the
// names on the map are ::label lines of the Markdown, standing at the anchors below.
const ART = { portada: ['portada-1400.jpg', 1400, 1021, 165],
  mapa: ['mapa-1900.jpg', 1900, 730, 122],
  ...Object.fromEntries(['nacimiento', 'arroyo', 'cascada', 'riego', 'molino', 'piraguas',
    'limpieza'].map((id) => [id, [`${id}-720.jpg`, 720, 540, 330]])) };
const resources = await Promise.all(Object.entries(ART).map(async ([id, [fileId, w, h, ppi]]) => {
  await loadImage(fileId, asset(fileId));
  return { id, typeId: 'dibujo', kind: 'bitmap', caption: '', altText: '', createdAt: 0,
    updatedAt: 0, bitmap: { fileId, format: 'jpeg', width: w, height: h, resolution: ppi },
    ...(id === 'mapa' && { anchors: ANCHORS }) };
}));
// The map again with no anchors, small, under the contents.
resources.push({ ...resources.find((r) => r.id === 'mapa'), id: 'rio', anchors: [] });
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { Nunito: ['400', '400i', '700'], Fredoka: ['500', '600', '700'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const doc = await buildDocumentWithFonts({ markdown, resources }, config, kitFonts(FONTS));
showPages(doc, { title: t({ es: 'Nuestro río', en: 'Our river', ca: 'El nostre riu' }) });
offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),
  `${RECIPE}.pdf`);

// ─── 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 v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout (prepareFonts,
// buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the
// files the PDF embeds too.

/** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts:
 *  the Fontsource resolver, and the faces of FONTS loaded with the ones the
 *  config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */
function kitFonts(FONTS = {}) {
  const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) =>
    ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' })));
  return { resolve: fontsourceResolver, faces };
}

/** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek
 *  when `text` needs them and the family ships them (kitSubsetsFor), each with
 *  its unicode-range. Null for a weight or slant Fontsource does not ship. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null;
  kitStatus('Loading fonts…');
  const at = await fontsourceAt(id);
  return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({
    source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`,
    unicodeRange: kitRanges()[subset],
  }));
}

/** The unicode-range of each Fontsource file the kit loads (a function: the
 *  kit is inlined after the pen's code, which runs before a const would be set). */
function kitRanges() {
  return {
    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',
    greek: 'U+0370-03FF',
  };
}

/** For the cjk, arabic and comics blocks, which add their own files: the
 *  faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  The files are added once all have loaded, in the order asked (latin, then
 *  latin-ext, then greek), whatever order they arrive in: the last one added
 *  wins a character two files hold. Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = kitRanges();
  const jobs = [];
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'])
      .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await
    const at = fontsourceAt(id);
    const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null;
    const subsets = ['latin', ...kitSubsetsFor(text, meta)];
    for (const [weight, style] of todo) {
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `${await at}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().catch(() => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return ready.filter((face) => face && document.fonts.add(face)).length;
}

/** A loaded FontFace covers this family, weight and style (fonts.check() would
 *  also say yes 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;
}

/** The files beyond latin `text` needs that `meta`'s family ships. */
function kitSubsetsFor(text, meta) {
  return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']]
    .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x);
}

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

/** A package's folder at the one version @5 names now (#766). */
function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) {
  return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json())
    .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`);
}

/** The family's Fontsource metadata (weights, styles, subsets), 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 v2 ── the same in every recipe · postext.dev/cookbook
/** The pages as spreads on a dark desk, page 1 alone, then verso | recto,
 *  each painted when it scrolls near. Other options go to renderPageToCanvas. */
function showPages(docs, { title, width = 460, ...render } = {}) {
  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, { ...render, 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 · pdf v3 ── the same in every recipe that exports a PDF
/** The Fontsource files the screen used, as TrueType, with their range and
 *  place: the nearest weight the family ships, upright with no italic;
 *  latin, then what its letters need (kitSubsetsFor). */
async function fontsourceProvider(family, weight, style, request) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  const weights = meta?.weights?.length ? meta.weights : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style;
  const text = String.fromCodePoint(...(request?.codePoints ?? []));
  const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => {
    const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`);
    if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`);
    return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())),
      { unicodeRange: kitRanges()[subset], order });
  }));
  return files.length === 1 ? files[0] : files;
}

/** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame
 *  shows no PDFs) and a download link. */
function offerPdf(makePdf, filename) {
  viewer();
  const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' });
  button.dataset.postextPdf = filename;
  button.addEventListener('click', async () => {
    button.disabled = true;
    button.textContent = 'Building the PDF…';
    try {
      const bytes = await makePdf();
      const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
      const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`;
      button.replaceWith(
        Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }),
        Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` }));
    } catch (error) {
      button.disabled = false;
      button.textContent = 'Build the PDF';
      kitFail(error);
    }
  });
  document.getElementById('pt-actions').append(button);
}

// ─── Kit · images v3 ── 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 as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25).
 *  `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */
async function loadSvg(fileId, svg, options) {
  if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces);
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img, options);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** A face's Fontsource files: nearest weight; latin + what the SVG sets. */
async function svgFaces(family, weight, style, { codePoints = [] } = {}) {
  const [id, m] = [fontsourceId(family), await fontsourceMeta(family)];
  const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight;
  const s = m?.styles.includes(style) === false ? 'normal' : style;
  return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array(
    await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer())));
}

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

## アレンジ

### 4つ目の系統を足す

海のテーマなら、`FAMILIES` に4つ目の項目を足し、`topic('tema-mar', 'mar', { frames })` でテンプレートを作ります。`FAMILIES` を回すループが `caja-mar`、`dato-mar` などのスタイルを書きます。

```diff
-  sol: { main: '#9a5209', tint: '#fcefd2', ground: '#f8e6b8' },
+  sol: { main: '#9a5209', tint: '#fcefd2', ground: '#f8e6b8' },
+  mar: { main: '#0f5a6b', tint: '#dcf0f2', ground: '#cbe6ea' },
```

### 本文の大きさを保つ

本文を縮めてはいけないフレームには `fit: { shrinkText: false }` を付けます。長い本文は周りのフレームをより強く押し、手順は絵のほうへ早く進みます。

```diff
-  frames: [title, { name: 'texto', flow: true, columns: 2, columnGap: mm(7), width: mm(HALF),
-    fit: { minScale: 0.9 } }],
+  frames: [title, { name: 'texto', flow: true, columns: 2, columnGap: mm(7), width: mm(HALF),
+    fit: { shrinkText: false } }],
```

## よくあるつまずき

- **目次は構成ページの枠の中には組めない.** :::frame の中に :::toc を書くと、目次は枠の段落として組まれます。toc.parts.design の部の行は描かれず空行になり、各項目の後に文書の段落間隔が入り、目次自身の見出しまで一覧に載ります。目次は流し込みのページとして組んでください。numbered: false、toc: false の見出しスタイルの advancedDesign で見出しを描き、続く本文に :::toc{keep="part"} を置けば、部の行、リーダー、keep の規則が働きます。その設計は段の余白で止まるため、このページの地色は、エンジンが目次を構成ページとして組めるようになるまで待つことになります。

- 絵に描き込んだ名前は言語に合わせて変えられません。地図には文字がなく、地名はすべてラベルです。
- フレームの下に置いた装飾要素は Sandbox でクリックできません。各テーマの番号がタイトルのフレームの中ではなく左にあるのはそのためです。
- 同じリソースを指定する2つの図は、最初の図の言葉を取ります。目次ページの小さな地図は同じファイルを別のリソース `rio` として使い、アンカーを持ちません。

## クレジット

- レシピ: Your Name ([@your-github](https://github.com/your-github))
- テキスト: Our river, a children's textbook unit written for this recipe in Spanish, Catalan and English: Postext Cookbook, CC-BY-4.0
- 画像: The valley of the River Aldara, painted from the mountains to the sea, with two children watching the water.: Generated With Diffusion Models, オリジナル
- 画像: The painted map of the valley, from the spring to the port, with no lettering: the names are labels set from the Markdown.: Generated With Diffusion Models, オリジナル
- 画像: A spring between mossy rocks.: Generated With Diffusion Models, オリジナル
- 画像: A mountain stream over round stones.: Generated With Diffusion Models, オリジナル
- 画像: A small waterfall into a green pool.: Generated With Diffusion Models, オリジナル
- 画像: A farmer opening the gate of an irrigation canal.: Generated With Diffusion Models, オリジナル
- 画像: An old stone watermill with its wheel.: Generated With Diffusion Models, オリジナル
- 画像: Children paddling canoes on a calm river.: Generated With Diffusion Models, オリジナル
- 画像: Children and a teacher picking up litter on a river bank.: Generated With Diffusion Models, オリジナル
- 書体: Nunito (OFL-1.1), Fredoka (OFL-1.1)
- コード: MIT · サンプルの内容: CC-BY-4.0

## 関連レシピ

- [No. 157 · Markdownから地名を入れる学校地図帳](https://postext.dev/ja/cookbook/school-atlas-labelled-maps.md): 文字を1つも含まない、描かれた地図に、リソースのアンカーごとの::label行で地名を入れます。組版ページと見開きで、スペイン語・英語・アラビア語の3版を組みます。 · 難易度 3 (上級) · 教科書
- [No. 159 · 文章に合わせて囲みが伸びるワークブック](https://postext.dev/ja/cookbook/activity-book-growing-boxes.md): ページテンプレートで組む理科のワークブック。スタックの囲みは文章に合わせて伸び、解答罫は空のフレームに引き、収まらない工作は追加ページに続きます。 · 難易度 3 (上級) · ワークブックと練習問題, 教科書
- [No. 158 · 見開きの絵入り年表](https://postext.dev/ja/cookbook/illustrated-timeline-spread.md): 見開きのページテンプレートで教科書の年表を組みます。帯がのどをまたぎ、二列のカードは文章が長くなると幅を分け合います。 · 難易度 3 (上級) · 教科書
