# عائلة إطارات كتاب مدرسي مميَّزة بالألوان

> ستة أنواع من إطارات الكتاب المدرسي بثلاثة ألوان، يميّزها شريط بأيقونة، أو شارة، أو لسان مرقّم، أو شريط رموز، أو علامة خارج الإطار.

- نسخة HTML: https://postext.dev/ar/cookbook/textbook-box-family
- وصفة رقم 008 · الإطارات والملاحظات · المستوى 2 (متوسط) · المخرجات: Canvas
- الأنواع: الكتب المدرسية
- تتطلب postext ≥ 1.4.1 · اختُبرت مع 1.4.1 بتاريخ 2026-09-26
- الصفحات: [27](https://postext.dev/cookbook/textbook-box-family/en/p01.webp?v=00ce4347), [28](https://postext.dev/cookbook/textbook-box-family/en/p02.webp?v=00ce4347), [29](https://postext.dev/cookbook/textbook-box-family/en/p03.webp?v=00ce4347)
- افتح في Sandbox: https://postext.dev/ar/sandbox#recipe=textbook-box-family&lang=en (.postext: https://postext.dev/cookbook/textbook-box-family/en/textbook-box-family.postext)
- آخر تحديث: 2026-09-26
- لغات أخرى: [en](https://postext.dev/en/cookbook/textbook-box-family.md), [es](https://postext.dev/es/cookbook/textbook-box-family.md), [ca](https://postext.dev/ca/cookbook/textbook-box-family.md), [zh](https://postext.dev/zh/cookbook/textbook-box-family.md)

## باختصار

فصل من كتاب مدرسي في الأحياء فيه ستة أنواع من الإطارات، مثل التحذيرات وملاحظات المختبر. لكل نوع لونه ورمزه، فيميّز الطلاب بينها بنظرة واحدة.

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

يُفتتح الفصل 2 من *Living Matter*، وهو كتاب مدرسي في الأحياء للمرحلة الثانوية العليا على صفحة 210 × 280 مم، تحت رسم لخلية تقطعها الزاوية العليا للصفحة، ورقم الفصل في نواتها. ثم تأتي ستة أنواع من الإطارات بثلاثة ألوان، لكل منها علامة خاصة به، فيميّز الطالب الهدف من التحذير قبل أن يقرأه. الأرجواني يميّز أهداف التعلم، المنضدة على شريط يحمل هدفًا، والتقييم الذاتي، الذي له بلاطة بعلامة صح خارج إطاره. والأخضر يميّز النصيحة الدراسية، المنضدة بجانب خط رفيع تعلوه شارة مصباح، وإطار المختبر برموز السلامة الثلاثة. والتحذير كهرماني، بشارة تنبيه على زاويته الخارجية. وBOX 2.1 بلون الخزامى تحت لسان أسود ودورق. وتأخذ المصطلحات العريضة لون إطارها، الأرجواني في BOX 2.1.

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

- كيف أصنع إطارات للأهداف والنصائح والتحذيرات بأيقونة أو شريط أو زوايا مستديرة أو لسان مرقّم؟
- كيف أضيف لسانًا مرقّمًا («BOX 1-1»)، أو أيقونة في الزاوية، أو أيقونة في الهامش مع خط؟
- كيف ألوّن المصطلحات الرئيسية (العريضة أو المائلة) في المتن أو داخل الإطارات؟

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

```js
// script.js, سطرًا 40–61
// box(id, hue, device) is the shared base: the hue colours the title, bullets and key terms.
const TAB = 5, BADGE = 7.4; // in mm: the tab's height (and offset), the warning badge's size
const calloutStyles = [
  // The first style is also the one a missing or misspelt type falls back to.
  box('objectives', 'band', { title: t({ en: 'In this chapter', es: 'En este capítulo' }),
    background: col('tintBand'), stripe: { enabled: true, width: mm(7.5), color: col('band') },
    icon: icon('target', 5) }), // on a side stripe (left by default) the icon is centred on it
  box('feature', 'band', { background: col('mist'),
    titleStyle: { fontFamily: 'Lexend', fontSize: pt(10.5), color: col('ink'), gap: mm(1.6) },
    // Printed only where the fence has label="…": the tab, the flask beside it, a rule to them.
    label: { fontFamily: LABEL, fontSize: pt(8), color: col('paper'), background: col('ink'),
      position: 'top-left', // this verso's outer corner, above the badge ('top-right' by default)
      height: mm(TAB), offset: mm(TAB), paddingX: mm(2.3), // offset = height: on the top edge
      icon: { resourceId: 'flask', width: mm(4.4), gap: mm(1.2) },
      rule: { enabled: true, color: col('ink'), width: pt(1.2) } } }),
  box('warning', 'warnInk', { background: col('tintWarn'), borderRadius: mm(1.4),
    border: { enabled: true, color: col('warn'), width: pt(0.75) },
    // The badge hangs half past the outer corner: sides of half badge + GAP align the title.
    padding: { top: mm(2.8), right: mm(BADGE / 2 + GAP), bottom: mm(3.2),
      left: mm(BADGE / 2 + GAP) },
    icon: icon('caution', BADGE, { position: 'corner', cornerSide: 'outer' }) }),
]; // straight into config(), followed by the 'more' styles
```

## المكونات

**تعلّم**

- [إطارات التنبيه](https://postext.dev/ar/docs/configuration.md#أنماط-الإطارات): أنماط إطارات مسمّاة للملاحظات والنصائح والتحذيرات: الخلفية والحد ونصف قطر الزوايا والشريط الجانبي والعنوان، وتنسيق طباعي خاص بالمتن والقوائم فيها.
- [ألسنة الإطارات المرقّمة](https://postext.dev/ar/docs/configuration.md#أنماط-الإطارات): لسان تسمية على حافة الإطار («إطار 1-1») يُؤخذ من السمة label في سياج الإطار.
- [أيقونات الإطارات وشارات الزوايا](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#الحاوية-callout)
- [إطارات عائمة](https://postext.dev/ar/docs/configuration.md#الحاوية-callout)
- [الغامق والمائل وألوانهما](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/configuration.md#عناصر-الصورة)
- [سمات العنوان](https://postext.dev/ar/docs/document-format.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/document-format.md#الموارد)
- [استشهادات تضع الأشكال](https://postext.dev/ar/docs/document-format.md#الإحالة-داخل-السطر-الصيغة-الأساسية)
- [موضع الأشكال](https://postext.dev/ar/docs/document-format.md#الموضع)
- [صفحات على اللوحة (Canvas)](https://postext.dev/ar/docs/configuration.md#رسم-صفحة-في-صورة-نقطية)
- citations
- [العناوين المرقّمة](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#أنواع-الموارد)

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

- [`bodyText`](https://postext.dev/ar/docs/configuration.md#نص-المتن), [`calloutStyles`](https://postext.dev/ar/docs/configuration.md#أنماط-الإطارات), [`captionStyle`](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#رؤوس-الصفحات-وتذييلاتها), [`headings`](https://postext.dev/ar/docs/configuration.md#العناوين), [`layout`](https://postext.dev/ar/docs/configuration.md#التخطيط), [`locale`](https://postext.dev/ar/docs/configuration.md#تقسيم-الكلمات-بالواصلة), [`orderedLists`](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#أنواع-الموارد), [`unorderedLists`](https://postext.dev/ar/docs/configuration.md#القوائم-غير-المرقّمة)

**واجهات API**

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

**الخطوط**

- Noto Serif (OFL-1.1), Lexend (OFL-1.1), Barlow Semi Condensed (OFL-1.1)

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

### 1 · أعطِ كل نوع من الإطارات لونًا

```js
// script.js, سطرًا 13–21
const palette = { ink: '#1f2430', paper: '#ffffff', // text; type on stripes and tabs
  band: '#ab2e78', tintBand: '#fae3ef', // the chapter's magenta: opener, objectives, self-check
  tip: '#25734a', tintTip: '#e2f0e7', // study tips and lab work
  warn: '#e39422', tintWarn: '#fcefd8', warnInk: '#94540a', // frame and badge; tint; its type
  mist: '#e8e3f0', rule: '#dccfd8', muted: '#675e66' }; // feature box; lines in drawings; heads
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color': point it at the band, so nothing prints blue.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.band })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
```

تتشارك ستة أنواع من الإطارات ثلاث درجات لونية: الأرجواني للأهداف والتقييم الذاتي، والأخضر للنصيحة الدراسية والمختبر، والكهرماني للتحذير. ولـ BOX 2.1 تعبئة بلون الخزامى، ويستعمل الأرجواني لمصطلحاته الأساسية. لكل درجة لونية مسحة باهتة للتعبئات، وكل إعداد لون مربوط بمدخل في لوحة الألوان، فتغيير درجة لونية يعني تعديل قيمة واحدة. والحروف بهذا الصغر تحتاج إلى تباين 4.5:1. يبلغ الأرجواني على الخزامى 4.9:1، أما الكهرماني على تعبئة التحذير فلا يبلغ إلا 2.2:1، لذا يحتفظ التحذير بالكهرماني لإطاره وشارته، ويُنضّد عنوانه ومصطلحاته الأساسية بـ `warnInk`، وهو كهرماني أغمق يبلغ 5.2:1.

### 2 · شارِك كل ما عدا ذلك

```js
// script.js, سطرًا 27–36
const box = (id, hue, device) => ({ id, marginTop: pt(LEAD), marginBottom: pt(LEAD / 2),
  padding: { top: mm(2.8), right: mm(3.4), bottom: mm(3.2), left: mm(3.4) },
  titleStyle: { fontFamily: LABEL, fontSize: pt(8.5), color: col(hue), // bold by default
    textTransform: 'uppercase', letterSpacing: pt(1.4), gap: mm(GAP) },
  body: { fontFamily: LABEL, fontSize: pt(9.6), lineHeight: pt(12.6), color: col('ink'),
    boldColor: col(hue), textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true },
  lists: { color: col(hue), gap: mm(2.2), itemSpacing: pt(2.4) }, // bullets and numbers
  ...device }); // the kind's title, fill and device: its spread wins over the base
// An SVG or bitmap resource, fitted into a square of that size (width × size with `width`).
const icon = (id, size, more) => ({ kind: 'resource', resourceId: id, size: mm(size), ...more });
```

يحمل `box()` الإعدادات التي تتشاركها كل الإطارات: الهوامش، والحشو، وحروف العنوان والمتن، وتباعد القوائم. ويلوّن الوسيطُ `hue` العنوانَ، والنقاط والأرقام، والمقاطع العريضة. يجعل `body.boldColor` عبارة **fluid mosaic model** أرجوانية في الأهداف و**cell wall** بنية كهرمانية في التحذير؛ ويبقى الأسود العريض في النص الجاري بلون الحبر ([الطباعة داخل الإطار](/ar/docs/configuration#التنسيق-الطباعي-داخل-الإطار)). والتصريح بـ `calloutStyles` يحل محل النمط الوحيد الذي يأتي مع المحرّك، وهو إطار رمادي اسمه `note`. والسياج الذي يغيب `type` فيه أو يُكتب خطأً يأخذ النمط الأول، لذا تبدأ القائمة بالأهداف، التي يحتويها كل فصل.

### 3 · الشريط واللسان والشارة

الشيفرة هي [الجواب المختصر](#الجواب-المختصر) أعلاه. عرض شريط الأهداف 7.5 مم وعرض هدفه 5 مم، فتقع الأيقونة داخل اللون وحولها متسع من الجانبين. و`offset` اللسان يساوي `height` الخاص به، فيقف اللسان على الحافة العليا للإطار، بعيدًا عن العنوان. و`'top-left'` هي الزاوية الخارجية لهذه الصفحة الزوجية؛ يتبع الدورقُ اللسانَ، ويمتد الخط على الحافة العليا من الدورق إلى الزاوية البعيدة. ويعلّق `position: 'corner'` شارة التحذير بنصف عرضها خارج الحافة، ويرسلها `cornerSide: 'outer'` إلى جهة الحافة الأمامية، أي اليسار في هذه الصفحة الزوجية. وفي تلك الجهة يزيح المحرّك العنوان ليبتعد عن الشارة ما لم يغطِّ الحشو أصلًا نصف الشارة مضافًا إليه فراغ العنوان، لذا يحشو التحذير الجانبين بـ `BADGE / 2 + GAP` فيصطف عنوانه مع نصه. ولا `borderRadius` إلا لهذا الإطار. ففي الإطار ذي الشريط يُرسم الشريط قضيبًا مربعًا منفصلًا، فتبقى الزوايا في تلك الجهة مربعة ([أنماط الإطارات](/ar/docs/configuration#أنماط-الإطارات)).

### 4 · ثلاث علامات أخرى

```js
// script.js, سطرًا 65–78
const BULB = 6.4; // the study tip's badge in mm: the text clears it
const moreStyles = [
  box('tip', 'tip', { title: t({ en: 'Study tip', es: 'Para recordar' }),
    backgroundEnabled: false, stripe: { enabled: true, width: pt(2.5), color: col('tip') },
    // A stripe narrower than its icon: the badge sits on it at the top and hides it there.
    padding: { top: mm(0.6), right: mm(0), bottom: mm(0.6), left: mm(BULB / 2 + 2) },
    icon: icon('bulb', BULB) }),
  box('safety', 'tip', { background: col('tintTip'), marginTop: pt(4), // under its heading
    icon: icon('safety', 7, { width: mm(24.5), align: 'center' }) }), // 3 pictograms, 1 image
  box('check', 'band', { background: col('tintBand'),
    // Outside the frame: [marker][rule][gap][box], the rule as tall as the box.
    marker: { kind: 'resource', resourceId: 'check', size: mm(7), align: 'top', gap: mm(3),
      rule: { enabled: true, color: col('band'), width: pt(0.75) } } }),
];
```

عرض شريط النصيحة الدراسية 2.5 pt مقابل شارة مصباح قطرها 6.4 مم، فيُطبع خطًّا رفيعًا تعلو الشارة طرفه العلوي؛ ويبعد حشو أيسر بنصف الشارة زائد 2 مم النصَّ عن المصباح. ويُدخل `icon.width` الصورة في صندوق عريض بدل مربع، فرموز السلامة الثلاثة، المرسومة صورة واحدة بعرض 24.5 مم، تصنع عمود أيقونة إطار المختبر، متوسطة على نصه. وتقع علامة التقييم الذاتي خارج الإطار، بالترتيب: العلامة، ثم الخط، ثم الفراغ، ثم الإطار. وبلاطتها المربعة على خط بسماكة 0.75 pt تميزها عن النصيحة الدراسية، التي شارتها مستديرة. ويضيف السياج `span="page" placement="bottom"` ليعوّم التقييم الذاتي إلى أسفل [الصفحة 29](https://postext.dev/cookbook/textbook-box-family/en/p03.webp?v=00ce4347).

### 5 · الأيقونات موارد

```js
// script.js, سطرًا 240–265
const svgResource = (id, width, height, extra) => ({ id, typeId: 'figure', kind: 'svg',
  svg: { fileId: `${id}.svg`, width, height }, createdAt: 0, updatedAt: 0, ...extra });
const pageTop = { position: 'top', span: 'page' }; // a 'top' float opens the page after its :ref
const resources = [
  // Uncited, so never placed as figures: the box styles and the opener use them by id.
  ...['target', 'bulb', 'caution', 'check'].map((id) => svgResource(id, 240, 240)),
  svgResource('flask', 200, 240), svgResource('safety', 760, 240), svgResource('cell', 2000, 2000),
  svgResource('mosaic', 3480, 1240, { placement: pageTop, caption: t({
    en: 'The fluid mosaic: magenta phospholipids, green proteins, an amber carrier with its '
      + 'glucose, grey cholesterol, amber sugars.', // one line: the page is wide
    es: 'El mosaico fluido: fosfolípidos magenta, proteínas verdes, transportadora ámbar con su '
      + 'glucosa, colesterol gris, azúcares ámbar.' }),
    altText: t({ en: 'A cell membrane in section', es: 'Una membrana celular en sección' }) }),
  svgResource('fusion', 1800, 500, { placement: { position: 'here' }, caption: t({
    en: 'Mouse proteins in green, human proteins in magenta (the red dye): the two cells, the '
      + 'hybrid just after fusion and the same hybrid 40 minutes later.',
    es: 'En verde, las proteínas de ratón; en magenta, las humanas (el colorante rojo): las dos '
      + 'células, el híbrido recién fusionado y el mismo híbrido 40 minutos después.' }),
    altText: t({ en: 'Two cells fusing into a hybrid', es: 'Dos células que se fusionan' }) }),
  svgResource('osmosis', 3480, 860, { placement: pageTop, caption: t({
    en: 'Red blood cells in a hypertonic, an isotonic and a hypotonic solution. Arrows show the '
      + 'net flow of water.',
    es: 'Glóbulos rojos en una disolución hipertónica, una isotónica y una hipotónica. Las flechas '
      + 'indican el flujo neto de agua.' }),
    altText: t({ en: 'Blood cells in three solutions', es: 'Glóbulos rojos en tres medios' }) }),
];
```

كل أيقونة، والدورق بجانب اللسان، وخلية الافتتاح، موارد SVG مصرّح بها مع المحتوى ومسجلة بمعرّف الملف. يسمّيها النمط بمعرّف المورد، ولأن النص لا يستشهد بها أبدًا لا يصير أي منها شكلًا مرقمًا. كانت رموز مثل ✓ أو ☞ ستكون أقصر كتابةً، لكن ملفات Fontsource اللاتينية التي تحملها الصفحات لا تحتويها: فكان الرسم على Canvas سيستعيرها من خط من خطوط النظام، وكان PDF سيُسقطها. ولا تحمل الرسوم كلمات أيضًا، لأن SVG المرسوم صورةً لا يستطيع استعمال خطوط الصفحة؛ لذا تسمّي التعليقات الألوان بدلًا من ذلك.

### 6 · افتتح الفصل بخلية

```js
// script.js, سطرًا 82–101
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), color: col(color), placement,
  overflow: 'wrap', align: 'left', ...extra }); // gotcha: overflow-ellipsis-default
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { y: mm(y) }, size: { width: mm(width) } });
const chapter = t({ en: 'Chapter {chapterNumber}', es: 'Capítulo {chapterNumber}' });
const opener = { enabled: true, minHeight: mm(66), slot: { elements: [
  { kind: 'image', id: 'cell', resourceId: 'cell', placement: { anchor: { to: 'page',
    edge: 'top-right' }, offset: { x: mm(58), y: mm(-71) }, size: { width: mm(150) } } },
  text('numeral', '{chapterNumber}', 'Lexend', 118, 'paper', { anchor: { to: 'page',
    edge: 'top-right' }, offset: { x: mm(-OUTER), y: mm(14) } },
    { fontWeight: 800, lineHeight: 1, align: 'right' }),
  text('kicker', `${chapter} · {attr.unit}`, LABEL, 9, 'band', { anchor: { to: 'container',
    edge: 'top-left' }, offset: { y: mm(3) } },
    { fontWeight: 700, letterSpacing: pt(1.8), textTransform: 'uppercase' }),
  text('title', '{titleText}', 'Lexend', 34, 'ink', below('kicker', 2.5, 112),
    { fontWeight: 700, lineHeight: 1.04 }),
  text('lead', '{attr.lead}', 'Noto Serif', 10.5, 'ink', below('title', 5, 98),
    { italic: true, lineHeight: 1.42 }),
] } };
```

الخلية عنصر صورة مثبت إلى الزاوية العليا اليمنى للصفحة ومدفوع خارجها، فلا يظهر في الغالب إلا ربعها السفلي الأيسر، ويقع الرقم في نواتها. ويبقى كلاهما داخل الـ 66 مم التي يحجزها `minHeight` للافتتاح. ويأتي العنوان التمهيدي والمقدمة من سطر العنوان، `# Cells and \\ Membranes {unit="The cell" lead="…"}`، ولا يكسر `\\` العنوان إلا في الافتتاح.

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

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

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

### script.js

```js
// ═══ Postext Cookbook · Nº 008 · A family of textbook boxes ═══════════════════════
// https://postext.dev/en/cookbook/textbook-box-family
// Code: MIT · Text: original (CC BY 4.0) · Drawings and icons: generated in code (CC BY 4.0)
// Fonts: Noto Serif, Lexend, Barlow Semi Condensed (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  defaultResourceTypes } from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: three hues for six kinds of box; everything links to an entry
const palette = { ink: '#1f2430', paper: '#ffffff', // text; type on stripes and tabs
  band: '#ab2e78', tintBand: '#fae3ef', // the chapter's magenta: opener, objectives, self-check
  tip: '#25734a', tintTip: '#e2f0e7', // study tips and lab work
  warn: '#e39422', tintWarn: '#fcefd8', warnInk: '#94540a', // frame and badge; tint; its type
  mist: '#e8e3f0', rule: '#dccfd8', muted: '#675e66' }; // feature box; lines in drawings; heads
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color': point it at the band, so nothing prints blue.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.band })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
// Label face (box text and titles, tabs, captions, heads); leading in pt; outer margin in mm
const LABEL = 'Barlow Semi Condensed', LEAD = 13.4, OUTER = 16, GAP = 1.5; // GAP: title gap, mm

// #region base: what every box shares; each kind brings its colour and its one device
const box = (id, hue, device) => ({ id, marginTop: pt(LEAD), marginBottom: pt(LEAD / 2),
  padding: { top: mm(2.8), right: mm(3.4), bottom: mm(3.2), left: mm(3.4) },
  titleStyle: { fontFamily: LABEL, fontSize: pt(8.5), color: col(hue), // bold by default
    textTransform: 'uppercase', letterSpacing: pt(1.4), gap: mm(GAP) },
  body: { fontFamily: LABEL, fontSize: pt(9.6), lineHeight: pt(12.6), color: col('ink'),
    boldColor: col(hue), textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true },
  lists: { color: col(hue), gap: mm(2.2), itemSpacing: pt(2.4) }, // bullets and numbers
  ...device }); // the kind's title, fill and device: its spread wins over the base
// An SVG or bitmap resource, fitted into a square of that size (width × size with `width`).
const icon = (id, size, more) => ({ kind: 'resource', resourceId: id, size: mm(size), ...more });
// #endregion

// #region answer: three devices: an icon on a wide stripe, a numbered tab, an outer badge
// box(id, hue, device) is the shared base: the hue colours the title, bullets and key terms.
const TAB = 5, BADGE = 7.4; // in mm: the tab's height (and offset), the warning badge's size
const calloutStyles = [
  // The first style is also the one a missing or misspelt type falls back to.
  box('objectives', 'band', { title: t({ en: 'In this chapter', es: 'En este capítulo' }),
    background: col('tintBand'), stripe: { enabled: true, width: mm(7.5), color: col('band') },
    icon: icon('target', 5) }), // on a side stripe (left by default) the icon is centred on it
  box('feature', 'band', { background: col('mist'),
    titleStyle: { fontFamily: 'Lexend', fontSize: pt(10.5), color: col('ink'), gap: mm(1.6) },
    // Printed only where the fence has label="…": the tab, the flask beside it, a rule to them.
    label: { fontFamily: LABEL, fontSize: pt(8), color: col('paper'), background: col('ink'),
      position: 'top-left', // this verso's outer corner, above the badge ('top-right' by default)
      height: mm(TAB), offset: mm(TAB), paddingX: mm(2.3), // offset = height: on the top edge
      icon: { resourceId: 'flask', width: mm(4.4), gap: mm(1.2) },
      rule: { enabled: true, color: col('ink'), width: pt(1.2) } } }),
  box('warning', 'warnInk', { background: col('tintWarn'), borderRadius: mm(1.4),
    border: { enabled: true, color: col('warn'), width: pt(0.75) },
    // The badge hangs half past the outer corner: sides of half badge + GAP align the title.
    padding: { top: mm(2.8), right: mm(BADGE / 2 + GAP), bottom: mm(3.2),
      left: mm(BADGE / 2 + GAP) },
    icon: icon('caution', BADGE, { position: 'corner', cornerSide: 'outer' }) }),
]; // straight into config(), followed by the 'more' styles
// #endregion

// #region more: a badge threaded on a thin rule, a strip of pictograms, a marker outside
const BULB = 6.4; // the study tip's badge in mm: the text clears it
const moreStyles = [
  box('tip', 'tip', { title: t({ en: 'Study tip', es: 'Para recordar' }),
    backgroundEnabled: false, stripe: { enabled: true, width: pt(2.5), color: col('tip') },
    // A stripe narrower than its icon: the badge sits on it at the top and hides it there.
    padding: { top: mm(0.6), right: mm(0), bottom: mm(0.6), left: mm(BULB / 2 + 2) },
    icon: icon('bulb', BULB) }),
  box('safety', 'tip', { background: col('tintTip'), marginTop: pt(4), // under its heading
    icon: icon('safety', 7, { width: mm(24.5), align: 'center' }) }), // 3 pictograms, 1 image
  box('check', 'band', { background: col('tintBand'),
    // Outside the frame: [marker][rule][gap][box], the rule as tall as the box.
    marker: { kind: 'resource', resourceId: 'check', size: mm(7), align: 'top', gap: mm(3),
      rule: { enabled: true, color: col('band'), width: pt(0.75) } } }),
];
// #endregion

// #region opener: a cell cut by the corner of the page, the chapter number inside it
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), color: col(color), placement,
  overflow: 'wrap', align: 'left', ...extra }); // gotcha: overflow-ellipsis-default
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { y: mm(y) }, size: { width: mm(width) } });
const chapter = t({ en: 'Chapter {chapterNumber}', es: 'Capítulo {chapterNumber}' });
const opener = { enabled: true, minHeight: mm(66), slot: { elements: [
  { kind: 'image', id: 'cell', resourceId: 'cell', placement: { anchor: { to: 'page',
    edge: 'top-right' }, offset: { x: mm(58), y: mm(-71) }, size: { width: mm(150) } } },
  text('numeral', '{chapterNumber}', 'Lexend', 118, 'paper', { anchor: { to: 'page',
    edge: 'top-right' }, offset: { x: mm(-OUTER), y: mm(14) } },
    { fontWeight: 800, lineHeight: 1, align: 'right' }),
  text('kicker', `${chapter} · {attr.unit}`, LABEL, 9, 'band', { anchor: { to: 'container',
    edge: 'top-left' }, offset: { y: mm(3) } },
    { fontWeight: 700, letterSpacing: pt(1.8), textTransform: 'uppercase' }),
  text('title', '{titleText}', 'Lexend', 34, 'ink', below('kicker', 2.5, 112),
    { fontWeight: 700, lineHeight: 1.04 }),
  text('lead', '{attr.lead}', 'Noto Serif', 10.5, 'ink', below('title', 5, 98),
    { italic: true, lineHeight: 1.42 }),
] } };
// #endregion

// Heads 12.8 mm below the trim (the larger folio 0.4 mm higher), titles 8.5 mm in from folios.
const head = (id, content, parity, edge, x, extra, y = 12.8) => ({ kind: 'text', id, content,
  parity, pages: 'body', fontFamily: LABEL, fontSize: pt(7.8), fontWeight: 600,
  letterSpacing: pt(1.3), textTransform: 'uppercase', color: col('muted'), ...extra,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } } });
const folio = { fontFamily: 'Lexend', fontSize: pt(9), fontWeight: 700, color: col('band') };
const header = { elements: [ // folios on the fore-edge, never on the opener
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio, 12.4),
  head('verso-title', '{title}', 'even', 'top-left', OUTER + 8.5),
  head('recto-title', `${chapter} · {chapterTitle}`, 'odd', 'top-right', -(OUTER + 8.5)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio, 12.4),
] }; // the opener's folio drops to the foot:
const footer = { elements: [{ ...head('drop', '{pageNumber}', 'all', 'bottom', 0, folio, -12),
  pages: 'opener' }] };

const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity)
  locale: t({ en: 'en-us', es: 'es' }), // exact codes only (gotcha: hyphenation-locales)
  resourceTypes: defaultResourceTypes(LANG), // "Figura" (gotcha: resource-types-locale)
  colorPalette, header, footer,
  page: { width: mm(210), height: mm(280), dpi: 150, margins: { top: mm(24), bottom: mm(22),
    left: mm(20), right: mm(OUTER), mirror: true } }, // left is the inner margin
  layout: { layoutType: 'double', gutterWidth: mm(8) },
  bodyText: { fontFamily: 'Noto Serif', fontSize: pt(9.4), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('band'),
    textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false }, // hyphens: default
  headings: { fontFamily: 'Lexend', color: col('band'), levels: [
    // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
      marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener },
    { level: 2, fontSize: pt(12.5), lineHeight: pt(LEAD), numberingTemplate: '{1}.{2}',
      marginTop: pt(LEAD * 1.4), marginBottom: pt(LEAD * 0.6) }, // 3 lines, close to its text
    { level: 3, fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('tip'),
      marginTop: pt(LEAD), marginBottom: pt(0) }, // a line clear of the text, on its box
  ] },
  // In 1.4.1 a box's lists.color reaches its numbers only if it differs from these bullets'.
  unorderedLists: { color: col('ink'), marginTop: pt(0), marginBottom: pt(0) },
  orderedLists: { fontFamily: 'Lexend', color: col('tip'), marginTop: pt(0), marginBottom: pt(0) },
  calloutStyles: [...calloutStyles, ...moreStyles],
  captionStyle: { fontFamily: LABEL, fontSize: pt(8.8), color: col('ink'),
    labelColor: col('band'), gap: mm(1.8) },
  paragraphStyles: [{ id: 'colophon', fontFamily: LABEL, fontSize: pt(7.6), lineHeight: pt(10),
    color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }],
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Living Matter"
subtitle: "Biology for upper secondary school"
---

# Cells and \\ Membranes {unit="The cell" lead="Every living cell is wrapped in a film ten thousand times thinner than a sheet of paper. Oxygen passes through it freely; salts and sugars need a protein to cross."}

A cell is a crowded, watery workshop. Its enzymes need salts, sugars and building blocks close at hand, and its waste has to leave before it builds up. The **plasma membrane**, a layer about eight nanometres thick, separates that workshop from the world outside. In this chapter you will see what the membrane is made of and why it behaves more like a liquid than a wall. Some substances cross it at no cost to the cell; others cross only when the cell spends energy.

:::callout{type="objectives"}
- Use the **fluid mosaic model** to describe the plasma membrane.
- Explain how **diffusion** and **osmosis** move substances without energy.
- Predict how a cell changes in a hypertonic, an isotonic and a hypotonic solution.
- Compare **active transport** with passive transport.
:::

## A boundary with doors

Membranes are built mostly from **phospholipids**. Each molecule has a head that is attracted to water and two fatty tails that avoid it. In water, phospholipids arrange themselves so that the tails are hidden: they form a double layer, the **lipid bilayer**, with the heads facing the watery fluid on both sides and the tails meeting in the middle (:ref{id="mosaic"}).

Because its core is oily, the bilayer lets small, neutral molecules such as oxygen and carbon dioxide slip straight across. Ions and larger polar molecules, such as glucose, are turned away. Water, although polar, is small enough to trickle through slowly on its own, and many cells speed it up with water channels, the aquaporins. Letting some substances through and not others is called **selective permeability**. It lets a cell keep an inside that differs from its surroundings, and it is why ions and sugars cross through proteins set in the membrane.

:::callout{type="tip"}
Read the name of the model as a description. **Fluid** means that the lipids drift sideways and swap places with their neighbours millions of times a second, and **mosaic** means that proteins are set into the layer like tiles in a floor.
:::

## The fluid mosaic

In 1972 S. Jonathan Singer and Garth Nicolson proposed the model that is still used today. The bilayer is the fluid base, and proteins float in it: some span the whole membrane, others rest on one of its faces. **Channel proteins** form water-filled pores for particular ions, and **carrier proteins** change shape to move a molecule from one side to the other. Chains of sugars fixed to proteins and lipids face the outside of the cell and work as identity tags that other cells recognise.

Cholesterol, tucked between the phospholipid tails, acts as a buffer. When it is warm, cholesterol restrains the tails and keeps the membrane from becoming runny; when it is cold, it keeps them from packing tightly and turning stiff. Living things tune their membranes to their temperature: bacteria grown in the cold build lipids with more kinks in their tails, which keeps the layer fluid, and many plants do the same as winter comes, as do the fish that live in the icy seas around Antarctica.

:::callout{type="feature" label="BOX 2.1" title="Watching proteins drift" span="page"}
:::columns{count=2 breaks="2"}
::resource{id="fusion"}

In 1970 Larry Frye and Michael Edidin fused a mouse cell with a human cell. They had tagged the mouse membrane proteins with a green fluorescent dye and the human ones with a red dye. At first each colour kept to its own half of the hybrid cell. Forty minutes later, at 37 °C, the colours were completely mixed (:ref{id="fusion"}). Kept cold, the hybrids mixed far more slowly. The proteins were **drifting** on their own through a fluid layer that the cold made stiffer.
:::
:::

:::callout{type="warning" title="Membrane or wall?"}
Plants, fungi and most bacteria also have a **cell wall** outside the membrane. The wall gives the cell its shape and keeps it from bursting, but it lets almost everything through. Only the membrane is selectively permeable.
:::

The lipids and many of the proteins in a membrane keep moving, yet the layer holds together, because wherever a lipid drifts, its tails stay hidden from the water. For the same reason, a small hole in a bilayer closes up again on its own.

## Crossing the membrane

Particles in a solution never stop moving. **Diffusion** is their net movement from where they are more concentrated to where they are less, until they are evenly spread. It needs no energy from the cell, because the particles’ own motion does the work: oxygen diffuses into a cell that keeps using it up, and carbon dioxide diffuses out.

**Osmosis** is the diffusion of water across a selectively permeable membrane. If the solution outside a cell holds more dissolved particles than the cytoplasm, it is **hypertonic**, and the cell loses water and shrinks; if it holds fewer, it is **hypotonic**, and water floods in; if the two are equal, it is **isotonic**, and the cell keeps its size (:ref{id="osmosis"}). Red blood cells in pure water swell until they burst. Plant cells do not burst: the wall holds them firm, which is why a watered plant stands upright and a dry one wilts.

Diffusion is quick over short distances and slow over long ones. An oxygen molecule crosses a cell ten micrometres across in about a fortieth of a second, yet it would need some seven hours to diffuse one centimetre through water. That is one reason why cells stay small, and why large animals need lungs, gills and blood to carry oxygen the rest of the way.

Transport down the gradient through a channel or a carrier protein is called **facilitated diffusion**, and it is still passive: the protein opens a way, but the cell spends no energy on it. To move a substance uphill, from low concentration to high, a cell has to spend energy, usually as ATP, and that is **active transport**. The sodium–potassium pump in your nerve cells pushes three sodium ions out and two potassium ions in for every ATP it uses, and pumps like it take a large share of a resting cell’s energy.

:::callout{type="check" title="Check yourself" span="page" placement="bottom"}
:::columns{count=2 breaks="4"}
1. Why can oxygen cross the bilayer but glucose cannot?
2. What does **fluid** mean in the fluid mosaic model?
3. A slice of cucumber is left in salty water. Use **osmosis** to predict what happens to its cells.
4. Why does the sodium–potassium pump count as **active transport** and not as diffusion?
5. Could a cell with a rigid membrane use **endocytosis** to take in a bacterium? Explain.
:::
:::

## Moving in bulk

Some cargo is far too large for any channel or carrier. A white blood cell swallows a whole bacterium by wrapping its membrane around it and pinching off a bubble, a **vesicle**, into the cytoplasm: this is **endocytosis**. Cells drink the same way: in pinocytosis the membrane folds in around a droplet of the fluid outside, with whatever is dissolved in it.

The reverse, **exocytosis**, carries material out. Vesicles filled with hormones, digestive enzymes or the mucus that lines your airways fuse with the plasma membrane and empty their contents outside. Most signals that a nerve cell passes to the next leave it the same way: vesicles release messenger molecules, the neurotransmitters, into the narrow gap between the two cells. Both processes cost energy, and both work only because the bilayer is fluid enough to break and reseal without leaking.

Membranes do not stop at the surface of the cell. Inside it, the same bilayer wraps the nucleus, the mitochondria and a maze of inner compartments, each keeping its own chemistry apart from the rest. In Chapter 3 you will follow a protein that the cell exports, from the ribosome that makes it, through the endoplasmic reticulum and the Golgi apparatus, to the vesicle that releases it outside.

### Try it: osmosis in a potato

:::callout{type="safety" title="Lab safety"}
Wear safety goggles and gloves, and always cut away from your fingers.
:::

1. Cut two potato strips of the same size, about five centimetres long, and weigh them.
2. Leave one in tap water and the other in strongly salted water for thirty minutes.
3. Dry both strips, weigh them again and bend them.
4. Explain the change in mass and in stiffness with what you know about osmosis.

:::paragraphs{style="colophon"}
**Living Matter** is a textbook invented for the Postext Cookbook. Text and drawings: original, CC BY 4.0. Set in Noto Serif, Lexend and Barlow Semi Condensed (SIL Open Font License).
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region icons: icons are resources too: declared with the content, registered by file id
const svgResource = (id, width, height, extra) => ({ id, typeId: 'figure', kind: 'svg',
  svg: { fileId: `${id}.svg`, width, height }, createdAt: 0, updatedAt: 0, ...extra });
const pageTop = { position: 'top', span: 'page' }; // a 'top' float opens the page after its :ref
const resources = [
  // Uncited, so never placed as figures: the box styles and the opener use them by id.
  ...['target', 'bulb', 'caution', 'check'].map((id) => svgResource(id, 240, 240)),
  svgResource('flask', 200, 240), svgResource('safety', 760, 240), svgResource('cell', 2000, 2000),
  svgResource('mosaic', 3480, 1240, { placement: pageTop, caption: t({
    en: 'The fluid mosaic: magenta phospholipids, green proteins, an amber carrier with its '
      + 'glucose, grey cholesterol, amber sugars.', // one line: the page is wide
    es: 'El mosaico fluido: fosfolípidos magenta, proteínas verdes, transportadora ámbar con su '
      + 'glucosa, colesterol gris, azúcares ámbar.' }),
    altText: t({ en: 'A cell membrane in section', es: 'Una membrana celular en sección' }) }),
  svgResource('fusion', 1800, 500, { placement: { position: 'here' }, caption: t({
    en: 'Mouse proteins in green, human proteins in magenta (the red dye): the two cells, the '
      + 'hybrid just after fusion and the same hybrid 40 minutes later.',
    es: 'En verde, las proteínas de ratón; en magenta, las humanas (el colorante rojo): las dos '
      + 'células, el híbrido recién fusionado y el mismo híbrido 40 minutos después.' }),
    altText: t({ en: 'Two cells fusing into a hybrid', es: 'Dos células que se fusionan' }) }),
  svgResource('osmosis', 3480, 860, { placement: pageTop, caption: t({
    en: 'Red blood cells in a hypertonic, an isotonic and a hypotonic solution. Arrows show the '
      + 'net flow of water.',
    es: 'Glóbulos rojos en una disolución hipertónica, una isotónica y una hipotónica. Las flechas '
      + 'indican el flujo neto de agua.' }),
    altText: t({ en: 'Blood cells in three solutions', es: 'Glóbulos rojos en tres medios' }) }),
];
// #endregion

// #region art: the icons and the drawings, in the palette's colours (seeded)
// No words in them: an SVG drawn as an image cannot use web fonts (gotcha: svg-no-webfonts).
function rng(seed) { // Mulberry32: the same drawing on every run
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let x = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x;
    return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
  };
}
const n = (v) => +v.toFixed(2);
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 dot = (x, y, r, fill, extra = '') => `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" `
  + `fill="${fill}"${extra}/>`;
const line = (d, stroke, width, extra = '') => `<path d="${d}" fill="none" stroke="${stroke}" `
  + `stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"${extra}/>`;
const shape = (d, fill, extra = '') => `<path d="${d}" fill="${fill}"${extra}/>`;
const capsule = (x, y, w, h, fill, turn = 0, extra = '') => `<rect x="${n(x - w / 2)}" `
  + `y="${n(y - h / 2)}" width="${n(w)}" height="${n(h)}" rx="${n(Math.min(w, h) / 2)}" `
  + `fill="${fill}" transform="rotate(${n(turn)} ${n(x)} ${n(y)})"${extra}/>`;
const arrow = (x, y, len, turn, fill) => shape(`M${x} ${y - 0.9}h${len - 4}v-1.9l4 2.8-4 2.8`
  + `v-1.9H${x}Z`, fill, ` transform="rotate(${turn} ${x} ${y})"`); // a path, never a marker

function target() { // white rings on the magenta stripe
  return svg(24, 24, `<g fill="none" stroke="${palette.paper}" stroke-width="2.3">`
    + `<circle cx="12" cy="12" r="9.8"/><circle cx="12" cy="12" r="5.3"/></g>`
    + dot(12, 12, 1.9, palette.paper));
}
function bulb() {
  return svg(24, 24, dot(12, 12, 12, palette.tip) + shape('M12 4.4a5.6 5.6 0 0 0-3.3 10.1c.7.5 1 '
    + '1.1 1 1.9v.6h4.6v-.6c0-.8.3-1.4 1-1.9A5.6 5.6 0 0 0 12 4.4Z', palette.paper)
    + capsule(12, 18.6, 4.6, 1.3, palette.paper) + capsule(12, 20.3, 3, 1.2, palette.paper));
}
function flask() {
  const body = 'M8 2v7L2.4 19.4A2.1 2.1 0 0 0 4.3 22.5h11.4a2.1 2.1 0 0 0 1.9-3.1L12 9V2';
  return svg(20, 24, shape(`${body}Z`, palette.paper) + shape('M5.3 14h9.4l3 5.4a1.1 1.1 0 0 1-1 '
    + '1.6H3.3a1.1 1.1 0 0 1-1-1.6Z', palette.band) + dot(8.6, 17.4, 1.1, palette.paper)
    + dot(11.8, 18.7, 0.8, palette.paper) + line(body, palette.ink, 1.7)
    + line('M6.4 2h7.2', palette.ink, 1.7));
}
function caution() { // ink on amber: white on amber would fail contrast
  return svg(24, 24, dot(12, 12, 11, palette.warn, ` stroke="${palette.paper}" stroke-width="2"`)
    + capsule(12, 10, 2.8, 9.2, palette.ink) + dot(12, 17.6, 1.7, palette.ink));
}
function check() { // a ticked tile: square where the study tip's badge is round
  return svg(24, 24, `<rect width="24" height="24" rx="5.5" fill="${palette.band}"/>`
    + line('M6.3 12.6l3.8 3.8 7.6-8', palette.paper, 2.9));
}
function safety() { // goggles, a glove and a blade: three mandatory-action discs
  const goggles = `<g fill="none" stroke="${palette.paper}" stroke-width="1.5">`
    + '<rect x="4.3" y="9" width="6.6" height="5.6" rx="2.4"/>'
    + '<rect x="13.1" y="9" width="6.6" height="5.6" rx="2.4"/></g>'
    + line('M10.9 11.4q1.1-1 2.2 0M2.6 11.6h1.7M19.7 11.6h1.7', palette.paper, 1.4);
  const glove = shape('M32.4 20.5v-6.6l-2.3-2.7a1.2 1.2 0 0 1 1.8-1.6l1.4 1.5V6.2a1.1 1.1 0 0 1 '
    + '2.2 0v5.2V4.9a1.1 1.1 0 0 1 2.2 0v6.5V5.5a1.1 1.1 0 0 1 2.2 0v6.2V7a1.1 1.1 0 0 1 2.2 0v8.6'
    + 'l-1.2 4.9Z', palette.paper);
  const blade = `<g transform="rotate(-45 64 12)">${capsule(64, 16.3, 3.6, 9, palette.paper)}`
    + shape('M62.2 11.4V6.3L64 3.2l1.8 3.1v5.1Z', palette.paper) + '</g>';
  return svg(76, 24, [12, 38, 64].map((x) => dot(x, 12, 11.4, palette.ink)).join('')
    + goggles + glove + blade);
}
function blob(cx, cy, rx, ry, r, fill, extra = '', square = 2.6) { // a soft, uneven shape
  const pts = Array.from({ length: 16 }, (_, i) => {
    const [c, sn] = [Math.cos((i * Math.PI) / 8), Math.sin((i * Math.PI) / 8)];
    const k = 1 + (r() - 0.5) * 0.12;
    const f = (v) => Math.sign(v) * Math.abs(v) ** (2 / square); // squarer than an ellipse
    return [cx + f(c) * rx * k, cy + f(sn) * ry * k];
  });
  const mid = (a, b) => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2].map(n);
  const d = pts.map((p, i) => `Q${p.map(n)} ${mid(p, pts[(i + 1) % 16])}`).join('');
  return shape(`M${mid(pts[15], pts[0])}${d}Z`, fill, extra);
}
const shine = (cx, cy, rx, ry, r) => blob(cx - rx * 0.28, cy - ry * 0.34, rx * 0.46, ry * 0.4,
  r, palette.paper, ' fill-opacity=".22"');
const pale = ` stroke="${palette.tip}" stroke-width="1.6"`; // a light green protein, outlined
const sugars = (x, y, r, count = 5) => { // a chain of sugars, branched once
  const sugar = (sx, sy) => dot(sx, sy, 2.1, palette.warn,
    ` stroke="${palette.paper}" stroke-width=".7"`);
  let out = '';
  for (let k = 0; k < count; k++, y -= 4.3, x += (r() - 0.5) * 4) {
    out += sugar(x, y) + (k === 2 ? sugar(x + 4.3, y - 1.6) : '');
  }
  return out;
};
function cell() { // a cell drawn as a disc of bilayer: mostly its lower-left quarter shows
  let out = dot(100, 100, 71, palette.band);
  for (let a = 0; a < 360; a += 3.05) {
    const [c, s] = [Math.cos((a * Math.PI) / 180), Math.sin((a * Math.PI) / 180)];
    const at = (d) => [100 + c * d, 100 + s * d];
    const tail = (d0, d1) => line(`M${at(d0).map(n)}L${at(d1).map(n)}`, palette.rule, 0.8);
    out += tail(90.5, 84.8) + tail(75.8, 81.2) + dot(...at(92.6), 2.6, palette.band)
      + dot(...at(73.6), 2.5, palette.paper);
  }
  [106, 133, 157, 184].forEach((a, k) => { // proteins across the visible arc
    const rad = (a * Math.PI) / 180;
    const fill = k % 2 ? palette.warn : palette.tip;
    out += capsule(100 + Math.cos(rad) * 83, 100 + Math.sin(rad) * 83, 26, 7, fill, a);
  });
  return svg(200, 200, out);
}
function mosaic() { // the membrane in section, 174 × 62 mm; the outside of the cell on top
  const r = rng(5);
  const W = 348;
  const yc = (x) => 70 + 3.4 * Math.sin(x / 44);
  const proteins = [[56, 34, palette.tip, 'channel'], [148, 38, palette.warn, 'carrier'],
    [234, 27, palette.tip], [306, 32, palette.tintTip, 'pale']];
  const clear = (x) => proteins.every(([px, w]) => Math.abs(x - px) > w / 2 + 1.8);
  let out = `<rect width="${W}" height="70" fill="${palette.tintBand}"/>`
    + `<rect y="70" width="${W}" height="54" fill="${palette.mist}"/>`;
  for (let x = 4; x < W; x += 5.9) { // phospholipids: heads out, two tails in
    if (!clear(x)) continue;
    for (const s of [-1, 1]) {
      const y = yc(x) + s * 13;
      for (const dx of [-1, 1]) {
        out += line(`M${n(x + dx)} ${n(y - s * 2.4)}l${n(r() - 0.5)} ${-s * 4}`
          + `l${n(r() - 0.5)} ${-s * 4.6}`, palette.band, 0.75, ' stroke-opacity=".4"');
      }
      out += dot(x, y, 2.75, palette.band);
      if (r() < 0.13 && clear(x + 3)) { // cholesterol among the tails: the only grey rods
        out += capsule(x + 3, y - s * 7.4, 2.1, 7.2, palette.ink, 0, ' fill-opacity=".62"');
      }
    }
  }
  out += sugars(196, yc(196) - 16.5, r, 3); // a glycolipid
  for (const [px, w, fill, kind] of proteins) { // proteins across the bilayer
    const y = yc(px);
    const halves = kind === 'channel' ? [px - w / 4 - 1.3, px + w / 4 + 1.3] : [px];
    for (const x of halves) {
      const rx = kind === 'channel' ? w / 4 : w / 2;
      out += blob(x, y, rx, 21, r, fill, kind === 'pale' ? pale : '') + shine(x, y, rx, 21, r);
    }
    if (kind === 'channel') {
      out += dot(px, y - 7, 2, palette.warnInk) + dot(px, y + 4, 2, palette.warnInk);
    }
    if (kind === 'carrier') { // a glucose held in the carrier's open mouth
      out += shape(`M${px - 7} ${y - 24}L${px} ${y - 12}L${px + 7} ${y - 24}Z`, palette.tintBand)
        + shape(`M${px - 3.6} ${y - 19.5}l1.8-3.1h3.6l1.8 3.1-1.8 3.1h-3.6Z`, palette.paper,
          ` stroke="${palette.ink}" stroke-width=".8"`);
    } else out += sugars(px + (r() - 0.5) * 5, y - 22.5, r);
  }
  out += blob(100, yc(100) + 22.5, 11, 5.5, r, palette.tintTip, pale) // proteins on one face only
    + blob(270, yc(270) - 22, 8.5, 5, r, palette.tip);
  for (let x = 6; x < W; x += 3.2) { // the cytoskeleton under the membrane
    out += dot(x, 111 + 2.2 * Math.sin(x / 7), 1.4, palette.muted, ' fill-opacity=".45"');
  }
  for (let k = 0; k < 9; k++) { // oxygen outside the cell
    const [x, y] = [14 + r() * 320, 8 + r() * 26];
    for (const dx of [0, 2.6]) out += dot(x + dx, y, 1.5, palette.ink, ' fill-opacity=".5"');
  }
  return svg(W, 124, out);
}
function fusion() { // two cells, the hybrid just after fusion, the hybrid 40 minutes later
  const r = rng(3);
  const cellAt = (cx, rx, fill, nuclei, pick) => {
    let out = `<ellipse cx="${cx}" cy="25" rx="${rx}" ry="${Math.min(rx, 15)}" fill="${fill}" `
      + `stroke="${palette.ink}" stroke-width=".8"/>`
      + nuclei.map((x) => dot(x, 25, 4.2, palette.rule)).join('');
    for (let a = 0; a < 360; a += 12) {
      const rad = (a * Math.PI) / 180;
      out += dot(cx + Math.cos(rad) * rx, 25 + Math.sin(rad) * Math.min(rx, 15), 1.7,
        pick(Math.cos(rad), r()));
    }
    return out;
  };
  return svg(180, 50, cellAt(16, 13, palette.tintTip, [16], () => palette.tip)
    + cellAt(45, 13, palette.tintBand, [45], () => palette.band)
    + arrow(65, 25, 12, 0, palette.muted)
    + cellAt(104, 22, palette.paper, [96, 112], (c) => (c < 0 ? palette.tip : palette.band))
    + arrow(132, 25, 12, 0, palette.muted)
    + cellAt(163, 15.5, palette.paper, [158, 168], (c, k) => (k < 0.5 ? palette.tip
      : palette.band)));
}
function osmosis() { // three solutions: water leaves, stays even, floods in
  const r = rng(9);
  const panel = (x, solutes, body, flow) => {
    let out = `<rect x="${x}" width="108" height="86" rx="4" fill="${palette.mist}"/>`;
    for (let k = 0; k < solutes; k++) {
      const [px, py] = [x + 5 + r() * 98, 5 + r() * 76];
      if (Math.hypot(px - x - 54, py - 43) > 30) out += dot(px, py, 1.5, palette.muted);
    }
    for (let k = 0; k < 6; k++) { // arrows: the net flow of water, out (+1) or in (-1)
      const rad = (k * Math.PI) / 3 + 0.5;
      if (flow === 0 && k % 3) continue;
      const d = flow < 0 || (flow === 0 && k) ? 40 : 27;
      out += arrow(x + 54 + Math.cos(rad) * d, 43 + Math.sin(rad) * d, 10,
        (rad * 180) / Math.PI + (d > 30 ? 180 : 0), palette.tip);
    }
    return out + body;
  };
  const at = (k, d) => [54 + Math.cos((k * Math.PI) / 11) * d, 43 + Math.sin((k * Math.PI) / 11)
    * d].map(n);
  const crenated = `M${at(0, 13)}${Array.from({ length: 22 }, (_, k) => (k % 2 ? ''
    : `Q${at(k + 1, 19)} ${at(k + 2, 13)}`)).join('')}Z`; // bumps: control points outside
  const disc = (x, rr) => dot(x + 54, 43, rr, palette.band)
    + dot(x + 54, 43, rr * 0.45, palette.tintBand);
  return svg(348, 86, panel(0, 70, shape(crenated, palette.band), 1)
    + panel(120, 34, disc(120, 17), 0) + panel(240, 8, disc(240, 23), -1));
}
const drawings = { target, bulb, flask, caution, safety, check, cell, mosaic, fusion, osmosis };
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { 'Noto Serif': ['400', '400i', '700'], Lexend: ['700', '800'], // text, display,
  'Barlow Semi Condensed': ['400', '600', '700'] }; // labels (gotcha: fonts-first)

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await Promise.all([loadFonts(FONTS, markdown),
  ...Object.entries(drawings).map(([id, draw]) => loadSvg(`${id}.svg`, draw()))]);
// Folio 27 is odd like page 1, always a recto: parity follows the page (gotcha: parity-page1-recto)
const continuation = { pageNumbering: { startAt: 27 },
  headings: { h1: 1, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } }; // the next # is chapter 2
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources, continuation }, config()), markdown);
showPages(doc, { title: t({ en: 'Living Matter, chapter 2', es: 'Materia viva, capítulo 2' }) });

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

## تنويعات

### مدّ إطارًا عبر العمودين

يمدّ `span="page"` على السياج أي نوع من الإطارات عبر العمودين؛ ويضم BOX 2.1 أيضًا مجموعة `:::columns` ليضع شكله بجانب نصه.

```diff
-:::callout{type="warning" title="Membrane or wall?"}
+:::callout{type="warning" title="Membrane or wall?" span="page"}
```

### علّق الشارة في جهة الكعب

ينقل `'inner'` الشارة إلى جهة الكعب، يسارًا في الصفحة الفردية ويمينًا في الزوجية؛ اترك في تلك الجهة متسعًا للنصف المعلق خارج الإطار.

```diff
-    icon: icon('caution', BADGE, { position: 'corner', cornerSide: 'outer' }) }),
+    icon: icon('caution', BADGE, { position: 'corner', cornerSide: 'inner' }) }),
```

### أوقف اللسان على الزاوية اليمنى

`'top-right'`، وهي القيمة الافتراضية، تعكس التسمية كلها: ينتقل اللسان إلى الزاوية اليمنى، والدورق إلى يساره، ويمتد الخط من اليسار إلى الداخل.

```diff
-      position: 'top-left', // this verso's outer corner, above the badge ('top-right' by default)
+      position: 'top-right',
```

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

- **أي كائن headings يُلغي فاصل الصفحة قبل H1.** ينتقل H1 افتراضيًا إلى صفحة فردية (always-odd)، لكن تمرير أي كائن headings يعيد ضبط هذا الافتراض، فتتوالى الفصول دون فاصل ولا يفعل span: 'page' شيئًا. أعد كتابة headings.levels[0].breakBefore: { enabled: true, parity } في كل إعداد.
- **النص غير المضبوط قد يترك علامة الترقيم منفصلة عن العريض أو عن :ref.** في postext 1.4.1 قد يكسر النص غير المضبوط (متون الإطارات، الفقرات ذات الحافة الحرّة) السطرَ بين مقطع عريض أو مائل، أو :ref، وعلامة الترقيم الملاصقة له: قد تفتتح النقطة السطر التالي، وقد ينتهي السطر السابق بـ '(' التي قبل الإحالة. النص المضبوط لا ينكسر هناك أبدًا. اقرأ إطارات كل نسخة وأعد صياغة كل جملة يحدث فيها ذلك، ليقع المقطع في وسط السطر.
- **المسافة غير القابلة للكسر تكسر السطر مع ذلك.** في postext 1.4.1 يعامل كاسر الأسطر U+00A0 معاملة المسافة العادية، فقد تنقسم 0.08 % أو 2.006 s أو Section 2 على سطرين. ألصق الجزأين (0.08%) أو أعد صياغة الجملة.
- **ملفات latin من Fontsource تُسقط الحروف خارج اللاتينية.** يضمّن مزوّد PDF ملفات latin من Fontsource، وهي تغطي الإسبانية ونصوص أوروبا الغربية لكنها لا تغطي → و≈ و✓ و★ ولا الحروف اليونانية أو حروف أوروبا الوسطى؛ فتغيب تلك الحروف عن PDF. أبقِ نص PDF ضمن نطاق latin.
- **النص داخل SVG في <img> لا يستطيع استخدام خطوط الويب.** يُرسَم SVG صورةً، والصورة لا تصل إلى خطوط الويب في الصفحة، فتعود تسمياته إلى خط من النظام. حوّل النص إلى مسارات، أو ضمّن مجموعة فرعية بـ @font-face داخل SVG، أو انقل التسميات إلى التعليق.
- **العنصر العائم 'top' لا يقع أبدًا في صفحة الإحالة إليه.** لا يصعد العنصر العائم أبدًا فوق الإحالة إليه، فالعنصر العائم 'top' الممتد بعرض الصفحة والمُحال إليه في الصفحة N يفتتح الصفحة N+1. أحِل إليه في موضع أبكر، أو استخدم الموضع 'auto' أو 'bottom' الذي يمكنه أن يشغل أسفل صفحة الإحالة.
- **لا يعمل :::columns إلا داخل إطار ولا ينقسم أبدًا.** يُتجاهَل :::columns خارج الإطار، والإطار الذي ينقسم لا يقطع أبدًا داخل مجموعة أعمدة. السمة breaks تعدّ الكتل الأبناء، والإطار المتداخل يُعدّ كتلة واحدة.
- **ترجم Figure/Table بـ defaultResourceTypes(locale).** يضبط locale في الإعداد تقسيمَ الكلمات بالواصلة، لا التعليقات: من دون resourceTypes تقول الأنواع المدمجة Figure وTable بالإنجليزية. مرّر resourceTypes: defaultResourceTypes('es') للإسبانية؛ ولأي لغة أخرى اكتب الأسماء بنفسك في resourceTypes.
- **8 لغات فقط تُقسَّم بالواصلة، بالرمز المطابق تمامًا.** يتوفر تقسيم الكلمات بالواصلة للغات en-us وes وfr وde وit وpt وca وnl، بمطابقة تامة للرمز: 'es-ES' أو أي لغة أخرى تعود دون تنبيه إلى الإنجليزية الأمريكية.
- **حمّل كل أوجه الخط قبل الإخراج.** يقيس الإخراج النص بأوجه الخط التي حمّلها المتصفح ويخزّن العروض مؤقتًا، فالوجه الذي يصل بعد البناء الأول يترك فواصل أسطر خاطئة وملف PDF لم يعد يطابق الشاشة. حمّل كل وزن وكل نمط أولًا، واستدعِ clearMeasurementCache() قبل إعادة البناء إذا تأخر وصول أحدها.
- **فيض نص التصميم افتراضيًا 'ellipsis-end'.** عنصر نص التصميم الذي لا يتسع له عرضه ينتهي افتراضيًا بعلامة الحذف. اضبط overflow: 'wrap' للعناوين التي يجب أن تنكسر على أسطر أكثر.
- **الصفحة 1 فردية: خطّط الصفحات بأرقامها الفعلية.** الصفحة 1 صفحة يمنى والصفحة 2 أول صفحة زوجية، فخطّط الصفحتين المتقابلتين بأرقام الصفحات الفعلية: الافتتاحية في صفحة زوجية تقابل الصفحة الفردية التي تليها.
- **يُخزَّن الإعداد مؤقتًا بحسب هويته: ابنِ كائنًا جديدًا.** يخزّن المحرّك الإعدادات المحسوبة مؤقتًا بحسب هوية الكائن، فتعديل الإعداد في مكانه ثم البناء مجددًا يعيد استخدام النتيجة القديمة. ابنِ كائنًا جديدًا في كل بناء، ولهذا يكون إعداد الوصفة دالة مصنِّعة: config().
- **تحذير إخراج: نوع إطار غير معروف** (`unknownCalloutType`). حاوية :::callout تسمّي نوعًا لا تعرّفه calloutStyles، فيأخذ النمط الأول دون أي تنبيه. الحل: عرّف النمط أو صحّح السمة type. ([التوثيق](https://postext.dev/ar/docs/configuration.md#الحاوية-callout))

- لا يُطبع اللسان إلا حين يحمل السياج `label="BOX 2.1"` ويحمل النمط كائن `label`. والنمط الخالي منه يتجاهل السمة.
- لا يرقّم Postext الإطارات. التسمية هي النص الحرفي في السياج، فتكتب كل رقم، BOX 2.1 وBOX 2.2، بيدك.
- تتعلق شارة الزاوية بنصف عرضها خارج جانب الإطار، في الهامش على الجهة الخارجية من الصفحة. أما في العمود الآخر من الصفحة نفسها فتتعلق في الفاصل بين الأعمدة، لذا أبقِ الإطارات ذات الشارات في العمود الخارجي أو اترك لها متسعًا.
- لا يعيد الإطار تلوين نقاطه وأرقامه إلا حيث يختلف `lists.color` الخاص به عن `unorderedLists.color`. فإذا كان كلاهما الأرجواني نفسه أخذت القائمة المرقمة داخل الإطار `orderedLists.color` بدلًا من ذلك، ولهذا يضبط هذا المستند نقاطه بلون الحبر.

## الحقوق

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

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

- [رقم 050 · دليل منتج مع تنبيهات السلامة](https://postext.dev/ar/cookbook/product-manual-warnings.md): دليل ألماني لغلاية، تحمل فيه صناديق WARNUNG وVORSICHT مثلث التحذير على شريط بلون الإشارة، مع تسميات ألمانية للأشكال والجداول. · المستوى 2 (متوسط) · الأدلة والمراجع
- [رقم 021 · إطارات تنقسم وتطفو وتُثبَّت](https://postext.dev/ar/cookbook/boxes-split-float-pin.md): ورقة عمل مخبرية ينقسم فيها إطار الخطوات بين عمودين، وتنتقل ورقة البيانات إلى رأس الصفحة التالية، وتُثبَّت شارة في أسفل الصفحة. · المستوى 3 (متقدم) · كتب التمارين والتدريبات, الكتب المدرسية
- [رقم 022 · ورقة عمل بصناديق إجابة وبنك كلمات](https://postext.dev/ar/cookbook/worksheet-answer-boxes.md): ورقة عمل في العلوم من أربع صفحات: صناديق إجابة بيضاء في بطاقات خضراء فاتحة، على بعد 2 mm تحت كل سؤال وخارج الشبكة، مع بنوك كلمات وفراغات من الشارات. · المستوى 2 (متوسط) · كتب التمارين والتدريبات
