# كتاب ألغاز: كلمات متقاطعة وبحث عن كلمات ومتاهة

> كلمات متقاطعة وشبكة بحث عن كلمات مبنيتان كجدولين من أسطر حروف: المربعات السوداء والكلمات المكتشفة تعبئة خلايا، وأرقام التلميحات مرفوعة.

- نسخة HTML: https://postext.dev/ar/cookbook/puzzle-book
- وصفة رقم 069 · الجداول · المستوى 3 (متقدم) · المخرجات: Canvas
- الأنواع: كتب التمارين والتدريبات
- تتطلب postext ≥ 1.4.1 · اختُبرت مع 1.4.1 بتاريخ 2026-09-26
- الصفحات: [1](https://postext.dev/cookbook/puzzle-book/es/p01.webp?v=91b919bc), [2](https://postext.dev/cookbook/puzzle-book/es/p02.webp?v=91b919bc), [3](https://postext.dev/cookbook/puzzle-book/es/p03.webp?v=91b919bc), [4](https://postext.dev/cookbook/puzzle-book/es/p04.webp?v=91b919bc), [5](https://postext.dev/cookbook/puzzle-book/es/p05.webp?v=91b919bc)
- افتح في Sandbox: https://postext.dev/ar/sandbox#recipe=puzzle-book&lang=es (.postext: https://postext.dev/cookbook/puzzle-book/es/puzzle-book.postext)
- آخر تحديث: 2026-09-26
- لغات أخرى: [en](https://postext.dev/en/cookbook/puzzle-book.md), [es](https://postext.dev/es/cookbook/puzzle-book.md), [ca](https://postext.dev/ca/cookbook/puzzle-book.md), [zh](https://postext.dev/zh/cookbook/puzzle-book.md), [ja](https://postext.dev/ja/cookbook/puzzle-book.md)

## باختصار

كتاب ألغاز صيفي للأطفال من سن التاسعة إلى الحادية عشرة، فيه كلمات متقاطعة وبحث عن كلمات ومتاهة. يبيّن كيف تُبنى شبكات الألغاز من أسطر حروف بسيطة.

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

كراسة صيفية للأطفال من سن التاسعة إلى الحادية عشرة: غلاف، وثلاثة ألغاز عن البحر، وصفحة حلول. تبدأ الصفحات من 2 إلى 5 تحت حقل لوني بحافة سفلية متموجة (أزرق البحر، فالنعناعي، فالمرجاني، ثم الأصفر للحلول) وعنوان بحجم 46 pt بخط Lilita One. تبني الشيفرة الكلمات المتقاطعة والبحث عن الكلمات كجدولين من أسطر حروف، وتعطي كلًّا منهما نمط جدول خاصًا به: مربعات مسطَّرة للكلمات المتقاطعة، وبلاطات باهتة بفواصل بيضاء وزوايا مستديرة للبحث عن الكلمات. المربعات السوداء في الكلمات المتقاطعة والكلمة المكتشفة سلفًا في البحث عن الكلمات تعبئة خلايا، وأرقام التلميحات مرفوعة في الزوايا. المتاهة رسم SVG يُولَّد من بذرة. وتطبع الصفحة الأخيرة الحلول الثلاثة جنبًا إلى جنب من البيانات نفسها، فوق إطار عن تعشيش السلاحف ضخمة الرأس.

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

- كيف أعطي كل جدول في المستند مظهره: خطوط الشبكة، وتعبئة الخلايا، والبلاطات، وإطارًا مستدير الزوايا؟
- كيف أضيف صورًا وجداول من الشيفرة (موارد) بدل ![]() في Markdown؟
- كيف أكتب الأرقام العلوية والسفلية والصيغ الكيميائية من دون محرّك الرياضيات الكامل؟
- كيف أصنع شارات مضمّنة: مفاتيح لوحة المفاتيح، ووسوم، وبنوك كلمات للتمارين؟
- كيف أضع عمودين داخل إطار (عمود نص بجانب عمود شكل)؟

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

```js
// script.js, سطرًا 33–59
// Every grid shares tableStyle; a named style sets its size, padding, rules and fills.
// A row is as tall as its lines plus the padding and a column is its share of the
// table's width, so the padding sets how deep a square is.
const CELL = (15 * LEAD) / 9; // pt: nine squares are fifteen lines of text, 9.4 mm each
const PAD = 1; // pt: a clue number sits this close to the corner, clear of the 1 pt rule
const CLUE_FACE = (CELL - 2 * PAD) / (2 * LEAD / BODY); // 8.9 pt: two lines fill a square
const LETTER = 15; // pt: the word search's capitals
const pad = (cell, face) => pt((cell - face * LEAD / BODY) / 2); // one line fills the cell
const KEY = 54; // mm: the three answers are this deep, so their captions share a line
const key = (count) => { // count squares in KEY mm, letters of 4/3 pt per mm of square
  const side = (KEY / count) * PT_PER_MM, face = (KEY / count) * 4 / 3;
  return { id: `solucion-${count}`, bodyFontSize: pt(face), cellPadding: pad(side, face),
    borderWidth: pt(0.5) };
};
// No grid has a head row; the grey head is off for the Cookbook's default-skin check.
const tableStyle = { headerBackgroundEnabled: false, bodyFontFamily: LABEL };
const tableStyles = [
  { id: 'crucigrama', bodyFontSize: pt(CLUE_FACE), cellPadding: pt(PAD), borderWidth: pt(1) },
  { id: 'sopa', bodyFontSize: pt(LETTER), cellPadding: pad(CELL, LETTER), // same squares
    bodyBackgroundEnabled: true, bodyBackground: col('tint'), // tiles with white joints
    borderColor: col('paper'), borderWidth: pt(2.4), borderRadius: mm(4) },
  key(9), key(12), // 6 mm squares with 8 pt letters, 4.5 mm squares with 6 pt letters
];
// A table resource names its style; a cell's own background covers the style's fill.
const here = (width) => ({ position: 'here', width, align: 'center' }); // of the column
const grid = (id, typeId, model, styleId, width, extra) => ({ id, typeId, kind: 'table',
  createdAt: 0, updatedAt: 0, table: { model, styleId }, placement: here(width), ...extra });
```

## المكونات

**تعلّم**

- [تعبئة الخلايا](https://postext.dev/ar/docs/document-format.md#التضمين-في-كتلة-اختياري-لوضع-صريح-داخل-التدفق): خلفية مرتبطة بلوحة الألوان لكل خلية، للخرائط الحرارية ومصفوفات التوافق والصفوف متناوبة الألوان المضبوطة يدويًا.
- [نمط الجدول](https://postext.dev/ar/docs/configuration.md#نمط-الجداول): التنسيق الطباعي للخلايا، وتعبئة الرأس والمتن، وأنماط الخطوط من الشبكة الكاملة إلى الخلوّ من الخطوط، والإطارات المستديرة الزوايا والحشو.
- [النص المرتفع والمنخفض](https://postext.dev/ar/docs/document-format.md#التنسيق-المضمّن): نص مرفوع ونص منخفض يُكتبان في Markdown نفسه، دون MathJax: الأسس، والأعداد الترتيبية، والصيغ الكيميائية، وعلامات الحواشي.

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

- [جداول من البيانات](https://postext.dev/ar/docs/document-format.md#التضمين-في-كتلة-اختياري-لوضع-صريح-داخل-التدفق)
- [أنماط الجداول المسمّاة](https://postext.dev/ar/docs/configuration.md#أنماط-الجداول-المسمّاة)
- [الشارات داخل السطر](https://postext.dev/ar/docs/configuration.md#أنماط-الشارات)
- [أعمدة داخل الإطار](https://postext.dev/ar/docs/document-format.md#columns)
- [إطارات التنبيه](https://postext.dev/ar/docs/configuration.md#أنماط-الإطارات)
- [أنماط العناوين](https://postext.dev/ar/docs/configuration.md#أنماط-العناوين)
- [ألوان لكل جزء](https://postext.dev/ar/docs/configuration.md#الحاوية-part)
- [صفحات افتتاح مصمَّمة](https://postext.dev/ar/docs/configuration.md#الامتداد-والتصميم-المتقدم)
- [سمات العنوان](https://postext.dev/ar/docs/document-format.md#سمات-العناوين)
- [الصور في تصاميم الصفحة](https://postext.dev/ar/docs/configuration.md#عناصر-الصورة)
- [الأغلفة وصفحات العنوان وبيانات النشر](https://postext.dev/ar/docs/configuration.md#أنماط-العناوين)
- [الترويسات وأرقام الصفحات](https://postext.dev/ar/docs/configuration.md#رؤوس-الصفحات-وتذييلاتها)
- [أنواع موارد مخصّصة](https://postext.dev/ar/docs/configuration.md#أنواع-الموارد)
- [أشكال في هذا الموضع بالضبط](https://postext.dev/ar/docs/document-format.md#التضمين-في-كتلة-اختياري-لوضع-صريح-داخل-التدفق)
- [نمط التعليق](https://postext.dev/ar/docs/configuration.md#نمط-التعليقات)
- [الأشكال والجداول بوصفها موارد](https://postext.dev/ar/docs/document-format.md#الموارد)
- [صفحات على اللوحة (Canvas)](https://postext.dev/ar/docs/configuration.md#رسم-صفحة-في-صورة-نقطية)
- [شريط فصل بعرض الصفحة](https://postext.dev/ar/docs/configuration.md#الامتداد-والتصميم-المتقدم)
- [أنماط الفقرات](https://postext.dev/ar/docs/configuration.md#أنماط-الفقرات)
- [ترويسات لكل قسم](https://postext.dev/ar/docs/configuration.md#أنماط-العناوين)
- [فواصل الأسطر في العناوين](https://postext.dev/ar/docs/document-format.md#فواصل-الأسطر-في-العناوين)

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

- [`bodyText`](https://postext.dev/ar/docs/configuration.md#نص-المتن), [`calloutStyles`](https://postext.dev/ar/docs/configuration.md#أنماط-الإطارات), [`captionStyle`](https://postext.dev/ar/docs/configuration.md#نمط-التعليقات), [`chipStyles`](https://postext.dev/ar/docs/configuration.md#أنماط-الشارات), [`colorPalette`](https://postext.dev/ar/docs/configuration.md#لوحة-الألوان), [`footer`](https://postext.dev/ar/docs/configuration.md#رؤوس-الصفحات-وتذييلاتها), [`header`](https://postext.dev/ar/docs/configuration.md#رؤوس-الصفحات-وتذييلاتها), [`headingStyles`](https://postext.dev/ar/docs/configuration.md#أنماط-العناوين), [`headings`](https://postext.dev/ar/docs/configuration.md#العناوين), [`layout`](https://postext.dev/ar/docs/configuration.md#التخطيط), [`page`](https://postext.dev/ar/docs/configuration.md#الصفحة), [`paragraphStyles`](https://postext.dev/ar/docs/configuration.md#أنماط-الفقرات), [`resourceTypes`](https://postext.dev/ar/docs/configuration.md#أنواع-الموارد), [`tableStyle`](https://postext.dev/ar/docs/configuration.md#نمط-الجداول), [`tableStyles`](https://postext.dev/ar/docs/configuration.md#أنماط-الجداول-المسمّاة)

**واجهات API**

- [`buildDocument`](https://postext.dev/ar/docs/configuration.md#بناء-مستند), [`clearMeasurementCache`](https://postext.dev/ar/docs/configuration.md#ذاكرة-القياس-المؤقتة), [`registerResourceImage`](https://postext.dev/ar/docs/architecture.md#واجهة-البرمجة-المتاحة-api), [`renderPageToCanvas`](https://postext.dev/ar/docs/configuration.md#رسم-صفحة-في-صورة-نقطية), [`setCellBackground`](https://postext.dev/ar/docs/document-format.md#التضمين-في-كتلة-اختياري-لوضع-صريح-داخل-التدفق)

**الخطوط**

- Lexend (OFL-1.1), Lilita One (OFL-1.1), Chivo Mono (OFL-1.1)

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

### 1 · نمط جدول واحد، وثلاثة مظاهر

الشيفرة هي [الجواب المختصر](#الجواب-المختصر) أعلاه. يضبط `tableStyle` خط Chivo Mono لكل الشبكات. كل نمط مسمّى في `tableStyles` يضبط حجم الحرف والحشوة والخطوط الخاصة به، ويختار الجدول أحدها بـ`table.styleId`. عرض العمود حصته من عرض الجدول، أما الصف فعمقه بقدر أسطره مع الحشوة، فالحشوة إذن تحدد عمق كل مربع. يحتفظ البحث عن الكلمات بمربع الكلمات المتقاطعة البالغ 9.41 مم حول سطر واحد من الحروف الكبيرة بحجم 15 pt، وتحسب `pad()` الـ2.9 pt التي يتركها ذلك السطر فوقه وتحته. خطوطه، بعرض 2.4 pt وبلون الورق، تقطّع التعبئة الباهتة إلى بلاطات، ويدوّر `borderRadius` زوايا الإطار إلى 4 مم ويقصّ التعبئة عليها. في صفحة الحلول تُدخل `key()` تسعة مربعات أو اثني عشر في 54 مم: مربعات 6 مم بحروف 8 pt للكلمات المتقاطعة، ومربعات 4.5 مم بحروف 6 pt للبحث عن الكلمات. يُرسم حل المتاهة مربعًا طول ضلعه 54 مم، فتتساوى الحلول الثلاثة في العمق وتشترك تعليقاتها في خط أساس على بعد 106.4 مم من الحافة العليا.

### 2 · تسعة مربعات، خمسة عشر سطرًا

```js
// script.js, سطرًا 63–77
// Two lines of CLUE_FACE (a number, an empty line) and the padding make a square.
function crossword(source, solved = false) {
  const lines = source.trim().split('\n');
  const white = (r, c) => (lines[r]?.[c] ?? '#') !== '#';
  let clue = 0;
  const rows = lines.map((line, r) => [...line].map((letter, c) => {
    if (!white(r, c)) return { content: '', background: col('ink') }; // a black square
    if (solved) return { content: letter, align: 'center' };
    // A square is numbered when a word starts in it, across or down, in reading order.
    const starts = (!white(r, c - 1) && white(r, c + 1)) || (!white(r - 1, c) && white(r + 1, c));
    const number = starts ? `**^${++clue}^**` : JOINER; // a bold superscript, or nothing
    return { content: `${number}\n${JOINER}` }; // gotcha: cell-blank-line
  }));
  return { rows, columnWidths: rows[0].map(() => 1) };
}
```

يضم المربع الأبيض سطرين من Chivo Mono بحجم 8.9 pt (رقم التلميح وسطر فارغ) داخل حشوة 1 pt، أي 26.7 pt إجمالًا: 9.41 مم، تُسع عرض الجدول. عمق تسعة مربعات 84.7 مم، وهو عمق خمسة عشر سطرًا من نص المتن ارتفاع كل منها 16 pt، ويبدأ إطار التلميحات على شبكة خطوط الأساس بعد سطر واحد. يأخذ المربع رقمًا، بترتيب القراءة، حين تبدأ فيه كلمة أفقية أو عمودية. الرقم حرف علوي غامق بحجم 5.2 pt (58 % من 8.9 pt) يرتفع 3 pt (ثلث 8.9 pt)، فيقع في الزاوية العليا للمربع. السطر الفارغ رابط كلمات (U+2060). ولو وُضعت مكانه مسافة غير فاصلة لأسقط الإصدار 1.4.1 السطر ولخرجت الشبكة بعمق 45.5 مم. المربع الأسود خلية فارغة لها `background` خاصة بها، مرتبطة بـ`ink`.

### 3 · الشيفرة تجد الكلمات في الشبكة

```js
// script.js, سطرًا 81–99
const STEPS = [[0, 1], [1, 0], [1, 1], [-1, 1], [0, -1], [-1, 0], [-1, -1], [1, -1]];
function locate(letters, word) { // the squares of a word, in any of eight directions
  const hits = [];
  letters.forEach((line, r) => [...line].forEach((_, c) => STEPS.forEach(([dr, dc]) => {
    const cells = [...word].map((_, i) => ({ row: r + i * dr, col: c + i * dc }));
    if (cells.every(({ row, col: k }, i) => letters[row]?.[k] === word[i])) hits.push(cells);
  })));
  if (hits.length !== 1) throw new Error(`${word} is in the grid ${hits.length} times`);
  return hits[0];
}
function wordSearch(source, fills) { // fills: [word, palette id] pairs
  const letters = source.trim().split('\n').slice(-12);
  let model = { rows: letters.map((line) => [...line].map((content) => ({ content,
    align: 'center' }))) };
  for (const [word, fill] of fills) {
    for (const at of locate(letters, word)) model = setCellBackground(model, at, col(fill));
  }
  return model;
}
```

الشبكة اثنا عشر سطرًا من الحروف الكبيرة في ملف محتوى خاص بها، بعد قائمة الكلمات المطلوب إيجادها. تبحث `locate()` عن كل كلمة في ثمانية اتجاهات وتطلق خطأ ما لم تجد تطابقًا واحدًا بالضبط، فخطأ مطبعي في الشبكة أو في القائمة يوقف البناء قبل أن يطبع كلمة لا يستطيع أحد إيجادها. تعيد `setCellBackground` نموذج جدول جديدًا فيه مربع معبأ إضافي. التعبئة مرتبطة بمدخل في لوحة الألوان وتغطي بلاطة النمط: مرجاني لـUMBRELLA في الصفحة 3، ونعناعي للكلمات العشر كلها في الصفحة 5. ليس لأنماط الجداول المسمّاة إعداد للصفوف المتناوبة في الإصدار 1.4.1، لذا يستدعي التنويع الأول `setCellBackground` على كل خلية في كل صف ثانٍ. بنك الكلمات فوق الشبكة صفّان متوسَّطان من الشارات: شارة UMBRELLA معبأة بالمرجاني كمربعاتها، والتسع الأخرى بيضاء بحدّ كحلي.

### 4 · صفحة افتتاح واحدة، بأربعة ألوان

```js
// script.js, سطرًا 103–131
const BAND = 40; // mm: the colour field, from the top edge
const pin = (to, edge, x, y, size) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });
const text = (id, content, family, size, placement, extra) => ({ kind: 'text', id, content,
  fontFamily: family, fontSize: pt(size), color: col('ink'), overflow: 'wrap', placement,
  align: placement.anchor.edge.endsWith('right') ? 'right' : 'left', ...extra });
const CAPS = { fontWeight: 700, textTransform: 'uppercase', letterSpacing: em(1 / 6) };
// The field's box sets the opener's depth: the text starts on the next grid line below.
const opener = { enabled: true, slot: { elements: [
  { kind: 'box', id: 'field', style: { backgroundColor: col('band') },
    placement: pin('page', 'top-left', 0, 0, { width: 'fill', height: mm(BAND) }) },
  { kind: 'image', id: 'surf', resourceId: 'olas', // the field's foot, cut in waves
    placement: pin('page', 'top-left', 0, BAND - 3, { width: mm(PAGE), height: mm(3.2) }) },
  text('kicker', '{attr.kicker}', LABEL, 8.5, pin('page', 'top-left', MARGIN, 11), CAPS),
  text('title', '{titleText}', DISPLAY, 46, pin('#kicker', 'below', -0.6, 0.5),
    { lineHeight: 1 }), // a multiple, never pt() (gotcha: design-lineheight-multiple)
  text('theme', '{attr.theme}', TEXT, 10, pin('page', 'top-right', -MARGIN, 23.5), {
    fontWeight: 700, box: { backgroundColor: col('paper'), borderRadius: mm(3.5),
      padding: { top: mm(1.6), right: mm(3.2), bottom: mm(1.6), left: mm(3.2) } } }),
] } };
// The H1 break is restated (gotcha: headings-drop-h1-break), parity 'any'. span 'page' even
// in one column, or the field is cut at the top margin (gotcha: opener-clipped-at-top).
const puzzleLevel = { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: opener };
// '# Sopa de letras {style="sopa"}': the style's palette turns 'band' mint on that page,
// in the field, the folio disc and anything else linked to it.
const BANDS = { crucigrama: 'band', sopa: 'mint', laberinto: 'coral', soluciones: 'sun' };
const puzzleStyles = Object.entries(BANDS)
  .map(([id, band]) => ({ id, palette: { band: palette[band] } }));
```

تشترك الصفحات من 2 إلى 5 في تصميم واحد. يمتد حقل لوني 40 مم من الحافة العليا وينتهي بحافة سفلية متموجة مرسومة بلون الورق؛ وتأتي العبارة التمهيدية والعنوان والموضوع في كبسولته البيضاء من سطر العنوان وخصائصه. الحقل وقرص رقم الصفحة مرتبطان بمدخل لوحة الألوان `band`، ويعطي نمط عنوان كل صفحة `band` قيمته الخاصة، فيجعل `# Word Search {style="sopa"}` كليهما نعناعيًا في الصفحة 3. يضبط المستوى 1 `span: 'page'` مع أن الكتاب بعمود واحد: يقصّ الإصدار 1.4.1 التصميم الباقي في العمود عند الهامش العلوي البالغ 16 مم، فيضيع أعلى الحقل والعبارة التمهيدية. صندوق الحقل يُحسب في العمق الذي تحجزه صفحة الافتتاح، فيبدأ النص على سطر الشبكة التالي، على بعد 44.2 مم من الحافة العليا و4 مم تحت الأمواج.

### 5 · الحلول من البيانات نفسها

```js
// script.js, سطرًا 504–520
const resourceTypes = [ // a plain resource prints no caption; an answer is numbered
  { id: 'plain', name: 'Plain', shortLabel: '', captionPrefix: '' },
  { id: 'solucion', ...t({ en: { name: 'Solution', captionPrefix: 'Solution' },
    es: { name: 'Solución', captionPrefix: 'Solución' } }), shortLabel: 'Sol.' },
].map((type) => ({ numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  ...type }));
const ANSWER_GAP = 6; // mm between the three answers
const ANSWER_WIDTH = KEY / ((MEASURE - 2 * ANSWER_GAP) / 3); // 54 mm of a 55.3 mm column
const answers = [
  grid('sol-crucigrama', 'solucion', crossword(crosswordText, true), 'solucion-9', ANSWER_WIDTH,
    { caption: t({ en: 'Crossword', es: 'Crucigrama' }) }),
  grid('sol-sopa', 'solucion', wordSearch(searchText, words.map((w) => [w, 'mint'])),
    'solucion-12', ANSWER_WIDTH, { caption: t({ en: 'Word search', es: 'Sopa de letras' }) }),
  picture('sol-laberinto', 'solucion', { placement: here(ANSWER_WIDTH), caption: t({
    en: 'Maze', es: 'Laberinto' }), altText: t({ en: 'The maze and its way out to the sea',
    es: 'El laberinto y su salida al mar' }) }),
];
```

تستدعي صفحة الحلول `crossword()` مع `solved` و`wordSearch()` مع الكلمات العشر كلها، على ملفات المحتوى نفسها التي تستعملها الألغاز، فأي تعديل في شبكة يغيّر حلها أيضًا. تستعمل الألغاز والرسوم النوع Plain، الذي لا تطبع `captionPrefix` الفارغة فيه أي تعليق لمورد بلا نص تعليق. وتستعمل الحلول النوع Solution، المرقّم من 1 إلى 3 بترتيب القراءة ليطابق العبارات التمهيدية من Puzzle 1 إلى Puzzle 3. تقع الحلول في إطار بلا تعبئة ولا حدّ يضم `:::columns{count=3 breaks="2,3"}`، حيث يفتح `breaks` عمودًا جديدًا عند الحل الثاني والثالث؛ عرض كل حل 54 مم في عمود عرضه 55.3 مم. ويفعل إطار Did you know? الشيء نفسه بـ`breaks="2"` ليضع رسم العش بجانب فقرته.

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

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

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

### script.js

```js
// ═══ Postext Cookbook · Nº 069 · Puzzle book: crossword, word search and maze ═══════
// https://postext.dev/en/cookbook/puzzle-book
// Code: MIT · Text and puzzles: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0)
// Fonts: Lexend, Lilita One, Chivo Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  setCellBackground } from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'puzzle-book';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = {
  ink: '#1f2a44', // text, grid rules and the black squares: a navy near-black
  paper: '#ffffff',
  band: '#2d9cdb', // a puzzle's colour field: sea blue until a puzzle's style replaces it
  sun: '#ffb627', coral: '#ff6f59', mint: '#5cc8a8', // the other fields; coral marks a find
  tint: '#fff1cc', // boxes and word-search tiles
  muted: '#5b6477', // running titles and the colophon
};
// Design slots read the hex in 1.4.1 and a puzzle's palette reads the id: col() writes both
// (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.ink })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, DISPLAY, LABEL] = ['Lexend', 'Lilita One', 'Chivo Mono'];
const PAGE = 210, MARGIN = 16; // mm: a square booklet, the same margin on every side
const MEASURE = PAGE - 2 * MARGIN; // 178 mm
const BODY = 11.5, LEAD = 16; // pt: large, open text for young readers
const PT_PER_MM = 72 / 25.4;
const JOINER = '\u2060'; // a word joiner: a line with nothing on it that still counts

// #region answer: a named table style for each grid, picked by the table's styleId
// Every grid shares tableStyle; a named style sets its size, padding, rules and fills.
// A row is as tall as its lines plus the padding and a column is its share of the
// table's width, so the padding sets how deep a square is.
const CELL = (15 * LEAD) / 9; // pt: nine squares are fifteen lines of text, 9.4 mm each
const PAD = 1; // pt: a clue number sits this close to the corner, clear of the 1 pt rule
const CLUE_FACE = (CELL - 2 * PAD) / (2 * LEAD / BODY); // 8.9 pt: two lines fill a square
const LETTER = 15; // pt: the word search's capitals
const pad = (cell, face) => pt((cell - face * LEAD / BODY) / 2); // one line fills the cell
const KEY = 54; // mm: the three answers are this deep, so their captions share a line
const key = (count) => { // count squares in KEY mm, letters of 4/3 pt per mm of square
  const side = (KEY / count) * PT_PER_MM, face = (KEY / count) * 4 / 3;
  return { id: `solucion-${count}`, bodyFontSize: pt(face), cellPadding: pad(side, face),
    borderWidth: pt(0.5) };
};
// No grid has a head row; the grey head is off for the Cookbook's default-skin check.
const tableStyle = { headerBackgroundEnabled: false, bodyFontFamily: LABEL };
const tableStyles = [
  { id: 'crucigrama', bodyFontSize: pt(CLUE_FACE), cellPadding: pt(PAD), borderWidth: pt(1) },
  { id: 'sopa', bodyFontSize: pt(LETTER), cellPadding: pad(CELL, LETTER), // same squares
    bodyBackgroundEnabled: true, bodyBackground: col('tint'), // tiles with white joints
    borderColor: col('paper'), borderWidth: pt(2.4), borderRadius: mm(4) },
  key(9), key(12), // 6 mm squares with 8 pt letters, 4.5 mm squares with 6 pt letters
];
// A table resource names its style; a cell's own background covers the style's fill.
const here = (width) => ({ position: 'here', width, align: 'center' }); // of the column
const grid = (id, typeId, model, styleId, width, extra) => ({ id, typeId, kind: 'table',
  createdAt: 0, updatedAt: 0, table: { model, styleId }, placement: here(width), ...extra });
// #endregion

// #region crossword: nine lines of letters give the crossword, or with solved its answer
// Two lines of CLUE_FACE (a number, an empty line) and the padding make a square.
function crossword(source, solved = false) {
  const lines = source.trim().split('\n');
  const white = (r, c) => (lines[r]?.[c] ?? '#') !== '#';
  let clue = 0;
  const rows = lines.map((line, r) => [...line].map((letter, c) => {
    if (!white(r, c)) return { content: '', background: col('ink') }; // a black square
    if (solved) return { content: letter, align: 'center' };
    // A square is numbered when a word starts in it, across or down, in reading order.
    const starts = (!white(r, c - 1) && white(r, c + 1)) || (!white(r - 1, c) && white(r + 1, c));
    const number = starts ? `**^${++clue}^**` : JOINER; // a bold superscript, or nothing
    return { content: `${number}\n${JOINER}` }; // gotcha: cell-blank-line
  }));
  return { rows, columnWidths: rows[0].map(() => 1) };
}
// #endregion

// #region search: a word search that finds its words in the grid and fills their squares
const STEPS = [[0, 1], [1, 0], [1, 1], [-1, 1], [0, -1], [-1, 0], [-1, -1], [1, -1]];
function locate(letters, word) { // the squares of a word, in any of eight directions
  const hits = [];
  letters.forEach((line, r) => [...line].forEach((_, c) => STEPS.forEach(([dr, dc]) => {
    const cells = [...word].map((_, i) => ({ row: r + i * dr, col: c + i * dc }));
    if (cells.every(({ row, col: k }, i) => letters[row]?.[k] === word[i])) hits.push(cells);
  })));
  if (hits.length !== 1) throw new Error(`${word} is in the grid ${hits.length} times`);
  return hits[0];
}
function wordSearch(source, fills) { // fills: [word, palette id] pairs
  const letters = source.trim().split('\n').slice(-12);
  let model = { rows: letters.map((line) => [...line].map((content) => ({ content,
    align: 'center' }))) };
  for (const [word, fill] of fills) {
    for (const at of locate(letters, word)) model = setCellBackground(model, at, col(fill));
  }
  return model;
}
// #endregion

// #region openers: one opener for every puzzle; each puzzle's style gives it its colour
const BAND = 40; // mm: the colour field, from the top edge
const pin = (to, edge, x, y, size) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });
const text = (id, content, family, size, placement, extra) => ({ kind: 'text', id, content,
  fontFamily: family, fontSize: pt(size), color: col('ink'), overflow: 'wrap', placement,
  align: placement.anchor.edge.endsWith('right') ? 'right' : 'left', ...extra });
const CAPS = { fontWeight: 700, textTransform: 'uppercase', letterSpacing: em(1 / 6) };
// The field's box sets the opener's depth: the text starts on the next grid line below.
const opener = { enabled: true, slot: { elements: [
  { kind: 'box', id: 'field', style: { backgroundColor: col('band') },
    placement: pin('page', 'top-left', 0, 0, { width: 'fill', height: mm(BAND) }) },
  { kind: 'image', id: 'surf', resourceId: 'olas', // the field's foot, cut in waves
    placement: pin('page', 'top-left', 0, BAND - 3, { width: mm(PAGE), height: mm(3.2) }) },
  text('kicker', '{attr.kicker}', LABEL, 8.5, pin('page', 'top-left', MARGIN, 11), CAPS),
  text('title', '{titleText}', DISPLAY, 46, pin('#kicker', 'below', -0.6, 0.5),
    { lineHeight: 1 }), // a multiple, never pt() (gotcha: design-lineheight-multiple)
  text('theme', '{attr.theme}', TEXT, 10, pin('page', 'top-right', -MARGIN, 23.5), {
    fontWeight: 700, box: { backgroundColor: col('paper'), borderRadius: mm(3.5),
      padding: { top: mm(1.6), right: mm(3.2), bottom: mm(1.6), left: mm(3.2) } } }),
] } };
// The H1 break is restated (gotcha: headings-drop-h1-break), parity 'any'. span 'page' even
// in one column, or the field is cut at the top margin (gotcha: opener-clipped-at-top).
const puzzleLevel = { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: opener };
// '# Sopa de letras {style="sopa"}': the style's palette turns 'band' mint on that page,
// in the field, the folio disc and anything else linked to it.
const BANDS = { crucigrama: 'band', sopa: 'mint', laberinto: 'coral', soluciones: 'sun' };
const puzzleStyles = Object.entries(BANDS)
  .map(([id, band]) => ({ id, palette: { band: palette[band] } }));
// #endregion

// The folio in a disc of the puzzle's colour; the book's title or the puzzle's beside it.
const disc = (parity, edge, x) => text(`folio-${parity}`, '{pageNumber}', DISPLAY, 12,
  pin('page', edge, x, PAGE - 14, { width: mm(8), height: mm(8) }), { parity, lineHeight: 1,
    align: 'center', verticalAlign: 'middle',
    box: { backgroundColor: col('band'), borderRadius: mm(4) } });
const running = (parity, edge, x, content) => text(`title-${parity}`, content, LABEL, 7.5,
  pin('page', edge, x, PAGE - 11.7), { ...CAPS, parity, color: col('muted') });
const footer = { elements: [
  disc('even', 'top-left', MARGIN), running('even', 'top-left', MARGIN + 11, '{title}'),
  disc('odd', 'top-right', -MARGIN), running('odd', 'top-right', -MARGIN - 11, '{chapterTitle}'),
] };
// The answer page trades its running title for the colophon of '# Answers {colophon="…"}'.
const colophon = (parity, edge, x) => text(`colophon-${parity}`, '{attr.colophon}', LABEL, 7,
  pin('page', edge, x, PAGE - 11.5), { parity, color: col('muted') });
Object.assign(puzzleStyles.find(({ id }) => id === 'soluciones'), { footer: { elements: [
  ...footer.elements.filter(({ id }) => id.startsWith('folio')),
  colophon('even', 'top-right', -MARGIN), colophon('odd', 'top-left', MARGIN)] } });

// The cover: the drawing over the whole page, the title on its sky, a name line on the sea.
const cover = { id: 'portada', header: { elements: [] },
  footer: { elements: [] }, advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'art', resourceId: 'portada',
      placement: pin('page', 'top-left', 0, 0, { width: mm(PAGE), height: mm(PAGE) }) },
    text('kicker', '{attr.kicker}', LABEL, 8.5, pin('page', 'top-left', MARGIN, 18), CAPS),
    text('title', '{titleText}', DISPLAY, 58, pin('#kicker', 'below', -0.8, 3,
      { width: mm(120) }), { lineHeight: 0.95 }),
    text('subtitle', '{attr.subtitle}', TEXT, 15, pin('#title', 'below', 0.8, 3),
      { fontWeight: 600 }),
    text('age', '{attr.age}', LABEL, 9.5, pin('#subtitle', 'below', 0, 5), { ...CAPS,
      box: { backgroundColor: col('coral'), borderRadius: mm(3.6),
        padding: { top: mm(1.8), right: mm(3.4), bottom: mm(1.8), left: mm(3.4) } } }),
    // The field fills the calm band between the wave stripes at 173 and 190 mm.
    { kind: 'box', id: 'name-field', style: { backgroundColor: col('paper'),
      borderRadius: mm(3) }, placement: pin('page', 'top-left', MARGIN, 176.9,
      { width: mm(104), height: mm(9) }) },
    text('name', '{attr.name}', LABEL, 8.5, pin('page', 'top-left', MARGIN + 5, 180.2), CAPS),
    { kind: 'rule', id: 'name-line', direction: 'horizontal', thickness: pt(0.75),
      color: col('muted'), placement: pin('page', 'top-left', MARGIN + 27, 183.2,
        { width: mm(72) }) },
  ] } } };

const chip = (id, fill) => ({ id, fontFamily: LABEL, fontSize: em(0.8), bold: true,
  background: col(fill), borderColor: col(fill === 'paper' ? 'ink' : fill),
  borderWidth: pt(0.75), borderRadius: em(1), paddingX: em(0.45) });

// resourceTypes and ANSWER_GAP are declared with the answers below: config() runs later.
const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity)
  colorPalette, resourceTypes, tableStyle, tableStyles, footer, header: { elements: [] },
  page: { width: mm(PAGE), height: mm(PAGE), dpi: 150, margins: { top: mm(MARGIN),
    bottom: mm(MARGIN), left: mm(MARGIN), right: mm(MARGIN) } },
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), // in the boxes too: their bold ignores the palette in 1.4.1
    textAlign: 'left', firstLineIndent: mm(0), paragraphSpacing: true },
  // The H1 line under each design is still measured: in Lilita One, not in Open Sans 700.
  headings: { fontFamily: DISPLAY, fontWeight: 400, levels: [
    puzzleLevel,
    { level: 3, fontFamily: LABEL, fontWeight: 700, fontSize: pt(8.5), lineHeight: pt(13),
      textTransform: 'uppercase', marginBottom: pt(3) },
  ] },
  headingStyles: [cover, ...puzzleStyles],
  // The word bank: ink on paper and coral, colours no puzzle's palette changes
  // (gotcha: section-palette-skips-chips).
  chipStyles: [chip('palabra', 'paper'), chip('hallada', 'coral')],
  calloutStyles: [
    { id: 'pistas', background: col('tint'), borderRadius: mm(3), columnGap: mm(8),
      padding: { top: mm(4), right: mm(5), bottom: mm(4), left: mm(5) },
      marginTop: pt(0), // the grid above already leaves a line
      body: { fontSize: pt(10), lineHeight: pt(13), paragraphSpacing: false } },
    { id: 'soluciones', backgroundEnabled: false, columnGap: mm(ANSWER_GAP), // columns only
      padding: { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) } },
    { id: 'dato', background: col('tint'), borderRadius: mm(3), columnGap: mm(6),
      padding: { top: mm(4), right: mm(5), bottom: mm(4.5), left: mm(5) },
      titleStyle: { fontFamily: DISPLAY, fontSize: pt(15), fontWeight: 400 },
      body: { fontSize: pt(10), lineHeight: pt(14) } },
  ],
  captionStyle: { fontFamily: LABEL, fontSize: pt(8), gap: mm(2) },
  paragraphStyles: [{ id: 'banco', textAlign: 'center' }], // the word bank's two rows
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Cuaderno de verano"
author: "Ediciones Caracola"
---

# Cuaderno \\ de verano {style="portada" kicker="Ediciones Caracola · Cuaderno 4" subtitle="Pasatiempos del mar" age="De 9 a 11 años" name="Nombre"}

# Crucigrama {style="crucigrama" kicker="Pasatiempo 1" theme="Animales del mar"}

Cada número pequeño, como el **^1^** de la esquina, marca la casilla donde empieza el nombre de un animal. Escribe una letra en cada casilla blanca.

::resource{id="crucigrama"}

:::callout{type="pistas"}
:::columns{count=2 breaks="7"}
### Horizontales

**1** Camina de lado y tiene pinzas.

**4** El mayor de todos los delfines.

**5** Pone los huevos en la arena.

**7** Ocho brazos, dos tentáculos y tinta.

**8** La azul es el animal más grande.

### Verticales

**1** Animal diminuto que forma arrecifes.

**2** Ave que chilla en los puertos.

**3** Pez que se vende seco y salado.

**4** A veces guarda una perla.

**6** Pez plano con forma de cometa.
:::
:::

# Sopa de letras {style="sopa" kicker="Pasatiempo 2" theme="Un día de playa"}

Busca estas diez palabras de un día de playa. Van en horizontal, en vertical o en diagonal, y algunas están escritas del revés. La primera ya está marcada.

:::paragraphs{style="banco"}
:chip[SOMBRILLA]{style="hallada"} :chip[CASTILLO]{style="palabra"} :chip[TOALLA]{style="palabra"} :chip[GAFAS]{style="palabra"} :chip[CUBO]{style="palabra"}

:chip[CHANCLAS]{style="palabra"} :chip[BAÑADOR]{style="palabra"} :chip[PELOTA]{style="palabra"} :chip[HELADO]{style="palabra"} :chip[CREMA]{style="palabra"}
:::

::resource{id="sopa"}

# Laberinto {style="laberinto" kicker="Pasatiempo 3" theme="Rumbo al mar"}

Esta tortuga acaba de salir del huevo, en un nido enterrado en la playa. Ayúdala a llegar al agua: entre las dunas solo hay un camino.

::resource{id="laberinto"}

# Soluciones {style="soluciones" kicker="Para mirar al terminar" theme="Pasatiempos 1 a 3" colophon="Pasatiempos, textos y dibujos originales (CC BY 4.0) · Compuesto en Lexend, Lilita One y Chivo Mono (SIL OFL)"}

:::callout{type="soluciones"}
:::columns{count=3 breaks="2,3"}
::resource{id="sol-crucigrama"}

::resource{id="sol-sopa"}

::resource{id="sol-laberinto"}
:::
:::

:::callout{type="dato" title="¿Sabías que…?"}
:::columns{count=2 breaks="2"}
::resource{id="nido"}

La tortuga boba cava su nido en la arena, de noche, y deja en él unos cien huevos. Dos meses después, las crías salen todas juntas, también de noche, y corren hacia la parte más clara del horizonte, que suele ser el mar. Por eso las farolas de un paseo marítimo pueden despistarlas. En varias playas del Mediterráneo español, grupos de voluntarios vigilan los nidos hasta que nacen las crías.
:::
:::
`; // the booklet's text, clues and word bank
const crosswordText = String.raw`CANGREJO#
O##A####B
R##V#ORCA
A##I#S##C
L#TORTUGA
#R#T#R##L
CALAMAR#A
#Y######O
BALLENA##
`; // nine lines: letters, # for a black square
const searchText = String.raw`SOMBRILLA CASTILLO TOALLA GAFAS CUBO CHANCLAS BAÑADOR PELOTA HELADO CREMA

MLZOIECDEOQO
MNUOBMUIAALS
RIOSOMBRILLA
NETAGIOVITRM
PDGPCDETRAÑE
JIABALSNLTFR
TAFÑCAGLSOMC
TLAOCHANCLAS
IBSOAODALEHA
RRINTTOOEPOZ
EOEYAEAENUAE
TMCOGTVSUIEN
`; // the words to find, then twelve lines

// #region art: the cover, the waves, the nest and the maze, drawn in the page's colours
const n = (v) => +v.toFixed(2);
function mulberry32(seed) { // a seeded PRNG: the same maze in every capture
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let r = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
    return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
  };
}
// No words in the drawings: an SVG drawn as an image cannot see the page's fonts
// (gotcha: svg-no-webfonts). Start and finish are pictures instead.
const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" `
  + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
const P = palette;
const wave = (y, amp, len, x0, x1) => { // a sine line from x0 to x1, one crest per len
  let d = `M${x0} ${y}`;
  for (let x = x0; x < x1; x += len) d += ` q${len / 4} ${-amp} ${len / 2} 0 t${len / 2} 0`;
  return d;
};
function turtle(x, y, s, turn = 0) { // a hatchling seen from above, s mm long, head up at 0°
  const line = `stroke="${P.ink}" stroke-width="0.45" stroke-linejoin="round"`;
  const skin = (d) => `<path d="${d}" fill="${P.paper}" ${line}/>`;
  return `<g transform="translate(${x} ${y}) rotate(${turn}) scale(${n(s / 12)})">`
    + skin('M-2.4 -1.6Q-6.8 -4.4 -7 -0.4Q-4.4 -0.6 -2.6 0.6Z') // front flippers
    + skin('M2.4 -1.6Q6.8 -4.4 7 -0.4Q4.4 -0.6 2.6 0.6Z')
    + skin('M-1.8 3Q-4.2 4.4 -3.6 5.9Q-2 5.2 -1 4Z') + skin('M1.8 3Q4.2 4.4 3.6 5.9Q2 5.2 1 4Z')
    + `<ellipse cy="-5.3" rx="1.7" ry="2" fill="${P.paper}" ${line}/>`
    + `<ellipse cy="0.4" rx="3.5" ry="4.5" fill="${P.mint}" ${line}/>`
    + `<path d="M0 -2L1.4 -1V1.4L0 2.4L-1.4 1.4V-1ZM0 -2V-4.1M0 2.4V4.9M1.4 -1L3.1 -2.2`
    + `M-1.4 -1L-3.1 -2.2M1.4 1.4L3.2 2.6M-1.4 1.4L-3.2 2.6" fill="none" stroke="${P.ink}" `
    + 'stroke-width="0.35"/>'
    + `<circle cx="-0.7" cy="-5.9" r="0.32" fill="${P.ink}"/>`
    + `<circle cx="0.7" cy="-5.9" r="0.32" fill="${P.ink}"/></g>`;
}
function coverArt() { // sky, a low sun, a striped sea, a boat, two gulls and a turtle
  const H = PAGE, sea = 112;
  let g = `<rect width="${PAGE}" height="${H}" fill="${P.tint}"/>`
    + `<circle cx="152" cy="${sea - 8}" r="46" fill="${P.sun}"/>`
    + `<rect y="${sea}" width="${PAGE}" height="${H - sea}" fill="${P.band}"/>`;
  for (let i = 0, y = sea + 9; y < H; i++, y += 9 + i * 1.6) { // stripes widen towards us
    g += `<path d="${wave(y, 1.2 + i * 0.25, 18 + i * 3, -9, PAGE + 20)}" fill="none" `
      + `stroke="${P.paper}" stroke-width="${n(1.1 + i * 0.35)}" stroke-linecap="round"/>`;
  }
  [[6, 34], [12, 22], [19, 12]].forEach(([dy, w]) => { // the sun's path on the water
    g += `<rect x="${152 - w / 2}" y="${sea + dy - 1}" width="${w}" height="2.2" rx="1.1" `
      + `fill="${P.sun}"/>`;
  });
  g += `<path d="M26 ${sea - 0.4}h15l-2.4 3.6h-10.4z" fill="${P.ink}"/>` // a boat on the line
    + `<path d="M33.6 ${sea - 1.6}v-15l8 13.6z" fill="${P.coral}"/>`
    + `<path d="M32.6 ${sea - 1.6}v-11.6l-6 11.6z" fill="${P.paper}"/>`;
  [[118, 34, 5], [131, 27, 3.6]].forEach(([x, y, w]) => { // two gulls
    g += `<path d="M${x - w} ${y}q${w / 2} -${w / 2} ${w} 0q${w / 2} -${w / 2} ${w} 0" `
      + `fill="none" stroke="${P.ink}" stroke-width="0.8" stroke-linecap="round"/>`;
  });
  return svg(PAGE, H, g + turtle(160, 170, 22, -35));
}
const surf = () => svg(PAGE, 3.2, `<path d="${wave(3.2, 1.6, 12, 0, PAGE + 12)} V3.2 H0Z" `
  + `fill="${P.paper}"/>`);
function nestArt() { // a cutaway beach: the eggs under the sand, two hatchlings on their way
  const W = 80, H = 46, shore = [46, 15.5], deep = [W, 40]; // the sand slopes under the sea
  const slope = (y) => shore[0] + ((y - shore[1]) / (deep[1] - shore[1])) * (deep[0] - shore[0]);
  let g = `<rect width="${W}" height="${H}" fill="${P.tint}"/>` // the box's own tint as sky
    + `<path d="M0 13.6Q13 11.4 25 13.8T${shore[0]} ${shore[1]}L${deep[0]} ${deep[1]}V${H}H0Z" `
    + `fill="${P.sun}"/><path d="M${shore[0]} ${shore[1]}H${W}V${deep[1]}Z" fill="${P.band}"/>`;
  for (let y = shore[1] + 5; y < deep[1] - 2; y += 5) { // ripples, from the slope outwards
    g += `<path d="${wave(y, 0.5, 4, n(slope(y) + 2), W + 4)}" fill="none" `
      + `stroke="${P.paper}" stroke-width="0.55"/>`;
  }
  g += `<ellipse cx="22" cy="33" rx="12.5" ry="8" fill="${P.tint}"/>`; // the egg chamber
  [[36.9, 5], [33.1, 4], [29.3, 3]].forEach(([y, count]) => { // the clutch, row on row
    for (let i = 0; i < count; i++) {
      g += `<circle cx="${n(22 + (i - (count - 1) / 2) * 4.35)}" cy="${y}" r="2.05" `
        + `fill="${P.paper}" stroke="${P.ink}" stroke-width="0.35"/>`;
    }
  });
  return svg(W, H, g + turtle(33, 10.4, 7, 100) + turtle(44.5, 12.4, 7, 112));
}
function maze(cols, rows, seed) { // a recursive backtracker: one path between any two squares
  const rand = mulberry32(seed);
  const open = new Set(); // passages, as 'r,c>r,c'
  const seen = new Set(['0,0']);
  const stack = [[0, 0]];
  const around = (r, c) => [[r - 1, c], [r + 1, c], [r, c - 1], [r, c + 1]];
  while (stack.length) {
    const [r, c] = stack.at(-1);
    const next = around(r, c).filter(([y, x]) =>
      y >= 0 && y < rows && x >= 0 && x < cols && !seen.has(`${y},${x}`));
    if (!next.length) { stack.pop(); continue; }
    const [y, x] = next[Math.floor(rand() * next.length)];
    open.add(`${r},${c}>${y},${x}`).add(`${y},${x}>${r},${c}`);
    seen.add(`${y},${x}`);
    stack.push([y, x]);
  }
  const passes = (a, b) => open.has(`${a}>${b}`);
  const queue = [[0, 0]]; // the way out, by breadth-first search
  const from = new Map([['0,0', null]]);
  for (let i = 0; i < queue.length; i++) {
    const [r, c] = queue[i];
    for (const [y, x] of around(r, c)) {
      if (from.has(`${y},${x}`) || !passes(`${r},${c}`, `${y},${x}`)) continue;
      from.set(`${y},${x}`, `${r},${c}`);
      queue.push([y, x]);
    }
  }
  const route = [];
  for (let at = `${rows - 1},${cols - 1}`; at; at = from.get(at)) {
    route.unshift(at.split(',').map(Number));
  }
  return { cols, rows, passes, route };
}
function mazeArt(m, unit, { solved = false } = {}) {
  // Sand above the maze for the nest and sea below it. The answer is square, as deep as
  // the answer grids beside it, so the three captions share a line: the rest of the
  // square goes to the sea, where the hatchling swims, with waves drawn k times larger.
  const side = 1.5, W = m.cols * unit + 2 * side, top = (solved ? 2.4 : 1.6) * unit;
  const foot = solved ? W - m.rows * unit - top : 1.6 * unit, k = solved ? 2 : 1;
  const H = m.rows * unit + top + foot;
  const X = (c) => n(side + c * unit), Y = (r) => n(top + r * unit);
  let walls = `M${X(1)} ${Y(0)}H${X(m.cols)}V${Y(m.rows)}M${X(m.cols - 1)} ${Y(m.rows)}`
    + `H${X(0)}V${Y(0)}`; // the frame, open above the first square and below the last
  for (let r = 0; r < m.rows; r++) {
    for (let c = 0; c < m.cols; c++) {
      const [here, right, below] = [`${r},${c}`, `${r},${c + 1}`, `${r + 1},${c}`];
      if (c < m.cols - 1 && !m.passes(here, right)) walls += `M${X(c + 1)} ${Y(r)}V${Y(r + 1)}`;
      if (r < m.rows - 1 && !m.passes(here, below)) walls += `M${X(c)} ${Y(r + 1)}H${X(c + 1)}`;
    }
  }
  // The nest above the entrance, the sea below the exit.
  const shore = H - foot + 4;
  let g = `<rect width="${W}" height="${H}" fill="${P.tint}"/>`
    + `<path d="${wave(shore, 1.4 * k, 12 * k, 0, W + 12 * k)}V${H}H0Z" fill="${P.band}"/>`;
  for (let y = shore + 5 * k; y < H; y += 4.5 * k) {
    g += `<path d="${wave(y, 0.8 * k, 10 * k, 0, W + 10 * k)}" fill="none" `
      + `stroke="${P.paper}" stroke-width="${0.7 * k}"/>`;
  }
  const [nx, ny] = [X(0.5), top - 7.5]; // a hollow in the sand and two empty shells
  g += `<ellipse cx="${nx}" cy="${ny}" rx="${unit * 0.6}" ry="3.6" fill="${P.sun}"/>`
    + [[nx + unit * 0.95, ny - 1.2], [nx + unit * 1.35, ny + 1.4]].map(([ex, ey]) =>
      `<ellipse cx="${n(ex)}" cy="${n(ey)}" rx="1.5" ry="1.9" fill="${P.paper}" `
      + `stroke="${P.ink}" stroke-width="0.4"/>`).join('');
  if (solved) { // the way out in coral, from the nest into the water, and the hatchling
    const pts = m.route.map(([r, c]) => `${X(c + 0.5)} ${Y(r + 0.5)}`);
    g += `<path d="M${X(0.5)} ${Y(0) - side}L${pts.join('L')}`
      + `L${X(m.cols - 0.5)} ${shore + 3}" fill="none" stroke="${P.coral}" `
      + `stroke-width="${n(unit * 0.3)}" stroke-linecap="round" stroke-linejoin="round"/>`
      + turtle(X(m.cols - 2.5), n(shore + foot / 2), unit * 1.8, 165);
  } else g += turtle(nx, ny + 0.5, unit * 1.05, 180);
  g += `<path d="${walls}" fill="none" stroke="${P.ink}" stroke-width="${n(unit * 0.12)}" `
    + 'stroke-linecap="round"/>';
  return { markup: svg(n(W), n(H), g), width: W, height: H };
}
const theMaze = maze(17, 9, 2027);
const drawings = { portada: { markup: coverArt(), width: PAGE, height: PAGE },
  olas: { markup: surf(), width: PAGE, height: 3.2 },
  nido: { markup: nestArt(), width: 80, height: 46 },
  laberinto: mazeArt(theMaze, 10),
  'sol-laberinto': mazeArt(theMaze, 10, { solved: true }) };
for (const [id, { markup }] of Object.entries(drawings)) await loadSvg(`${id}.svg`, markup);
const picture = (id, typeId, extra) => ({ id, typeId, kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: `${id}.svg`, width: drawings[id].width * 10, height: drawings[id].height * 10 },
  ...extra });
// #endregion

const words = searchText.trim().split('\n')[0].split(' '); // the first one comes found
const share = (count, size) => (count * size) / MEASURE; // a grid's share of the measure
const puzzles = [
  grid('crucigrama', 'plain', crossword(crosswordText), 'crucigrama',
    share(9, CELL / PT_PER_MM), { altText: t({ en: 'An empty crossword, nine squares a side',
      es: 'Un crucigrama vacío de nueve por nueve' }) }),
  grid('sopa', 'plain', wordSearch(searchText, [[words[0], 'coral']]), 'sopa',
    share(12, CELL / PT_PER_MM), { altText: t({ en: 'A word search, twelve letters a side',
      es: 'Una sopa de letras de doce por doce' }) }),
  picture('laberinto', 'plain', { placement: here(1), altText: t({
    en: 'A maze from a turtle nest to the sea', es: 'Un laberinto desde un nido hasta el mar' }) }),
  picture('nido', 'plain', { placement: here(1), altText: t({
    en: 'Eggs under the sand, two hatchlings on their way to the sea',
    es: 'Huevos bajo la arena y dos crías camino del mar' }) }),
  picture('portada', 'plain', { altText: t({ en: 'A low sun, a sailing boat and a turtle at sea',
    es: 'Un sol bajo, un velero y una tortuga en el mar' }) }),
  picture('olas', 'plain', { altText: '' }), // the field's wavy foot: decoration
];

// #region answers: the answer page reuses the puzzles' data; a resource type numbers it
const resourceTypes = [ // a plain resource prints no caption; an answer is numbered
  { id: 'plain', name: 'Plain', shortLabel: '', captionPrefix: '' },
  { id: 'solucion', ...t({ en: { name: 'Solution', captionPrefix: 'Solution' },
    es: { name: 'Solución', captionPrefix: 'Solución' } }), shortLabel: 'Sol.' },
].map((type) => ({ numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  ...type }));
const ANSWER_GAP = 6; // mm between the three answers
const ANSWER_WIDTH = KEY / ((MEASURE - 2 * ANSWER_GAP) / 3); // 54 mm of a 55.3 mm column
const answers = [
  grid('sol-crucigrama', 'solucion', crossword(crosswordText, true), 'solucion-9', ANSWER_WIDTH,
    { caption: t({ en: 'Crossword', es: 'Crucigrama' }) }),
  grid('sol-sopa', 'solucion', wordSearch(searchText, words.map((w) => [w, 'mint'])),
    'solucion-12', ANSWER_WIDTH, { caption: t({ en: 'Word search', es: 'Sopa de letras' }) }),
  picture('sol-laberinto', 'solucion', { placement: here(ANSWER_WIDTH), caption: t({
    en: 'Maze', es: 'Laberinto' }), altText: t({ en: 'The maze and its way out to the sea',
    es: 'El laberinto y su salida al mar' }) }),
];
// #endregion
const resources = [...puzzles, ...answers];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first)
  Lexend: ['400', '600', '700'], 'Lilita One': ['400'], 'Chivo Mono': ['400', '700'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const allText = [markdown, crosswordText, searchText].join('\n');
await loadFonts(FONTS, allText);
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), allText);
showPages(doc, { title: t({ en: 'Summer Workbook · Puzzles from the sea',
  es: 'Cuaderno de verano · Pasatiempos del mar' }) });

// ─── 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 v1 ── the same in every recipe · postext.dev/cookbook ────────
// Postext measures text with the faces the browser has loaded, and caches the
// widths, so every face must be ready before the first build. Faces come from
// Fontsource: the same static files the PDF embeds, so screen and PDF agree.

/** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = {
    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',
  };
  const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin'];
  const jobs = [];
  let added = 0;
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const meta = optional ? await fontsourceMeta(family) : null;
    for (const spec of new Set(specs)) {
      const weight = parseInt(spec, 10);
      const style = spec.endsWith('i') ? 'italic' : 'normal';
      if (hasFace(family, weight, style)) continue;
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/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().then((ready) => { document.fonts.add(ready); added++; }, () => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return added;
}

/** Runs `build` (a buildDocument or buildBundle call) and checks the faces
 *  the pages use. A regular face missing from FONTS is loaded with a warning;
 *  bold and italic variants are loaded when the family ships them. Then the
 *  measurement caches are cleared and the build runs again. */
async function buildWithFonts(build, text = '') {
  const tried = new Set();
  for (let round = 0; round < 3; round++) {
    kitStatus('Laying out…');
    await new Promise(requestAnimationFrame);          // let the status paint first
    const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; });
    const wanted = { base: {}, variants: {} };
    for (const { font, base } of [result].flat().flatMap(fontStringsOf)) {
      const { family, weight, style } = parseFont(font);
      const key = `${family}|${weight}|${style}`;
      if (tried.has(key) || hasFace(family, weight, style)) continue;
      tried.add(key);
      (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`);
    }
    if (Object.keys(wanted.base).length) {
      console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`);
    }
    const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true });
    if (added === 0) return result;
    clearMeasurementCache();
  }
  throw new Error('The fonts did not settle after three builds.');
}

/** Every font string of the layout. `base` marks a block's own face; its
 *  bold, italic and bold-italic variants are listed whether or not used. */
function fontStringsOf(doc) {
  const found = new Map();
  const walk = (node) => {
    if (!node || typeof node !== 'object') return;
    if (Array.isArray(node)) { node.forEach(walk); return; }
    for (const [key, value] of Object.entries(node)) {
      if (typeof value === 'string' && /fontString$/i.test(key)) {
        found.set(value, found.get(value) || key === 'fontString');
      } else if (value && typeof value === 'object') walk(value);
    }
  };
  walk(doc.pages);
  walk(doc.blocks);
  return [...found].map(([font, base]) => ({ font, base }));
}

/** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }.
 *  A string with no weight ('95.8px Young Serif', from a design text) is 400. */
function parseFont(font) {
  const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim());
  if (!m) throw new Error(`Unexpected font string: ${font}`);
  const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]);
  return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' };
}

/** True when a loaded FontFace covers exactly this family, weight and style
 *  (document.fonts.check() is also true 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;
}

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

/** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), 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 v1 ── the same in every recipe · postext.dev/cookbook ───────
/** Shows the pages as facing spreads on a dark desk: the first page is a
 *  recto on its own, then verso | recto pairs, as in a bound book. Pages
 *  are painted when they scroll near the screen. */
function showPages(docs, { title, width = 460 } = {}) {
  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, { 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 · images v1 ── 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 (drawn in code, or fetched) as a vector image. */
async function loadSvg(fileId, svg) {
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** 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 ───────────────────────────────────────────────────────────────────────
```

## تنويعات

### خطّط صفوف البحث عن الكلمات

يساعد أصفر أعمق في كل صف ثانٍ القارئ الصغير على تتبّع سطر من الحروف، ولأن الكلمات تُعبأ بعد الخطوط تبقى UMBRELLA مرجانية وتحتفظ شبكة الحل بكلماتها النعناعية.

```diff
   let model = { rows: letters.map((line) => [...line].map((content) => ({ content,
     align: 'center' }))) };
+  const stripe = { hex: '#ffdd80', model: 'hex' }; // a shade deeper than the tiles
+  letters.forEach((line, r) => r % 2 && [...line].forEach((_, c) => {
+    model = setCellBackground(model, { row: r, col: c }, stripe);
+  }));
   for (const [word, fill] of fills) {
```

### ارسم متاهة أصعب

واحد وعشرون مربعًا في أحد عشر تتسع في العرض نفسه بـ8.4 مم لكل منها، ويُرسم الحل في الصفحة 5 من المتاهة الجديدة.

```diff
-const theMaze = maze(17, 9, 2027);
+const theMaze = maze(21, 11, 2027);
```

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

- **السطر الفارغ في خلية الجدول يُحذف.** يبدأ فاصل السطر في خلية الجدول فقرة جديدة، لكن postext 1.4.1 يحذف الفقرة الفارغة أو التي لا تحتوي إلا مسافات غير قابلة للكسر (U+00A0)، فالخلية المكتوبة 'NAME\n\n' ارتفاعها سطر واحد. لترك أسطر للكتابة عليها، ضع رابط الكلمات (U+2060) في كل سطر فارغ: 'NAME\n\u2060\n\u2060' ارتفاعها ثلاثة أسطر.
- **الجدول 'here' لا ينقسم أبدًا.** لا تنقسم عبر الأعمدة والصفحات إلا الجداول العائمة؛ أما الجدول الموضوع 'here' فينتقل كاملًا. اجعل الجدول الطويل يطفو، أو أبقِ الجداول داخل النص قصيرة.
- **لا يعمل :::columns إلا داخل إطار ولا ينقسم أبدًا.** يُتجاهَل :::columns خارج الإطار، والإطار الذي ينقسم لا يقطع أبدًا داخل مجموعة أعمدة. السمة breaks تعدّ الكتل الأبناء، والإطار المتداخل يُعدّ كتلة واحدة.
- **الافتتاحية الباقية في عمودها تُقصّ عند أعلى كتلة النص.** في postext 1.4.1 يُقصّ العنوان ذو التصميم المتقدّم الباقي في عموده عند الحافة العليا للعمود: الإطار أو الصورة المثبّتة على الصفحة أو على النزف يُرسَم في الهوامش الجانبية لكن لا في الهامش العلوي، ولا يقول ذلك أي تحذير. أعطِ هذا العنوان span: 'page'، حتى في كتاب بعمود واحد: فيُرسَم تصميمه حينئذ كاملًا شريطَ افتتاحية الصفحة.
- **لوحة ألوان القسم أو الجزء لا تعيد تلوين الشارات.** في postext 1.4.1 تعيد لوحة ألوان نمط العنوان أو الجزء تلوين النص والإطارات وعناصر التصميم في صفحاته، لكن الشارة تحتفظ بألوان مُدخلها في chipStyles. فالشارة المرتبطة بمُدخل يتجاوزه القسم تُظهر قيمة المستند في كل صفحة. اجعل ألوان الشارات مما لا يغيّره أي قسم، كالحبر على الورق، أو أعطِ كل قسم نمط شارة خاصًا به.
- **لوحة الألوان المستبدلة لا تصل إلى عناصر التصميم ولا إلى لون الإحالة.** يقرأ postext 1.4.1 الإعداد colorPalette في أنماط النص (المتن والعناوين والقوائم والتعليقات والجداول والإطارات) لكن لا في عناصر الترويسات والتذييلات والافتتاحيات وصفحات الأجزاء، ولا في bodyText.referenceColor: تحتفظ بالقيمة الست عشرية المكتوبة بجانب paletteId الخاص بها. حين تستبدل لوحة الألوان، لنسخة شاشة داكنة أو لإعادة تلوين، أعِد كتابة كل لون مرتبط من colorPalette قبل البناء.
- **أي كائن headings يُلغي فاصل الصفحة قبل H1.** ينتقل H1 افتراضيًا إلى صفحة فردية (always-odd)، لكن تمرير أي كائن headings يعيد ضبط هذا الافتراض، فتتوالى الفصول دون فاصل ولا يفعل span: 'page' شيئًا. أعد كتابة headings.levels[0].breakBefore: { enabled: true, parity } في كل إعداد.
- **lineHeight لنص التصميم مُضاعِف، لا بُعد أبدًا.** في خانة التصميم، يضاعف lineHeight لعنصر النص مقاس خطه (lineHeight: 1.05). في postext 1.4.1 لا يُرفض بُعدٌ مثل pt(15): يُقاس ارتفاع الافتتاحية NaN، ويسقط ما تحجزه من مساحة، بما فيه minHeight، دون تحذير، فيجري النص تحت العنوان.
- **النص داخل SVG في <img> لا يستطيع استخدام خطوط الويب.** يُرسَم SVG صورةً، والصورة لا تصل إلى خطوط الويب في الصفحة، فتعود تسمياته إلى خط من النظام. حوّل النص إلى مسارات، أو ضمّن مجموعة فرعية بـ @font-face داخل SVG، أو انقل التسميات إلى التعليق.
- **يُخزَّن الإعداد مؤقتًا بحسب هويته: ابنِ كائنًا جديدًا.** يخزّن المحرّك الإعدادات المحسوبة مؤقتًا بحسب هوية الكائن، فتعديل الإعداد في مكانه ثم البناء مجددًا يعيد استخدام النتيجة القديمة. ابنِ كائنًا جديدًا في كل بناء، ولهذا يكون إعداد الوصفة دالة مصنِّعة: config().
- **حمّل كل أوجه الخط قبل الإخراج.** يقيس الإخراج النص بأوجه الخط التي حمّلها المتصفح ويخزّن العروض مؤقتًا، فالوجه الذي يصل بعد البناء الأول يترك فواصل أسطر خاطئة وملف PDF لم يعد يطابق الشاشة. حمّل كل وزن وكل نمط أولًا، واستدعِ clearMeasurementCache() قبل إعادة البناء إذا تأخر وصول أحدها.

- أرقام التلميحات في الإطار مكتوبة في ملف المحتوى، أما أرقام الشبكة فمحسوبة. بعد تعديل شبكة، راجع التلميحات عليها، فالشيفرة لا تقارن بينهما.
- تعيد لوحة ألوان نمط العنوان تلوين عناصر التصميم والنص وخطوط الجداول، لكنها لا تبلغ داخل رسم SVG، لذا تُرسم الأمواج في أسفل كل حقل بلون الورق ويخدم رسم واحد الحقول الأربعة. وفي الإصدار 1.4.1 تتخطى تعبئة الجداول أيضًا: مربع كلمات متقاطعة مرتبط بـ`band` سيبقى أزرق بلون البحر في صفحة الحلول الصفراء.

## الحقوق

- الوصفة: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- الخطوط: Lexend (OFL-1.1), Lilita One (OFL-1.1), Chivo Mono (OFL-1.1)
- الشيفرة: MIT · محتوى المثال: CC-BY-4.0

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

- [رقم 022 · ورقة عمل بصناديق إجابة وبنك كلمات](https://postext.dev/ar/cookbook/worksheet-answer-boxes.md): ورقة عمل في العلوم من أربع صفحات: صناديق إجابة بيضاء في بطاقات خضراء فاتحة، على بعد 2 mm تحت كل سؤال وخارج الشبكة، مع بنوك كلمات وفراغات من الشارات. · المستوى 2 (متوسط) · كتب التمارين والتدريبات
- [رقم 035 · تقويم بستاني: شبكة تقويم ومخطط مستعرض](https://postext.dev/ar/cookbook/garden-almanac.md): مخطط زراعة لثمانية وثلاثين محصولًا مُنضَّد بالعرض على صفحتين خاصتين به، وتقويم محسوب من التواريخ ومصفوفة رفقة، خلاياها ممتلئة من لوحة الألوان. · المستوى 3 (متقدم) · الأدلة والمراجع
- [رقم 060 · كتاب قراءة مبكرة بشارات المقاطع](https://postext.dev/ar/cookbook/reading-primer-syllables.md): وحدة الحرف M من كتاب قراءة إسباني: المقاطع شارات بلون حرفها الصوتي، وشبكة كلمات مصوّرة في كل خلية منها لوحة مائية. · المستوى 2 (متوسط) · كتب التمارين والتدريبات
