# 解答欄と語群を備えたワークシート

> 4ページの理科のワークシート。淡い緑のカードに白い解答欄を入れ、各問いの2 mm下にグリッドから外して置き、語群と空欄はチップで作ります。

- HTML版: https://postext.dev/ja/cookbook/worksheet-answer-boxes
- レシピ No. 022 · 囲みと注 · 難易度 2 (中級) · 出力: Canvas
- ジャンル: ワークブックと練習問題
- 必要なもの postext ≥ 1.4.1 · テスト環境 1.9.2 ／テスト日 2026-10-01
- ページ: [1](https://postext.dev/cookbook/worksheet-answer-boxes/en/p01.webp?v=2633c157), [2](https://postext.dev/cookbook/worksheet-answer-boxes/en/p02.webp?v=2633c157), [3](https://postext.dev/cookbook/worksheet-answer-boxes/en/p03.webp?v=2633c157), [4](https://postext.dev/cookbook/worksheet-answer-boxes/en/p04.webp?v=2633c157)
- Sandboxで開く: https://postext.dev/ja/sandbox#recipe=worksheet-answer-boxes&lang=en (.postext: https://postext.dev/cookbook/worksheet-answer-boxes/en/worksheet-answer-boxes.postext)
- 最終更新: 2026-09-25
- 他の言語: [en](https://postext.dev/en/cookbook/worksheet-answer-boxes.md), [es](https://postext.dev/es/cookbook/worksheet-answer-boxes.md), [ca](https://postext.dev/ca/cookbook/worksheet-answer-boxes.md), [zh](https://postext.dev/zh/cookbook/worksheet-answer-boxes.md), [ar](https://postext.dev/ar/cookbook/worksheet-answer-boxes.md)

## かんたんな説明

8歳の子ども向けの、植物のつくりを学ぶ理科のワークシートです。問いごとに下に答えを書く欄があり、一部の文には、語のリストから選んで埋める空欄があります。

## できあがり

8歳の子ども向けの架空のシリーズ『Seedlings Science』のワークシート3Aを、200 × 260 mmの4ページに片面刷りで組みます。若葉色の帯には、黄色い単元のピル、タイトル、名前と日付を書く白い欄が載ります。その横では、白い円に入った水彩のエンドウの6つの部位に黄色で番号が振られています。この水彩画は拡散モデルで生成したもので、最初から円に収めた状態で、帯の端で接する2枚のJPEGとして用意しています。上の1枚は四隅が帯の緑、下の1枚は白です。番号と引出線は画像の上にコードで描きます。どの活動も、淡い緑のカードの上に番号付きの見出しを立てます。カードには、小さな灰色の促しの言葉と数行分の余白を持つ白い解答欄、黄色いチップの語群、文の中の枠線の空欄、食べ物と植物の部位を結ぶ表、色を塗る丸のチェックリストが入ります。各解答欄は問いの2 mm下、次の要素の5 mm上に置き、どのカードもそのページで次に来るものの9 mm上で終わります。本文は、SILが文字を学ぶ子どものために設計したAndikaで組みます。

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

- 活動カードの中に解答欄を正確な間隔で入れ子にし、語群と空欄を加えるには？
- 記入線、解答欄、語群、チェックリストのあるワークシートのページを作るには？
- キーボードのキー、タグ、練習問題の語群などのインラインのチップを作るには？
- 表のセルに画像やアイコンを入れるには？

## 手短な答え

```js
// script.js, 行 24–56
// In the Markdown a box inside a box is a fence inside a fence (each ::: closes one):
//   :::callout{type="card"}
//   What is inside the pea pods?
//   :::callout{type="answer"}
//   Write here               ← a prompt first: a box of :::space alone collapses
//   :::space{lines=3}           (gotcha: space-dropped-box-top)
//   :::
//   :::
const [ASK, AFTER, HEAD, NEXT] = [2, 5, 3, 9]; // mm: question→box, box→next, heading→card
const card = {
  id: 'card', background: col('tint'), borderRadius: mm(4),
  padding: { top: mm(4), right: mm(5), bottom: mm(5), left: mm(5) },
  marginTop: mm(HEAD), marginBottom: mm(NEXT), // NEXT: under a card, to whatever follows
  // Off the grid, or what follows a card drops to the next grid line. Only the outer card can
  // set it: a nested box ignores snapToGrid, span and placement (gotcha: nested-callout-limits).
  snapToGrid: false,
  body: { paragraphSpacing: false }, // the boxes' margins do all the spacing
  lists: { bulletChar: '', // no bullet: in the checklist a circle chip opens each item
    gap: mm(2), itemSpacing: pt(7) }, columnGap: mm(6),
};
// A nested box's margins collapse with its neighbours', the larger winning (ASK, AFTER); the
// last box's marginBottom falls inside the card's padding instead of adding to it.
const nested = (id, look) => ({ id, background: col('paper'), borderRadius: mm(2.5),
  marginTop: mm(ASK), marginBottom: mm(AFTER), ...look });
const answer = nested('answer', { border: { enabled: true, color: col('rule'), width: pt(0.75) },
  padding: { top: mm(1.6), right: mm(3), bottom: mm(2), left: mm(3) },
  // A small grey prompt on a full 17 pt line; :::space counts in these body lines.
  body: { fontFamily: LABEL, fontSize: pt(8), color: col('muted') } });
const bank = nested('bank', { title: t({ en: 'Word bank', es: 'Banco de palabras' }),
  padding: { top: mm(2.2), right: mm(3), bottom: mm(2.4), left: mm(3) },
  titleStyle: { fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600, color: col('leaf'),
    textTransform: 'uppercase', letterSpacing: pt(1.3), gap: mm(1.6) } });
// config() plugs in all three: calloutStyles: [card, answer, bank].
```

## 材料

**学べること**

- [入れ子の囲み](https://postext.dev/ja/docs/configuration.md#calloutコンテナー): 囲みの中の囲みです。それぞれ独自のスタイルを持ち、正確な間隔で積み重なります。解答欄付きのワークシートのカードなどに使います。
- [意図してグリッドを外す](https://postext.dev/ja/docs/architecture.md#グリッドを崩す要素): 見出し、段落スタイル、積み重ねた囲みは、グリッドから外れた正確な間隔を保てます。その後の本文はグリッドに戻ります。
- [インラインのチップ](https://postext.dev/ja/docs/configuration.md#チップスタイル): 語を囲む角の丸い枠です。1つの単位として行を移り、伸縮しません。キーキャップ、タグ、語群、音節などに使います。

**ほかに使うもの**

- [囲み](https://postext.dev/ja/docs/configuration.md#囲みスタイル)
- [囲みの中の段組み](https://postext.dev/ja/docs/document-format.md#columns)
- [明示的な縦のアキ](https://postext.dev/ja/docs/document-format.md#space)
- [見出しスタイル](https://postext.dev/ja/docs/configuration.md#見出しスタイル)
- [番号付き見出し](https://postext.dev/ja/docs/configuration.md#レベルごとの上書き)
- [番号のない章](https://postext.dev/ja/docs/configuration.md#見出しスタイル)
- [デザインした章扉](https://postext.dev/ja/docs/configuration.md#幅と詳細デザイン)
- [ページデザインのテキスト・罫・ボックス](https://postext.dev/ja/docs/configuration.md#柱とノンブル)
- [ページデザインの画像](https://postext.dev/ja/docs/configuration.md#画像要素)
- [見出しの属性](https://postext.dev/ja/docs/document-format.md#見出しの属性)
- [タイトル内の改行](https://postext.dev/ja/docs/document-format.md#タイトル内の改行)
- [名前付きの表スタイル](https://postext.dev/ja/docs/configuration.md#名前付きの表スタイル)
- [表のセル内の画像](https://postext.dev/ja/docs/document-format.md#ブロック埋め込み任意本文中への明示的な配置)
- [独自のリソースの種類](https://postext.dev/ja/docs/configuration.md#リソースの種類)
- [その場に置く図](https://postext.dev/ja/docs/document-format.md#ブロック埋め込み任意本文中への明示的な配置)
- [箇条書きとチェックリスト](https://postext.dev/ja/docs/configuration.md#箇条書きリスト)
- [番号付きリスト](https://postext.dev/ja/docs/configuration.md#番号付きリスト)
- [段末そろえ](https://postext.dev/ja/docs/configuration.md#段末そろえ)
- [段抜きの章見出し帯](https://postext.dev/ja/docs/configuration.md#幅と詳細デザイン)
- [段落スタイル](https://postext.dev/ja/docs/configuration.md#段落スタイル)
- [リソースとしての図と表](https://postext.dev/ja/docs/document-format.md#リソース)

**設定の一覧**

- [`bodyText`](https://postext.dev/ja/docs/configuration.md#本文), [`calloutStyles`](https://postext.dev/ja/docs/configuration.md#囲みスタイル), [`chipStyles`](https://postext.dev/ja/docs/configuration.md#チップスタイル), [`colorPalette`](https://postext.dev/ja/docs/configuration.md#カラーパレット), [`footer`](https://postext.dev/ja/docs/configuration.md#柱とノンブル), [`header`](https://postext.dev/ja/docs/configuration.md#柱とノンブル), [`headingStyles`](https://postext.dev/ja/docs/configuration.md#見出しスタイル), [`headings`](https://postext.dev/ja/docs/configuration.md#見出し), [`layout`](https://postext.dev/ja/docs/configuration.md#レイアウト), [`locale`](https://postext.dev/ja/docs/configuration.md#ハイフネーション), [`orderedLists`](https://postext.dev/ja/docs/configuration.md#番号付きリスト), [`page`](https://postext.dev/ja/docs/configuration.md#ページ), [`paragraphStyles`](https://postext.dev/ja/docs/configuration.md#段落スタイル), [`resourceTypes`](https://postext.dev/ja/docs/configuration.md#リソースの種類), [`tableStyles`](https://postext.dev/ja/docs/configuration.md#名前付きの表スタイル)

**API**

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

**書体**

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

## 作り方

### 1 · 見出しもグリッドから外す

```js
// script.js, 行 160–168
  headings: { fontFamily: DISPLAY, snapToGrid: false, // or what follows snaps back to the grid
    balancing: { enabled: false }, // no extra space above headings to fill out a page
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
        marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener() },
      { level: 2, numberingTemplate: '{2}', marginTop: mm(0), marginBottom: mm(0),
        advancedDesign: activity },
    ] },
```

手短な答えでは、各囲みのマージンが隣のマージンと相殺され、大きいほうが採用されます。語群の5 mmはその下のリストの上の2 mmに勝ち、最後の解答欄の5 mmは、カードの下パディング5 mmに足されずにその中に収まります。グリッドに乗せたままだと、レベル2の見出しは円の8.6 mmではなく10.8～14.1 mmを確保します。見出しの後に続くものが次の17 ptの行に吸着し直すからです。`headings.snapToGrid: false`で8.6 mmに保ちます。入れ子のスタイルに`snapToGrid`を設定しても何も変わりません。グリッドから外せるのは外側のカードだけです（[入れ子の囲み](/ja/docs/configuration#calloutコンテナー)）。段末そろえはオフにしています。段末そろえはページを埋めるために見出しの上にスペースを足すので、2ページ目では活動3の上が9 mmではなく33 mmになってしまいます。

### 2 · 空欄はすべてチップで作る

```js
// script.js, 行 60–74
const chip = (id, fill, look) => ({ id, fontFamily: LABEL, background: col(fill),
  borderWidth: pt(0), borderColor: col('leaf'), borderRadius: em(1), ...look });
// An empty chip holds a U+2060 word joiner, as one of spaces prints its markup (gotcha:
// empty-chip). Its box is a band 1.25 em tall, so paddingX ROUND makes it a circle.
const [EMPTY, ROUND, OUTLINE] = ['\u2060', em(0.625), pt(0.9)];
// A blank is all padding, 2 × paddingX wide and 19.6 pt tall: taller than the 17 pt line, so
// the card's itemSpacing sets its items 24 pt apart (gotcha: chip-overlap).
const blank = (id, width) => chip(id, 'paper', { borderWidth: OUTLINE, borderRadius: mm(1.4),
  paddingX: mm(width / 2), paddingY: em(0.12), fontSize: em(1.15) });
const chipStyles = [
  chip('word', 'sun', { bold: true, paddingX: em(0.6), paddingY: em(0.08), gap: em(0.45) }),
  blank('blank', 34), blank('wide', 38), // mm: in a sentence; alone in a column
  chip('dot', 'leaf', { paddingX: ROUND, fontSize: em(0.6) }),
  chip('tick', 'paper', { borderWidth: OUTLINE, paddingX: ROUND, fontSize: em(1.1), gap: em(0.5) }),
];
```

チップは行の中の分割されない箱で、そのパディングは行を高くせずに行の外まで塗られます。語群は、入れ子にした`bank`の囲みに並べたチップの列です。空欄、線で結ぶ点、色を塗る丸もチップで、中身はそれぞれU+2060のワードジョイナーだけです。postext 1.4.1では、スペースだけのチップがマークアップをそのまま刷ってしまうためです（[インラインのチップ](/ja/docs/document-format#インラインのチップ)）。幅は`paddingX`で決めます。空欄は文の中では34 mm、段に単独で置くときは38 mmで、丸には高さ1.25 emの半分を左右それぞれに与えます。チェックリストでは、箇条書きの記号の位置に丸が立ちます（`bulletChar: ''`）。空欄は19.6 ptで17 ptの行より高いので、1つずつ専用のリスト項目に入れ、次の項目と24 pt離しています。1.4.1のSandboxは、`blank`、`wide`、`tick`について今も「チップが隣の行に接触」の警告を表示します。各チップを17 ptの行送りと比べるだけで、リスト項目の間の7 ptを数えに入れないためです。

### 3 · 見出しのデザインで活動に番号を振る

```js
// script.js, 行 78–92
const DISC_H2 = 8.6; // mm: the disc and the title are this tall, so the heading reserves this
const numberDisc = { kind: 'text', id: 'num', content: '{number}', // numberingTemplate '{2}'
  fontFamily: DISPLAY, fontSize: pt(15), fontWeight: 800, color: col('paper'),
  box: { backgroundColor: col('leaf'), borderRadius: mm(DISC_H2 / 2) }, // centred both ways
  placement: { anchor: { to: 'container', edge: 'top-left' },
    size: { width: mm(DISC_H2), height: mm(DISC_H2) } } };
const activityTitle = { kind: 'text', id: 'title', content: '{titleText}',
  fontFamily: DISPLAY, fontSize: pt(17), fontWeight: 700, color: col('ink'), align: 'left',
  overflow: 'wrap', placement: { anchor: { to: '#num', edge: 'right-of' },
    offset: { x: mm(3) }, size: { width: mm(140), height: mm(DISC_H2) } } };
const activity = { enabled: true, slot: { elements: [numberDisc, activityTitle] } };
// ## How did I do? {style="review"} is not an activity: it has no number and a star for a disc.
const review = { id: 'review', numbered: false, advancedDesign: { ...activity, slot: {
  elements: [{ kind: 'image', id: 'num', resourceId: 'star', placement: numberDisc.placement },
    activityTitle] } } };
```

`advancedDesign`を持つレベル2の見出しは段の中の位置を保ち、テキストの代わりにデザインが描かれます。レベルの`numberingTemplate`を刷る`{number}`を入れた円と、その右の`{titleText}`です（[幅と詳細デザイン](/ja/docs/configuration#幅と詳細デザイン)）。円とタイトルはどちらも高さ8.6 mmで、グリッドから外した見出しはちょうどその高さを確保するので、デザインに`minHeight`は要りません。番号はレベル2のカウンターから取るので、Markdownに活動を挿入すると、その後の活動の番号が振り直されます。自己評価の見出しは`numbered: false`のスタイルを使って番号の対象から外し、円の位置にSVGの星を置きます。

### 4 · 表のセルに絵を入れる

```js
// script.js, 行 130–146
const tableStyles = [{ id: 'match', borderRadius: mm(3), // no header row, so no header fill
  headerBackgroundEnabled: false, bodyBackgroundEnabled: true, bodyBackground: col('paper'),
  bodyFontFamily: LABEL, bodyFontSize: pt(13), bodyColor: col('ink'), cellPadding: mm(1.4),
  // White rules on the white panel: unseen, but they hide the canvas's seams between cells.
  rules: 'grid', borderColor: col('paper'), borderWidth: pt(1) }];
const cell = (content, extra) => ({ content, verticalAlign: 'middle', ...extra });
const dotCell = () => cell(`:chip[${EMPTY}]{style="dot"}`, { align: 'center' });
const matchRow = (icon, food, part) => [cell('', { image: { resourceId: icon, width: 0.76 },
  align: 'center' }), cell(food), dotCell(), cell(''), dotCell(),
cell(`:chip[${part}]{style="word"}`)];
const sheetType = { id: 'sheet', name: 'Worksheet item', shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }; // no caption, no number
// Set 'here' in its card, after a :::space (gotcha: box-embed-no-gap); built once FOODS exist.
const foods = () => ({ id: 'foods', typeId: 'sheet', kind: 'table', createdAt: 0, updatedAt: 0,
  placement: { position: 'here' }, table: { styleId: 'match', model: {
    columnWidths: [0.14, 0.25, 0.06, 0.31, 0.06, 0.18], // the widest gap: room to draw a line
    rows: FOODS.map((name, r) => matchRow(ICONS[r], name, PARTS[r])) } } });
```

`TableCell.image`はリソースをセルの中に描きます。ここではセル幅の76 %で描き、行はそれに合わせて高くなります。`verticalAlign: 'middle'`で、食べ物の名前、点、チップを絵に対して中央にそろえます。表の31 %を占めるいちばん広い列は、児童が線を引くために空けておきます。名前付きのスタイルは表の枠の角を丸め、白い本文の塗りをその形で切り抜きます。グリッドの罫も白です。Canvasは塗ったセルの間にかすかな細線を残しますが、白いパネルの上の白い1 ptの罫がそれを覆います（[名前付きの表スタイル](/ja/docs/configuration#名前付きの表スタイル)）。リソースの種類の`captionPrefix`は空で、表にはキャプションのテキストもないので、表の下には何も刷られません。`placement: { position: 'here' }`は、表を`::resource`の行がある位置、つまりカードの中に置きます。

## レシピの全体

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

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

### script.js

```js
// ═══ Postext Cookbook · Nº 022 · Worksheet with answer boxes and a word bank ══════
// https://postext.dev/en/cookbook/worksheet-answer-boxes
// Code: MIT · Text: original (CC BY 4.0) · Plant: diffusion models · Icons: drawn in code
// Fonts: Andika, Baloo 2, Fredoka (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage }
  from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'worksheet-answer-boxes';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { ink: '#243040', muted: '#5d6975', paper: '#ffffff', // type; prompts; boxes
  leaf: '#2f7d4a', sun: '#f4b43a', soil: '#8a5a36', // the accent; words to pick; drawings
  tint: '#e5f1e7', rule: '#a9c9b1' }; // the activity cards; answer-box outlines
// Design elements read the hex and ignore the palette (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color': point it at the leaf, so nothing prints blue.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.leaf })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, DISPLAY, LABEL] = ['Andika', 'Baloo 2', 'Fredoka']; // Andika: for early readers
const [TOP, SIDE] = [18, 16]; // mm; a sheet printed one-sided, so the margins do not mirror

// #region answer: cards that hold white answer boxes, stacked exactly, off the grid
// In the Markdown a box inside a box is a fence inside a fence (each ::: closes one):
//   :::callout{type="card"}
//   What is inside the pea pods?
//   :::callout{type="answer"}
//   Write here               ← a prompt first: a box of :::space alone collapses
//   :::space{lines=3}           (gotcha: space-dropped-box-top)
//   :::
//   :::
const [ASK, AFTER, HEAD, NEXT] = [2, 5, 3, 9]; // mm: question→box, box→next, heading→card
const card = {
  id: 'card', background: col('tint'), borderRadius: mm(4),
  padding: { top: mm(4), right: mm(5), bottom: mm(5), left: mm(5) },
  marginTop: mm(HEAD), marginBottom: mm(NEXT), // NEXT: under a card, to whatever follows
  // Off the grid, or what follows a card drops to the next grid line. Only the outer card can
  // set it: a nested box ignores snapToGrid, span and placement (gotcha: nested-callout-limits).
  snapToGrid: false,
  body: { paragraphSpacing: false }, // the boxes' margins do all the spacing
  lists: { bulletChar: '', // no bullet: in the checklist a circle chip opens each item
    gap: mm(2), itemSpacing: pt(7) }, columnGap: mm(6),
};
// A nested box's margins collapse with its neighbours', the larger winning (ASK, AFTER); the
// last box's marginBottom falls inside the card's padding instead of adding to it.
const nested = (id, look) => ({ id, background: col('paper'), borderRadius: mm(2.5),
  marginTop: mm(ASK), marginBottom: mm(AFTER), ...look });
const answer = nested('answer', { border: { enabled: true, color: col('rule'), width: pt(0.75) },
  padding: { top: mm(1.6), right: mm(3), bottom: mm(2), left: mm(3) },
  // A small grey prompt on a full 17 pt line; :::space counts in these body lines.
  body: { fontFamily: LABEL, fontSize: pt(8), color: col('muted') } });
const bank = nested('bank', { title: t({ en: 'Word bank', es: 'Banco de palabras' }),
  padding: { top: mm(2.2), right: mm(3), bottom: mm(2.4), left: mm(3) },
  titleStyle: { fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600, color: col('leaf'),
    textTransform: 'uppercase', letterSpacing: pt(1.3), gap: mm(1.6) } });
// config() plugs in all three: calloutStyles: [card, answer, bank].
// #endregion

// #region chips: words to pick, blanks to fill, dots to join, circles to colour
const chip = (id, fill, look) => ({ id, fontFamily: LABEL, background: col(fill),
  borderWidth: pt(0), borderColor: col('leaf'), borderRadius: em(1), ...look });
// An empty chip holds a U+2060 word joiner, as one of spaces prints its markup (gotcha:
// empty-chip). Its box is a band 1.25 em tall, so paddingX ROUND makes it a circle.
const [EMPTY, ROUND, OUTLINE] = ['\u2060', em(0.625), pt(0.9)];
// A blank is all padding, 2 × paddingX wide and 19.6 pt tall: taller than the 17 pt line, so
// the card's itemSpacing sets its items 24 pt apart (gotcha: chip-overlap).
const blank = (id, width) => chip(id, 'paper', { borderWidth: OUTLINE, borderRadius: mm(1.4),
  paddingX: mm(width / 2), paddingY: em(0.12), fontSize: em(1.15) });
const chipStyles = [
  chip('word', 'sun', { bold: true, paddingX: em(0.6), paddingY: em(0.08), gap: em(0.45) }),
  blank('blank', 34), blank('wide', 38), // mm: in a sentence; alone in a column
  chip('dot', 'leaf', { paddingX: ROUND, fontSize: em(0.6) }),
  chip('tick', 'paper', { borderWidth: OUTLINE, paddingX: ROUND, fontSize: em(1.1), gap: em(0.5) }),
];
// #endregion

// #region activity: an in-column H2 design: the automatic number in a green disc
const DISC_H2 = 8.6; // mm: the disc and the title are this tall, so the heading reserves this
const numberDisc = { kind: 'text', id: 'num', content: '{number}', // numberingTemplate '{2}'
  fontFamily: DISPLAY, fontSize: pt(15), fontWeight: 800, color: col('paper'),
  box: { backgroundColor: col('leaf'), borderRadius: mm(DISC_H2 / 2) }, // centred both ways
  placement: { anchor: { to: 'container', edge: 'top-left' },
    size: { width: mm(DISC_H2), height: mm(DISC_H2) } } };
const activityTitle = { kind: 'text', id: 'title', content: '{titleText}',
  fontFamily: DISPLAY, fontSize: pt(17), fontWeight: 700, color: col('ink'), align: 'left',
  overflow: 'wrap', placement: { anchor: { to: '#num', edge: 'right-of' },
    offset: { x: mm(3) }, size: { width: mm(140), height: mm(DISC_H2) } } };
const activity = { enabled: true, slot: { elements: [numberDisc, activityTitle] } };
// ## How did I do? {style="review"} is not an activity: it has no number and a star for a disc.
const review = { id: 'review', numbered: false, advancedDesign: { ...activity, slot: {
  elements: [{ kind: 'image', id: 'num', resourceId: 'star', placement: numberDisc.placement },
    activityTitle] } } };
// #endregion

const [BAND, AIR, DOT] = [76, 5, 6]; // mm: the opener's band, air under the disc, dot size
const DISC = { x: 108, y: 5, d: 78 }; // mm: the plant's disc on the page
const EDGE = BAND - DISC.y; // mm down the disc: the band's edge, where its two pictures meet
const at = (x, y, size, edge = 'top-left') => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(y) }, size });
const words = (id, content, family, size, weight, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), fontWeight: weight, color: col('paper'),
  align: 'left', overflow: 'wrap', placement, ...extra });
const tag = { textTransform: 'uppercase', letterSpacing: pt(1.3) };
const dot = ([n, x, y]) => words(`dot${n}`, String(n), DISPLAY, 10.5, 800, at(DISC.x + x - DOT / 2,
  DISC.y + y - DOT / 2, { width: mm(DOT), height: mm(DOT) }), { color: col('ink'), align: 'center',
  box: { backgroundColor: col('sun'), borderRadius: mm(DOT / 2) } }); // a yellow disc per number
const field = (id, label, x, w) => [words(`${id}-label`, label, LABEL, 7.5, 600, at(x, 59), tag),
  { kind: 'box', id, style: { backgroundColor: col('paper'), borderRadius: mm(1.6) },
    placement: at(x, 63, { width: mm(w), height: mm(7.5) }) }]; // a label over a white field
// Images reserve no height in an opener (gotcha: opener-image-no-reserve), so minHeight does.
const opener = () => ({ enabled: true, minHeight: mm(DISC.y + DISC.d + AIR - TOP),
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('leaf') },
      placement: at(0, 0, { height: mm(BAND) }) },
    ...[['plant', 0, EDGE], ['plant-foot', EDGE, DISC.d - EDGE], ['leaders', 0, DISC.d]]
      .map(([id, y, h]) => ({ kind: 'image', id, resourceId: id, // the leaders last, on top
        placement: at(DISC.x, DISC.y + y, { width: mm(DISC.d), height: mm(h) }) })),
    ...LABELS.map(dot),
    words('unit', '{attr.unit}', LABEL, 9, 600, at(SIDE, TOP - 4), { ...tag, color: col('ink'),
      box: { backgroundColor: col('sun'), borderRadius: mm(3),
        padding: { top: mm(1), right: mm(2.6), bottom: mm(1), left: mm(2.6) } } }),
    words('title', '{titleText}', DISPLAY, 40, 800, at(SIDE, 23.5, { width: mm(88) }),
      { lineHeight: 0.98 }), // two lines: the headings break with \\
    words('series', '{title} · {subtitle} · {attr.sheet}', LABEL, 9.5, 500, at(SIDE, 53)),
    ...field('name', t({ en: 'Name', es: 'Nombre' }), SIDE, 58),
    ...field('date', t({ en: 'Date', es: 'Fecha' }), SIDE + 62, 22),
  ] } });

// #region tables: a rounded panel with a picture in each row and chips to join
const tableStyles = [{ id: 'match', borderRadius: mm(3), // no header row, so no header fill
  headerBackgroundEnabled: false, bodyBackgroundEnabled: true, bodyBackground: col('paper'),
  bodyFontFamily: LABEL, bodyFontSize: pt(13), bodyColor: col('ink'), cellPadding: mm(1.4),
  // White rules on the white panel: unseen, but they hide the canvas's seams between cells.
  rules: 'grid', borderColor: col('paper'), borderWidth: pt(1) }];
const cell = (content, extra) => ({ content, verticalAlign: 'middle', ...extra });
const dotCell = () => cell(`:chip[${EMPTY}]{style="dot"}`, { align: 'center' });
const matchRow = (icon, food, part) => [cell('', { image: { resourceId: icon, width: 0.76 },
  align: 'center' }), cell(food), dotCell(), cell(''), dotCell(),
cell(`:chip[${part}]{style="word"}`)];
const sheetType = { id: 'sheet', name: 'Worksheet item', shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }; // no caption, no number
// Set 'here' in its card, after a :::space (gotcha: box-embed-no-gap); built once FOODS exist.
const foods = () => ({ id: 'foods', typeId: 'sheet', kind: 'table', createdAt: 0, updatedAt: 0,
  placement: { position: 'here' }, table: { styleId: 'match', model: {
    columnWidths: [0.14, 0.25, 0.06, 0.31, 0.06, 0.18], // the widest gap: room to draw a line
    rows: FOODS.map((name, r) => matchRow(ICONS[r], name, PARTS[r])) } } });
// #endregion

const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity)
  locale: t({ en: 'en-us', es: 'es' }), // exact codes only (gotcha: hyphenation-locales)
  resourceTypes: [sheetType], colorPalette, chipStyles, tableStyles, header: { elements: [] },
  footer: { elements: [words('foot', '{title} · {subtitle} · {attr.unit} · {pageNumber}', LABEL,
    7.8, 600, at(SIDE, -11, undefined, 'bottom-left'), { ...tag, color: col('muted') })] },
  page: { width: mm(200), height: mm(260), dpi: 150, margins: { top: mm(TOP), bottom: mm(20),
    left: mm(SIDE), right: mm(SIDE) } }, layout: { layoutType: 'single' },
  bodyText: { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(17), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true },
  // #region grid: the headings leave the grid too, and nothing stretches the gaps
  headings: { fontFamily: DISPLAY, snapToGrid: false, // or what follows snaps back to the grid
    balancing: { enabled: false }, // no extra space above headings to fill out a page
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
        marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener() },
      { level: 2, numberingTemplate: '{2}', marginTop: mm(0), marginBottom: mm(0),
        advancedDesign: activity },
    ] },
  // #endregion
  orderedLists: { fontFamily: DISPLAY, fontWeight: 800, marginTop: mm(ASK), // a bank's AFTER wins
    numberVerticalOffset: pt(1.7) }, // centred 0.3 em over the baseline, Baloo 2 sat high
  headingStyles: [review], calloutStyles: [card, answer, bank],
  paragraphStyles: [{ id: 'lead', fontSize: pt(14), lineHeight: pt(20) }, { id: 'colophon',
    fontFamily: LABEL, fontSize: pt(7.5), lineHeight: pt(10), color: col('muted') }],
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Seedlings Science"
subtitle: "Year 4"
---

# Plants and \\ their parts {unit="Unit 3" sheet="Worksheet 3A"}

:::paragraphs{style="lead"}
Every part of a plant has a job to do. In this worksheet you will name the parts of a pea plant and find out what each one does and which ones we eat. You will need a pencil and some coloured pencils.
:::

## Look closely

:::callout{type="card"}
What is inside the pea pods on the plant at the top of the page?

:::callout{type="answer"}
Write here
:::space{lines=4}
:::

Which part of the plant grows under the ground? Why does the plant need it?

:::callout{type="answer"}
Write here
:::space{lines=6}
:::
:::

## Name the parts

:::callout{type="card"}
Six parts of the pea plant on page 1 have a number. Choose a word from the word bank and write it next to the same number.

:::callout{type="bank"}
:chip[stem]{style="word"} :chip[seeds]{style="word"} :chip[flower]{style="word"} :chip[roots]{style="word"} :chip[leaf]{style="word"} :chip[fruit]{style="word"}
:::

:::columns{count=3}
1. :chip[⁠]{style="wide"}
2. :chip[⁠]{style="wide"}
3. :chip[⁠]{style="wide"}
4. :chip[⁠]{style="wide"}
5. :chip[⁠]{style="wide"}
6. :chip[⁠]{style="wide"}
:::
:::

## What does each part do?

:::callout{type="card"}
Finish each sentence with a word from the word bank.

:::callout{type="bank"}
:chip[food]{style="word"} :chip[insects]{style="word"} :chip[water]{style="word"} :chip[plant]{style="word"} :chip[upright]{style="word"} :chip[seeds]{style="word"}
:::

1. The **roots** hold the plant in the soil and take in :chip[⁠]{style="blank"}.
2. The **stem** keeps the plant :chip[⁠]{style="blank"} and carries water to the leaves.
3. The **leaves** use sunlight to make :chip[⁠]{style="blank"} for the plant.
4. The **flower** has bright petals that attract :chip[⁠]{style="blank"} such as bees.
5. The **fruit** grows around the :chip[⁠]{style="blank"} and keeps them safe.
6. A **seed** can grow into a new :chip[⁠]{style="blank"}.
:::

**Did you know?** A pea plant cannot stand up on its own. It climbs by wrapping thin, curly tendrils around anything it touches.

## Parts we eat

:::callout{type="card"}
We eat many different plant parts. Draw a line from each food to the part of the plant it comes from.

:::space{lines=0.33}
::resource{id="foods"}
:::

## Draw and label

:::callout{type="card"}
Draw a plant you have seen at home or at school. Label at least four of its parts.

:::callout{type="answer"}
My plant is called
:::space{lines=7}
:::
:::

## Try it at home

:::callout{type="card"}
Put a dried pea on some wet cotton wool in a clear jar. Keep the cotton wool damp and look at the pea every day for a week. What do you think will come out of the pea first: a root or a shoot?

:::callout{type="answer"}
I think
:::space{lines=3}
:::

After a week, draw or write what you saw.

:::callout{type="answer"}
I saw
:::space{lines=6}
:::
:::

## How did I do? {style="review"}

:::callout{type="card"}
Colour a circle for each thing you can do.

- :chip[⁠]{style="tick"} I can name the six parts of a plant.
- :chip[⁠]{style="tick"} I can say what each part does.
- :chip[⁠]{style="tick"} I can tell which part of a plant a food comes from.
:::

:::paragraphs{style="colophon"}
Seedlings Science is an invented series made for the Postext Cookbook. Text: CC BY 4.0. Plant: diffusion models.

Set in Andika, Baloo 2 and Fredoka (SIL Open Font License).
:::
`; // content.<lang>.md, inlined by the Cookbook

// The matching table's words. The parts are shuffled so that no food faces its own part.
const FOODS = t({ en: 'carrot|asparagus|lettuce|broccoli|orange|kidney beans',
  es: 'zanahoria|espárrago|lechuga|brócoli|naranja|alubias' }).split('|');
const PARTS = t({ en: 'leaf|seeds|root|fruit|stem|flower',
  es: 'hoja|semillas|raíz|fruto|tallo|flor' }).split('|');

// #region art: the plant's leader lines, the six foods and the star, in the palette's colours
// The pea plant is a watercolour already set in its disc, in two JPEGs that meet at the band's
// edge: the top one's corners carry the band's green, the foot's are white. One picture would
// resample its green-to-white step and print a pale line along the band's edge. No words in
// the drawings: an SVG drawn as an image cannot use web fonts (gotcha: svg-no-webfonts); the
// plant's numbers are design elements set in Baloo 2 over the picture (see the band).
// [number, x, y] in the disc's millimetres: each numbered dot, where its leader line starts.
const LABELS = [[1, 58, 8], [2, 68, 33], [3, 9, 44], [4, 13, 20], [5, 62, 52], [6, 18, 66]];
const n = (v) => +v.toFixed(2);
const svgDoc = (w, h, body, scale = 10) => `<svg xmlns="http://www.w3.org/2000/svg" `
  + `width="${w * scale}" height="${h * scale}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
const circle = (x, y, r, fill, extra = '') => `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" `
  + `fill="${fill}"${extra}/>`;
const path = (d, fill, extra = '') => `<path d="${d}" fill="${fill}"${extra}/>`;
const stroke = (d, color, width) => path(d, 'none', ` stroke="${color}" stroke-width="${width}" `
  + 'stroke-linecap="round" stroke-linejoin="round"');
const leafShape = (x, y, len, wid, turn, fill) => `<g transform="translate(${n(x)} ${n(y)}) `
  + `rotate(${n(turn)})">${path(`M0 0C${n(len * 0.3)} ${n(-wid)} ${n(len * 0.8)} ${n(-wid)} `
  + `${n(len)} 0C${n(len * 0.8)} ${n(wid)} ${n(len * 0.3)} ${n(wid)} 0 0Z`, fill)}</g>`;
function leaders() { // from each dot to its part, in ink on a white halo that clears the soil
  // 1 a flower, 2 the closed pod's wall (the fruit), 3 a pea in the open pod (the seeds),
  // 4 a leaf's blade, 5 the stem between two leaves, 6 the taproot.
  const targets = { 1: [45.5, 11.5], 2: [50, 34], 3: [28.4, 33.2], 4: [34.6, 16.4],
    5: [38.8, 52], 6: [38.4, 64] };
  const lines = LABELS.map(([num, x, y]) => `M${x} ${y}L${targets[num].join(' ')}`).join('');
  return svgDoc(78, 78, stroke(lines, palette.paper, 0.9) + stroke(lines, palette.ink, 0.35)
    + Object.values(targets).map(([x, y]) => circle(x, y, 0.75, palette.ink,
      ` stroke="${palette.paper}" stroke-width=".3"`)).join(''));
}
function star() { // the self-check's badge: a white star on a sun disc
  const pts = Array.from({ length: 10 }, (_, i) => {
    const a = -Math.PI / 2 + (i * Math.PI) / 5;
    const rr = i % 2 ? 4.4 : 9.4;
    return `${n(12 + Math.cos(a) * rr)} ${n(12.6 + Math.sin(a) * rr)}`;
  });
  return svgDoc(24, 24, circle(12, 12, 12, palette.sun) + path(`M${pts.join('L')}Z`,
    palette.paper), 10);
}
const food = {
  carrot: () => path('M3.5 17.5C8 13 12 8.5 16.2 5.2C18 4 20.6 6.4 19.2 8.2C15.8 12.4 10 15.8 '
    + '3.5 17.5Z', palette.sun) + stroke('M8 13.6l1.4 1.2M11.6 10.6l1.3 1.3M14.6 8l1.2 1.2',
    palette.soil, 0.4) + [-120, -80, -40].map((a) => leafShape(18.4, 6.4, 6, 1.4, a,
    palette.leaf)).join(''),
  asparagus: () => [[10, 7], [12, 12], [14, 17]].map(([x0, x1]) => path(`M${x0 - 1.3} 19`
    + `L${x1 - 1} 5Q${x1} 1.4 ${x1 + 1} 5L${x0 + 1.3} 19Z`, palette.leaf) + stroke(`M${x1 - 0.8} `
    + `7.6l.8.8.8-.8M${(x0 + x1) / 2 - 0.9} 11.4l.9.9.9-.9`, palette.tint, 0.35)).join('')
    + path('M7.8 13.4h8.4v2.2H7.8Z', palette.sun),
  lettuce: () => { // a head seen from the side: two dark outer leaves cupping a pale heart
    const side = path('M13 19C6 19 2.6 15 3 9.6C3.2 7.4 4.6 6 5.8 7C6.2 5.4 7.8 4.8 8.6 6.2'
      + 'C9.4 5.2 10.6 5.6 10.6 6.8C9 10 9.4 15 13 19Z', palette.leaf)
      + stroke('M11 17.6C7.6 16 5.2 12.6 5.2 8.6', palette.tint, 0.4); // a frilled leaf, its vein
    return `<ellipse cx="12" cy="11.4" rx="5.8" ry="6.8" fill="${palette.rule}"/>`
      + stroke('M12 17.6C11.6 14 11.8 10 12.8 6', palette.tint, 0.45)
      + side + `<g transform="translate(24 0) scale(-1 1)">${side}</g>`;
  },
  broccoli: () => path('M10 19l1-7h2l1 7Z', palette.rule) + [[8, 9, 3.4], [12, 7, 3.8],
    [16, 9, 3.4], [10, 11.5, 2.6], [14, 11.5, 2.6]].map(([x, y, rr]) => circle(x, y, rr,
    palette.leaf)).join(''),
  orange: () => circle(12, 11.5, 7.5, palette.sun) + leafShape(12, 4.2, 5, 1.6, -30,
    palette.leaf) + circle(9, 9, 1.2, palette.paper, ' fill-opacity=".45"'),
  beans: () => [[7, 13, -20], [13, 9, 15], [16, 15, -35]].map(([x, y, a]) => `<g transform=`
    + `"translate(${x} ${y}) rotate(${a})">${path('M-4 0C-4 -3 -1 -3 0 -1.5C1 -3 4 -3 4 0'
    + 'C4 3 -4 3 -4 0Z', palette.soil)}</g>`).join(''),
};
const ICONS = Object.keys(food); // carrot … beans: the matching table's rows, in order
const drawings = { leaders, star,
  ...Object.fromEntries(Object.entries(food).map(([id, draw]) => [id, () => svgDoc(24, 20,
    draw())])) };
const ALT = { ...Object.fromEntries(ICONS.map((id, i) => [id, FOODS[i]])),
  plant: t({ en: 'A pea plant with its flower, pod, peas, leaf, stem and roots numbered 1 to 6',
    es: 'Una planta de guisante con la flor, la vaina, los guisantes, la hoja, el tallo y las '
      + 'raíces numerados del 1 al 6' }),
  leaders: '', star: t({ en: 'A white star on a yellow disc',
    es: 'Una estrella blanca en un disco amarillo' }) };
// Each picture is a resource that the opener, a table cell or the review heading names by
// id. None is cited, so none is placed as a figure. Drawings in px, 10 to the millimetre.
const pictures = Object.keys(drawings).map((id) => ({ id, typeId: 'sheet', kind: 'svg',
  altText: ALT[id], createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`,
    width: 240, height: 200,
    ...{ leaders: { width: 780, height: 780 }, star: { width: 240, height: 240 } }[id] } }));
// The plant's two slices, 14 px to the millimetre (356 dpi): 78 × 71 mm and 78 × 7 mm.
pictures.push(...[['plant', 994], ['plant-foot', 98]].map(([id, h]) => ({ id, typeId: 'sheet',
  kind: 'bitmap', altText: id === 'plant' ? ALT.plant : '', createdAt: 0, updatedAt: 0,
  bitmap: { fileId: `${id}-1092.jpg`, format: 'jpeg', width: 1092, height: h } })));
// #endregion

const resources = [...pictures, foods()];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { Andika: ['400', '700'], 'Baloo 2': ['700', '800'], // (gotcha: fonts-first)
  Fredoka: ['400', '500', '600', '700'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await Promise.all([loadFonts(FONTS, markdown),
  ...Object.entries(drawings).map(([id, draw]) => loadSvg(`${id}.svg`, draw())),
  ...['plant-1092.jpg', 'plant-foot-1092.jpg'].map((file) => loadImage(file, asset(file)))]);
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: t({ en: 'Plants and their parts', es: 'Las plantas y sus partes' }) });

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## アレンジ

### カードをグリッドに戻す

既定の`snapToGrid: true`では、カードの後に続くものが次の17 ptのグリッド行まで下がり、各カードの下の9 mmは、活動2の下で14.5 mm、活動4の下で13.2 mm、活動6の下で11.2 mmになります。

```diff
-  snapToGrid: false,
+  snapToGrid: true,
```

### 解答の場所を広げる

`:::space`は解答欄自身の17 ptの行で数えます。1行足すごとに、児童が書ける場所が6 mm増えます。

```diff
 Write here
-:::space{lines=4}
+:::space{lines=6}
```

## よくあるつまずき

- **:::spaceは囲みや段の頭では捨てられる.** :::spaceは段、囲み、:::columnsグループの頭では、それが囲みの唯一の中身であっても捨てられます。そのため、空きだけで作った解答欄の囲みはつぶれてしまいます。囲みはタイトルか設問の行で始め、そのあとに空きを入れてください。
- **空白だけのチップはマークアップがそのまま出る.** テキストが空白（ノーブレークスペースを含む）だけのチップは、:chip[ ]がそのまま印字されます。空の解答用チップにはワードジョイナー（U+2060）を入れてください。
- **入れ子の囲みはspan、placement、snapToGridを無視する.** ほかの囲みの中に入れ子にした囲みは、自分のspan、placement、snapToGrid、floatBarrierを無視し、常に親の内側の幅で親の中を流れます。
- **行送りより高いチップは次の行に触れる.** 箱が行送りより高いチップは、次の行のチップに触れます（chipOverlap警告）。上下のパディング、枠線、サイズを小さくするか、行送りを広げてください。
- **囲みの中の表や図には前後の空きがない.** postext 1.4.1では、::resourceで位置'here'に置いた表や図は、:::calloutの中では本文中で取る空きをまったく取らず、上下の段落に接します。::resourceの行の前に:::spaceを置き、あとにテキストが続くときはもう1つ置いてください。lines=0.33のように1行の端数を指定すれば、小さな空きになります。
- **章扉の画像は確保する高さに数えられない.** postext 1.4.1では、詳細デザインの見出しは、確保する高さを画像抜きで計測します。テキスト、罫、箱はページに固定していても数えられますが、ページの頭に裁ち落としで置いた写真のような画像は何も確保しないため、本文がその上から始まることがあります。minHeightを本文が始まるべき位置に設定してください。
- **headingsオブジェクトを渡すとH1の改ページが消える.** 既定ではH1は奇数ページへ改ページします（always-odd）。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。
- **SVGの<img>内のテキストはWebフォントを使えない.** SVGは画像として描かれ、画像はページのWebフォントにアクセスできないため、ラベルはシステムの書体にフォールバックします。テキストをアウトライン化するか、SVGに@font-faceのサブセットを埋め込むか、ラベルをキャプションに移してください。
- **パレットを差し替えても、デザイン要素と参照色は変わらない.** postext 1.4.1はcolorPaletteをテキストのスタイル（本文、見出し、リスト、キャプション、表、囲み）には反映しますが、ヘッダー、フッター、章扉、部扉の要素と、bodyText.referenceColorには反映しません。これらはpaletteIdの横に書いた16進の色のままです。画面用のダーク版や色替えのためにパレットを差し替えるときは、ビルドの前に、リンクしたすべての色をcolorPaletteから書き直してください。
- **ハイフネーションできるのは8つのロケールだけで、コードは完全一致.** ハイフネーションが用意されているのはen-us、es、fr、de、it、pt、ca、nlで、コードは完全一致で照合されます。'es-ES'やほかの言語は、何の知らせもなくアメリカ英語にフォールバックします。
- **設定はオブジェクトの同一性でキャッシュされる。毎回新しいオブジェクトを作る.** エンジンは解決済みの設定をオブジェクトの同一性でキャッシュします。そのため、設定をその場で書き換えて再ビルドすると前の結果が再利用されます。ビルドのたびに新しいオブジェクトを作ってください。レシピの設定がファクトリー関数config()になっているのはこのためです。
- **レイアウトの前にすべてのフォントを読み込む.** レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。
- **組版の警告: チップが隣の行に接触** (`chipOverlap`). チップスタイルのボックスが行送りより高いため、連続する行のチップどうしが接します。 解決策: チップの上下のパディング、輪郭線、サイズを小さくするか、行送りを大きくしてください。 ([ドキュメント](https://postext.dev/ja/docs/configuration.md#チップスタイル))

- postext 1.4.1では、囲みのスタイルに罫線入りの背景がないため、囲みが自分の中に記入用の罫線を引けません。解答欄は無地のまま`:::space`で高さを取り、短い記入欄は枠線のチップで作ります。
- `numbered: false`の見出しスタイルも、そのレベルのデザインを描き、`{number}`が空のまま残ります。専用のデザインがなければ、自己評価は空の緑の円で始まってしまいます。そこで`review`は活動のデザインを複製し、円があった位置に星を置きます。

## クレジット

- レシピ: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- 画像: The pea plant on the opener, a watercolour (top of the disc): Generated With Diffusion Models, オリジナル
- 画像: The pea plant’s roots, the foot of the same watercolour below the band: Generated With Diffusion Models, オリジナル
- 画像: The plant's leader lines, the six food icons and the star, drawn in code in the page’s palette: Ignacio Ferro, MIT
- 書体: Andika (OFL-1.1), Baloo 2 (OFL-1.1), Fredoka (OFL-1.1)
- コード: MIT · サンプルの内容: CC-BY-4.0

## 関連レシピ

- [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 (中級) · ワークブックと練習問題
- [No. 069 · パズルブック：クロスワード、単語探し、迷路](https://postext.dev/ja/cookbook/puzzle-book.md): クロスワードと単語探しを、文字の行から表として組みます。黒マスと見つけた単語はセルの塗り、カギの番号は上付き文字です。 · 難易度 3 (上級) · ワークブックと練習問題
