# Eine Rätselseite mit Lösungen auf dem Kopf

> Die Rätselseite eines Kindermagazins: Kreuzworträtsel, Suchsel, Rätselfrage, eine Geschichte zum Ordnen und mehr auf Karten, die Lösungen auf dem Kopf.

- HTML-Fassung: https://postext.dev/de/cookbook/puzzle-page-upside-down-answers
- Rezept Nº 174 · Schulbücher · Stufe 2 (Mittel) · Ausgaben: Canvas, PDF
- Gattungen: Magazine und Zines, Arbeitshefte und Übungen
- Benötigt postext ≥ 1.59.0, postext-pdf ≥ 1.59.0 · getestet mit 1.59.0, postext-pdf 1.59.0 am 2026-10-24
- Seiten: [1](https://postext.dev/cookbook/puzzle-page-upside-down-answers/en/p01.webp?v=face85ae), [2](https://postext.dev/cookbook/puzzle-page-upside-down-answers/en/p02.webp?v=face85ae)
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=puzzle-page-upside-down-answers&lang=en (.postext: https://postext.dev/cookbook/puzzle-page-upside-down-answers/en/puzzle-page-upside-down-answers.postext)
- Zuletzt aktualisiert: 2026-10-24
- Andere Sprachen: [en](https://postext.dev/en/cookbook/puzzle-page-upside-down-answers.md), [es](https://postext.dev/es/cookbook/puzzle-page-upside-down-answers.md), [ca](https://postext.dev/ca/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)

## Kurz gesagt

Die Rätselseite eines Kindermagazins über das Meer. Jedes Rätsel steht auf einer eigenen Karte, die Lösungen sind darunter auf dem Kopf gedruckt, und zum Nachsehen drehen die Kinder das Heft um.

## Was Sie setzen

Die Rätselseite von *Splash!*, einem Kindermagazin über das Meer, auf zwei Seiten in A4 mit zwei Spalten. Ein Gemälde des Riffs füllt den oberen Teil der ersten Seite, der Titel steht über dem offenen Wasser. Darunter steht jedes Rätsel auf einer cremefarbenen Karte mit einer korallroten Nummer: ein Kreuzworträtsel aus sechs Meerestieren und ihren Fragen, ein Suchsel, das acht Wörter waagerecht, senkrecht, diagonal und rückwärts versteckt, eine Rätselfrage mit drei Antworten zum Einkreisen, das Leben einer Schildkröte zum Ordnen, ein Satz mit vertauschten Wörtern und Tierkinder, die mit ihrem Namen verbunden werden. Unter jedem Rätsel, auf seiner Karte, stehen die Lösungen in kleiner grauer Schrift auf dem Kopf, so wie Rätselhefte sie schon immer versteckt haben.

**Dieses Rezept beantwortet:**

- Wie mache ich aus einer Wortliste ein Kreuzworträtsel und ein Suchsel mit Lösungen auf dem Kopf?
- Wie drucke ich die Lösungen auf dem Kopf unter eine Übung?

## Die kurze Antwort

```js
// script.js, Zeilen 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) };
```

## Zutaten

**Zeigt**

- [Suchsel und Kreuzworträtsel](https://postext.dev/de/docs/exercises.md#wortsuchrätsel-und-kreuzworträtsel): Suchsel und Kreuzworträtsel aus einer Wortliste (mit Hinweisen): die Engine setzt die Wörter mit einem Seed, füllt das Raster und nummeriert das Kreuzworträtsel.
- [Lösungen](https://postext.dev/de/docs/exercises.md#lösungen): Wo die Lösungen gedruckt werden: nirgends, nach jeder Übung, auf dem Kopf an ihrem Fuß, am Kapitelende oder dort, wo eine Zeile :::solutions steht.
- [Übungsstile](https://postext.dev/de/docs/exercises.md#übungsstile): exerciseStyles: wie eine Familie von Übungen gedruckt wird: die Nummer (Kreis, Quadrat, Bezeichnung), die Arbeitsanweisung, die Buchstaben der Antworten, die Lücken, die R/F-Kästchen, die Punkte zum Verbinden, der Wortspeicher, die Schreibflächen, die Raster, die Symbole und der Schwierigkeitsgrad.

**Verwendet außerdem**

- [Übungen](https://postext.dev/de/docs/exercises.md#übungen)
- [Auswahl und richtig oder falsch](https://postext.dev/de/docs/exercises.md#auswahl-und-richtig-oder-falsch)
- [Zuordnen und sortieren](https://postext.dev/de/docs/exercises.md#zuordnen-und-sortieren)
- [Ordnen](https://postext.dev/de/docs/exercises.md#ordnen)
- [Offene Fragen und Schreibflächen](https://postext.dev/de/docs/exercises.md#offene-fragen-und-schreibflächen)
- [Kästen](https://postext.dev/de/docs/configuration-callouts.md#kastenstile)
- [Drei oder mehr Spalten](https://postext.dev/de/docs/configuration-page-layout.md#layout-typen)
- [Gestaltete Auftakte](https://postext.dev/de/docs/configuration-headings.md#seitenbreite-und-erweiterte-gestaltung)
- [Überschriftenattribute](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)
- [Bilder in Seitengestaltungen](https://postext.dev/de/docs/configuration-headers-footers.md#bildelemente)
- [Abbildungen und Tabellen als Ressourcen](https://postext.dev/de/docs/document-format-resources.md#ressourcen)
- [Semantische Farbpalette](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette)
- [PDF-Export](https://postext.dev/de/docs/configuration-pdf-output.md#pdfs-erzeugen)
- [Kästen, die sich teilen oder ganz bleiben](https://postext.dev/de/docs/configuration-callouts.md#der-container-callout)
- [Spaltenausgleich](https://postext.dev/de/docs/configuration-page-flow.md#spaltenausgleich)
- [Abbildungen genau hier](https://postext.dev/de/docs/document-format-resources.md#blockeinbettung-optional-ausdrückliche-platzierung-im-text)
- [Kapitelband über die ganze Breite](https://postext.dev/de/docs/configuration-headings.md#seitenbreite-und-erweiterte-gestaltung)
- [Kopfzeilen nach Seitenrolle](https://postext.dev/de/docs/configuration-headers-footers.md#textelemente)
- [Absatzformate](https://postext.dev/de/docs/configuration-styles.md#absatzformate)
- [Im PDF eingebettete Schriften](https://postext.dev/de/docs/configuration-pdf-output.md#warum-ein-font-provider)
- [Eigene Ressourcentypen](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen)

**Die Konfiguration auf einen Blick**

- [`bodyText`](https://postext.dev/de/docs/configuration-text.md#fließtext), [`calloutStyles`](https://postext.dev/de/docs/configuration-callouts.md#kastenstile), [`colorPalette`](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette), `exerciseStyles`, [`footer`](https://postext.dev/de/docs/configuration-headers-footers.md#kopf--und-fußzeilen), [`header`](https://postext.dev/de/docs/configuration-headers-footers.md#kopf--und-fußzeilen), [`headings`](https://postext.dev/de/docs/configuration-headings.md#überschriften), [`layout`](https://postext.dev/de/docs/configuration-page-layout.md#layout), [`locale`](https://postext.dev/de/docs/configuration-language.md#silbentrennung), [`page`](https://postext.dev/de/docs/configuration-page-layout.md#seite), [`paragraphStyles`](https://postext.dev/de/docs/configuration-styles.md#absatzformate), [`resourceTypes`](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen)

**APIs**

- [`buildDocumentWithFonts`](https://postext.dev/de/docs/configuration-programmatic-usage.md#schriften-vor-dem-layout-laden), [`decompressWoff2`](https://postext.dev/de/docs/configuration-pdf-output.md#font-provider-im-browser-fontsource--woff2), [`registerResourceImage`](https://postext.dev/de/docs/architecture.md#api-oberfläche), [`renderPageToCanvas`](https://postext.dev/de/docs/configuration-programmatic-usage.md#eine-seite-als-bitmap-rendern), [`renderToPdf`](https://postext.dev/de/docs/configuration-pdf-output.md#pdfs-erzeugen)

**Schriften**

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

## Zubereitung

### 1 · Jedes Rätsel auf eine Karte setzen, die Lösungen gedreht

Der Code steht oben in [der kurzen Antwort](https://postext.dev/de/cookbook/puzzle-page-upside-down-answers.md#die-kurze-antwort). Das `box` des Übungsstils nennt einen Kastenstil, deshalb steht jedes Rätsel auf einer Karte, mit seiner Nummer in einem Kreis und seinem Titel als erster Zeile der Aufgabenstellung. `placement: 'inverted'` setzt die Lösungen hinter jedes Rätsel in einen um 180° gedrehten Kasten innerhalb der Karte, sodass Rätsel und Lösung nie auf verschiedene Seiten fallen; die Raster und die Schreiblinie bleiben im Innenabstand der Karte.

### 2 · Die Raster von der Engine bauen lassen

Das Markdown enthält nur die Wörter: `- OCTOPUS: Eight arms and three hearts` für das Kreuzworträtsel, `- SHELL` für das Suchsel. Die Engine legt das Kreuzworträtsel so an, dass jedes Wort ein anderes kreuzt, und nummeriert die Anfänge in Leserichtung. Die Suchwörter versteckt sie in den Richtungen, die der Block nennt (`directions="across down diagonal backwards"`), und füllt den Rest mit den Buchstaben derselben Wörter. Beide Raster hängen an einem festen Startwert, sie sehen also bei jedem Lauf und in jeder Ausgabe gleich aus, und die spanische Ausgabe baut ihr eigenes Raster aus ihren eigenen Wörtern.

### 3 · Ein Riff über beide Spalten

```js
// script.js, Zeilen 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) } } },
] } };
```

Der Auftakt läuft über beide Spalten (`span: 'page'`), deshalb beginnen die Rätsel in jeder Spalte unter dem Gemälde. Das Bild ist auf die Proportionen des Bands zugeschnitten und an der Seite verankert, so läuft es oben und an den Seiten in den Anschnitt. Der Vorspann steht in einem dunklen Kasten, damit er sich über dem Wasser lesen lässt.

## Das ganze Rezept

Eine Datei, aus dem Ordner des Rezepts zusammengesetzt, mit dem Beispieltext und dem gemeinsamen Baukasten der Rezepte bereits eingebettet; sie baut ihre eigene Seite auf. Zum Ausführen setzen Sie sie in ein `<script type="module">` auf einer leeren Seite oder fügen sie in das JS-Feld eines neuen CodePen-Pens ein (als Modul). Sie importiert postext von esm.sh, es gibt also nichts zu installieren oder zu bauen.

- Quellordner: 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 = 'en'; // @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: "Splash! · Issue 14"
---

# Reef Puzzles {kicker="Puzzle page" sub="Solve them all, then turn the magazine round to check your answers."}

:::exercise{type=crossword}
**Sea-creature crossword**

Read the clues and fill in the grid.

- OCTOPUS: Eight arms and three hearts
- SHARK: A fish whose teeth grow back all its life
- CRAB: Walks sideways along the sand
- WHALE: The biggest animal that has ever lived
- CORAL: A reef is built by millions of these tiny animals
- SEAL: Barks on the rocks and swims like a torpedo
:::

:::exercise{type=wordsearch size=9 directions="across down diagonal backwards"}
**Rock-pool word search**

Find the eight words. They run across, down, diagonally and even backwards.

- SHELL
- WAVE
- SAND
- KELP
- REEF
- TIDE
- PEARL
- SQUID
:::

:::exercise
**Riddle of the deep**

I change colour to hide, I squirt ink when I am scared, and I can squeeze through a hole the size of a coin. Who am I?

- [ ] A starfish
- [x] An octopus
- [ ] A jellyfish
:::

:::exercise{type=order}
**From egg to ocean**

Put the story in order. Write 1 to 5 in the boxes.

::resource{id="hatchling"}

1. A mother turtle lays her eggs in the warm sand.
2. Two months later, the hatchlings dig their way out.
3. They race across the beach to the sea at night.
4. They grow up far away in the open ocean.
5. Years later, they come back to the same beach.
:::

:::exercise{type=scramble}
**Mixed-up fact**

Put the words in order to find a true fact, then write it on the line.

- Seahorse / dads / carry / the / babies / in / a / pouch
:::

:::exercise
**Baby names**

Join each sea animal to the name of its baby.

- Whale -> calf
- Seal -> pup
- Turtle -> hatchling
- Seahorse -> fry
:::

:::paragraphs{style="colophon"}
Puzzles written for Splash! (CC BY 4.0). Paintings generated with diffusion models. Set in Baloo 2, Nunito and 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 ───────────────────────────────────────────────────────────────────────
```

## Varianten

### Die Wortliste ausblenden

Ein schwereres Suchsel druckt nur das Raster: Fügen Sie dem Block `hidden` hinzu.

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

### Die Lösungen am Ende des Hefts drucken

`chapterEnd` sammelt alle Lösungen am Ende, statt jede einzeln zu drehen.

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

## Häufige Fehler

- Ein Wort des Kreuzworträtsels, das kein anderes kreuzt, fällt weg und wird als `exerciseWordUnplaced` gemeldet. Wählen Sie Wörter mit gemeinsamen Buchstaben, oder ergänzen Sie eines, das zwei Gruppen verbindet.
- Eine Rätselkarte bleibt zusammen, deshalb rückt ein hohes Kreuzworträtsel in die nächste Spalte, wenn es nicht passt. Prüfen Sie die spanische Ausgabe, deren Wörter länger sind.

## Nachweise

- Rezept: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Bilder: A coral reef with a smiling sea turtle, an octopus, a clownfish, a pufferfish and a seahorse under sunbeams.: Generated With Diffusion Models, Original
- Bilder: A baby sea turtle hatching from its egg on the sand.: Generated With Diffusion Models, Original
- Schrift: Baloo 2 (OFL-1.1), Nunito (OFL-1.1), Fredoka (OFL-1.1)
- Code: MIT · Beispielinhalt: CC-BY-4.0

## Verwandte Rezepte

- [Nº 172 · Japanisches Übungsheft mit Lösungen auf dem Kopf](https://postext.dev/de/cookbook/japanese-drill-upside-down-answers.md): Zwei Seiten einer japanischen Übungsheftreihe, Rechnen waagerecht und Kanji in Spalten, unter jeder Aufgabe ihre Lösungen auf dem Kopf. · Stufe 2 (Mittel) · Arbeitshefte und Übungen, Lehrbücher
- [Nº 159 · Arbeitsheft mit Kästen, die mitwachsen](https://postext.dev/de/cookbook/activity-book-growing-boxes.md): Ein Sachkunde-Arbeitsheft aus Seitenvorlagen: Kästen in einem Stapel wachsen mit ihrem Text, Schreiblinien liegen auf leeren Rahmen, das Projekt läuft weiter. · Stufe 3 (Fortgeschritten) · Arbeitshefte und Übungen, Lehrbücher
- [Nº 022 · Arbeitsblatt mit Antwortkästen und Wortspeicher](https://postext.dev/de/cookbook/worksheet-answer-boxes.md): Ein vierseitiges Sachkunde-Arbeitsblatt: weiße Antwortkästen in hellgrünen Karten, 2 mm unter jeder Frage, ohne Raster, mit Wortspeichern und Lücken aus Chips. · Stufe 2 (Mittel) · Arbeitshefte und Übungen
