# 小学校の学習ワークの1単元

> 4ページの理科ワークの単元。11の活動は Markdown に書いた :::exercise のブロックで、ひとつの演習スタイルが番号の円、語群、結ぶ点まで組みます。

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

## かんたんな説明

8歳の子ども向けの、背骨のある動物を学ぶ学習ワークです。問題と答えは本文に書き、どの活動がページでどう見えるかはひとつのスタイルが決めます。

## できあがり

小学3年生向け理科ワークの第5単元を A4 判で組みます。題は *Animals with backbones*（背骨のある動物）です。単元のとびらでは、緑の帯の下に池の絵を置きます。そのあとに11の活動が続き、どれも緑の円に入った番号と、えんぴつ、ふきだし、ペアのいずれかのアイコンを持ちます。点線で囲んだ色付きの枠の語群、T か F に印をつける文、動物をその仲間に線で結ぶ問題、一文と短い文章の中で丸で囲む語、番号をつけるカエルの成長段階、文字さがし、動物の仲間分け、罫線に書く自由記述の問い、方眼に書くもうひとつの問いが並びます。スペイン語版は同じ単元をスペインの学校の流儀で組み、V と F、*une con flechas* を使います。挿絵の3枚は拡散モデルで生成しました。

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

- 番号の円、語群、正誤、線で結ぶ問題が並ぶワークのページはどう組みますか？
- 問題番号の横に活動アイコン（書く、話す、ペア）を置くには？
- 記入線、解答欄、語群、チェックリストのあるワークシートのページを作るには？

## 手短な答え

```js
// script.js, 行 34–61
// The Markdown says what each activity asks (:::exercise, [[answers]], - A -> B, - [x]);
// this style says how a Spanish workbook prints it: the number in a disc of the unit
// colour, the instruction in bold, blanks on a line, V/F boxes at the line's end, dots to
// join, the bank in a tinted box, squared paper for writing and the activity icons.
const icon = (id, alt) => ({ resourceId: `icon-${id}`, alt, size: 1.05 });
const actividad = {
  id: 'actividad',
  number: { shape: 'disc', background: col('unit'), color: col('paper'),
    fontFamily: DISPLAY, fontSize: em(1.05), fontWeight: 800 },
  instruction: { bold: true, color: col('ink') },
  options: { marker: 'circle' },
  blank: { look: 'line', color: col('ink'), weight: pt(0.75), extra: em(2) },
  trueFalse: { look: 'boxes' }, // V F in Spanish, T F in English: the language's own words
  match: { look: 'dots', gap: em(7), dotSize: em(0.42), color: col('unit') },
  bank: { box: 'banco', order: 'alpha', separator: ' ' },
  area: { kind: 'lines', spacing: mm(9), color: col('rule'), weight: pt(0.6) },
  grid: { cell: mm(9.5), fontFamily: DISPLAY, fontSize: pt(13), rule: col('rule'),
    color: col('ink') },
  icons: {
    escribe: icon('write', t({ es: 'Escribe', en: 'Write' })),
    oral: icon('oral', t({ es: 'Actividad oral', en: 'Speaking' })),
    pareja: icon('pair', t({ es: 'En pareja', en: 'In pairs' })),
  },
  marginTop: mm(7), marginBottom: mm(2), itemSpacing: em(0.8),
};
// The same activity written on squared paper (cuadrícula): `style="cuadricula"`.
const cuadricula = { id: 'cuadricula', extends: 'actividad',
  area: { kind: 'grid', spacing: mm(5), weight: pt(0.4) } };
```

## 材料

**学べること**

