# Una pàgina de passatemps amb les solucions del revés

> La pàgina de passatemps d’una revista infantil: mots encreuats, sopa de lletres, endevinalla i història per ordenar, en targetes amb les solucions del revés.

- Versió HTML: https://postext.dev/ca/cookbook/puzzle-page-upside-down-answers
- Recepta Núm. 174 · Llibres de text · Nivell 2 (Intermedi) · Sortides: Canvas, PDF
- Gèneres: Revistes i fanzins, Quaderns i exercicis
- Requereix postext ≥ 1.59.0, postext-pdf ≥ 1.59.0 · provada amb 1.59.0, postext-pdf 1.59.0 el 2026-10-24
- Pàgines: [1](https://postext.dev/cookbook/puzzle-page-upside-down-answers/es/p01.webp?v=e721a2eb), [2](https://postext.dev/cookbook/puzzle-page-upside-down-answers/es/p02.webp?v=e721a2eb)
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=puzzle-page-upside-down-answers&lang=es (.postext: https://postext.dev/cookbook/puzzle-page-upside-down-answers/es/puzzle-page-upside-down-answers.postext)
- Última actualització: 2026-10-24
- Altres idiomes: [en](https://postext.dev/en/cookbook/puzzle-page-upside-down-answers.md), [es](https://postext.dev/es/cookbook/puzzle-page-upside-down-answers.md), [de](https://postext.dev/de/cookbook/puzzle-page-upside-down-answers.md), [fr](https://postext.dev/fr/cookbook/puzzle-page-upside-down-answers.md), [it](https://postext.dev/it/cookbook/puzzle-page-upside-down-answers.md), [pt](https://postext.dev/pt/cookbook/puzzle-page-upside-down-answers.md), [zh](https://postext.dev/zh/cookbook/puzzle-page-upside-down-answers.md), [ja](https://postext.dev/ja/cookbook/puzzle-page-upside-down-answers.md), [ar](https://postext.dev/ar/cookbook/puzzle-page-upside-down-answers.md)

## En poques paraules

La pàgina de passatemps d’una revista infantil sobre el mar. Cada passatemps va en la seva targeta, amb les solucions impreses del revés a sota, perquè els lectors girin la revista i les comprovin.

## Què compondràs

La pàgina de passatemps de *¡Chapuzón!*, una revista infantil sobre el mar, en dues pàgines de mida A4 a dues columnes. Una pintura de l’escull omple la part de dalt de la primera pàgina, amb el títol sobre l’aigua oberta. A sota, cada passatemps va en una targeta crema amb el número en corall: uns mots encreuats fets amb sis animals marins i les seves pistes, una sopa de lletres que amaga vuit paraules en horitzontal, en vertical, en diagonal i del revés, una endevinalla amb tres opcions per encerclar, la vida d’una tortuga per ordenar, una dada remenada i cries d’animals per unir amb el seu nom. Sota cada passatemps, dins la seva targeta, les solucions van del revés en lletra grisa petita, com les han amagat sempre les revistes de passatemps.

**Aquesta recepta respon a:**

- Com faig uns mots encreuats i una sopa de lletres amb una llista de paraules i les solucions del revés?
- Com imprimeixo les solucions cap per avall al peu d'un exercici?

## La resposta curta

```js
// script.js, línies 33–59
// placement 'inverted' sets each puzzle's key after it in a box turned 180°, inside the
// puzzle's card, so a page break never parts a puzzle from its answers. The key is made
// from the puzzle: the crossword's words by number, the ringed option, the right order.
const puzzle = {
  id: 'puzzle',
  box: 'card', // the card each puzzle sits on (a calloutStyles id)
  number: { shape: 'disc', fontFamily: DISPLAY, background: col('coral') },
  instruction: { fontFamily: TEXT, bold: false },
  options: { marker: 'circle', labels: 'none' }, // a ring to colour in, no letters
  grid: { fontFamily: LABEL, rule: col('sea'), cell: em(1.45) },
  match: { color: col('sea') },
  area: { kind: 'lines', color: col('sea') },
  answers: { color: col('coral'), fontFamily: LABEL },
  solutions: { placement: 'inverted', box: 'turned' },
  marginTop: mm(0), marginBottom: mm(0),
};
const card = { id: 'card', title: '', background: col('card'), borderRadius: mm(4),
  padding: { top: mm(3.5), right: mm(4), bottom: mm(3), left: mm(4) },
  titleStyle: { fontFamily: DISPLAY, fontSize: pt(15), fontWeight: 700, color: col('ink') },
  body: { fontFamily: TEXT, fontSize: pt(10.5), lineHeight: pt(15), color: col('ink'),
    firstLineIndent: pt(0) },
  marginTop: mm(0), marginBottom: mm(5), keepTogether: true };
const turned = { id: 'turned', title: '', backgroundEnabled: false, border: { enabled: false },
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
  body: { fontFamily: LABEL, fontSize: pt(8.5), lineHeight: pt(11), color: col('muted'),
    firstLineIndent: pt(0) },
  marginTop: mm(2), marginBottom: mm(0) };
```

## Ingredients

**Ensenya**

- [Sopes de lletres i mots encreuats](https://postext.dev/ca/docs/exercises.md#sopes-de-lletres-i-mots-encreuats): Sopes de lletres i mots encreuats a partir d'una llista de paraules (i pistes): el motor col·loca les paraules amb una llavor, omple la graella i numera els mots encreuats.
- [Solucions](https://postext.dev/ca/docs/exercises.md#solucions): On s'imprimeixen les solucions: enlloc, després de cada exercici, cap per avall al peu, al final del capítol o on hi ha una línia :::solutions.
- [Estils d'exercici](https://postext.dev/ca/docs/exercises.md#estils-dexercici): exerciseStyles: com s'imprimeix una família d'exercicis: el número (disc, quadrat, rètol), l'enunciat, les lletres de les opcions, els buits, les caselles de V/F, els punts per unir, el banc de paraules, les zones d'escriptura, les quadrícules, les icones d'activitat i la dificultat.

**També fa servir**

- [Exercicis](https://postext.dev/ca/docs/exercises.md#exercicis)
- [Elecció i vertader o fals](https://postext.dev/ca/docs/exercises.md#opció-múltiple-i-vertader-o-fals)
- [Unir i classificar](https://postext.dev/ca/docs/exercises.md#relacionar-i-classificar)
- [Ordenar](https://postext.dev/ca/docs/exercises.md#ordenació)
- [Preguntes obertes i zones d'escriptura](https://postext.dev/ca/docs/exercises.md#preguntes-obertes-i-espai-per-escriure)
- [Requadres](https://postext.dev/ca/docs/configuration-callouts.md#estils-de-requadre)
- [Tres o més columnes](https://postext.dev/ca/docs/configuration-page-layout.md#tipus-de-disposició)
- [Obertures dissenyades](https://postext.dev/ca/docs/configuration-headings.md#extensió-i-disseny-avançat)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Imatges als dissenys de pàgina](https://postext.dev/ca/docs/configuration-headers-footers.md#elements-dimatge)
- [Figures i taules com a recursos](https://postext.dev/ca/docs/document-format-resources.md#recursos)
- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration-fonts-colors-viewers.md#paleta-de-colors)
- [Exportació a PDF](https://postext.dev/ca/docs/configuration-pdf-output.md#generació-de-pdf)
- [Requadres que es parteixen o no](https://postext.dev/ca/docs/configuration-callouts.md#el-contenidor-callout)
- [Equilibri de columnes](https://postext.dev/ca/docs/configuration-page-flow.md#equilibratge-de-columnes)
- [Figures just aquí](https://postext.dev/ca/docs/document-format-resources.md#inserció-en-bloc-opcional-collocació-en-línia-explícita)
- [Banda de capítol d'amplada completa](https://postext.dev/ca/docs/configuration-headings.md#extensió-i-disseny-avançat)
- [Capçaleres segons el tipus de pàgina](https://postext.dev/ca/docs/configuration-headers-footers.md#elements-de-text)
- [Estils de paràgraf](https://postext.dev/ca/docs/configuration-styles.md#estils-de-paràgraf)
- [Fonts incrustades al PDF](https://postext.dev/ca/docs/configuration-pdf-output.md#per-què-un-proveïdor-de-fonts)
- [Tipus de recurs propis](https://postext.dev/ca/docs/configuration-resources.md#tipus-de-recurs)

**La configuració d'un cop d'ull**

- [`bodyText`](https://postext.dev/ca/docs/configuration-text.md#text-de-cos), [`calloutStyles`](https://postext.dev/ca/docs/configuration-callouts.md#estils-de-requadre), [`colorPalette`](https://postext.dev/ca/docs/configuration-fonts-colors-viewers.md#paleta-de-colors), `exerciseStyles`, [`footer`](https://postext.dev/ca/docs/configuration-headers-footers.md#capçaleres-i-peus), [`header`](https://postext.dev/ca/docs/configuration-headers-footers.md#capçaleres-i-peus), [`headings`](https://postext.dev/ca/docs/configuration-headings.md#encapçalaments), [`layout`](https://postext.dev/ca/docs/configuration-page-layout.md#disposició), [`locale`](https://postext.dev/ca/docs/configuration-language.md#partició-de-mots), [`page`](https://postext.dev/ca/docs/configuration-page-layout.md#pàgina), [`paragraphStyles`](https://postext.dev/ca/docs/configuration-styles.md#estils-de-paràgraf), [`resourceTypes`](https://postext.dev/ca/docs/configuration-resources.md#tipus-de-recurs)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/ca/docs/configuration-programmatic-usage.md#carregar-les-fonts-abans-de-compondre), [`decompressWoff2`](https://postext.dev/ca/docs/configuration-pdf-output.md#proveïdor-de-fonts-al-navegador-fontsource--woff2), [`registerResourceImage`](https://postext.dev/ca/docs/architecture.md#superfície-dapi), [`renderPageToCanvas`](https://postext.dev/ca/docs/configuration-programmatic-usage.md#renderitzar-una-pàgina-en-un-mapa-de-bits), [`renderToPdf`](https://postext.dev/ca/docs/configuration-pdf-output.md#generació-de-pdf)

**Tipus de lletra**

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

## Elaboració

### 1 · Cada passatemps en la seva targeta, amb les solucions girades

El codi és [la resposta curta](https://postext.dev/ca/cookbook/puzzle-page-upside-down-answers.md#la-resposta-curta) de més amunt. El `box` de l’estil d’exercici anomena un estil de requadre, així que cada passatemps va en una targeta, amb el número en un cercle i el títol com a primera línia de l’enunciat. `placement: 'inverted'` posa les solucions després de cada passatemps en un requadre girat 180°, dins la targeta, de manera que el passatemps i les solucions mai no cauen en pàgines diferents; les quadrícules i la línia d’escriptura queden dins del marge interior de la targeta.

### 2 · Deixa que el motor construeixi les quadrícules

El Markdown només porta les paraules: `- PULPO: Ocho brazos y tres corazones` per als mots encreuats i `- CONCHA` per a la sopa de lletres. El motor col·loca els mots encreuats de manera que cada paraula es creui amb una altra i numera els inicis en ordre de lectura. Les paraules de la sopa, les amaga en les direccions que demana l’obertura (`directions="across down diagonal backwards"`) i omple la resta amb lletres de les mateixes paraules. Totes dues fan servir una llavor, així que la quadrícula és la mateixa en cada composició i en cada sortida, i l’edició anglesa construeix la seva amb les seves pròpies paraules.

### 3 · Un escull a l’ample de les dues columnes

```js
// script.js, línies 63–82
const BAND = 104; // mm from the top edge: the painting's height at the page's width
const opener = { enabled: true, minHeight: mm(BAND - 16 + 6), slot: { elements: [
  { kind: 'image', id: 'reef', resourceId: 'reef', decorative: true, reserve: false,
    placement: { anchor: { to: 'page', edge: 'top-left' },
      size: { width: mm(210), height: mm(BAND) } } },
  { kind: 'text', id: 'kicker', content: '{attr.kicker}', fontFamily: LABEL, fontSize: pt(11),
    fontWeight: 600, color: col('paper'), textTransform: 'uppercase', letterSpacing: pt(2),
    box: { backgroundColor: col('coral'), borderRadius: mm(2),
      padding: { top: mm(0.8), bottom: mm(0.8), left: mm(2.4), right: mm(2.2) } },
    placement: { anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(16), y: mm(14) } } },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(56),
    fontWeight: 800, lineHeight: 1, color: col('paper'),
    placement: { anchor: { to: '#kicker', edge: 'below' }, offset: { x: mm(0), y: mm(3) } } },
  { kind: 'text', id: 'sub', content: '{attr.sub}', fontFamily: TEXT, fontSize: pt(12),
    fontWeight: 700, color: col('paper'), overflow: 'wrap', align: 'left',
    box: { backgroundColor: col('ink'), borderRadius: mm(2),
      padding: { top: mm(1.5), bottom: mm(1.5), left: mm(3), right: mm(3) } },
    placement: { anchor: { to: '#title', edge: 'below' }, offset: { x: mm(0), y: mm(2) },
      size: { width: mm(110) } } },
] } };
```

L’obertura ocupa les dues columnes (`span: 'page'`), així que els passatemps comencen sota la pintura a cada columna. La imatge està retallada a la proporció de la franja i ancorada a la pàgina, de manera que surt a sang per dalt i pels costats; l’entradeta va en una caixa fosca perquè es llegeixi sobre l’aigua.

## La recepta completa

Un sol fitxer, compost a partir de la carpeta de la recepta amb el text d'exemple i el kit comú del Receptari ja inclosos; construeix la seva pròpia pàgina. Per executar-lo, posa'l en un `<script type="module">` d'una pàgina buida o enganxa'l al tauler JS d'un pen nou de CodePen (com a mòdul). Importa postext des d'esm.sh, així que no cal instal·lar ni compilar res.

- Carpeta de la recepta: https://github.com/drnachio/postext/tree/main/cookbook/puzzle-page-upside-down-answers

### script.js

```js
// ═══ Postext Cookbook · Nº 174 · A puzzle page with the answers upside down ═══════
// https://postext.dev/en/cookbook/puzzle-page-upside-down-answers
// Code: MIT · Text: original (CC BY 4.0) · Pictures: generated with diffusion models
// Fonts: Baloo 2, Nunito, Fredoka (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 ('en' | 'es')
const RECIPE = 'puzzle-page-upside-down-answers';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: reef colours: deep blue ink, coral, sea green, a sandy paper
const palette = {
  ink: '#16324a', // text: a deep sea blue
  coral: '#e8573f', // the accent: puzzle numbers, the title kicker
  sea: '#0f8b8d', // the second: crossword and word-search rules, the riddle's rings
  sun: '#ffc93c', // the turtle card's badge
  card: '#fff4e0', // the puzzle cards
  muted: '#5d7180', // answers, colophon, folio
  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: 'accent (defaults)', value: { hex: palette.coral, model: 'hex' } },
];
// #endregion
const [DISPLAY, TEXT, LABEL] = ['Baloo 2', 'Nunito', 'Fredoka'];

// #region answer: every puzzle on a card, its answers turned upside down under it
// placement 'inverted' sets each puzzle's key after it in a box turned 180°, inside the
// puzzle's card, so a page break never parts a puzzle from its answers. The key is made
// from the puzzle: the crossword's words by number, the ringed option, the right order.
const puzzle = {
  id: 'puzzle',
  box: 'card', // the card each puzzle sits on (a calloutStyles id)
  number: { shape: 'disc', fontFamily: DISPLAY, background: col('coral') },
  instruction: { fontFamily: TEXT, bold: false },
  options: { marker: 'circle', labels: 'none' }, // a ring to colour in, no letters
  grid: { fontFamily: LABEL, rule: col('sea'), cell: em(1.45) },
  match: { color: col('sea') },
  area: { kind: 'lines', color: col('sea') },
  answers: { color: col('coral'), fontFamily: LABEL },
  solutions: { placement: 'inverted', box: 'turned' },
  marginTop: mm(0), marginBottom: mm(0),
};
const card = { id: 'card', title: '', background: col('card'), borderRadius: mm(4),
  padding: { top: mm(3.5), right: mm(4), bottom: mm(3), left: mm(4) },
  titleStyle: { fontFamily: DISPLAY, fontSize: pt(15), fontWeight: 700, color: col('ink') },
  body: { fontFamily: TEXT, fontSize: pt(10.5), lineHeight: pt(15), color: col('ink'),
    firstLineIndent: pt(0) },
  marginTop: mm(0), marginBottom: mm(5), keepTogether: true };
const turned = { id: 'turned', title: '', backgroundEnabled: false, border: { enabled: false },
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
  body: { fontFamily: LABEL, fontSize: pt(8.5), lineHeight: pt(11), color: col('muted'),
    firstLineIndent: pt(0) },
  marginTop: mm(2), marginBottom: mm(0) };
// #endregion

// #region opener: the reef to the bleed, the title over its open water
const BAND = 104; // mm from the top edge: the painting's height at the page's width
const opener = { enabled: true, minHeight: mm(BAND - 16 + 6), slot: { elements: [
  { kind: 'image', id: 'reef', resourceId: 'reef', decorative: true, reserve: false,
    placement: { anchor: { to: 'page', edge: 'top-left' },
      size: { width: mm(210), height: mm(BAND) } } },
  { kind: 'text', id: 'kicker', content: '{attr.kicker}', fontFamily: LABEL, fontSize: pt(11),
    fontWeight: 600, color: col('paper'), textTransform: 'uppercase', letterSpacing: pt(2),
    box: { backgroundColor: col('coral'), borderRadius: mm(2),
      padding: { top: mm(0.8), bottom: mm(0.8), left: mm(2.4), right: mm(2.2) } },
    placement: { anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(16), y: mm(14) } } },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(56),
    fontWeight: 800, lineHeight: 1, color: col('paper'),
    placement: { anchor: { to: '#kicker', edge: 'below' }, offset: { x: mm(0), y: mm(3) } } },
  { kind: 'text', id: 'sub', content: '{attr.sub}', fontFamily: TEXT, fontSize: pt(12),
    fontWeight: 700, color: col('paper'), overflow: 'wrap', align: 'left',
    box: { backgroundColor: col('ink'), borderRadius: mm(2),
      padding: { top: mm(1.5), bottom: mm(1.5), left: mm(3), right: mm(3) } },
    placement: { anchor: { to: '#title', edge: 'below' }, offset: { x: mm(0), y: mm(2) },
      size: { width: mm(110) } } },
] } };
// #endregion

const config = {
  locale: t({ en: 'en-gb', es: 'es' }),
  colorPalette,
  page: { sizePreset: 'custom', width: mm(210), height: mm(280), dpi: 150,
    margins: { top: mm(16), bottom: mm(18), left: mm(14), right: mm(14), mirror: true } },
  layout: { layoutType: 'double', gutterWidth: mm(7) },
  bodyText: { fontFamily: TEXT, fontSize: pt(10.5), lineHeight: pt(15), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'left', firstLineIndent: pt(0), hyphenation: { enabled: true } },
  headings: { fontFamily: DISPLAY, color: col('ink'), fontWeight: 800,
    balancing: { enabled: false },
    // The opener spans both columns; the puzzles start under the painting.
    levels: [{ level: 1, fontSize: pt(56), span: 'page', breakBefore: { parity: 'any' },
      marginBottom: pt(0), advancedDesign: opener }] },
  paragraphStyles: [{ id: 'colophon', fontFamily: TEXT, fontSize: pt(7.5), lineHeight: pt(10),
    color: col('muted'), firstLineIndent: pt(0), marginTop: mm(2) }],
  resourceTypes: [{ id: 'picture', name: t({ en: 'Picture', es: 'Dibujo' }), shortLabel: '',
    captionPrefix: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal',
    defaultPlacement: { position: 'here' } }],
  calloutStyles: [card, turned],
  exerciseStyles: [puzzle],
  header: { elements: [] },
  footer: { elements: [{ kind: 'text', id: 'folio', content: '{title} · {pageNumber}',
    fontFamily: LABEL, fontSize: pt(8), color: col('muted'), parity: 'all', pages: 'all',
    placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { x: mm(0), y: mm(-9) } } }] },
};

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "¡Chapuzón! · Número 14"
---

# Pasatiempos del arrecife {kicker="Pasatiempos" sub="Resuélvelos todos y luego gira la revista para ver las soluciones."}

:::exercise{type=crossword}
**Crucigrama marino**

Lee las pistas y rellena la cuadrícula.

- PULPO: Ocho brazos y tres corazones
- TIBURÓN: Un pez al que le salen dientes nuevos toda la vida
- CANGREJO: Camina de lado por la arena
- BALLENA: El animal más grande que ha existido
- CORAL: Millones de estos animalitos construyen el arrecife
- FOCA: Ladra en las rocas y nada como un torpedo
:::

:::exercise{type=wordsearch size=9 directions="across down diagonal backwards"}
**Sopa de letras de la charca**

Encuentra las ocho palabras. Van en horizontal, en vertical, en diagonal y también del revés.

- CONCHA
- OLA
- ARENA
- ALGA
- MAREA
- PERLA
- CALAMAR
- ROCA
:::

:::exercise
**Adivinanza del fondo del mar**

Cambio de color para esconderme, suelto tinta si me asusto y paso por un agujero del tamaño de una moneda. ¿Quién soy?

- [ ] Una estrella de mar
- [x] Un pulpo
- [ ] Una medusa
:::

:::exercise{type=order}
**El viaje de la tortuga**

Ordena la historia. Escribe del 1 al 5 en las casillas.

::resource{id="hatchling"}

1. Una tortuga madre pone sus huevos en la arena caliente.
2. Dos meses después, las crías excavan para salir.
3. De noche, corren por la playa hasta el mar.
4. Crecen lejos, en mar abierto.
5. Años más tarde, vuelven a la misma playa.
:::

:::exercise{type=scramble}
**Un dato revuelto**

Ordena las palabras para descubrir un dato cierto y escríbelo en la línea.

- Los / caballitos / de / mar / machos / llevan / a / las / crías / en / una / bolsa
:::

:::exercise
**¿Cómo se llama la cría?**

Une cada animal con el nombre de su cría.

- Ballena -> ballenato
- Foca -> cachorro
- Tortuga -> tortuguita
- Pez -> alevín
:::

:::paragraphs{style="colophon"}
Pasatiempos escritos para ¡Chapuzón! (CC BY 4.0). Ilustraciones generadas con modelos de difusión. Compuesto en Baloo 2, Nunito y Fredoka (SIL OFL).
:::
`;

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = {
  'Baloo 2': ['700', '800'], // the title, the puzzles' names and numbers
  Nunito: ['400', '400i', '700'], // instructions, clues, the riddle
  Fredoka: ['400', '600'], // grid letters, the kicker, the answers
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadImage('reef-1600x792.jpg', asset('reef-1600x792.jpg'));
await loadImage('hatchling-600.jpg', asset('hatchling-600.jpg'));
const resources = [
  { id: 'reef', typeId: 'picture', kind: 'bitmap', createdAt: 0, updatedAt: 0,
    bitmap: { fileId: 'reef-1600x792.jpg', format: 'jpeg', width: 1600, height: 792 } },
  { id: 'hatchling', typeId: 'picture', kind: 'bitmap', createdAt: 0, updatedAt: 0,
    bitmap: { fileId: 'hatchling-600.jpg', format: 'jpeg', width: 600, height: 600 },
    placement: { position: 'here', width: 0.42, align: 'center' },
    altText: t({ en: 'A baby sea turtle hatching from its egg.',
      es: 'Una cría de tortuga marina sale del huevo.' }) },
];
const doc = await buildDocumentWithFonts({ markdown, resources }, config, kitFonts(FONTS));
showPages(doc, { title: t({ en: 'Puzzle page, answers upside down',
  es: 'Pasatiempos con soluciones del revés' }) });
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 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 ───────────────────────────────────────────────────────────────────────
```

## Variants

### Amaga la llista de paraules

Una sopa de lletres més difícil imprimeix només la quadrícula: afegeix `hidden` a l’obertura.

```diff
-:::exercise{type=wordsearch size=9 directions="across down diagonal backwards"}
+:::exercise{type=wordsearch size=9 directions="across down diagonal backwards" hidden}
```

### Imprimeix les solucions al final de la revista

`chapterEnd` reuneix totes les solucions al final en lloc de girar-les una per una.

```diff
-  solutions: { placement: 'inverted', box: 'turned' },
+  solutions: { placement: 'chapterEnd' },
```

## Errors freqüents

- Una paraula dels mots encreuats que no es creua amb cap altra queda fora i s’avisa amb `exerciseWordUnplaced`. Tria paraules que comparteixin lletres o afegeix-ne una que uneixi dos grups.
- Una targeta de passatemps es manté sencera, així que uns mots encreuats alts passen a la columna següent si no hi caben: revisa l’edició espanyola, de paraules més llargues.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Imatges: A coral reef with a smiling sea turtle, an octopus, a clownfish, a pufferfish and a seahorse under sunbeams.: Generated With Diffusion Models, original
- Imatges: A baby sea turtle hatching from its egg on the sand.: Generated With Diffusion Models, original
- Tipus de lletra: Baloo 2 (OFL-1.1), Nunito (OFL-1.1), Fredoka (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 172 · Exercicis japonesos amb les solucions del revés](https://postext.dev/ca/cookbook/japanese-drill-upside-down-answers.md): Dues pàgines d’una sèrie d’exercicis japonesa, la de comptes en horitzontal i la de kanji en columnes, amb les solucions del revés sota cada exercici. · Nivell 2 (Intermedi) · Quaderns i exercicis, Llibres de text
- [Núm. 159 · Quadern d’activitats amb requadres que creixen](https://postext.dev/ca/cookbook/activity-book-growing-boxes.md): Un quadern de ciències fet amb plantilles: els requadres d’una pila creixen amb el text, les línies van en marcs buits i el projecte passa de pàgina. · Nivell 3 (Avançat) · Quaderns i exercicis, Llibres de text
- [Núm. 022 · Fitxa amb caixes de resposta i banc de paraules](https://postext.dev/ca/cookbook/worksheet-answer-boxes.md): Una fitxa de ciències de quatre pàgines: caixes de resposta blanques en targetes verd clar, a 2 mm de cada pregunta, i xips per a bancs de paraules i buits. · Nivell 2 (Intermedi) · Quaderns i exercicis
