# صفحة ألغاز بإجابات مقلوبة

> صفحة الألغاز في مجلة للأطفال: كلمات متقاطعة، وبحث عن الكلمات، ولغز، وقصة للترتيب وغيرها، كلٌّ في بطاقة وإجاباته مطبوعة مقلوبة تحته.

- نسخة HTML: https://postext.dev/ar/cookbook/puzzle-page-upside-down-answers
- وصفة رقم 174 · الكتب المدرسية · المستوى 2 (متوسط) · المخرجات: Canvas, PDF
- الأنواع: المجلات والمجلات المستقلة, كتب التمارين والتدريبات
- تتطلب postext ≥ 1.59.0, postext-pdf ≥ 1.59.0 · اختُبرت مع 1.59.0, postext-pdf 1.59.0 بتاريخ 2026-10-24
- الصفحات: [1](https://postext.dev/cookbook/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)
- افتح في Sandbox: https://postext.dev/ar/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)
- آخر تحديث: 2026-10-24
- لغات أخرى: [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), [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)

## باختصار

صفحة الألغاز في مجلة للأطفال عن البحر. لكل لعبة بطاقتها، وإجاباتها مطبوعة مقلوبة تحتها، فيدير القارئ المجلة ليتحقق منها.

## ما الذي ستنضده

صفحة الألغاز في *Splash!*، مجلة للأطفال عن البحر، على صفحتين بمقاس A4 بعمودين. تملأ لوحة الشعاب المرجانية أعلى الصفحة الأولى، والعنوان فوق الماء المفتوح. وتحتها تأخذ كل لعبة بطاقة بلون القشدة برقم مرجاني: كلمات متقاطعة من ستة حيوانات بحرية وتعريفاتها، وبحث عن الكلمات يخفي ثماني كلمات أفقيًا وعموديًا وقطريًا ومعكوسة، ولغز بثلاثة خيارات يُحاط الصحيح منها بدائرة، وحياة سلحفاة تُرتَّب مراحلها، ومعلومة مبعثرة الكلمات، وصغار حيوانات تُوصَل بأسمائها. وتحت كل لعبة، داخل بطاقتها، تُطبع الإجابات مقلوبة بحروف رمادية صغيرة، كما خبّأتها مجلات الألغاز دائمًا.

**تجيب هذه الوصفة عن:**

- كيف أصنع كلمات متقاطعة وبحثًا عن الكلمات من قائمة كلمات، مع الإجابات مقلوبة؟
- كيف أطبع الإجابات مقلوبة أسفل التمرين؟

## الجواب المختصر

```js
// script.js, سطرًا 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) };
```

## المكونات

**تعلّم**

- [البحث عن الكلمات والكلمات المتقاطعة](https://postext.dev/ar/docs/exercises.md#البحث-عن-الكلمات-والكلمات-المتقاطعة): شبكات البحث عن الكلمات والكلمات المتقاطعة من قائمة كلمات (وتلميحات): يضع المحرك الكلمات ببذرة ويملأ الشبكة ويرقّم الكلمات المتقاطعة.
- [الحلول](https://postext.dev/ar/docs/exercises.md#الحلول): أين تُطبع الحلول: لا مكان، بعد كل تمرين، مقلوبة في أسفله، في آخر الفصل، أو حيث يوجد سطر :::solutions.
- [أنماط التمارين](https://postext.dev/ar/docs/exercises.md#أنماط-التمارين): exerciseStyles: كيف تُطبع فئة من التمارين: الرقم (دائرة، مربع، عنوان)، والتعليمة، وحروف الخيارات، والفراغات، ومربعات الصواب والخطأ، ونقاط التوصيل، وبنك الكلمات، ومساحات الكتابة، والشبكات، وأيقونات النشاط، ومستوى الصعوبة.

**تستخدم أيضًا**

- [التمارين](https://postext.dev/ar/docs/exercises.md#التمارين)
- [الاختيار والصواب والخطأ](https://postext.dev/ar/docs/exercises.md#الاختيار-من-متعدد-وصح-أو-خطأ)
- [التوصيل والتصنيف](https://postext.dev/ar/docs/exercises.md#التوصيل-والتصنيف)
- [الترتيب](https://postext.dev/ar/docs/exercises.md#إعادة-الترتيب)
- [الأسئلة المفتوحة ومساحات الكتابة](https://postext.dev/ar/docs/exercises.md#الأسئلة-المفتوحة-ومساحات-الكتابة)
- [إطارات التنبيه](https://postext.dev/ar/docs/configuration-callouts.md#أنماط-الإطارات)
- [ثلاثة أعمدة أو أكثر](https://postext.dev/ar/docs/configuration-page-layout.md#أنواع-التخطيط)
- [صفحات افتتاح مصمَّمة](https://postext.dev/ar/docs/configuration-headings.md#الامتداد-والتصميم-المتقدم)
- [سمات العنوان](https://postext.dev/ar/docs/document-format.md#سمات-العناوين)
- [الصور في تصاميم الصفحة](https://postext.dev/ar/docs/configuration-headers-footers.md#عناصر-الصورة)
- [الأشكال والجداول بوصفها موارد](https://postext.dev/ar/docs/document-format-resources.md#الموارد)
- [لوحة ألوان دلالية](https://postext.dev/ar/docs/configuration-fonts-colors-viewers.md#لوحة-الألوان)
- [التصدير إلى PDF](https://postext.dev/ar/docs/configuration-pdf-output.md#توليد-ملفات-pdf)
- [إطارات تنقسم أو تبقى كاملة](https://postext.dev/ar/docs/configuration-callouts.md#الحاوية-callout)
- [موازنة الأعمدة](https://postext.dev/ar/docs/configuration-page-flow.md#موازنة-الأعمدة)
- [أشكال في هذا الموضع بالضبط](https://postext.dev/ar/docs/document-format-resources.md#التضمين-في-كتلة-اختياري-لوضع-صريح-داخل-التدفق)
- [شريط فصل بعرض الصفحة](https://postext.dev/ar/docs/configuration-headings.md#الامتداد-والتصميم-المتقدم)
- [الترويسات بحسب دور الصفحة](https://postext.dev/ar/docs/configuration-headers-footers.md#عناصر-النص)
- [أنماط الفقرات](https://postext.dev/ar/docs/configuration-styles.md#أنماط-الفقرات)
- [الخطوط المضمَّنة في PDF](https://postext.dev/ar/docs/configuration-pdf-output.md#لماذا-مزوّد-الخطوط)
- [أنواع موارد مخصّصة](https://postext.dev/ar/docs/configuration-resources.md#أنواع-الموارد)

**الإعدادات في لمحة**

- [`bodyText`](https://postext.dev/ar/docs/configuration-text.md#نص-المتن), [`calloutStyles`](https://postext.dev/ar/docs/configuration-callouts.md#أنماط-الإطارات), [`colorPalette`](https://postext.dev/ar/docs/configuration-fonts-colors-viewers.md#لوحة-الألوان), `exerciseStyles`, [`footer`](https://postext.dev/ar/docs/configuration-headers-footers.md#الترويسات-والتذييلات), [`header`](https://postext.dev/ar/docs/configuration-headers-footers.md#الترويسات-والتذييلات), [`headings`](https://postext.dev/ar/docs/configuration-headings.md#العناوين), [`layout`](https://postext.dev/ar/docs/configuration-page-layout.md#التخطيط), [`locale`](https://postext.dev/ar/docs/configuration-language.md#تقسيم-الكلمات-بالواصلة), [`page`](https://postext.dev/ar/docs/configuration-page-layout.md#الصفحة), [`paragraphStyles`](https://postext.dev/ar/docs/configuration-styles.md#أنماط-الفقرات), [`resourceTypes`](https://postext.dev/ar/docs/configuration-resources.md#أنواع-الموارد)

**واجهات API**

- [`buildDocumentWithFonts`](https://postext.dev/ar/docs/configuration-programmatic-usage.md#تحميل-الخطوط-قبل-الإخراج), [`decompressWoff2`](https://postext.dev/ar/docs/configuration-pdf-output.md#مزوّد-الخطوط-في-المتصفح-fontsource--woff2), [`registerResourceImage`](https://postext.dev/ar/docs/architecture.md#واجهة-البرمجة-المتاحة-api), [`renderPageToCanvas`](https://postext.dev/ar/docs/configuration-programmatic-usage.md#رسم-صفحة-في-صورة-نقطية), [`renderToPdf`](https://postext.dev/ar/docs/configuration-pdf-output.md#توليد-ملفات-pdf)

**الخطوط**

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

## طريقة التحضير

### 1 · كل لعبة في بطاقة، وإجاباتها مقلوبة

الشيفرة هي [الجواب المختصر](https://postext.dev/ar/cookbook/puzzle-page-upside-down-answers.md#الجواب-المختصر) أعلاه. يسمّي `box` في نمط التمرين نمطَ صندوق، فتُنضَّد كل لعبة على بطاقة، رقمها في قرص وعنوانها في السطر الأول من تعليمتها. ويضع `placement: 'inverted'` الإجابات بعد كل لعبة في صندوق مُدار 180° داخل البطاقة، فلا تقع اللعبة وإجاباتها في صفحتين مختلفتين أبدًا؛ وتبقى الشبكات وسطر الكتابة داخل الحشوة الداخلية للبطاقة.

### 2 · دع المحرّك يبني الشبكات

لا يحمل Markdown إلا الكلمات: `- OCTOPUS: Eight arms and three hearts` للكلمات المتقاطعة، و`- SHELL` للبحث عن الكلمات. يرصّ المحرك الكلمات المتقاطعة بحيث تتقاطع كل كلمة مع أخرى، ويرقّم البدايات بترتيب القراءة. ويخفي كلمات البحث في الاتجاهات التي تسمّيها الكتلة (`directions="across down diagonal backwards"`)، ويملأ الباقي بحروف الكلمات نفسها. وللشبكتين بذرة ثابتة، فتخرج كل شبكة كما هي في كل تنضيد وفي كل مُخرَج، وتبني الطبعة الإسبانية شبكتها من كلماتها.

### 3 · شعاب مرجانية عبر العمودين

```js
// script.js, سطرًا 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) } } },
] } };
```

يمتد الافتتاح على العمودين (`span: 'page'`)، فتبدأ الألعاب تحت اللوحة في كل عمود. والصورة مقصوصة على نسب الشريط ومثبتة بالصفحة، فتمتد إلى النزف من الأعلى والجانبين؛ والمقدمة في صندوق داكن لتُقرأ فوق الماء.

## الوصفة كاملة

ملف واحد، مركّب من مجلد الوصفة ومعه نص المثال وأدوات دليل الوصفات المشتركة مضمّنة؛ وهو ينشئ صفحته بنفسه. لتشغيله، ضعه داخل `<script type="module">` في صفحة فارغة أو الصقه في لوحة JS في مثال CodePen جديد (وحدةً). يستورد postext من esm.sh، فلا حاجة إلى تثبيت أو بناء.

- مجلد الوصفة: 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 ───────────────────────────────────────────────────────────────────────
```

## تنويعات

### أخفِ قائمة الكلمات

البحث عن الكلمات الأصعب يطبع الشبكة وحدها: أضف `hidden` إلى الكتلة.

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

### اطبع الإجابات في آخر المجلة

يجمع `chapterEnd` كل الإجابات في الآخر بدل قلب كل واحدة منها.

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

## أخطاء شائعة

- الكلمة في الكلمات المتقاطعة التي لا تتقاطع مع أي كلمة أخرى تُترك خارج الشبكة ويُبلَّغ عنها بـ`exerciseWordUnplaced`. اختر كلمات تشترك في حروف، أو أضف كلمة تصل بين مجموعتين.
- بطاقة اللعبة تبقى متماسكة، فإذا لم تتسع كلمات متقاطعة طويلة انتقلت إلى العمود التالي: راجع الطبعة الإسبانية، فكلماتها أطول.

## الحقوق

- الوصفة: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- الصور: A coral reef with a smiling sea turtle, an octopus, a clownfish, a pufferfish and a seahorse under sunbeams.: Generated With Diffusion Models, أصلي
- الصور: A baby sea turtle hatching from its egg on the sand.: Generated With Diffusion Models, أصلي
- الخطوط: Baloo 2 (OFL-1.1), Nunito (OFL-1.1), Fredoka (OFL-1.1)
- الشيفرة: MIT · محتوى المثال: CC-BY-4.0

## وصفات ذات صلة

- [رقم 172 · كراسة تمارين يابانية حلولها مطبوعة مقلوبة](https://postext.dev/ar/cookbook/japanese-drill-upside-down-answers.md): صفحتان من سلسلة كراسات تمارين يابانية، الحساب أفقي والكانجي في أعمدة، وتحت كل تمرين حلوله مطبوعة مقلوبة. · المستوى 2 (متوسط) · كتب التمارين والتدريبات, الكتب المدرسية
- [رقم 159 · كراسة أنشطة بإطارات تطول مع نصها](https://postext.dev/ar/cookbook/activity-book-growing-boxes.md): كراسة علوم مبنية على قوالب الصفحات: إطارات مجموعة في عمود تطول مع نصها، وأسطر الإجابة على إطارات فارغة، والمشروع الطويل يستمر في صفحة مضافة. · المستوى 3 (متقدم) · كتب التمارين والتدريبات, الكتب المدرسية
- [رقم 022 · ورقة عمل بصناديق إجابة وبنك كلمات](https://postext.dev/ar/cookbook/worksheet-answer-boxes.md): ورقة عمل في العلوم من أربع صفحات: صناديق إجابة بيضاء في بطاقات خضراء فاتحة، على بعد 2 mm تحت كل سؤال وخارج الشبكة، مع بنوك كلمات وفراغات من الشارات. · المستوى 2 (متوسط) · كتب التمارين والتدريبات
