# رسالة تجارية وفق DIN 5008

> عرض أسعار ألماني من صفحتين بمقاس A4. سطر الموضوع هو H1، ويثبّت تصميمه ترويسة الشركة والعنوان وكتلة المعلومات في مواضع النموذج B.

- نسخة HTML: https://postext.dev/ar/cookbook/din-business-letter
- وصفة رقم 058 · الصفحة والشبكة · المستوى 2 (متوسط) · المخرجات: 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/din-business-letter/en/p01.webp?v=5cf359b0), [2](https://postext.dev/cookbook/din-business-letter/en/p02.webp?v=5cf359b0)
- PDF: https://postext.dev/cookbook/din-business-letter/en/din-business-letter.pdf?v=5cf359b0
- افتح في Sandbox: https://postext.dev/ar/sandbox#recipe=din-business-letter&lang=en (.postext: https://postext.dev/cookbook/din-business-letter/en/din-business-letter.postext)
- آخر تحديث: 2026-09-27
- لغات أخرى: [en](https://postext.dev/en/cookbook/din-business-letter.md), [es](https://postext.dev/es/cookbook/din-business-letter.md), [ca](https://postext.dev/ca/cookbook/din-business-letter.md), [zh](https://postext.dev/zh/cookbook/din-business-letter.md)

## باختصار

عرض أسعار من صفحتين صادر عن ورشة نوافذ ألمانية. يبيّن كيف تضع العنوان وسائر أجزاء الرسالة التجارية حيث تضعها المواصفة الألمانية، لتناسب ظرفًا بنافذة.

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

Fensterwerkstatt Tessin ورشة متخيَّلة في برلين ترمّم النوافذ المزدوجة، وهي ترسل عرض أسعار إلى الشركة التي تدير مبنى سكنيًا. يقع العرض في ورقتين A4 بالألمانية، مخرجتين وفق النموذج B من DIN 5008. يعبر أعلى الصفحة شريط بلون أزرق برلين يحمل شعار الورشة واسمها. وتحته يقع كل جزء حيث تضعه المواصفة: سطر المرسل والعنوان داخل الحقل 85 × 45 مم الذي يظهر من نافذة ظرف DL، وكتلة المعلومات على بعد 125 مم من الحافة اليسرى، والموضوع بالخط الغامق بعد سطرين فارغين تحت الحقل، وعلامات الطي والثقب عند 105 و148.5 و210 مم. تجري بيانات الشركة في ثلاثة أعمدة أسفل الورقتين. وتكرر الورقة الثانية المرجع بجانب «Seite 2 von 2» وتنتهي بتوقيع مرسوم بالشيفرة.

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

- كيف أثبّت عنوان الرسالة وكتلة المعلومات في مواضع DIN 5008 بالمليمتر؟
- كيف أضيف علامة مائية أو لونًا خفيفًا للخلفية أو صورة زخرفية في كل صفحة؟
- كيف أخفي الترويسات في صفحات الافتتاح والصفحات الفارغة، أو ألوّن صفحة زوجية فارغة بلون الجزء؟
- كيف أنضد نصًّا تصديريًّا أو إهداءً أو توقيعًا أو اقتباسًا بارزًا بعلامة اقتباس كبيرة؟

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

```js
// script.js, سطرًا 58–80
const at = (x, y, size) => ({ anchor: { to: 'page', edge: 'top-left' }, // mm from the corner
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });
const ZONE = DIN.field.y + DIN.notes; // 62.7 mm: where the six address lines start
const SUBJECT = DIN.field.y + DIN.field.h + 2 * LEAD * PT; // two blank lines under the field
const firstPage = () => ({ enabled: true, slot: { elements: [ // in paint order
  ...letterhead(), // band, logo and name, and the corners of the window
  text('from', FROM, FACE.from, at(DIN.left, ZONE - 3.6)), // the return line, underlined
  rule('from-rule', 'muted', { anchor: { to: '#from', edge: 'below' }, offset: { y: mm(0.6) },
    size: { width: mm(WINDOW) } }),
  // One attribute holds the whole address. Its '\n' starts a new line only when
  // paragraphIndent is above zero (gotcha: design-text-newline).
  text('to', '{attr.to}', { ...FACE.address, paragraphIndent: pt(0.01) },
    at(DIN.left, ZONE, { width: mm(WINDOW) })),
  ...infoBlock(), // labels, and values from {attr.*}
  text('subject', '{titleText}', FACE.subject, at(DIN.left, SUBJECT, { width: mm(TEXT) })),
] } });
// The H1 reserves the sheet down to its lowest element (gotcha: opener-reserves-anchored).
// fontFamily: the design paints the H1, but the build measures it (default: Open Sans).
const headings = () => ({ fontFamily: 'Familjen Grotesk', levels: [{ level: 1,
  span: 'page', // a design kept in the column would be cut off at the top margin
  breakBefore: { enabled: true, parity: 'any' }, // gotcha: headings-drop-h1-break
  marginBottom: pt(2 * LEAD), // two blank lines between the subject and the salutation
  advancedDesign: firstPage() }] });
```

## المكونات

**تعلّم**

- [تثبيت عناصر التصميم](https://postext.dev/ar/docs/configuration.md#تموضع-العناصر): ضع العناصر بالنسبة إلى الحاوية أو الصفحة أو النزف أو عنصر آخر (right-of، below، align-*) بدل الإحداثيات.
- [صفحات افتتاح مصمَّمة](https://postext.dev/ar/docs/configuration.md#الامتداد-والتصميم-المتقدم): عنوان يُرسم تركيبًا حرًّا من النصوص والخطوط الفاصلة والمربعات والصور، ويحجز الارتفاع الذي يحتاجه فوق المتن.

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

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

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

- [`bodyText`](https://postext.dev/ar/docs/configuration.md#نص-المتن), [`colorPalette`](https://postext.dev/ar/docs/configuration.md#لوحة-الألوان), [`footer`](https://postext.dev/ar/docs/configuration.md#رؤوس-الصفحات-وتذييلاتها), [`header`](https://postext.dev/ar/docs/configuration.md#رؤوس-الصفحات-وتذييلاتها), [`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#أنواع-الموارد), [`unorderedLists`](https://postext.dev/ar/docs/configuration.md#القوائم-غير-المرقّمة)

**واجهات API**

- [`buildDocument`](https://postext.dev/ar/docs/configuration.md#بناء-مستند), [`clearMeasurementCache`](https://postext.dev/ar/docs/configuration.md#ذاكرة-القياس-المؤقتة), [`decompressWoff2`](https://postext.dev/ar/docs/configuration.md#مزوّد-الخطوط-في-المتصفح-fontsource--woff2), [`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)

**الخطوط**

- Nunito Sans (OFL-1.1), Familjen Grotesk (OFL-1.1), Reddit Mono (OFL-1.1)

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

### 1 · ثبّت الصفحة الأولى على الورقة بالمليمتر

```js
// script.js, سطرًا 58–80
const at = (x, y, size) => ({ anchor: { to: 'page', edge: 'top-left' }, // mm from the corner
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });
const ZONE = DIN.field.y + DIN.notes; // 62.7 mm: where the six address lines start
const SUBJECT = DIN.field.y + DIN.field.h + 2 * LEAD * PT; // two blank lines under the field
const firstPage = () => ({ enabled: true, slot: { elements: [ // in paint order
  ...letterhead(), // band, logo and name, and the corners of the window
  text('from', FROM, FACE.from, at(DIN.left, ZONE - 3.6)), // the return line, underlined
  rule('from-rule', 'muted', { anchor: { to: '#from', edge: 'below' }, offset: { y: mm(0.6) },
    size: { width: mm(WINDOW) } }),
  // One attribute holds the whole address. Its '\n' starts a new line only when
  // paragraphIndent is above zero (gotcha: design-text-newline).
  text('to', '{attr.to}', { ...FACE.address, paragraphIndent: pt(0.01) },
    at(DIN.left, ZONE, { width: mm(WINDOW) })),
  ...infoBlock(), // labels, and values from {attr.*}
  text('subject', '{titleText}', FACE.subject, at(DIN.left, SUBJECT, { width: mm(TEXT) })),
] } });
// The H1 reserves the sheet down to its lowest element (gotcha: opener-reserves-anchored).
// fontFamily: the design paints the H1, but the build measures it (default: Open Sans).
const headings = () => ({ fontFamily: 'Familjen Grotesk', levels: [{ level: 1,
  span: 'page', // a design kept in the column would be cut off at the top margin
  breakBefore: { enabled: true, parity: 'any' }, // gotcha: headings-drop-h1-break
  marginBottom: pt(2 * LEAD), // two blank lines between the subject and the salutation
  advancedDesign: firstPage() }] });
```

يثبّت `at()` العنصر من زاويته العليا اليسرى إلى `'page'`، أي صندوق حدّ القص ([موضع العناصر](/ar/docs/configuration#تموضع-العناصر))، فكل `x` و`y` مسافة من زاوية الورقة، وتدخل أرقام المواصفة كما هي: العنوان على بعد 25 مم أفقيًا و62.7 مم عموديًا، وأول تسمية في كتلة المعلومات عند 125 و50. هذه العناصر تكوّن تصميم H1. نص العنوان هو الموضوع (`{titleText}`) وخصائصه تحمل العنوان البريدي والمراجع، فتحمل الإعدادات أدوات المراسلة وتحمل Markdown الرسالة. الموضوع، عند 100.6 مم، هو أدنى العناصر، فيحجز العنوان الورقة حتى أسفل الموضوع. يضيف `marginBottom` السطرين الفارغين، وتبدأ التحية على سطر الشبكة التالي، عند 117 مم.

العنوان البريدي خاصية واحدة بين أسطرها `\n`. في الإصدار 1.4.1 لا يحوّل نص التصميم `\n` في الخاصية إلى فاصل سطر إلا إذا كانت قيمة `paragraphIndent` أكبر من الصفر. يضبطها العنوان على 0.01 pt، وهي إزاحة أصغر من أن تُرى؛ وعند 0 تُطبع الشرطة المائلة العكسية والحرف n في العنوان. ومن دون `span: 'page'` يبقى التصميم في عمود النص ويُقصّ عند الهامش العلوي البالغ 27 مم، فيضيع الشعار والاسم ولا يبقى من الشريط إلا 5 مم من أسفله.

### 2 · اربط كتلة المعلومات بتسمية واحدة مثبَّتة

```js
// script.js, سطرًا 84–95
const INFO = [['Ihr Zeichen', 'ihr-zeichen'], ['Ihre Nachricht vom', 'ihre-nachricht'],
  ['Unser Zeichen', 'unser-zeichen'], ['Name', 'name'], ['Telefon', 'telefon'],
  ['E-Mail', 'email'], ['Datum', 'datum']]; // [label, heading attribute]
const VALUE = 29; // mm from the labels to the values
const infoBlock = () => INFO.flatMap(([label, key], i) => [
  text(`label-${i}`, label, FACE.label, i === 0 ? at(DIN.info.x, DIN.info.y) : {
    anchor: { to: `#label-${i - 1}`, edge: 'below' }, // each label under the last
    offset: { y: mm(key === 'datum' ? ROW * PT : 0) } }), // a blank row before the date
  text(`value-${i}`, `{attr.${key}}`, FACE.value, { // a 12 pt row as well, on the label's baseline
    anchor: { to: `#label-${i}`, edge: 'align-top' }, offset: { x: mm(VALUE) },
    size: { width: mm(DIN.info.w - VALUE) } }),
]);
```

لا تُثبَّت على الورقة إلا التسمية الأولى. كل تسمية أخرى مرتبطة بموضع `'below'` تحت سابقتها، وكل قيمة بتسميتها بـ`'align-top'` وإزاحة 29 مم، فلا يحتاج صف يُضاف إلى `INFO` إلى إحداثيات. يضع نص التصميم خط الأساس عند 80% من صندوق السطر. التسمية بحجم 7 pt مع `lineHeight: 12 / 7` والقيمة بحجم 9 pt مع `12 / 9` يأخذ كلاهما سطرًا ارتفاعه 12 pt، فيشتركان في خط أساس واحد على كل صف ارتفاعه 4.23 مم.

### 3 · ارسم ترويسة الشركة في التصميم نفسه

```js
// script.js, سطرًا 99–120
const [BAND, TICK] = [32, 3]; // mm: the band's depth, each arm of the window's corner angles
const LOGO = { y: 7, h: 18 }; // mm: the mark, centred in the band
const CRAFTS = 3 * 7.5 * 1.45 * PT; // mm: three 7.5 pt lines at 1.45
const letterhead = () => [
  { kind: 'box', id: 'band', style: { backgroundColor: col('brand') },
    placement: at(0, 0, { width: 'fill', height: mm(BAND) }) },
  { kind: 'image', id: 'logo', resourceId: 'logo',
    placement: at(DIN.left, LOGO.y, { height: mm(LOGO.h) }) },
  text('name', 'Tessin', face('Familjen Grotesk', 30, 700, 'paper', { lineHeight: 1 }),
    { anchor: { to: '#logo', edge: 'right-of' }, offset: { x: mm(5), y: mm(1.2) } }),
  text('trade', 'FENSTERWERKSTATT · BERLIN', face('Reddit Mono', 7.5, 500, 'sky',
    { letterSpacing: pt(1.2) }), { anchor: { to: '#name', edge: 'below' },
    offset: { x: mm(0.4), y: mm(1.5) } }),
  text('crafts', 'Kastenfenster\nHolzfenster\nDenkmalpflege', face('Reddit Mono', 7.5,
    400, 'sky', { lineHeight: 1.45, align: 'right' }),
    at(210 - DIN.right - 45, LOGO.y + (LOGO.h - CRAFTS) / 2, { width: mm(45) })),
  ...[[0, 0], [1, 0], [0, 1], [1, 1]].flatMap(([right, low], i) => { // turned inwards
    const [x, y] = [DIN.field.x + right * DIN.field.w, DIN.field.y + low * DIN.field.h];
    return [rule(`across-${i}`, 'marks', at(x - right * TICK, y, { width: mm(TICK) })),
      rule(`down-${i}`, 'marks', at(x, y - low * TICK, { height: mm(TICK) }), 'vertical')];
  }),
];
```

الشريط صندوق مثبَّت في زاوية الورقة. يمدّه `width: 'fill'` من حافة إلى حافة، ويتجاوز عمقه البالغ 32 مم الهامش العلوي (27 مم) بـ5 مم. الشعار عنصر صورة يرسم موردًا بارتفاع 18 مم ويأخذ عرضه من الرسم ([عناصر الصور](/ar/docs/configuration#عناصر-الصورة)). يتدلّى الاسم `'right-of'` الشعار، وسطر الحرفة `'below'` الاسم. ثمانية خطوط طول كل منها 3 مم ترسم زاوية عند كل ركن من حقل العنوان 85 × 45 مم، وهو الجزء من الورقة الذي تُظهره نافذة الظرف.

### 4 · ضع ما تشترك فيه الأوراق في الترويسة والتذييل

```js
// script.js, سطرًا 124–146
const header = { elements: [
  // In the H1's design they would stretch its reserve (gotcha: opener-reserves-anchored).
  ...[[DIN.folds[0], 5], [DIN.punch, 8], [DIN.folds[1], 5]].map(([y, length], i) =>
    rule(`mark-${i}`, 'marks', at(0, y, { width: mm(length) }))), // fold, punch, fold
  // Page 1 opens with the H1, which makes it an 'opener'; later sheets are 'body' pages.
  { kind: 'box', id: 'strip', pages: 'body', style: { backgroundColor: col('brand') },
    placement: at(0, 0, { width: 'fill', height: mm(4) }) },
  text('ref', 'Fensterwerkstatt Tessin · Unser Zeichen {attr.unser-zeichen} · {attr.datum}',
    FACE.small, at(DIN.left, 13.2), { pages: 'body' }),
  text('page', 'Seite {pageNumber} von {totalPages}', FACE.small,
    at(210 - DIN.right - 40, 13.2, { width: mm(40) }), { pages: 'body', align: 'right' }),
] };
const FOOT = 297 - DIN.bottom + 5; // mm: the hairline over the company data
const COMPANY = [ // three columns, 57 mm apart, inside the bottom margin
  'Fensterwerkstatt Tessin GmbH\nRennbahnstraße 48\n13086 Berlin\nTelefon 030 23125-400',
  'Geschäftsführer Martin Tessin\nAmtsgericht Charlottenburg\nHRB 000000 B\nUSt-IdNr. DE000000000',
  'Musterbank Berlin\nIBAN DE00 0000 0000 0000 0000 00\nBIC MUSTDEBBXXX',
];
const footer = { elements: [
  rule('foot-rule', 'rule', at(DIN.left, FOOT, { width: mm(TEXT) })),
  ...COMPANY.map((lines, i) => text(`company-${i}`, lines, FACE.small,
    at(DIN.left + 57 * i, FOOT + 2.5, { width: mm(52) }))),
] };
```

العنصر من تصميم H1 المثبَّت تحت الموضوع يزيد الارتفاع الذي يحجزه العنوان. فلو كانت علامة الطي عند 210 مم في التصميم لنزلت التحية إلى 222.8 مم ولامتدت الرسالة إلى ثلاث صفحات. أما خانة الترويسة فتُرسم في كل صفحة ولا تحجز مساحة، لذا توضع فيها العلامات الثلاث ([الترويسات والتذييلات](/ar/docs/configuration#رؤوس-الصفحات-وتذييلاتها)). تبدأ الصفحة 1 بـH1، فهي صفحة `'opener'`، ولذا يقصر `pages: 'body'` الشريط الأزرق وسطر المرجع و`Seite {pageNumber} von {totalPages}` على الأوراق التالية. يقرأ سطر المرجع خصائص H1 عبر `{attr.unser-zeichen}` و`{attr.datum}`.

### 5 · ضع التوقيع في مجرى النص

```js
// script.js, سطرًا 150–159
const resourceTypes = [{ id: 'drawing', name: 'Zeichnung', shortLabel: 'Zeichnung',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', captionPrefix: '' }];
const drawing = (id, width, height, altText, placement) => ({ id, typeId: 'drawing',
  kind: 'svg', svg: { fileId: `${id}.svg`, width, height }, altText, placement,
  createdAt: 0, updatedAt: 0 });
const resources = [ // an image element draws the logo; nothing cites it
  drawing('logo', 200, 280, 'Zeichen der Fensterwerkstatt Tessin, ein Kastenfenster'),
  drawing('signature', 760, 328, 'Unterschrift von Martin Tessin',
    { position: 'here', width: 0.297 }), // 49 × 21.1 mm: under 4 lines, no grid gap below
];
```

يضع `::resource{id="signature"}` الرسم تحت «Mit freundlichen Grüßen»، ويجعله `width: 0.297` بعرض 49 مم وعمق 21.1 مم، أي أقل بقليل من أربعة أسطر ارتفاع كل منها 15 pt. النص الذي يلي موردًا في المجرى يعود إلى شبكة المتن. عند `0.25` كان عمق الرسم 17.8 مم، وأبعدت الـ3.4 مم التي أضافتها الشبكة تحته الاسمَ عن التوقيع. نوع المورد له `captionPrefix` فارغة، ولا يضع الإصدار 1.4.1 تعليقًا حين تكون البادئة والتعليق فارغين معًا، فلا تظهر تحت التوقيع تسمية ولا رقم ([أنواع الموارد](/ar/docs/configuration#أنواع-الموارد)). الشعار مورد من النوع نفسه، لا يرسمه إلا عنصر الصورة. لا يسمّيه أي `:ref` ولا `::resource`، فلا يدخل المجرى أبدًا.

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

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

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

### script.js

```js
// ═══ Postext Cookbook · Nº 058 · Business letter to DIN 5008 ═════════════════════
// https://postext.dev/en/cookbook/din-business-letter
// Code: MIT · Text: original, in German (CC BY 4.0) · Logo, signature: drawn in code (CC BY 4.0)
// Fonts: Nunito Sans, Familjen Grotesk, Reddit Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// A German quotation on A4 whose H1 design pins the letterhead and the address to DIN 5008.
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage }
  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 = 'din-business-letter';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = {
  ink: '#1f2429', brand: '#1b4a73', // text, a blue-grey near-black; Berlin blue, for the band
  sky: '#b4cbe0', paper: '#ffffff', // the trade and the crafts on the band; the logo and name
  muted: '#5b6670', pen: '#26408f', // labels, return line, company data; the signature's ink
  marks: '#98a3ad', rule: '#c9d1d8', // fold marks, window corners; the hairline in the footer
};
// col(id) links a palette entry and keeps its hex for designs (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// main-color is the engine's default accent: the dashes of the list take it.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.brand })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));

// DIN 5008, form B: every position in mm from the sheet's top-left corner.
const DIN = {
  left: 25, right: 20, top: 27, bottom: 30, // the margins of the text
  field: { x: 20, y: 45, w: 85, h: 45 }, // the address field, behind the envelope window
  notes: 17.7, // the field's top zone, for the return line; the address zone takes the rest
  info: { x: 125, y: 50, w: 75 }, // the information block
  folds: [105, 210], punch: 148.5, // fold marks for a DL envelope, and the punch mark
};
const TEXT = 210 - DIN.left - DIN.right; // 165 mm of text width
const WINDOW = DIN.field.w - 2 * (DIN.left - DIN.field.x); // 75 mm: text inset 5 mm each side
const PT = 25.4 / 72; // mm per point
const [LEAD, ROW] = [15, 12]; // pt: the body's leading, a row of the information block

// Design text is centred and cut with '…' by default (gotcha: overflow-ellipsis-default).
const text = (id, content, face, placement, more) => ({ kind: 'text', id, content,
  align: 'left', overflow: 'wrap', ...face, placement, ...more }); // more: pages, align
const rule = (id, colour, placement, direction = 'horizontal') => ({ kind: 'rule', id,
  direction, thickness: pt(0.5), color: col(colour), placement });
const face = (fontFamily, size, fontWeight, colour, more) => ({ fontFamily, fontSize: pt(size),
  fontWeight, color: col(colour), ...more });
const FACE = { // lineHeight is a multiple, never pt() (gotcha: design-lineheight-multiple)
  label: face('Reddit Mono', 7, 400, 'muted', { lineHeight: ROW / 7 }),
  value: face('Nunito Sans', 9, 400, 'ink', { lineHeight: ROW / 9 }),
  from: face('Reddit Mono', 6.5, 400, 'muted', { lineHeight: 1.2 }),
  address: face('Nunito Sans', 10, 400, 'ink', // six lines fill the address zone
    { lineHeight: (DIN.field.h - DIN.notes) / 6 / PT / 10 }),
  subject: face('Familjen Grotesk', 11, 700, 'ink', { lineHeight: LEAD / 11 }),
  small: face('Reddit Mono', 7, 400, 'muted', { lineHeight: 1.45 }),
};
const FROM = 'Fensterwerkstatt Tessin · Rennbahnstr. 48 · 13086 Berlin'; // 72 mm: fits

// #region answer: the subject line's design pins page 1 to the sheet at form B's positions
const at = (x, y, size) => ({ anchor: { to: 'page', edge: 'top-left' }, // mm from the corner
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });
const ZONE = DIN.field.y + DIN.notes; // 62.7 mm: where the six address lines start
const SUBJECT = DIN.field.y + DIN.field.h + 2 * LEAD * PT; // two blank lines under the field
const firstPage = () => ({ enabled: true, slot: { elements: [ // in paint order
  ...letterhead(), // band, logo and name, and the corners of the window
  text('from', FROM, FACE.from, at(DIN.left, ZONE - 3.6)), // the return line, underlined
  rule('from-rule', 'muted', { anchor: { to: '#from', edge: 'below' }, offset: { y: mm(0.6) },
    size: { width: mm(WINDOW) } }),
  // One attribute holds the whole address. Its '\n' starts a new line only when
  // paragraphIndent is above zero (gotcha: design-text-newline).
  text('to', '{attr.to}', { ...FACE.address, paragraphIndent: pt(0.01) },
    at(DIN.left, ZONE, { width: mm(WINDOW) })),
  ...infoBlock(), // labels, and values from {attr.*}
  text('subject', '{titleText}', FACE.subject, at(DIN.left, SUBJECT, { width: mm(TEXT) })),
] } });
// The H1 reserves the sheet down to its lowest element (gotcha: opener-reserves-anchored).
// fontFamily: the design paints the H1, but the build measures it (default: Open Sans).
const headings = () => ({ fontFamily: 'Familjen Grotesk', levels: [{ level: 1,
  span: 'page', // a design kept in the column would be cut off at the top margin
  breakBefore: { enabled: true, parity: 'any' }, // gotcha: headings-drop-h1-break
  marginBottom: pt(2 * LEAD), // two blank lines between the subject and the salutation
  advancedDesign: firstPage() }] });
// #endregion

// #region info: the information block, labels from the stationery, values from the letter
const INFO = [['Ihr Zeichen', 'ihr-zeichen'], ['Ihre Nachricht vom', 'ihre-nachricht'],
  ['Unser Zeichen', 'unser-zeichen'], ['Name', 'name'], ['Telefon', 'telefon'],
  ['E-Mail', 'email'], ['Datum', 'datum']]; // [label, heading attribute]
const VALUE = 29; // mm from the labels to the values
const infoBlock = () => INFO.flatMap(([label, key], i) => [
  text(`label-${i}`, label, FACE.label, i === 0 ? at(DIN.info.x, DIN.info.y) : {
    anchor: { to: `#label-${i - 1}`, edge: 'below' }, // each label under the last
    offset: { y: mm(key === 'datum' ? ROW * PT : 0) } }), // a blank row before the date
  text(`value-${i}`, `{attr.${key}}`, FACE.value, { // a 12 pt row as well, on the label's baseline
    anchor: { to: `#label-${i}`, edge: 'align-top' }, offset: { x: mm(VALUE) },
    size: { width: mm(DIN.info.w - VALUE) } }),
]);
// #endregion

// #region letterhead: a band of Berlin blue, the logo and the name, the window's corners
const [BAND, TICK] = [32, 3]; // mm: the band's depth, each arm of the window's corner angles
const LOGO = { y: 7, h: 18 }; // mm: the mark, centred in the band
const CRAFTS = 3 * 7.5 * 1.45 * PT; // mm: three 7.5 pt lines at 1.45
const letterhead = () => [
  { kind: 'box', id: 'band', style: { backgroundColor: col('brand') },
    placement: at(0, 0, { width: 'fill', height: mm(BAND) }) },
  { kind: 'image', id: 'logo', resourceId: 'logo',
    placement: at(DIN.left, LOGO.y, { height: mm(LOGO.h) }) },
  text('name', 'Tessin', face('Familjen Grotesk', 30, 700, 'paper', { lineHeight: 1 }),
    { anchor: { to: '#logo', edge: 'right-of' }, offset: { x: mm(5), y: mm(1.2) } }),
  text('trade', 'FENSTERWERKSTATT · BERLIN', face('Reddit Mono', 7.5, 500, 'sky',
    { letterSpacing: pt(1.2) }), { anchor: { to: '#name', edge: 'below' },
    offset: { x: mm(0.4), y: mm(1.5) } }),
  text('crafts', 'Kastenfenster\nHolzfenster\nDenkmalpflege', face('Reddit Mono', 7.5,
    400, 'sky', { lineHeight: 1.45, align: 'right' }),
    at(210 - DIN.right - 45, LOGO.y + (LOGO.h - CRAFTS) / 2, { width: mm(45) })),
  ...[[0, 0], [1, 0], [0, 1], [1, 1]].flatMap(([right, low], i) => { // turned inwards
    const [x, y] = [DIN.field.x + right * DIN.field.w, DIN.field.y + low * DIN.field.h];
    return [rule(`across-${i}`, 'marks', at(x - right * TICK, y, { width: mm(TICK) })),
      rule(`down-${i}`, 'marks', at(x, y - low * TICK, { height: mm(TICK) }), 'vertical')];
  }),
];
// #endregion

// #region furniture: marks on every sheet, 'Seite 2 von 2' on the next, the company data
const header = { elements: [
  // In the H1's design they would stretch its reserve (gotcha: opener-reserves-anchored).
  ...[[DIN.folds[0], 5], [DIN.punch, 8], [DIN.folds[1], 5]].map(([y, length], i) =>
    rule(`mark-${i}`, 'marks', at(0, y, { width: mm(length) }))), // fold, punch, fold
  // Page 1 opens with the H1, which makes it an 'opener'; later sheets are 'body' pages.
  { kind: 'box', id: 'strip', pages: 'body', style: { backgroundColor: col('brand') },
    placement: at(0, 0, { width: 'fill', height: mm(4) }) },
  text('ref', 'Fensterwerkstatt Tessin · Unser Zeichen {attr.unser-zeichen} · {attr.datum}',
    FACE.small, at(DIN.left, 13.2), { pages: 'body' }),
  text('page', 'Seite {pageNumber} von {totalPages}', FACE.small,
    at(210 - DIN.right - 40, 13.2, { width: mm(40) }), { pages: 'body', align: 'right' }),
] };
const FOOT = 297 - DIN.bottom + 5; // mm: the hairline over the company data
const COMPANY = [ // three columns, 57 mm apart, inside the bottom margin
  'Fensterwerkstatt Tessin GmbH\nRennbahnstraße 48\n13086 Berlin\nTelefon 030 23125-400',
  'Geschäftsführer Martin Tessin\nAmtsgericht Charlottenburg\nHRB 000000 B\nUSt-IdNr. DE000000000',
  'Musterbank Berlin\nIBAN DE00 0000 0000 0000 0000 00\nBIC MUSTDEBBXXX',
];
const footer = { elements: [
  rule('foot-rule', 'rule', at(DIN.left, FOOT, { width: mm(TEXT) })),
  ...COMPANY.map((lines, i) => text(`company-${i}`, lines, FACE.small,
    at(DIN.left + 57 * i, FOOT + 2.5, { width: mm(52) }))),
] };
// #endregion

// #region signature: a drawing set in the flow by ::resource, with no label or number
const resourceTypes = [{ id: 'drawing', name: 'Zeichnung', shortLabel: 'Zeichnung',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', captionPrefix: '' }];
const drawing = (id, width, height, altText, placement) => ({ id, typeId: 'drawing',
  kind: 'svg', svg: { fileId: `${id}.svg`, width, height }, altText, placement,
  createdAt: 0, updatedAt: 0 });
const resources = [ // an image element draws the logo; nothing cites it
  drawing('logo', 200, 280, 'Zeichen der Fensterwerkstatt Tessin, ein Kastenfenster'),
  drawing('signature', 760, 328, 'Unterschrift von Martin Tessin',
    { position: 'here', width: 0.297 }), // 49 × 21.1 mm: under 4 lines, no grid gap below
];
// #endregion

const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity)
  locale: 'de', resourceTypes, colorPalette, // German hyphenation (gotcha: hyphenation-locales)
  page: { sizePreset: 'custom', width: mm(210), height: mm(297), dpi: 150, margins: {
    top: mm(DIN.top), bottom: mm(DIN.bottom), left: mm(DIN.left), right: mm(DIN.right) } },
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: 'Nunito Sans', fontSize: pt(10.5), lineHeight: pt(LEAD), // justified
    color: col('ink'), boldColor: col('ink'), firstLineIndent: pt(0), // and hyphenated by default
    paragraphSpacing: true }, // DIN 5008: a blank line between paragraphs, no indent
  headings: headings(), header, footer,
  unorderedLists: { bulletChar: '–' }, // in main-color
  paragraphStyles: [
    { id: 'stack', marginBottom: pt(LEAD) }, // name and role, the enclosures: no blank lines
    { id: 'colophon', fontFamily: 'Reddit Mono', fontSize: pt(6.5), lineHeight: pt(9),
      color: col('muted') }, // a blank line under the enclosures, from the stack's margin
  ],
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`# Angebot 2026-117: Instandsetzung der 24 Kastenfenster im Vorderhaus Christburger Straße 17 {to="Hausverwaltung Brenner & Kolb GmbH\nFrau Dr. Ines Kolb\nWinsstraße 11\n10405 Berlin" ihr-zeichen="Ko/CS17-F" ihre-nachricht="03.09.2026" unser-zeichen="MT 2026-117" name="Martin Tessin" telefon="030 23125-418" email="mt@tessin.example" datum="22.09.2026"}

Sehr geehrte Frau Dr. Kolb,

vielen Dank für Ihre Anfrage und für den Ortstermin am 10. September. Wir haben dabei alle 24 Kastenfenster des Vorderhauses aufgenommen, je sechs im ersten bis vierten Obergeschoss, drei zur Straße und drei zum Hof. Die Fenster stammen aus der Bauzeit um 1896. Ihre Rahmen aus Kiefernkernholz sind bis auf wenige Stellen gesund, und in 19 Fenstern sitzt noch das mundgeblasene Zylinderglas. Eine Instandsetzung kostet deshalb deutlich weniger als ein Nachbau, und die Straßenfassade behält ihre Fenster.

Die Schäden liegen vor allem an den Außenflügeln der Hofseite. Dort sind bei elf der zwölf Fenster die Wetterschenkel und die unteren Rahmenecken morsch. Der Kitt ist an fast allen Außenflügeln versprödet und stellenweise herausgebrochen, sodass bei Schlagregen Wasser in die Glasfalze läuft. Die Anstriche aus mehreren Jahrzehnten lösen sich großflächig; nach unserer Probe enthalten die beiden untersten Schichten Blei.

Unser Angebot umfasst die folgenden Leistungen, die im beiliegenden Leistungsverzeichnis einzeln aufgeführt sind:

- Ausbau in vier Etappen zu je sechs Fenstern, erst die Außen-, dann die Innenflügel, sodass jede Wohnung während der Arbeiten geschlossen bleibt
- Entlackung in unserer Werkstatt mit Infrarotwärme statt Lösemitteln, Entsorgung der bleihaltigen Altanstriche als Sonderabfall
- Holzreparatur mit Vierungen aus Altholz, neue Wetterschenkel an 22 Außenflügeln
- Ersatz von 14 gesprungenen oder fehlenden Scheiben durch Restaurierungsglas
- Neuverkittung aller Scheiben mit Leinölkitt, Anstrich mit Leinölfarbe in drei Schichten
- Wiedereinbau, Gängigmachen der Beschläge, eine eingefräste Dichtung in jedem Innenflügel

Für alle 24 Fenster berechnen wir 52.800,00 Euro netto, also 2.200,00 Euro je Fenster. Mit 19 % Umsatzsteuer von 10.032,00 Euro beträgt die Angebotssumme 62.832,00 Euro; das Angebot gilt bis zum 31. Oktober 2026. Die Flügel bauen wir von innen aus; ein Gerüst ist nicht nötig, und Arbeiten an der Fassade sind nicht enthalten.

Die Arbeiten würden wir in der Zeit von Mitte Januar bis Mitte April 2027 ausführen. Die Außenflügel einer Etappe bleiben zwei Wochen in der Werkstatt, die Innenflügel eine. Die Mieterinnen und Mieter informieren wir zwei Wochen vor jeder Etappe mit einem Aushang und einem Brief, der den Tag des Ausbaus nennt. Nach jeder abgeschlossenen Etappe berechnen wir ein Viertel der Angebotssumme, zahlbar innerhalb von 14 Tagen ohne Abzug.

Zur Eigentümerversammlung am 14. Oktober bringe ich gern einen instand gesetzten Außenflügel aus unserer Werkstatt mit, an dem die Eigentümer Glas, Kitt und Anstrich sehen können. Bei Fragen zum Leistungsverzeichnis erreichen Sie mich werktags von 7 bis 16 Uhr unter der Durchwahl 418.

Mit freundlichen Grüßen

::resource{id="signature"}

:::paragraphs{style="stack"}
Martin Tessin

Geschäftsführer
:::

:::paragraphs{style="stack"}
**Anlagen**

Leistungsverzeichnis, 6 Seiten

Fotodokumentation der 24 Fenster

Farbbefund der Außenflügel
:::

:::paragraphs{style="colophon"}
Gesetzt in Nunito Sans, Familjen Grotesk und Reddit Mono (SIL OFL) · Firma, Personen und Bankdaten sind erfunden.
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region art: the logo and the signature, drawn in code
function mulberry32(seed) { // a seeded generator: the same signature on every run
  return () => {
    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;
  };
}
function logoSvg(stroke) { // a Berlin box window: a cross frame, six panes and the sill
  const line = (d, width) => `<path d="${d}" fill="none" stroke="${stroke}" `
    + `stroke-width="${width}"/>`;
  return '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="280" viewBox="0 0 40 56">'
    + line('M1.5 1.5H38.5V51.5H1.5ZM1.5 17H38.5M20 1.5V51.5', 3) + line('M1.5 34.5H38.5', 1.4)
    + line('M0 54.8H40', 2.2) + '</svg>';
}
function catmull(points) { // a smooth path through the points, as cubic Béziers
  const f = (v) => v.toFixed(1);
  let d = `M${f(points[0][0])} ${f(points[0][1])}`;
  for (let i = 0; i < points.length - 1; i++) {
    const [p0, p1, p2, p3] = [points[i - 1] ?? points[i], points[i], points[i + 1],
      points[i + 2] ?? points[i + 1]];
    const c1 = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6];
    const c2 = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6];
    d += `C${f(c1[0])} ${f(c1[1])} ${f(c2[0])} ${f(c2[1])} ${f(p2[0])} ${f(p2[1])}`;
  }
  return d;
}
function signatureSvg() { // 'M. Tessin' in a quick, forward-leaning hand
  const rand = mulberry32(5008);
  const SLANT = 0.32; // the lean: x moves right by a third of the height above the baseline
  const hand = (points) => points.map(([x, y]) => { // shake each point a little, then lean it
    const [jx, jy] = [x + (rand() - 0.5) * 1.4, y + (rand() - 0.5) * 1.4];
    return [jx + SLANT * (60 - jy), jy];
  });
  const strokes = [ // x, y on a 190 × 82 sheet, baseline at 60
    [[3, 63], [7, 44], [11, 18], [14, 9], [17, 22], [19, 44], [21, 60], [23, 44], [27, 22],
      [30, 17], [32, 30], [33, 48], [35, 61], [40, 63], [45, 57]], // M
    [[48.5, 61], [49.5, 60]], // the full stop
    [[50, 25], [55, 20], [78, 15], [102, 12], [128, 9]], // the bar of the T
    [[80, 13], [79, 30], [76, 48], [73, 61], [77, 66], [84, 61], [89, 52], [91, 46], [88, 43],
      [85, 48], [87, 57], [93, 62], [97, 56], [100, 47], [102, 44], [104, 51], [102, 58],
      [99, 61], [103, 62], [108, 58], [111, 48], [113, 44], [115, 51], [113, 58], [110, 61],
      [114, 62], [120, 58], [123, 52], [125, 46], [126, 58], [130, 63], [134, 57], [137, 47],
      [140, 48], [141, 62], [144, 52], [148, 46], [152, 48], [153, 61], [159, 64], [168, 57]],
    [[127, 37], [128.5, 36]], // the dot on the i
    [[168, 57], [174, 50], [172, 58], [156, 70], [120, 76], [80, 76], [50, 72], [36, 68]],
  ];
  const pen = `fill="none" stroke="${palette.pen}" stroke-width="1.9" stroke-linecap="round" `
    + 'stroke-linejoin="round"';
  // The viewBox starts 3 units above the sheet, which lowers the ink towards the typed name.
  return '<svg xmlns="http://www.w3.org/2000/svg" width="760" height="328" viewBox="0 -3 190 82">'
    + strokes.map((points) => `<path d="${catmull(hand(points))}" ${pen}/>`).join('') + '</svg>';
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the pages paint, loaded before the first build (gotcha: fonts-first).
const FONTS = { 'Nunito Sans': ['400', '700'], 'Familjen Grotesk': ['700'],
  'Reddit Mono': ['400', '500'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadSvg('logo.svg', logoSvg(palette.paper));
await loadSvg('signature.svg', signatureSvg());
await loadFonts(FONTS, markdown);
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: t({ en: 'Business letter to DIN 5008', es: 'Carta comercial DIN 5008' }) });
offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),
  `${RECIPE}.pdf`);

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

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

- **تحجز الافتتاحية ارتفاعًا يصل إلى أدنى عناصرها المثبّتة على الصفحة.** تحجز الافتتاحية ذات التصميم المتقدّم ارتفاع أدنى عناصرها، وتُحتسب كذلك العناصر المثبّتة على الصفحة أو على النزف تحت العنوان، فالزخرفة في أسفل الصفحة تدفع النص إلى الصفحة التالية. أبقِ هذه الزخرفة فوق العنوان، أو انقلها إلى خانة في الترويسة أو التذييل، أو حدّد الحجز بـ minHeight.
- **\n في السمة لا يكسر السطر إلا مع paragraphIndent > 0.** في عنصر نص التصميم، لا يبدأ \n المكتوب في قيمة سمة سطرًا جديدًا إلا إذا كانت قيمة paragraphIndent أكبر من الصفر أو كان هناك حرف استهلالي؛ وإلا بقي النص على سطر واحد. اضبط paragraphIndent على قيمة ضئيلة جدًا (0.01 pt)، أو استخدم سمة لكل سطر.
- **عناصر الترويسة والتذييل تُرسَم فوق النص.** تُرسَم عناصر الترويسة والتذييل فوق الصفحة، ولا تُفسح منطقة النص لها مكانًا. أبقِها داخل الهوامش، فالهوامش هي التي تحجز مساحتها.
- **أي كائن headings يُلغي فاصل الصفحة قبل H1.** ينتقل H1 افتراضيًا إلى صفحة فردية (always-odd)، لكن تمرير أي كائن headings يعيد ضبط هذا الافتراض، فتتوالى الفصول دون فاصل ولا يفعل span: 'page' شيئًا. أعد كتابة headings.levels[0].breakBefore: { enabled: true, parity } في كل إعداد.
- **فيض نص التصميم افتراضيًا 'ellipsis-end'.** عنصر نص التصميم الذي لا يتسع له عرضه ينتهي افتراضيًا بعلامة الحذف. اضبط overflow: 'wrap' للعناوين التي يجب أن تنكسر على أسطر أكثر.
- **lineHeight لنص التصميم مُضاعِف، لا بُعد أبدًا.** في خانة التصميم، يضاعف lineHeight لعنصر النص مقاس خطه (lineHeight: 1.05). في postext 1.4.1 لا يُرفض بُعدٌ مثل pt(15): يُقاس ارتفاع الافتتاحية NaN، ويسقط ما تحجزه من مساحة، بما فيه minHeight، دون تحذير، فيجري النص تحت العنوان.
- **لوحة الألوان المستبدلة لا تصل إلى عناصر التصميم ولا إلى لون الإحالة.** يقرأ postext 1.4.1 الإعداد colorPalette في أنماط النص (المتن والعناوين والقوائم والتعليقات والجداول والإطارات) لكن لا في عناصر الترويسات والتذييلات والافتتاحيات وصفحات الأجزاء، ولا في bodyText.referenceColor: تحتفظ بالقيمة الست عشرية المكتوبة بجانب paletteId الخاص بها. حين تستبدل لوحة الألوان، لنسخة شاشة داكنة أو لإعادة تلوين، أعِد كتابة كل لون مرتبط من colorPalette قبل البناء.
- **8 لغات فقط تُقسَّم بالواصلة، بالرمز المطابق تمامًا.** يتوفر تقسيم الكلمات بالواصلة للغات en-us وes وfr وde وit وpt وca وnl، بمطابقة تامة للرمز: 'es-ES' أو أي لغة أخرى تعود دون تنبيه إلى الإنجليزية الأمريكية.
- **يطلب PDF كل وزن وكل نمط من كل عائلة.** يطلب renderToPdf من مزوّد الخطوط الأوجه العريضة والمائلة والعريضة المائلة لكل عائلة قد تستخدمها أي كتلة، حتى التي لا تُطبع أبدًا، ورفضٌ واحد يوقف التصدير. يجب أن يلجأ المزوّد إلى أقرب وزن توفّره العائلة، وأن يعود إلى الوجه القائم حين لا يوجد وجه مائل.

- تقول البطاقة الخاصة بـ`\n` في الخصائص إن النص يبقى على سطر واحد حين تكون `paragraphIndent` عند 0. في الإصدار 1.4.1 يلتفّ العنوان عند عرضه البالغ 75 مم، ويُطبع الحرفان `\n` حيثما كان يجب أن ينكسر السطر.
- يعدّ `{totalPages}` صفحات المستند كله، فلو كانت رسالتان في ملف Markdown واحد لقرأت الورقة الثانية من الرسالة الأولى «Seite 2 von 4». ابنِ كل رسالة وحدها.

## الحقوق

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

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

- [رقم 029 · دليل التثبيت السريع: ملصق مبني من عناصر متسلسلة](https://postext.dev/ar/cookbook/anchoring-cheat-sheet.md): ملصق محاضرات بقياس A3 تتعلّق عناصره بالنزف أو بالصفحة أو بخانتها أو ببعضها، ونسخة ثانية تؤطّر كل عنصر وتضع وسمًا على ثلاثة عشر منها. · المستوى 2 (متوسط) · الأوراق المفردة والمطبوعات العابرة
- [رقم 063 · شهادة بإطار من زخرفة الغيوشيه](https://postext.dev/ar/cookbook/certificate-single-page.md): شهادات إتمام دورة يرسمها نمط عنوان واحد: إطار غيوشيه في ترويسته، وختم وخطوط توقيع في تذييله، والاسم في صفحة افتتاحه. · المستوى 2 (متوسط) · الأوراق المفردة والمطبوعات العابرة
- [رقم 005 · ترويسات بحسب زوجية الصفحة في كتاب مقالات](https://postext.dev/ar/cookbook/running-heads-by-parity.md): عناصر ترويسة مصفّاة بالزوجية ودور الصفحة: عنوان الكتاب على الزوجية، وعنوان المقالة مختصرًا على الفردية، وألسنة أرقام في الهامش، ورقم سفلي في الافتتاح. · المستوى 2 (متوسط) · الأدب القصصي والمسرح والنثر الأدبي
