# ملف PDF موسوم ومتاح للجميع (PDF/UA)

> دليل لإعادة التدوير يُصدَّر ملف PDF/UA-1 موسومًا فيه نص بديل وخلايا عناوين للجداول ولغة المستند ومحتويات مرتبطة، وترسم المعاينة الوسوم على الصفحة 3.

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

## باختصار

دليل بلدي لإعادة التدوير في أربع صفحات. يبيّن كيف تصنع ملف PDF تتابعه قارئات الشاشة، فيه وصف للصور وجداول تستطيع قراءتها بصوت عالٍ.

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

*Reciclar en el barrio* دليل لإعادة التدوير في أربع صفحات بقياس A4، صادر عن بلدية إسبانية متخيَّلة، يُقرأ على الشاشة أو يُطبع في البيت. يُظهر الغلاف الكحلي حاويات الشارع الخمس على رصيف أصفر. تضم الصفحة 2 المحتويات وثلاثة إطارات بجانب رسالة من مستشار البلدية، وتضم الصفحتان 3 و4 القسمين، في عمودين غير مضبوطين بخط Atkinson Hyperlegible Next. يصدّر المثال ملف PDF موسومًا يجتاز فحوص PDF/UA-1 في veraPDF، فيه العناوين والقوائم والإطارات والجداول بترتيب القراءة، ونص بديل للشكل، وخلايا عناوين في الجدولين كليهما. يعلن الملف عنوانه ولغته (`es`)، وترتبط إشاراته المرجعية وأسطر المحتويات بالصفحات. الوسوم لا تُطبع، لذلك يرسمها المثال فوق الصفحة 3 من معاينته، وهي المعروضة هنا، فتقع كل كتلة موسومة في إطار مرقّم يحمل اسم وسمها. ملف PDF المنزَّل خالٍ من هذه الطبقة.

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

- كيف أصدّر ملف PDF موسومًا (PDF/UA) فيه نص بديل وخلايا عناوين للجداول ولغة المستند؟
- كيف أحصل على تسميتي «شكل» و«جدول» بلغة مستندي؟
- كيف أضيف جدول محتويات يحدّث نفسه (نقاط توجيه، أرقام صفحات، مؤلفون، صفوف الأجزاء)؟
- كيف أصدّر ملف PDF حقيقيًّا في المتصفح مع تضمين الخطوط؟

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

```js
// script.js, سطرًا 48–71
// renderToPdf writes a tagged PDF by default: the tag tree follows the headings, paragraphs,
// lists and boxes of the Markdown. Pictures and tables carry their own accessible text here.
const table = (id, tsv, columnWidths, caption, altText) => ({ id, typeId: 'table', kind: 'table',
  caption, altText, createdAt: 0, updatedAt: 0, // altText → the Table's /Summary
  placement: { position: 'here' }, // read where cited, not after the page (gotcha: float-read-last)
  table: { model: { headerRowCount: 1, columnWidths, // row 0: TH cells, scope Column
    rows: parseTSV(tsv).rows.map((row) => row.map(({ content }, c) => ({ content: cell(content),
      ...(c === 0 && { isHeader: true }) }))) } } }); // column 0: TH cells, scope Row
const resources = () => [
  { id: 'contenedores', typeId: 'figure', kind: 'svg', placement: { position: 'here' },
    svg: { fileId: 'contenedores.svg', width: FIGURE[0] * PX, height: FIGURE[1] * PX },
    caption: 'Una isla del barrio: de izquierda a derecha, amarillo, azul, verde, marrón y gris.',
    altText: 'Cinco contenedores en fila: amarillo con una botella de plástico, azul con una caja '
      + 'de cartón, verde con una botella de vidrio, marrón con un corazón de manzana y gris con '
      + 'una bolsa de basura cerrada.', createdAt: 0, updatedAt: 0 }, // → the Figure's /Alt
  table('dudas', dudas, [3, 2], 'Los residuos que más dudas dan.',
    'Once residuos y el contenedor de cada uno.'),
  table('horarios', horarios, [2.2, 4, 1.4], 'Días y horas de recogida.',
    'Qué días y desde qué hora se vacía cada contenedor.'),
  { id: 'calle', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, // the cover's row:
    svg: { fileId: 'calle.svg', width: STREET[0] * PX, height: STREET[1] * PX } }, // an artifact
];
const exportPdf = (doc) => renderToPdf(doc, { fontProvider: fontsourceProvider,
  resourceBytes: imageBytes }); // accessible and outlines default to true
```

## المكونات

**تعلّم**

