# Uma unidade de livro didático infantil em três idiomas

> Nove páginas de uma unidade do 3.º ano em espanhol, catalão e inglês: páginas duplas sobre fundos coloridos, caixas com aba que crescem e um mapa rotulado.

- Versão HTML: https://postext.dev/pt/cookbook/children-textbook-pages
- Receita Nº 156 · Publicações completas · Nível 3 (Avançado) · Saídas: Canvas, PDF
- Gêneros: Livros didáticos
- Requer postext ≥ 1.40.0, postext-pdf ≥ 1.40.0 · testada com 1.40.0, postext-pdf 1.40.0 em 2026-10-17
- Páginas: [1](https://postext.dev/cookbook/children-textbook-pages/en/p01.webp?v=cb4d77a3), [2](https://postext.dev/cookbook/children-textbook-pages/en/p02.webp?v=cb4d77a3), [3](https://postext.dev/cookbook/children-textbook-pages/en/p03.webp?v=cb4d77a3), [4](https://postext.dev/cookbook/children-textbook-pages/en/p04.webp?v=cb4d77a3), [5](https://postext.dev/cookbook/children-textbook-pages/en/p05.webp?v=cb4d77a3), [6](https://postext.dev/cookbook/children-textbook-pages/en/p06.webp?v=cb4d77a3), [7](https://postext.dev/cookbook/children-textbook-pages/en/p07.webp?v=cb4d77a3), [8](https://postext.dev/cookbook/children-textbook-pages/en/p08.webp?v=cb4d77a3), [9](https://postext.dev/cookbook/children-textbook-pages/en/p09.webp?v=cb4d77a3)
- PDF: https://postext.dev/cookbook/children-textbook-pages/en/children-textbook-pages.pdf?v=cb4d77a3
- Abrir no Sandbox: https://postext.dev/pt/sandbox#recipe=children-textbook-pages&lang=en (.postext: https://postext.dev/cookbook/children-textbook-pages/en/children-textbook-pages.postext)
- Última atualização: 2026-10-17
- Outros idiomas: [en](https://postext.dev/en/cookbook/children-textbook-pages.md), [es](https://postext.dev/es/cookbook/children-textbook-pages.md), [ca](https://postext.dev/ca/cookbook/children-textbook-pages.md), [de](https://postext.dev/de/cookbook/children-textbook-pages.md), [fr](https://postext.dev/fr/cookbook/children-textbook-pages.md), [it](https://postext.dev/it/cookbook/children-textbook-pages.md), [zh](https://postext.dev/zh/cookbook/children-textbook-pages.md), [ja](https://postext.dev/ja/cookbook/children-textbook-pages.md), [ar](https://postext.dev/ar/cookbook/children-textbook-pages.md)

## Em poucas palavras

Uma unidade de livro didático sobre um rio, com nove páginas. Cada tema é uma página dupla sobre um fundo colorido, com aba de título, texto, imagens e caixas. O mapa não tem nenhuma palavra pintada: os nomes vêm do texto.

## O que você vai compor

Nove páginas de *Nosso rio*, unidade 4 de um livro inventado de Ciências Sociais do 3.º ano, numa página de 210 × 280 mm, em espanhol, catalão e inglês. Um vale pintado abre a unidade, uma página de sumário lista duas partes e seguem três temas em página dupla, cada um sobre o seu fundo: azul-água, verde-campo, areia-sol. Cada tema tem uma aba com o número e o título, texto em duas colunas e caixas com aba que crescem com o texto. O segundo tema é um único mapa pintado em página dupla, com o rio, as aldeias e os três cursos nomeados a partir do Markdown. Os três textos diferem em um quinto ou mais e cabem nos mesmos modelos: uma caixa toma a altura que as suas palavras pedem e empurra a seguinte, e o mapa é reduzido um pouco antes de qualquer texto.

**Esta receita responde a:**

- Como componho uma unidade de livro didático em três idiomas com os mesmos modelos de página?
- Como faço os boxes de uma página crescerem com o texto e empurrarem para baixo os que estão embaixo?
- Como coloco os nomes de um mapa a partir do texto, para que uma tradução troque as palavras e não o desenho?

## A resposta curta

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

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

## Ingredientes

**Ensina**

- [Páginas compostas](https://postext.dev/pt/docs/composed-pages.md#páginas-compostas): Uma página montada a partir de um modelo de quadros com nome na configuração e preenchida por uma cerca ::::page cujo conteúdo nomeia cada quadro pelo seu slot.
- [Quadros que crescem num grupo](https://postext.dev/pt/docs/composed-pages.md#quando-o-texto-cresce): Uma pilha ou fila de quadros de uma página composta: um quadro tão alto quanto o seu texto, nunca abaixo do minHeight, empurra os quadros seguintes e o que está ancorado neles.
- [Abas numeradas nos boxes](https://postext.dev/pt/docs/configuration-callouts.md#estilos-de-boxe): Uma aba de rótulo na borda do boxe (“BOXE 1-1”) tirada do atributo label da cerca do bloco.

**Também usa**

- [Rótulos sobre a imagem](https://postext.dev/pt/docs/composed-pages.md#figuras-com-rótulos)
- [Sumário](https://postext.dev/pt/docs/configuration-notes-references.md#sumário)
- [Modelos em página dupla](https://postext.dev/pt/docs/composed-pages.md#páginas-duplas)
- [Boxes](https://postext.dev/pt/docs/configuration-callouts.md#estilos-de-boxe)
- [Textos, fios e caixas nos designs de página](https://postext.dev/pt/docs/configuration-headers-footers.md#cabeços-e-rodapés)
- [Imagens nos designs de página](https://postext.dev/pt/docs/configuration-headers-footers.md#elementos-de-imagem)
- [Ancoragem de elementos de design](https://postext.dev/pt/docs/configuration-headers-footers.md#posicionamento-de-elementos)
- [Decoração ancorada aos blocos](https://postext.dev/pt/docs/composed-pages.md#decoração-cabeços-e-camadas)
- [Margens espelhadas](https://postext.dev/pt/docs/configuration-page-layout.md#margens-espelhadas)
- [Cabeços e fólios](https://postext.dev/pt/docs/configuration-headers-footers.md#cabeços-e-rodapés)
- [Estilos de título](https://postext.dev/pt/docs/configuration-styles.md#estilos-de-título)
- [Paleta de cores semântica](https://postext.dev/pt/docs/configuration-fonts-colors-viewers.md#paleta-de-cores)
- [Figuras e tabelas como recursos](https://postext.dev/pt/docs/document-format-resources.md#recursos)
- [Páginas em um canvas](https://postext.dev/pt/docs/configuration-programmatic-usage.md#renderizar-uma-página-como-bitmap)
- [Exportação para PDF](https://postext.dev/pt/docs/configuration-pdf-output.md#geração-de-pdf)
- [Blocos que afastam o texto, pela caixa ou pelo contorno](https://postext.dev/pt/docs/composed-pages.md#blocos-que-afastam-o-texto)
- [Atributos de título](https://postext.dev/pt/docs/document-format.md#atributos-de-título)
- [Aberturas desenhadas](https://postext.dev/pt/docs/configuration-headings.md#largura-e-design-avançado)
- [Páginas de abertura de parte](https://postext.dev/pt/docs/configuration-styles.md#o-contêiner-part)
- [Fontes incorporadas ao PDF](https://postext.dev/pt/docs/configuration-pdf-output.md#por-que-um-provedor-de-fontes)
- [Tipos de recurso personalizados](https://postext.dev/pt/docs/configuration-resources.md#tipos-de-recurso)
- [Capítulos sem número](https://postext.dev/pt/docs/configuration-styles.md#estilos-de-título)

**A configuração em resumo**

- [`bodyText`](https://postext.dev/pt/docs/configuration-text.md#texto-do-corpo), [`calloutStyles`](https://postext.dev/pt/docs/configuration-callouts.md#estilos-de-boxe), [`captionStyle`](https://postext.dev/pt/docs/configuration-resources.md#estilo-de-legenda), [`colorPalette`](https://postext.dev/pt/docs/configuration-fonts-colors-viewers.md#paleta-de-cores), [`footer`](https://postext.dev/pt/docs/configuration-headers-footers.md#cabeços-e-rodapés), [`header`](https://postext.dev/pt/docs/configuration-headers-footers.md#cabeços-e-rodapés), [`headingStyles`](https://postext.dev/pt/docs/configuration-styles.md#estilos-de-título), [`headings`](https://postext.dev/pt/docs/configuration-headings.md#títulos), [`labelStyles`](https://postext.dev/pt/docs/configuration-composed-pages.md#estilos-de-rótulo), [`layout`](https://postext.dev/pt/docs/configuration-page-layout.md#diagramação), [`locale`](https://postext.dev/pt/docs/configuration-language.md#hifenização), [`orderedLists`](https://postext.dev/pt/docs/configuration-lists-math.md#listas-numeradas), [`page`](https://postext.dev/pt/docs/configuration-page-layout.md#página), [`pageTemplates`](https://postext.dev/pt/docs/configuration-composed-pages.md#modelos-de-página), [`parts`](https://postext.dev/pt/docs/configuration-styles.md#partes), [`resourceTypes`](https://postext.dev/pt/docs/configuration-resources.md#tipos-de-recurso), [`toc`](https://postext.dev/pt/docs/configuration-notes-references.md#sumário), [`unorderedLists`](https://postext.dev/pt/docs/configuration-lists-math.md#listas-com-marcadores)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/pt/docs/configuration-programmatic-usage.md#carregar-as-fontes-antes-do-layout), [`decompressWoff2`](https://postext.dev/pt/docs/configuration-pdf-output.md#provedor-de-fontes-no-navegador-fontsource--woff2), [`registerResourceImage`](https://postext.dev/pt/docs/architecture.md#superfície-da-api), [`renderPageToCanvas`](https://postext.dev/pt/docs/configuration-programmatic-usage.md#renderizar-uma-página-como-bitmap), [`renderToPdf`](https://postext.dev/pt/docs/configuration-pdf-output.md#geração-de-pdf)

**Tipos**

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

## Preparo

### 1 · Construa as caixas com `extends`

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

Um estilo de caixa se faz em três passos. `caja` guarda a forma, a tipografia e a geometria da aba; `caja-agua`, `caja-campo` e `caja-sol` põem a cor de uma família na aba e nos marcadores; `dato-`, `palabras-` e `actividad-` fixam o tipo: caixa com fundo, com borda ou com faixa. Uma família nova são três cores em `FAMILIES` e nenhum estilo se escreve duas vezes.

### 2 · Faça cada tema herdar de um modelo base

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

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

A base é uma página dupla: um fundo até a sangria, uma aba de título com o número e um texto em duas colunas. Um tema a estende (`extends: 'tema'`), muda as cores de três elementos de decoração pelo seu `id` e acrescenta seus blocos. A aba e o número são decoração ancorada ao bloco do título: a aba é tão alta quanto o título que o idioma pede, e o número fica à esquerda do bloco, onde nada o cobre.

### 3 · Deixe os grupos dividirem o espaço

Cada tema tem dois grupos. A página esquerda é uma pilha: título, texto e depois a tira de imagens ou o mapa. A direita é outra pilha de caixas, ou uma fileira de três caixas que dividem a largura. Um bloco é tão alto quanto as suas palavras e nunca menos que o seu `minHeight`: o texto em espanhol empurra tudo o que tem embaixo e nenhuma posição do modelo muda. Quando falta espaço a uma página, a [escada de ajuste](https://postext.dev/pt/docs/composed-pages.md#quando-ainda-assim-não-cabe) recupera espaço, reduz o mapa e as imagens e deixa o texto por último, até 90 %. O `pnpm cookbook bench` recompõe as três edições com o texto 30 % mais longo.

### 4 · Ponha os nomes do mapa a partir do texto

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

O mapa é uma pintura sem uma letra. Cada lugar é uma âncora da imagem, um ponto em frações da largura e da altura com um estilo de rótulo, e cada edição escreve as suas próprias linhas `::label` nessas âncoras. *Penalta Reservoir*, *Pantà de Penyalta* e *Embalse de Peñalta* ficam no mesmo lugar, e um nome mais longo se quebra dentro do `width` da âncora.

### 5 · Mantenha cada parte do sumário unida

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

`:::toc{keep="part" levels="1-2"}` lista os temas e as suas seções e mantém cada linha de parte com as suas entradas. Uma cerca `:::part` com `parts.page: false` é só uma linha do sumário e uma seção para os cabeçalhos: não acrescenta página de abertura. O sumário é uma página de texto corrido e não um bloco, porque um `:::toc` dentro de um bloco perde as linhas de parte.

## A receita completa

Um único arquivo, composto a partir da pasta da receita com o texto de exemplo e o kit comum das Receitas já incluídos; ele monta a própria página. Para executá-lo, coloque-o em um `<script type="module">` de uma página vazia ou cole-o no painel JS de um pen novo do CodePen (como módulo). Ele importa o postext do esm.sh, então não há nada para instalar nem compilar.

- Pasta da receita: https://github.com/drnachio/postext/tree/main/cookbook/children-textbook-pages

### script.js

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

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

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

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

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

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

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

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

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

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

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

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

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Our river"
subtitle: "Social science · Year 3"
---

::::page{template="unidad" unidad="Unit 4 · Social science"}
# Our river {style="portada"}

Every town you know stands near water. This unit follows one river, the Aldara, from the snow where it starts to the sea where it ends. You will find out where a river is born, how it changes on its way down, and how the people of Fontalba, Molinar and Aldamar live beside it.

:::frame{slot="aprenderas" label="You will learn"}
- How a spring becomes a stream, and a stream becomes a river.
- What the upper, middle and lower course of a river are.
- How people use river water, and how to look after it.
:::

:::frame{slot="colofon"}
Set in Nunito and Fredoka (SIL OFL) · Original text (CC BY 4.0) · Illustrations generated with diffusion models
:::
::::

# In this unit {style="indice"}

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

:::figure{resource="rio"}
::alt[Painted map of the valley of the River Aldara, from the snowy mountains to the sea.]
::caption[The River Aldara from its spring to the sea. The map is on pages 6 and 7.]
:::

::::page{template="saberes"}
# What do you know about rivers? {style="previo"}

Before you start, think about the rivers you have seen. Write your ideas on the lines. At the end of the unit, come back to them and see what has changed.

:::frame{slot="pregunta-1" label="Think 1"}
Where do you think a river starts?
:::

:::frame{slot="pregunta-2" label="Think 2"}
What do people use a river for?
:::

:::frame{slot="pregunta-3" label="Think 3"}
Which animals and plants live in or beside a river?
:::
::::

:::part{number="A" title="A river from the mountains to the sea"}
:::

::::page{template="tema-agua" spread numero="1"}
# Where a river is born

Rain and snow fall on the mountains. Some of the water runs down the slopes and some soaks into the ground. Underground it moves slowly, until it meets rock it cannot cross. Then it comes out into the open. A place where water comes out of the ground is called a **spring**.

## From spring to stream

A spring gives only a trickle. The trickle runs downhill and meets other trickles, and together they make a **stream**. On a steep slope a stream runs fast. It rolls pebbles along and wears them smooth. When streams meet, they make a bigger one. The River Aldara begins like this, in the snow of the White Range, 1,850 metres above the sea.

:::figure{resource="nacimiento" slot="foto-1"}
::alt[Clear water trickling out between mossy rocks.]
::caption[A spring. The water has been underground for months.]
:::

:::figure{resource="arroyo" slot="foto-2"}
::alt[A stream tumbling over round stones.]
::caption[A stream, steep and fast, with pebbles worn smooth.]
:::

:::figure{resource="cascada" slot="foto-3"}
::alt[A small waterfall falling into a green pool.]
::caption[A waterfall, where the stream drops over a step of rock.]
:::

:::frame{slot="probar" label="Try it"}
Hold a wet sponge over a tray and squeeze it gently. Where does the water come out? Watch how it drips and then runs. That is how a spring feeds a stream.
:::

:::frame{slot="sabias" label="Did you know?"}
At its spring, the Aldara is a trickle you could step over. After 94 kilometres it is as wide as a football pitch is long.
:::

:::frame{slot="palabras" label="Words to learn"}
**Spring:** a place where water comes out of the ground.

**Stream:** a small river that runs fast down a slope.

**Tributary:** a stream that flows into a bigger river.

**Bank:** the land along the side of a river.
:::

:::frame{slot="actividades" label="Activities"}
1. Look at the three pictures. Which one shows a spring? How can you tell?
2. Why does a stream run faster in the mountains than in the valley?
3. Which of the four words in the box can you see in the pictures?
:::
::::

::::page{template="tema-campo" spread numero="2"}
# From the mountains to the sea

The River Aldara is 94 kilometres long. It starts in the White Range, crosses a valley and a plain, and reaches the sea at Aldamar. Geographers split a river into three parts. Each part is called a **course**, and each one looks different.

:::figure{resource="mapa" slot="mapa"}
::alt[A painted map of the valley: snowy mountains and a spring at the left, the village of Fontalba, a reservoir with a dam, the town of Molinar with its bridge and mill, farm fields, the river bending across a plain, and the port of Aldamar by the sea at the right.]
::label[White Range]{at="sierra"}
::label[Spring]{at="nacimiento"}
::label[Fontalba]{at="fontalba"}
::label[Penalta Reservoir]{at="embalse"}
::label[Molinar]{at="molinar"}
::label[Aldamar]{at="aldamar"}
::label[Sea]{at="mar"}
::label[River Aldara]{at="rio"}
::label[Upper course]{at="alto"}
::label[Middle course]{at="medio"}
::label[Lower course]{at="bajo"}
:::

:::frame{slot="actividad" label="Look at the map"}
Follow the river with your finger from the spring to the sea. Which town is nearest to the sea?
:::

:::frame{slot="curso-1" label="Upper course"}
The slope is steep. The water is cold, clear and fast, and it cuts into the rock. Fontalba stands here.
:::

:::frame{slot="curso-2" label="Middle course"}
The valley is wider and the river slows down. Farmers water their fields, and Molinar grew around a bridge and a mill.
:::

:::frame{slot="curso-3" label="Lower course"}
The land is almost flat. The river is wide and slow, and it bends in great loops. At Aldamar it flows into the sea.
:::
::::

:::part{number="B" title="People and the river"}
:::

::::page{template="tema-sol" spread numero="3"}
# People and the river

People have always settled near rivers. They need water to drink, to wash and to grow food. A river also gives fish, a road for boats and the push that turns a wheel.

## Looking after the river

A river stays clean only if we keep it clean. Rubbish, oil and dirty water make the river sick, and the fish and plants that live in it suffer. Everything that goes down a drain or a street ends up in a river or in the sea.

:::frame{slot="cuidar" label="We look after our river"}
- We put rubbish in the bin, never in the water.
- We use water carefully and turn off the tap.
- We keep to the paths and leave plants and animals alone.
- We help to clean the bank when the school organises it.
:::

:::frame{slot="turno" label="Your turn"}
1. Draw a poster with three rules to keep our river clean.
2. Ask at home: where does the water from your tap come from?
:::

:::figure{resource="limpieza" slot="cuidar-foto"}
::alt[Children with gloves and paper bags picking up litter on a river bank.]
:::

:::frame{slot="uso-1" label="1 · Water for the fields"}
In the middle course, farmers open small canals so the river water runs between the rows. Without it, tomatoes and lettuces would not grow in summer. A single canal can carry water for many kilometres.
:::

:::figure{resource="riego" slot="uso-1-foto"}
::alt[A farmer lifting a gate in an irrigation canal in a field of vegetables.]
:::

:::frame{slot="uso-2" label="2 · Mills"}
Long ago the falling water turned the wheel of the mill at Molinar. The wheel moved the stones that ground wheat into flour for the whole valley. Today the old mill is a small museum.
:::

:::figure{resource="molino" slot="uso-2-foto"}
::alt[An old stone mill with a big wooden wheel beside a river.]
:::

:::frame{slot="uso-3" label="3 · Sport and play"}
Now the calm lower course is a place for canoes. On summer mornings children from Aldamar learn to paddle with a monitor, and everyone wears a life jacket.
:::

:::figure{resource="piraguas" slot="uso-3-foto"}
::alt[Children in life jackets paddling two canoes on a calm river.]
:::
::::
`; // content.<lang>.md, inlined by the Cookbook

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

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

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

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

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

// ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout (prepareFonts,
// buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the
// files the PDF embeds too.

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

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

/** The unicode-range of each Fontsource file the kit loads (a function: the
 *  kit is inlined after the pen's code, which runs before a const would be set). */
function kitRanges() {
  return {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
    greek: 'U+0370-03FF',
  };
}

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

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

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

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

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

/** The family's Fontsource metadata (weights, styles, subsets), or null. */
function fontsourceMeta(family) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

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

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

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

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

// ─── Kit · pdf v3 ── the same in every recipe that exports a PDF
/** The Fontsource files the screen used, as TrueType, with their range and
 *  place: the nearest weight the family ships, upright with no italic;
 *  latin, then what its letters need (kitSubsetsFor). */
async function fontsourceProvider(family, weight, style, request) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  const weights = meta?.weights?.length ? meta.weights : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style;
  const text = String.fromCodePoint(...(request?.codePoints ?? []));
  const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => {
    const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`);
    if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`);
    return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())),
      { unicodeRange: kitRanges()[subset], order });
  }));
  return files.length === 1 ? files[0] : files;
}

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

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

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

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

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

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

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

## Variações

### Acrescente uma quarta família

Um tema sobre o mar é uma quarta entrada de `FAMILIES` e um modelo feito com `topic('tema-mar', 'mar', { frames })`. O laço sobre `FAMILIES` escreve os seus estilos, `caja-mar`, `dato-mar` e os demais.

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

### Deixe o texto no seu tamanho

Um bloco que não deve reduzir o texto leva `fit: { shrinkText: false }`. Um texto mais longo empurra então com mais força os blocos em volta e a escada chega antes às imagens.

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

## Erros comuns

- **Um sumário não pode ser composto dentro de um quadro de uma página composta.** Um :::toc dentro de um :::frame de uma página composta é composto como os parágrafos do quadro: as linhas de parte de toc.parts.design não são desenhadas e deixam uma linha vazia, cada entrada é seguida do espaçamento de parágrafo do documento e o título do próprio sumário aparece nele. Componha o sumário como uma página de texto corrido: um estilo de título (numbered: false, toc: false) cujo advancedDesign pinta o título, seguido de :::toc{keep="part"} no texto, onde as linhas de parte, os pontilhados e a regra keep funcionam. O seu desenho para na margem da coluna, de modo que a cor de fundo dessa página terá de esperar que o motor componha o sumário como página composta.

- Um nome pintado dentro de uma imagem não poderia mudar com o idioma: o mapa não leva nenhuma palavra e cada nome é um rótulo.
- Um elemento de decoração que fica sob um bloco não pode ser clicado no Sandbox; por isso o número de cada tema está à esquerda do bloco do título e não dentro.
- Duas figuras que definem o mesmo recurso tomam as palavras da primeira: o mapa pequeno do sumário é o mesmo arquivo sob outro recurso, `rio`, sem âncoras.

## Créditos

- Receita: Your Name ([@your-github](https://github.com/your-github))
- Texto: Our river, a children's textbook unit written for this recipe in Spanish, Catalan and English: Postext Cookbook, CC-BY-4.0
- Imagens: The valley of the River Aldara, painted from the mountains to the sea, with two children watching the water.: Generated With Diffusion Models, original
- Imagens: The painted map of the valley, from the spring to the port, with no lettering: the names are labels set from the Markdown.: Generated With Diffusion Models, original
- Imagens: A spring between mossy rocks.: Generated With Diffusion Models, original
- Imagens: A mountain stream over round stones.: Generated With Diffusion Models, original
- Imagens: A small waterfall into a green pool.: Generated With Diffusion Models, original
- Imagens: A farmer opening the gate of an irrigation canal.: Generated With Diffusion Models, original
- Imagens: An old stone watermill with its wheel.: Generated With Diffusion Models, original
- Imagens: Children paddling canoes on a calm river.: Generated With Diffusion Models, original
- Imagens: Children and a teacher picking up litter on a river bank.: Generated With Diffusion Models, original
- Tipos: Nunito (OFL-1.1), Fredoka (OFL-1.1)
- Código: MIT · Conteúdo de exemplo: CC-BY-4.0

## Relacionadas

- [Nº 157 · Um atlas escolar rotulado a partir do Markdown](https://postext.dev/pt/cookbook/school-atlas-labelled-maps.md): Mapas pintados sem uma letra e nomeados por linhas ::label nas âncoras de cada recurso, em páginas compostas e numa página dupla, em três idiomas. · Nível 3 (Avançado) · Livros didáticos
- [Nº 159 · Caderno de atividades com boxes que crescem](https://postext.dev/pt/cookbook/activity-book-growing-boxes.md): Um caderno de ciências feito com modelos: os boxes de uma pilha crescem com o texto, as linhas ficam em quadros vazios e o projeto continua na página seguinte. · Nível 3 (Avançado) · Cadernos de exercícios, Livros didáticos
- [Nº 158 · Uma linha do tempo ilustrada em página dupla](https://postext.dev/pt/cookbook/illustrated-timeline-spread.md): Um modelo de página dupla para a linha do tempo de um livro didático: uma faixa atravessa a lombada e duas fileiras de fichas dividem a largura. · Nível 3 (Avançado) · Livros didáticos
