# A puzzle page with the answers upside down

> A children's magazine puzzle page: crossword, word search, riddle, story to order, matching and a mixed-up fact on cards, each with its answers upside down.

- HTML version: https://postext.dev/en/cookbook/puzzle-page-upside-down-answers
- Recipe Nº 174 · Textbooks · Level 2 (Intermediate) · Outputs: Canvas, PDF
- Genres: Magazines & zines, Workbooks & exercises
- Requires postext ≥ 1.59.0, postext-pdf ≥ 1.59.0 · tested with 1.59.0, postext-pdf 1.59.0 on 2026-10-24
- Pages: [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)
- Open in Sandbox: https://postext.dev/en/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)
- Last updated: 2026-10-24
- Other languages: [es](https://postext.dev/es/cookbook/puzzle-page-upside-down-answers.md), [ca](https://postext.dev/ca/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)

## In short

The puzzle page of a children's magazine about the sea. Each puzzle sits on its own card, and its answers are printed upside down underneath, so readers turn the magazine round to check.

## What you'll build

The puzzle page of *Splash!*, a children's magazine about the sea, on two A4-sized pages in two columns. A reef painting fills the top of the first page with the title over its open water. Below it, each puzzle sits on a cream card with a coral number: a crossword built from six sea creatures and their clues, a word search that hides eight words across, down, diagonally and backwards, a riddle with three options to ring, a turtle's life to put in order, a mixed-up fact to unscramble and baby animals to join with their names. Under every puzzle, inside its card, the answers are printed upside down in small grey type, the way puzzle magazines have always kept them out of sight.

**This recipe answers:**

- How do I make a crossword and a word search from a list of words, with the answers upside down?
- How do I print the answers upside down at the foot of an exercise?

## The short answer

Every puzzle on a card, its answers turned upside down under it.

```js
// script.js, lines 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

**Teaches**

- [Word searches and crosswords](https://postext.dev/en/docs/exercises.md#word-searches-and-crosswords): Word searches and crosswords built from a list of words (and clues): the engine places the words with a seed, fills the grid, numbers the crossword and prints its clue lists in the document's language.
- [Solutions](https://postext.dev/en/docs/exercises.md#solutions): Where the answers print: none, after each exercise, upside down at its foot, at the end of the chapter, or where a :::solutions line stands; a key made from the answers and the :::solution of each exercise.
- [Exercise styles](https://postext.dev/en/docs/exercises.md#exercise-styles): exerciseStyles: how a family of exercises prints — the number (disc, square, label), the instruction, option labels, blanks, true/false boxes, dots to join, the word bank, writing areas, grids, activity icons and difficulty marks.

**Also uses**

- [Exercises](https://postext.dev/en/docs/exercises.md#exercises)
- [Choice and true or false](https://postext.dev/en/docs/exercises.md#choice-and-true-or-false)
- [Matching and sorting](https://postext.dev/en/docs/exercises.md#matching-and-sorting)
- [Ordering](https://postext.dev/en/docs/exercises.md#ordering)
- [Open questions and writing areas](https://postext.dev/en/docs/exercises.md#open-questions-and-writing-areas)
- [Callout boxes](https://postext.dev/en/docs/configuration-callouts.md#callout-styles)
- [Three or more columns](https://postext.dev/en/docs/configuration-page-layout.md#layout-types)
- [Designed openers](https://postext.dev/en/docs/configuration-headings.md#span-and-advanced-design)
- [Heading attributes](https://postext.dev/en/docs/document-format.md#heading-attributes)
- [Pictures in page designs](https://postext.dev/en/docs/configuration-headers-footers.md#image-elements)
- [Figures and tables as resources](https://postext.dev/en/docs/document-format-resources.md#resources)
- [Semantic colour palette](https://postext.dev/en/docs/configuration-fonts-colors-viewers.md#color-palette)
- [PDF export](https://postext.dev/en/docs/configuration-pdf-output.md#generating-pdfs)
- [Boxes that split or stay whole](https://postext.dev/en/docs/configuration-callouts.md#the-callout-container)
- [Column balancing](https://postext.dev/en/docs/configuration-page-flow.md#column-balancing)
- [Figures exactly here](https://postext.dev/en/docs/document-format-resources.md#block-embed-optional-explicit-inline-placement)
- [Full-width chapter band](https://postext.dev/en/docs/configuration-headings.md#span-and-advanced-design)
- [Heads by page role](https://postext.dev/en/docs/configuration-headers-footers.md#text-elements)
- [Paragraph styles](https://postext.dev/en/docs/configuration-styles.md#paragraph-styles)
- [Fonts embedded in the PDF](https://postext.dev/en/docs/configuration-pdf-output.md#why-a-font-provider)
- [Custom resource types](https://postext.dev/en/docs/configuration-resources.md#resource-types)

**Config at a glance**

- [`bodyText`](https://postext.dev/en/docs/configuration-text.md#body-text), [`calloutStyles`](https://postext.dev/en/docs/configuration-callouts.md#callout-styles), [`colorPalette`](https://postext.dev/en/docs/configuration-fonts-colors-viewers.md#color-palette), `exerciseStyles`, [`footer`](https://postext.dev/en/docs/configuration-headers-footers.md#headers--footers), [`header`](https://postext.dev/en/docs/configuration-headers-footers.md#headers--footers), [`headings`](https://postext.dev/en/docs/configuration-headings.md#headings), [`layout`](https://postext.dev/en/docs/configuration-page-layout.md#layout), [`locale`](https://postext.dev/en/docs/configuration-language.md#hyphenation), [`page`](https://postext.dev/en/docs/configuration-page-layout.md#page), [`paragraphStyles`](https://postext.dev/en/docs/configuration-styles.md#paragraph-styles), [`resourceTypes`](https://postext.dev/en/docs/configuration-resources.md#resource-types)

**APIs**

- [`buildDocumentWithFonts`](https://postext.dev/en/docs/configuration-programmatic-usage.md#loading-fonts-before-layout), [`decompressWoff2`](https://postext.dev/en/docs/configuration-pdf-output.md#browser-font-provider-fontsource--woff2), [`registerResourceImage`](https://postext.dev/en/docs/architecture.md#api-surface), [`renderPageToCanvas`](https://postext.dev/en/docs/configuration-programmatic-usage.md#rendering-a-page-to-a-bitmap), [`renderToPdf`](https://postext.dev/en/docs/configuration-pdf-output.md#generating-pdfs)

**Typefaces**

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

## Method

### 1 · Every puzzle on a card, its answers turned

The code is [the short answer](https://postext.dev/en/cookbook/puzzle-page-upside-down-answers.md#the-short-answer) above. The exercise style's `box` names a box style, so every puzzle is set on a card, its number in a disc and its title as the first line of its instruction. `placement: 'inverted'` sets the key after each puzzle in a box turned 180°, inside the card, so the two never fall on different pages; the grids and the writing line stay inside the card's padding.

### 2 · Let the engine build the grids

The Markdown holds only the words: `- OCTOPUS: Eight arms and three hearts` for the crossword, `- SHELL` for the word search. The engine lays the crossword with every word crossing another and numbers the starts in reading order, and hides the search words in the directions the fence names (`directions="across down diagonal backwards"`), filling the rest with the words' own letters. Both are seeded, so the grid is the same on every run, in every output, and the Spanish edition builds its own grid from its own words.

### 3 · A reef across both columns

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

The opener spans both columns (`span: 'page'`), so the puzzles start under the painting in each column. The picture is cut to the band's proportions and anchored to the page, so it bleeds at the top and the sides; the standfirst sits in a dark box so it reads over the water.

## The whole recipe

One file, composed from the recipe's folder with the sample text and the Cookbook's shared kit inlined; it builds its own page. To run it, put it in a `<script type="module">` on an empty page, or paste it into the JS panel of a new CodePen (as a module). It imports postext from esm.sh, so there is nothing to install or build.

- Source folder: 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 ───────────────────────────────────────────────────────────────────────
```

## Variations

### Hide the word list

A harder word search prints only the grid: add `hidden` to the fence.

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

### Print the answers at the end of the magazine

`chapterEnd` collects every key at the end instead of turning each one.

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

## Pitfalls

- A crossword word that crosses no other is left out and reported as `exerciseWordUnplaced`. Choose words that share letters, or add one that links two groups.
- A puzzle card is kept together, so a tall crossword moves to the next column when it does not fit: check the Spanish edition, whose words are longer.

## Credits

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

## Related

- [Nº 172 · A Japanese drill with the answers upside down](https://postext.dev/en/cookbook/japanese-drill-upside-down-answers.md): Two pages of a Japanese drill series, arithmetic across and kanji in columns, each exercise followed by its answers printed upside down. · Level 2 (Intermediate) · Workbooks & exercises, Textbooks
- [Nº 159 · Activity book with boxes that grow](https://postext.dev/en/cookbook/activity-book-growing-boxes.md): A science activity book on page templates: boxes in a stack grow with their text, answer lines sit on empty frames, a project goes on overleaf. · Level 3 (Advanced) · Workbooks & exercises, Textbooks
- [Nº 022 · Worksheet with answer boxes and a word bank](https://postext.dev/en/cookbook/worksheet-answer-boxes.md): A four-page science worksheet: white answer boxes in pale green cards, 2 mm under each question and off the grid, with word banks and blanks made of chips. · Level 2 (Intermediate) · Workbooks & exercises