- [練習問題のスタイル](https://postext.dev/ja/docs/exercises.md#練習問題スタイル): exerciseStyles。練習問題の組み方を決める。番号（丸、四角、見出し語）、指示文、選択肢の記号、空欄、正誤の欄、線結びの点、語群、解答欄、マス目、活動アイコン、難易度。
- [穴埋めと語群](https://postext.dev/ja/docs/exercises.md#穴埋めと語群): [[答え]] の空欄を線、点線、いちばん長い答えの幅の枠で印刷する。[[a | (b)]] は文中で丸をつける選択、::bank[…] は答えを集め、まぎらわしい語を加えた語群。
- [線結びと分類](https://postext.dev/ja/docs/exercises.md#線で結ぶ問題と分類): - A -> B の組を、点を線で結ぶ（または記号を書く）二列にばらして印刷する。分類問題は - 分類: 語 のリストを語群と表にする。

**ほかに使うもの**

- [練習問題](https://postext.dev/ja/docs/exercises.md#練習問題)
- [選択問題と正誤問題](https://postext.dev/ja/docs/exercises.md#選択問題と正誤問題)
- [語に印をつける](https://postext.dev/ja/docs/exercises.md#印を付ける)
- [並べ替え](https://postext.dev/ja/docs/exercises.md#並べ替え)
- [単語探しとクロスワード](https://postext.dev/ja/docs/exercises.md#言葉さがしとクロスワード)
- [記述問題と解答欄](https://postext.dev/ja/docs/exercises.md#記述問題と解答欄)
- [デザインした章扉](https://postext.dev/ja/docs/configuration-headings.md#幅と詳細デザイン)
- [見出しの属性](https://postext.dev/ja/docs/document-format.md#見出しの属性)
- [囲み](https://postext.dev/ja/docs/configuration-callouts.md#囲みスタイル)
- [セマンティックカラーパレット](https://postext.dev/ja/docs/configuration-fonts-colors-viewers.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-programmatic-usage.md#ページをビットマップに描画する)
- [PDFの書き出し](https://postext.dev/ja/docs/configuration-pdf-output.md#pdfの生成)
- [リソースとしての図と表](https://postext.dev/ja/docs/document-format-resources.md#リソース)
- [独自のリソースの種類](https://postext.dev/ja/docs/configuration-resources.md#リソースの種類)
- [分割する囲み、分割しない囲み](https://postext.dev/ja/docs/configuration-callouts.md#calloutコンテナー)
- [解答](https://postext.dev/ja/docs/exercises.md#解答)
- [その場に置く図](https://postext.dev/ja/docs/document-format-resources.md#ブロック埋め込み任意本文中への明示的な配置)
- [段落スタイル](https://postext.dev/ja/docs/configuration-styles.md#段落スタイル)
- [PDFに埋め込むフォント](https://postext.dev/ja/docs/configuration-pdf-output.md#なぜフォントプロバイダーが必要か)

**設定の一覧**

- [`bodyText`](https://postext.dev/ja/docs/configuration-text.md#本文), [`calloutStyles`](https://postext.dev/ja/docs/configuration-callouts.md#囲みスタイル), [`colorPalette`](https://postext.dev/ja/docs/configuration-fonts-colors-viewers.md#カラーパレット), `exerciseStyles`, [`footer`](https://postext.dev/ja/docs/configuration-headers-footers.md#柱とノンブル), [`header`](https://postext.dev/ja/docs/configuration-headers-footers.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#ページ), [`paragraphStyles`](https://postext.dev/ja/docs/configuration-styles.md#段落スタイル), [`resourceTypes`](https://postext.dev/ja/docs/configuration-resources.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の生成)

**書体**

- Atkinson Hyperlegible Next (OFL-1.1), Grandstander (OFL-1.1)

## 作り方

### 1 · 活動は Markdown に書き、スタイルは一度だけ決める

```js
// script.js, 行 34–61
// The Markdown says what each activity asks (:::exercise, [[answers]], - A -> B, - [x]);
// this style says how a Spanish workbook prints it: the number in a disc of the unit
// colour, the instruction in bold, blanks on a line, V/F boxes at the line's end, dots to
// join, the bank in a tinted box, squared paper for writing and the activity icons.
const icon = (id, alt) => ({ resourceId: `icon-${id}`, alt, size: 1.05 });
const actividad = {
  id: 'actividad',
  number: { shape: 'disc', background: col('unit'), color: col('paper'),
    fontFamily: DISPLAY, fontSize: em(1.05), fontWeight: 800 },
  instruction: { bold: true, color: col('ink') },
  options: { marker: 'circle' },
  blank: { look: 'line', color: col('ink'), weight: pt(0.75), extra: em(2) },
  trueFalse: { look: 'boxes' }, // V F in Spanish, T F in English: the language's own words
  match: { look: 'dots', gap: em(7), dotSize: em(0.42), color: col('unit') },
  bank: { box: 'banco', order: 'alpha', separator: ' ' },
  area: { kind: 'lines', spacing: mm(9), color: col('rule'), weight: pt(0.6) },
  grid: { cell: mm(9.5), fontFamily: DISPLAY, fontSize: pt(13), rule: col('rule'),
    color: col('ink') },
  icons: {
    escribe: icon('write', t({ es: 'Escribe', en: 'Write' })),
    oral: icon('oral', t({ es: 'Actividad oral', en: 'Speaking' })),
    pareja: icon('pair', t({ es: 'En pareja', en: 'In pairs' })),
  },
  marginTop: mm(7), marginBottom: mm(2), itemSpacing: em(0.8),
};
// The same activity written on squared paper (cuadrícula): `style="cuadricula"`.
const cuadricula = { id: 'cuadricula', extends: 'actividad',
  area: { kind: 'grid', spacing: mm(5), weight: pt(0.4) } };
```

活動はどれも `:::exercise` のブロックです。児童が読む文も正解も本文にあります。`[[hair]]` は答えが *hair* の空欄、`- [x]` は正しい文、`- Dolphin -> Mammal` は線で結ぶ組、`[[pelo | (plumas)]]` は正しい語をかっこに入れた選択肢です。スタイルが持つのは見た目だけなので、11の活動はひとつの `actividad` を共有し、2つ目の `cuadricula` が `extends` でそれを受け継いで、罫線を方眼に替えます。エンジンは活動に番号をふり、結ぶ問題の右の列と並べかえの項目をシードで混ぜ、どの空欄もいちばん長い答えの幅にそろえて答えが透けないようにします。英語版には T と F、スペイン語版には V と F を印刷し、設定にそのための語はひとつも書きません。

### 2 · 活動にアイコンを付ける

```js
// script.js, 行 277–288
const disc = (body) => '<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" '
  + `viewBox="0 0 24 24"><circle cx="12" cy="12" r="12" fill="${palette.unit}"/>`
  + `<g fill="none" stroke="#fff" stroke-width="1.7" stroke-linecap="round" `
  + `stroke-linejoin="round">${body}</g></svg>`;
const ICONS = {
  write: disc('<path d="M7 17l1-4 7.5-7.5 3 3L11 16l-4 1z"/><path d="M14 7l3 3"/>'),
  oral: disc('<path d="M6 7.5h12v7.5H11l-3.5 3v-3H6z"/><path d="M9.5 11.2h.01M12 11.2h.01M14.5'
    + ' 11.2h.01" stroke-width="2.2"/>'),
  pair: disc('<circle cx="9" cy="9" r="2.3"/><circle cx="15.3" cy="9.6" r="2"/>'
    + '<path d="M4.8 17.5c.4-2.8 2.1-4.3 4.2-4.3s3.8 1.5 4.2 4.3M13.6 13.9c.6-.3 1.1-.4 1.7-.4'
    + ' 1.9 0 3.3 1.3 3.7 3.8"/>'),
};
```

`exerciseStyles[].icons` のアイコンは本の画像をひとつ指し、`:::exercise{icon="oral pareja"}` は書いた順に画像を番号のあとに並べます。読み上げでは `alt` が読まれます。アイコンは単元の色の円の上にコードで描いた平らな記号なので、PDF でもくっきりし、パレットに合わせて色が変わります。書く活動の横にはえんぴつを置きます。話すアイコンとペアのアイコンは最後の活動の印で、そこには書く欄がありません（`area="none"`）。

### 3 · 単元を帯と絵で始める

```js
// script.js, 行 65–81
const at = (to, edge, x = 0, y = 0, size) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });
const PIC = 75; // mm: the painting's height (it is 176 × 75 mm, 2.35:1)
const opener = { enabled: true, minHeight: mm(20 + PIC + 4), slot: { elements: [
  { kind: 'box', id: 'band', style: { backgroundColor: col('unit'), borderRadius: mm(4) },
    placement: at('container', 'top-left', 0, 0, { width: mm(176), height: mm(18) }) },
  { kind: 'text', id: 'unit', content: '{attr.unidad}', fontFamily: DISPLAY, fontSize: pt(11),
    fontWeight: 800, color: col('unit'), align: 'center', verticalAlign: 'middle',
    textTransform: 'uppercase', letterSpacing: pt(1.4), lineHeight: 1.1,
    box: { backgroundColor: col('paper'), borderRadius: mm(4) },
    placement: at('container', 'top-left', 4, 4, { width: mm(30), height: mm(10) }) },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(27),
    fontWeight: 800, color: col('paper'), align: 'left', lineHeight: 1,
    placement: at('container', 'top-left', 40, 4.4, { width: mm(132) }) },
  { kind: 'image', id: 'picture', resourceId: '{attr.pic}', decorative: true,
    placement: at('container', 'top-left', 0, 22, { width: mm(176), height: mm(PIC) }) },
] } };
```

単元のレベル1の見出しが、属性（`unidad="Unit 5" pic="charca"`）から帯、単元番号の角丸、題を描き、帯の下に 176 × 75 mm の絵を置きます。活動の中の絵には、キャプションも番号もないリソースの種類（`dibujo`）を使います。ワークの絵はそう印刷するものだからです。本文は子どもにも読みやすく組みます。Atkinson Hyperlegible Next の 12 pt、行送り 17 pt、ハイフネーションなし、左揃えです。

## レシピの全体

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

- レシピのフォルダー: https://github.com/drnachio/postext/tree/main/cookbook/primary-workbook-activities

### script.js

```js
// ═══ Postext Cookbook · Nº 168 · Primary workbook unit of activities ═══════════
// https://postext.dev/en/cookbook/primary-workbook-activities
// Code: MIT · Text: original (CC BY 4.0) · Pictures: generated with diffusion models
// Fonts: Atkinson Hyperlegible Next, Grandstander (SIL OFL 1.1) · Needs postext ≥ 1.59.0
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'es'; // @lang: the language of the sample document ('es' | 'en')
const RECIPE = 'primary-workbook-activities';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: one unit colour (green for this unit), a pale tint of it, a sun yellow
const palette = {
  ink: '#1f2b27', // text: a green-tinted near-black
  unit: '#2b7a3d', // the unit's colour: discs, band, folio, dots to join
  deep: '#1d5a2b', // the bank's words and the kicker on white
  tint: '#e6f2e2', // the reminder box, the bank
  sun: '#f2b330', // the reminder's stripe
  rule: '#9cc3a2', // writing lines, the squared paper
  muted: '#5c6b64', // running heads, colophon
  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: 'unit (defaults)', value: { hex: palette.unit, model: 'hex' } },
];
// #endregion
const [TEXT, DISPLAY] = ['Atkinson Hyperlegible Next', 'Grandstander'];

// #region answer: the workbook's activity style, one for every kind of exercise
// The Markdown says what each activity asks (:::exercise, [[answers]], - A -> B, - [x]);
// this style says how a Spanish workbook prints it: the number in a disc of the unit
// colour, the instruction in bold, blanks on a line, V/F boxes at the line's end, dots to
// join, the bank in a tinted box, squared paper for writing and the activity icons.
const icon = (id, alt) => ({ resourceId: `icon-${id}`, alt, size: 1.05 });
const actividad = {
  id: 'actividad',
  number: { shape: 'disc', background: col('unit'), color: col('paper'),
    fontFamily: DISPLAY, fontSize: em(1.05), fontWeight: 800 },
  instruction: { bold: true, color: col('ink') },
  options: { marker: 'circle' },
  blank: { look: 'line', color: col('ink'), weight: pt(0.75), extra: em(2) },
  trueFalse: { look: 'boxes' }, // V F in Spanish, T F in English: the language's own words
  match: { look: 'dots', gap: em(7), dotSize: em(0.42), color: col('unit') },
  bank: { box: 'banco', order: 'alpha', separator: ' ' },
  area: { kind: 'lines', spacing: mm(9), color: col('rule'), weight: pt(0.6) },
  grid: { cell: mm(9.5), fontFamily: DISPLAY, fontSize: pt(13), rule: col('rule'),
    color: col('ink') },
  icons: {
    escribe: icon('write', t({ es: 'Escribe', en: 'Write' })),
    oral: icon('oral', t({ es: 'Actividad oral', en: 'Speaking' })),
    pareja: icon('pair', t({ es: 'En pareja', en: 'In pairs' })),
  },
  marginTop: mm(7), marginBottom: mm(2), itemSpacing: em(0.8),
};
// The same activity written on squared paper (cuadrícula): `style="cuadricula"`.
const cuadricula = { id: 'cuadricula', extends: 'actividad',
  area: { kind: 'grid', spacing: mm(5), weight: pt(0.4) } };
// #endregion

// #region opener: the unit band in the unit colour, the painting under it
const at = (to, edge, x = 0, y = 0, size) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });
const PIC = 75; // mm: the painting's height (it is 176 × 75 mm, 2.35:1)
const opener = { enabled: true, minHeight: mm(20 + PIC + 4), slot: { elements: [
  { kind: 'box', id: 'band', style: { backgroundColor: col('unit'), borderRadius: mm(4) },
    placement: at('container', 'top-left', 0, 0, { width: mm(176), height: mm(18) }) },
  { kind: 'text', id: 'unit', content: '{attr.unidad}', fontFamily: DISPLAY, fontSize: pt(11),
    fontWeight: 800, color: col('unit'), align: 'center', verticalAlign: 'middle',
    textTransform: 'uppercase', letterSpacing: pt(1.4), lineHeight: 1.1,
    box: { backgroundColor: col('paper'), borderRadius: mm(4) },
    placement: at('container', 'top-left', 4, 4, { width: mm(30), height: mm(10) }) },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(27),
    fontWeight: 800, color: col('paper'), align: 'left', lineHeight: 1,
    placement: at('container', 'top-left', 40, 4.4, { width: mm(132) }) },
  { kind: 'image', id: 'picture', resourceId: '{attr.pic}', decorative: true,
    placement: at('container', 'top-left', 0, 22, { width: mm(176), height: mm(PIC) }) },
] } };
// #endregion

// Running heads outside: subject and unit at the head, a folio disc and the book at the foot.
const label = (id, content, parity, edge, x, y, extra = {}) => ({ kind: 'text', id, content,
  parity, fontFamily: TEXT, fontSize: pt(7.5), fontWeight: 700, color: col('muted'),
  textTransform: 'uppercase', letterSpacing: pt(1.2), placement: at('page', edge, x, y),
  ...extra });
const folio = (parity, edge, side) => label(`folio-${parity}`, '{pageNumber}', parity, edge,
  16 * side, -15, { fontFamily: DISPLAY, fontSize: pt(10), fontWeight: 800, letterSpacing: pt(0),
    textTransform: 'none', color: col('paper'), align: 'center', verticalAlign: 'middle',
    lineHeight: 1.2, box: { backgroundColor: col('unit'), borderRadius: mm(4) },
    placement: { ...at('page', edge, 16 * side, -15), size: { width: mm(8), height: mm(8) } } });
const header = { elements: [
  label('verso', '{title}', 'even', 'top-left', 18, 13, { pages: 'body' }),
  label('recto', '{chapterTitle}', 'odd', 'top-right', -16, 13,
    { pages: 'body', align: 'right', color: col('unit') }),
] };
const footer = { elements: [
  folio('even', 'bottom-left', 1), folio('odd', 'bottom-right', -1),
  label('book-even', '{subtitle}', 'even', 'bottom-left', 27, -13.4),
  label('book-odd', '{subtitle}', 'odd', 'bottom-right', -27, -13.4, { align: 'right' }),
] };

const config = {
  locale: t({ es: 'es', en: 'en-gb' }),
  // The pictures of a workbook carry no caption and no number.
  resourceTypes: [{ id: 'dibujo', name: t({ es: 'Dibujo', en: 'Picture' }), shortLabel: '',
    captionPrefix: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal',
    defaultPlacement: { position: 'here' } }],
  colorPalette,
  page: { sizePreset: 'custom', width: mm(210), height: mm(297), dpi: 150,
    margins: { top: mm(22), bottom: mm(24), left: mm(18), right: mm(16), mirror: true } },
  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,
    hyphenation: { enabled: false }, // primary readers: whole words on every line
    optimalLineBreaking: true, avoidWidows: true, avoidOrphans: true, avoidRunts: true,
  },
  headings: { fontFamily: DISPLAY, color: col('ink'), fontWeight: 800, levels: [
    { level: 1, fontSize: pt(27), breakBefore: { parity: 'any' }, marginTop: pt(0),
      marginBottom: mm(2), advancedDesign: opener },
  ] },
  exerciseStyles: [actividad, cuadricula],
  orderedLists: { numberFormat: 'arabic', color: col('unit'), fontWeight: 700,
    marginTop: pt(0), marginBottom: pt(0) },
  calloutStyles: [
    { id: 'recuerda', background: col('tint'), borderRadius: mm(3),
      stripe: { enabled: true, side: 'left', width: pt(4), color: col('sun') },
      padding: { top: mm(3), right: mm(5), bottom: mm(3.5), left: mm(5) },
      titleStyle: { fontFamily: DISPLAY, fontSize: pt(12), fontWeight: 800, color: col('deep') },
      body: { fontFamily: TEXT, fontSize: pt(11.5), lineHeight: pt(16), color: col('ink'),
        boldColor: col('ink'), firstLineIndent: pt(0) }, marginTop: mm(4), marginBottom: mm(2) },
    { id: 'sabias', background: col('paper'), borderRadius: mm(3),
      border: { enabled: true, color: col('sun'), width: pt(1.5) },
      padding: { top: mm(3), right: mm(5), bottom: mm(3.5), left: mm(5) },
      titleStyle: { fontFamily: DISPLAY, fontSize: pt(12), fontWeight: 800, color: col('deep') },
      body: { fontFamily: TEXT, fontSize: pt(11.5), lineHeight: pt(16), color: col('ink'),
        firstLineIndent: pt(0) }, marginTop: mm(7), marginBottom: mm(2) },
    { id: 'banco', title: '', backgroundEnabled: true, background: col('tint'),
      border: { enabled: true, color: col('unit'), width: pt(1), style: 'dashed' },
      borderRadius: mm(3), padding: { top: mm(2), right: mm(4), bottom: mm(2), left: mm(4) },
      marginTop: mm(1), marginBottom: mm(2), keepTogether: true },
  ],
  paragraphStyles: [{ id: 'colofon', fontFamily: TEXT, fontSize: pt(7.5), lineHeight: pt(10),
    color: col('muted'), firstLineIndent: pt(0), marginTop: mm(5) }],
  header,
  footer,
};

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Ciencias de la Naturaleza 3"
subtitle: "Cuaderno de actividades"
---

# Los animales vertebrados {unidad="Unidad 5" pic="charca"}

Los animales **vertebrados** tienen esqueleto interno y columna vertebral. Se dividen en cinco grupos: mamíferos, aves, peces, anfibios y reptiles. En una charca pueden vivir animales de casi todos ellos.

:::callout{type="recuerda" title="Recuerda"}
Para saber a qué grupo pertenece un animal, fíjate en tres cosas: qué le cubre el cuerpo (pelo, plumas, escamas o piel desnuda), cómo respira (con pulmones o con branquias) y cómo nace (del vientre de su madre o de un huevo).
:::

:::exercise{icon="escribe" bank}
Completa las oraciones con las palabras del recuadro.

::bank[escamas | pulmones]

1. Los mamíferos tienen el cuerpo cubierto de [[pelo]].
2. Las aves nacen de un [[huevo]] y tienen el cuerpo cubierto de plumas.
3. Los peces respiran por [[branquias]] y se mueven con aletas.
4. Las ranas y los sapos son [[anfibios]]: de pequeños viven en el agua.
:::

:::exercise{type="truefalse" icon="escribe"}
Lee y marca **V** si es verdadero o **F** si es falso.

- [x] La tortuga es un reptil y nace de un huevo.
- [ ] El delfín es un pez porque vive en el mar.
- [x] El murciélago es un mamífero que vuela.
- [ ] Los anfibios tienen el cuerpo cubierto de plumas.
- [x] Las serpientes no tienen patas, pero tienen columna vertebral.
:::

:::exercise{icon="escribe"}
Une con flechas cada animal con su grupo.

- Delfín -> Mamífero
- Pingüino -> Ave
- Salamandra -> Anfibio
- Lagartija -> Reptil
- Sardina -> Pez
:::

:::exercise{icon="escribe"}
Rodea la palabra correcta.

1. Las aves tienen el cuerpo cubierto de [[pelo | (plumas)]].
2. Los peces respiran con [[(branquias) | pulmones]].
3. Los reptiles tienen la piel cubierta de [[(escamas) | plumas]].
4. Las crías de los mamíferos toman [[agua | (leche)]] de su madre.
:::

:::exercise{type="mark" mark="circle" icon="escribe"}
Lee el texto y rodea los nombres de los animales que nacen de un huevo.

En la granja de mi abuela viven [[gallinas]], dos vacas, un caballo y unos [[patos]] que nadan en la balsa. Junto al muro duerme el perro, y entre las piedras se esconde una [[lagartija]]. En el estanque hay [[ranas]] y algunos [[peces]] rojos.
:::

:::callout{type="sabias" title="¿Sabías que…?"}
El ornitorrinco es un mamífero que pone huevos. Vive en los ríos de Australia, tiene pico de pato y, cuando nacen sus crías, las alimenta con leche, como todos los mamíferos.
:::

:::exercise{type="order" icon="escribe"}
Observa cómo crece una rana. Después, numera las frases en orden del 1 al 5.

::resource{id="ciclo"}

1. La rana pone sus huevos en el agua.
2. Del huevo sale un renacuajo con cola.
3. Al renacuajo le salen las patas de atrás.
4. Le salen las patas de delante y la cola se hace corta.
5. La rana adulta sale del agua y respira con pulmones.
:::

:::exercise{type="wordsearch" size="10" directions="across down" icon="escribe"}
Busca en la sopa de letras el nombre de seis animales vertebrados.

- CABALLO
- GAVIOTA
- TRUCHA
- SAPO
- TORTUGA
- ZORRO
:::

:::exercise{type="open" lines="2" icon="escribe"}
Observa el dibujo. ¿Qué dos animales vertebrados mira Lucía con su lupa? ¿A qué grupo pertenece cada uno?

::resource{id="nina"}

:::solution
Un gorrión, que es un ave, y una lagartija, que es un reptil.
:::
:::

:::exercise{type="sort" icon="escribe"}
Clasifica estos animales según lo que les cubre el cuerpo.

- Pelo: oveja, ardilla, conejo
- Plumas: gorrión, cigüeña, loro
- Escamas: lagarto, salmón, cocodrilo
:::

:::exercise{type="open" style="cuadricula" lines="5" icon="escribe"}
Elige un animal vertebrado que hayas visto cerca de tu casa. Escribe a qué grupo pertenece, qué le cubre el cuerpo y cómo nace.

:::solution
La lagartija es un reptil. Tiene el cuerpo cubierto de escamas y nace de un huevo.
:::
:::

:::exercise{type="open" area="none" icon="oral pareja"}
Habla con tu compañero o compañera. ¿Qué animal vertebrado te gustaría ser durante un día? ¿Cómo te moverías y qué comerías?
:::


:::paragraphs{style="colofon"}
Compuesto en Atkinson Hyperlegible Next y Grandstander (SIL OFL) · Texto original (CC BY 4.0) · Ilustraciones generadas con modelos de difusión
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region icons: the three activity icons, white pictograms on a disc of the unit colour
const disc = (body) => '<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" '
  + `viewBox="0 0 24 24"><circle cx="12" cy="12" r="12" fill="${palette.unit}"/>`
  + `<g fill="none" stroke="#fff" stroke-width="1.7" stroke-linecap="round" `
  + `stroke-linejoin="round">${body}</g></svg>`;
const ICONS = {
  write: disc('<path d="M7 17l1-4 7.5-7.5 3 3L11 16l-4 1z"/><path d="M14 7l3 3"/>'),
  oral: disc('<path d="M6 7.5h12v7.5H11l-3.5 3v-3H6z"/><path d="M9.5 11.2h.01M12 11.2h.01M14.5'
    + ' 11.2h.01" stroke-width="2.2"/>'),
  pair: disc('<circle cx="9" cy="9" r="2.3"/><circle cx="15.3" cy="9.6" r="2"/>'
    + '<path d="M4.8 17.5c.4-2.8 2.1-4.3 4.2-4.3s3.8 1.5 4.2 4.3M13.6 13.9c.6-.3 1.1-.4 1.7-.4'
    + ' 1.9 0 3.3 1.3 3.7 3.8"/>'),
};
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { 'Atkinson Hyperlegible Next': ['400', '700'], Grandstander: ['800'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const resources = [];
const add = (id, fileId, kind, width, height, more, placement) => resources.push({ id, kind,
  typeId: 'dibujo', createdAt: 0, updatedAt: 0, ...(placement && { placement }),
  [kind]: { fileId, width, height, ...more } });
const picture = (id, file, w, h, width) => loadImage(file, asset(file)).then(() => add(id, file,
  'bitmap', w, h, { format: 'jpeg' }, width && { position: 'here', width, align: 'center' }));
await Promise.all([
  picture('charca', 'charca-1088.jpg', 1088, 463),
  picture('ciclo', 'ciclo-1088.jpg', 1088, 220, 0.9),
  picture('nina', 'nina-720.jpg', 720, 720, 0.3),
  ...Object.entries(ICONS).map(async ([id, svg]) => {
    await loadSvg(`icon-${id}.svg`, svg);
    add(`icon-${id}`, `icon-${id}.svg`, 'svg', 240, 240);
  }),
]);
const doc = await buildDocumentWithFonts({ markdown, resources }, config, kitFonts(FONTS));
showPages(doc, { title: t({ es: 'Cuaderno de actividades', en: 'Activity book' }) });
offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),
  `${RECIPE}-${LANG}.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 and asks the kit
// for Fontsource's files, the ones the PDF embeds too.

/** buildDocumentWithFonts' options: the resolver, and FONTS' faces loaded
 *  with the config's. 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 files with their ranges: latin, then latin-ext and greek when
 *  `text` needs them (kitSubsetsFor). Null for a face Fontsource lacks. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(await fontsourceWeights(family, style)).includes(weight)) 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],
  }));
}

/** Each file's unicode-range (a function: the kit runs after the pen's code). */
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: loads `faces` (as FONTS) for the
 *  sample `text`; `optional` skips a face Fontsource lacks. Files are added in
 *  the order asked, the last winning a shared character. Resolves to the count. */
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 && (await fontsourceWeights(family, style)).includes(weight))) 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 face (fonts.check() says yes to any family). */
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, id = fontsourceId(family)) {
  return (fontsourceMeta.c ??= {})[id] ??= fetch(`https://api.fontsource.org/v1/fonts/${id}`)
    .then((res) => (res.ok ? res.json() : null), () => null);
}

/** The weights the package ships in `style`: the API pairs none (no Libre
 *  Caslon 700 italic file), so italics are read from latin-italic.css. */
function fontsourceWeights(family, style, id = fontsourceId(family)) {
  return (fontsourceWeights.c ??= {})[id + style] ??= fontsourceMeta(family).then(async (m) => {
    if (!m?.styles.includes(style)) return m ? [] : [400, 700];
    const css = style === 'italic' ? await fetch(`${await fontsourceAt(id)}latin-italic.css`).then((r) => (r.ok ? r.text() : ''), () => '') : '';
    const w = [...css.matchAll(/weight: (\d+)/g)].map((x) => +x[1]);
    return w.length ? w : m.weights;
  });
}

// ─── 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 in that style, 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 s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style;
  const shipped = meta ? await fontsourceWeights(family, s) : [];
  const weights = shipped.length ? shipped : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  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 ───────────────────────────────────────────────────────────────────────
```

## アレンジ

### 先生用に答えを印刷する

同じ Markdown から先生用の版ができます。空欄はすべて埋まり、正しい文のボックスには印が付き、正しい語はマゼンタの丸で囲まれます。

```diff
   exerciseStyles: [actividad, cuadricula],
+  exercises: { edition: 'teacher' },
```

### 活動の番号を四角に入れる

単元の色の角丸の四角にすると、別の出版社の体裁に見えます。

```diff
-  number: { shape: 'disc', background: col('unit'), color: col('paper'),
+  number: { shape: 'rounded', background: col('unit'), color: col('paper'),
```

## よくあるつまずき

- 文章の中で丸で囲む語は、丸のために左右に少し余白を取ります。そのため、カンマやピリオドの直前にある語は、その記号から少し離れて見えます。

## クレジット

- レシピ: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- 画像: Animals at a pond: a frog, a kingfisher, a trout, a turtle and a rabbit.: Generated With Diffusion Models, オリジナル
- 画像: Five stages of a frog's life, in a jumbled order.: Generated With Diffusion Models, オリジナル
- 画像: A girl with a magnifying glass watching a sparrow and a lizard on a wall.: Generated With Diffusion Models, オリジナル
- 書体: Atkinson Hyperlegible Next (OFL-1.1), Grandstander (OFL-1.1)
- コード: MIT · サンプルの内容: CC-BY-4.0

## 関連レシピ

- [No. 174 · 答えを逆さに刷ったパズルページ](https://postext.dev/ja/cookbook/puzzle-page-upside-down-answers.md): 子ども向け雑誌のパズルページです。クロスワード、ワードサーチ、なぞなぞ、話の並べ替えなどをカードに載せ、答えはそれぞれ逆さに刷ります。 · 難易度 2 (中級) · 雑誌とジン, ワークブックと練習問題
- [No. 173 · 右から左へ組むアラビア語の練習帳](https://postext.dev/ja/cookbook/arabic-workbook-rtl.md): 右から左へ組んだアラビア語の小学校練習帳。أ ب جの選択肢、線で結ぶ2列、語群の枠、صحとخطأの欄、単語探し、アラビア・インド数字が入ります。 · 難易度 2 (中級) · ワークブックと練習問題, 教科書
- [No. 170 · 国ごとの書き方で組む筆算プリント](https://postext.dev/ja/cookbook/column-arithmetic-by-country.md): Markdown の1行 - 864 ÷ 4 がそのまま筆算になる計算プリント。数字を1マスに1つずつ置き、わり算の筆算は国ごとの学校の書き方に合わせます。 · 難易度 3 (上級) · ワークブックと練習問題, 教科書