- [PDF موسوم سهل الوصول](https://postext.dev/ar/docs/configuration.md#إنتاج-pdf-الإعدادات): شجرة بنية PDF/UA-1 بترتيب القراءة: العناوين، والقوائم، والجداول برؤوسها، والأشكال بنصوصها البديلة، والصيغ، والعنوان واللغة.
- [جدول المحتويات](https://postext.dev/ar/docs/configuration.md#جدول-المحتويات): حاوية :::toc تُبنى من العناوين: نقاط إرشادية، وتسميات الصفحات كما طُبعت، وسطور المؤلفين، وصفوف أجزاء ملوّنة، ومداخل قابلة للنقر في PDF.

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

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

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

- [`bodyText`](https://postext.dev/ar/docs/configuration.md#نص-المتن), [`calloutStyles`](https://postext.dev/ar/docs/configuration.md#أنماط-الإطارات), [`captionStyle`](https://postext.dev/ar/docs/configuration.md#نمط-التعليقات), [`colorPalette`](https://postext.dev/ar/docs/configuration.md#لوحة-الألوان), [`footer`](https://postext.dev/ar/docs/configuration.md#رؤوس-الصفحات-وتذييلاتها), [`header`](https://postext.dev/ar/docs/configuration.md#رؤوس-الصفحات-وتذييلاتها), [`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#تقسيم-الكلمات-بالواصلة), [`orderedLists`](https://postext.dev/ar/docs/configuration.md#القوائم-المرقّمة), [`page`](https://postext.dev/ar/docs/configuration.md#الصفحة), [`paragraphStyles`](https://postext.dev/ar/docs/configuration.md#أنماط-الفقرات), [`resourceTypes`](https://postext.dev/ar/docs/configuration.md#أنواع-الموارد), [`tableStyle`](https://postext.dev/ar/docs/configuration.md#نمط-الجداول), [`toc`](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), [`defaultResourceTypes`](https://postext.dev/ar/docs/configuration.md#أنواع-الموارد), [`parseTSV`](https://postext.dev/ar/docs/document-format.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)

**الخطوط**

- Atkinson Hyperlegible Next (OFL-1.1), Atkinson Hyperlegible Mono (OFL-1.1), Public Sans (OFL-1.1)

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

### 1 · لغة المستند وعنوانه

```js
// script.js, سطرًا 127–129
  locale: LANG, // → /Lang es (it hyphenates justified text only: gotcha ragged-no-hyphenation)
  resourceTypes: defaultResourceTypes(LANG), // "Figura", "Tabla" (gotcha: resource-types-locale)
  // /Title and /Author come from the frontmatter, every value quoted (gotcha: quote-frontmatter)
```

يكتب postext-pdf لغة الوسوم من `locale`، وهي هنا `/Lang es`، فيقرأ قارئ الشاشة الدليل بنطق إسباني؛ ومن دونها يعلن الملف `en-US`. يصير `title` و`author` في البيانات التمهيدية (frontmatter) للملف `content.es.md` عنوان ملف PDF ومؤلفه، ويطلب الملف من برامج العرض أن تُظهر ذلك العنوان في شريط النافذة بدل اسم الملف. تعطي `defaultResourceTypes(LANG)` التعليقات والاستشهادات أسماءها الإسبانية: “Figura 1.1” و“tabla 1.1”. أما [الجواب المختصر](#الجواب-المختصر) فيعطي الشكل والجدولين نصهم المتاح: يصير `altText` الوصف البديل للشكل (`/Alt`) و`/Summary` لكل جدول، ويُوسَم الصف الأول والعمود الأول في الجدولين خلايا عناوين.

### 2 · مخطط واحد، وشريط غلاف لا يحمل إلا العنوان

```js
// script.js, سطرًا 75–105
const [BAND, COVER] = [54, 176]; // mm from the trim top to the foot of each band
const band = (height) => ({ kind: 'box', id: 'band', style: { backgroundColor: col('navy') },
  placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(height) } } });
const display = (size, hue) => face('Public Sans', 800, size, { lineHeight: 1, color: col(hue) });
const section = { enabled: true, slot: { elements: [band(BAND),
  text('number', '{number}', display(64, 'signal'), at('container', 'top-left', 0, 4)),
  text('title', '{titleText}', display(28, 'paper'),
    { ...at('#number', 'right-of', 6, 3.5), size: { width: mm(130) } })] } };
// A heading design's text is tagged as the heading, so the cover's band holds only the title.
const cover = { enabled: true, minHeight: mm(COVER - TOP + 24), slot: { elements: [band(COVER),
  { kind: 'box', id: 'kerb', style: { backgroundColor: col('signal') },
    placement: { ...at('bleed', 'top-left', 0, COVER), size: { width: 'fill', height: mm(3) } } },
  { kind: 'image', id: 'bins', resourceId: 'calle', // the wheels stand on the kerb
    placement: { ...at('#kerb', 'align-bottom', SIDE), size: { width: mm(STREET[0]) } } },
  text('title', '{titleText}', display(66, 'paper'),
    { ...at('container', 'top-left', 0, 26), size: { width: mm(STREET[0]) } })] } };
const plain = (id, more) => ({ id, numbered: false, span: 'column', ...more,
  advancedDesign: { enabled: false }, fontSize: pt(26), lineHeight: pt(2 * LEAD) });
const headingStyles = [
  { id: 'portada', numbered: false, toc: false, span: 'page', advancedDesign: cover,
    layout: { layoutType: 'single' }, footer: { elements: [] } },
  plain('indice', { toc: false }), // the contents leave their own heading out
  plain('presentacion', { breakBefore: { enabled: false } }), // gotcha: style-inherits-break
];
const headings = { fontFamily: 'Public Sans', fontWeight: 800, color: col('ink'),
  levels: [ // restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break)
    { level: 1, numberingTemplate: '{1}', breakBefore: { enabled: true, parity: 'any' },
      span: 'page', advancedDesign: section, marginBottom: pt(LEAD) }, // 9.7 mm under the band
    { level: 2, fontWeight: 700, fontSize: pt(13.5), lineHeight: pt(LEAD), marginTop: pt(0),
      marginBottom: pt(0) }, // the line above an H2 is the paragraph's, or the resource's, gap
  ] };
```

تأخذ شجرة الوسوم عناصر H1 وH2 من عناوين Markdown، وتأتي الإشارات المرجعية في ملف PDF من القائمة نفسها، فيكون الغلاف والمحتويات والرسالة والقسمان في المستوى 1، والعناوين الفرعية في المستوى 2. يَسِم postext-pdf كل عنصر نصي في تصميم العنوان على أنه جزء من ذلك العنوان، لذلك لا يحمل شريط الغلاف إلا العنوان، وتُنضَّد المقدمة واسم البلدية تحته فقرتين. الأشرطة الكحلية والرصيف وصف الحاويات عناصر تصميم، ويَسِم ملف PDF إطارات التصميم وصوره بأنها زخارف (artifacts)، فلا تحتاج إلى نص بديل ويتخطاها قارئ الشاشة.

### 3 · محتويات مرتبطة بالصفحات

```js
// script.js, سطرًا 109–114
const [NUMBER, GAP] = [6, 2.5]; // mm: the number column and the gap before a title
const toc = { levels: [{ level: 1, ...face('Public Sans', 700, 12), numberWidth: mm(NUMBER),
  numberGap: mm(GAP), marginTop: pt(LEAD / 2) }, { level: 2, indent: mm(NUMBER + GAP) }],
  unnumbered: { indent: mm(NUMBER + GAP) }, leader: { gap: mm(1.5) }, // Presentación: no number
  // The leaders take this face, not Public Sans (gotcha: toc-leader-kerning)
  pageNumber: { fontFamily: 'Atkinson Hyperlegible Next', fontWeight: 700, width: mm(8) } };
```

يسرد `:::toc` عناصر H1 وH2 مع تسميات الصفحات التي تطبعها، وفي ملف PDF يرتبط كل سطر من أسطره الأحد عشر بأعلى صفحته. وترتبط استشهادات `:ref` “figura 1.1” و“tabla 1.1” في الصفحة 3 و“tabla 2.1” في الصفحة 4 بالشكل والجدولين. تأخذ الخطوط المنقّطة خط أرقام الصفحات، Atkinson Hyperlegible Next Bold، لأن سلسلة النقاط في Public Sans تتباعد أكثر من النقطة الواحدة التي يُحسب الخط المنقّط على أساسها، فكانت النقاط تتداخل مع الأرقام.

### 4 · ترتيب القراءة مرسومًا على الصفحة

```js
// script.js, سطرًا 301–318
// An opener band's title, each column from top to bottom, then the page's floats. A paragraph
// continued in the next column stays one element, so its second part keeps its number.
const tagOf = (b) => ({ heading: `H${b.headingLevel ?? 1}`, callout: 'Div', listItem: 'LI',
  resource: b.resourceBlock?.kind === 'table' ? 'Table' : 'Figure' })[b.type] ?? 'P';
function readingOrder(page) {
  const ids = new Map(); // element → its number
  const add = (block, box) => {
    const key = block.id.replace(/-cont-\d+$/, ''); // fragments share their block's id
    if (!ids.has(key)) ids.set(key, ids.size + 1);
    return { n: ids.get(key), tag: tagOf(block), box, cont: key !== block.id };
  };
  const blocks = page.columns.flatMap((c) => c.blocks);
  const title = blocks.find((b) => b.hidden && b.type === 'heading'); // drawn by the band
  return [...(page.openerBand && title ? [add(title, union(page.openerBand.blocks
    .filter((b) => b.kind === 'text')))] : []),
  ...blocks.filter((b) => !b.hidden).map((b) => add(b, b.bbox)),
  ...(page.floats ?? []).map((b) => add(b, b.bbox))];
}
```

يتبع قارئ الشاشة الوسوم، و`renderToPdf` يَسِم كل صفحة بالترتيب الذي يرسمها به: عنوان الشريط، ثم العمود الأول من أعلاه إلى أسفله، ثم الثاني، ثم العناصر العائمة في الصفحة. تعيد هذه الدالة ذلك الترتيب لترقّم الإطارات في الصفحة 3، وتسرد شجرة البنية في ملف PDF العناصر الأربعة عشر نفسها بالترتيب نفسه. ولهذا الترتيب يقع الشكل والجدولان في مجرى النص، مع `placement: { position: 'here' }` وسطر `::resource`. فلو عُوِّم الشكل 1.1 إلى أسفل العمود الأول لوُسم بعد الجدول 1.1، آخر كتلة في صفحته. وتحتفظ فقرة الزجاج الممتدة إلى العمود الثاني برقم واحد، 9، لأن ملف PDF يَسِم جزأيها فقرة واحدة.

### 5 · ألوان مختارة بنسبة التباين

```js
// script.js, سطرًا 16–35
const palette = {
  ink: '#14243a', // text: 15.6:1 on white
  navy: '#173556', // bands: white type on it 12.5:1
  signal: '#f2b705', // yellow: only on navy (6.9:1) or as a fill, never as text on white
  tint: '#e8eef5', // header cells and boxes: ink on it 13.4:1
  rule: '#aebccb', // table rules
  muted: '#4a5a6e', // footer and colophon: 7.0:1 on white
  paper: '#ffffff',
};
// hex beside the id: designs read the hex (gotcha: palette-skips-designs)
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// 'main-color' is the id the engine's default styles link to: any default left in them is navy
const colorPalette = [...Object.entries(palette), ['main-color', palette.navy]]
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const BIN = { amarillo: '#f2b705', azul: '#1f6fc5', verde: '#2e8b4a', marron: '#8a5a2f',
  gris: '#6b7480' }; // the street bins' colours, for the pictograms and the swatches
const NAME = { amarillo: 'Amarillo', azul: 'Azul', verde: 'Verde', marron: 'Marrón', gris: 'Gris',
  punto: 'Punto limpio' }; // a table cell never shows a colour alone: its name goes beside it
const cell = (text) => (text in NAME // an unknown colour ('none') draws an empty square
  ? `:swatch{color="${BIN[text] ?? 'none'}"} ${NAME[text]}` : text);
```

لا يفحص PDF/UA-1 الألوان، لكن WCAG 2.2 يطلب نسبة 4.5:1 بين النص وخلفيته. يبلغ الحبر 15.6:1 على الأبيض، والحروف البيضاء 12.5:1 على الأشرطة الكحلية، ورمادي التذييل 7.0:1. ويعطي الأصفر 1.8:1 على الأبيض، لذلك لا يظهر إلا على الكحلي (6.9:1) وكلون تعبئة. وتتبع الرسوم الرمزية نسبة 3:1 التي يطلبها WCAG للرسوم: أبيض حيث يبلغها مقابل لون الحاوية، وبلون الحبر على الحاوية الصفراء. تكتب `cell()` اسم كل حاوية بجانب مربعها اللوني، وتعطي مركز إعادة التدوير مربعًا فارغًا، فلا تعتمد أي تعليمة على التمييز بين الألوان.

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

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

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

### script.js

```js
// ═══ Postext Cookbook · Nº 039 · Accessible tagged PDF (PDF/UA) ═════════════════════
// https://postext.dev/en/cookbook/accessible-tagged-pdf
// Code: MIT · Text: original (CC BY 4.0) · Pictograms: generated in code (CC BY 4.0)
// Fonts: Atkinson Hyperlegible Next and Mono, Public Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  defaultResourceTypes, parseTSV,
} 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 = 'accessible-tagged-pdf';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: text colours chosen for their contrast, and never a colour without its name
const palette = {
  ink: '#14243a', // text: 15.6:1 on white
  navy: '#173556', // bands: white type on it 12.5:1
  signal: '#f2b705', // yellow: only on navy (6.9:1) or as a fill, never as text on white
  tint: '#e8eef5', // header cells and boxes: ink on it 13.4:1
  rule: '#aebccb', // table rules
  muted: '#4a5a6e', // footer and colophon: 7.0:1 on white
  paper: '#ffffff',
};
// hex beside the id: designs read the hex (gotcha: palette-skips-designs)
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// 'main-color' is the id the engine's default styles link to: any default left in them is navy
const colorPalette = [...Object.entries(palette), ['main-color', palette.navy]]
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const BIN = { amarillo: '#f2b705', azul: '#1f6fc5', verde: '#2e8b4a', marron: '#8a5a2f',
  gris: '#6b7480' }; // the street bins' colours, for the pictograms and the swatches
const NAME = { amarillo: 'Amarillo', azul: 'Azul', verde: 'Verde', marron: 'Marrón', gris: 'Gris',
  punto: 'Punto limpio' }; // a table cell never shows a colour alone: its name goes beside it
const cell = (text) => (text in NAME // an unknown colour ('none') draws an empty square
  ? `:swatch{color="${BIN[text] ?? 'none'}"} ${NAME[text]}` : text);
// #endregion

const TRIM = [210, 297]; // A4, the size residents print at home
const [TOP, FOOT, SIDE, LEAD] = [20, 22, 18, 15.5]; // margins in mm, not mirrored; leading in pt
const [PX, FIGURE, STREET] = [12, [83, 30], [174, 44]]; // drawings: px per mm, sizes in mm
const face = (fontFamily, fontWeight, size, more) => ({ fontFamily, fontWeight, fontSize: pt(size),
  ...more });
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const text = (id, content, style, placement) => ({ kind: 'text', id, content, align: 'left',
  overflow: 'wrap', ...style, placement }); // default: '…' (gotcha: overflow-ellipsis-default)

// #region answer: the alt text and header cells the tags take from the resources
// renderToPdf writes a tagged PDF by default: the tag tree follows the headings, paragraphs,
// lists and boxes of the Markdown. Pictures and tables carry their own accessible text here.
const table = (id, tsv, columnWidths, caption, altText) => ({ id, typeId: 'table', kind: 'table',
  caption, altText, createdAt: 0, updatedAt: 0, // altText → the Table's /Summary
  placement: { position: 'here' }, // read where cited, not after the page (gotcha: float-read-last)
  table: { model: { headerRowCount: 1, columnWidths, // row 0: TH cells, scope Column
    rows: parseTSV(tsv).rows.map((row) => row.map(({ content }, c) => ({ content: cell(content),
      ...(c === 0 && { isHeader: true }) }))) } } }); // column 0: TH cells, scope Row
const resources = () => [
  { id: 'contenedores', typeId: 'figure', kind: 'svg', placement: { position: 'here' },
    svg: { fileId: 'contenedores.svg', width: FIGURE[0] * PX, height: FIGURE[1] * PX },
    caption: 'Una isla del barrio: de izquierda a derecha, amarillo, azul, verde, marrón y gris.',
    altText: 'Cinco contenedores en fila: amarillo con una botella de plástico, azul con una caja '
      + 'de cartón, verde con una botella de vidrio, marrón con un corazón de manzana y gris con '
      + 'una bolsa de basura cerrada.', createdAt: 0, updatedAt: 0 }, // → the Figure's /Alt
  table('dudas', dudas, [3, 2], 'Los residuos que más dudas dan.',
    'Once residuos y el contenedor de cada uno.'),
  table('horarios', horarios, [2.2, 4, 1.4], 'Días y horas de recogida.',
    'Qué días y desde qué hora se vacía cada contenedor.'),
  { id: 'calle', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, // the cover's row:
    svg: { fileId: 'calle.svg', width: STREET[0] * PX, height: STREET[1] * PX } }, // an artifact
];
const exportPdf = (doc) => renderToPdf(doc, { fontProvider: fontsourceProvider,
  resourceBytes: imageBytes }); // accessible and outlines default to true
// #endregion

// #region headings: one outline: cover and contents unnumbered, then sections 1 and 2 with H2s
const [BAND, COVER] = [54, 176]; // mm from the trim top to the foot of each band
const band = (height) => ({ kind: 'box', id: 'band', style: { backgroundColor: col('navy') },
  placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(height) } } });
const display = (size, hue) => face('Public Sans', 800, size, { lineHeight: 1, color: col(hue) });
const section = { enabled: true, slot: { elements: [band(BAND),
  text('number', '{number}', display(64, 'signal'), at('container', 'top-left', 0, 4)),
  text('title', '{titleText}', display(28, 'paper'),
    { ...at('#number', 'right-of', 6, 3.5), size: { width: mm(130) } })] } };
// A heading design's text is tagged as the heading, so the cover's band holds only the title.
const cover = { enabled: true, minHeight: mm(COVER - TOP + 24), slot: { elements: [band(COVER),
  { kind: 'box', id: 'kerb', style: { backgroundColor: col('signal') },
    placement: { ...at('bleed', 'top-left', 0, COVER), size: { width: 'fill', height: mm(3) } } },
  { kind: 'image', id: 'bins', resourceId: 'calle', // the wheels stand on the kerb
    placement: { ...at('#kerb', 'align-bottom', SIDE), size: { width: mm(STREET[0]) } } },
  text('title', '{titleText}', display(66, 'paper'),
    { ...at('container', 'top-left', 0, 26), size: { width: mm(STREET[0]) } })] } };
const plain = (id, more) => ({ id, numbered: false, span: 'column', ...more,
  advancedDesign: { enabled: false }, fontSize: pt(26), lineHeight: pt(2 * LEAD) });
const headingStyles = [
  { id: 'portada', numbered: false, toc: false, span: 'page', advancedDesign: cover,
    layout: { layoutType: 'single' }, footer: { elements: [] } },
  plain('indice', { toc: false }), // the contents leave their own heading out
  plain('presentacion', { breakBefore: { enabled: false } }), // gotcha: style-inherits-break
];
const headings = { fontFamily: 'Public Sans', fontWeight: 800, color: col('ink'),
  levels: [ // restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break)
    { level: 1, numberingTemplate: '{1}', breakBefore: { enabled: true, parity: 'any' },
      span: 'page', advancedDesign: section, marginBottom: pt(LEAD) }, // 9.7 mm under the band
    { level: 2, fontWeight: 700, fontSize: pt(13.5), lineHeight: pt(LEAD), marginTop: pt(0),
      marginBottom: pt(0) }, // the line above an H2 is the paragraph's, or the resource's, gap
  ] };
// #endregion

// #region contents: the H1s and H2s with their page numbers; each row links in the PDF
const [NUMBER, GAP] = [6, 2.5]; // mm: the number column and the gap before a title
const toc = { levels: [{ level: 1, ...face('Public Sans', 700, 12), numberWidth: mm(NUMBER),
  numberGap: mm(GAP), marginTop: pt(LEAD / 2) }, { level: 2, indent: mm(NUMBER + GAP) }],
  unnumbered: { indent: mm(NUMBER + GAP) }, leader: { gap: mm(1.5) }, // Presentación: no number
  // The leaders take this face, not Public Sans (gotcha: toc-leader-kerning)
  pageNumber: { fontFamily: 'Atkinson Hyperlegible Next', fontWeight: 700, width: mm(8) } };
// #endregion

const label = (size, color, weight = 400) => face('Atkinson Hyperlegible Mono', weight, size,
  { color: col(color) });
const footer = { elements: [ // the PDF tags these as pagination artifacts
  text('where', '{title} · Castrovalle', { ...label(7.5, 'muted'), letterSpacing: pt(0.6),
    overflow: 'clip' }, at('container', 'bottom-left', 0, -11)),
  text('folio', '{pageNumber}', { ...label(9, 'ink', 700), align: 'right', overflow: 'clip' },
    at('container', 'bottom-right', 0, -10.6))] };

const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity)
  // #region identity: the language the PDF declares, and captions in that language
  locale: LANG, // → /Lang es (it hyphenates justified text only: gotcha ragged-no-hyphenation)
  resourceTypes: defaultResourceTypes(LANG), // "Figura", "Tabla" (gotcha: resource-types-locale)
  // /Title and /Author come from the frontmatter, every value quoted (gotcha: quote-frontmatter)
  // #endregion
  colorPalette, headings, headingStyles, toc, footer,
  header: { elements: [] }, // each page opens with an H1, so the folio goes in the footer
  page: { width: mm(TRIM[0]), height: mm(TRIM[1]), dpi: 150,
    margins: { top: mm(TOP), bottom: mm(FOOT), left: mm(SIDE), right: mm(SIDE) } },
  layout: { gutterWidth: mm(8) }, // two columns: the default layout
  bodyText: { fontFamily: 'Atkinson Hyperlegible Next', fontSize: pt(10.5), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), textAlign: 'left',
    firstLineIndent: mm(0), paragraphSpacing: true }, // ragged, so no runt check: ragged-runts
  unorderedLists: { color: col('ink'), marginTop: pt(0), marginBottom: pt(LEAD) },
  orderedLists: { color: col('ink'), fontWeight: 700, marginTop: pt(0), marginBottom: pt(LEAD) },
  paragraphStyles: [{ id: 'entradilla', fontSize: pt(17), lineHeight: pt(24) },
    { id: 'carta', fontSize: pt(12), lineHeight: pt(18), spaceBetween: pt(9) },
    // the council's imprint drops seven lines below the lead, to the cover's last line
    { id: 'sello', ...label(9, 'ink'), lineHeight: pt(LEAD), marginTop: pt(7 * LEAD) },
    { id: 'colofon', ...label(7, 'muted'), lineHeight: pt(10), marginTop: pt(LEAD) }],
  calloutStyles: [{ id: 'formatos', background: col('tint'), padding: mm(4), snapToGrid: false,
    titleStyle: { ...label(8, 'navy', 700), letterSpacing: pt(0.6), textTransform: 'uppercase' },
    marginTop: mm(4.5), body: { fontSize: pt(10), lineHeight: pt(14.5) } }],
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackground: col('tint'), headerColor: col('ink'), headerFontSize: pt(9.5),
    bodyFontSize: pt(9.5), cellPadding: mm(1.35) }, // faces and colours follow the body text
  captionStyle: { fontSize: pt(9) },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Reciclar en el barrio"
subtitle: "Guía de residuos del barrio de la Estación"
author: "Ayuntamiento de Castrovalle"
---

# Reciclar \\ en el barrio {style="portada"}

:::paragraphs{style="entradilla"}
Dónde va cada residuo del barrio de la Estación, del brik de leche al sofá viejo, y a qué hora pasa cada camión.
:::

:::paragraphs{style="sello"}
**Ayuntamiento de Castrovalle** · Concejalía de Medio Ambiente · Enero de 2026
:::

# Índice {style="indice"}

:::toc

:::callout{type="formatos" title="Cómo usar esta guía"}
La sección 1 recorre la isla de contenedores de izquierda a derecha, y su tabla resuelve los residuos que más dudas dan. La sección 2 trata lo que no va al contenedor: el punto limpio, los horarios de recogida y la retirada de muebles.

Cada cuadrado de color de las tablas lleva al lado el nombre de su contenedor, para que nadie tenga que distinguir los colores.
:::

:::callout{type="formatos" title="Esta guía, en otros formatos"}
Este PDF está etiquetado. Un lector de pantalla recorre sus títulos, listas y tablas en el orden de lectura y lee la descripción de cada figura, y cada fila del índice lleva a su página.

Si la prefieres en letra grande, en lectura fácil o en papel, pídela en el 010 o en la Oficina de Atención a la Ciudadanía. Te la enviamos a casa.
:::

:::callout{type="formatos" title="Datos útiles"}
- **Información municipal:** 010, de lunes a sábado, de 8:00 a 20:00.
- **Punto limpio:** avenida de los Álamos, 14.
- **Muebles y enseres:** cita previa en el 010.
- **Atención a la Ciudadanía:** plaza Mayor, 1.
:::

:::columnbreak

# Presentación {style="presentacion"}

:::paragraphs{style="carta"}
En el barrio de la Estación viven unas 6400 personas, y cada una tira algo más de un kilo de basura al día. Hasta hace un año, casi todo acababa en el mismo sitio. Desde marzo hay contenedor marrón en todas las calles, y con él son cinco los contenedores de cada isla.

Esta guía explica qué va en cada contenedor y qué hacer con lo que no cabe en ninguno: el sofá viejo, el aceite de la sartén, las pilas o una lámpara rota. Los horarios de recogida y la dirección del punto limpio están en la última página.

Lo que se echa al contenedor marrón se convierte en compost en la planta de la comarca y vuelve a los parques y jardines del municipio. Para que ese compost sirva, la materia orgánica tiene que llegar limpia, sin bolsas de plástico ni restos de vidrio.

En la planta de clasificación, un envase echado al contenedor equivocado se aparta a mano. Una bolsa de basura en el contenedor del papel moja y mancha el cartón, y puede echar a perder la carga entera del camión.

Entre marzo y diciembre, el barrio llevó al contenedor marrón 312 toneladas de restos de comida, una de cada siete toneladas de la basura que tiró en esos meses. Este año queremos llegar a una de cada cuatro, y para eso basta con que cada casa separe sus restos de comida.

*Marta Ibarra, concejala de Medio Ambiente*
:::

# Qué va en cada contenedor

En cada calle del barrio hay una isla de cinco contenedores, siempre en el mismo orden, el de la :ref{id="contenedores" style="full" case="lower"}. Cada color recoge un tipo de residuo, pero no todo el plástico va al amarillo ni todo el vidrio al verde.

::resource{id="contenedores"}

## Amarillo: envases

Envases de plástico, latas y briks: botellas de agua y de refresco, botes de champú y de detergente, latas de conserva y de bebida, bolsas de plástico, bandejas de corcho blanco, papel de aluminio, chapas y tapas de metal. Vacíalos antes de tirarlos; no hace falta lavarlos. Un juguete o un cubo no son envases, aunque sean de plástico, y van al contenedor gris.

## Azul: papel y cartón

Periódicos, revistas, folletos, sobres, cajas de cartón y hueveras de cartón. Pliega las cajas antes de echarlas, para que quepan más. Las servilletas y el papel de cocina usados van al marrón, y los tiques de compra, de papel térmico, al gris.

## Verde: vidrio

Botellas, tarros y frascos de vidrio, sin tapas ni tapones: los de metal y plástico van al amarillo, y los de corcho, al marrón. Los vasos, las copas y los platos no son vidrio de envase: funden a otra temperatura y estropean el vidrio que se recicla. Van al gris, y los espejos y los cristales de ventana, al punto limpio.

## Marrón: orgánico

Restos de comida, crudos o cocinados: mondas de fruta, cáscaras de huevo, espinas, posos de café e infusiones, flores secas, tapones de corcho y papel de cocina sucio. Usa bolsas compostables, con la marca de la norma UNE-EN 13432: en la planta, las bolsas de plástico se retiran a mano antes de hacer el compost.

## Gris: resto

Lo que no va en ninguno de los otros cuatro: pañales y compresas, colillas, excrementos de mascotas y arena del gato, polvo de barrer, chicles, cerámica y loza rota. Antes de echar algo al gris, busca en la :ref{id="dudas" style="full" case="lower"} si tiene un sitio mejor, y echa siempre la bolsa cerrada.

::resource{id="dudas"}

# Lo que no va al contenedor

Algunos residuos no caben en ninguna isla, por su tamaño o porque contaminan. Se llevan al punto limpio, se dejan en los puntos de recogida de las tiendas o se recogen a domicilio.

## El punto limpio

Está en la avenida de los Álamos, 14, junto a la rotonda del polígono, y abre de martes a sábado, de 9:00 a 14:00 y de 16:00 a 19:30. Allí se dejan, sin coste para los vecinos:

- aceite de cocina usado, en una botella de plástico cerrada;
- pilas, baterías y bombillas;
- pequeños aparatos eléctricos, como secadores, móviles o cargadores;
- pintura, disolventes y aerosoles con restos;
- espejos, cristales de ventana y radiografías;
- restos de poda, en sacos de hasta 25 kilos.

Las pilas y las bombillas también se pueden dejar en los contenedores de las tiendas que las venden, y los medicamentos, con su caja, en cualquier farmacia. La ropa y el calzado usados tienen sus propios contenedores, en la plaza del Mercado y junto al centro de salud.

## Cuándo pasa cada camión

Los contenedores se vacían de noche, salvo el del vidrio, que se vacía por la mañana. La :ref{id="horarios" style="full" case="lower"} da el día y la hora de cada recogida. Echa las bolsas a partir de las 20:00, para que pasen el menor tiempo posible en la calle, y el vidrio, de día, porque hace ruido.

::resource{id="horarios"}

## Muebles y enseres

Un colchón, una silla o una lavadora no se dejan junto a los contenedores. Si compras un electrodoméstico nuevo, la tienda se lleva el viejo sin cobrarte nada. Lo demás lo retira gratis el servicio municipal, en la puerta de casa:

1. Llama al 010 o pide cita en la sede electrónica del Ayuntamiento.
2. Apunta el día y el número de recogida.
3. Pega en cada objeto un papel con ese número.
4. Esa noche, desde las 21:00, saca los objetos a la acera de tu portal.

Si tienes una duda que esta guía no resuelve, llama al 010, de lunes a sábado de 8:00 a 20:00, o pregunta en el punto limpio. Las preguntas que más se repitan entrarán en la próxima edición.

:::paragraphs{style="colofon"}
Castrovalle es un municipio imaginario, y esta guía se escribió para el Recetario de Postext (postext.dev). Compuesta en Atkinson Hyperlegible Next, Atkinson Hyperlegible Mono y Public Sans (SIL Open Font License). Texto e ilustraciones: CC BY 4.0.
:::
`; // content.<lang>.md, inlined by the Cookbook
const dudas = String.raw`Residuo	Contenedor
Brik de leche o de zumo	amarillo
Tapa de un tarro de cristal	amarillo
Papel de aluminio	amarillo
Bolsa de patatas fritas	amarillo
Caja de pizza	azul
Frasco de colonia	verde
Servilleta de papel usada	marron
Vaso o copa de cristal	gris
Tique de compra	gris
Bombilla	punto
Aceite de cocina usado	punto
`; // TSV, as a spreadsheet exports it: residuo, contenedor
const horarios = String.raw`Contenedor	Días	Desde
amarillo	Lunes, miércoles y viernes	22:00
azul	Martes y sábados	22:00
verde	Jueves alternos	8:00
marron	Todos los días	23:00
gris	Todos los días	23:00
`; // TSV: contenedor, días, desde qué hora

// #region reading-order: the order of the tags, which is the order renderToPdf paints the page in
// An opener band's title, each column from top to bottom, then the page's floats. A paragraph
// continued in the next column stays one element, so its second part keeps its number.
const tagOf = (b) => ({ heading: `H${b.headingLevel ?? 1}`, callout: 'Div', listItem: 'LI',
  resource: b.resourceBlock?.kind === 'table' ? 'Table' : 'Figure' })[b.type] ?? 'P';
function readingOrder(page) {
  const ids = new Map(); // element → its number
  const add = (block, box) => {
    const key = block.id.replace(/-cont-\d+$/, ''); // fragments share their block's id
    if (!ids.has(key)) ids.set(key, ids.size + 1);
    return { n: ids.get(key), tag: tagOf(block), box, cont: key !== block.id };
  };
  const blocks = page.columns.flatMap((c) => c.blocks);
  const title = blocks.find((b) => b.hidden && b.type === 'heading'); // drawn by the band
  return [...(page.openerBand && title ? [add(title, union(page.openerBand.blocks
    .filter((b) => b.kind === 'text')))] : []),
  ...blocks.filter((b) => !b.hidden).map((b) => add(b, b.bbox)),
  ...(page.floats ?? []).map((b) => add(b, b.bbox))];
}
// #endregion

// #region art: the five street bins, and the reading order painted over page 3
const n = (v) => Math.round(v * 100) / 100;
const rgb = (hex) => hex.slice(1).match(/../g).map((c) => parseInt(c, 16));
const shade = (hex, k) => `#${rgb(hex).map((c) => Math.round(c * k).toString(16).padStart(2, '0'))
  .join('')}`;
const luminance = (hex) => rgb(hex).map((c) => c / 255).map((c) => (c <= 0.03928 ? c / 12.92
  : ((c + 0.055) / 1.055) ** 2.4)).reduce((sum, c, i) => sum + c * [0.2126, 0.7152, 0.0722][i], 0);
// A white glyph where it reaches 3:1 against the bin (WCAG's figure for graphics), else ink.
const glyphOn = (hex) => (1.05 / (luminance(hex) + 0.05) >= 3 ? '#ffffff' : palette.ink);
// Pictograms in an 8 × 10 box centred on (0, 0): a bottle, a box, a wine bottle, an apple core
// and a tied bag. Strokes only, so they stay vector in the PDF.
const GLYPH = {
  amarillo: 'M-1.2 -5L1.2 -5L1.2 -3.6C2.8 -3 3 -2 3 -1L3 4.2C3 4.8 2.6 5 2 5L-2 5C-2.6 5 -3 4.8 '
    + '-3 4.2L-3 -1C-3 -2 -2.8 -3 -1.2 -3.6Z M-3 0.6L3 0.6',
  azul: 'M-4 -1L0 -3L4 -1L4 4L0 5.6L-4 4Z M-4 -1L0 1L4 -1 M0 1L0 5.6 M-4 -1L-5 -3.4L-1 -5.4L0 -3',
  verde: 'M-0.9 -5.4L0.9 -5.4L0.9 -2.2C2.6 -1.4 2.8 -0.4 2.8 0.8L2.8 4.6C2.8 5.1 2.5 5.4 2 5.4L-2 '
    + '5.4C-2.5 5.4 -2.8 5.1 -2.8 4.6L-2.8 0.8C-2.8 -0.4 -2.6 -1.4 -0.9 -2.2Z',
  marron: 'M-2.6 -3.2C-0.6 -3.8 0.6 -3.8 2.6 -3.2C1.2 -1.6 1.2 1.6 2.6 3.6C0.6 4.4 -0.6 4.4 '
    + '-2.6 3.6C-1.2 1.6 -1.2 -1.6 -2.6 -3.2Z M0 -3.6L0.4 -5.6 M0.4 -5C1.6 -6 2.8 -5.6 3.2 -5',
  gris: 'M-3.4 -1.6C-3.8 1.4 -3.4 5 0 5C3.4 5 3.8 1.4 3.4 -1.6C2.6 -2.6 1 -3 0 -3.2C-1 -3 '
    + '-2.6 -2.6 -3.4 -1.6Z M-1.6 -3.1L-2.4 -5.2L0 -4L2.4 -5.2L1.6 -3.1',
};
function binsSvg([w, h], size) { // five bins across w mm, their wheels on the foot of the drawing
  const step = w / 5;
  const bins = Object.keys(BIN).map((id, i) => {
    const [cx, bw, r] = [step * (i + 0.5), size * 0.68, size * 0.06]; // centre, width, wheel
    const [top, foot] = [h - size - r, h - r];
    const lid = `M${n(cx - bw / 2 - r / 2)} ${n(top)}L${n(cx + bw / 2 + r / 2)} ${n(top)}`
      + `L${n(cx + bw / 2)} ${n(top - size * 0.1)}L${n(cx - bw / 2)} ${n(top - size * 0.1)}Z`;
    const body = `M${n(cx - bw / 2)} ${n(top)}L${n(cx + bw / 2)} ${n(top)}L${n(cx + bw * 0.46)} `
      + `${n(foot)}L${n(cx - bw * 0.46)} ${n(foot)}Z`;
    const wheel = (x) => `<circle cx="${n(x)}" cy="${n(foot)}" r="${n(r)}" fill="${palette.ink}"/>`;
    return `<path d="${body}" fill="${BIN[id]}"/><path d="${lid}" fill="${shade(BIN[id], 0.72)}"/>`
      + wheel(cx - bw * 0.34) + wheel(cx + bw * 0.34)
      + `<path d="${GLYPH[id]}" transform="translate(${n(cx)} ${n(top + size * 0.48)}) `
      + `scale(${n(size / 16)})" fill="none" stroke="${glyphOn(BIN[id])}" stroke-width="0.8" `
      + 'stroke-linejoin="round" stroke-linecap="round"/>'; // the glyphs are drawn for a 16 mm bin
  });
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w * PX}" height="${h * PX}" `
    + `viewBox="0 0 ${w} ${h}">${bins.join('')}</svg>`;
}
function union(boxes) { // the box around several design elements
  return boxes.reduce((u, { bbox: b }) => {
    const [x, y] = [Math.min(u.x, b.x), Math.min(u.y, b.y)];
    return { x, y, width: Math.max(u.x + u.width, b.x + b.width) - x,
      height: Math.max(u.y + u.height, b.y + b.height) - y };
  }, boxes[0].bbox);
}
function drawReadingOrder(ctx, page, scale) {
  const mmPx = (v) => (v * page.width * scale) / TRIM[0];
  const [r, pad, ink] = [mmPx(2.8), mmPx(1.2), '#d6146e']; // disc radius, box padding, magenta
  const inset = mmPx(0.6); // a gap under a box another one touches, such as a heading
  const boxOf = ({ x, y, width, height }, grow = 0) => ({ x: x * scale - pad, y: y * scale
    + inset - grow, w: width * scale + 2 * pad, h: height * scale - inset + 2 * grow });
  const tab = (label, { x, y, w }, fill) => { // a label straddling the box's top-right corner
    ctx.font = `700 ${r * 0.95}px "Atkinson Hyperlegible Mono"`;
    const lw = ctx.measureText(label).width + r * 0.8;
    ctx.fillStyle = fill;
    ctx.fillRect(x + w - lw, y - r * 0.55, lw, r * 1.1);
    ctx.fillStyle = '#ffffff';
    ctx.fillText(label, x + w - lw / 2, y + r * 0.02);
  };
  const marks = readingOrder(page).map((m) => ({ ...m, ...boxOf(m.box) }));
  ctx.save();
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.lineWidth = r / 4.5;
  ctx.strokeStyle = ink;
  marks.forEach((m, i) => { // a line down each column, from one number to the next
    const p = marks[i - 1];
    if (!p || m.y < p.y) return; // no line for the jump to the next column
    ctx.beginPath();
    ctx.moveTo(p.x - r * 1.3, p.y + r);
    ctx.lineTo(m.x - r * 1.3, m.y + r);
    ctx.stroke();
  });
  for (const m of marks) {
    ctx.fillStyle = 'rgba(214, 20, 110, 0.07)';
    ctx.fillRect(m.x, m.y, m.w, m.h);
    ctx.setLineDash(m.cont ? [r / 2, r / 3] : []);
    ctx.strokeRect(m.x, m.y, m.w, m.h);
    ctx.setLineDash([]);
    ctx.fillStyle = ink;
    ctx.beginPath();
    ctx.arc(m.x - r * 1.3, m.y + r, r, 0, Math.PI * 2);
    ctx.fill();
    tab(m.cont ? `${m.tag} (cont.)` : m.tag, m, ink);
    ctx.font = `700 ${r * 1.15}px "Atkinson Hyperlegible Mono"`;
    ctx.fillText(String(m.n), m.x - r * 1.3, m.y + r * 1.05);
  }
  if (page.footer?.blocks.length) { // running heads and folios: artifacts, never read
    const foot = boxOf(union(page.footer.blocks), pad);
    ctx.strokeStyle = '#6b7480';
    ctx.setLineDash([r / 2, r / 3]);
    ctx.strokeRect(foot.x, foot.y, foot.w, foot.h);
    tab('Artifact', foot, '#6b7480');
  }
  ctx.restore();
}
function showReadingOrder(page) { // over the viewer's page, and in the Cookbook's page images
  const canvas = [...document.querySelectorAll('#pages canvas')]
    .find((c) => c.postext.page === page);
  const layer = Object.assign(document.createElement('canvas'), { width: canvas.clientWidth * 2,
    height: canvas.clientHeight * 2 }); // transparent, over the painted page
  layer.style.cssText = 'position:absolute;top:0;left:0;width:100%;background:none;box-shadow:none';
  canvas.parentElement.style.position = 'relative';
  canvas.after(layer);
  drawReadingOrder(layer.getContext('2d'), page, layer.width / page.width);
  window.__postextOverlay = (ctx, p, _, scale) => p.index === page.index
    && drawReadingOrder(ctx, p, scale);
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Loaded before layout (gotcha: fonts-first); the PDF embeds the same files (gotcha: latin-subset)
const FONTS = { 'Atkinson Hyperlegible Next': ['400', '400i', '700', '700i'],
  'Atkinson Hyperlegible Mono': ['400', '700'], 'Public Sans': ['700', '800'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await loadSvg('contenedores.svg', binsSvg(FIGURE, 23));
await loadSvg('calle.svg', binsSvg(STREET, 36));
const content = { markdown, resources: resources() };
const doc = await buildWithFonts(() => buildDocument(content, config()), markdown);
showPages(doc, { title: 'Reciclar en el barrio · PDF accesible' });
showReadingOrder(doc.pages[2]); // page 3: every tagged block numbered in reading order
offerPdf(() => exportPdf(doc), `${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 ───────────────────────────────────────────────────────────────────────
```

## تنويعات

### دع الشكل يطفو

احذف الموضع فيطفو الشكل 1.1 إلى أسفل العمود الأول في الصفحة 3، ويَسِمه ملف PDF بعد الجدول 1.1، آخر كتلة في الصفحة.

```diff
-  { id: 'contenedores', typeId: 'figure', kind: 'svg', placement: { position: 'here' },
+  { id: 'contenedores', typeId: 'figure', kind: 'svg',
```

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

- **الشكل العائم يُقرأ بعد نص صفحته.** يوسم renderToPdf الصفحة بالترتيب الذي يرسمها به: عنوان شريط الافتتاحية، ثم كل عمود من الأعلى إلى الأسفل، ثم العناصر العائمة في الصفحة. لذلك يُقرأ الشكل أو الجدول الذي يطفو إلى رأس عمود بعد آخر فقرة وآخر جدول في صفحته، بعيدًا عن الجملة التي تحيل إليه. ضع ما يجب أن يلقاه القارئ حيث يُحال إليه داخل النص، بالموضع placement 'here' وسطر ::resource.
- **نقاط الإرشاد في جدول المحتويات تتجاوز مكانها في خط يقنّن أزواج النقاط.** يحسب postext 1.4.1 عدد نقاط الإرشاد من عرض نقطة واحدة ويطبعها بخط أرقام الصفحات. وبعض الخطوط تباعد بين سلسلة النقاط أكثر من ذلك (Public Sans Bold بمقاس 12 pt: 6.7 px للنقطة الواحدة بدقة 150 dpi، و7.6 px لكل نقطة في السلسلة)، فتتجاوز النقاط مساحتها وتلامس رقم الصفحة ولا تعود متراصفة من صف إلى صف. أعطِ toc.pageNumber قيمة fontFamily تحتفظ نقاطها بعرضها في السلسلة، مثل خط المتن.
- **النص غير المضبوط لا يُفحص أبدًا بحثًا عن الكلمات المعزولة.** تعمل optimalLineBreaking وavoidRunts وruntPenalty وruntMinCharacters على كاسر الأسطر Knuth–Plass، الذي لا يشغّله postext 1.4.1 إلا للنص المضبوط. الفقرة غير المضبوطة تُكسر سطرًا سطرًا وقد تنتهي بكلمة قصيرة واحدة أيًّا كانت تلك الإعدادات. اقرأ الأسطر الأخيرة من النص غير المضبوط وأعد صياغة الفقرة التي تنتهي بكلمة معزولة.
- **ضع كل قيمة في الترويسة الأمامية (frontmatter) بين علامتي اقتباس.** يقرأ YAML القيمة title: 1984 رقمًا، ويقرأ التاريخ كائن Date، والقيم غير النصية تُطبع فارغة في العناصر النائبة وتترك ملف PDF بلا عنوان. ضع كل قيمة بين علامتي اقتباس: title: "1984".
- **ترجم Figure/Table بـ defaultResourceTypes(locale).** يضبط locale في الإعداد تقسيمَ الكلمات بالواصلة، لا التعليقات: من دون resourceTypes تقول الأنواع المدمجة Figure وTable بالإنجليزية. مرّر resourceTypes: defaultResourceTypes('es') للإسبانية؛ ولأي لغة أخرى اكتب الأسماء بنفسك في resourceTypes.
- **النص غير المضبوط لا يُقسَّم بالواصلة أبدًا.** لا يُطبَّق تقسيم الكلمات بالواصلة إلا على النص المضبوط؛ أما النص ذو الحافة الحرّة فينكسر بين الكلمات، فيصير التفاوت كبيرًا في العمود الضيق غير المضبوط. اضبط المقطع أو وسّع عرض السطر.
- **معرّف :ref المجهول يطبع '?' دون تحذير من المحرّك.** الإحالة :ref إلى معرّف لا يملكه أي مورد تطبع "?" ولا تضع شيئًا، ولا ينبّه إليها إلا Sandbox. تحقّق من وجود كل معرّف تحيل إليه.
- **أي كائن headings يُلغي فاصل الصفحة قبل H1.** ينتقل H1 افتراضيًا إلى صفحة فردية (always-odd)، لكن تمرير أي كائن headings يعيد ضبط هذا الافتراض، فتتوالى الفصول دون فاصل ولا يفعل span: 'page' شيئًا. أعد كتابة headings.levels[0].breakBefore: { enabled: true, parity } في كل إعداد.
- **يرث نمط العنوان فاصل الصفحة من مستواه.** يأخذ مُدخل headingStyles كل حقل يتركه من مستوى عنوانه، بما فيه breakBefore. فصفحة المحتويات أو صفحة بيانات النشر المنسّقة على H1 بعد :::pagebreak ترث الزوجية 'odd' وتقع بعد صفحة فارغة. أعطِ هذا النمط breakBefore: { enabled: false }.
- **لوحة الألوان المستبدلة لا تصل إلى عناصر التصميم ولا إلى لون الإحالة.** يقرأ postext 1.4.1 الإعداد colorPalette في أنماط النص (المتن والعناوين والقوائم والتعليقات والجداول والإطارات) لكن لا في عناصر الترويسات والتذييلات والافتتاحيات وصفحات الأجزاء، ولا في bodyText.referenceColor: تحتفظ بالقيمة الست عشرية المكتوبة بجانب paletteId الخاص بها. حين تستبدل لوحة الألوان، لنسخة شاشة داكنة أو لإعادة تلوين، أعِد كتابة كل لون مرتبط من colorPalette قبل البناء.
- **فيض نص التصميم افتراضيًا 'ellipsis-end'.** عنصر نص التصميم الذي لا يتسع له عرضه ينتهي افتراضيًا بعلامة الحذف. اضبط overflow: 'wrap' للعناوين التي يجب أن تنكسر على أسطر أكثر.
- **يُخزَّن الإعداد مؤقتًا بحسب هويته: ابنِ كائنًا جديدًا.** يخزّن المحرّك الإعدادات المحسوبة مؤقتًا بحسب هوية الكائن، فتعديل الإعداد في مكانه ثم البناء مجددًا يعيد استخدام النتيجة القديمة. ابنِ كائنًا جديدًا في كل بناء، ولهذا يكون إعداد الوصفة دالة مصنِّعة: config().
- **حمّل كل أوجه الخط قبل الإخراج.** يقيس الإخراج النص بأوجه الخط التي حمّلها المتصفح ويخزّن العروض مؤقتًا، فالوجه الذي يصل بعد البناء الأول يترك فواصل أسطر خاطئة وملف PDF لم يعد يطابق الشاشة. حمّل كل وزن وكل نمط أولًا، واستدعِ clearMeasurementCache() قبل إعادة البناء إذا تأخر وصول أحدها.
- **ملفات latin من Fontsource تُسقط الحروف خارج اللاتينية.** يضمّن مزوّد PDF ملفات latin من Fontsource، وهي تغطي الإسبانية ونصوص أوروبا الغربية لكنها لا تغطي → و≈ و✓ و★ ولا الحروف اليونانية أو حروف أوروبا الوسطى؛ فتغيب تلك الحروف عن PDF. أبقِ نص PDF ضمن نطاق latin.

لا يستطيع المثال التحقق من ملف PDF الذي يُنتجه، لأن veraPDF يعمل خارج المتصفح. نزّل الملف وشغّل `verapdf -f ua1 accessible-tagged-pdf.pdf`؛ النسخة الملتقطة لهذه الصفحة تجتاز الفحص. ولا يستطيع المدقّق أن يحكم هل يصف النص البديل صورته، فاستمع إلى الدليل بقارئ شاشة أيضًا.

التُقط هذا الدليل بالإصدار postext 1.4.1، وفيه كان رابط Markdown يطبع كلماته ولا يضيف رابطًا إلى ملف PDF، لذلك تطبع بيانات الطبع عنوان الموقع كاملًا. ومنذ postext 1.10 صار الرابط `[text](url)` فعّالًا في ملف PDF، موسومًا عنصر `Link` نصّه الكلمات المرتبطة.

## الحقوق

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

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

- [رقم 024 · PDF جاهز للطباعة: النزف وعلامات القص وCMYK](https://postext.dev/ar/cookbook/print-ready-pdf.md): مطوية معرض مُخرجة بنزف 3 مم وعلامات قص، ومصدَّرة إلى PDF بألوان CMYK يأخذ خرائطه ومخطط طابقه من نُسخ طباعة أصلية. · المستوى 3 (متقدم) · الأوراق المفردة والمطبوعات العابرة
- [رقم 025 · ملف PDF حقيقي تُضمَّن فيه الخطوط نفسها](https://postext.dev/ar/cookbook/pdf-with-embedded-fonts.md): برنامج حفل موسيقي مُصدَّر إلى PDF. يُجلب كل خط مرة واحدة، لـ FontFace وللـ PDF، ويصير كل عنوان علامة مرجعية. · المستوى 2 (متوسط) · الأوراق المفردة والمطبوعات العابرة
- [رقم 006 · صفحات تمهيدية بأرقام رومانية، ثم الصفحة 1](https://postext.dev/ar/cookbook/front-matter-roman-to-arabic.md): الغلاف والصفحات التمهيدية عناوين بلا ترقيم تُعدّ بالأرقام الرومانية الصغيرة؛ و:::numbering يعيد العدّ إلى 1 على الصفحة الفردية التي تبدأ بها الرواية. · المستوى 3 (متقدم) · الأدب القصصي والمسرح والنثر الأدبي
