# PDF جاهز للطباعة: النزف وعلامات القص وCMYK

> مطوية معرض مُخرجة بنزف 3 مم وعلامات قص، ومصدَّرة إلى PDF بألوان CMYK يأخذ خرائطه ومخطط طابقه من نُسخ طباعة أصلية.

- نسخة HTML: https://postext.dev/ar/cookbook/print-ready-pdf
- وصفة رقم 024 · الإخراج والدمج · المستوى 3 (متقدم) · المخرجات: Canvas, PDF
- الأنواع: الأوراق المفردة والمطبوعات العابرة
- تتطلب 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/print-ready-pdf/es/p01.webp?v=cb185f61), [2](https://postext.dev/cookbook/print-ready-pdf/es/p02.webp?v=cb185f61), [3](https://postext.dev/cookbook/print-ready-pdf/es/p03.webp?v=cb185f61), [4](https://postext.dev/cookbook/print-ready-pdf/es/p04.webp?v=cb185f61)
- PDF: https://postext.dev/cookbook/print-ready-pdf/es/print-ready-pdf.pdf?v=cb185f61
- افتح في Sandbox: https://postext.dev/ar/sandbox#recipe=print-ready-pdf&lang=es (.postext: https://postext.dev/cookbook/print-ready-pdf/es/print-ready-pdf.postext)
- آخر تحديث: 2026-09-26
- لغات أخرى: [en](https://postext.dev/en/cookbook/print-ready-pdf.md), [es](https://postext.dev/es/cookbook/print-ready-pdf.md), [ca](https://postext.dev/ca/cookbook/print-ready-pdf.md), [zh](https://postext.dev/zh/cookbook/print-ready-pdf.md)

## باختصار

مطوية لمعرض خرائط، جاهزة للإرسال إلى المطبعة. تبيّن كيف تضيف مساحة زائدة خلف حافة الصفحة وعلامات القص، وتحفظ ملف PDF بالألوان التي تستعملها آلات الطباعة.

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

دليل القاعات لمعرض *Cartografías imaginarias*، معرض متخيَّل لخرائط أماكن خيالية، كما يذهب إلى المطبعة، بالإسبانية في طبعتي هذه الصفحة كلتيهما. كل صفحة بقياس 170 × 227 مم تقع على ورقة أكبر بـ 22 مم تحمل النزف وعلامات القص. على الغلاف يعبر مسار برتقالي أرخبيلًا بدرجات خضراء متدرجة ويخرج عن حدّ القص إلى النزف. وفي الداخل تنفتح أربع قاعات بخط Karla غير المضبوط تحت عناوين صغيرة على هيئة نقاط مسار، ويحمل لسان برتقالي رقم الصفحة خارج الحافة الأمامية؛ ويضع الغلاف الخلفي مخطط طابق فوق شريط من التضاريس. يصدّر مثال CodePen ملف PDF للمطبعة، بألوان CMYK مع خطوطه وإشاراته المرجعية وتسميات صفحاته، وبروفة بتدرج الرمادي. في ملف PDF المطبعة تأتي الخرائط والمخطط من نُسخ طباعة أصلية بأحبار مختارة باليد؛ أما الحروف واللسان وحلقات نقاط المسار فتأخذ التحويل البسيط.

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

- كيف أصدّر ملف PDF جاهزًا للطباعة بالنزف وعلامات القص وألوان CMYK والإشارات المرجعية وتسميات الصفحات؟
- كيف أصدّر ملف PDF حقيقيًّا في المتصفح مع تضمين الخطوط؟
- كيف أضيف علامة مائية أو لونًا خفيفًا للخلفية أو صورة زخرفية في كل صفحة؟

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

```js
// script.js, سطرًا 37–59
const BLEED = 3; // mm of artwork past the trim: the cover, the tab and the back band reach it
// Hook-up: config().page.cutLines. A mark starts markOffset outside the trim and runs 5 mm
// (markLength): an offset equal to the bleed keeps the marks off the artwork whatever BLEED
// is. Each page grows by doc.trimOffset a side, bleed + offset + mark: 11 mm here.
const cutLines = { enabled: true, bleed: mm(BLEED), markOffset: mm(BLEED) };

// renderToPdf ignores config.pdfGeneration, so every PDF setting goes in its options.
// `masters` holds print-master bytes by fileId (section 2 writes them).
function pressPdf(doc, colorSpace, { resources, masters }) {
  // A resource names its master in svg.pdfFileId, but outside bundles renderToPdf only asks
  // for svg.fileId: answer that id with the master (gotcha: pdf-master-resourcebytes).
  const masterOf = new Map(resources.filter((r) => r.svg?.pdfFileId)
    .map((r) => [r.svg.fileId, r.svg.pdfFileId]));
  return renderToPdf(doc, {
    // The kit's provider snaps weights and falls back to upright, since the PDF asks for every
    // style of every family (gotcha: pdf-provider-all-styles); TrueType faces are subset.
    fontProvider: fontsourceProvider,
    colorSpace, // 'cmyk' for the press, 'grayscale' for a proof: a naive conversion, no ICC
    // The proof keeps the SVGs, which its grey conversion can reach; masters stay in CMYK.
    resourceBytes: (fileId) => (colorSpace === 'cmyk' && masters.get(masterOf.get(fileId)))
      || imageBytes(fileId),
  }); // bookmarks (from the headings) and /PageLabels (from the folios) come by default
}
```

## المكونات

**تعلّم**

- [النزف وعلامات القص](https://postext.dev/ar/docs/configuration.md#خطوط-القص): يوسّع الورقة بمقدار النزف، ويرسم علامات القص عند الزوايا في اللوحة (Canvas) وفي PDF، ويتيح تثبيت عناصر التصميم على حدّ النزف.
- [ملفات PDF بنظام CMYK وبالتدرج الرمادي](https://postext.dev/ar/docs/configuration.md#إنتاج-pdf-الإعدادات): تحمل الألوان نموذجها (hex، rgb، cmyk، hsl)، ويمكن فرض CMYK أو التدرج الرمادي على PDF للمطبعة أو لبروفة.
- [نسخ SVG الأصلية للطباعة](https://postext.dev/ar/docs/sandbox.md#presetjson): ملف PDF متجهي يضمّنه مُخرِج PDF كما هو بدل شكل SVG، لرسوم مطابقة لملف الرسّام.

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

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

**واجهات API**

- [`buildDocument`](https://postext.dev/ar/docs/configuration.md#بناء-مستند), [`clearMeasurementCache`](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#أنواع-الموارد), [`registerResourceImage`](https://postext.dev/ar/docs/architecture.md#واجهة-البرمجة-المتاحة-api), [`renderPageToCanvas`](https://postext.dev/ar/docs/configuration.md#رسم-صفحة-في-صورة-نقطية), [`renderToPdf`](https://postext.dev/ar/docs/configuration.md#توليد-ملفات-pdf)

**الخطوط**

- Karla (OFL-1.1), Space Grotesk (OFL-1.1), Space Mono (OFL-1.1)

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

### 1 · اختر ألوان الشاشة بحسب ألواح الطباعة

```js
// script.js, سطرًا 18–33
const palette = {
  ink: '#161616', // text: a neutral grey prints on the black plate alone (K91)
  muted: '#666666', // the running heads, on black alone for the same reason (K60)
  forest: '#0b3d2e', // the sea, the kickers, the back band: its plain build is a petrol teal
  signal: '#ff6626', // route, waypoints, tab: red at full strength, so no black (C0 M60 Y85)
  sage: '#9fb8a8', // high ground; small type on forest
  paper: '#f4f1ea', // type on forest
};
// col(id): a palette-linked colour. It carries the hex too, because 1.4.1 paints design
// elements from the hex (gotcha: palette-skips-designs).
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' } })),
  // The engine's defaults link to 'main-color': point it at the forest, so nothing prints blue.
  { id: 'main-color', name: 'forest (defaults)', value: { hex: palette.forest, model: 'hex' } },
];
```

مع `colorSpace: 'cmyk'` يحوّل postext-pdf كل قيمة ست عشرية بصيغة بسيطة دون ملف تعريف ICC: الأسود يساوي 1 ناقص أعلى قناة RGB، والسماوي والأرجواني والأصفر يأتي كل منها من قناته ومن ذلك الأسود. لذا يُطبع الرمادي المحايد على لوح الأسود وحده (`#161616` يصير K91)، واللون الذي تكون إحدى قنواته بكامل قوتها يُطبع بلا أسود: البرتقالي `#ff6626` يصير C0 M60 Y85 K0 في اللسان والحلقات والمسار. ويخرج الأرجواني صفرًا كلما كان الأخضر أعلى القنوات، فيصير لون الغابة C82 M0 Y25 K76، وتُظهره البروفة أزرق مخضرًا بلون البترول؛ لهذا تأتي الخرائط والمخطط من نُسخ طباعة أصلية (الخطوة 5)، ولا يبقى التركيب بالأحبار الثلاثة إلا على حروف الغابة الصغيرة العريضة في العناوين الصغيرة وإحالة الشكل وتسميات التعليقات.

### 2 · لسان حافة يصمد أمام المقصلة

```js
// script.js, سطرًا 69–90
const TAB = { w: 10, h: 24 }; // the tab as it will be trimmed, mm; its foot is level with the text
const tab = (parity) => {
  const edge = parity === 'even' ? 'bottom-left' : 'bottom-right'; // the fore-edge
  const from = (to, y) => ({ anchor: { to, edge }, offset: { y: mm(y) } });
  return [
    // The box hangs from the bleed frame and BLEED of its width is trimmed off, so a cut that
    // lands a millimetre outside the trim still leaves orange at the edge.
    { kind: 'box', id: `tab-${parity}`, style: { backgroundColor: col('signal') },
      placement: { ...from('bleed', -(FOOT + BLEED)), size: { width: mm(TAB.w + BLEED),
        height: mm(TAB.h) } } },
    // The folio hangs from the trim frame ('page'), so it centres on the part left after the cut.
    { kind: 'text', id: `folio-${parity}`, content: '{pageNumber}', ...label, align: 'center',
      fontSize: pt(11), letterSpacing: pt(0), color: col('ink'),
      placement: { ...from('page', -FOOT), size: { width: mm(TAB.w), height: mm(TAB.h) } } },
  ].map((element) => ({ ...element, parity, pages: 'body' })); // never on the covers
};
const head = (parity, content) => ({ kind: 'text', id: `head-${parity}`, content, ...label,
  color: col('muted'), parity, pages: 'body',
  placement: { anchor: { to: 'container', edge: parity === 'even' ? 'top-left' : 'top-right' },
    offset: { y: mm(11) } } });
const header = { elements: [head('even', '{title}'), head('odd', '{subtitle}')] };
const footer = { elements: [...tab('even'), ...tab('odd')] };
```

يتدلى إطار اللسان من إطار النزف، وهو أعرض من اللسان النهائي بمقدار `BLEED`، فالقصّة التي تقع على بعد مليمتر خارج حدّ القص تترك مع ذلك برتقاليًا عند الحافة؛ ويتدلى رقم الصفحة من إطار الصفحة، وهو حدّ القص، ويتوسط الـ 10 مم الباقية. يختار `parity` الصفحات التي يظهر فيها كل عنصر، ويتغير `edge` معه ليبلغ الحافة الأمامية، بينما يُبقي `pages: 'body'` اللسانَ والترويسات (`{title}` و`{subtitle}` من البيانات التمهيدية (frontmatter)) بعيدًا عن الغلافين، اللذين يُعدّان صفحتي افتتاح لأن كلًّا منهما يبدأ بعنوان يمتد بعرض الصفحة أو ينتقل إلى صفحة جديدة. وأي عنصر في الترويسة أو التذييل يتكرر بهذه الطريقة، فعنصر `image` مع `parity` و`pages` يضع علامة مائية أو صورة زخرفية في كل صفحة؛ والخانات تُرسم فوق النص، والخلفية الملوّنة خلفه مكانها `page.backgroundColor`.

### 3 · الرسوم حتى النزف، والحروف داخل حدّ القص

```js
// script.js, سطرًا 108–152
const BAND = 44; // mm from the trim foot to the top of the back band
const onForest = { color: col('paper'), align: 'left', overflow: 'wrap' };
const art = (resourceId, edge) => ({ kind: 'image', id: resourceId, resourceId,
  placement: { anchor: { to: 'bleed', edge }, size: { width: 'fill' } } }); // height: its ratio
// The map reserves no height (gotcha: opener-image-no-reserve), so the text puts a
// :::pagebreak right after the cover heading: without it the lead would start on the map.
// span: 'page' makes the cover an opener, so furniture set to pages: 'body' skips it.
const cover = { id: 'cover', numbered: false, span: 'page', advancedDesign: { enabled: true,
  slot: { elements: [ // paint order: the map first, the type on top
    art('cubierta', 'top-left'),
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...label, color: col('sage'),
      placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(6) } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Space Grotesk',
      fontWeight: 700, fontSize: pt(50), lineHeight: 0.98, ...onForest,
      placement: { anchor: { to: '#kicker', edge: 'below' }, offset: { y: mm(4) },
        size: { width: mm(132) } } },
    { kind: 'text', id: 'subtitle', content: '{subtitle}', fontFamily: 'Karla', fontSize: pt(13),
      ...onForest, placement: { anchor: { to: '#title', edge: 'below' }, offset: { y: mm(5) } } },
  ] } },
  footer: { elements: [ // the cover's own foot: dates and place, over open sea
    { kind: 'text', id: 'dates', content: '{attr.fechas}', ...label, color: col('paper'),
      placement: { anchor: { to: 'container', edge: 'bottom-left' }, offset: { y: mm(-15) } } },
    { kind: 'text', id: 'place', content: '{attr.lugar}', ...label, color: col('sage'),
      placement: { anchor: { to: '#dates', edge: 'below' }, offset: { y: mm(1.6) } } },
  ] } };
const back = { id: 'back', numbered: false, breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: room('{attr.kicker}'),
  margins: { bottom: mm(BAND + 8) }, // the text stops 8 mm above the band
  footer: { elements: [
    art('banda', 'bottom-left'),
    { kind: 'text', id: 'venue', content: '{attr.lugar}', fontFamily: 'Space Grotesk',
      fontWeight: 700, fontSize: pt(20), ...onForest, // on the band, level with the text edge
      placement: { anchor: { to: '#banda', edge: 'align-top' }, offset: { x: mm(BLEED + OUTER),
        y: mm(6) } } },
    { kind: 'text', id: 'address', content: '{attr.direccion}', fontFamily: 'Karla',
      fontSize: pt(10), ...onForest,
      placement: { anchor: { to: '#venue', edge: 'below' }, offset: { y: mm(1.5) } } },
    { kind: 'text', id: 'when', content: '{attr.fechas}', ...label, color: col('sage'),
      placement: { anchor: { to: '#address', edge: 'below' }, offset: { y: mm(4) } } },
    { kind: 'text', id: 'colophon', content: '{attr.colofon}', ...label, fontWeight: 400,
      fontSize: pt(6.3), letterSpacing: pt(0), textTransform: 'none', lineHeight: 1.4,
      color: col('sage'), overflow: 'wrap',
      placement: { anchor: { to: 'page', edge: 'bottom-left' }, offset: { x: mm(OUTER),
        y: mm(-6) }, size: { width: mm(78) } } }, // 57 monospaced characters a line
  ] } };
```

يضيف `cutLines` النزف وإزاحة العلامات والعلامة البالغة 5 مم إلى كل جانب من الصفحة، أي 11 مم هنا، وبما أن الجواب المختصر يجعل الإزاحة مساوية للنزف، تبدأ العلامات عند حافة النزف وتمتد إلى الخارج، على لوح الأسود. وعنصر الصورة المثبّت إلى النزف مع `width: 'fill'` يأخذ عرض ذلك الإطار ويحفظ نِسَبه: خريطة الغلاف مرسومة بقياس 176 × 233 مم، أي حدّ القص زائد 3 مم في كل جانب، فتبلغ حافة النزف حيث تبدأ علامات القص، بينما تتدلى الحروف من منطقة النص، داخل حدّ القص بمسافة وافية. والغلاف الخلفي نمط عنوان يحلّ تذييله محل تذييل المستند، بشريط من المولّد نفسه مثبّت إلى أسفل النزف وهامش سفلي يوقف النص على بعد 8 مم فوقه.

### 4 · عناوين تصير إشارات مرجعية

```js
// script.js, سطرًا 171–182
  headings: {
    // A designed heading keeps its own text, hidden, for the bookmarks and the tags. Set it in
    // a face the pages already load, or the PDF embeds Open Sans for that text alone.
    fontFamily: 'Space Grotesk',
    levels: [
      // Rooms follow on: any headings object drops the H1 break (gotcha: headings-drop-h1-break),
      // so it is stated off. The back cover breaks through its style, a :::pagebreak ends the
      // front one. The template numbers kicker and bookmark: "Sala 1 Una isla en ninguna parte".
      { level: 1, numberingTemplate: 'Sala {1}', breakBefore: { enabled: false },
        marginTop: pt(LEAD), advancedDesign: room('{number} · {attr.fecha}') },
    ],
  },
```

يبني `renderToPdf` الإشارات المرجعية من العناوين، لذا كل قاعة عنوان من المستوى الأول يلي سابقه دون فاصل صفحة، ويعطي `numberingTemplate` العنوانَ الصغير قيمة `{number}` والإشارة المرجعية بادئتها: يسرد ملف PDF الغلاف، ومن «Sala 1 Una isla en ninguna parte» إلى «Sala 4 La isla del tesoro»، والمعلومات العملية. العنوان ذو التصميم يخفي نصه لكنه يحتفظ به للإشارات المرجعية والوسوم؛ ودون `fontFamily` يُضبط ذلك النص المخفي بخط Open Sans 700، فتحمّله الصفحات ويضمّنه PDF لذلك النص وحده. وتتبع تسميات الصفحات `page.pageNumbering`، المتروك على قيمته الافتراضية، عشري من 1، فالصفحة 3 في قارئ PDF هي الصفحة التي يحمل لسانها 3.

### 5 · نُسخ طباعة أصلية بأحبار دقيقة

```js
// script.js, سطرًا 377–409
// C, M, Y, K in %, one build per colour of the drawings, matched to the screen colours on a
// proof. The plain conversion would print the sea C82 M0 Y25 K76, a petrol teal; here it is
// a four-ink green. The orange keeps its plain build, so the route matches the tab.
const INKS = { forest: [100, 45, 80, 55], shallows: [100, 50, 85, 40], coast: [90, 50, 85, 20],
  lowland: [80, 40, 75, 5], upland: [50, 15, 45, 20], sage: [40, 20, 35, 0],
  summit: [10, 5, 10, 0], tint: [5, 0, 5, 5], signal: [0, 60, 85, 0], paper: [0, 0, 0, 0] };
function pdfPage(w, h, shapes) { // w, h in mm; the content stream flips y to match the SVG
  const s = 72 / 25.4; // pt per mm
  const ink = (id, op) => `${INKS[id].map((v) => v / 100).join(' ')} ${op}`;
  const ops = (d) => d.replace(/([MLCZ])([^MLCZ]*)/g,
    (_, c, v) => `${v.trim()} ${{ M: 'm', L: 'l', C: 'c', Z: 'h' }[c]} `.trimStart());
  const body = shapes.map(({ d, fill, stroke, width, dash }) => [
    'q', fill && ink(fill, 'k'), stroke && ink(stroke, 'K'), stroke && `${width} w 1 J 1 j`,
    dash && `[${dash.join(' ')}] 0 d`, ops(d), fill ? 'f' : 'S', 'Q',
  ].filter(Boolean).join(' ')).join('\n');
  const stream = `${s} 0 0 ${-s} 0 ${h * s} cm\n${body}`;
  const objects = ['<< /Type /Catalog /Pages 2 0 R >>', '<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
    `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${n(w * s)} ${n(h * s)}] /Contents 4 0 R >>`,
    `<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`];
  let pdf = '%PDF-1.4\n';
  const offsets = objects.map((object, i) => {
    const offset = pdf.length;
    pdf += `${i + 1} 0 obj\n${object}\nendobj\n`;
    return offset;
  });
  const xref = pdf.length;
  pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`
    + offsets.map((o) => `${String(o).padStart(10, '0')} 00000 n \n`).join('')
    + `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
  return new TextEncoder().encode(pdf); // ASCII only, so string length = byte length
}
const masters = new Map(Object.entries(DRAWINGS) // by fileId: cubierta.pdf, banda.pdf, plano.pdf
  .map(([id, [size, shapes]]) => [`${id}.pdf`, pdfPage(...size, shapes)]));
```

كل رسم معرَّف مرة واحدة، مساراتٍ. تحوّلها منطقة الرسوم إلى SVG الذي ترسمه اللوحة (Canvas)، وتكتب هذه المنطقة المسارات نفسها في ملف PDF بصفحة واحدة بألوان CMYK، بتركيبات مختارة لتطابق البروفةُ الشاشةَ، فيصير البحر C100 M45 Y80 K55 بدل C82 M0 Y25 K76 البسيط. يسمّي كل مورد نسخته الأصلية في `svg.pdfFileId`، وتسلّم `pressPdf()` في الجواب المختصر بايتات النسخة الأصلية إلى `renderToPdf` تحت معرّف SVG، ويضمّن postext-pdf تلك الصفحة دون تغيير، رسومًا متجهية بأحبار النسخة الأصلية نفسها. يصح ذلك على المخطط، وهو شكل، وعلى خريطة الغلاف والشريط الخلفي، وهما صورتا تصميم. أما البروفة بتدرج الرمادي فتأخذ ملفات SVG بدلًا منها، لأن النسخة الأصلية المضمَّنة لا تُحوَّل أبدًا وكانت ستبقى ملوّنة.

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

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

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

### script.js

```js
// ═══ Postext Cookbook · Nº 024 · Print-ready PDF: bleed, crop marks and CMYK ═══════════
// https://postext.dev/en/cookbook/print-ready-pdf
// Code: MIT · Text: original (CC BY 4.0) · Maps: generated in code (CC BY 4.0)
// Fonts: Karla, Space Grotesk, Space Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// An exhibition leaflet set up for the press: bleed and crop marks on every page, and a CMYK
// PDF that embeds its fonts and takes the maps and the floor plan from print masters.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  defaultResourceTypes,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'es'; // @lang: the language of the sample document (this recipe is Spanish only)
const RECIPE = 'print-ready-pdf';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: screen colours picked for the plates the CMYK file prints them on
const palette = {
  ink: '#161616', // text: a neutral grey prints on the black plate alone (K91)
  muted: '#666666', // the running heads, on black alone for the same reason (K60)
  forest: '#0b3d2e', // the sea, the kickers, the back band: its plain build is a petrol teal
  signal: '#ff6626', // route, waypoints, tab: red at full strength, so no black (C0 M60 Y85)
  sage: '#9fb8a8', // high ground; small type on forest
  paper: '#f4f1ea', // type on forest
};
// col(id): a palette-linked colour. It carries the hex too, because 1.4.1 paints design
// elements from the hex (gotcha: palette-skips-designs).
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' } })),
  // The engine's defaults link to 'main-color': point it at the forest, so nothing prints blue.
  { id: 'main-color', name: 'forest (defaults)', value: { hex: palette.forest, model: 'hex' } },
];
// #endregion

// #region answer: bleed and crop marks on every page; a CMYK PDF that swaps in print masters
const BLEED = 3; // mm of artwork past the trim: the cover, the tab and the back band reach it
// Hook-up: config().page.cutLines. A mark starts markOffset outside the trim and runs 5 mm
// (markLength): an offset equal to the bleed keeps the marks off the artwork whatever BLEED
// is. Each page grows by doc.trimOffset a side, bleed + offset + mark: 11 mm here.
const cutLines = { enabled: true, bleed: mm(BLEED), markOffset: mm(BLEED) };

// renderToPdf ignores config.pdfGeneration, so every PDF setting goes in its options.
// `masters` holds print-master bytes by fileId (section 2 writes them).
function pressPdf(doc, colorSpace, { resources, masters }) {
  // A resource names its master in svg.pdfFileId, but outside bundles renderToPdf only asks
  // for svg.fileId: answer that id with the master (gotcha: pdf-master-resourcebytes).
  const masterOf = new Map(resources.filter((r) => r.svg?.pdfFileId)
    .map((r) => [r.svg.fileId, r.svg.pdfFileId]));
  return renderToPdf(doc, {
    // The kit's provider snaps weights and falls back to upright, since the PDF asks for every
    // style of every family (gotcha: pdf-provider-all-styles); TrueType faces are subset.
    fontProvider: fontsourceProvider,
    colorSpace, // 'cmyk' for the press, 'grayscale' for a proof: a naive conversion, no ICC
    // The proof keeps the SVGs, which its grey conversion can reach; masters stay in CMYK.
    resourceBytes: (fileId) => (colorSpace === 'cmyk' && masters.get(masterOf.get(fileId)))
      || imageBytes(fileId),
  }); // bookmarks (from the headings) and /PageLabels (from the folios) come by default
}
// #endregion

const TRIM = [170, 227]; // mm: the leaflet as it leaves the guillotine
const [TOP, FOOT, INNER, OUTER] = [20, 22, 18, 34]; // margins, mm, mirrored; the tab is outside
const LEAD = 13.6; // body leading, pt
const label = { fontFamily: 'Space Mono', fontWeight: 700, fontSize: pt(7.5),
  letterSpacing: pt(1.3), textTransform: 'uppercase', align: 'left' }; // default: centred

// #region tab: a thumb tab off the fore-edge that carries the folio, on body pages only
const TAB = { w: 10, h: 24 }; // the tab as it will be trimmed, mm; its foot is level with the text
const tab = (parity) => {
  const edge = parity === 'even' ? 'bottom-left' : 'bottom-right'; // the fore-edge
  const from = (to, y) => ({ anchor: { to, edge }, offset: { y: mm(y) } });
  return [
    // The box hangs from the bleed frame and BLEED of its width is trimmed off, so a cut that
    // lands a millimetre outside the trim still leaves orange at the edge.
    { kind: 'box', id: `tab-${parity}`, style: { backgroundColor: col('signal') },
      placement: { ...from('bleed', -(FOOT + BLEED)), size: { width: mm(TAB.w + BLEED),
        height: mm(TAB.h) } } },
    // The folio hangs from the trim frame ('page'), so it centres on the part left after the cut.
    { kind: 'text', id: `folio-${parity}`, content: '{pageNumber}', ...label, align: 'center',
      fontSize: pt(11), letterSpacing: pt(0), color: col('ink'),
      placement: { ...from('page', -FOOT), size: { width: mm(TAB.w), height: mm(TAB.h) } } },
  ].map((element) => ({ ...element, parity, pages: 'body' })); // never on the covers
};
const head = (parity, content) => ({ kind: 'text', id: `head-${parity}`, content, ...label,
  color: col('muted'), parity, pages: 'body',
  placement: { anchor: { to: 'container', edge: parity === 'even' ? 'top-left' : 'top-right' },
    offset: { y: mm(11) } } });
const header = { elements: [head('even', '{title}'), head('odd', '{subtitle}')] };
const footer = { elements: [...tab('even'), ...tab('odd')] };
// #endregion

const room = (kicker) => ({ enabled: true, slot: { elements: [ // a waypoint, kicker and title
  { kind: 'box', id: 'stop', style: { backgroundColor: col('paper'), borderColor: col('signal'),
    borderWidth: mm(0.9), borderRadius: mm(1.7) },
  placement: { anchor: { to: 'container', edge: 'top-left' }, size: { width: mm(3.4),
    height: mm(3.4) } } },
  { kind: 'text', id: 'kicker', content: kicker, ...label, color: col('forest'),
    placement: { anchor: { to: '#stop', edge: 'right-of' }, offset: { x: mm(2.2) } } },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Space Grotesk',
    fontWeight: 700, fontSize: pt(17), lineHeight: 1.08, color: col('ink'), align: 'left',
    overflow: 'wrap', // design text ends in '…' by default (gotcha: overflow-ellipsis-default)
    placement: { anchor: { to: '#stop', edge: 'below' }, offset: { y: mm(1.2) },
      size: { width: 'fill' } } },
] } });

// #region covers: the front art runs bleed to bleed; the back band bleeds on three sides
const BAND = 44; // mm from the trim foot to the top of the back band
const onForest = { color: col('paper'), align: 'left', overflow: 'wrap' };
const art = (resourceId, edge) => ({ kind: 'image', id: resourceId, resourceId,
  placement: { anchor: { to: 'bleed', edge }, size: { width: 'fill' } } }); // height: its ratio
// The map reserves no height (gotcha: opener-image-no-reserve), so the text puts a
// :::pagebreak right after the cover heading: without it the lead would start on the map.
// span: 'page' makes the cover an opener, so furniture set to pages: 'body' skips it.
const cover = { id: 'cover', numbered: false, span: 'page', advancedDesign: { enabled: true,
  slot: { elements: [ // paint order: the map first, the type on top
    art('cubierta', 'top-left'),
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...label, color: col('sage'),
      placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(6) } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Space Grotesk',
      fontWeight: 700, fontSize: pt(50), lineHeight: 0.98, ...onForest,
      placement: { anchor: { to: '#kicker', edge: 'below' }, offset: { y: mm(4) },
        size: { width: mm(132) } } },
    { kind: 'text', id: 'subtitle', content: '{subtitle}', fontFamily: 'Karla', fontSize: pt(13),
      ...onForest, placement: { anchor: { to: '#title', edge: 'below' }, offset: { y: mm(5) } } },
  ] } },
  footer: { elements: [ // the cover's own foot: dates and place, over open sea
    { kind: 'text', id: 'dates', content: '{attr.fechas}', ...label, color: col('paper'),
      placement: { anchor: { to: 'container', edge: 'bottom-left' }, offset: { y: mm(-15) } } },
    { kind: 'text', id: 'place', content: '{attr.lugar}', ...label, color: col('sage'),
      placement: { anchor: { to: '#dates', edge: 'below' }, offset: { y: mm(1.6) } } },
  ] } };
const back = { id: 'back', numbered: false, breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: room('{attr.kicker}'),
  margins: { bottom: mm(BAND + 8) }, // the text stops 8 mm above the band
  footer: { elements: [
    art('banda', 'bottom-left'),
    { kind: 'text', id: 'venue', content: '{attr.lugar}', fontFamily: 'Space Grotesk',
      fontWeight: 700, fontSize: pt(20), ...onForest, // on the band, level with the text edge
      placement: { anchor: { to: '#banda', edge: 'align-top' }, offset: { x: mm(BLEED + OUTER),
        y: mm(6) } } },
    { kind: 'text', id: 'address', content: '{attr.direccion}', fontFamily: 'Karla',
      fontSize: pt(10), ...onForest,
      placement: { anchor: { to: '#venue', edge: 'below' }, offset: { y: mm(1.5) } } },
    { kind: 'text', id: 'when', content: '{attr.fechas}', ...label, color: col('sage'),
      placement: { anchor: { to: '#address', edge: 'below' }, offset: { y: mm(4) } } },
    { kind: 'text', id: 'colophon', content: '{attr.colofon}', ...label, fontWeight: 400,
      fontSize: pt(6.3), letterSpacing: pt(0), textTransform: 'none', lineHeight: 1.4,
      color: col('sage'), overflow: 'wrap',
      placement: { anchor: { to: 'page', edge: 'bottom-left' }, offset: { x: mm(OUTER),
        y: mm(-6) }, size: { width: mm(78) } } }, // 57 monospaced characters a line
  ] } };
// #endregion

const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity)
  locale: LANG, // hyphenation (for justified text only) and the PDF's /Lang
  // "Figura 1": Spanish names by hand (gotcha: resource-types-locale), one running count
  resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}',
    resetOn: 'never' })),
  colorPalette,
  headingStyles: [cover, back],
  page: { width: mm(TRIM[0]), height: mm(TRIM[1]), cutLines,
    margins: { top: mm(TOP), bottom: mm(FOOT), left: mm(INNER), right: mm(OUTER), mirror: true } },
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: 'Karla', fontSize: pt(9.6), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('forest'),
    // Ragged, like the labels. Ragged text is never hyphenated (gotcha: ragged-no-hyphenation);
    // the 118 mm measure keeps the rag shallow.
    textAlign: 'left', firstLineIndent: mm(0), paragraphSpacing: true },
  // #region levels: every room a numbered H1 with no page break, so each is a PDF bookmark
  headings: {
    // A designed heading keeps its own text, hidden, for the bookmarks and the tags. Set it in
    // a face the pages already load, or the PDF embeds Open Sans for that text alone.
    fontFamily: 'Space Grotesk',
    levels: [
      // Rooms follow on: any headings object drops the H1 break (gotcha: headings-drop-h1-break),
      // so it is stated off. The back cover breaks through its style, a :::pagebreak ends the
      // front one. The template numbers kicker and bookmark: "Sala 1 Una isla en ninguna parte".
      { level: 1, numberingTemplate: 'Sala {1}', breakBefore: { enabled: false },
        marginTop: pt(LEAD), advancedDesign: room('{number} · {attr.fecha}') },
    ],
  },
  // #endregion
  paragraphStyles: [
    // Only size and face change: styles inherit the rest. 'ficha' sets each room's object label.
    { id: 'lead', fontFamily: 'Space Grotesk', fontSize: pt(12), lineHeight: pt(16.5) },
    { id: 'ficha', fontFamily: 'Space Mono', fontSize: pt(7), lineHeight: pt(10.5) },
  ],
  // Captions in the label face, like the object labels; the label in forest (orange is 2.9:1).
  captionStyle: { fontFamily: 'Space Mono', fontSize: pt(7.5), labelColor: col('forest') },
  header, footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Cartografías imaginarias"
subtitle: "Mapas de lugares que nunca existieron"
author: "Biblioteca del Faro"
---

# Cartografías imaginarias {style="cover" kicker="Exposición temporal · Ala Norte" fechas="Del 14 de enero al 29 de abril de 2027" lugar="Biblioteca del Faro · Puerto Alba"}

:::pagebreak

:::paragraphs{style="lead"}
La exposición reúne en facsímil cuatro mapas de lugares inventados, impresos entre 1516 y 1883. Tres dibujan las costas de tierras que nadie ha pisado: la isla de Utopía, el país de la Ternura y la isla del tesoro. El cuarto, la carta marina de un poema de Lewis Carroll, deja el mar en blanco. Las salas siguen el orden de las fechas y se recorren en el sentido de las agujas del reloj.
:::

# Una isla en ninguna parte {fecha="1516"}

En 1516 Tomás Moro publicó en Lovaina un librito en latín sobre el mejor estado de una república y sobre una isla nueva llamada Utopía. El nombre viene del griego *ou tópos*, «ningún lugar». La isla tiene forma de media luna, doscientas millas de anchura en su parte central y cincuenta y cuatro ciudades casi idénticas. La capital, Amauroto, «la ciudad oscura», se levanta junto al Anidro, «el río sin agua». Moro disfraza de geografía un tratado político, y el grabado de la primera edición rotula en latín la capital, el nacimiento del Anidro y su desembocadura.

:::paragraphs{style="ficha"}
Tomás Moro, *Libellus… deque nova insula Utopia*. Lovaina: Dirk Martens, 1516. Facsímil. «Utopiae insulae figura», grabado de la primera edición.
:::

# El país de la Ternura {fecha="1654"}

Madeleine de Scudéry publicó en 1654 el primero de los diez tomos de *Clélie*, una novela ambientada en la Roma antigua, e incluyó en él un mapa grabado, la *Carte de Tendre*. Desde Nueva Amistad salen tres caminos hacia tres ciudades llamadas Ternura: la de la Inclinación, la de la Estima y la del Reconocimiento. El primero sigue un río y es el más rápido; los otros dos atraviesan aldeas con nombres de virtudes, como Sinceridad o Generosidad. Quien se desvía acaba en el lago de la Indiferencia, y más allá del mar Peligroso el grabado deja unas Tierras Desconocidas.

:::paragraphs{style="ficha"}
Madeleine de Scudéry, *Clélie, histoire romaine*, tomo I. París: Augustin Courbé, 1654. Facsímil. «Carte de Tendre», grabado de François Chauveau.
:::

# El océano en blanco {fecha="1876"}

En *La caza del Snark*, el poema que Lewis Carroll publicó en 1876 con el subtítulo «Una agonía en ocho cantos», el Campanero, que capitanea a ocho hombres y un castor, compra una gran carta del mar sin el menor rastro de tierra, y la tripulación la celebra porque, por fin, todos la entienden. Cuando el capitán pregunta de qué sirven los polos, los trópicos y los meridianos de Mercator, los marineros contestan que son simples signos convencionales. La carta es «un vacío perfecto y absoluto», y el libro la imprimió tal cual en el segundo canto: un rectángulo en blanco con unos pocos rótulos en el marco. El Campanero, para quien todo lo que dice tres veces es verdad, no tiene otro método para cruzar el océano que tocar la campana, y en la travesía el bauprés se confunde a veces con el timón, algo que según él ocurre a menudo en los climas tropicales.

:::paragraphs{style="ficha"}
Lewis Carroll, *The Hunting of the Snark*, con ilustraciones de Henry Holiday. Londres: Macmillan, 1876. Facsímil. Carta del océano, a tamaño real.
:::

# La isla del tesoro {fecha="1881"}

En el verano de 1881, en Braemar, en las Tierras Altas de Escocia, Robert Louis Stevenson dibujó y coloreó una isla para entretener a su hijastro, Lloyd Osbourne, de trece años. De aquel mapa salió la novela, titulada al principio *The Sea Cook*, que se publicó por entregas en la revista juvenil *Young Folks* entre octubre de 1881 y enero de 1882, firmada por un tal «capitán George North», y como libro en 1883. En 1894 contó en la revista *The Idler* que el mapa original, enviado con el manuscrito a la editorial Cassell, nunca llegó, y que tuvo que rehacerlo al revés, a partir del libro: hizo inventario de cada alusión y ajustó con un compás la isla a esos datos. El nuevo mapa se dibujó en el despacho de su padre, con ballenas y barcos, y Thomas Stevenson, ingeniero de faros, falsificó con esmero la firma del capitán Flint y las instrucciones de navegación de Billy Bones. Es el que abre, como frontispicio, la primera edición, y se expone junto al número de *The Idler* en el que Stevenson cuenta la pérdida.

:::paragraphs{style="ficha"}
Robert Louis Stevenson, *Treasure Island*. Londres: Cassell & Company, 1883. Facsímil. «My First Book: Treasure Island», en *The Idler*, 1894.
:::

# Información práctica {style="back" kicker="Visita" lugar="Biblioteca del Faro" direccion="Paseo del Muelle, 12 · Puerto Alba" fechas="Del 14 de enero al 29 de abril de 2027" colofon="Compuesto en Karla, Space Grotesk y Space Mono (SIL OFL). Textos: CC BY 4.0; mapas dibujados en código."}

El plano de la :ref{id="plano" style="full" case="lower"} traza el recorrido, que empieza y acaba en el vestíbulo.

**Horario.** De martes a sábado, de 10:00 a 14:00 y de 17:00 a 20:00; domingos y festivos, de 10:00 a 14:00. Los lunes, cerrado.

**Entrada libre.** Visitas guiadas los sábados a las 12:00, sin reserva, en grupos de hasta veinte personas.

**Taller familiar.** «Dibuja tu isla», para niños de 6 a 12 años, los domingos a las 11:00. Inscripción en la recepción.

**Accesibilidad.** Todas las salas están a pie de calle. En la recepción hay lupas y los textos de sala en letra grande.
`; // content.<lang>.md, inlined by the Cookbook

// #region art: an invented archipelago and a floor plan, drawn as paths
// Paths and flat fills only: no <marker>, filter or mask, so the PDF keeps every drawing
// vector (gotcha: svg-no-marker-filters).
const PX = 12; // declared pixels per mm. Only the ratio counts: SVG figures fill their frame
const rng = (seed) => () => { // Mulberry32: the same maps on every run
  seed = (seed + 0x6d2b79f5) | 0;
  let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
const n = (v) => Math.round(v * 100) / 100;
const xy = ([x, y]) => `${n(x)} ${n(y)}`;
const spline = (pts, closed) => { // Catmull-Rom through the points, as cubic Béziers
  const last = pts.length - 1;
  const at = (i) => pts[closed ? (i + pts.length) % pts.length : Math.max(0, Math.min(last, i))];
  const segs = pts.slice(0, closed ? pts.length : -1).map((p, i) => {
    const [a, b, c, d] = [at(i - 1), p, at(i + 1), at(i + 2)];
    return `C${xy([b[0] + (c[0] - a[0]) / 6, b[1] + (c[1] - a[1]) / 6])} `
      + `${xy([c[0] - (d[0] - b[0]) / 6, c[1] - (d[1] - b[1]) / 6])} ${xy(c)}`;
  });
  return `M${xy(pts[0])}${segs.join('')}${closed ? 'Z' : ''}`;
};
const disc = (x, y, r, k = 0.5523 * r) => `M${xy([x - r, y])}C${xy([x - r, y - k])} `
  + `${xy([x - k, y - r])} ${xy([x, y - r])}C${xy([x + k, y - r])} ${xy([x + r, y - k])} `
  + `${xy([x + r, y])}C${xy([x + r, y + k])} ${xy([x + k, y + r])} ${xy([x, y + r])}`
  + `C${xy([x - k, y + r])} ${xy([x - r, y + k])} ${xy([x - r, y])}Z`;
const poly = (...pts) => `M${pts.map(xy).join('L')}`;
const box = (x, y, w, h) => `${poly([x, y], [x + w, y], [x + w, y + h], [x, y + h])}Z`;
// The drawings' own colours: the plan's floor, the sea near a coast, the relief bands.
const ART = { tint: '#e3ebe4', shallows: '#0f4a37', coast: '#1f5c45', lowland: '#3f7d5f',
  upland: '#6f9a80', summit: '#dfe7dc' };
const hex = (id) => palette[id] ?? ART[id];
const toSvg = (w, h, shapes) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * PX}" `
  + `height="${h * PX}" viewBox="0 0 ${w} ${h}">${shapes.map(({ d, fill, stroke, width, dash }) =>
    `<path d="${d}" fill="${fill ? hex(fill) : 'none'}"${stroke ? ` stroke="${hex(stroke)}" `
    + `stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"` : ''}${dash
      ? ` stroke-dasharray="${dash.join(' ')}"` : ''}/>`).join('')}</svg>`;

// Elevation: filled bands from the coast to the summit (thin contour lines alias in thumbnails).
const RELIEF = ['coast', 'lowland', 'upland', 'sage', 'summit'];
// An island is a few overlapping lobes. Each band is filled for every lobe, so shapes of one
// colour merge into a single coast with saddles and more than one summit.
function island(lobes, seed, aspect = 0.8) {
  const rand = rng(seed);
  return lobes.map(([cx, cy, r]) => {
    const waves = [2, 3, 4, 6].map((k) => [k, (rand() * 0.26) / Math.sqrt(k), rand() * 6.28]);
    const peak = [cx + (rand() - 0.5) * r * 0.6, cy + (rand() - 0.5) * r * 0.5];
    // The contour at s × r (plus `grow` mm); higher contours drift towards the summit.
    return (s, grow = 0, drift = 0) => Array.from({ length: 64 }, (_, i) => {
      const a = (i / 64) * Math.PI * 2;
      const bump = waves.reduce((sum, [k, amp, ph]) => sum + amp * Math.sin(k * a + ph + drift), 0);
      const [ox, oy] = [peak[0] + (cx - peak[0]) * s, peak[1] + (cy - peak[1]) * s];
      const rr = r * s * (1 + bump) + grow;
      return [ox + Math.cos(a) * rr, oy + Math.sin(a) * rr * aspect];
    });
  });
}
function terrain(w, h, isles, route = []) {
  const lobes = isles.flat();
  const fillAll = (fill, s, grow = 0, drift = 0) =>
    lobes.map((f) => ({ d: spline(f(s, grow, drift), true), fill }));
  const shapes = [{ d: box(0, 0, w, h), fill: 'forest' },
    ...fillAll(RELIEF[0], 1.55, 1, 0.3), ...fillAll('forest', 1.55, 0, 0.3), // a 1 mm sea contour
    ...fillAll('shallows', 1.28),
    ...RELIEF.flatMap((fill, i) => fillAll(fill, 1 - i * 0.19, 0, i * 0.22))]; // coast to summit
  if (route.length) {
    shapes.push({ d: spline(route, false), stroke: 'signal', width: 1.5, dash: [3.2, 2.4] });
    for (const [x, y] of route.slice(1, -1)) {
      shapes.push({ d: disc(x, y, 2.7), fill: 'signal' }, { d: disc(x, y, 1.1), fill: 'paper' });
    }
  }
  return shapes;
}
// The cover: the trim plus the bleed on every side, 176 × 233 mm. The route enters from the
// bleed and leaves through it, past four stops, one per room.
const COVER = TRIM.map((side) => side + 2 * BLEED);
const SCALE = [BLEED + TRIM[0] - OUTER - 20, BLEED + 209]; // the scale bar's corner
const coverShapes = [...terrain(...COVER, [
  island([[112, 152, 32], [140, 170, 24], [128, 128, 18]], 7),
  island([[36, 134, 14], [49, 145, 9]], 11),
  island([[151, 77, 10]], 3)],
[[-4, 202], [40, 137], [98, 160], [136, 150], [151, 78], [182, 36]]),
  // A scale bar ending on the recto's text edge, level with the dates (trim → bleed frame).
  ...[0, 2].map((i) => ({ d: box(SCALE[0] + i * 5, SCALE[1], 5, 1.4), fill: 'paper' })),
  { d: box(...SCALE, 20, 1.4), stroke: 'paper', width: 0.3 }];
const BANDART = [COVER[0], BAND + BLEED]; // the back band, from the bleed's foot
const bandShapes = terrain(...BANDART, [ // the back band: the same sea, another coast
  island([[160, 46, 24], [140, 56, 12], [176, 30, 14]], 5, 0.62),
  island([[4, 26, 13]], 9, 0.7)]); // an islet cut by the trim: its bleed is on the sheet

// The floor plan, 118 × 54 mm: walls with doorways, cases, and the route with numbered stops.
// Stroked numerals in a 0.6 × 1 box: SVG text gets none of the web fonts (gotcha: svg-no-webfonts).
const DIGITS = {
  1: 'M0.14 0.22L0.36 0L0.36 1',
  2: 'M0.04 0.24C0.08 -0.06 0.58 -0.06 0.56 0.28C0.54 0.52 0.04 0.7 0.04 1L0.58 1',
  3: 'M0.06 0L0.56 0L0.28 0.38C0.64 0.36 0.66 1 0.28 1C0.16 1 0.06 0.96 0.02 0.88',
  4: 'M0.44 1L0.44 0L0.02 0.66L0.6 0.66',
};
const glyph = (k, x, y, size) => DIGITS[k].replace(/(-?[\d.]+) (-?[\d.]+)/g,
  (_, a, b) => xy([x + (a - 0.3) * size, y + (b - 0.5) * size]));
const PLAN = [118, 54];
const STOPS = [[20, 37], [20, 13], [79, 13], [98, 37]]; // rooms 1 to 4, clockwise
const planShapes = [
  { d: box(1, 1, 116, 48), fill: 'tint' }, // the floor: four rooms round the vestibule
  ...[[6, 42, 14, 4], [23, 42, 12, 4], [5, 4, 4, 14], [50, 17, 22, 4], [86, 4, 24, 4],
    [110, 30, 4, 14], [84, 44, 22, 3]].map((r) => ({ d: box(...r), fill: 'sage' })), // cases
  ...[[[52, 49], [1, 49], [1, 1], [117, 1], [117, 49], [66, 49]], // walls; the gaps are doors
    [[1, 25], [15, 25]], [[25, 25], [40, 25]], [[40, 1], [40, 8]], [[40, 17], [40, 33]],
    [[40, 42], [40, 49]], [[40, 25], [93, 25]], [[103, 25], [117, 25]], [[78, 25], [78, 33]],
    [[78, 42], [78, 49]]].map((pts) => ({ d: poly(...pts), stroke: 'forest', width: 1.2 })),
  // The route starts and ends in the vestibule, just inside the door.
  { d: poly([56, 45], [56, 37], [20, 37], [20, 13], [98, 13], [98, 37], [62, 37], [62, 45]),
    stroke: 'signal', width: 0.9, dash: [2.2, 1.7] },
  { d: `${poly([53, 54], [56, 50.4], [59, 54])}Z`, fill: 'signal' }, // the way in
  ...STOPS.flatMap(([x, y], i) => [{ d: disc(x, y, 3.3), fill: 'signal' },
    { d: glyph(i + 1, x, y, 3.4), stroke: 'paper', width: 0.5 }]),
];
// Each drawing by resource id: its size in mm and its shapes.
const DRAWINGS = { cubierta: [COVER, coverShapes], banda: [BANDART, bandShapes],
  plano: [PLAN, planShapes] };
// #endregion

// #region master: print masters, one-page PDFs written in the inks the designer chose
// C, M, Y, K in %, one build per colour of the drawings, matched to the screen colours on a
// proof. The plain conversion would print the sea C82 M0 Y25 K76, a petrol teal; here it is
// a four-ink green. The orange keeps its plain build, so the route matches the tab.
const INKS = { forest: [100, 45, 80, 55], shallows: [100, 50, 85, 40], coast: [90, 50, 85, 20],
  lowland: [80, 40, 75, 5], upland: [50, 15, 45, 20], sage: [40, 20, 35, 0],
  summit: [10, 5, 10, 0], tint: [5, 0, 5, 5], signal: [0, 60, 85, 0], paper: [0, 0, 0, 0] };
function pdfPage(w, h, shapes) { // w, h in mm; the content stream flips y to match the SVG
  const s = 72 / 25.4; // pt per mm
  const ink = (id, op) => `${INKS[id].map((v) => v / 100).join(' ')} ${op}`;
  const ops = (d) => d.replace(/([MLCZ])([^MLCZ]*)/g,
    (_, c, v) => `${v.trim()} ${{ M: 'm', L: 'l', C: 'c', Z: 'h' }[c]} `.trimStart());
  const body = shapes.map(({ d, fill, stroke, width, dash }) => [
    'q', fill && ink(fill, 'k'), stroke && ink(stroke, 'K'), stroke && `${width} w 1 J 1 j`,
    dash && `[${dash.join(' ')}] 0 d`, ops(d), fill ? 'f' : 'S', 'Q',
  ].filter(Boolean).join(' ')).join('\n');
  const stream = `${s} 0 0 ${-s} 0 ${h * s} cm\n${body}`;
  const objects = ['<< /Type /Catalog /Pages 2 0 R >>', '<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
    `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${n(w * s)} ${n(h * s)}] /Contents 4 0 R >>`,
    `<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`];
  let pdf = '%PDF-1.4\n';
  const offsets = objects.map((object, i) => {
    const offset = pdf.length;
    pdf += `${i + 1} 0 obj\n${object}\nendobj\n`;
    return offset;
  });
  const xref = pdf.length;
  pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`
    + offsets.map((o) => `${String(o).padStart(10, '0')} 00000 n \n`).join('')
    + `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
  return new TextEncoder().encode(pdf); // ASCII only, so string length = byte length
}
const masters = new Map(Object.entries(DRAWINGS) // by fileId: cubierta.pdf, banda.pdf, plano.pdf
  .map(([id, [size, shapes]]) => [`${id}.pdf`, pdfPage(...size, shapes)]));
// #endregion

// Every drawing is an SVG for the screen and names its print master for the press.
const svgResource = (id, extra) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0,
  updatedAt: 0, ...extra, svg: { fileId: `${id}.svg`, pdfFileId: `${id}.pdf`,
    width: DRAWINGS[id][0][0] * PX, height: DRAWINGS[id][0][1] * PX } });
const resources = [
  svgResource('cubierta', { altText: 'Islas inventadas en verdes escalonados sobre un mar '
    + 'verde oscuro, cruzadas por una ruta naranja con cuatro paradas.' }),
  svgResource('banda', { altText: 'La costa de una isla inventada.' }),
  svgResource('plano', { caption: 'Plano de la exposición, con el itinerario en naranja.',
    altText: 'Plano: cuatro salas alrededor del vestíbulo y un itinerario '
    + 'naranja que las recorre en el sentido de las agujas del reloj.' }),
];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the pages use. Layout measures with the browser's fonts, so the kit loads them
// from Fontsource before the first build (gotcha: fonts-first); the PDF embeds the same files,
// Fontsource's latin subsets, which cover Spanish (gotcha: latin-subset).
const FONTS = { Karla: ['400', '400i', '700'], 'Space Grotesk': ['400', '700'],
  'Space Mono': ['400', '400i', '700'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await Promise.all(Object.entries(DRAWINGS)
  .map(([id, [size, shapes]]) => loadSvg(`${id}.svg`, toSvg(...size, shapes))));
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: 'Cartografías imaginarias · PDF listo para imprenta' });
const inputs = { resources, masters };
offerPdf(() => pressPdf(doc, 'cmyk', inputs), `${RECIPE}.pdf`); // the file for the press
offerPdf(() => pressPdf(doc, 'grayscale', inputs), `${RECIPE}-proof.pdf`); // a proof to read
// The kit names both buttons alike; once built, each download link carries its file name.
const button = (file) => document.querySelector(`[data-postext-pdf="${file}"]`);
button(`${RECIPE}.pdf`).textContent = 'Press PDF (CMYK)';
button(`${RECIPE}-proof.pdf`).textContent = 'Greyscale proof';

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

/** Registers SVG markup (drawn in code, or fetched) as a vector image. */
async function loadSvg(fileId, svg) {
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

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

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

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

## تنويعات

### اطلب نزفًا بمقدار 5 مم

الرسوم واللسان ومقياس الرسم محسوبة من `BLEED`، وعلامات القص تتبعه عبر `markOffset`، فالمطبعة التي تريد 5 مم لا تحتاج إلا إلى هذا التغيير، وتكبر كل ورقة إلى 200 × 257 مم.

```diff
-const BLEED = 3; // mm of artwork past the trim: the cover, the tab and the back band reach it
+const BLEED = 5; // mm of artwork past the trim: the cover, the tab and the back band reach it
```

### اطبع الرسوم من ملفات SVG

دون `pdfFileId` يرسم ملف المطبعة كل صورة من SVG الخاص بها، رسومًا متجهية أيضًا لكن عبر التحويل البسيط، فيُطبع البحر C82 M0 Y25 K76، أزرق مخضرًا بلون البترول.

```diff
-  updatedAt: 0, ...extra, svg: { fileId: `${id}.svg`, pdfFileId: `${id}.pdf`,
+  updatedAt: 0, ...extra, svg: { fileId: `${id}.svg`,
```

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

- **يتجاهل renderToPdf القيمة svg.pdfFileId: قدّم النسخة الطباعية عبر resourceBytes.** خارج الحزم، يطلب renderToPdf من resourceBytes معرّف fileId الخاص بملف SVG نفسه ولا يطلب svg.pdfFileId أبدًا. أعِد في resourceBytes بايتات النسخة الطباعية مقابل fileId الخاص بملف SVG.
- **يطلب PDF كل وزن وكل نمط من كل عائلة.** يطلب renderToPdf من مزوّد الخطوط الأوجه العريضة والمائلة والعريضة المائلة لكل عائلة قد تستخدمها أي كتلة، حتى التي لا تُطبع أبدًا، ورفضٌ واحد يوقف التصدير. يجب أن يلجأ المزوّد إلى أقرب وزن توفّره العائلة، وأن يعود إلى الوجه القائم حين لا يوجد وجه مائل.
- **لا <marker> ولا مرشِّحات في رسوم SVG (وإلا البديل النقطي).** لا يبقى شكل SVG متّجهيًا في PDF إلا إذا خلا من <marker> والمرشِّحات والأقنعة؛ وإلا عاد إلى صورة نقطية، والمرشِّحات المتداخلة بعمق قد تجعله فارغًا في Chrome. ارسم رؤوس الأسهم مسارات.
- **النص داخل SVG في <img> لا يستطيع استخدام خطوط الويب.** يُرسَم SVG صورةً، والصورة لا تصل إلى خطوط الويب في الصفحة، فتعود تسمياته إلى خط من النظام. حوّل النص إلى مسارات، أو ضمّن مجموعة فرعية بـ @font-face داخل SVG، أو انقل التسميات إلى التعليق.
- **ملفات latin من Fontsource تُسقط الحروف خارج اللاتينية.** يضمّن مزوّد PDF ملفات latin من Fontsource، وهي تغطي الإسبانية ونصوص أوروبا الغربية لكنها لا تغطي → و≈ و✓ و★ ولا الحروف اليونانية أو حروف أوروبا الوسطى؛ فتغيب تلك الحروف عن PDF. أبقِ نص PDF ضمن نطاق latin.
- **صور الافتتاحية لا تُحتسب أبدًا في الارتفاع الذي تحجزه.** في postext 1.4.1 يقيس العنوان ذو التصميم المتقدّم الارتفاعَ الذي يحجزه دون صوره: تُحتسب نصوصه وخطوطه وإطاراته، حتى المثبّتة على الصفحة، أما الصورة، كصورة تمتد بالنزف عبر رأس الصفحة، فلا تحجز شيئًا، فقد يبدأ النص فوقها. اضبط minHeight على الموضع الذي يجب أن يبدأ فيه النص.
- **النص غير المضبوط لا يُقسَّم بالواصلة أبدًا.** لا يُطبَّق تقسيم الكلمات بالواصلة إلا على النص المضبوط؛ أما النص ذو الحافة الحرّة فينكسر بين الكلمات، فيصير التفاوت كبيرًا في العمود الضيق غير المضبوط. اضبط المقطع أو وسّع عرض السطر.
- **ترجم Figure/Table بـ defaultResourceTypes(locale).** يضبط locale في الإعداد تقسيمَ الكلمات بالواصلة، لا التعليقات: من دون resourceTypes تقول الأنواع المدمجة Figure وTable بالإنجليزية. مرّر resourceTypes: defaultResourceTypes('es') للإسبانية؛ ولأي لغة أخرى اكتب الأسماء بنفسك في resourceTypes.
- **أي كائن headings يُلغي فاصل الصفحة قبل H1.** ينتقل H1 افتراضيًا إلى صفحة فردية (always-odd)، لكن تمرير أي كائن headings يعيد ضبط هذا الافتراض، فتتوالى الفصول دون فاصل ولا يفعل span: 'page' شيئًا. أعد كتابة headings.levels[0].breakBefore: { enabled: true, parity } في كل إعداد.
- **لوحة الألوان المستبدلة لا تصل إلى عناصر التصميم ولا إلى لون الإحالة.** يقرأ postext 1.4.1 الإعداد colorPalette في أنماط النص (المتن والعناوين والقوائم والتعليقات والجداول والإطارات) لكن لا في عناصر الترويسات والتذييلات والافتتاحيات وصفحات الأجزاء، ولا في bodyText.referenceColor: تحتفظ بالقيمة الست عشرية المكتوبة بجانب paletteId الخاص بها. حين تستبدل لوحة الألوان، لنسخة شاشة داكنة أو لإعادة تلوين، أعِد كتابة كل لون مرتبط من colorPalette قبل البناء.
- **فيض نص التصميم افتراضيًا 'ellipsis-end'.** عنصر نص التصميم الذي لا يتسع له عرضه ينتهي افتراضيًا بعلامة الحذف. اضبط overflow: 'wrap' للعناوين التي يجب أن تنكسر على أسطر أكثر.
- **يُخزَّن الإعداد مؤقتًا بحسب هويته: ابنِ كائنًا جديدًا.** يخزّن المحرّك الإعدادات المحسوبة مؤقتًا بحسب هوية الكائن، فتعديل الإعداد في مكانه ثم البناء مجددًا يعيد استخدام النتيجة القديمة. ابنِ كائنًا جديدًا في كل بناء، ولهذا يكون إعداد الوصفة دالة مصنِّعة: config().
- **حمّل كل أوجه الخط قبل الإخراج.** يقيس الإخراج النص بأوجه الخط التي حمّلها المتصفح ويخزّن العروض مؤقتًا، فالوجه الذي يصل بعد البناء الأول يترك فواصل أسطر خاطئة وملف PDF لم يعد يطابق الشاشة. حمّل كل وزن وكل نمط أولًا، واستدعِ clearMeasurementCache() قبل إعادة البناء إذا تأخر وصول أحدها.

- يكتب postext-pdf 1.4.1 كل صفحة صندوقَ MediaBox واحدًا، هو الورقة كلها بقياس 192 × 249 مم، دون TrimBox ولا BleedBox، ويطبع علامات القص على لوح الأسود وحده. أعطِ المطبعة قياس القص، 170 × 227 مم، أو أضف الصناديق بأداة ما قبل الطباعة إذا طلبت المطبعة PDF/X.
- في 1.4.1 تبدأ علامة القص على بعد `markOffset` خارج حدّ القص، لا خارج النزف، فإذا كان النزف أعرض من الإزاحة الافتراضية البالغة 3 مم طُبعت العلامات فوق الرسوم. اجعل الإزاحة مساوية للنزف، كما يفعل `cutLines` هنا.
- لا يقرأ `renderToPdf` 1.4.1 الإعداد `config.pdfGeneration`. تمرّر `pressPdf()` فضاء الألوان خيارًا، والإشارات المرجعية والوسوم مفعّلة افتراضيًا.
- بعد البناء يتحول كل زر إلى رابط «Open the PDF ↗» ورابط تنزيل، وروابط التنزيل وحدها تحمل أسماء الملفات.

## الحقوق

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

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

- [رقم 051 · مجلة ريزو بحبرين: الرسوم بلون خاص واحد](https://postext.dev/ar/cookbook/riso-zine-single-ink.md): مجلة من أربع صفحات لآلة ريزوغراف بأسطوانتين: الرسوم الملونة تُطبع درجاتٍ من الوردي، يعيد المثال تلوينها لـCanvas وrenderToPdf في PDF. · المستوى 3 (متقدم) · المجلات والمجلات المستقلة
- [رقم 039 · ملف PDF موسوم ومتاح للجميع (PDF/UA)](https://postext.dev/ar/cookbook/accessible-tagged-pdf.md): دليل لإعادة التدوير يُصدَّر ملف PDF/UA-1 موسومًا فيه نص بديل وخلايا عناوين للجداول ولغة المستند ومحتويات مرتبطة، وترسم المعاينة الوسوم على الصفحة 3. · المستوى 2 (متوسط) · التقارير
- [رقم 003 · افتتاح فصل على شريط ملوّن يمتد إلى النزف](https://postext.dev/ar/cookbook/chapter-opener-bleed-band.md): افتتاح بالإعداد advancedDesign على عنوان المستوى الأول: شريط حتى النزف، ورقم الفصل على قاعدته، وتمهيد ومقدمة من سمات العنوان. · المستوى 3 (متقدم) · الكتب المدرسية
