# ذهاب وإياب لملف .postext بلغتين

> مطوية DL بوجهين تُكتب في ملف .postext بالشيفرة ثم تُنضَّد من جديد من بايتاته، مع الخطوط والرسوم وتسميات التعليقات التي تحملها كل طبعة.

- نسخة HTML: https://postext.dev/ar/cookbook/bundle-round-trip
- وصفة رقم 041 · الإخراج والدمج · المستوى 2 (متوسط) · المخرجات: Canvas, PDF, حزمة .postext
- الأنواع: الأوراق المفردة والمطبوعات العابرة
- تتطلب postext ≥ 1.4.1, postext-pdf ≥ 1.4.1 · اختُبرت مع 1.4.1, postext-pdf 1.4.1 بتاريخ 2026-09-26
- الصفحات: [1](https://postext.dev/cookbook/bundle-round-trip/en/p01.webp?v=c823c651), [2](https://postext.dev/cookbook/bundle-round-trip/en/p02.webp?v=c823c651)
- PDF: https://postext.dev/cookbook/bundle-round-trip/en/bundle-round-trip.pdf?v=c823c651
- افتح في Sandbox: https://postext.dev/ar/sandbox#recipe=bundle-round-trip&lang=en (.postext: https://postext.dev/cookbook/bundle-round-trip/en/bundle-round-trip.postext)
- آخر تحديث: 2026-09-26
- لغات أخرى: [en](https://postext.dev/en/cookbook/bundle-round-trip.md), [es](https://postext.dev/es/cookbook/bundle-round-trip.md), [ca](https://postext.dev/ca/cookbook/bundle-round-trip.md), [zh](https://postext.dev/zh/cookbook/bundle-round-trip.md)

## باختصار

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

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

وجها مطوية زيارة بقياس DL، أي 99 × 210 مم، لطاحونة مدّ على مصبّ نهر متخيَّل. الوجه الأمامي رسم واحد: عجلة طاحونة على خط الماء، نصفها الأعلى على الرمل ونصفها الأسفل شاحب تحت الماء الأزرق، والعنوان بخط DM Serif Display المائل بحجم 50 نقطة، ولسان أزرق يسمّي الطبعة: EN أو ES. يبدأ الوجه الخلفي بمقطع عرضي للطاحونة، ثم النص بخط DM Sans مضبوطًا، ومواعيد الفتح في جدول برأس أزرق، والتعليقات بخط Instrument Sans، ثم بيانات الطبع وشريط أزرق باسم الناشر. تُكتب كل طبعة في ملف `.postext`، وكل صفحة هنا مُنضَّدة من بايتات ذلك الملف، بالخطوط والرسوم التي يحملها.

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

- كيف أنضّد ملف .postext بالخطوط والصور والإعدادات التي يحملها؟
- كيف أنشئ حزمة .postext من الشيفرة، لأسلّم مستندًا إلى Sandbox أو إلى برنامج آخر؟
- كيف أنشر الكتاب نفسه بلغتين من مشروع واحد؟
- كيف أحصل على تسميتي «شكل» و«جدول» بلغة مستندي؟

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

```js
// script.js, سطرًا 296–312
// The writer: text, design, resources and every file they name, zipped. createBundle looks
// up each fileId (a drawing's svg.fileId, a face's variant fileId) in `files`.
const { bytes, warnings } = await createBundle({
  name: t({ en: 'The Tide Mill of Arenal', es: 'El molino de mareas de Arenal' }),
  locale: LANG, // one language per bundle: createBundle 1.4.1 writes no translations
  markdown, config: config(), resources,
  files: { ...drawings, ...faceFiles },
  thumbnail: { data: drawings['cover.svg'], mime: 'image/svg+xml' }, // the book's picture
});
if (warnings.length) console.warn(warnings); // what was left out, and why

// The reader has nothing but the bytes. Each fileId is now the file's path inside the zip:
// mill.svg is resources/mill.svg, and the faces sit under fonts/.
const bundle = await openBundle(bytes);
await loadBundleFonts(bundle); // one FontFace per face from the file, in place of loadFonts()
await registerBundleImages(bundle); // the drawings, for the canvas
const docs = buildBundle(bundle); // one VDTDocument per chapter: a leaflet has one
```

## المكونات

**تعلّم**

- [حزم .postext](https://postext.dev/ar/docs/configuration.md#الحزم-ملفات-postext): كتاب كامل في ملف واحد (الفصول والإعدادات والموارد والخطوط) يمكن للشيفرة إنشاؤه وفتحه وإخراجه، ويمكن تحريره في Sandbox.
- [«شكل» و«جدول» بلغتك](https://postext.dev/ar/docs/configuration.md#أنواع-الموارد): تُرجع defaultResourceTypes(locale) نوعَي الشكل والجدول المدمجين، بتسمياتهما وإحالاتهما، بلغة المستند.
- [خطوطك الخاصة](https://postext.dev/ar/docs/configuration.md#الخطوط-المخصّصة): عائلات خطوط العلامة التجارية أو المرخّصة المعلنة في customFonts، تُسجَّل للإخراج وتُضمَّن في PDF من البايتات نفسها.

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

- [تقسيم الكلمات ولغة المستند](https://postext.dev/ar/docs/justification.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#التضمين-في-كتلة-اختياري-لوضع-صريح-داخل-التدفق)
- [مسافة عمودية صريحة](https://postext.dev/ar/docs/document-format.md#space)
- [كتب تُبنى فصلًا فصلًا](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/document-format.md#البيانات-التمهيدية-frontmatter)
- [نمط التعليق](https://postext.dev/ar/docs/configuration.md#نمط-التعليقات)
- [نمط الجدول](https://postext.dev/ar/docs/configuration.md#نمط-الجداول)
- [التصدير إلى PDF](https://postext.dev/ar/docs/configuration.md#توليد-ملفات-pdf)
- [الخطوط المضمَّنة في PDF](https://postext.dev/ar/docs/configuration.md#لماذا-مزوّد-الخطوط)
- citations
- [شريط فصل بعرض الصفحة](https://postext.dev/ar/docs/configuration.md#الامتداد-والتصميم-المتقدم)
- [فواصل الصفحات والأعمدة](https://postext.dev/ar/docs/document-format.md#pagebreak)
- [الترويسات بحسب دور الصفحة](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#نص-المتن), [`captionStyle`](https://postext.dev/ar/docs/configuration.md#نمط-التعليقات), [`colorPalette`](https://postext.dev/ar/docs/configuration.md#لوحة-الألوان), [`customFonts`](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#أنماط-الفقرات), [`resourceTypes`](https://postext.dev/ar/docs/configuration.md#أنواع-الموارد), [`tableStyle`](https://postext.dev/ar/docs/configuration.md#نمط-الجداول)

**واجهات API**

- [`buildBundle`](https://postext.dev/ar/docs/configuration.md#إخراج-حزمة-ورسمها), [`bundleFontProvider`](https://postext.dev/ar/docs/configuration.md#إخراج-حزمة-ورسمها), [`bundleResourceBytes`](https://postext.dev/ar/docs/configuration.md#إخراج-حزمة-ورسمها), [`clearMeasurementCache`](https://postext.dev/ar/docs/configuration.md#ذاكرة-القياس-المؤقتة), [`createBundle`](https://postext.dev/ar/docs/configuration.md#إنشاء-حزمة), [`decompressWoff2`](https://postext.dev/ar/docs/configuration.md#مزوّد-الخطوط-في-المتصفح-fontsource--woff2), [`defaultResourceTypes`](https://postext.dev/ar/docs/configuration.md#أنواع-الموارد), [`loadBundleFonts`](https://postext.dev/ar/docs/configuration.md#إخراج-حزمة-ورسمها), [`openBundle`](https://postext.dev/ar/docs/configuration.md#فتح-حزمة), [`registerBundleImages`](https://postext.dev/ar/docs/configuration.md#إخراج-حزمة-ورسمها), [`renderPageToCanvas`](https://postext.dev/ar/docs/configuration.md#رسم-صفحة-في-صورة-نقطية), [`renderToPdf`](https://postext.dev/ar/docs/configuration.md#توليد-ملفات-pdf)

**الخطوط**

- DM Sans (OFL-1.1), DM Serif Display (OFL-1.1), Instrument Sans (OFL-1.1)

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

### 1 · اكتب الطبعة، ثم اقرأ البايتات وحدها

```js
// script.js, سطرًا 296–312
// The writer: text, design, resources and every file they name, zipped. createBundle looks
// up each fileId (a drawing's svg.fileId, a face's variant fileId) in `files`.
const { bytes, warnings } = await createBundle({
  name: t({ en: 'The Tide Mill of Arenal', es: 'El molino de mareas de Arenal' }),
  locale: LANG, // one language per bundle: createBundle 1.4.1 writes no translations
  markdown, config: config(), resources,
  files: { ...drawings, ...faceFiles },
  thumbnail: { data: drawings['cover.svg'], mime: 'image/svg+xml' }, // the book's picture
});
if (warnings.length) console.warn(warnings); // what was left out, and why

// The reader has nothing but the bytes. Each fileId is now the file's path inside the zip:
// mill.svg is resources/mill.svg, and the faces sit under fonts/.
const bundle = await openBundle(bytes);
await loadBundleFonts(bundle); // one FontFace per face from the file, in place of loadFonts()
await registerBundleImages(bundle); // the drawings, for the canvas
const docs = buildBundle(bundle); // one VDTDocument per chapter: a leaflet has one
```

تضغط `createBundle` في ملف zip الفصلَ والإعدادات والموارد وكل ملف تسمّيه؛ ولا تتلقى `openBundle` سوى تلك البايتات. الخطوط تأتي من `loadBundleFonts`، والرسوم من `registerBundleImages`، والتصميم من `bundle.config`. الخط الذي لا يرد في `files` يُطبع بخط المتصفح الاحتياطي، والرسم الذي يغيب يُسقَط مع تحذير وتُطبع الإحالة إليه (?). داخل الملف المضغوط يصير كل `fileId` مسارًا (`mill.svg` صار `resources/mill.svg`)، والموارد و`customFonts` التي تعيدها `openBundle` تستعمل الأسماء الجديدة من الأصل ([فتح حزمة](/ar/docs/configuration#فتح-حزمة)).

### 2 · ضع الخطوط في الملف

```js
// script.js, سطرًا 279–291
const customFonts = Object.entries(FONTS).map(([name, specs]) => ({ name,
  variants: specs.map((spec) => ({ weight: parseInt(spec, 10), format: 'woff2',
    style: spec.endsWith('i') ? 'italic' : 'normal', fileId: `${fontsourceId(name)}-${spec}` })),
}));
// The bytes: Fontsource's static woff2 files, latin subset, which covers the Spanish text too.
const faceFiles = Object.fromEntries(await Promise.all(customFonts.flatMap(({ name, variants }) =>
  variants.map(async ({ weight, style, fileId }) => {
    const id = fontsourceId(name);
    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 ${name} ${weight} ${style}`);
    return [fileId, new Uint8Array(await res.arrayBuffer())];
  }))));
```

تحمل الحزمة خطًا حين تسمّيه `customFonts` وتحفظ `files` بايتاته تحت `fileId` الخاص بذلك الوزن أو النمط؛ فتخزّن `createBundle` خط DM Sans بوزن 400 باسم `fonts/dm-sans-400-normal.woff2`. ينزّل المثال ملفات woff2 السبعة من Fontsource ولا يسجّل أيًّا منها: تضيف `loadBundleFonts` نسخ الحزمة إلى `document.fonts` قبل أن تقيس `buildBundle` أي سطر، ويأخذ ملف PDF خطوطه من الملفات السبعة نفسها.

### 3 · سمِّ كل ملف بـfileId الخاص به

```js
// script.js, سطرًا 236–269
const svg = (id, w, h, altText, extra) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0,
  updatedAt: 0, altText, svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, ...extra });
const row = (...cells) => cells.map((content) => ({ content }));
const head = (...cells) => cells.map((content) => ({ content, isHeader: true }));
const resources = [
  svg('cover', PAGE.w, PAGE.h, t({ en: 'A mill wheel on the waterline, its lower half pale '
    + 'under the estuary', es: 'Una rueda de molino en la línea del agua, con la mitad '
    + 'inferior pálida bajo la ría' })),
  svg('mill', SECTION.w, SECTION.h, t({
    en: 'The mill in section: the pond at high level on the left, the mill house on the dam '
      + 'with its millstones, the horizontal wheel in the vaulted pit, and the estuary on the '
      + 'right below a dashed high-water line',
    es: 'El molino en sección: el estanque a nivel alto a la izquierda, la casa del molino sobre '
      + 'la presa con sus muelas, el rodezno en el cárcavo abovedado y la ría a la derecha, bajo '
      + 'una línea discontinua de pleamar' }), {
    placement: { position: 'here' },
    caption: t({ en: 'Two hours after high water: the pond turns the wheel, and the estuary '
      + 'has fallen below the dashed line.',
    es: 'Dos horas tras la pleamar: el estanque mueve el rodezno y la ría ha quedado por debajo '
      + 'de la línea discontinua.' }) }),
  { id: 'hours', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
    placement: { position: 'here' },
    caption: t({ en: 'Opening hours. Last entry 45 minutes before closing.',
      es: 'Horario. Última entrada 45 minutos antes del cierre.' }),
    table: { model: { headerRowCount: 1, columnWidths: [1.55, 0.9, 1.55], rows: t({
      en: [head('Season', 'Days', 'Hours'),
        row('April–June', 'Tue–Sun', '10:00–14:00, 16:00–19:00'),
        row('July–August', 'Mon–Sun', '10:00–20:00'),
        row('September–March', 'Fri–Sun', '10:30–14:30')],
      es: [head('Temporada', 'Días', 'Horario'),
        row('Abril–junio', 'Mar.–dom.', '10:00–14:00 y 16:00–19:00'),
        row('Julio–agosto', 'Lun.–dom.', '10:00–20:00'),
        row('Septiembre–marzo', 'Vie.–dom.', '10:30–14:30')] }) } } },
];
```

الرسمان شيفرة SVG يولّدها المثال، وتُسلَّم إلى `files` تحت `svg.fileId` الخاص بمورد كل منهما؛ أما الجدول فبيانات، فينتقل في `preset.json` مع تعليقه. يُنضَّد المقطع العرضي والجدول بالإعداد `position: 'here'`: المقطع في رأس الوجه الخلفي، والمواعيد تحت الفقرة التي تحيل إليها. في الإصدار 1.4.1 لا يحصل المورد المضمَّن في النص على أي فراغ تحته، فيترك سطر `:::space{lines=0.5}` بعد الجدول 3.7 مم تحت تعليقه؛ ومن دونه تبدأ الفقرة التالية على بعد 1.4 مم من التعليق.

### 4 · اكتب التسميات في الملف

```js
// script.js, سطرًا 56–61
  // Hyphenation patterns and the PDF's /Lang, by exact code (gotcha: hyphenation-locales).
  locale: t({ en: 'en-us', es: 'es' }),
  // Figura and Tabla travel inside the Spanish file. Left out, they follow whoever opens it:
  // the Sandbox at /en/sandbox prints Figure 1.1 (gotcha: bundle-labels-reader-locale).
  // '{n}' numbers them 1, 2, 3: a leaflet has no chapters to number its figures by.
  resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}' })),
```

حين يخلو الملف من `resourceTypes`، تبنيها `openBundle` باللغة التي يطلبها القارئ، لا بلغة الملف: المطوية الإسبانية إذا فُتحت بـ`{ locale: 'en' }`، أو استُوردت إلى Sandbox على /en/sandbox، تطبع Figure 1.1 فوق نص إسباني. الأنواع المكتوبة في الإعدادات تحلّ محل ذلك الافتراض، فيطبع الملف الإسباني Figura 1 وTabla 1 أينما فُتح. يُسقط `'{n}'` رقم الفصل، وهو رقم لا تحتاجه مطوية من صفحتين ([أنواع الموارد](/ar/docs/configuration#أنواع-الموارد)). والدالة `config()` نفسها تضبط `locale`، فتختار أنماط تقسيم الكلمات الإسبانية بالواصلة (compuer-tas في الوجه الخلفي) واللغة التي يعلنها ملف PDF.

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

```js
// script.js, سطرًا 318–326
const file = `tide-mill-${LANG}.postext`;
document.getElementById('pt-actions').append(Object.assign(document.createElement('a'), {
  href: URL.createObjectURL(new Blob([bytes], { type: 'application/zip' })), download: file,
  textContent: `Download ${file} · ${Math.round(bytes.length / 1024)} KB` }));
// The PDF embeds the faces the bundle carries, and draws the figures from its files.
offerPdf(() => renderToPdf(docs, {
  fontProvider: bundleFontProvider(bundle, { decodeWoff2: decompressWoff2 }),
  resourceBytes: bundleResourceBytes(bundle),
}), `${RECIPE}-${LANG}.pdf`);
```

يقدّم الرابط `bytes` التي نُضّدت منها الصفحات، 132 كيلوبايت مع الخطوط. وإذا استُورد الملف في Sandbox (*الكتب › جديد › افتح ملف .postext…*) صار كتابًا إسبانيًا أو إنجليزيًا صورته العجلة، مأخوذة من `thumbnail`. يأخذ ملف PDF خطوطه من `bundleFontProvider`، الذي يعيد أقرب خط في الحزمة وزنًا، وبالنمط نفسه إن وُجد، ورسومَه من `bundleResourceBytes`، فلا يُنزَّل أي خط أو صورة مرة ثانية ([تنضيد حزمة وإخراجها](/ar/docs/configuration#إخراج-حزمة-ورسمها)).

### 6 · ارسم الوجه الأمامي بعنوان واحد

```js
// script.js, سطرًا 28–51
const at = (x, y, width, edge = 'top-left') => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(y) }, size: { width: mm(width) } });
const text = (id, content, family, size, placement, look) => ({ kind: 'text', id, content,
  fontFamily: family, fontSize: pt(size), color: col('estuary'), overflow: 'wrap', // gotcha:
  placement, ...look }); // overflow-ellipsis-default
const caps = { fontFamily: LABEL, fontWeight: 700, textTransform: 'uppercase',
  letterSpacing: pt(1.15) };
const [MEASURE, EDGE] = [PAGE.w - 2 * PAGE.side, 17]; // mm; EDGE: trim to kicker and facts
const cover = { id: 'cover', advancedDesign: { enabled: true, slot: { elements: [
  { kind: 'image', id: 'art', resourceId: 'cover', placement: { anchor: { to: 'page',
    edge: 'top-left' }, size: { width: mm(PAGE.w), height: mm(PAGE.h) } } },
  text('kicker', '{attr.kicker}', LABEL, 7.5, at(PAGE.side, EDGE, MEASURE), caps),
  // The language tab: the edition's code on a blue flap hanging from the top edge.
  text('edition', '{attr.edition}', LABEL, 8, { anchor: { to: 'page', edge: 'top-right' },
    offset: { x: mm(-PAGE.side) } }, { ...caps, color: col('foam'), box: {
    backgroundColor: col('estuary'), padding: { top: mm(8), right: mm(2.4), bottom: mm(2.2),
      left: mm(2.4) } } }),
  text('title', '{titleText}', DISPLAY, 50, at(PAGE.side - 0.8, 25, MEASURE + 2),
    { italic: true, lineHeight: 0.96 }), // a multiple (gotcha: design-lineheight-multiple)
  text('lead', '{attr.lead}', TEXT, 11, at(PAGE.side + 2, WATER + 50, MEASURE - 4),
    { color: col('foam'), italic: true, lineHeight: 1.4 }),
  text('facts', '{attr.facts}', LABEL, 7.5, at(PAGE.side, -EDGE, MEASURE, 'bottom-left'),
    { ...caps, color: col('sand') }),
] } } };
```

الوجه الأمامي هو تصميم عنوان الغلاف: الرسم مثبّت إلى الصفحة بحجمه الكامل، ونص العنوان عبر `{titleText}`، والعنوان التمهيدي والمقدمة وسطر المعلومات من سماته، وعنصر نصي داخل إطار للسان اللغة. مستوى H1 مضبوط على `span: 'page'`، فيبدأ الرسم من حافة الورق. أما تصميم العنوان الذي يبقى داخل العمود فيُقصّ عند الحافتين العليا والسفلى لكتلة النص: كان الرسم سيبدأ على بعد 12 مم من الأعلى ويتوقف قبل الحافة السفلى بـ13 مم، وكان لسان اللغة سيضيق إلى شريط بلا حروف.

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

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

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

### script.js

```js
// ═══ Postext Cookbook · Nº 041 · .postext round trip in two languages ════════════
// https://postext.dev/en/cookbook/bundle-round-trip
// Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0)
// Fonts: DM Sans, DM Serif Display, Instrument Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import { createBundle, openBundle, loadBundleFonts, registerBundleImages, buildBundle,
  bundleFontProvider, bundleResourceBytes, defaultResourceTypes, renderPageToCanvas,
  clearMeasurementCache } from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { ink: '#172130', muted: '#56606c', // text; the colophon
  estuary: '#25476a', mud: '#8a6f4d', // the one accent; the wheel's wood in the drawings
  sand: '#e9dcc4', foam: '#eef2f3', rule: '#c4ced6', paper: '#ffffff' };
// The hex rides along: design elements read it, not the palette (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color': point it at the estuary blue.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.estuary })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, DISPLAY, LABEL] = ['DM Sans', 'DM Serif Display', 'Instrument Sans'];
const PAGE = { w: 99, h: 210, top: 12, bottom: 13, side: 10 }; // mm: a DL leaflet, both sides
const [BODY, LEAD] = [9.4, 13.4]; // pt
const WATER = 98; // mm from the top of the cover: where the sand ends and the estuary begins

// #region cover: the front of the leaflet, a heading drawn over one picture
const at = (x, y, width, edge = 'top-left') => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(y) }, size: { width: mm(width) } });
const text = (id, content, family, size, placement, look) => ({ kind: 'text', id, content,
  fontFamily: family, fontSize: pt(size), color: col('estuary'), overflow: 'wrap', // gotcha:
  placement, ...look }); // overflow-ellipsis-default
const caps = { fontFamily: LABEL, fontWeight: 700, textTransform: 'uppercase',
  letterSpacing: pt(1.15) };
const [MEASURE, EDGE] = [PAGE.w - 2 * PAGE.side, 17]; // mm; EDGE: trim to kicker and facts
const cover = { id: 'cover', advancedDesign: { enabled: true, slot: { elements: [
  { kind: 'image', id: 'art', resourceId: 'cover', placement: { anchor: { to: 'page',
    edge: 'top-left' }, size: { width: mm(PAGE.w), height: mm(PAGE.h) } } },
  text('kicker', '{attr.kicker}', LABEL, 7.5, at(PAGE.side, EDGE, MEASURE), caps),
  // The language tab: the edition's code on a blue flap hanging from the top edge.
  text('edition', '{attr.edition}', LABEL, 8, { anchor: { to: 'page', edge: 'top-right' },
    offset: { x: mm(-PAGE.side) } }, { ...caps, color: col('foam'), box: {
    backgroundColor: col('estuary'), padding: { top: mm(8), right: mm(2.4), bottom: mm(2.2),
      left: mm(2.4) } } }),
  text('title', '{titleText}', DISPLAY, 50, at(PAGE.side - 0.8, 25, MEASURE + 2),
    { italic: true, lineHeight: 0.96 }), // a multiple (gotcha: design-lineheight-multiple)
  text('lead', '{attr.lead}', TEXT, 11, at(PAGE.side + 2, WATER + 50, MEASURE - 4),
    { color: col('foam'), italic: true, lineHeight: 1.4 }),
  text('facts', '{attr.facts}', LABEL, 7.5, at(PAGE.side, -EDGE, MEASURE, 'bottom-left'),
    { ...caps, color: col('sand') }),
] } } };
// #endregion

const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity)
  // #region labels: the edition's language, written into the file with the rest of the config
  // Hyphenation patterns and the PDF's /Lang, by exact code (gotcha: hyphenation-locales).
  locale: t({ en: 'en-us', es: 'es' }),
  // Figura and Tabla travel inside the Spanish file. Left out, they follow whoever opens it:
  // the Sandbox at /en/sandbox prints Figure 1.1 (gotcha: bundle-labels-reader-locale).
  // '{n}' numbers them 1, 2, 3: a leaflet has no chapters to number its figures by.
  resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}' })),
  // #endregion
  colorPalette, customFonts,
  page: { sizePreset: 'custom', width: mm(PAGE.w), height: mm(PAGE.h), dpi: 150,
    margins: { top: mm(PAGE.top), bottom: mm(PAGE.bottom), left: mm(PAGE.side),
      right: mm(PAGE.side) } }, // a flyer printed both sides: nothing to mirror
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: mm(4),
    indentAfterHeading: false, minWordSpacing: 0.75, maxWordSpacing: 1.6 },
  headings: { fontFamily: DISPLAY, fontWeight: 400, levels: [ // in main-color: the estuary
    // The H1 break, restated (gotcha: headings-drop-h1-break). In the column, the cover design
    // is cut at the text block's top and bottom edges; span: 'page' paints it from the trim.
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' } },
    { level: 2, fontSize: pt(15), lineHeight: pt(LEAD * 1.25), marginTop: pt(LEAD * 0.5),
      marginBottom: pt(LEAD * 0.25) },
  ] },
  headingStyles: [cover],
  captionStyle: { fontFamily: LABEL, fontSize: pt(7.8), labelColor: col('estuary'), gap: mm(1.8) },
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackground: col('estuary'), headerColor: col('paper'), headerFontFamily: LABEL,
    headerFontSize: pt(7.6), bodyFontSize: pt(8.2), cellPadding: mm(1.3) },
  paragraphStyles: [{ id: 'colophon', fontSize: pt(6.6), lineHeight: pt(8.8),
    color: col('muted'), textAlign: 'left', firstLineIndent: mm(0), marginTop: pt(LEAD) }],
  header: { elements: [] },
  // The back's foot: a strip of estuary with the publisher, the frontmatter's author.
  footer: { elements: [
    { kind: 'box', id: 'strip', pages: 'body', style: { backgroundColor: col('estuary') },
      placement: { anchor: { to: 'page', edge: 'bottom-left' },
        size: { width: 'fill', height: mm(7) } } },
    text('foot', '{author}', LABEL, 7.5, { anchor: { to: 'page', edge: 'bottom-left' },
      offset: { x: mm(PAGE.side), y: mm(-2.4) } }, { ...caps, color: col('foam'),
      pages: 'body', overflow: 'clip' }),
  ] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "The Tide Mill of Arenal"
author: "Arenal Estuary Trust"
---

# The Tide Mill {style="cover" kicker="Arenal estuary · Visitor leaflet 3" lead="For 165 years the tide turned its four wheels. Walk the dam and look down into the wheel pit, where the pond empties twice a day." facts="Open all year · Free on Sundays" edition="EN"}

:::pagebreak

::resource{id="mill"}

## Two tides a day

On the flood tide the sea pushes open the gates in the dam and fills the millpond behind it, six hectares of salt water; when the tide turns, the water inside presses them shut. Two hours after high water, the estuary has fallen far enough for the miller to open the chutes (:ref{id="mill" style="full"}). The water drops onto a horizontal wheel in the vaulted pit under the floor, and an upright shaft turns the millstones above it.

The mill ground maize and wheat for the farms of the valley from 1791 until 1956. Restored in 2004, it grinds again on demonstration days, two hours after high water (:ref{id="hours" style="full"}).

::resource{id="hours"}

:::space{lines=0.5}

The path on the dam is flat enough for wheelchairs, and eleven steps lead down to the wheel pit. Tickets cost €3; entry is free on Sundays.

:::paragraphs{style="colophon"}
Leaflet 3, English edition · Text and drawings CC BY 4.0 · Set in DM Sans, DM Serif Display and Instrument Sans (SIL Open Font License).
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region art: the cover's wheel in the estuary, and the mill in section
// No words in the drawings: an SVG drawn as an image cannot use web fonts (gotcha:
// svg-no-webfonts). Every length is in millimetres of the printed page.
const SECTION = { w: 79, h: 35 }; // the mill in section, as wide as the text
const n = (v) => +v.toFixed(2);
const svgDoc = (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 circle = (x, y, r, fill, extra = '') => `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" `
  + `fill="${fill}"${extra}/>`;
const path = (d, fill, extra = '') => `<path d="${d}" fill="${fill}"${extra}/>`;
const line = (d, color, width, extra = '') => path(d, 'none', ` stroke="${color}" `
  + `stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"${extra}`);
const group = (x, y, turn, body) => `<g transform="translate(${n(x)} ${n(y)}) `
  + `rotate(${n(turn)})">${body}</g>`;
// A wave line across the page: cubic arcs of wavelength `len`, `amp` high.
const wave = (y, len, amp, phase, width) => {
  let d = `M${n(-phase)} ${n(y)}`;
  for (let x = -phase; x < width + len; x += len) {
    const [q, h] = [x + len / 4, x + 3 * len / 4];
    d += `C${n(q)} ${n(y - amp)} ${n(q)} ${n(y - amp)} ${n(x + len / 2)} ${n(y)}`
      + `C${n(h)} ${n(y + amp)} ${n(h)} ${n(y + amp)} ${n(x + len)} ${n(y)}`;
  }
  return d;
};
// The wheel: a hub and eighteen blades, each a spoon on a spoke, the spoon bent back against
// the turn; the square end of the shaft at the centre.
function wheel(cx, cy, r, color, extra = '') {
  const spoke = `M${n(r * 0.28)} ${n(-r * 0.018)}H${n(r * 0.54)}V${n(r * 0.018)}H${n(r * 0.28)}Z`;
  const spoon = `M0 0C${n(r * 0.1)} ${n(-r * 0.08)} ${n(r * 0.36)} ${n(-r * 0.12)} ${n(r * 0.46)} `
    + `${n(-r * 0.05)}C${n(r * 0.5)} ${n(-r * 0.01)} ${n(r * 0.44)} ${n(r * 0.06)} ${n(r * 0.3)} `
    + `${n(r * 0.06)}C${n(r * 0.18)} ${n(r * 0.06)} ${n(r * 0.06)} ${n(r * 0.03)} 0 0Z`;
  const blade = path(spoke, color) + group(r * 0.52, 0, -16, path(spoon, color));
  let out = '';
  for (let i = 0; i < 18; i++) out += group(cx, cy, i * 20, blade);
  const ring = ` stroke="${palette.sand}" stroke-width="${n(r * 0.03)}"`;
  return `<g${extra}>${out}${circle(cx, cy, r * 0.31, color)}`
    + `${circle(cx, cy, r * 0.22, 'none', ring)}`
    + `<rect x="${n(cx - r * 0.06)}" y="${n(cy - r * 0.06)}" width="${n(r * 0.12)}" `
    + `height="${n(r * 0.12)}" fill="${palette.sand}"/></g>`;
}
function coverArt() {
  const [cx, r] = [PAGE.w / 2, 37];
  let body = `<rect width="${PAGE.w}" height="${WATER}" fill="${palette.sand}"/>`;
  // The mud flat the ebb leaves: three bands above the waterline, darker towards the water.
  for (const [y, h, o] of [[WATER - 15, 3, 0.1], [WATER - 10, 4, 0.16], [WATER - 5, 5, 0.24]]) {
    body += `<rect y="${y}" width="${PAGE.w}" height="${h}" fill="${palette.mud}" `
      + `fill-opacity="${o}"/>`;
  }
  body += wheel(cx, WATER, r, palette.estuary);
  body += `<rect y="${WATER}" width="${PAGE.w}" height="${PAGE.h - WATER}" `
    + `fill="${palette.estuary}"/>`;
  // Under the water the wheel shows as a pale ghost: the same drawing, clipped to the water.
  body += `<clipPath id="under"><rect y="${WATER}" width="${PAGE.w}" height="${PAGE.h}"/>`
    + `</clipPath>${wheel(cx, WATER, r, palette.foam, ' clip-path="url(#under)" opacity=".2"')}`;
  for (const [dy, phase, o] of [[3, 0, 0.5], [10, 4, 0.3], [18, 8, 0.2], [28, 2, 0.12]]) {
    body += line(wave(WATER + dy, 11, 0.9, phase, PAGE.w), palette.foam, 0.7,
      ` stroke-opacity="${o}"`);
  }
  return svgDoc(PAGE.w, PAGE.h, body);
}
// A level mark: the surveyor's triangle standing on a water surface.
const level = (x, y, fill) => path(`M${n(x - 1.4)} ${n(y - 2.2)}H${n(x + 1.4)}L${n(x)} ${n(y)}Z`,
  fill, fill === 'none' ? ` stroke="${palette.estuary}" stroke-width=".3"` : '');
const arrow = (d, tip, turn, color) => line(d, color, 0.55)
  + group(...tip, turn, line('M-1.6-1L0 0-1.6 1', color, 0.55));
function sectionArt() {
  const { w, h } = SECTION;
  const [HIGH, LOW, FLOOR, WHEEL] = [10, 26.5, 15.5, 27]; // mm: levels, floor and wheel heights
  const P = palette;
  let b = '';
  // Water first: the pond held at high tide, the estuary fallen to low water.
  b += path(`M0 ${HIGH}H31V33H0Z`, P.estuary);
  b += path(`M52 ${LOW}H${w}V${h}H52Z`, P.estuary);
  b += line(`M52 ${HIGH}H${w - 1}`, P.estuary, 0.35, ' stroke-dasharray="1.4 1"');
  // The ground: the pond's bed and the estuary's mud bank.
  b += path(`M0 33L31 32V${h}H0Z`, P.mud);
  b += path(`M52 32.5L${w} 34V${h}H52Z`, P.mud);
  // The dam and the mill house on it, in sand with a mud outline; the roof in mud.
  const stroke = ` stroke="${P.mud}" stroke-width=".45"`;
  b += path(`M30 ${h}V5.6H54V${h}Z`, P.sand, stroke);
  b += path('M28.5 6L42 0.4L55.5 6Z', P.mud);
  // The wheel pit: a vaulted opening through the dam, with the ebb running out of it.
  b += path(`M33.5 ${h}V25A8 8 0 0 1 49.5 25V${h}Z`, P.paper, stroke);
  b += path('M33.5 30.5H55V33.5H33.5Z', P.estuary);
  // The chute from the pond onto the wheel, and the gate lifted above its mouth.
  b += path(`M30 22L36.4 ${WHEEL - 1.2}`, 'none', ` stroke="${P.estuary}" stroke-width="1.8"`);
  b += `<rect x="29.2" y="16.8" width="1.6" height="4" fill="${P.ink}"/>`;
  // The horizontal wheel, and its shaft up through the floor to the runner stone.
  b += line(`M41.5 ${FLOOR}V${WHEEL + 1}`, P.ink, 0.6);
  b += `<rect x="35.8" y="${WHEEL - 0.8}" width="11.4" height="1.6" rx=".5" fill="${P.mud}"/>`;
  for (let x = 36.6; x < 47; x += 1.6) {
    b += line(`M${n(x)} ${WHEEL - 1.4}V${WHEEL + 1.2}`, P.mud, 0.45); // the blades, edge-on
  }
  // The milling floor, the runner stone on the bed stone, and the hopper above them.
  b += line(`M31 ${FLOOR}H53`, P.mud, 0.45);
  const stone = (x, y, sw) => `<rect x="${x}" y="${n(y)}" width="${sw}" height="1.6" `
    + `fill="${P.rule}" stroke="${P.ink}" stroke-width=".3"/>`;
  b += stone(36.5, FLOOR - 3.2, 10) + stone(36, FLOOR - 1.6, 11);
  b += path(`M38.6 8H44.4L42.6 ${FLOOR - 4.2}H40.4Z`, P.mud);
  // Level marks, and the way the water goes.
  b += level(6, HIGH, P.estuary) + level(73, LOW, P.estuary) + level(73, HIGH, 'none');
  b += arrow('M9 27C16 26 22 24.4 27.4 23', [27.4, 23], -15, P.foam);
  b += arrow('M50.5 32H63', [63, 32], 0, P.foam);
  return svgDoc(w, h, b);
}
// fileId → markup: the files the resources below name.
const drawings = { 'cover.svg': coverArt(), 'mill.svg': sectionArt() };
// #endregion

// #region resources: the drawings name their files by fileId; the table carries its own data
const svg = (id, w, h, altText, extra) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0,
  updatedAt: 0, altText, svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, ...extra });
