# 文章に合わせて囲みが伸びるワークブック

> ページテンプレートで組む理科のワークブック。スタックの囲みは文章に合わせて伸び、解答罫は空のフレームに引き、収まらない工作は追加ページに続きます。

- HTML版: https://postext.dev/ja/cookbook/activity-book-growing-boxes
- レシピ No. 159 · 囲みと注 · 難易度 3 (上級) · 出力: Canvas, PDF
- ジャンル: ワークブックと練習問題, 教科書
- 必要なもの postext ≥ 1.39.0, postext-pdf ≥ 1.39.0 · テスト環境 1.39.0, postext-pdf 1.39.0 ／テスト日 2026-10-17
- ページ: [1](https://postext.dev/cookbook/activity-book-growing-boxes/en/p01.webp?v=8302cac1), [2](https://postext.dev/cookbook/activity-book-growing-boxes/en/p02.webp?v=8302cac1), [3](https://postext.dev/cookbook/activity-book-growing-boxes/en/p03.webp?v=8302cac1), [4](https://postext.dev/cookbook/activity-book-growing-boxes/en/p04.webp?v=8302cac1), [5](https://postext.dev/cookbook/activity-book-growing-boxes/en/p05.webp?v=8302cac1)
- PDF: https://postext.dev/cookbook/activity-book-growing-boxes/en/activity-book-growing-boxes.pdf?v=8302cac1
- Sandboxで開く: https://postext.dev/ja/sandbox#recipe=activity-book-growing-boxes&lang=en (.postext: https://postext.dev/cookbook/activity-book-growing-boxes/en/activity-book-growing-boxes.postext)
- 最終更新: 2026-10-17
- 他の言語: [en](https://postext.dev/en/cookbook/activity-book-growing-boxes.md), [es](https://postext.dev/es/cookbook/activity-book-growing-boxes.md), [ca](https://postext.dev/ca/cookbook/activity-book-growing-boxes.md), [de](https://postext.dev/de/cookbook/activity-book-growing-boxes.md), [fr](https://postext.dev/fr/cookbook/activity-book-growing-boxes.md), [it](https://postext.dev/it/cookbook/activity-book-growing-boxes.md), [pt](https://postext.dev/pt/cookbook/activity-book-growing-boxes.md), [zh](https://postext.dev/zh/cookbook/activity-book-growing-boxes.md), [ar](https://postext.dev/ar/cookbook/activity-book-growing-boxes.md)

## かんたんな説明

水をテーマにした子ども向け理科ワークブックの5ページ。問題はそれぞれ色の付いた囲みに入り、下に答えを書く罫線があります。問題が長いと囲みが伸び、下のものを押し下げます。長い工作は次のページに続きます。

## できあがり

架空の小学4年生向け理科ワークブック『Water on the move』の単元3から5ページを、210 × 280 mm で組みます。表紙は拡散モデルで生成した水の循環の絵が裁ち落としまで広がり、下端で紙に溶け、その上に5本のベクターの矢印が重なり、その下に 54 pt の書名と名前を書く罫線があります。プリントはどれも同じページテンプレートから組みます。単元名の行、青い帯の見出し、小さな絵を添えた短い本文、番号のタブが付いた水色の活動の囲みが三つ。一つめと三つめの囲みの下には答えを書く罫線があります。囲みの高さは中の文章で決まるので、あちこちで1行ずつ長くなるフランス語版では、テンプレートの位置を何も変えなくても英語版より罫線が下に来ます。工作はペットボトルで雨量計を作るもので、手順が1ページに収まりません。手順の囲みはページの下端で *Continued* を付けて切れ、追加されたページの上で続き、そのあとに観察を書く6本の罫線が続きます。タブや観察欄の見出しを含め、文字はすべて Markdown に書きます。

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

- 活動の囲みを文章に合わせて伸ばし、収まらないものを次のページに続けるには？
- 収まらない活動を「次ページへ続く」の印を付けて次のページに続けるには？
- 記入線、解答欄、語群、チェックリストのあるワークシートのページを作るには？

## 手短な答え

```js
// script.js, 行 49–75
// An empty frame is an answer space: the Markdown sends it nothing (optional: no slotEmpty).
// Its `rules` draw a line at the foot of every LINE mm of its height, so the lines are part
// of the frame and move with it when a box above grows. minHeight too: a box that runs out of
// room may not take the lines away from the child.
const answer = (name, lines) => ({ name, optional: true, width: mm(W), height: mm(LINE * lines),
  minHeight: mm(LINE * lines),
  rules: { every: mm(LINE), color: col('rule'), width: pt(0.75) } });
// A box is as tall as its text, never less than drawn: a longer text makes it taller and
// pushes everything after it in the group down the page.
const box = (name, style, minHeight) => ({ name, style, width: mm(W), minHeight: mm(minHeight) });
const sheet = {
  id: 'ficha',
  decoration: { elements: [kicker] },
  frames: [
    ...opening,
    // The group starts where its first box is placed: under the text, and runs down from there.
    { ...box('act-1', 'actividad', 22), placement: at('#texto', 'below', 0, 6) },
    answer('lineas-1', 3),
    box('act-2', 'actividad', 22),
    // The last box goes on overleaf, with the answer space after it, when its text outgrows
    // the page; the other two push it down instead.
    { ...box('act-3', 'actividad', 22), overflow: { continueIn: 'next-page' } },
    answer('lineas-3', 2),
  ],
  groups: [{ name: 'actividades', kind: 'stack', gap: mm(4.5),
    frames: ['act-1', 'lineas-1', 'act-2', 'act-3', 'lineas-3'] }],
};
```

## 材料

**学べること**

- [グループの中で伸びる枠](https://postext.dev/ja/docs/composed-pages.md#文章が増えたとき): テンプレートページの枠を縦または横に並べるグループ。枠は文章の高さに合わせて伸び（minHeight 未満にはならない）、後ろの枠とそれに固定した要素を押し下げます。
- [次のページに続く枠](https://postext.dev/ja/docs/composed-pages.md#文章が追加ページに続くとき): overflow.continueIn は、枠に収まらない分をテンプレートページの後ろに追加するページ（overflowTemplate から組む）へ送り、切れ目に言語ごとの続きの印を付けます。
- [テンプレートページ](https://postext.dev/ja/docs/composed-pages.md#テンプレートページ): 設定に置いた名前付き枠のテンプレートから組むページ。::::page フェンスの中身が slot 名で各枠に流し込まれます。

**ほかに使うもの**

- [明示的な縦のアキ](https://postext.dev/ja/docs/document-format-directives.md#space)
- [インラインのチップ](https://postext.dev/ja/docs/configuration-styles.md#チップスタイル)
- [囲み](https://postext.dev/ja/docs/configuration-callouts.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-styles.md#見出しスタイル)
- [箇条書きとチェックリスト](https://postext.dev/ja/docs/configuration-lists-math.md#箇条書きリスト)
- [番号付きリスト](https://postext.dev/ja/docs/configuration-lists-math.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-fonts-colors-viewers.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/configuration-page-flow.md#段末そろえ)
- [枠に固定した飾り](https://postext.dev/ja/docs/composed-pages.md#飾り柱レイヤー)
- [見出しの属性](https://postext.dev/ja/docs/document-format.md#見出しの属性)
- [その場に置く図](https://postext.dev/ja/docs/document-format-resources.md#ブロック埋め込み任意本文中への明示的な配置)
- [PDFに埋め込むフォント](https://postext.dev/ja/docs/configuration-pdf-output.md#なぜフォントプロバイダーが必要か)
- [独自のリソースの種類](https://postext.dev/ja/docs/configuration-resources.md#リソースの種類)
- [リソースとしての図と表](https://postext.dev/ja/docs/document-format-resources.md#リソース)
- [連結したテキスト枠](https://postext.dev/ja/docs/composed-pages.md#枠をつなぐ)

**設定の一覧**

- [`bodyText`](https://postext.dev/ja/docs/configuration-text.md#本文), [`calloutStyles`](https://postext.dev/ja/docs/configuration-callouts.md#囲みスタイル), [`chipStyles`](https://postext.dev/ja/docs/configuration-styles.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#見出し), [`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#ページテンプレート), [`resourceTypes`](https://postext.dev/ja/docs/configuration-resources.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の生成)

**書体**

- Andika (OFL-1.1), Baloo 2 (OFL-1.1), Lexend (OFL-1.1)

## 作り方

### 1 · 活動を名前で自分の囲みに流し込む

テンプレートは上の[手短な答え](https://postext.dev/ja/cookbook/activity-book-growing-boxes.md#手短な答え)です。Markdown では、プリント1枚が一つの `::::page` フェンスです。最初の見出しは `takes: 'h1'` のフレームへ、段落はフローのフレームへ、`:::frame{slot="…"}` はそれぞれ同じ名前のフレームへ入り、タブの文字は `label` に書きます。解答のフレームには何も入らないので `optional: true` を付けます。付けないと空のフレームが `slotEmpty` の警告を出します。絵を描く活動は `:::space{lines=3}` で自分の囲みの中に白い場所を取ります。どれだけの場所が要るかは、問題の一部だからです。

```md
:::frame{slot="act-1" label="1 · Read and answer"}
Tom lives in Leeds. The water from his tap comes from a reservoir in the hills…
:::
```

### 2 · 罫線を囲みと一緒にスタックで動かす

囲みにはどれも描いたときの高さ 22 mm を `minHeight` に指定し、それより上は `auto` の高さにします。グループは、本文の下に置いた `act-1` の位置から5つのフレームを 4.5 mm 間隔で縦に並べるので、ある囲みが伸びるとその後ろがすべて下がります。罫線は空のフレームそのものに属します。その `rules` が、フレームの高さ 8 mm ごとの下端、文字の下に1本ずつ線を引くので、罫線はフレームと一緒に動き、解答欄を長くすればフレームが高くなります。解答のフレームには高さと同じ `minHeight` を指定してください。グループの場所が足りなくなると、[リフローの段階](https://postext.dev/ja/docs/composed-pages.md#それでも収まらないとき)はグループのフレームから最小値まで場所を取り戻しますが、空のフレームの最小値はゼロなので、罫線が次の囲みに重なってしまいます。ワークシートの最後の囲み `act-3` は、文章がページに収まらないと次のページへ続き（`overflow.continueIn: 'next-page'`）、その下の解答の罫線も一緒に移ります。

### 3 · 工作を次のページへ続ける

```js
// script.js, 行 79–106
const project = {
  id: 'proyecto',
  overflowTemplate: 'proyecto-sigue',
  decoration: { elements: [kicker] },
  frames: [
    ...opening,
    { ...box('material', 'material', 20), placement: at('#texto', 'below', 0, 9) },
    // As tall as its text, never past the foot of the page, at full size: the rest ends
    // with the language's mark (Continúa, Continued, À suivre) and goes on on an added page.
    { name: 'pasos', style: 'proyecto', width: mm(W), overflow: { continueIn: 'next-page' } },
  ],
  groups: [{ name: 'trabajo', kind: 'stack', gap: mm(9), frames: ['material', 'pasos'] }],
};
// The added page: the rest of the steps at the head, then lines for the child's notes,
// under a heading the page fence names (notas="…").
const projectGoesOn = {
  id: 'proyecto-sigue',
  decoration: { elements: [kicker,
    { kind: 'text', id: 'notas-titulo', content: '{attr.notas}', fontFamily: DISPLAY,
      fontSize: pt(15), fontWeight: 700, color: col('sea'),
      placement: { ...at('#notas', 'top-left', 0, -3), size: { width: mm(W) } } }] },
  frames: [
    { name: 'pasos', style: 'proyecto', width: mm(W),
      placement: at('container', 'top-left', 0, 9) },
    answer('notas', 6),
  ],
  groups: [{ name: 'trabajo', kind: 'stack', gap: mm(16), frames: ['pasos', 'notas'] }],
};
```

手順の囲みには決まった高さがなく、文章に合わせて伸び、版面の下端で止まります。別のページへ続けられるフレームは、文字を縮める前に続きへ送ります。そのため囲みは本来の文字サイズのまま行と行のあいだで切れ、言語ごとの続きの印（`locale` に応じて *Continúa*、*Continued*、*À suivre*）で終わり、`proyecto-sigue` のページの同じ名前のフレームで続きます（[文章が追加ページに続くとき](https://postext.dev/ja/docs/composed-pages.md#文章が追加ページに続くとき)）。追加されたページは囲みの続きの下に観察の罫線を引き、見出しにはフェンスの `notas="What I noticed"` の文字を使います。文字は Markdown に残り、版ごとに変わります。

### 4 · 表紙を裁ち落としまで描く

```js
// script.js, 行 110–131
// and a line for the child's name
const cover = {
  id: 'portada', header: false, footer: false,
  decoration: { elements: [
    { kind: 'image', id: 'cielo', resourceId: 'portada', decorative: true,
      placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(178) } } },
    // The arrows are vector, over the painting: the same frame, so they stay on its features.
    { kind: 'image', id: 'ciclo', resourceId: 'ciclo', decorative: true,
      placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(178) } } },
    { kind: 'rule', id: 'nombre-linea', direction: 'horizontal', thickness: pt(1),
      color: col('sea'), placement: { ...at('#nombre', 'bottom-left', 0, 1),
        size: { width: mm(W) } } },
  ] },
  frames: [
    { name: 'titulo', takes: 'h1', width: mm(W), placement: at('container', 'top-left', 0, 162) },
    { name: 'subtitulo', flow: true, width: mm(W) },
    { name: 'nombre', style: 'campo', width: mm(W), minHeight: mm(12) },
    { name: 'colofon', style: 'colofon', width: mm(W), placement: at('container', 'bottom-left') },
  ],
  groups: [{ name: 'portada', kind: 'stack', gap: mm(4),
    frames: ['titulo', 'subtitulo', 'nombre'] }],
};
```

表紙もテンプレートページです。絵は裁ち落としに固定した装飾で、書名、シリーズ名、名前欄が版面の上から 162 mm の位置で始まるスタックになります。名前の罫はフレームの下端に固定してあるので、別の言語でラベルが2行になっても罫は文字の下にあります。`header: false` と `footer: false` で表紙にはノンブルを付けません。

## レシピの全体

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

- レシピのフォルダー: https://github.com/drnachio/postext/tree/main/cookbook/activity-book-growing-boxes

### script.js

```js
// ═══ Postext Cookbook · Nº 159 · Activity book with boxes that grow ══════════════
// https://postext.dev/en/cookbook/activity-book-growing-boxes
// Code: MIT · Text: original (CC BY 4.0) · Pictures: generated with diffusion models
// Fonts: Andika, Baloo 2, Lexend (SIL OFL 1.1) · Needs postext ≥ 1.39.0
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es' | 'fr')
const RECIPE = 'activity-book-growing-boxes';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: one ink, the water blue, the sun, and the pale fields of the boxes
const palette = {
  ink: '#1f2d38', // text
  sea: '#1d659c', // titles, tabs, list numbers
  sky: '#e2f0f9', // activity boxes
  sun: '#f0a531', // the project's box
  sand: '#fcf0d8', // the materials box
  rule: '#93bcd8', // answer lines, blanks
  paper: '#ffffff',
};
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: 'sea (defaults)', value: { hex: palette.sea, model: 'hex' } },
];
// #endregion

const [TEXT, DISPLAY, LABEL] = ['Andika', 'Baloo 2', 'Lexend']; // Andika: for early readers
const W = 174; // mm: the width of the type area, and of every box
const LINE = 8; // mm between two answer lines: room for a child's handwriting
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });

// The head every sheet shares: the unit over the title band, the text with its drawing.
const kicker = { kind: 'text', id: 'tema', content: '{attr.tema}', fontFamily: LABEL,
  fontSize: pt(8.5), fontWeight: 600, color: col('sea'), textTransform: 'uppercase',
  letterSpacing: pt(1.4), placement: at('container', 'top-left') };
const opening = [
  { name: 'titulo', takes: 'h1', style: 'banda', width: mm(W),
    placement: at('container', 'top-left', 0, 6) },
  { name: 'texto', flow: true, width: mm(W - 52), minHeight: mm(36),
    placement: at('#titulo', 'below', 0, 4) },
  { name: 'dibujo', width: mm(44), placement: at('#titulo', 'below', W - 44, 4) },
];

// #region answer: boxes in a stack grow with their text; empty ruled frames hold the answers
// An empty frame is an answer space: the Markdown sends it nothing (optional: no slotEmpty).
// Its `rules` draw a line at the foot of every LINE mm of its height, so the lines are part
// of the frame and move with it when a box above grows. minHeight too: a box that runs out of
// room may not take the lines away from the child.
const answer = (name, lines) => ({ name, optional: true, width: mm(W), height: mm(LINE * lines),
  minHeight: mm(LINE * lines),
  rules: { every: mm(LINE), color: col('rule'), width: pt(0.75) } });
// A box is as tall as its text, never less than drawn: a longer text makes it taller and
// pushes everything after it in the group down the page.
const box = (name, style, minHeight) => ({ name, style, width: mm(W), minHeight: mm(minHeight) });
const sheet = {
  id: 'ficha',
  decoration: { elements: [kicker] },
  frames: [
    ...opening,
    // The group starts where its first box is placed: under the text, and runs down from there.
    { ...box('act-1', 'actividad', 22), placement: at('#texto', 'below', 0, 6) },
    answer('lineas-1', 3),
    box('act-2', 'actividad', 22),
    // The last box goes on overleaf, with the answer space after it, when its text outgrows
    // the page; the other two push it down instead.
    { ...box('act-3', 'actividad', 22), overflow: { continueIn: 'next-page' } },
    answer('lineas-3', 2),
  ],
  groups: [{ name: 'actividades', kind: 'stack', gap: mm(4.5),
    frames: ['act-1', 'lineas-1', 'act-2', 'act-3', 'lineas-3'] }],
};
// #endregion

// #region continue: the project's steps go on on a page of their own, after their mark
const project = {
  id: 'proyecto',
  overflowTemplate: 'proyecto-sigue',
  decoration: { elements: [kicker] },
  frames: [
    ...opening,
    { ...box('material', 'material', 20), placement: at('#texto', 'below', 0, 9) },
    // As tall as its text, never past the foot of the page, at full size: the rest ends
    // with the language's mark (Continúa, Continued, À suivre) and goes on on an added page.
    { name: 'pasos', style: 'proyecto', width: mm(W), overflow: { continueIn: 'next-page' } },
  ],
  groups: [{ name: 'trabajo', kind: 'stack', gap: mm(9), frames: ['material', 'pasos'] }],
};
// The added page: the rest of the steps at the head, then lines for the child's notes,
// under a heading the page fence names (notas="…").
const projectGoesOn = {
  id: 'proyecto-sigue',
  decoration: { elements: [kicker,
    { kind: 'text', id: 'notas-titulo', content: '{attr.notas}', fontFamily: DISPLAY,
      fontSize: pt(15), fontWeight: 700, color: col('sea'),
      placement: { ...at('#notas', 'top-left', 0, -3), size: { width: mm(W) } } }] },
  frames: [
    { name: 'pasos', style: 'proyecto', width: mm(W),
      placement: at('container', 'top-left', 0, 9) },
    answer('notas', 6),
  ],
  groups: [{ name: 'trabajo', kind: 'stack', gap: mm(16), frames: ['pasos', 'notas'] }],
};
// #endregion

// #region cover: a painted landscape to the bleed with the cycle's arrows over it, the title,
// and a line for the child's name
const cover = {
  id: 'portada', header: false, footer: false,
  decoration: { elements: [
    { kind: 'image', id: 'cielo', resourceId: 'portada', decorative: true,
      placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(178) } } },
    // The arrows are vector, over the painting: the same frame, so they stay on its features.
    { kind: 'image', id: 'ciclo', resourceId: 'ciclo', decorative: true,
      placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(178) } } },
    { kind: 'rule', id: 'nombre-linea', direction: 'horizontal', thickness: pt(1),
      color: col('sea'), placement: { ...at('#nombre', 'bottom-left', 0, 1),
        size: { width: mm(W) } } },
  ] },
  frames: [
    { name: 'titulo', takes: 'h1', width: mm(W), placement: at('container', 'top-left', 0, 162) },
    { name: 'subtitulo', flow: true, width: mm(W) },
    { name: 'nombre', style: 'campo', width: mm(W), minHeight: mm(12) },
    { name: 'colofon', style: 'colofon', width: mm(W), placement: at('container', 'bottom-left') },
  ],
  groups: [{ name: 'portada', kind: 'stack', gap: mm(4),
    frames: ['titulo', 'subtitulo', 'nombre'] }],
};
// #endregion

// A box with a tab: the tab's words come from the Markdown (label="…").
const tabbed = (id, fill, tab, tabInk, extra = {}) => ({
  id, backgroundEnabled: true, background: col(fill), borderRadius: mm(3),
  border: { enabled: false },
  padding: { top: mm(3), right: mm(5), bottom: mm(3.5), left: mm(5) },
  label: { position: 'top-left', background: col(tab), color: col(tabInk), fontFamily: LABEL,
    fontSize: pt(9), fontWeight: 600, height: mm(6), paddingX: mm(3), offset: mm(6),
    inset: mm(4), rule: { enabled: false, color: col(tab) } },
  body: { fontFamily: TEXT, fontSize: pt(11.5), lineHeight: pt(16), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: pt(0) },
  ...extra,
});
const plain = (id, size, lead, color, extra = {}) => ({ id, backgroundEnabled: false,
  border: { enabled: false }, padding: { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) },
  body: { fontFamily: LABEL, fontSize: pt(size), lineHeight: pt(lead), color: col(color),
    boldColor: col(color), italicColor: col(color), firstLineIndent: pt(0) }, ...extra });

const folio = (id, parity, edge, x) => ({ kind: 'text', id, content: '{pageNumber}', parity,
  fontFamily: DISPLAY, fontSize: pt(11), fontWeight: 800, color: col('paper'),
  align: 'center', box: { backgroundColor: col('sea'), borderRadius: mm(4) },
  placement: { ...at('page', edge, x, -9), size: { width: mm(8), height: mm(8) } } });
const bookTitle = (id, parity, edge, x) => ({ kind: 'text', id, content: '{title} · {subtitle}',
  parity, fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 500, color: col('sea'),
  textTransform: 'uppercase', letterSpacing: pt(1.1), placement: at('page', edge, x, -11) });

const config = {
  locale: t({ es: 'es', en: 'en-gb', fr: 'fr' }), // hyphenation and the continuation mark
  resourceTypes: [{ id: 'dibujo', name: t({ es: 'Dibujo', en: 'Drawing', fr: 'Dessin' }),
    shortLabel: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal',
    captionPrefix: '', defaultPlacement: { position: 'here' } }],
  colorPalette,
  page: {
    sizePreset: 'custom', width: mm(210), height: mm(280), dpi: 150,
    margins: { top: mm(20), bottom: mm(22), left: mm(20), right: mm(16), mirror: true },
  },
  layout: { layoutType: 'single' },
  bodyText: {
    fontFamily: TEXT, fontSize: pt(11.5), lineHeight: pt(16), 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, color: col('sea'), fontWeight: 700,
    balancing: { enabled: false },
    levels: [
      // Every sheet is a composed page, which starts a page of its own.
      { level: 1, fontSize: pt(25), lineHeight: pt(30), fontWeight: 800, color: col('paper'),
        breakBefore: { enabled: false }, marginTop: pt(0), marginBottom: pt(0) },
      { level: 3, fontSize: pt(13.5), lineHeight: pt(17), marginTop: pt(6), marginBottom: pt(2) },
    ],
  },
  headingStyles: [{ id: 'portada', fontSize: pt(54), lineHeight: pt(54), fontWeight: 800,
    color: col('sea'), marginTop: pt(0), marginBottom: pt(0) }],
  unorderedLists: { bulletChar: '•', color: col('sea'), fontWeight: 700, itemSpacing: pt(3) },
  orderedLists: { numberFormat: 'arabic', fontFamily: DISPLAY, fontWeight: 800, color: col('sea'),
    itemSpacing: pt(4) },
  chipStyles: [
    // An empty chip, :chip[ ]{style="hueco"}, is a blank to write in: padding and an outline.
    { id: 'hueco', fontFamily: LABEL, background: col('paper'), borderColor: col('rule'),
      borderWidth: pt(0.9), borderRadius: mm(1.4), paddingX: mm(13), paddingY: em(0.1) },
    { id: 'casilla', fontFamily: LABEL, background: col('paper'), borderColor: col('sea'),
      borderWidth: pt(0.9), borderRadius: mm(1), paddingX: em(0.55), paddingY: em(0.05),
      gap: em(0.5) },
  ],
  calloutStyles: [
    tabbed('actividad', 'sky', 'sea', 'paper'),
    tabbed('material', 'sand', 'sun', 'ink'),
    tabbed('proyecto', 'paper', 'sun', 'ink',
      { border: { enabled: true, color: col('sun'), width: pt(1.5) } }),
    { ...plain('banda', 25, 30, 'paper'), backgroundEnabled: true, background: col('sea'),
      borderRadius: mm(3), padding: { top: mm(2.5), right: mm(5), bottom: mm(3), left: mm(5) } },
    plain('campo', 10, 14, 'sea', { padding: { top: mm(5), right: mm(0), bottom: mm(0),
      left: mm(0) } }),
    plain('colofon', 7.5, 10, 'ink'),
  ],
  header: { elements: [] },
  footer: { elements: [
    folio('folio-par', 'even', 'bottom-left', 20),
    bookTitle('titulo-par', 'even', 'bottom-left', 32),
    folio('folio-impar', 'odd', 'bottom-right', -20),
    bookTitle('titulo-impar', 'odd', 'bottom-right', -32),
  ] },
  pageTemplates: [cover, sheet, project, projectGoesOn],
};

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Water on the move"
subtitle: "Activity book"
---

::::page{template="portada"}
# Water on the move {style="portada"}

Science activity book · Year 4 · Unit 3

:::frame{slot="nombre"}
Name
:::

:::frame{slot="colofon"}
Set in Andika, Baloo 2 and Lexend (SIL OFL) · Original text (CC BY 4.0) · Illustrations generated with diffusion models
:::
::::

::::page{template="ficha" tema="Unit 3 · Sheet 1"}
# Where is the water?

Almost three quarters of the Earth is covered by water. Most of it is **salt water**, in the seas and oceans. **Fresh water**, the kind we drink, is only a small part: it runs in rivers, rests in lakes, hides under the ground or lies frozen at the poles.

::resource{id="planeta" slot="dibujo"}

:::frame{slot="act-1" label="1 · Read and answer"}
Tom lives in Leeds. The water from his tap comes from a reservoir in the hills, goes through a treatment works and travels through pipes to his house. Where does the water from your tap come from? Why do you think it has to be cleaned before we drink it?
:::

:::frame{slot="act-2" label="2 · Fill in the gaps"}
- The water in the seas and oceans is :chip[ ]{style="hueco"} and we cannot drink it.
- Rivers, lakes and the ground below us hold :chip[ ]{style="hueco"} water.
- At the poles, nearly all the water is frozen into :chip[ ]{style="hueco"}.
:::

:::frame{slot="act-3" label="3 · Draw and write"}
Draw a place near your home where there is water: a fountain, a river, a reservoir or the beach. Then write its name on the lines and what people use it for.

:::space{lines=3}
:::
::::

::::page{template="ficha" tema="Unit 3 · Sheet 2"}
# Solid, liquid and gas

Water changes state when it gets warmer or colder. Below zero degrees it turns into **ice**, which is a solid. When ice warms up it melts and becomes a **liquid** again. When water boils it turns into **steam**, a gas we cannot see.

::resource{id="estados" slot="dibujo"}

:::frame{slot="act-1" label="1 · Observe"}
Put an ice cube on a plate and leave it on the table. Look at it every ten minutes for half an hour. What happens to the ice cube? What is on the plate at the end? Write down why you think this happens.
:::

:::frame{slot="act-2" label="2 · Tick the true sentences"}
- :chip[ ]{style="casilla"} Ice is water in its solid state.
- :chip[ ]{style="casilla"} You can pick up steam in your hand.
- :chip[ ]{style="casilla"} A puddle dries up because the sun makes the water evaporate.
- :chip[ ]{style="casilla"} Water in the freezer turns into steam.
:::

:::frame{slot="act-3" label="3 · Explain"}
When you cook pasta, steam rises from the pan. If you hold a cold lid over it, the lid fills with tiny drops. Where do those drops come from?
:::
::::

::::page{template="proyecto" tema="Unit 3 · Project" notas="What I noticed"}
# Make a rain gauge

A rain gauge measures how much rain falls in one place. Weather stations use very precise instruments, but you can make one from a bottle and keep a record of the rain for a whole week.

::resource{id="pluviometro" slot="dibujo"}

:::frame{slot="material" label="You will need"}
- A clean, empty two-litre plastic bottle.
- Scissors, a 20-centimetre ruler and sticky tape.
- A permanent marker and a handful of small stones.
:::

:::frame{slot="pasos" label="Step by step"}
1. Ask an adult to cut the bottle where it starts to get narrower. Keep the top part: it will be your funnel.
2. Put the stones in the bottom. They stop the bottle blowing over in the wind.
3. Pour in water until the stones are covered and mark that level with the marker. That line is the zero of your rain gauge.
4. Tape the ruler to the outside of the bottle, so that the zero on the ruler sits exactly on the line you marked.
5. Turn the top part upside down and push it into the bottle, neck first. The funnel collects the rain and slows down evaporation.
6. Find an open spot in the playground or the garden, away from trees and roofs, which keep the rain off.
7. Every day at the same time, read how many millimetres the water has risen above the zero and write it in your record. Then pour the water out down to the zero line.

### My record for the week

- Monday: :chip[ ]{style="hueco"} millimetres
- Tuesday: :chip[ ]{style="hueco"} millimetres
- Wednesday: :chip[ ]{style="hueco"} millimetres
- Thursday: :chip[ ]{style="hueco"} millimetres
- Friday: :chip[ ]{style="hueco"} millimetres

### Think

Which day had the most rain? Add up the five days: how many millimetres fell in total? Compare your result with a classmate who put their rain gauge somewhere else.
:::
::::
`; // content.<lang>.md, inlined by the Cookbook

// #region art: a painted cover and three vignettes; only the cycle's arrows are vector
// The pictures were painted with a diffusion model on white (the cover on a white edge it fades
// into), cut to the frames' aspect and declared with their pixels. The cover's arrows are an
// SVG in the same frame as the painting, 210 × 178 mm, with their points read off the painting
// in percent of its width and height: the sea's mist, the clouds, the rain, the river.
const P = palette;
const ARROW_X = 2.1; // mm per percent of the cover's width
const ARROW_Y = 1.78; // mm per percent of its height
// A smooth stroke through the points, with the head turned to the last stretch.
const arrow = (...pts) => {
  const p = pts.map(([x, y]) => [x * ARROW_X, y * ARROW_Y]);
  let d = `M${p[0][0].toFixed(1)} ${p[0][1].toFixed(1)}`;
  for (let i = 0; i < p.length - 1; i++) {
    const [a, b] = [p[Math.max(i - 1, 0)], p[i + 2] ?? p[i + 1]];
    const c1 = [p[i][0] + (p[i + 1][0] - a[0]) / 6, p[i][1] + (p[i + 1][1] - a[1]) / 6];
    const c2 = [p[i + 1][0] - (b[0] - p[i][0]) / 6, p[i + 1][1] - (b[1] - p[i][1]) / 6];
    d += ` C${c1.map((v) => v.toFixed(1)).join(' ')} ${c2.map((v) => v.toFixed(1)).join(' ')} `
      + `${p[i + 1][0].toFixed(1)} ${p[i + 1][1].toFixed(1)}`;
  }
  const [t, e] = [p[p.length - 2], p[p.length - 1]];
  const ang = Math.atan2(e[1] - t[1], e[0] - t[0]);
  const tip = (back, side) => `${(e[0] + back * Math.cos(ang) - side * Math.sin(ang)).toFixed(1)},`
    + `${(e[1] + back * Math.sin(ang) + side * Math.cos(ang)).toFixed(1)}`;
  const head = `${tip(3.4, 0)} ${tip(-1, 2.5)} ${tip(-1, -2.5)}`;
  return `<path d="${d}" fill="none" stroke="${P.sea}" stroke-width="2.2" stroke-linecap="round"/>`
    + `<polygon points="${head}" fill="${P.sea}" stroke="${P.sea}" stroke-width="1.4" `
    + 'stroke-linejoin="round"/>'
    + `<path d="${d}" fill="none" stroke="${P.paper}" stroke-width="1.1" stroke-linecap="round"/>`
    + `<polygon points="${head}" fill="${P.paper}"/>`;
};
const CYCLE = [
  [84, 54, 81, 42, 82, 30], // evaporation: the mist rises off the sea
  [93, 54, 95, 42, 92, 31],
  [64, 21, 54, 17, 45, 12], // the clouds drift inland to the mountains
  [29, 8, 28, 14, 27, 20], // precipitation: rain on the peaks
  [22, 43.6, 26, 45.3, 30, 47.8], // run-off: the river down from the waterfall
  [37, 57.5, 46, 59.3, 55, 60.8], // and out across the plain to the sea
];
const cycleSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 210 178" width="2100" `
  + `height="1780">${CYCLE.map((a) => arrow(...Array.from({ length: a.length / 2 },
    (_, i) => [a[2 * i], a[2 * i + 1]]))).join('')}</svg>`;
await loadSvg('ciclo.svg', cycleSvg);

const ALT = {
  portada: t({
    es: 'El ciclo del agua en un paisaje: el sol calienta el mar, el vapor sube a las nubes, '
      + 'la lluvia cae sobre las montañas nevadas y el río baja de nuevo al mar',
    en: 'The water cycle in a landscape: the sun warms the sea, vapour rises to the clouds, '
      + 'rain falls on the snowy mountains and the river runs back down to the sea',
    fr: 'Le cycle de l’eau dans un paysage : le soleil chauffe la mer, la vapeur monte aux '
      + 'nuages, la pluie tombe sur les montagnes enneigées et la rivière redescend vers la mer' }),
  ciclo: t({ es: 'Flechas: el vapor sube del mar, las nubes van hacia la montaña, la lluvia cae '
      + 'y el río vuelve al mar',
    en: 'Arrows: vapour rises from the sea, the clouds drift to the mountains, rain falls and '
      + 'the river returns to the sea',
    fr: 'Flèches : la vapeur monte de la mer, les nuages vont vers la montagne, la pluie tombe '
      + 'et la rivière retourne à la mer' }),
  planeta: t({ es: 'La Tierra vista desde el espacio, casi toda azul, y una gota de agua',
    en: 'The Earth seen from space, mostly blue, and a drop of water',
    fr: 'La Terre vue de l’espace, presque toute bleue, et une goutte d’eau' }),
  estados: t({ es: 'Un cubito de hielo que se derrite, un vaso de agua y una taza que echa vapor',
    en: 'An ice cube melting, a glass of water and a steaming cup',
    fr: 'Un glaçon qui fond, un verre d’eau et une tasse qui fume' }),
  pluviometro: t({ es: 'Una botella cortada con una regla pegada, piedras y un embudo bajo '
      + 'la lluvia: el pluviómetro',
    en: 'A cut bottle with a ruler taped on, stones and a funnel in the rain: '
      + 'the rain gauge',
    fr: 'Une bouteille coupée avec une règle collée, des cailloux et un entonnoir sous la pluie : '
      + 'le pluviomètre' }),
};
// Bitmaps: file, pixels and ppi (the vignettes are 44 mm wide, the cover 210 mm).
const BITMAPS = {
  portada: ['portada-1440.jpg', 1440, 1221, 174],
  planeta: ['planeta-960.jpg', 960, 873, 554],
  estados: ['estados-960.jpg', 960, 873, 554],
  pluviometro: ['pluviometro-960.jpg', 960, 873, 554],
};
const resources = [
  ...(await Promise.all(Object.entries(BITMAPS).map(async ([id, [fileId, w, h, ppi]]) => {
    await loadImage(fileId, asset(fileId));
    return { id, typeId: 'dibujo', kind: 'bitmap', caption: '', altText: ALT[id], createdAt: 0,
      updatedAt: 0, bitmap: { fileId, format: 'jpeg', width: w, height: h, resolution: ppi } };
  }))),
  { id: 'ciclo', typeId: 'dibujo', kind: 'svg', caption: '', altText: ALT.ciclo, createdAt: 0,
    updatedAt: 0, svg: { fileId: 'ciclo.svg', width: 2100, height: 1780 } },
];
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // text, display and label faces, loaded before the build
  Andika: ['400', '400i', '700'], 'Baloo 2': ['700', '800'], Lexend: ['500', '600'],
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const doc = await buildDocumentWithFonts({ markdown, resources }, config, kitFonts(FONTS));
showPages(doc, { title: t({ es: 'El agua, de viaje', en: 'Water on the move',
  fr: 'L’eau en voyage' }) });
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 ───────────────────────────────────────────────────────────────────────
```

## アレンジ

### 活動の罫線を増やす

罫線の本数は解答のフレームの高さで決まります。`rules` が入るだけの線を引くので、テンプレートでこの値を変えれば、それを使うすべてのワークシートにその本数の罫線が付きます。

```diff
-    answer('lineas-1', 3),
+    answer('lineas-1', 5),
```

### 続ける前に手順を縮めてみる

別のページへ続けられるフレームは、文字を縮める前に続きへ送ります。`fit.preferContinue: false` を指定すると、段階はまず手順を `fit.minScale`（90 %）まで小さく組み、それでも足りないときだけ別のページへ送ります。子どもの本では、1ページ減らすことより文字の大きさのほうが大事なので、このレシピは既定のままにしています。

```diff
-      overflow: { continueIn: 'next-page' } },
+      fit: { preferContinue: false }, overflow: { continueIn: 'next-page' } },
```

## よくあるつまずき

- 何も入らないフレームは、`optional: true` を付けないと `slotEmpty` を出します。ここでは解答のフレームすべてに付けています。
- スタックでは囲みを置くのはグループで、囲み自身の `placement` は使われません。ただし最初の一つは例外です。グループの最初の囲みの位置を決めておかないと、スタックは版面の上端から始まり、見出しに重なります。

## クレジット

- レシピ: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- 画像: The water cycle in a landscape: snowy mountains, a river, a coast and the sea, under sun and rain clouds.: Generated With Diffusion Models, オリジナル
- 画像: The Earth seen from space, with a drop of water.: Generated With Diffusion Models, オリジナル
- 画像: An ice cube melting, a glass of water and a steaming cup.: Generated With Diffusion Models, オリジナル
- 画像: A rain gauge made from a cut bottle, with a ruler and stones.: Generated With Diffusion Models, オリジナル
- 書体: Andika (OFL-1.1), Baloo 2 (OFL-1.1), Lexend (OFL-1.1)
- コード: MIT · サンプルの内容: CC-BY-4.0

## 関連レシピ

- [No. 022 · 解答欄と語群を備えたワークシート](https://postext.dev/ja/cookbook/worksheet-answer-boxes.md): 4ページの理科のワークシート。淡い緑のカードに白い解答欄を入れ、各問いの2 mm下にグリッドから外して置き、語群と空欄はチップで作ります。 · 難易度 2 (中級) · ワークブックと練習問題
- [No. 049 · 解答欄付きの試験問題](https://postext.dev/ja/cookbook/exam-paper.md): USレター判4ページの歴史の試験。マーク欄と配点はチップで描いて配点はタブ位置にそろえ、設問は1、a)、i)と番号付けし、解答用の罫線は表で引きます。 · 難易度 2 (中級) · ワークブックと練習問題
- [No. 060 · 音節チップで組む幼児向けの読み方教本](https://postext.dev/ja/cookbook/reading-primer-syllables.md): 読み方教本から、文字Mの単元。単語の音をひとつずつ、母音ごとの色のチップに入れ、絵のついた単語を、マスごとに水彩画の入ったグリッドに並べます。 · 難易度 2 (中級) · ワークブックと練習問題
