# كراسة أنشطة بإطارات تطول مع نصها

> كراسة علوم مبنية على قوالب الصفحات: إطارات الرصّة تطول مع نصها، وأسطر الإجابة على إطارات فارغة، والمشروع الطويل يستمر في صفحة مضافة.

- نسخة HTML: https://postext.dev/ar/cookbook/activity-book-growing-boxes
- وصفة رقم 159 · الإطارات والملاحظات · المستوى 3 (متقدم) · المخرجات: Canvas, PDF
- الأنواع: كتب التمارين والتدريبات, الكتب المدرسية
- تتطلب postext ≥ 1.39.0, postext-pdf ≥ 1.39.0 · اختُبرت مع 1.39.0, postext-pdf 1.39.0 بتاريخ 2026-10-17
- الصفحات: [1](https://postext.dev/cookbook/activity-book-growing-boxes/en/p01.webp?v=8302cac1), [2](https://postext.dev/cookbook/activity-book-growing-boxes/en/p02.webp?v=8302cac1), [3](https://postext.dev/cookbook/activity-book-growing-boxes/en/p03.webp?v=8302cac1), [4](https://postext.dev/cookbook/activity-book-growing-boxes/en/p04.webp?v=8302cac1), [5](https://postext.dev/cookbook/activity-book-growing-boxes/en/p05.webp?v=8302cac1)
- PDF: https://postext.dev/cookbook/activity-book-growing-boxes/en/activity-book-growing-boxes.pdf?v=8302cac1
- افتح في Sandbox: https://postext.dev/ar/sandbox#recipe=activity-book-growing-boxes&lang=en (.postext: https://postext.dev/cookbook/activity-book-growing-boxes/en/activity-book-growing-boxes.postext)
- آخر تحديث: 2026-10-17
- لغات أخرى: [en](https://postext.dev/en/cookbook/activity-book-growing-boxes.md), [es](https://postext.dev/es/cookbook/activity-book-growing-boxes.md), [ca](https://postext.dev/ca/cookbook/activity-book-growing-boxes.md), [de](https://postext.dev/de/cookbook/activity-book-growing-boxes.md), [fr](https://postext.dev/fr/cookbook/activity-book-growing-boxes.md), [it](https://postext.dev/it/cookbook/activity-book-growing-boxes.md), [pt](https://postext.dev/pt/cookbook/activity-book-growing-boxes.md), [zh](https://postext.dev/zh/cookbook/activity-book-growing-boxes.md), [ja](https://postext.dev/ja/cookbook/activity-book-growing-boxes.md)

## باختصار

خمس صفحات من كراسة علوم للأطفال عن الماء. كل سؤال في إطار ملوّن تحته أسطر للكتابة. إذا طال السؤال طال إطاره ودفع ما تحته إلى الأسفل. والمشروع الطويل يكمل في الصفحة التالية.

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

خمس صفحات من *Water on the move*، الوحدة الثالثة من كراسة علوم متخيّلة للصف الرابع، بقياس 210 × 280 ملم. على الغلاف لوحة لدورة الماء مولّدة بنموذج انتشار تمتد حتى منطقة النزف وتذوب في الورق عند أسفلها، وفوقها خمسة أسهم متجهية، فوق عنوان بحجم 54 نقطة وسطر للاسم. كل ورقة عمل تخرج من قالب الصفحة نفسه: سطر الوحدة، وشريط أزرق للعنوان، ونص قصير بجانب لوحة صغيرة، ثم ثلاثة إطارات أنشطة زرقاء فاتحة لكل منها لسان مرقّم. تحت الإطار الأول والثالث أسطر للإجابة. طول كل إطار بطول نصه، لذا تنزل أسطر الورقة الفرنسية، وهي أطول بسطر هنا وهناك، أكثر مما تنزل أسطر الورقة الإنجليزية، من غير أن يتغير أي موضع في القالب. والمشروع، وهو مقياس مطر يُصنع من قارورة، فيه خطوات أكثر مما تتسع له صفحة واحدة: يتوقف إطاره في أسفل الصفحة عند *Continued* ويستمر في أعلى صفحة مضافة، تليه ستة أسطر لملاحظات التلميذ. كل الكلمات، ومنها كلمات الألسنة وعنوان الملاحظات، مكتوبة في Markdown.

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

- كيف أجعل إطارات الأنشطة تطول مع نصها، وأنقل الطويل منها إلى الصفحة التالية؟
- كيف أجعل نشاطًا لا يتسع يستمر في الصفحة التالية مع علامة «يتبع»؟
- كيف أبني صفحة ورقة عمل: أسطر للتعبئة، وإطارات إجابات، وبنوك كلمات، وقوائم تحقق؟

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

```js
// script.js, سطرًا 49–75
// An empty frame is an answer space: the Markdown sends it nothing (optional: no slotEmpty).
// Its `rules` draw a line at the foot of every LINE mm of its height, so the lines are part
// of the frame and move with it when a box above grows. minHeight too: a box that runs out of
// room may not take the lines away from the child.
const answer = (name, lines) => ({ name, optional: true, width: mm(W), height: mm(LINE * lines),
  minHeight: mm(LINE * lines),
  rules: { every: mm(LINE), color: col('rule'), width: pt(0.75) } });
// A box is as tall as its text, never less than drawn: a longer text makes it taller and
// pushes everything after it in the group down the page.
const box = (name, style, minHeight) => ({ name, style, width: mm(W), minHeight: mm(minHeight) });
const sheet = {
  id: 'ficha',
  decoration: { elements: [kicker] },
  frames: [
    ...opening,
    // The group starts where its first box is placed: under the text, and runs down from there.
    { ...box('act-1', 'actividad', 22), placement: at('#texto', 'below', 0, 6) },
    answer('lineas-1', 3),
    box('act-2', 'actividad', 22),
    // The last box goes on overleaf, with the answer space after it, when its text outgrows
    // the page; the other two push it down instead.
    { ...box('act-3', 'actividad', 22), overflow: { continueIn: 'next-page' } },
    answer('lineas-3', 2),
  ],
  groups: [{ name: 'actividades', kind: 'stack', gap: mm(4.5),
    frames: ['act-1', 'lineas-1', 'act-2', 'act-3', 'lineas-3'] }],
};
```

## المكونات

**تعلّم**

- [إطارات تكبر داخل مجموعة](https://postext.dev/ar/docs/composed-pages.md#حين-يطول-النص): رصّة أو صف من إطارات صفحة مركّبة: يطول الإطار بطول نصه ولا يقل عن minHeight، فيدفع الإطارات التي تليه وما ثُبّت عليها.
- [إطارات تستمر في الصفحة التالية](https://postext.dev/ar/docs/composed-pages.md#حين-يستمر-النص-في-صفحات-مضافة): يرسل overflow.continueIn ما لا يتسع له الإطار إلى صفحة تُضاف بعد صفحته المركّبة، تُبنى من overflowTemplate، مع علامة الاستمرار بلغة الوثيقة عند موضع القطع.
- [الصفحات المركّبة](https://postext.dev/ar/docs/composed-pages.md#الصفحات-المركّبة): صفحة تُرسم من قالب في الإعدادات يضم إطارات مسمّاة، ويملؤها سياج ‎::::page‎ يسمّي محتواه كل إطار باسم خانته (slot).

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

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

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

- [`bodyText`](https://postext.dev/ar/docs/configuration-text.md#نص-المتن), [`calloutStyles`](https://postext.dev/ar/docs/configuration-callouts.md#أنماط-الإطارات), [`chipStyles`](https://postext.dev/ar/docs/configuration-styles.md#أنماط-الشارات), [`colorPalette`](https://postext.dev/ar/docs/configuration-fonts-colors-viewers.md#لوحة-الألوان), [`footer`](https://postext.dev/ar/docs/configuration-headers-footers.md#رؤوس-الصفحات-وتذييلاتها), [`header`](https://postext.dev/ar/docs/configuration-headers-footers.md#رؤوس-الصفحات-وتذييلاتها), [`headingStyles`](https://postext.dev/ar/docs/configuration-styles.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#تقسيم-الكلمات-بالواصلة), [`orderedLists`](https://postext.dev/ar/docs/configuration-lists-math.md#القوائم-المرقّمة), [`page`](https://postext.dev/ar/docs/configuration-page-layout.md#الصفحة), [`pageTemplates`](https://postext.dev/ar/docs/configuration-composed-pages.md#قوالب-الصفحات), [`resourceTypes`](https://postext.dev/ar/docs/configuration-resources.md#أنواع-الموارد), [`unorderedLists`](https://postext.dev/ar/docs/configuration-lists-math.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)

**الخطوط**

- Andika (OFL-1.1), Baloo 2 (OFL-1.1), Lexend (OFL-1.1)

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

### 1 · اصبب كل نشاط في إطاره باسمه

القالب هو [الجواب المختصر](https://postext.dev/ar/cookbook/activity-book-growing-boxes.md#الجواب-المختصر) أعلاه. في Markdown، كل ورقة سياج ‎`::::page`‎ واحد: يذهب العنوان الأول إلى الإطار الذي له ‎`takes: 'h1'`‎، والفقرة إلى إطار التدفق، وكل ‎`:::frame{slot="…"}`‎ إلى الإطار الذي يحمل ذلك الاسم، وكلمات اللسان في ‎`label`‎. إطارات الإجابة لا تستقبل شيئًا، ولذلك تحمل ‎`optional: true`‎، وإلا أطلق الإطار الفارغ التحذير ‎`slotEmpty`‎. ونشاط الرسم يحتفظ بمساحته البيضاء داخل إطاره بـ ‎`:::space{lines=3}`‎، لأن المساحة التي يحتاجها الرسم جزء من السؤال.

```md
:::frame{slot="act-1" label="1 · Read and answer"}
Tom lives in Leeds. The water from his tap comes from a reservoir in the hills…
:::
```

### 2 · دع الرصّة تحرّك الأسطر مع الإطارات

لكل إطار ‎`minHeight`‎ قدره 22 ملم، وهو الارتفاع الذي رُسم به، وفوق ذلك ارتفاع ‎`auto`‎. تضع المجموعة إطاراتها الخمسة بعضها تحت بعض بفاصل 4.5 ملم، بدءًا من الموضع الذي يوضع فيه ‎`act-1`‎ تحت النص، فإذا طال إطار نزل كل ما بعده. والأسطر جزء من الإطار الفارغ نفسه: يرسم ‎`rules`‎ فيه خطًا عند أسفل كل 8 ملم من ارتفاعه، تحت النص، فتتحرك الأسطر مع الإطار، ويصير موضع الإجابة الأطول إطارًا أطول. أعطِ إطارات الإجابة ‎`minHeight`‎ مساويًا لارتفاعها: حين تضيق المساحة في مجموعة، يسترد [سُلّم إعادة الترتيب](https://postext.dev/ar/docs/composed-pages.md#حين-يبقى-المحتوى-أكبر-من-إطاره) المساحة من إطارات المجموعة حتى حدها الأدنى، والحد الأدنى للإطار الفارغ صفر، فتقع الأسطر فوق الإطار التالي. وآخر إطار في الورقة، ‎`act-3`‎، يتابع في الصفحة التالية (‎`overflow.continueIn: 'next-page'`‎) إذا زاد نصه عن الصفحة، وتنتقل معه أسطر الإجابة التي تحته.

### 3 · انقل المشروع إلى الصفحة التالية

```js
// script.js, سطرًا 79–106
const project = {
  id: 'proyecto',
  overflowTemplate: 'proyecto-sigue',
  decoration: { elements: [kicker] },
  frames: [
    ...opening,
    { ...box('material', 'material', 20), placement: at('#texto', 'below', 0, 9) },
    // As tall as its text, never past the foot of the page, at full size: the rest ends
    // with the language's mark (Continúa, Continued, À suivre) and goes on on an added page.
    { name: 'pasos', style: 'proyecto', width: mm(W), overflow: { continueIn: 'next-page' } },
  ],
  groups: [{ name: 'trabajo', kind: 'stack', gap: mm(9), frames: ['material', 'pasos'] }],
};
// The added page: the rest of the steps at the head, then lines for the child's notes,
// under a heading the page fence names (notas="…").
const projectGoesOn = {
  id: 'proyecto-sigue',
  decoration: { elements: [kicker,
    { kind: 'text', id: 'notas-titulo', content: '{attr.notas}', fontFamily: DISPLAY,
      fontSize: pt(15), fontWeight: 700, color: col('sea'),
      placement: { ...at('#notas', 'top-left', 0, -3), size: { width: mm(W) } } }] },
  frames: [
    { name: 'pasos', style: 'proyecto', width: mm(W),
      placement: at('container', 'top-left', 0, 9) },
    answer('notas', 6),
  ],
  groups: [{ name: 'trabajo', kind: 'stack', gap: mm(16), frames: ['pasos', 'notas'] }],
};
```

ليس لإطار الخطوات ارتفاع ثابت: يطول بطول نصه ويتوقف عند أسفل منطقة النص. الإطار الذي يستطيع المتابعة في صفحة أخرى يتابع قبل أن يُصغَّر نصه، فيُقطع الإطار بين سطرين بحجمه الكامل، وينتهي بعلامة الاستمرار بلغة الوثيقة (*Continúa* أو *Continued* أو *À suivre* بحسب ‎`locale`‎)، ويستمر في الإطار الذي يحمل الاسم نفسه في صفحة من القالب ‎`proyecto-sigue`‎ ([حين يستمر النص في صفحات مضافة](https://postext.dev/ar/docs/composed-pages.md#حين-يستمر-النص-في-صفحات-مضافة)). وترسم الصفحة المضافة أسطر الملاحظات تحت بقية الإطار، بالعنوان الذي يسمّيه السياج في ‎`notas="What I noticed"`‎، فتبقى الكلمات في Markdown وتتغير مع كل طبعة.

### 4 · ارسم الغلاف حتى منطقة النزف

```js
// script.js, سطرًا 110–131
// and a line for the child's name
const cover = {
  id: 'portada', header: false, footer: false,
  decoration: { elements: [
    { kind: 'image', id: 'cielo', resourceId: 'portada', decorative: true,
      placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(178) } } },
    // The arrows are vector, over the painting: the same frame, so they stay on its features.
    { kind: 'image', id: 'ciclo', resourceId: 'ciclo', decorative: true,
      placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(178) } } },
    { kind: 'rule', id: 'nombre-linea', direction: 'horizontal', thickness: pt(1),
      color: col('sea'), placement: { ...at('#nombre', 'bottom-left', 0, 1),
        size: { width: mm(W) } } },
  ] },
  frames: [
    { name: 'titulo', takes: 'h1', width: mm(W), placement: at('container', 'top-left', 0, 162) },
    { name: 'subtitulo', flow: true, width: mm(W) },
    { name: 'nombre', style: 'campo', width: mm(W), minHeight: mm(12) },
    { name: 'colofon', style: 'colofon', width: mm(W), placement: at('container', 'bottom-left') },
  ],
  groups: [{ name: 'portada', kind: 'stack', gap: mm(4),
    frames: ['titulo', 'subtitulo', 'nombre'] }],
};
```

الغلاف أيضًا صفحة مركّبة: رسمه زخرفة مثبّتة على منطقة النزف، والعنوان وسطر السلسلة وخانة الاسم رصّة تبدأ على بعد 162 ملم من أعلى منطقة النص. خط الاسم مثبّت على أسفل إطاره، فإذا شغلت التسمية سطرين في لغة أخرى بقي الخط تحت الكلمات. ويُبعد ‎`header: false`‎ و‎`footer: false`‎ رقم الصفحة عن الغلاف.

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

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

- مجلد الوصفة: https://github.com/drnachio/postext/tree/main/cookbook/activity-book-growing-boxes

### script.js

```js
// ═══ Postext Cookbook · Nº 159 · Activity book with boxes that grow ══════════════
// https://postext.dev/en/cookbook/activity-book-growing-boxes
// Code: MIT · Text: original (CC BY 4.0) · Pictures: generated with diffusion models
// Fonts: Andika, Baloo 2, Lexend (SIL OFL 1.1) · Needs postext ≥ 1.39.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' | 'fr')
const RECIPE = 'activity-book-growing-boxes';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: one ink, the water blue, the sun, and the pale fields of the boxes
const palette = {
  ink: '#1f2d38', // text
  sea: '#1d659c', // titles, tabs, list numbers
  sky: '#e2f0f9', // activity boxes
  sun: '#f0a531', // the project's box
  sand: '#fcf0d8', // the materials box
  rule: '#93bcd8', // answer lines, blanks
  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: 'sea (defaults)', value: { hex: palette.sea, model: 'hex' } },
];
// #endregion

const [TEXT, DISPLAY, LABEL] = ['Andika', 'Baloo 2', 'Lexend']; // Andika: for early readers
const W = 174; // mm: the width of the type area, and of every box
const LINE = 8; // mm between two answer lines: room for a child's handwriting
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });

// The head every sheet shares: the unit over the title band, the text with its drawing.
const kicker = { kind: 'text', id: 'tema', content: '{attr.tema}', fontFamily: LABEL,
  fontSize: pt(8.5), fontWeight: 600, color: col('sea'), textTransform: 'uppercase',
  letterSpacing: pt(1.4), placement: at('container', 'top-left') };
const opening = [
  { name: 'titulo', takes: 'h1', style: 'banda', width: mm(W),
    placement: at('container', 'top-left', 0, 6) },
  { name: 'texto', flow: true, width: mm(W - 52), minHeight: mm(36),
    placement: at('#titulo', 'below', 0, 4) },
  { name: 'dibujo', width: mm(44), placement: at('#titulo', 'below', W - 44, 4) },
];

// #region answer: boxes in a stack grow with their text; empty ruled frames hold the answers
// An empty frame is an answer space: the Markdown sends it nothing (optional: no slotEmpty).
// Its `rules` draw a line at the foot of every LINE mm of its height, so the lines are part
// of the frame and move with it when a box above grows. minHeight too: a box that runs out of
// room may not take the lines away from the child.
const answer = (name, lines) => ({ name, optional: true, width: mm(W), height: mm(LINE * lines),
  minHeight: mm(LINE * lines),
  rules: { every: mm(LINE), color: col('rule'), width: pt(0.75) } });
// A box is as tall as its text, never less than drawn: a longer text makes it taller and
// pushes everything after it in the group down the page.
const box = (name, style, minHeight) => ({ name, style, width: mm(W), minHeight: mm(minHeight) });
const sheet = {
  id: 'ficha',
  decoration: { elements: [kicker] },
  frames: [
    ...opening,
    // The group starts where its first box is placed: under the text, and runs down from there.
    { ...box('act-1', 'actividad', 22), placement: at('#texto', 'below', 0, 6) },
    answer('lineas-1', 3),
    box('act-2', 'actividad', 22),
    // The last box goes on overleaf, with the answer space after it, when its text outgrows
    // the page; the other two push it down instead.
    { ...box('act-3', 'actividad', 22), overflow: { continueIn: 'next-page' } },
    answer('lineas-3', 2),
  ],
  groups: [{ name: 'actividades', kind: 'stack', gap: mm(4.5),
    frames: ['act-1', 'lineas-1', 'act-2', 'act-3', 'lineas-3'] }],
};
// #endregion

// #region continue: the project's steps go on on a page of their own, after their mark
const project = {
  id: 'proyecto',
  overflowTemplate: 'proyecto-sigue',
  decoration: { elements: [kicker] },
  frames: [
    ...opening,
    { ...box('material', 'material', 20), placement: at('#texto', 'below', 0, 9) },
    // As tall as its text, never past the foot of the page, at full size: the rest ends
    // with the language's mark (Continúa, Continued, À suivre) and goes on on an added page.
    { name: 'pasos', style: 'proyecto', width: mm(W), overflow: { continueIn: 'next-page' } },
  ],
  groups: [{ name: 'trabajo', kind: 'stack', gap: mm(9), frames: ['material', 'pasos'] }],
};
// The added page: the rest of the steps at the head, then lines for the child's notes,
// under a heading the page fence names (notas="…").
const projectGoesOn = {
  id: 'proyecto-sigue',
  decoration: { elements: [kicker,
    { kind: 'text', id: 'notas-titulo', content: '{attr.notas}', fontFamily: DISPLAY,
      fontSize: pt(15), fontWeight: 700, color: col('sea'),
      placement: { ...at('#notas', 'top-left', 0, -3), size: { width: mm(W) } } }] },
  frames: [
    { name: 'pasos', style: 'proyecto', width: mm(W),
      placement: at('container', 'top-left', 0, 9) },
    answer('notas', 6),
  ],
  groups: [{ name: 'trabajo', kind: 'stack', gap: mm(16), frames: ['pasos', 'notas'] }],
};
// #endregion

// #region cover: a painted landscape to the bleed with the cycle's arrows over it, the title,
// and a line for the child's name
const cover = {
  id: 'portada', header: false, footer: false,
  decoration: { elements: [
    { kind: 'image', id: 'cielo', resourceId: 'portada', decorative: true,
      placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(178) } } },
    // The arrows are vector, over the painting: the same frame, so they stay on its features.
    { kind: 'image', id: 'ciclo', resourceId: 'ciclo', decorative: true,
      placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(178) } } },
    { kind: 'rule', id: 'nombre-linea', direction: 'horizontal', thickness: pt(1),
      color: col('sea'), placement: { ...at('#nombre', 'bottom-left', 0, 1),
        size: { width: mm(W) } } },
  ] },
  frames: [
    { name: 'titulo', takes: 'h1', width: mm(W), placement: at('container', 'top-left', 0, 162) },
    { name: 'subtitulo', flow: true, width: mm(W) },
    { name: 'nombre', style: 'campo', width: mm(W), minHeight: mm(12) },
    { name: 'colofon', style: 'colofon', width: mm(W), placement: at('container', 'bottom-left') },
  ],
  groups: [{ name: 'portada', kind: 'stack', gap: mm(4),
    frames: ['titulo', 'subtitulo', 'nombre'] }],
};
// #endregion

// A box with a tab: the tab's words come from the Markdown (label="…").
const tabbed = (id, fill, tab, tabInk, extra = {}) => ({
  id, backgroundEnabled: true, background: col(fill), borderRadius: mm(3),
  border: { enabled: false },
  padding: { top: mm(3), right: mm(5), bottom: mm(3.5), left: mm(5) },
  label: { position: 'top-left', background: col(tab), color: col(tabInk), fontFamily: LABEL,
    fontSize: pt(9), fontWeight: 600, height: mm(6), paddingX: mm(3), offset: mm(6),
    inset: mm(4), rule: { enabled: false, color: col(tab) } },
  body: { fontFamily: TEXT, fontSize: pt(11.5), lineHeight: pt(16), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: pt(0) },
  ...extra,
});
const plain = (id, size, lead, color, extra = {}) => ({ id, backgroundEnabled: false,
  border: { enabled: false }, padding: { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) },
  body: { fontFamily: LABEL, fontSize: pt(size), lineHeight: pt(lead), color: col(color),
    boldColor: col(color), italicColor: col(color), firstLineIndent: pt(0) }, ...extra });

const folio = (id, parity, edge, x) => ({ kind: 'text', id, content: '{pageNumber}', parity,
  fontFamily: DISPLAY, fontSize: pt(11), fontWeight: 800, color: col('paper'),
  align: 'center', box: { backgroundColor: col('sea'), borderRadius: mm(4) },
  placement: { ...at('page', edge, x, -9), size: { width: mm(8), height: mm(8) } } });
const bookTitle = (id, parity, edge, x) => ({ kind: 'text', id, content: '{title} · {subtitle}',
  parity, fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 500, color: col('sea'),
  textTransform: 'uppercase', letterSpacing: pt(1.1), placement: at('page', edge, x, -11) });

const config = {
  locale: t({ es: 'es', en: 'en-gb', fr: 'fr' }), // hyphenation and the continuation mark
  resourceTypes: [{ id: 'dibujo', name: t({ es: 'Dibujo', en: 'Drawing', fr: 'Dessin' }),
    shortLabel: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal',
    captionPrefix: '', defaultPlacement: { position: 'here' } }],
  colorPalette,
  page: {
    sizePreset: 'custom', width: mm(210), height: mm(280), dpi: 150,
    margins: { top: mm(20), bottom: mm(22), left: mm(20), right: mm(16), mirror: true },
  },
  layout: { layoutType: 'single' },
  bodyText: {
    fontFamily: TEXT, fontSize: pt(11.5), lineHeight: pt(16), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true,
    hyphenation: { enabled: true }, optimalLineBreaking: true,
    avoidWidows: true, avoidOrphans: true, avoidRunts: true,
  },
  headings: {
    fontFamily: DISPLAY, color: col('sea'), fontWeight: 700,
    balancing: { enabled: false },
    levels: [
      // Every sheet is a composed page, which starts a page of its own.
      { level: 1, fontSize: pt(25), lineHeight: pt(30), fontWeight: 800, color: col('paper'),
        breakBefore: { enabled: false }, marginTop: pt(0), marginBottom: pt(0) },
      { level: 3, fontSize: pt(13.5), lineHeight: pt(17), marginTop: pt(6), marginBottom: pt(2) },
    ],
  },
  headingStyles: [{ id: 'portada', fontSize: pt(54), lineHeight: pt(54), fontWeight: 800,
    color: col('sea'), marginTop: pt(0), marginBottom: pt(0) }],
  unorderedLists: { bulletChar: '•', color: col('sea'), fontWeight: 700, itemSpacing: pt(3) },
  orderedLists: { numberFormat: 'arabic', fontFamily: DISPLAY, fontWeight: 800, color: col('sea'),
    itemSpacing: pt(4) },
  chipStyles: [
    // An empty chip, :chip[ ]{style="hueco"}, is a blank to write in: padding and an outline.
    { id: 'hueco', fontFamily: LABEL, background: col('paper'), borderColor: col('rule'),
      borderWidth: pt(0.9), borderRadius: mm(1.4), paddingX: mm(13), paddingY: em(0.1) },
    { id: 'casilla', fontFamily: LABEL, background: col('paper'), borderColor: col('sea'),
      borderWidth: pt(0.9), borderRadius: mm(1), paddingX: em(0.55), paddingY: em(0.05),
      gap: em(0.5) },
  ],
  calloutStyles: [
    tabbed('actividad', 'sky', 'sea', 'paper'),
    tabbed('material', 'sand', 'sun', 'ink'),
    tabbed('proyecto', 'paper', 'sun', 'ink',
      { border: { enabled: true, color: col('sun'), width: pt(1.5) } }),
    { ...plain('banda', 25, 30, 'paper'), backgroundEnabled: true, background: col('sea'),
      borderRadius: mm(3), padding: { top: mm(2.5), right: mm(5), bottom: mm(3), left: mm(5) } },
    plain('campo', 10, 14, 'sea', { padding: { top: mm(5), right: mm(0), bottom: mm(0),
      left: mm(0) } }),
    plain('colofon', 7.5, 10, 'ink'),
  ],
  header: { elements: [] },
  footer: { elements: [
    folio('folio-par', 'even', 'bottom-left', 20),
    bookTitle('titulo-par', 'even', 'bottom-left', 32),
    folio('folio-impar', 'odd', 'bottom-right', -20),
    bookTitle('titulo-impar', 'odd', 'bottom-right', -32),
  ] },
  pageTemplates: [cover, sheet, project, projectGoesOn],
};

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Water on the move"
subtitle: "Activity book"
---

::::page{template="portada"}
# Water on the move {style="portada"}

Science activity book · Year 4 · Unit 3

:::frame{slot="nombre"}
Name
:::

:::frame{slot="colofon"}
Set in Andika, Baloo 2 and Lexend (SIL OFL) · Original text (CC BY 4.0) · Illustrations generated with diffusion models
:::
::::

::::page{template="ficha" tema="Unit 3 · Sheet 1"}
# Where is the water?

Almost three quarters of the Earth is covered by water. Most of it is **salt water**, in the seas and oceans. **Fresh water**, the kind we drink, is only a small part: it runs in rivers, rests in lakes, hides under the ground or lies frozen at the poles.

::resource{id="planeta" slot="dibujo"}

:::frame{slot="act-1" label="1 · Read and answer"}
Tom lives in Leeds. The water from his tap comes from a reservoir in the hills, goes through a treatment works and travels through pipes to his house. Where does the water from your tap come from? Why do you think it has to be cleaned before we drink it?
:::

:::frame{slot="act-2" label="2 · Fill in the gaps"}
- The water in the seas and oceans is :chip[ ]{style="hueco"} and we cannot drink it.
- Rivers, lakes and the ground below us hold :chip[ ]{style="hueco"} water.
- At the poles, nearly all the water is frozen into :chip[ ]{style="hueco"}.
:::

:::frame{slot="act-3" label="3 · Draw and write"}
Draw a place near your home where there is water: a fountain, a river, a reservoir or the beach. Then write its name on the lines and what people use it for.

:::space{lines=3}
:::
::::

::::page{template="ficha" tema="Unit 3 · Sheet 2"}
# Solid, liquid and gas

Water changes state when it gets warmer or colder. Below zero degrees it turns into **ice**, which is a solid. When ice warms up it melts and becomes a **liquid** again. When water boils it turns into **steam**, a gas we cannot see.

::resource{id="estados" slot="dibujo"}

:::frame{slot="act-1" label="1 · Observe"}
Put an ice cube on a plate and leave it on the table. Look at it every ten minutes for half an hour. What happens to the ice cube? What is on the plate at the end? Write down why you think this happens.
:::

:::frame{slot="act-2" label="2 · Tick the true sentences"}
- :chip[ ]{style="casilla"} Ice is water in its solid state.
- :chip[ ]{style="casilla"} You can pick up steam in your hand.
- :chip[ ]{style="casilla"} A puddle dries up because the sun makes the water evaporate.
- :chip[ ]{style="casilla"} Water in the freezer turns into steam.
:::

:::frame{slot="act-3" label="3 · Explain"}
When you cook pasta, steam rises from the pan. If you hold a cold lid over it, the lid fills with tiny drops. Where do those drops come from?
:::
::::

::::page{template="proyecto" tema="Unit 3 · Project" notas="What I noticed"}
# Make a rain gauge

A rain gauge measures how much rain falls in one place. Weather stations use very precise instruments, but you can make one from a bottle and keep a record of the rain for a whole week.

::resource{id="pluviometro" slot="dibujo"}

:::frame{slot="material" label="You will need"}
- A clean, empty two-litre plastic bottle.
- Scissors, a 20-centimetre ruler and sticky tape.
- A permanent marker and a handful of small stones.
:::

:::frame{slot="pasos" label="Step by step"}
1. Ask an adult to cut the bottle where it starts to get narrower. Keep the top part: it will be your funnel.
2. Put the stones in the bottom. They stop the bottle blowing over in the wind.
3. Pour in water until the stones are covered and mark that level with the marker. That line is the zero of your rain gauge.
4. Tape the ruler to the outside of the bottle, so that the zero on the ruler sits exactly on the line you marked.
5. Turn the top part upside down and push it into the bottle, neck first. The funnel collects the rain and slows down evaporation.
6. Find an open spot in the playground or the garden, away from trees and roofs, which keep the rain off.
7. Every day at the same time, read how many millimetres the water has risen above the zero and write it in your record. Then pour the water out down to the zero line.

### My record for the week

- Monday: :chip[ ]{style="hueco"} millimetres
- Tuesday: :chip[ ]{style="hueco"} millimetres
- Wednesday: :chip[ ]{style="hueco"} millimetres
- Thursday: :chip[ ]{style="hueco"} millimetres
- Friday: :chip[ ]{style="hueco"} millimetres

### Think

Which day had the most rain? Add up the five days: how many millimetres fell in total? Compare your result with a classmate who put their rain gauge somewhere else.
:::
::::
`; // content.<lang>.md, inlined by the Cookbook

// #region art: a painted cover and three vignettes; only the cycle's arrows are vector
// The pictures were painted with a diffusion model on white (the cover on a white edge it fades
// into), cut to the frames' aspect and declared with their pixels. The cover's arrows are an
// SVG in the same frame as the painting, 210 × 178 mm, with their points read off the painting
// in percent of its width and height: the sea's mist, the clouds, the rain, the river.
const P = palette;
const ARROW_X = 2.1; // mm per percent of the cover's width
const ARROW_Y = 1.78; // mm per percent of its height
// A smooth stroke through the points, with the head turned to the last stretch.
const arrow = (...pts) => {
  const p = pts.map(([x, y]) => [x * ARROW_X, y * ARROW_Y]);
  let d = `M${p[0][0].toFixed(1)} ${p[0][1].toFixed(1)}`;
  for (let i = 0; i < p.length - 1; i++) {
    const [a, b] = [p[Math.max(i - 1, 0)], p[i + 2] ?? p[i + 1]];
    const c1 = [p[i][0] + (p[i + 1][0] - a[0]) / 6, p[i][1] + (p[i + 1][1] - a[1]) / 6];
    const c2 = [p[i + 1][0] - (b[0] - p[i][0]) / 6, p[i + 1][1] - (b[1] - p[i][1]) / 6];
    d += ` C${c1.map((v) => v.toFixed(1)).join(' ')} ${c2.map((v) => v.toFixed(1)).join(' ')} `
      + `${p[i + 1][0].toFixed(1)} ${p[i + 1][1].toFixed(1)}`;
  }
  const [t, e] = [p[p.length - 2], p[p.length - 1]];
  const ang = Math.atan2(e[1] - t[1], e[0] - t[0]);
  const tip = (back, side) => `${(e[0] + back * Math.cos(ang) - side * Math.sin(ang)).toFixed(1)},`
    + `${(e[1] + back * Math.sin(ang) + side * Math.cos(ang)).toFixed(1)}`;
  const head = `${tip(3.4, 0)} ${tip(-1, 2.5)} ${tip(-1, -2.5)}`;
  return `<path d="${d}" fill="none" stroke="${P.sea}" stroke-width="2.2" stroke-linecap="round"/>`
    + `<polygon points="${head}" fill="${P.sea}" stroke="${P.sea}" stroke-width="1.4" `
    + 'stroke-linejoin="round"/>'
    + `<path d="${d}" fill="none" stroke="${P.paper}" stroke-width="1.1" stroke-linecap="round"/>`
    + `<polygon points="${head}" fill="${P.paper}"/>`;
};
const CYCLE = [
  [84, 54, 81, 42, 82, 30], // evaporation: the mist rises off the sea
  [93, 54, 95, 42, 92, 31],
  [64, 21, 54, 17, 45, 12], // the clouds drift inland to the mountains
  [29, 8, 28, 14, 27, 20], // precipitation: rain on the peaks
  [22, 43.6, 26, 45.3, 30, 47.8], // run-off: the river down from the waterfall
  [37, 57.5, 46, 59.3, 55, 60.8], // and out across the plain to the sea
];
const cycleSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 210 178" width="2100" `
  + `height="1780">${CYCLE.map((a) => arrow(...Array.from({ length: a.length / 2 },
    (_, i) => [a[2 * i], a[2 * i + 1]]))).join('')}</svg>`;
await loadSvg('ciclo.svg', cycleSvg);

const ALT = {
  portada: t({
    es: 'El ciclo del agua en un paisaje: el sol calienta el mar, el vapor sube a las nubes, '
      + 'la lluvia cae sobre las montañas nevadas y el río baja de nuevo al mar',
    en: 'The water cycle in a landscape: the sun warms the sea, vapour rises to the clouds, '
      + 'rain falls on the snowy mountains and the river runs back down to the sea',
    fr: 'Le cycle de l’eau dans un paysage : le soleil chauffe la mer, la vapeur monte aux '
      + 'nuages, la pluie tombe sur les montagnes enneigées et la rivière redescend vers la mer' }),
  ciclo: t({ es: 'Flechas: el vapor sube del mar, las nubes van hacia la montaña, la lluvia cae '
      + 'y el río vuelve al mar',
    en: 'Arrows: vapour rises from the sea, the clouds drift to the mountains, rain falls and '
      + 'the river returns to the sea',
    fr: 'Flèches : la vapeur monte de la mer, les nuages vont vers la montagne, la pluie tombe '
      + 'et la rivière retourne à la mer' }),
  planeta: t({ es: 'La Tierra vista desde el espacio, casi toda azul, y una gota de agua',
    en: 'The Earth seen from space, mostly blue, and a drop of water',
    fr: 'La Terre vue de l’espace, presque toute bleue, et une goutte d’eau' }),
  estados: t({ es: 'Un cubito de hielo que se derrite, un vaso de agua y una taza que echa vapor',
    en: 'An ice cube melting, a glass of water and a steaming cup',
    fr: 'Un glaçon qui fond, un verre d’eau et une tasse qui fume' }),
  pluviometro: t({ es: 'Una botella cortada con una regla pegada, piedras y un embudo bajo '
      + 'la lluvia: el pluviómetro',
    en: 'A cut bottle with a ruler taped on, stones and a funnel in the rain: '
      + 'the rain gauge',
    fr: 'Une bouteille coupée avec une règle collée, des cailloux et un entonnoir sous la pluie : '
      + 'le pluviomètre' }),
};
// Bitmaps: file, pixels and ppi (the vignettes are 44 mm wide, the cover 210 mm).
const BITMAPS = {
  portada: ['portada-1440.jpg', 1440, 1221, 174],
  planeta: ['planeta-960.jpg', 960, 873, 554],
  estados: ['estados-960.jpg', 960, 873, 554],
  pluviometro: ['pluviometro-960.jpg', 960, 873, 554],
};
const resources = [
  ...(await Promise.all(Object.entries(BITMAPS).map(async ([id, [fileId, w, h, ppi]]) => {
    await loadImage(fileId, asset(fileId));
    return { id, typeId: 'dibujo', kind: 'bitmap', caption: '', altText: ALT[id], createdAt: 0,
      updatedAt: 0, bitmap: { fileId, format: 'jpeg', width: w, height: h, resolution: ppi } };
  }))),
  { id: 'ciclo', typeId: 'dibujo', kind: 'svg', caption: '', altText: ALT.ciclo, createdAt: 0,
    updatedAt: 0, svg: { fileId: 'ciclo.svg', width: 2100, height: 1780 } },
];
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // text, display and label faces, loaded before the build
  Andika: ['400', '400i', '700'], 'Baloo 2': ['700', '800'], Lexend: ['500', '600'],
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const doc = await buildDocumentWithFonts({ markdown, resources }, config, kitFonts(FONTS));
showPages(doc, { title: t({ es: 'El agua, de viaje', en: 'Water on the move',
  fr: 'L’eau en voyage' }) });
offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),
  `${RECIPE}.pdf`);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## تنويعات

### أعطِ نشاطًا أسطرًا أكثر

عدد الأسطر هو ارتفاع إطار الإجابة: يرسم ‎`rules`‎ فيه ما يتسع من الأسطر. غيّره في القالب فتحصل كل ورقة تستخدمه على هذه الأسطر.

```diff
-    answer('lineas-1', 3),
+    answer('lineas-1', 5),
```

### دع الخطوات تصغر قبل أن تستمر

الإطار الذي يستطيع المتابعة في صفحة أخرى يتابع قبل أن يُصغَّر نصه. مع ‎`fit.preferContinue: false`‎ يصغّر السُّلّم الخطوات أولًا حتى ‎`fit.minScale`‎ (90 %)، ولا ينقلها إلى صفحة أخرى إلا إذا لم يكفِ ذلك. وفي كتاب للأطفال يهمّ حجم الحرف أكثر من صفحة نوفّرها، لذلك تبقي الوصفة على القيمة الافتراضية.

```diff
-      overflow: { continueIn: 'next-page' } },
+      fit: { preferContinue: false }, overflow: { continueIn: 'next-page' } },
```

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

- الإطار الذي لا يستقبل شيئًا يطلق ‎`slotEmpty`‎ ما لم يحمل ‎`optional: true`‎. وكل إطارات الإجابة هنا تحمله.
- في الرصّة تضع المجموعة الإطارات ولا يُعتدّ بـ ‎`placement`‎ الخاص بكل منها، إلا الأول: حدّد موضع الإطار الأول في المجموعة، وإلا بدأت الرصّة من أعلى منطقة النص فوق العنوان.

## الحقوق

- الوصفة: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- الصور: The water cycle in a landscape: snowy mountains, a river, a coast and the sea, under sun and rain clouds.: Generated With Diffusion Models, أصلي
- الصور: The Earth seen from space, with a drop of water.: Generated With Diffusion Models, أصلي
- الصور: An ice cube melting, a glass of water and a steaming cup.: Generated With Diffusion Models, أصلي
- الصور: A rain gauge made from a cut bottle, with a ruler and stones.: Generated With Diffusion Models, أصلي
- الخطوط: Andika (OFL-1.1), Baloo 2 (OFL-1.1), Lexend (OFL-1.1)
- الشيفرة: MIT · محتوى المثال: CC-BY-4.0

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

- [رقم 022 · ورقة عمل بصناديق إجابة وبنك كلمات](https://postext.dev/ar/cookbook/worksheet-answer-boxes.md): ورقة عمل في العلوم من أربع صفحات: صناديق إجابة بيضاء في بطاقات خضراء فاتحة، على بعد 2 mm تحت كل سؤال وخارج الشبكة، مع بنوك كلمات وفراغات من الشارات. · المستوى 2 (متوسط) · كتب التمارين والتدريبات
- [رقم 049 · ورقة امتحان مع ورقة إجابة](https://postext.dev/ar/cookbook/exam-paper.md): امتحان تاريخ من أربع صفحات على مقاس US Letter، دوائر الإجابة والدرجات فيه شارات، والدرجات على موضع جدولة، والأسئلة مرقّمة 1 وa) وi)، وأسطر الإجابة يرسمها جدول. · المستوى 2 (متوسط) · كتب التمارين والتدريبات
- [رقم 060 · كتاب قراءة مبكرة بشارات المقاطع](https://postext.dev/ar/cookbook/reading-primer-syllables.md): وحدة الحرف M من كتاب لتعليم القراءة: أصوات الكلمة شارات بلون حرفها الصوتي، وشبكة كلمات مصوّرة في كل خلية منها لوحة مائية. · المستوى 2 (متوسط) · كتب التمارين والتدريبات