const row = (...cells) => cells.map((content) => ({ content }));
const head = (...cells) => cells.map((content) => ({ content, isHeader: true }));
const resources = [
  svg('cover', PAGE.w, PAGE.h, t({ en: 'A mill wheel on the waterline, its lower half pale '
    + 'under the estuary', es: 'Una rueda de molino en la línea del agua, con la mitad '
    + 'inferior pálida bajo la ría' })),
  svg('mill', SECTION.w, SECTION.h, t({
    en: 'The mill in section: the pond at high level on the left, the mill house on the dam '
      + 'with its millstones, the horizontal wheel in the vaulted pit, and the estuary on the '
      + 'right below a dashed high-water line',
    es: 'El molino en sección: el estanque a nivel alto a la izquierda, la casa del molino sobre '
      + 'la presa con sus muelas, el rodezno en el cárcavo abovedado y la ría a la derecha, bajo '
      + 'una línea discontinua de pleamar' }), {
    placement: { position: 'here' },
    caption: t({ en: 'Two hours after high water: the pond turns the wheel, and the estuary '
      + 'has fallen below the dashed line.',
    es: 'Dos horas tras la pleamar: el estanque mueve el rodezno y la ría ha quedado por debajo '
      + 'de la línea discontinua.' }) }),
  { id: 'hours', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
    placement: { position: 'here' },
    caption: t({ en: 'Opening hours. Last entry 45 minutes before closing.',
      es: 'Horario. Última entrada 45 minutos antes del cierre.' }),
    table: { model: { headerRowCount: 1, columnWidths: [1.55, 0.9, 1.55], rows: t({
      en: [head('Season', 'Days', 'Hours'),
        row('April–June', 'Tue–Sun', '10:00–14:00, 16:00–19:00'),
        row('July–August', 'Mon–Sun', '10:00–20:00'),
        row('September–March', 'Fri–Sun', '10:30–14:30')],
      es: [head('Temporada', 'Días', 'Horario'),
        row('Abril–junio', 'Mar.–dom.', '10:00–14:00 y 16:00–19:00'),
        row('Julio–agosto', 'Lun.–dom.', '10:00–20:00'),
        row('Septiembre–marzo', 'Vie.–dom.', '10:30–14:30')] }) } } },
];
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the pages use. They travel inside the bundle, so the reader loads them from
// there, before the layout (gotcha: fonts-first).
const FONTS = { 'DM Sans': ['400', '400i', '700'], 'DM Serif Display': ['400', '400i'],
  'Instrument Sans': ['400', '700'] };

