# كتاب واحد وملفا EPUB 3: تخطيط ثابت وقابل لإعادة التدفق

> تكتب renderToEpub من postext-epub الفصول التي أخرجتها buildBundle ملفَّ EPUB بتخطيط ثابت وآخر قابلًا لإعادة التدفق، وتسرد readEpub ما يحويه كل ملف.

- نسخة HTML: https://postext.dev/ar/cookbook/epub-fixed-and-reflowable
- وصفة رقم 115 · الإخراج والدمج · المستوى 3 (متقدم) · المخرجات: Canvas, EPUB
- الأنواع: الأدب القصصي والمسرح والنثر الأدبي
- تتطلب postext ≥ 1.15.0 · اختُبرت مع 1.15.0 بتاريخ 2026-10-04
- الصفحات: [1](https://postext.dev/cookbook/epub-fixed-and-reflowable/en/p01.webp?v=3670184b), [2](https://postext.dev/cookbook/epub-fixed-and-reflowable/en/p02.webp?v=3670184b), [3](https://postext.dev/cookbook/epub-fixed-and-reflowable/en/p03.webp?v=3670184b), [4](https://postext.dev/cookbook/epub-fixed-and-reflowable/en/p04.webp?v=3670184b), [5](https://postext.dev/cookbook/epub-fixed-and-reflowable/en/p05.webp?v=3670184b), [6](https://postext.dev/cookbook/epub-fixed-and-reflowable/en/p06.webp?v=3670184b)
- افتح في Sandbox: https://postext.dev/ar/sandbox#recipe=epub-fixed-and-reflowable&lang=en (.postext: https://postext.dev/cookbook/epub-fixed-and-reflowable/en/epub-fixed-and-reflowable.postext)
- آخر تحديث: 2026-10-04
- لغات أخرى: [en](https://postext.dev/en/cookbook/epub-fixed-and-reflowable.md), [es](https://postext.dev/es/cookbook/epub-fixed-and-reflowable.md), [ca](https://postext.dev/ca/cookbook/epub-fixed-and-reflowable.md), [zh](https://postext.dev/zh/cookbook/epub-fixed-and-reflowable.md)

## باختصار

كتاب صغير من الحكايات يُخرَج للطباعة مرة واحدة ويُحفظ كتابين إلكترونيين: واحد يحتفظ بكل صفحة مطبوعة، وآخر يعيد الهاتف أو القارئ الإلكتروني تنضيد نصه بالحجم الذي يناسبه.

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

*Fables of Aesop* (حكايات إيسوب)، ثماني عشرة حكاية في ثلاثة كتب على صفحة بقياس 120 × 190 mm، مع صفحة عنوان وبيانات الطبع على ظهرها وصفحة افتتاح غائرة لكل كتاب. وتنضّد الطبعة الإسبانية قصة *¡Adiós, Cordera!* لكلارين (Clarín) في ثلاثة فصول على الصفحة نفسها. يُخرَج الكتاب مرة واحدة، فصلًا فصلًا، ويصير ذلك الإخراج ملفي EPUB 3. يحتفظ التخطيط الثابت بكل صفحة مطبوعة: صفحات الافتتاح والترويسات ومواضع انتهاء الأسطر، بخط Caslon المضمَّن. ويحتفظ الملف القابل لإعادة التدفق بالنص وبنيته، ويعيد نظام القراءة تنضيده على الهاتف أو على شاشة حبر إلكتروني. يُنزَّل الملفان كلاهما من الصفحة، ويجتاز كلاهما فحص EPUBCheck من W3C.

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

- كيف أصدّر كتابي ملف EPUB، بصفحات ثابتة أو بنص يُعاد تدفقه؟

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

```js
// script.js, سطرًا 255–269
const { title, subtitle, author } = docs[0].metadata; // the first chapter's front matter
const book = {
  metadata: { title, subtitle, creators: [author], language: LANG,
    rights: t({ en: 'Public domain', es: 'Dominio público' }),
    // A fixed date gives the same bytes on every run; the identifier (a urn:uuid) comes
    // from the title, the author and the language, so a new version keeps its place.
    modified: new Date('2026-10-04T00:00:00Z') },
  // Every face the pages use, as bytes: a face left out is set in the reader's own
  // (gotcha: epub-embeds-given-fonts).
  fonts: await epubFonts(FONTS),
  onWarning: (w) => console.warn(`[epub] ${w.kind}`, w.family ?? w.fileId ?? w.detail),
};
// The same documents renderToPdf would print: one per chapter, in book order.
const fixed = await renderToEpub(docs, { ...book, layout: 'fixed' });
const reflowable = await renderToEpub(docs, { ...book, layout: 'reflowable' });
```

## المكونات

**تعلّم**

- [التصدير إلى EPUB](https://postext.dev/ar/docs/configuration.md#كتب-epub-postext-epub): تكتب الدالة renderToEpub من postext-epub الفصول المُخرَجة ملفَّ EPUB 3 في المتصفح: إما تخطيطًا ثابتًا يحفظ كل صفحة مطبوعة، وإما نصًا متدفقًا تُستمد أنماطه من الإعدادات نفسها.
- [كتب تُبنى فصلًا فصلًا](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/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#البيانات-التمهيدية-frontmatter)
- [لوحة ألوان دلالية](https://postext.dev/ar/docs/configuration.md#لوحة-الألوان)
- [الخطوط قبل الإخراج](https://postext.dev/ar/docs/configuration.md#ذاكرة-القياس-المؤقتة)
- [فواصل الصفحات والأعمدة](https://postext.dev/ar/docs/document-format.md#pagebreak)
- [ترويسات لكل قسم](https://postext.dev/ar/docs/configuration.md#أنماط-العناوين)

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

- [`bodyText`](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#التخطيط), [`locale`](https://postext.dev/ar/docs/configuration.md#تقسيم-الكلمات-بالواصلة), [`page`](https://postext.dev/ar/docs/configuration.md#الصفحة), [`paragraphStyles`](https://postext.dev/ar/docs/configuration.md#أنماط-الفقرات)

**واجهات API**

- [`buildBundle`](https://postext.dev/ar/docs/configuration.md#إخراج-حزمة-ورسمها), [`clearMeasurementCache`](https://postext.dev/ar/docs/configuration.md#ذاكرة-القياس-المؤقتة), [`readEpub`](https://postext.dev/ar/docs/configuration.md#كتابة-كتاب), [`renderPageToCanvas`](https://postext.dev/ar/docs/configuration.md#رسم-صفحة-في-صورة-نقطية), [`renderToEpub`](https://postext.dev/ar/docs/configuration.md#كتابة-كتاب)

**الخطوط**

- Libre Caslon Text (OFL-1.1), Libre Caslon Display (OFL-1.1), Jost (OFL-1.1)

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

### 1 · أخرِج الكتاب فصولًا

```js
// script.js, سطرًا 42–61
// Each line spans the measure and centres its text; the rule centres under the title.
const text = (id, content, fontFamily, size, placement, extra = {}) => ({ kind: 'text', id,
  content, fontFamily, fontSize: pt(size), color: col('ink'), align: 'center',
  overflow: 'wrap', lineHeight: 1.15, ...extra,
  placement: { ...placement, size: { width: 'fill', height: 'auto' } } });
const rule = (id, below, y) => ({ kind: 'rule', id, direction: 'horizontal', thickness: pt(0.75),
  color: col('accent'),
  placement: { ...at(below, 'below', (MEASURE - 10) / 2, y), size: { width: mm(10) } } });
const titlePage = { id: 'title-page', header: { elements: [] }, footer: { elements: [] },
  marginBottom: pt(0), advancedDesign: { enabled: true, minHeight: mm(120), slot: { elements: [
    text('author', '{author}', LABEL, 9, at('container', 'top-left', 0, 30), caps(9)),
    text('title', '{titleText}', DISPLAY, 34, at('#author', 'below', 0, 8)),
    rule('rule', '#title', 6),
    text('subtitle', '{subtitle}', TEXT, 11, at('#title', 'below', 0, 13), { italic: true }),
  ] } } };
const opener = { enabled: true, minHeight: mm(56), slot: { elements: [
  text('kicker', '{attr.kicker}', LABEL, 8, at('container', 'top-left', 0, 14), caps(8)),
  text('title', '{titleText}', DISPLAY, 28, at('#kicker', 'below', 0, 4)),
  rule('rule', '#title', 5),
] } };
```

تُخرج `buildBundle` كل فصل مستندًا قائمًا بذاته، وتنقل أرقام الصفحات وزوجيتها من فصل إلى الذي يليه، فتكون `docs` هي المصفوفة نفسها التي تطبعها `renderToPdf`. ويقرأ كاتب EPUB هذه المصفوفة: يصير كل فصل مدخلًا في قائمة التنقل، ويكتب الملف القابل لإعادة التدفق مستند XHTML واحدًا لكل فصل. صفحة العنوان وبيانات الطبع على ظهرها هما الفصل الأول، وبياناته التمهيدية (frontmatter) هي عنوان الكتاب وعنوانه الفرعي ومؤلفه.

### 2 · سلّم الكاتب ملفات الخطوط

```js
// script.js, سطرًا 235–246
// Only the latin files: they hold every letter of both samples. A text beyond Latin-1 would
// add each family's latin-ext file with its unicodeRange.
const epubFonts = (faces) => Promise.all(Object.entries(faces).flatMap(([family, specs]) =>
  specs.map(async (spec) => {
    const [weight, style] = [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'];
    const id = fontsourceId(family);
    const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/`
      + `${id}-latin-${weight}-${style}.woff2`);
    if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
    const bytes = new Uint8Array(await res.arrayBuffer());
    return { family, weight, style, format: 'woff2', bytes };
  })));
```

الخطوط التي حمّلها المتصفح ليست ملفات يستطيع الكاتب قراءتها، لذلك يجلب المثال ملفات woff2 نفسها من Fontsource مرة أخرى، والمتصفح يحتفظ بها في ذاكرته المؤقتة. يصير كل ملف منها ملف خط داخل EPUB وقاعدة `@font-face`. ومن دونها يضع التخطيط الثابت النص المنضَّد بخط Caslon بالخط الذي يختاره القارئ الإلكتروني، فلا تعود الأسطر مطابقة للعرض الذي حُسبت عليه.

### 3 · اكتب النسختين

الشيفرة هي [الجواب المختصر](#الجواب-المختصر) أعلاه. يكتب `layout: 'fixed'` مستند XHTML واحدًا لكل صفحة مطبوعة بقياس الصفحة بعد القص، بنص حقيقي قابل للتحديد؛ ويعيد `layout: 'reflowable'` بناء الفقرات من الأسطر المُخرَجة، ويحذف الواصلات التي أضافها تقسيم الأسطر، ويستخرج ورقة أنماط من الإعدادات. ويجعل تاريخ `modified` الثابت كل تشغيل يُنتج البايتات نفسها. تأتي البيانات الوصفية من البيانات التمهيدية للفصل الأول، فيكون عنوان الملف ومؤلفه هما المطبوعين على صفحة العنوان.

### 4 · اقرأ كل ملف من جديد

```js
// script.js, سطرًا 273–295
const el = (tag, css, text) => Object.assign(document.createElement(tag),
  { textContent: text ?? '' }, { style: css });
const shelf = el('section', 'display: flex; flex-wrap: wrap; gap: 16px; justify-content: center;'
  + 'padding: 24px 16px 0; color: #d9d5cc; font: 13px/1.5 system-ui, sans-serif');
shelf.id = 'epub';
for (const [layout, bytes] of [['fixed', fixed], ['reflowable', reflowable]]) {
  const epub = readEpub(bytes); // what a reading system opens: spine, contents, page list
  const file = `${RECIPE}-${layout}.epub`;
  const card = el('article', 'width: min(420px, 92vw); padding: 16px 18px;'
    + 'border: 1px solid #2c3038; border-radius: 6px; background: #15181d');
  const spine = el('ol', 'margin: 8px 0; padding-left: 22px; max-height: 9em; overflow: auto');
  spine.append(...epub.spine.map((item) => el('li', '', item.path.slice(epub.root.length))));
  const link = el('a', 'color: #d8a21a', `Download ${file}`);
  link.href = URL.createObjectURL(new Blob([bytes], { type: 'application/epub+zip' }));
  link.download = file;
  card.append(el('h2', 'margin: 0; font-size: 15px; color: #f4f1ea', `EPUB 3 · ${epub.layout}`),
    el('p', 'margin: 4px 0 0', `${epub.spine.length} documents in the spine · `
      + `${epub.toc.length} contents entries · ${epub.pageList.length} print pages · `
      + `${Math.round(bytes.length / 1024)} KB`), spine, link);
  shelf.append(card);
}
document.getElementById('pages').before(shelf);
kitStatus(`${docs.length} documents laid out · two EPUB 3 files`);
```

تفتح `readEpub` الملف كما يفتحه نظام القراءة: العمود الفقري (spine) بترتيب القراءة، والمحتويات، وقائمة الصفحات، والتخطيط. للملف الثابت مدخل واحد في العمود الفقري لكل صفحة، وللملف القابل لإعادة التدفق أربعة مداخل، واحد لكل فصل. ويحمل كلاهما قائمة صفحات بأرقام الصفحات المطبوعة، فيستطيع القارئ أن يتبع استشهادًا إلى الصفحة 9 في أي منهما. يعطي الرابط `<a download>` البايتات اسم ملف، ولا يمر شيء عبر خادم.

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

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

- مجلد الوصفة: https://github.com/drnachio/postext/tree/main/cookbook/epub-fixed-and-reflowable

### script.js

```js
// ═══ Postext Cookbook · Nº 115 · One book, two EPUB 3 files: fixed and reflowable ════
// https://postext.dev/en/cookbook/epub-fixed-and-reflowable
// Code: MIT · Text: Aesop, tr. G. F. Townsend, 1867; L. Alas, Clarín, 1893 (PD) · Pictures: none
// Fonts: Libre Caslon Text, Libre Caslon Display, Jost (SIL OFL 1.1) · Needs postext ≥ 1.15.0
//
// A small book of three chapters, laid out once with buildBundle and written as two EPUB 3
// files with postext-epub: a fixed layout that keeps every printed page, and reflowable text
// that a phone or an e-reader sets again. Each file is read back with readEpub to list what
// a reading system will open, with a link to download it.
import {
  buildBundle, renderPageToCanvas, clearMeasurementCache,
} from 'https://esm.sh/postext';
import { renderToEpub, readEpub } from 'https://esm.sh/postext-epub';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'epub-fixed-and-reflowable';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: semantic colours, every one linked by id
const palette = {
  ink: '#211e1b', // text: a warm near-black
  accent: '#2d5872', // Prussian blue: kickers, rules, folios
  rule: '#c8c0b4', // the title page's hairline
  muted: '#6b645d', // running heads and the imprint
  paper: '#fbf8f1', // an off-white page, kept in the fixed-layout EPUB
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.accent, model: 'hex' } },
];
// #endregion
const [TEXT, DISPLAY, LABEL] = ['Libre Caslon Text', 'Libre Caslon Display', 'Jost'];
const [TRIM_W, TRIM_H, TOP, BOTTOM, INNER, OUTER] = [120, 190, 20, 22, 16, 15]; // mm
const MEASURE = TRIM_W - INNER - OUTER; // 89 mm: about 62 characters of Libre Caslon Text
const LEAD = 13.5; // pt: the body leading
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const caps = (size, color = 'accent') => ({ fontFamily: LABEL, fontSize: pt(size),
  fontWeight: 600, letterSpacing: pt(size * 0.2), textTransform: 'uppercase', color: col(color) });

// #region openers: a title page, then each chapter sunk under its kicker and a short rule
// Each line spans the measure and centres its text; the rule centres under the title.
const text = (id, content, fontFamily, size, placement, extra = {}) => ({ kind: 'text', id,
  content, fontFamily, fontSize: pt(size), color: col('ink'), align: 'center',
  overflow: 'wrap', lineHeight: 1.15, ...extra,
  placement: { ...placement, size: { width: 'fill', height: 'auto' } } });
const rule = (id, below, y) => ({ kind: 'rule', id, direction: 'horizontal', thickness: pt(0.75),
  color: col('accent'),
  placement: { ...at(below, 'below', (MEASURE - 10) / 2, y), size: { width: mm(10) } } });
const titlePage = { id: 'title-page', header: { elements: [] }, footer: { elements: [] },
  marginBottom: pt(0), advancedDesign: { enabled: true, minHeight: mm(120), slot: { elements: [
    text('author', '{author}', LABEL, 9, at('container', 'top-left', 0, 30), caps(9)),
    text('title', '{titleText}', DISPLAY, 34, at('#author', 'below', 0, 8)),
    rule('rule', '#title', 6),
    text('subtitle', '{subtitle}', TEXT, 11, at('#title', 'below', 0, 13), { italic: true }),
  ] } } };
const opener = { enabled: true, minHeight: mm(56), slot: { elements: [
  text('kicker', '{attr.kicker}', LABEL, 8, at('container', 'top-left', 0, 14), caps(8)),
  text('title', '{titleText}', DISPLAY, 28, at('#kicker', 'below', 0, 4)),
  rule('rule', '#title', 5),
] } };
// #endregion

// #region running-heads: the book on the verso, the chapter on the recto, folios outside
const head = (id, content, parity, edge, x, extra = {}) => ({ kind: 'text', id, content, parity,
  pages: 'body', ...caps(7, 'muted'), fontWeight: 500, placement: at('page', edge, x, 11),
  ...extra });
const folio = { fontFamily: TEXT, fontSize: pt(8.5), fontWeight: 400, letterSpacing: pt(0),
  color: col('accent') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-title', '{title}', 'even', 'top', (OUTER - INNER) / 2),
  head('recto-title', '{chapterTitle}', 'odd', 'top', (INNER - OUTER) / 2),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
const footer = { elements: [head('drop-folio', '{pageNumber}', 'all', 'bottom', 0,
  { ...folio, pages: 'opener', placement: at('page', 'bottom', 0, -11) })] };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: LANG,
  colorPalette,
  page: { sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150,
    backgroundColor: col('paper'), margins: { top: mm(TOP), bottom: mm(BOTTOM),
      left: mm(INNER), right: mm(OUTER), mirror: true } },
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: TEXT, fontSize: pt(9.6), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false,
    hyphenation: { enabled: true }, optimalLineBreaking: true,
    avoidWidows: true, avoidOrphans: true, avoidRunts: true },
  headings: { fontFamily: TEXT, color: col('ink'), fontWeight: 400, levels: [
    // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
    { level: 1, fontFamily: DISPLAY, fontSize: pt(28),
      breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener },
    { level: 2, fontSize: pt(11.5), lineHeight: pt(LEAD), italic: true, color: col('accent'),
      marginTop: pt(LEAD), marginBottom: pt(LEAD / 2), keepWithNext: true },
  ] },
  headingStyles: [titlePage],
  paragraphStyles: [
    // The imprint on the title's verso: the book's line in ink, then the notes in grey.
    { id: 'imprint-head', fontFamily: TEXT, fontSize: pt(8), lineHeight: pt(11.5),
      color: col('ink'), italicColor: col('ink'), textAlign: 'left', firstLineIndent: pt(0),
      marginBottom: pt(8) },
    { id: 'imprint', fontFamily: TEXT, fontSize: pt(8), lineHeight: pt(11.5),
      color: col('muted'), italicColor: col('muted'), textAlign: 'left',
      firstLineIndent: pt(0), spaceBetween: pt(5) },
    { id: 'asterism', fontSize: pt(9), letterSpacing: pt(3), color: col('accent'),
      textAlign: 'center', firstLineIndent: pt(0), marginTop: pt(LEAD / 2),
      marginBottom: pt(LEAD / 2) },
  ],
  header, footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
// The title page and its imprint, then three chapters: one document each in the book.
const chapters = [String.raw`---
title: "Fables of Aesop"
subtitle: "Eighteen fables in three books"
author: "Aesop"
---

# Fables of Aesop {style="title-page"}

:::pagebreak

:::paragraphs{style="imprint-head"}
*Fables of Aesop* · Eighteen fables in three books
:::

:::paragraphs{style="imprint"}
The fables follow George Fyler Townsend’s translation from the Greek, *Three Hundred Æsop’s Fables* (London: George Routledge and Sons, 1867), as transcribed by Project Gutenberg (eBook #21). The text is in the public domain; the opening words of each fable, set in capitals there, are set in lower case here.

Set in Libre Caslon Text, Libre Caslon Display and Jost (SIL Open Font License). Laid out once with Postext and written out as two EPUB 3 files, one with fixed pages and one with reflowable text.
:::
`, String.raw`# The Lion’s Court {kicker="Book I"}

## The Lion and the Mouse

A Lion was awakened from sleep by a Mouse running over his face. Rising up angrily, he caught him and was about to kill him, when the Mouse piteously entreated, saying: “If you would only spare my life, I would be sure to repay your kindness.” The Lion laughed and let him go. It happened shortly after this that the Lion was caught by some hunters, who bound him by strong ropes to the ground. The Mouse, recognizing his roar, came and gnawed the rope with his teeth, and set him free, exclaiming:

“You ridiculed the idea of my ever being able to help you, not expecting to receive from me any repayment of your favour; now you know that it is possible for even a Mouse to confer benefits on a Lion.”

## The Lion in Love

A Lion demanded the daughter of a woodcutter in marriage. The Father, unwilling to grant, and yet afraid to refuse his request, hit upon this expedient to rid himself of his importunities. He expressed his willingness to accept the Lion as the suitor of his daughter on one condition: that he should allow him to extract his teeth, and cut off his claws, as his daughter was fearfully afraid of both. The Lion cheerfully assented to the proposal. But when the toothless, clawless Lion returned to repeat his request, the Woodman, no longer afraid, set upon him with his club, and drove him away into the forest.

## The Sick Lion

A Lion, unable from old age and infirmities to provide himself with food by force, resolved to do so by artifice. He returned to his den, and lying down there, pretended to be sick, taking care that his sickness should be publicly known. The beasts expressed their sorrow, and came one by one to his den, where the Lion devoured them. After many of the beasts had thus disappeared, the Fox discovered the trick and presenting himself to the Lion, stood on the outside of the cave, at a respectful distance, and asked him how he was. “I am very middling,” replied the Lion, “but why do you stand without? Pray enter within to talk with me.” “No, thank you,” said the Fox. “I notice that there are many prints of feet entering your cave, but I see no trace of any returning.”

He is wise who is warned by the misfortunes of others.

## The Lion, the Fox, and the Ass

The Lion, the Fox and the Ass entered into an agreement to assist each other in the chase. Having secured a large booty, the Lion on their return from the forest asked the Ass to allot his due portion to each of the three partners in the treaty. The Ass carefully divided the spoil into three equal shares and modestly requested the two others to make the first choice. The Lion, bursting out into a great rage, devoured the Ass. Then he requested the Fox to do him the favour to make a division. The Fox accumulated all that they had killed into one large heap and left to himself the smallest possible morsel. The Lion said, “Who has taught you, my very excellent fellow, the art of division? You are perfect to a fraction.” He replied, “I learned it from the Ass, by witnessing his fate.”

Happy is the man who learns from the misfortunes of others.

## The Kingdom of the Lion

The Beasts of the field and forest had a Lion as their king. He was neither wrathful, cruel, nor tyrannical, but just and gentle as a king could be. During his reign he made a royal proclamation for a general assembly of all the birds and beasts, and drew up conditions for a universal league, in which the Wolf and the Lamb, the Panther and the Kid, the Tiger and the Stag, the Dog and the Hare, should live together in perfect peace and amity. The Hare said, “Oh, how I have longed to see this day, in which the weak shall take their place with impunity by the side of the strong.” And after the Hare said this, he ran for his life.

## The Old Lion

A Lion, worn out with years and powerless from disease, lay on the ground at the point of death. A Boar rushed upon him, and avenged with a stroke of his tusks a long-remembered injury. Shortly afterwards the Bull with his horns gored him as if he were an enemy. When the Ass saw that the huge beast could be assailed with impunity, he let drive at his forehead with his heels. The expiring Lion said, “I have reluctantly brooked the insults of the brave, but to be compelled to endure such treatment from thee, a disgrace to Nature, is indeed to die a double death.”
`, String.raw`# The Fox’s Wits {kicker="Book II"}

## The Fox and the Grapes

A Famished Fox saw some clusters of ripe black grapes hanging from a trellised vine. She resorted to all her tricks to get at them, but wearied herself in vain, for she could not reach them. At last she turned away, hiding her disappointment and saying: “The Grapes are sour, and not ripe as I thought.”

## The Fox and the Crow

A Crow having stolen a bit of meat, perched in a tree and held it in her beak. A Fox, seeing this, longed to possess the meat himself, and by a wily stratagem succeeded. “How handsome is the Crow,” he exclaimed, “in the beauty of her shape and in the fairness of her complexion! Oh, if her voice were only equal to her beauty, she would deservedly be considered the Queen of Birds!” This he said deceitfully; but the Crow, anxious to refute the reflection cast upon her voice, set up a loud caw and dropped the flesh. The Fox quickly picked it up, and thus addressed the Crow: “My good Crow, your voice is right enough, but your wit is wanting.”

## The Fox and the Goat

A Fox one day fell into a deep well and could find no means of escape. A Goat, overcome with thirst, came to the same well, and seeing the Fox, inquired if the water was good. Concealing his sad plight under a merry guise, the Fox indulged in a lavish praise of the water, saying it was excellent beyond measure, and encouraging him to descend. The Goat, mindful only of his thirst, thoughtlessly jumped down, but just as he drank, the Fox informed him of the difficulty they were both in and suggested a scheme for their common escape. “If,” said he, “you will place your forefeet upon the wall and bend your head, I will run up your back and escape, and will help you out afterwards.” The Goat readily assented and the Fox leaped upon his back. Steadying himself with the Goat’s horns, he safely reached the mouth of the well and made off as fast as he could. When the Goat upbraided him for breaking his promise, he turned around and cried out, “You foolish old fellow! If you had as many brains in your head as you have hairs in your beard, you would never have gone down before you had inspected the way up, nor have exposed yourself to dangers from which you had no means of escape.”

Look before you leap.

## The Fox Who Had Lost His Tail

A Fox caught in a trap escaped, but in so doing lost his tail. Thereafter, feeling his life a burden from the shame and ridicule to which he was exposed, he schemed to convince all the other Foxes that being tailless was much more attractive, thus making up for his own deprivation. He assembled a good many Foxes and publicly advised them to cut off their tails, saying that they would not only look much better without them, but that they would get rid of the weight of the brush, which was a very great inconvenience. One of them interrupting him said, “If you had not yourself lost your tail, my friend, you would not thus counsel us.”

## The Ass in the Lion’s Skin

An Ass, having put on the Lion’s skin, roamed about in the forest and amused himself by frightening all the foolish animals he met in his wanderings. At last coming upon a Fox, he tried to frighten him also, but the Fox no sooner heard the sound of his voice than he exclaimed, “I might possibly have been frightened myself, if I had not heard your bray.”

## The Fox and the Woodcutter

A Fox, running before the hounds, came across a Woodcutter felling an oak and begged him to show him a safe hiding-place. The Woodcutter advised him to take shelter in his own hut, so the Fox crept in and hid himself in a corner. The huntsman soon came up with his hounds and inquired of the Woodcutter if he had seen the Fox. He declared that he had not seen him, and yet pointed, all the time he was speaking, to the hut where the Fox lay hidden. The huntsman took no notice of the signs, but believing his word, hastened forward in the chase. As soon as they were well away, the Fox departed without taking any notice of the Woodcutter: whereon he called to him and reproached him, saying, “You ungrateful fellow, you owe your life to me, and yet you leave me without a word of thanks.” The Fox replied, “Indeed, I should have thanked you fervently if your deeds had been as good as your words, and if your hands had not been traitors to your speech.”
`,
  String.raw`# Small Creatures {kicker="Book III"}

## The Ants and the Grasshopper

The Ants were spending a fine winter’s day drying grain collected in the summertime. A Grasshopper, perishing with famine, passed by and earnestly begged for a little food. The Ants inquired of him, “Why did you not treasure up food during the summer?” He replied, “I had not leisure enough. I passed the days in singing.” They then said in derision: “If you were foolish enough to sing all the summer, you must dance supperless to bed in the winter.”

## The Hare and the Tortoise

A Hare one day ridiculed the short feet and slow pace of the Tortoise, who replied, laughing: “Though you be swift as the wind, I will beat you in a race.” The Hare, believing her assertion to be simply impossible, assented to the proposal; and they agreed that the Fox should choose the course and fix the goal. On the day appointed for the race the two started together. The Tortoise never for a moment stopped, but went on with a slow but steady pace straight to the end of the course. The Hare, lying down by the wayside, fell fast asleep. At last waking up, and moving as fast as he could, he saw the Tortoise had reached the goal, and was comfortably dozing after her fatigue.

Slow but steady wins the race.

## The Town Mouse and the Country Mouse

A Country Mouse invited a Town Mouse, an intimate friend, to pay him a visit and partake of his country fare. As they were on the bare plowlands, eating there wheat-stocks and roots pulled up from the hedgerow, the Town Mouse said to his friend, “You live here the life of the ants, while in my house is the horn of plenty. I am surrounded by every luxury, and if you will come with me, as I wish you would, you shall have an ample share of my dainties.” The Country Mouse was easily persuaded, and returned to town with his friend. On his arrival, the Town Mouse placed before him bread, barley, beans, dried figs, honey, raisins, and, last of all, brought a dainty piece of cheese from a basket. The Country Mouse, being much delighted at the sight of such good cheer, expressed his satisfaction in warm terms and lamented his own hard fate. Just as they were beginning to eat, someone opened the door, and they both ran off squeaking, as fast as they could, to a hole so narrow that two could only find room in it by squeezing. They had scarcely begun their repast again when someone else entered to take something out of a cupboard, whereupon the two Mice, more frightened than before, ran away and hid themselves. At last the Country Mouse, almost famished, said to his friend: “Although you have prepared for me so dainty a feast, I must leave you to enjoy it by yourself. It is surrounded by too many dangers to please me. I prefer my bare plowlands and roots from the hedgerow, where I can live in safety, and without fear.”

## The Ant and the Dove

An Ant went to the bank of a river to quench its thirst, and being carried away by the rush of the stream, was on the point of drowning. A Dove sitting on a tree overhanging the water plucked a leaf and let it fall into the stream close to her. The Ant climbed onto it and floated in safety to the bank. Shortly afterwards a birdcatcher came and stood under the tree, and laid his lime-twigs for the Dove, which sat in the branches. The Ant, perceiving his design, stung him in the foot. In pain the birdcatcher threw down the twigs, and the noise made the Dove take wing.

## The Crow and the Pitcher

A Crow perishing with thirst saw a pitcher, and hoping to find water, flew to it with delight. When he reached it, he discovered to his grief that it contained so little water that he could not possibly get at it. He tried everything he could think of to reach the water, but all his efforts were in vain. At last he collected as many stones as he could carry and dropped them one by one with his beak into the pitcher, until he brought the water within his reach and thus saved his life.

Necessity is the mother of invention.

## The Mice in Council

The Mice summoned a council to decide how they might best devise means of warning themselves of the approach of their great enemy the Cat. Among the many plans suggested, the one that found most favour was the proposal to tie a bell to the neck of the Cat, so that the Mice, being warned by the sound of the tinkling, might run away and hide themselves in their holes at his approach. But when the Mice further debated who among them should thus “bell the Cat,” there was no one found to do it.
`].map((markdown) => ({ markdown }));
const markdown = chapters.map((c) => c.markdown).join('\n\n'); // every word, for the fonts

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first)
  'Libre Caslon Text': ['400', '400i'], // the text, the fable titles, the imprint
  'Libre Caslon Display': ['400'], // the book's and the chapters' titles
  Jost: ['500', '600'], // kickers, author, running heads
};

// #region embed: the same Fontsource files, as the EPUB's font files
// Only the latin files: they hold every letter of both samples. A text beyond Latin-1 would
// add each family's latin-ext file with its unicodeRange.
const epubFonts = (faces) => Promise.all(Object.entries(faces).flatMap(([family, specs]) =>
  specs.map(async (spec) => {
    const [weight, style] = [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'];
    const id = fontsourceId(family);
    const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/`
      + `${id}-latin-${weight}-${style}.woff2`);
    if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
    const bytes = new Uint8Array(await res.arrayBuffer());
    return { family, weight, style, format: 'woff2', bytes };
  })));
// #endregion

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
const docs = await buildWithFonts(() => buildBundle({ chapters, config: config() }), markdown);
showPages(docs, { title: `${docs[0].metadata.title} · EPUB 3` });

// #region answer: one layout, two EPUB 3 files: every printed page, or text that reflows
const { title, subtitle, author } = docs[0].metadata; // the first chapter's front matter
const book = {
  metadata: { title, subtitle, creators: [author], language: LANG,
    rights: t({ en: 'Public domain', es: 'Dominio público' }),
    // A fixed date gives the same bytes on every run; the identifier (a urn:uuid) comes
    // from the title, the author and the language, so a new version keeps its place.
    modified: new Date('2026-10-04T00:00:00Z') },
  // Every face the pages use, as bytes: a face left out is set in the reader's own
  // (gotcha: epub-embeds-given-fonts).
  fonts: await epubFonts(FONTS),
  onWarning: (w) => console.warn(`[epub] ${w.kind}`, w.family ?? w.fileId ?? w.detail),
};
// The same documents renderToPdf would print: one per chapter, in book order.
const fixed = await renderToEpub(docs, { ...book, layout: 'fixed' });
const reflowable = await renderToEpub(docs, { ...book, layout: 'reflowable' });
// #endregion

// #region shelf: each file read back: its spine, its contents and a link to keep it
const el = (tag, css, text) => Object.assign(document.createElement(tag),
  { textContent: text ?? '' }, { style: css });
const shelf = el('section', 'display: flex; flex-wrap: wrap; gap: 16px; justify-content: center;'
  + 'padding: 24px 16px 0; color: #d9d5cc; font: 13px/1.5 system-ui, sans-serif');
shelf.id = 'epub';
for (const [layout, bytes] of [['fixed', fixed], ['reflowable', reflowable]]) {
  const epub = readEpub(bytes); // what a reading system opens: spine, contents, page list
  const file = `${RECIPE}-${layout}.epub`;
  const card = el('article', 'width: min(420px, 92vw); padding: 16px 18px;'
    + 'border: 1px solid #2c3038; border-radius: 6px; background: #15181d');
  const spine = el('ol', 'margin: 8px 0; padding-left: 22px; max-height: 9em; overflow: auto');
  spine.append(...epub.spine.map((item) => el('li', '', item.path.slice(epub.root.length))));
  const link = el('a', 'color: #d8a21a', `Download ${file}`);
  link.href = URL.createObjectURL(new Blob([bytes], { type: 'application/epub+zip' }));
  link.download = file;
  card.append(el('h2', 'margin: 0; font-size: 15px; color: #f4f1ea', `EPUB 3 · ${epub.layout}`),
    el('p', 'margin: 4px 0 0', `${epub.spine.length} documents in the spine · `
      + `${epub.toc.length} contents entries · ${epub.pageList.length} print pages · `
      + `${Math.round(bytes.length / 1024)} KB`), spine, link);
  shelf.append(card);
}
document.getElementById('pages').before(shelf);
kitStatus(`${docs.length} documents laid out · two EPUB 3 files`);
// #endregion

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

## تنويعات

### أعطِ التخطيط الثابت صورة غلاف

ارسم صفحة العنوان على لوحة (canvas) ومرّرها غلافًا؛ ويخبر `showsFirstPage` الكاتبَ ألا يضيفها صفحةً مستقلة.

```diff
+const canvas = document.createElement('canvas');
+renderPageToCanvas(docs[0].pages[0], docs[0], canvas, { scale: 2 });
+const png = await new Promise((done) => canvas.toBlob(done, 'image/png'));
+const cover = { bytes: new Uint8Array(await png.arrayBuffer()), mediaType: 'image/png',
+  showsFirstPage: true };
-const fixed = await renderToEpub(docs, { ...book, layout: 'fixed' });
+const fixed = await renderToEpub(docs, { ...book, layout: 'fixed', cover });
```

### اطبع الفصول نفسها ملف PDF

تأخذ `renderToPdf(docs, …)` من `postext-pdf` المصفوفة نفسها، فيأتي ملف PDF وملف EPUB والشاشة من إخراج واحد. وتُظهر [الرواية الكتالونية في قطع الجيب](https://postext.dev/ar/cookbook/catalan-pocket-novella.md) جانب PDF.

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

- **لا تضمّن renderToEpub إلا الخطوط التي تمرّرها إليها.** الخطوط التي حمّلها المتصفح ليست ملفات تستطيع postext-epub قراءتها: مرّر في options.fonts بايتات كل خط تستخدمه الصفحات (ملفات woff2 نفسها من Fontsource التي حمّلتها الصفحة). يُبلَّغ عن الخط الناقص بتحذير missingFont، ويُنضّد نظام القراءة ذلك النص بخط من عنده، وهو في التخطيط الثابت لا يتسع للأسطر التي نُضّدت بها الصفحة. لا تضمّن إلا الخطوط التي تسمح رخصتها بذلك.
- **أي كائن headings يُلغي فاصل الصفحة قبل H1.** ينتقل H1 افتراضيًا إلى صفحة فردية (always-odd)، لكن تمرير أي كائن headings يعيد ضبط هذا الافتراض، فتتوالى الفصول دون فاصل ولا يفعل span: 'page' شيئًا. أعد كتابة headings.levels[0].breakBefore: { enabled: true, parity } في كل إعداد.
- **حمّل كل أوجه الخط قبل الإخراج.** يقيس الإخراج النص بأوجه الخط التي حمّلها المتصفح ويخزّن العروض مؤقتًا، فالوجه الذي يصل بعد البناء الأول يترك فواصل أسطر خاطئة وملف PDF لم يعد يطابق الشاشة. حمّل كل وزن وكل نمط أولًا، واستدعِ clearMeasurementCache() قبل إعادة البناء إذا تأخر وصول أحدها.
- **يُخزَّن الإعداد مؤقتًا بحسب هويته: ابنِ كائنًا جديدًا.** يخزّن المحرّك الإعدادات المحسوبة مؤقتًا بحسب هوية الكائن، فتعديل الإعداد في مكانه ثم البناء مجددًا يعيد استخدام النتيجة القديمة. ابنِ كائنًا جديدًا في كل بناء، ولهذا يكون إعداد الوصفة دالة مصنِّعة: config().

يحتفظ الملف القابل لإعادة التدفق بنص العنوان لا بتصميمه: العنوان التمهيدي في كل صفحة افتتاح (*Book I*) عنصر من عناصر التصميم، فلا يدخل في النص. ضع الكلمات التي يجب أن يراها القارئ في العنوان نفسه أو في النص.

## الحقوق

- الوصفة: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- النص: Eighteen fables from Three Hundred Æsop’s Fables, translated from the Greek by George Fyler Townsend (London, 1867), the opening capitals of each fable set in lower case: Aesop, tr. George Fyler Townsend ([المصدر](https://www.gutenberg.org/ebooks/21)), ملكية عامة
- النص: ¡Adiós, Cordera!, complete, from El Señor y lo demás, son cuentos (Madrid, 1893), its five parts grouped in three chapters, with dialogue dashes and « » quotation marks: Leopoldo Alas, Clarín ([المصدر](https://www.gutenberg.org/ebooks/62361)), ملكية عامة
- الخطوط: Libre Caslon Text (OFL-1.1), Libre Caslon Display (OFL-1.1), Jost (OFL-1.1)
- الشيفرة: MIT · محتوى المثال: MIT

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

- [رقم 104 · رواية قصيرة كتالونية بتقسيم على قواعد IEC](https://postext.dev/ar/cookbook/catalan-pocket-novella.md): رواية El transplantat لنارسيس أوليه كاملة في كتاب جيب من 32 صفحة: مقاطع كتالونية على قواعد IEC، وl·l تُقسم il- | lusió، ونقش لكل فصل. · المستوى 2 (متوسط) · الأدب القصصي والمسرح والنثر الأدبي
- [رقم 100 · كتاب مُخرَج على المكتب، بالأبعاد الثلاثة](https://postext.dev/ar/cookbook/folio-book-on-a-desk.md): تفتح الدالة createFolioFromDocument من الحزمة postext-folio فصلًا مُخرَجًا ككتاب مجلَّد بالقماش على مكتب من خشب الجوز، بمظهر يحدده config.folio. · المستوى 2 (متوسط) · الأدب القصصي والمسرح والنثر الأدبي
- [رقم 041 · ذهاب وإياب لملف .postext بلغتين](https://postext.dev/ar/cookbook/bundle-round-trip.md): مطوية DL بوجهين تُكتب في ملف .postext بالشيفرة ثم تُنضَّد من جديد من بايتاته، مع الخطوط والرسوم وتسميات التعليقات التي تحملها كل طبعة. · المستوى 2 (متوسط) · الأوراق المفردة والمطبوعات العابرة