// #region faces: FONTS as customFonts, each face a woff2 file named by its fileId
const customFonts = Object.entries(FONTS).map(([name, specs]) => ({ name,
  variants: specs.map((spec) => ({ weight: parseInt(spec, 10), format: 'woff2',
    style: spec.endsWith('i') ? 'italic' : 'normal', fileId: `${fontsourceId(name)}-${spec}` })),
}));
// The bytes: Fontsource's static woff2 files, latin subset, which covers the Spanish text too.
const faceFiles = Object.fromEntries(await Promise.all(customFonts.flatMap(({ name, variants }) =>
  variants.map(async ({ weight, style, fileId }) => {
    const id = fontsourceId(name);
    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 ${name} ${weight} ${style}`);
    return [fileId, new Uint8Array(await res.arrayBuffer())];
  }))));
// #endregion

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region answer: write this edition to a .postext file, then lay it out from those bytes alone
// The writer: text, design, resources and every file they name, zipped. createBundle looks
// up each fileId (a drawing's svg.fileId, a face's variant fileId) in `files`.
const { bytes, warnings } = await createBundle({
  name: t({ en: 'The Tide Mill of Arenal', es: 'El molino de mareas de Arenal' }),
  locale: LANG, // one language per bundle: createBundle 1.4.1 writes no translations
  markdown, config: config(), resources,
  files: { ...drawings, ...faceFiles },
  thumbnail: { data: drawings['cover.svg'], mime: 'image/svg+xml' }, // the book's picture
});
if (warnings.length) console.warn(warnings); // what was left out, and why

// The reader has nothing but the bytes. Each fileId is now the file's path inside the zip:
// mill.svg is resources/mill.svg, and the faces sit under fonts/.
const bundle = await openBundle(bytes);
await loadBundleFonts(bundle); // one FontFace per face from the file, in place of loadFonts()
await registerBundleImages(bundle); // the drawings, for the canvas
const docs = buildBundle(bundle); // one VDTDocument per chapter: a leaflet has one
// #endregion
showPages(docs, { title: t({ en: 'The Tide Mill · English edition',
  es: 'El molino de mareas · edición en español' }) });

// #region handoff: the same bytes as a download for the Sandbox, and a PDF from the bundle
const file = `tide-mill-${LANG}.postext`;
document.getElementById('pt-actions').append(Object.assign(document.createElement('a'), {
  href: URL.createObjectURL(new Blob([bytes], { type: 'application/zip' })), download: file,
  textContent: `Download ${file} · ${Math.round(bytes.length / 1024)} KB` }));
// The PDF embeds the faces the bundle carries, and draws the figures from its files.
offerPdf(() => renderToPdf(docs, {
  fontProvider: bundleFontProvider(bundle, { decodeWoff2: decompressWoff2 }),
  resourceBytes: bundleResourceBytes(bundle),
}), `${RECIPE}-${LANG}.pdf`);
// #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 · pdf v1 ── the same in every recipe that exports a PDF ──────────────
/** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen
 *  used, snapping to a weight the family ships and falling back to upright
 *  when it has no italic: the PDF asks for every face a block could use. */
async function fontsourceProvider(family, weight, style) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  const weights = meta?.weights?.length ? meta.weights : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style;
  const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`);
  if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`);
  return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
}

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

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

## تنويعات

### اقرأ حزمة ثنائية اللغة

يحمل دليل Postext فصوله وإعداداته وتعليقاته الإنجليزية والإسبانية في ملف واحد؛ ويختار `{ locale }` الطبعة، فتُفتح في اثني عشر فصلًا على 48 صفحة بأي من اللغتين، بينما يظل الرابط يقدّم ملف المطوية ويبني زر PDF الدليل.

```diff
-const bundle = await openBundle(bytes);
+const guide = await fetch('https://postext.dev/bundles/postext-guide.postext');
+const bundle = await openBundle(await guide.arrayBuffer(), { locale: LANG });
```

### دع القارئ يختار التسميات

من دون الأنواع تتبع التسميات اللغة التي يُفتح بها الملف، وتبدأ الأشكال العدّ من عنوان الغلاف: تطبع الطبعة الإسبانية هنا Figura 1.1، وتطبع Figure 1.1 حين يستوردها Sandbox على /en/sandbox.

```diff
-  resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}' })),
```

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

- **حمّل كل أوجه الخط قبل الإخراج.** يقيس الإخراج النص بأوجه الخط التي حمّلها المتصفح ويخزّن العروض مؤقتًا، فالوجه الذي يصل بعد البناء الأول يترك فواصل أسطر خاطئة وملف PDF لم يعد يطابق الشاشة. حمّل كل وزن وكل نمط أولًا، واستدعِ clearMeasurementCache() قبل إعادة البناء إذا تأخر وصول أحدها.
- **الحزمة بلا resourceTypes تسمّي الأشكال بلغة القارئ.** حين لا يحمل ملف .postext الإعداد resourceTypes، يبني openBundle في postext 1.4.1 تسميتي Figure وTable باللغة التي يطلبها القارئ، لا بلغة الملف نفسه، ويستورد Sandbox كل ملف بلغة واجهته. فالحزمة الإسبانية المفتوحة بـ { locale: 'en' }، أو المستوردة في /en/sandbox، تطبع Figure 1.1 فوق نص إسباني بينما لا يزال bundle.locale يقول 'es'. اكتب resourceTypes: defaultResourceTypes(lang) في الإعداد الذي تمرّره إلى createBundle.
- **8 لغات فقط تُقسَّم بالواصلة، بالرمز المطابق تمامًا.** يتوفر تقسيم الكلمات بالواصلة للغات en-us وes وfr وde وit وpt وca وnl، بمطابقة تامة للرمز: 'es-ES' أو أي لغة أخرى تعود دون تنبيه إلى الإنجليزية الأمريكية.
- **أي كائن headings يُلغي فاصل الصفحة قبل H1.** ينتقل H1 افتراضيًا إلى صفحة فردية (always-odd)، لكن تمرير أي كائن headings يعيد ضبط هذا الافتراض، فتتوالى الفصول دون فاصل ولا يفعل span: 'page' شيئًا. أعد كتابة headings.levels[0].breakBefore: { enabled: true, parity } في كل إعداد.
- **الشكل داخل النص يحصل على فراغ فوقه لا تحته.** في postext 1.4.1 يحصل الشكل الذي يضعه ::resource في الموضع 'here' على سطر شبكة واحد من الفراغ فوقه، أما تحته فلا يحصل إلا على ما يتبقى حين يلتصق السطر التالي بشبكة خطوط الأساس: من سطر كامل إلى لا شيء تقريبًا، فقد تبدأ الفقرة التالية تحت التعليق مباشرةً. أتبِع سطر ::resource بـ :::space{lines=1}؛ وهو، مثل أي :::space، يُحذف في أعلى العمود.
- **النص داخل SVG في <img> لا يستطيع استخدام خطوط الويب.** يُرسَم SVG صورةً، والصورة لا تصل إلى خطوط الويب في الصفحة، فتعود تسمياته إلى خط من النظام. حوّل النص إلى مسارات، أو ضمّن مجموعة فرعية بـ @font-face داخل SVG، أو انقل التسميات إلى التعليق.
- **لوحة الألوان المستبدلة لا تصل إلى عناصر التصميم ولا إلى لون الإحالة.** يقرأ postext 1.4.1 الإعداد colorPalette في أنماط النص (المتن والعناوين والقوائم والتعليقات والجداول والإطارات) لكن لا في عناصر الترويسات والتذييلات والافتتاحيات وصفحات الأجزاء، ولا في bodyText.referenceColor: تحتفظ بالقيمة الست عشرية المكتوبة بجانب paletteId الخاص بها. حين تستبدل لوحة الألوان، لنسخة شاشة داكنة أو لإعادة تلوين، أعِد كتابة كل لون مرتبط من colorPalette قبل البناء.
- **lineHeight لنص التصميم مُضاعِف، لا بُعد أبدًا.** في خانة التصميم، يضاعف lineHeight لعنصر النص مقاس خطه (lineHeight: 1.05). في postext 1.4.1 لا يُرفض بُعدٌ مثل pt(15): يُقاس ارتفاع الافتتاحية NaN، ويسقط ما تحجزه من مساحة، بما فيه minHeight، دون تحذير، فيجري النص تحت العنوان.
- **فيض نص التصميم افتراضيًا 'ellipsis-end'.** عنصر نص التصميم الذي لا يتسع له عرضه ينتهي افتراضيًا بعلامة الحذف. اضبط overflow: 'wrap' للعناوين التي يجب أن تنكسر على أسطر أكثر.
- **يُخزَّن الإعداد مؤقتًا بحسب هويته: ابنِ كائنًا جديدًا.** يخزّن المحرّك الإعدادات المحسوبة مؤقتًا بحسب هوية الكائن، فتعديل الإعداد في مكانه ثم البناء مجددًا يعيد استخدام النتيجة القديمة. ابنِ كائنًا جديدًا في كل بناء، ولهذا يكون إعداد الوصفة دالة مصنِّعة: config().

- تكتب `createBundle` في postext 1.4.1 لغة واحدة في كل ملف. المدخل `localized` الموصوف في [الحزم ثنائية اللغة](/ar/docs/configuration#الحزم-الثنائية-اللغة) ليس في ذلك الإصدار، مع أن `openBundle` 1.4.1 تقرأ مثل تلك الملفات.

## الحقوق

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

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

- [رقم 011 · مصدر واحد وطبعتان للورق والشاشة](https://postext.dev/ar/cookbook/print-and-screen-editions.md): الطبعتان من إعداد واحد: يحمل htmlViewer.overrides تصميم الشاشة الداكن، ويدمجه applyHtmlViewerOverrides قبل كل بناء HTML. · المستوى 3 (متقدم) · المجلات والمجلات المستقلة
- [رقم 025 · ملف PDF حقيقي تُضمَّن فيه الخطوط نفسها](https://postext.dev/ar/cookbook/pdf-with-embedded-fonts.md): برنامج حفل موسيقي مُصدَّر إلى PDF. يُجلب كل خط مرة واحدة، لـ FontFace وللـ PDF، ويصير كل عنوان علامة مرجعية. · المستوى 2 (متوسط) · الأوراق المفردة والمطبوعات العابرة
- [رقم 007 · كتاب واحد من فصول منفصلة](https://postext.dev/ar/cookbook/book-from-chapters.md): ينضّد buildBundle خمسة ملفات Markdown كتابًا واحدًا: يبدأ كل فصل على صفحة فردية، وتتواصل أرقام الصفحات والفصول والأشكال من ملف إلى آخر. · المستوى 3 (متقدم) · الأدلة والمراجع
