# عيّنة خطوط تُحمَّل كل خطوطها قبل الإخراج

> عيّنة خطوط من أربع صفحات يسمّي بناؤها الأول كل خط تستعمله صفحاتها؛ فيحمّلها المثال ويفرّغ ذاكرة العروض المؤقتة ثم يبني من جديد.

- نسخة HTML: https://postext.dev/ar/cookbook/fonts-before-layout
- وصفة رقم 026 · الإخراج والدمج · المستوى 2 (متوسط) · المخرجات: Canvas
- الأنواع: الكتالوجات
- تتطلب postext ≥ 1.4.1 · اختُبرت مع 1.4.1 بتاريخ 2026-09-26
- الصفحات: [1](https://postext.dev/cookbook/fonts-before-layout/en/p01.webp?v=5357fb32), [2](https://postext.dev/cookbook/fonts-before-layout/en/p02.webp?v=5357fb32), [3](https://postext.dev/cookbook/fonts-before-layout/en/p03.webp?v=5357fb32), [4](https://postext.dev/cookbook/fonts-before-layout/en/p04.webp?v=5357fb32)
- افتح في Sandbox: https://postext.dev/ar/sandbox#recipe=fonts-before-layout&lang=en (.postext: https://postext.dev/cookbook/fonts-before-layout/en/fonts-before-layout.postext)
- آخر تحديث: 2026-09-26
- لغات أخرى: [en](https://postext.dev/en/cookbook/fonts-before-layout.md), [es](https://postext.dev/es/cookbook/fonts-before-layout.md), [ca](https://postext.dev/ca/cookbook/fonts-before-layout.md), [zh](https://postext.dev/zh/cookbook/fonts-before-layout.md)

## باختصار

كتيّب من أربع صفحات يعرض ثلاثة خطوط. يبيّن لماذا يجب أن يكتمل تحميل الخطوط كلها قبل تخطيط الصفحات، وإلا انكسرت الأسطر في غير مواضعها.

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

*House Specimen Nº 3*، من دار Pellow Lane Press المتخيَّلة، عيّنة خطوط من أربع صفحات لثلاثة خطوط. على الغلاف مساحة بلون اللازورد الفائق فيها *Ag* أبيض بخط Noto Serif Display المائل بحجم 240 نقطة، وسطران بخط ثابت العرض يسمّيان الخطوط الثلاثة؛ ويقع العنوان تحت تلك المساحة. وتعرض الصفحة 2 خط Ysabeau Office في شلال من 7 إلى 14 نقطة، ثم تنضّد جملًا جامعة لحروف الإسبانية والبولندية والتشيكية تحتاج حروفها ż وř وů إلى ملف خط ثانٍ. والصفحة 3 صفحة التدقيق: جدول بخط IBM Plex Mono يملؤه المثال، بالخطوط العشرة التي طلبها الإخراج وأحجامها. وتنضّد الصفحة 4 الفقرة الأولى من الصفحة 1 مرتين، من البناء الأول ومن البناء الأخير. جرى البناء الأول قبل وصول الخطوط، فانتهت أسطره في مواضع أخرى وتجاوز بعضها عرض السطر.

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

- لماذا تتغير مواضع كسر الأسطر أو تتراكب الكلمات في PDF، وكيف أحمّل ملفات خطوطي قبل الإخراج؟
- كيف أستعمل خطوط علامتي التجارية أو خطوطًا مرخّصة في الإخراج وأضمّنها في ملف PDF؟
- كيف أعرف ما الخطأ في مستندي (تحذيرات، فيض، إخراج لا يستقر)؟

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

```js
// script.js, سطرًا 283–322
// Every block, table, caption, chip, opener and running head keeps the font string it is set in
// (fontString, headerFontString…) and those of the bold and italics it may use (boldFontString…).
function fontStringsIn(doc) {
  const found = new Map(); // font string → true when something is set in it
  const walk = (node) => {
    if (!node || typeof node !== 'object') return;
    for (const [key, value] of Object.entries(node)) {
      if (typeof value !== 'string' || !/fontString$/i.test(key)) walk(value);
      else found.set(value, found.get(value) || !/(bold|italic)FontString$/i.test(key));
    }
  };
  walk(doc.pages); walk(doc.blocks); // not doc.config: it is large and holds no font strings
  return found;
}
function faceOf(font) { // 'italic 700 22.9px "Source Serif 4"' → { family, weight, style, px }
  const [, italic, weight = '400', px, family] = /^(italic )?(\d+ )?([\d.]+)px (.+)$/.exec(font);
  return { family: family.replaceAll('"', ''), weight: weight.trim(), px: Number(px),
    style: italic ? 'italic' : 'normal' };
}
const nameOf = (face) => `${face.family} ${face.weight} ${face.style}`; // a FontFace works too
async function buildWithLoadedFonts(build, sample) { // → every build, first to last
  const builds = [];
  while (builds.length < 4) {
    builds.push(build()); // the first one measures with whatever faces the browser has
    // fonts.check() says yes to an undeclared family and to a face it can fake, so each face that
    // something is set in needs a FontFace of its own; a bold or italic that is only named loads
    // if declared (a family with no italic has none). load() fetches the files the sample needs.
    const declared = new Set([...document.fonts].map(nameOf)), missing = new Set(), pending = [];
    for (const [font, set] of fontStringsIn(builds.at(-1))) {
      const name = nameOf(faceOf(font));
      if (!declared.has(name)) { if (set) missing.add(name); }
      else if (!document.fonts.check(font, sample)) pending.push(font);
    }
    if (missing.size) throw new Error(`No FontFace for ${[...missing].join(', ')}`);
    if (!pending.length) return builds;
    await Promise.all(pending.map((font) => document.fonts.load(font, sample)));
    clearMeasurementCache(); // the widths measured with a fallback stay cached until cleared
  }
  throw new Error(`The fonts had not settled after ${builds.length} builds.`);
}
```

## المكونات

**تعلّم**

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

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

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

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

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

**واجهات API**

- [`buildDocument`](https://postext.dev/ar/docs/configuration.md#بناء-مستند), [`clearMeasurementCache`](https://postext.dev/ar/docs/configuration.md#ذاكرة-القياس-المؤقتة), [`defaultResourceTypes`](https://postext.dev/ar/docs/configuration.md#أنواع-الموارد), [`registerResourceImage`](https://postext.dev/ar/docs/architecture.md#واجهة-البرمجة-المتاحة-api), [`renderPageToCanvas`](https://postext.dev/ar/docs/configuration.md#رسم-صفحة-في-صورة-نقطية)

**الخطوط**

- Ysabeau Office (OFL-1.1), Noto Serif Display (OFL-1.1), IBM Plex Mono (OFL-1.1)

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

### 1 · عرّف كل ملف من غير أن تجلبه

```js
// script.js, سطرًا 260–279
const SUBSETS = { // the characters each file covers, copied from the family's @font-face CSS
  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' };
function declareFaces(fonts) { // Fontsource's static files stand in for your own /fonts/ folder
  for (const [family, specs] of Object.entries(fonts)) {
    const id = family.toLowerCase().replaceAll(' ', '-');
    for (const spec of specs) {
      const [weight, style] = [spec.slice(0, 3), spec.endsWith('i') ? 'italic' : 'normal'];
      for (const [subset, unicodeRange] of Object.entries(SUBSETS)) {
        const file = `${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`;
        // Adding a face fetches nothing: the file downloads when a load or a line needs it.
        const url = `https://cdn.jsdelivr.net/npm/@fontsource/${file}`;
        document.fonts.add(new FontFace(family, `url(${url})`, { weight, style, unicodeRange }));
      }
    }
  }
}
```

كائن `FontFace` ملف واحد، مثل قاعدة `@font-face`، فالخط الذي له ملف latin وملف latin-ext يُعرَّف بكائنين `FontFace` يشتركان في العائلة والوزن والنمط ويختلفان في `unicodeRange`، المنسوخ من CSS العائلة. وإضافتهما إلى `document.fonts` لا تنزّل شيئًا: يجلب المتصفح الملف حين يحتاج نداءُ `load()` أو سطرٌ من النص إلى حروفه. لا تحمّل `buildDocument` أي خط (وحدها `loadBundleFonts` تفعل، للملفات التي داخل حزمة `.postext`)، و`customFonts` في الإعدادات لا تفعل سوى وصف تلك الملفات، فعلى الصفحة المبنية من Markdown أن تعرّف خطوطها بنفسها.

### 2 · ابنِ، ثم حمّل، ثم فرّغ، ثم ابنِ من جديد

```js
// script.js, سطرًا 348–355
kitStatus('Loading fonts…'); // the kit's bar: it also reports any error thrown below
declareFaces(FONTS);
const build = () => buildDocument({ markdown, resources: resources() }, config());
const builds = await buildWithLoadedFonts(build, markdown);
audit = auditOf(builds); // page 3's table
drawProof(builds[0], builds.at(-1)); // page 4's picture
const doc = (await buildWithLoadedFonts(build, markdown)).at(-1); // nothing is left to load
showPages(doc, { title: 'Load every font before layout' });
```

يقيس البناء الأول بما في المتصفح، أي بخط احتياطي، لكن صفحاته تسمّي من الآن كل خط تحتاجه. تجمع `buildWithLoadedFonts`، وهي الجواب المختصر أعلاه، تلك الأسماء من كل بناء، وتحمّل الخطوط التي ما زالت ناقصة مستعملة نص الكتيّب عيّنةً، وتبني من جديد حتى لا يجد بناءٌ شيئًا باقيًا يحمّله. تجلب هذه العيّنة أيضًا ملفات latin-ext لخطي العرض والخط ثابت العرض، مع أنهما لا ينضّدان أيًّا من تلك الحروف؛ فإن كان حجم التنزيل يهمّك، فحمّل كل خط بنصه وحده. لا تأخذ `clearMeasurementCache()` أي وسيط، وتفرّغ العروض التي خزّنها البناء الأول؛ ومن دونها يحتفظ البناء الثاني بكل مواضع كسر الأسطر من الأول (انظر التنويعات).

### 3 · ابنِ التدقيق من الإخراج

```js
// script.js, سطرًا 326–343
function auditOf(builds) {
  const doc = builds.at(-1), faces = new Map(), declared = new Set([...document.fonts].map(nameOf));
  for (const face of [...fontStringsIn(doc).keys()].map(faceOf)) {
    const name = `${face.weight}${face.style === 'italic' ? ' italic' : ''}`; // '400 italic'
    const key = `${Object.keys(FONTS).indexOf(face.family)} ${name}`; // FONTS order, upright first
    // A face with no file is only named, never set: the browser fakes it if a line asks for it.
    if (!faces.has(key)) faces.set(key, { family: face.family, sizes: new Set(),
      face: declared.has(nameOf(face)) ? name : `${name} · no file` });
    faces.get(key).sizes.add(Math.round((face.px * 72 * 10) / DPI) / 10); // px back to pt
  }
  const rows = [...faces].sort(([a], [b]) => a.localeCompare(b)).map(([, f], i, all) => [
    i && all[i - 1][1].family === f.family ? '' : f.family, // each family named once
    f.face, [...f.sizes].sort((a, b) => a - b).join(' · ')].map((content) => ({ content })));
  const files = [...document.fonts].filter((face) => face.status === 'loaded').length;
  const warnings = doc.warnings?.length || 'no'; // what else to read in a finished layout
  return { rows, note: `Build ${builds.length}: ${rows.length} faces · ${files} files loaded · `
    + `${doc.converged ? 'converged' : 'not converged'} · ${warnings} layout warnings` };
}
```

الجدول هو المرور نفسه مجمّعًا بحسب الخط، مع تحويل كل حجم من بكسلات الإخراج إلى نقاط. وتقرأ ملاحظته حقلين آخرين من البناء الذي تصفه: `converged`، وقيمته true حين تستقر جولات الإخراج، و`warnings`، الذي يسرد في 1.4.1 كل إطار لا يتسع له أي عمود. افحص الحقلين قبل أن تعرض صفحة أو تصدّرها. ويضم الجدول خطوطًا أكثر مما تُظهره الصفحات، لأن كل كتلة نص وجدول وتعليق تسمّي وزنًا عريضًا ومائلًا وعريضًا مائلًا إلى جانب خطها، و`renderToPdf` تطلبها كلها. ويشترط الجواب المختصر وجود `FontFace` خاص بكل خط يُنضَّد به شيء، لأن `document.fonts.check()` تعيد true لعائلة غير معرَّفة ولوزن عريض يستطيع المتصفح تزويره. أما الخط الذي يُسمّى فحسب فيُحمَّل حين يُعرَّف: العائلة التي لا ملف مائلًا لها تمرّ، ويضع الجدول على خطوطها المائلة علامة `no file`.

### 4 · احتفظ بالبناء الأول دليلًا

```js
// script.js, سطرًا 124–168
const STRIP = { lines: 8, overrun: 10 }; // page 1's first paragraph; mm shown past the measure
const PROOF = { // px: two strips a lead apart, cut at 300 dpi
  width: Math.round(((MEASURE + STRIP.overrun) / 25.4) * 2 * DPI),
  height: Math.round((((2 * STRIP.lines + 1) * LEAD) / 72) * 2 * DPI) };
const proof = { moved: 0, total: 0 }; // lines of text the first build broke elsewhere, of all
const proofFigure = () => ({ id: 'proof', typeId: 'figure', kind: 'bitmap', createdAt: 0,
  updatedAt: 0, placement: here,
  bitmap: { fileId: 'proof.png', format: 'png', width: PROOF.width, height: PROOF.height },
  caption: 'The first paragraph of page 1 as the first build set it, measured before the fonts '
    + 'had arrived (above), and as the last build set it (below). The first build broke '
    + `${proof.moved} of its ${proof.total} lines of text elsewhere. The rule marks the measure.`,
  altText: `Two strips of the same ${STRIP.lines} lines of text. In the upper strip the lines `
    + 'break in other places and some run past a vertical rule; in the lower one every line '
    + 'stops short of it.' });
function drawProof(first, last) {
  const linesOf = (doc) => doc.blocks.filter((b) => b.type === 'paragraph')
    .map((b) => b.lines.map((l) => l.text));
  const [before, after] = [first, last].map(linesOf);
  proof.total = before.flat().length;
  proof.moved = before.flatMap((lines, i) => lines.filter((t, j) => t !== after[i]?.[j])).length;
  const canvas = Object.assign(document.createElement('canvas'), PROOF);
  const ctx = canvas.getContext('2d');
  const strip = (PROOF.height * STRIP.lines) / (2 * STRIP.lines + 1);
  const edge = Math.round((PROOF.width * MEASURE) / (MEASURE + STRIP.overrun));
  // The renderer clips each column 2 pt past its edge, which would cut the first build's lines
  // at the measure: paint a copy of page 1 whose column reaches across the whole strip.
  const wide = (column) => ({ ...column,
    bbox: { ...column.bbox, width: column.bbox.width + (STRIP.overrun / 25.4) * DPI } });
  [first, last].forEach((doc, i) => {
    const page = document.createElement('canvas');
    renderPageToCanvas({ ...doc.pages[0], columns: doc.pages[0].columns.map(wide) }, doc, page,
      { scale: 2 }); // 300 dpi
    const { x, y } = doc.pages[0].columns[0].blocks.find((b) => b.type === 'paragraph').bbox;
    ctx.drawImage(page, 2 * x, 2 * y, PROOF.width, strip,
      0, i * (PROOF.height - strip), PROOF.width, strip);
  });
  ctx.fillStyle = `${palette.ultramarine}1f`; // a pale wash over the margin past the measure
  ctx.fillRect(edge, 0, PROOF.width - edge, PROOF.height);
  ctx.fillStyle = palette.ultramarine; // a hairline at the measure, and each strip's name
  ctx.fillRect(edge, 0, 2, PROOF.height);
  ctx.font = `700 ${(7 / 72) * 2 * DPI}px "IBM Plex Mono"`; // 7 pt, loaded by now
  ['first', 'last'].forEach((name, i) => // on the last line of each strip
    ctx.fillText(name, edge + 12, (i ? PROOF.height : strip) - 16));
  registerResourceImage('proof.png', canvas);
}
```

تعيد `buildWithLoadedFonts` كل بناء، فيستطيع المثال أن يرسم البناء الأول مرة أخرى بعد وصول الخطوط الحقيقية ويقارنه سطرًا بسطر بالأخير؛ ومن هذه المقارنة يأتي العدد الذي في التعليق. السطر غير المضبوط المقيس بخط احتياطي أضيق يتجاوز عرض السطر إلى أن يقصّ مقطعُ العمود، الذي يمتد نقطتين بعد حافته، حروفَه الأخيرة؛ لذلك يرسم المثال الصفحة 1 بعمود موسَّع ليُظهر التجاوز كله. وترسم اللوحة (Canvas) و`postext-pdf` كل مقطع من سطر مضبوط أو مختلط الأنماط حيث قاسه الإخراج، فالخط الحقيقي الأعرض من الاحتياطي يُطبع فوق الكلمة التالية، وتتراكب كلمات ملف PDF بُني قبل وصول خطوطه.

### 5 · شلال على إيقاع النص

```js
// script.js, سطرًا 31–45
const bodyText = () => ({ // one family name, never a CSS stack (gotcha: font-family-one-name)
  fontFamily: TEXT, fontSize: pt(11), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true }); // ragged and spaced
// Every waterfall size and pangram is two leads (31 pt) deep, on the text's 15.5 pt rhythm.
const line = (size) => ({ fontSize: pt(size), lineHeight: pt(2 * LEAD) });
const paragraphStyles = () => [
  ...[7, 8, 9, 10, 11, 12, 14].map((size) => ({ id: `s${size}`, ...line(size) })),
  { id: 'pangram', ...line(13) },
  { id: 'colophon', fontSize: pt(7), lineHeight: pt(10), fontFamily: MONO, color: col('muted') }];
// Size labels: boxless mono chips. A Plex Mono letter is 0.6 em wide, so a one-digit label gets
// half a letter each side and the samples start on one edge.
const tag = { fontFamily: MONO, fontSize: pt(7), color: col('ultramarine'),
  backgroundEnabled: false, borderWidth: pt(0), paddingX: pt(0), gap: mm(2.5) };
const chipStyles = () => [{ id: 'size', ...tag }, { id: 'size-1', ...tag, paddingX: em(0.3) }];
```

ينضّد خط Ysabeau Office النص بحجم 11 نقطة على 15.5 نقطة، بسطر فارغ بين الفقرات بدل الإزاحة. النص غير مضبوط، فتحتفظ كل مسافة بين كلمتين بعرضها الطبيعي. والعريض والمائل والإحالات تُنضَّد بالحبر؛ ولو تُركت لأخذت اللون الرئيسي. وكل حجم في الشلال وكل جملة جامعة نمط فقرة بعمق سطرين (31 نقطة)، فتحتفظ الصفحة بإيقاع النص البالغ 15.5 نقطة. وتسميات الأحجام شارات بخط ثابت العرض، وتأخذ الأحجام ذات الرقم الواحد حشوة بنصف حرف من كل جانب، فتبدأ كل عيّنة عند الحافة نفسها.

### 6 · ثلاثة خطوط على غلاف واحد

```js
// script.js, سطرًا 49–76
const Y = { kicker: 14, glyphs: 17, label: FIELD - 12, title: FIELD + 10, // mm from the top edge
  end: FIELD + 42 }; // where the opener ends: under the title, the lead and a line of air
const ITALIC_FOOT = 5; // mm: the italic A's foot reaches this far left of the glyphs' origin
const at = (x, y, width) => ({ anchor: { to: 'page', edge: 'top-left' },
  offset: { x: mm(x), y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const text = (id, content, style, placement) => ({ kind: 'text', id, content, align: 'left',
  overflow: 'wrap', ...style, placement }); // design text wraps instead of ending in an ellipsis
const cover = () => ({ level: 1, fontSize: pt(30), italic: true, // headings.levels[0]
  breakBefore: { enabled: true, parity: 'odd' }, // restated (gotcha: headings-drop-h1-break)
  span: 'page', // lets the field reach the top edge: in the column it stops at the top margin
  advancedDesign: { enabled: true, minHeight: mm(Y.end - MARGIN.top), // from the top margin
    slot: { elements: [
      { kind: 'box', id: 'field', style: { backgroundColor: col('ultramarine') }, placement: {
        anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill', height: mm(FIELD) } } },
      text('kicker', '{attr.kicker}', { ...label, fontWeight: 700, color: col('paper') },
        at(MARGIN.inner, Y.kicker)),
      // lineHeight multiplies the size (gotcha: design-lineheight-multiple)
      text('glyphs', '{attr.glyphs}', { ...display, fontSize: pt(240), lineHeight: 1,
        color: col('paper') }, at(MARGIN.inner + ITALIC_FOOT, Y.glyphs)),
      text('label', '{attr.label}', { ...label, color: col('mist') }, at(MARGIN.inner, Y.label)),
      text('faces', '{attr.faces}', { ...label, color: col('mist') },
        { anchor: { to: '#label', edge: 'below' }, offset: { y: mm(1.2) } }),
      text('title', '{titleText}', { ...display, fontSize: pt(30), lineHeight: 1.05,
        color: col('ink') }, at(MARGIN.inner, Y.title, PAGE.width - 2 * MARGIN.inner)),
      text('lead', '{attr.lead}', { fontFamily: TEXT, italic: true, fontSize: pt(12),
        lineHeight: 1.35, color: col('ink') }, { anchor: { to: '#title', edge: 'below' },
        offset: { y: mm(3) }, size: { width: mm(MEASURE) } }),
    ] } } });
```

الغلاف هو تصميم مستوى H1: حروف السمة `glyphs` في العنوان بخط العرض بحجم 240 نقطة، وتحتها سطران بخط ثابت العرض يسمّيان ذلك الخط وخطي النص والتسميات، كما في ورقة عيّنات مسبك حروف. يسمح `span: 'page'` للمساحة المثبّتة إلى النزف بأن تمتد خارج الحافة العليا؛ ولو بقي التصميم في العمود لقُصّ عند الهامش الأعلى، فيذهب رأس المساحة والعنوان التمهيدي. ويُحتسب `minHeight` من الهامش الأعلى ويحجز المساحة حتى `Y.end`، أي 42 مم تحت المساحة الملونة؛ ومن دونه يبدأ النص أعلى بسطر، ملتصقًا تحت المقدمة.

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

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

- مجلد الوصفة: https://github.com/drnachio/postext/tree/main/cookbook/fonts-before-layout

### script.js

```js
// ═══ Postext Cookbook · Nº 026 · Type specimen with every font loaded before layout ═════════
// https://postext.dev/en/cookbook/fonts-before-layout
// Code: MIT · Text: original (CC BY 4.0) · Picture: cut from the pen's own first and last builds
// Fonts: Ysabeau Office, Noto Serif Display, IBM Plex Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache, defaultResourceTypes,
  registerResourceImage } from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'fonts-before-layout';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const PAGE = { width: 180, height: 240 }; // mm
const MARGIN = { top: 22, bottom: 24, inner: 20, outer: 48 }; // mm: inner is the spine side
const MEASURE = PAGE.width - MARGIN.inner - MARGIN.outer; // 112 mm: about 70 letters at 11 pt
const FIELD = 122; // mm from the top edge: the ultramarine field of the opener
const LEAD = 15.5; // pt: the body leading and the step of every vertical space
const DPI = 150; // font strings carry px at this resolution; the audit turns them back into pt
const [TEXT, DISPLAY, MONO] = ['Ysabeau Office', 'Noto Serif Display', 'IBM Plex Mono'];
const palette = { ink: '#16161a', ultramarine: '#3246d3', mist: '#c9d0f6', // mist: 4.6:1 on
  rule: '#cfc9bd', muted: '#6b6a70', paper: '#ffffff' }; // ultramarine, for labels on the field
// Every colour keeps its palette id beside its hex, because 1.4.1 paints design slots from the
// hex (gotcha: palette-skips-designs); main-color catches any default left unstated.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = () => Object.entries({ ...palette, 'main-color': palette.ultramarine })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const label = { fontFamily: MONO, fontSize: pt(7.5), letterSpacing: pt(1.2),
  textTransform: 'uppercase' };
const display = { fontFamily: DISPLAY, fontWeight: 900, italic: true };

// #region type: the text face at 11 on 15.5 pt, and a waterfall of it on two leads a line
const bodyText = () => ({ // one family name, never a CSS stack (gotcha: font-family-one-name)
  fontFamily: TEXT, fontSize: pt(11), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true }); // ragged and spaced
// Every waterfall size and pangram is two leads (31 pt) deep, on the text's 15.5 pt rhythm.
const line = (size) => ({ fontSize: pt(size), lineHeight: pt(2 * LEAD) });
const paragraphStyles = () => [
  ...[7, 8, 9, 10, 11, 12, 14].map((size) => ({ id: `s${size}`, ...line(size) })),
  { id: 'pangram', ...line(13) },
  { id: 'colophon', fontSize: pt(7), lineHeight: pt(10), fontFamily: MONO, color: col('muted') }];
// Size labels: boxless mono chips. A Plex Mono letter is 0.6 em wide, so a one-digit label gets
// half a letter each side and the samples start on one edge.
const tag = { fontFamily: MONO, fontSize: pt(7), color: col('ultramarine'),
  backgroundEnabled: false, borderWidth: pt(0), paddingX: pt(0), gap: mm(2.5) };
const chipStyles = () => [{ id: 'size', ...tag }, { id: 'size-1', ...tag, paddingX: em(0.3) }];
// #endregion

// #region opener: the H1 as a bleed field, the display face at 240 pt, labels naming the faces
const Y = { kicker: 14, glyphs: 17, label: FIELD - 12, title: FIELD + 10, // mm from the top edge
  end: FIELD + 42 }; // where the opener ends: under the title, the lead and a line of air
const ITALIC_FOOT = 5; // mm: the italic A's foot reaches this far left of the glyphs' origin
const at = (x, y, width) => ({ anchor: { to: 'page', edge: 'top-left' },
  offset: { x: mm(x), y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const text = (id, content, style, placement) => ({ kind: 'text', id, content, align: 'left',
  overflow: 'wrap', ...style, placement }); // design text wraps instead of ending in an ellipsis
const cover = () => ({ level: 1, fontSize: pt(30), italic: true, // headings.levels[0]
  breakBefore: { enabled: true, parity: 'odd' }, // restated (gotcha: headings-drop-h1-break)
  span: 'page', // lets the field reach the top edge: in the column it stops at the top margin
  advancedDesign: { enabled: true, minHeight: mm(Y.end - MARGIN.top), // from the top margin
    slot: { elements: [
      { kind: 'box', id: 'field', style: { backgroundColor: col('ultramarine') }, placement: {
        anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill', height: mm(FIELD) } } },
      text('kicker', '{attr.kicker}', { ...label, fontWeight: 700, color: col('paper') },
        at(MARGIN.inner, Y.kicker)),
      // lineHeight multiplies the size (gotcha: design-lineheight-multiple)
      text('glyphs', '{attr.glyphs}', { ...display, fontSize: pt(240), lineHeight: 1,
        color: col('paper') }, at(MARGIN.inner + ITALIC_FOOT, Y.glyphs)),
      text('label', '{attr.label}', { ...label, color: col('mist') }, at(MARGIN.inner, Y.label)),
      text('faces', '{attr.faces}', { ...label, color: col('mist') },
        { anchor: { to: '#label', edge: 'below' }, offset: { y: mm(1.2) } }),
      text('title', '{titleText}', { ...display, fontSize: pt(30), lineHeight: 1.05,
        color: col('ink') }, at(MARGIN.inner, Y.title, PAGE.width - 2 * MARGIN.inner)),
      text('lead', '{attr.lead}', { fontFamily: TEXT, italic: true, fontSize: pt(12),
        lineHeight: 1.35, color: col('ink') }, { anchor: { to: '#title', edge: 'below' },
        offset: { y: mm(3) }, size: { width: mm(MEASURE) } }),
    ] } } });
// #endregion

// Running heads at the outer edge of the text; a drop folio there too on the opener (a recto).
const HEADS = { top: 13, bottom: 12 }; // mm from the top and the bottom edge of the page
const head = (id, content, parity, edge, x, pages = 'body') => text(id, content, { parity,
  pages, fontFamily: MONO, fontSize: pt(7.5), color: col('muted') }, { anchor: { to: 'page',
  edge }, offset: { x: mm(x), y: mm(edge.startsWith('top') ? HEADS.top : -HEADS.bottom) } });
const header = () => ({ elements: [
  head('verso', '{pageNumber} · {title}', 'even', 'top-left', MARGIN.outer),
  head('recto', '{chapterTitle} · {pageNumber}', 'odd', 'top-right', -MARGIN.outer)] });
const footer = () => ({ elements: [
  head('drop-folio', '{pageNumber}', 'all', 'bottom-right', -MARGIN.outer, 'opener')] });

const config = () => ({ // a new object per build (gotcha: config-cache-identity)
  // "Table 1", not "Table 1.1": the booklet has one chapter. Table captions sit above.
  resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}',
    ...(type.id === 'table' && { captionStyle: { position: 'above' } }) })),
  colorPalette: colorPalette(), layout: { layoutType: 'single' },
  page: { width: mm(PAGE.width), height: mm(PAGE.height), dpi: DPI,
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true } },
  bodyText: bodyText(), paragraphStyles: paragraphStyles(), chipStyles: chipStyles(),
  headings: { fontFamily: DISPLAY, fontWeight: 900, color: col('ink'), levels: [cover(),
    { level: 2, fontSize: pt(16), lineHeight: pt(2 * LEAD), marginTop: pt(LEAD),
      marginBottom: pt(0) }] },
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackground: col('ultramarine'), headerColor: col('paper'), headerFontFamily: MONO,
    headerFontSize: pt(7.5), bodyFontFamily: MONO, bodyFontSize: pt(7.5), cellPadding: mm(1) },
  captionStyle: { fontFamily: MONO, fontSize: pt(7.5), labelColor: col('ultramarine'),
    note: { color: col('muted') } },
  header: header(), footer: footer(),
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
// The table and the picture come from the builds themselves (section 4).
let audit = { rows: [], note: '' };
const here = { position: 'here' }; // both sit where ::resource puts them
const resources = () => [
  { id: 'faces', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0, placement: here,
    caption: 'Faces this document asked for, read from its own layout.', note: audit.note,
    table: { model: { headerRowCount: 1, columnWidths: [3, 2, 5], rows: [
      ['Family', 'Face', 'Sizes (pt)'].map((content) => ({ content, isHeader: true })),
      ...audit.rows] } } },
  proofFigure(), // drawn from the builds just below
];

// #region art-proof: page 1's first paragraph from the first build, over the same from the last
const STRIP = { lines: 8, overrun: 10 }; // page 1's first paragraph; mm shown past the measure
const PROOF = { // px: two strips a lead apart, cut at 300 dpi
  width: Math.round(((MEASURE + STRIP.overrun) / 25.4) * 2 * DPI),
  height: Math.round((((2 * STRIP.lines + 1) * LEAD) / 72) * 2 * DPI) };
const proof = { moved: 0, total: 0 }; // lines of text the first build broke elsewhere, of all
const proofFigure = () => ({ id: 'proof', typeId: 'figure', kind: 'bitmap', createdAt: 0,
  updatedAt: 0, placement: here,
  bitmap: { fileId: 'proof.png', format: 'png', width: PROOF.width, height: PROOF.height },
  caption: 'The first paragraph of page 1 as the first build set it, measured before the fonts '
    + 'had arrived (above), and as the last build set it (below). The first build broke '
    + `${proof.moved} of its ${proof.total} lines of text elsewhere. The rule marks the measure.`,
  altText: `Two strips of the same ${STRIP.lines} lines of text. In the upper strip the lines `
    + 'break in other places and some run past a vertical rule; in the lower one every line '
    + 'stops short of it.' });
function drawProof(first, last) {
  const linesOf = (doc) => doc.blocks.filter((b) => b.type === 'paragraph')
    .map((b) => b.lines.map((l) => l.text));
  const [before, after] = [first, last].map(linesOf);
  proof.total = before.flat().length;
  proof.moved = before.flatMap((lines, i) => lines.filter((t, j) => t !== after[i]?.[j])).length;
  const canvas = Object.assign(document.createElement('canvas'), PROOF);
  const ctx = canvas.getContext('2d');
  const strip = (PROOF.height * STRIP.lines) / (2 * STRIP.lines + 1);
  const edge = Math.round((PROOF.width * MEASURE) / (MEASURE + STRIP.overrun));
  // The renderer clips each column 2 pt past its edge, which would cut the first build's lines
  // at the measure: paint a copy of page 1 whose column reaches across the whole strip.
  const wide = (column) => ({ ...column,
    bbox: { ...column.bbox, width: column.bbox.width + (STRIP.overrun / 25.4) * DPI } });
  [first, last].forEach((doc, i) => {
    const page = document.createElement('canvas');
    renderPageToCanvas({ ...doc.pages[0], columns: doc.pages[0].columns.map(wide) }, doc, page,
      { scale: 2 }); // 300 dpi
    const { x, y } = doc.pages[0].columns[0].blocks.find((b) => b.type === 'paragraph').bbox;
    ctx.drawImage(page, 2 * x, 2 * y, PROOF.width, strip,
      0, i * (PROOF.height - strip), PROOF.width, strip);
  });
  ctx.fillStyle = `${palette.ultramarine}1f`; // a pale wash over the margin past the measure
  ctx.fillRect(edge, 0, PROOF.width - edge, PROOF.height);
  ctx.fillStyle = palette.ultramarine; // a hairline at the measure, and each strip's name
  ctx.fillRect(edge, 0, 2, PROOF.height);
  ctx.font = `700 ${(7 / 72) * 2 * DPI}px "IBM Plex Mono"`; // 7 pt, loaded by now
  ['first', 'last'].forEach((name, i) => // on the last line of each strip
    ctx.fillText(name, edge + 12, (i ? PROOF.height : strip) - 16));
  registerResourceImage('proof.png', canvas);
}
// #endregion

const markdown = String.raw`---
title: "House Specimen"
author: "Pellow Lane Press"
---

# Three faces, proofed {kicker="Pellow Lane Press · House specimen Nº 3" lead="Our text, display and label faces at work, and proof that each of them had arrived before these lines were set." glyphs="Ag" label="Noto Serif Display 900 italic · 240 pt" faces="Text: Ysabeau Office · Labels: IBM Plex Mono"}

A compositor in a metal shop could only set a line in a face that was in the case. Postext measures every word with the fonts the browser holds at that moment and keeps the widths, so a face that arrives a second late leaves the page broken for a fallback, with no warning. These pages were built three times: once to learn which faces the layout asks for, again once all of them had loaded, and a last time to print their list on page 3 and, on page 4, what the first build got wrong.

## Seven sizes of the text face

Ysabeau, drawn by Christian Thalmann, carries the letterforms of the Garamond tradition into a low-contrast sans serif. Its Office cut sets tabular lining figures and a level hyphen by default.

:::paragraphs{style="s7"}
:chip[7 pt]{style="size-1"} Credits and map legends, where small print needs open counters.
:::

:::paragraphs{style="s8"}
:chip[8 pt]{style="size-1"} Captions and table notes, where the tabular figures keep 1,048 and 2,096 in step.
:::

:::paragraphs{style="s9"}
:chip[9 pt]{style="size-1"} A reference column set close; the long ascenders keep the lines apart.
:::

:::paragraphs{style="s10"}
:chip[10 pt]{style="size"} Notes and asides, a size below the text they sit beside.
:::

:::paragraphs{style="s11"}
:chip[11 pt]{style="size"} The text of this booklet, eleven on fifteen and a half.
:::

:::paragraphs{style="s12"}
:chip[12 pt]{style="size"} A standfirst, or a first reader for children.
:::

:::paragraphs{style="s14"}
:chip[14 pt]{style="size"} A heading, or a line on a poster.
:::

## Beyond Latin-1

Spanish needs nothing beyond the latin file of each face. Polish and Czech need more: ż, ł, ř and ů live in a second file, latin-ext, which the browser fetches only when a load or a line asks for those letters.

:::paragraphs{style="pangram"}
:chip[es]{style="size"} El veloz murciélago hindú comía feliz cardillo y kiwi.

:chip[pl]{style="size"} Zażółć gęślą jaźń.

:chip[cs]{style="size"} Příliš žluťoučký kůň úpěl ďábelské ódy.
:::

:::pagebreak

## The proof

The script compiled the table below from the layout. After the first build, it walked the finished pages for every font they had asked for, in the text, headings, chips, tables, captions, opener and running heads. It loaded each face that had not arrived, emptied the measurement cache and built the pages again, then wrote down what it had found.

::resource{id="faces"}

Some faces in the table set nothing in this booklet. Beside the face of every block of text, table and caption, Postext names a bold, an italic and a bold italic, whether the text uses them or not, and a PDF export asks for all of them. The script loads each one it has a file for.

Before loading anything, the script also checked that every face some text is set in had a file of its own. It could not rely on the browser’s check, which answers yes for a family nobody declared and for any bold it can fake by thickening the regular.

:::pagebreak

## What the first build got wrong

The first build ran before any of these files had arrived, so the browser measured its words in a fallback face. Drawn in the real faces, its lines no longer fit the measure.

::resource{id="proof"}

The fallback widths stay in the measurement cache, and a second build made without emptying it breaks every line where the first one did.

:::paragraphs{style="colophon"}
Set in Ysabeau Office, Noto Serif Display and IBM Plex Mono (SIL OFL 1.1) · Text: original, CC BY 4.0 · Pellow Lane Press is imaginary.
:::
`; // content.<lang>.md: every frontmatter value is quoted

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the layout asks for (page 3 lists them), each declared from two files.
const FONTS = {
  'Ysabeau Office': ['400', '400i', '700', '700i'], // text, waterfall, pangrams, lead
  'Noto Serif Display': ['900', '900i'], // the glyphs, the title, the subheads
  'IBM Plex Mono': ['400', '400i', '700', '700i'], // labels, chips, the table, captions
};

// #region declare: one FontFace per file, as a stylesheet has one @font-face rule per file
const SUBSETS = { // the characters each file covers, copied from the family's @font-face CSS
  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' };
function declareFaces(fonts) { // Fontsource's static files stand in for your own /fonts/ folder
  for (const [family, specs] of Object.entries(fonts)) {
    const id = family.toLowerCase().replaceAll(' ', '-');
    for (const spec of specs) {
      const [weight, style] = [spec.slice(0, 3), spec.endsWith('i') ? 'italic' : 'normal'];
      for (const [subset, unicodeRange] of Object.entries(SUBSETS)) {
        const file = `${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`;
        // Adding a face fetches nothing: the file downloads when a load or a line needs it.
        const url = `https://cdn.jsdelivr.net/npm/@fontsource/${file}`;
        document.fonts.add(new FontFace(family, `url(${url})`, { weight, style, unicodeRange }));
      }
    }
  }
}
// #endregion

// #region answer: build, collect every font the layout asked for, load it, clear, build again
// Every block, table, caption, chip, opener and running head keeps the font string it is set in
// (fontString, headerFontString…) and those of the bold and italics it may use (boldFontString…).
function fontStringsIn(doc) {
  const found = new Map(); // font string → true when something is set in it
  const walk = (node) => {
    if (!node || typeof node !== 'object') return;
    for (const [key, value] of Object.entries(node)) {
      if (typeof value !== 'string' || !/fontString$/i.test(key)) walk(value);
      else found.set(value, found.get(value) || !/(bold|italic)FontString$/i.test(key));
    }
  };
  walk(doc.pages); walk(doc.blocks); // not doc.config: it is large and holds no font strings
  return found;
}
function faceOf(font) { // 'italic 700 22.9px "Source Serif 4"' → { family, weight, style, px }
  const [, italic, weight = '400', px, family] = /^(italic )?(\d+ )?([\d.]+)px (.+)$/.exec(font);
  return { family: family.replaceAll('"', ''), weight: weight.trim(), px: Number(px),
    style: italic ? 'italic' : 'normal' };
}
const nameOf = (face) => `${face.family} ${face.weight} ${face.style}`; // a FontFace works too
async function buildWithLoadedFonts(build, sample) { // → every build, first to last
  const builds = [];
  while (builds.length < 4) {
    builds.push(build()); // the first one measures with whatever faces the browser has
    // fonts.check() says yes to an undeclared family and to a face it can fake, so each face that
    // something is set in needs a FontFace of its own; a bold or italic that is only named loads
    // if declared (a family with no italic has none). load() fetches the files the sample needs.
    const declared = new Set([...document.fonts].map(nameOf)), missing = new Set(), pending = [];
    for (const [font, set] of fontStringsIn(builds.at(-1))) {
      const name = nameOf(faceOf(font));
      if (!declared.has(name)) { if (set) missing.add(name); }
      else if (!document.fonts.check(font, sample)) pending.push(font);
    }
    if (missing.size) throw new Error(`No FontFace for ${[...missing].join(', ')}`);
    if (!pending.length) return builds;
    await Promise.all(pending.map((font) => document.fonts.load(font, sample)));
    clearMeasurementCache(); // the widths measured with a fallback stay cached until cleared
  }
  throw new Error(`The fonts had not settled after ${builds.length} builds.`);
}
// #endregion

// #region audit: page 3's table, one row per face the walk found, with every size it set
function auditOf(builds) {
  const doc = builds.at(-1), faces = new Map(), declared = new Set([...document.fonts].map(nameOf));
  for (const face of [...fontStringsIn(doc).keys()].map(faceOf)) {
    const name = `${face.weight}${face.style === 'italic' ? ' italic' : ''}`; // '400 italic'
    const key = `${Object.keys(FONTS).indexOf(face.family)} ${name}`; // FONTS order, upright first
    // A face with no file is only named, never set: the browser fakes it if a line asks for it.
    if (!faces.has(key)) faces.set(key, { family: face.family, sizes: new Set(),
      face: declared.has(nameOf(face)) ? name : `${name} · no file` });
    faces.get(key).sizes.add(Math.round((face.px * 72 * 10) / DPI) / 10); // px back to pt
  }
  const rows = [...faces].sort(([a], [b]) => a.localeCompare(b)).map(([, f], i, all) => [
    i && all[i - 1][1].family === f.family ? '' : f.family, // each family named once
    f.face, [...f.sizes].sort((a, b) => a - b).join(' · ')].map((content) => ({ content })));
  const files = [...document.fonts].filter((face) => face.status === 'loaded').length;
  const warnings = doc.warnings?.length || 'no'; // what else to read in a finished layout
  return { rows, note: `Build ${builds.length}: ${rows.length} faces · ${files} files loaded · `
    + `${doc.converged ? 'converged' : 'not converged'} · ${warnings} layout warnings` };
}
// #endregion

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region build: declare the files, build until the fonts settle, audit, build the last time
kitStatus('Loading fonts…'); // the kit's bar: it also reports any error thrown below
declareFaces(FONTS);
const build = () => buildDocument({ markdown, resources: resources() }, config());
const builds = await buildWithLoadedFonts(build, markdown);
audit = auditOf(builds); // page 3's table
drawProof(builds[0], builds.at(-1)); // page 4's picture
const doc = (await buildWithLoadedFonts(build, markdown)).at(-1); // nothing is left to load
showPages(doc, { title: 'Load every font before layout' });
// #endregion

// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ─────

// ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ─────────
function mm(value) { return { value, unit: 'mm' }; }
function pt(value) { return { value, unit: 'pt' }; }
function em(value) { return { value, unit: 'em' }; }
/** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */
function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; }
/** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */
function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; }

// ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ────────
// Postext measures text with the faces the browser has loaded, and caches the
// widths, so every face must be ready before the first build. Faces come from
// Fontsource: the same static files the PDF embeds, so screen and PDF agree.

/** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
  };
  const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin'];
  const jobs = [];
  let added = 0;
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const meta = optional ? await fontsourceMeta(family) : null;
    for (const spec of new Set(specs)) {
      const weight = parseInt(spec, 10);
      const style = spec.endsWith('i') ? 'italic' : 'normal';
      if (hasFace(family, weight, style)) continue;
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`;
        const face = new FontFace(family, `url(${url}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: ranges[subset] });
        jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return added;
}

/** Runs `build` (a buildDocument or buildBundle call) and checks the faces
 *  the pages use. A regular face missing from FONTS is loaded with a warning;
 *  bold and italic variants are loaded when the family ships them. Then the
 *  measurement caches are cleared and the build runs again. */
async function buildWithFonts(build, text = '') {
  const tried = new Set();
  for (let round = 0; round < 3; round++) {
    kitStatus('Laying out…');
    await new Promise(requestAnimationFrame);          // let the status paint first
    const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; });
    const wanted = { base: {}, variants: {} };
    for (const { font, base } of [result].flat().flatMap(fontStringsOf)) {
      const { family, weight, style } = parseFont(font);
      const key = `${family}|${weight}|${style}`;
      if (tried.has(key) || hasFace(family, weight, style)) continue;
      tried.add(key);
      (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`);
    }
    if (Object.keys(wanted.base).length) {
      console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`);
    }
    const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true });
    if (added === 0) return result;
    clearMeasurementCache();
  }
  throw new Error('The fonts did not settle after three builds.');
}

/** Every font string of the layout. `base` marks a block's own face; its
 *  bold, italic and bold-italic variants are listed whether or not used. */
function fontStringsOf(doc) {
  const found = new Map();
  const walk = (node) => {
    if (!node || typeof node !== 'object') return;
    if (Array.isArray(node)) { node.forEach(walk); return; }
    for (const [key, value] of Object.entries(node)) {
      if (typeof value === 'string' && /fontString$/i.test(key)) {
        found.set(value, found.get(value) || key === 'fontString');
      } else if (value && typeof value === 'object') walk(value);
    }
  };
  walk(doc.pages);
  walk(doc.blocks);
  return [...found].map(([font, base]) => ({ font, base }));
}

/** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }.
 *  A string with no weight ('95.8px Young Serif', from a design text) is 400. */
function parseFont(font) {
  const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim());
  if (!m) throw new Error(`Unexpected font string: ${font}`);
  const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]);
  return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' };
}

/** True when a loaded FontFace covers exactly this family, weight and style
 *  (document.fonts.check() is also true for families nobody declared). */
function hasFace(family, weight, style) {
  for (const face of document.fonts) {
    if (face.status !== 'loaded' || face.style !== style) continue;
    if (face.family.replace(/^["']|["']$/g, '') !== family) continue;
    const [low, high = low] = face.weight.split(' ').map(Number);
    if (weight >= low && weight <= high) return true;
  }
  return false;
}

/** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */
function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); }

/** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */
function fontsourceMeta(family) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

// ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ───────
/** Shows the pages as facing spreads on a dark desk: the first page is a
 *  recto on its own, then verso | recto pairs, as in a bound book. Pages
 *  are painted when they scroll near the screen. */
function showPages(docs, { title, width = 460 } = {}) {
  const root = viewer(title);
  const pages = [docs].flat().flatMap((doc) =>
    doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index })));
  const spreads = [];
  let verso = null;
  for (const p of pages) {
    if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; }
    else { spreads.push([verso, p]); verso = null; }
  }
  if (verso) spreads.push([verso, null]);
  const density = Math.min(window.devicePixelRatio || 1, 2);
  showPages.painter?.disconnect();
  const painter = new IntersectionObserver((entries) => {
    for (const { isIntersecting, target } of entries) {
      if (!isIntersecting) continue;
      painter.unobserve(target);
      const { doc, page } = target.postext;
      renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width });
    }
  }, { rootMargin: '800px' });
  showPages.painter = painter;
  root.replaceChildren(...spreads.map((pair) => {
    const spread = document.createElement('div');
    spread.className = 'pt-spread';
    for (const p of pair) {
      const figure = document.createElement('figure');
      if (p) {
        const label = p.page.pageLabel || String(p.n + 1);
        const canvas = document.createElement('canvas');
        canvas.postext = p;
        canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`;
        canvas.setAttribute('role', 'img');
        canvas.setAttribute('aria-label', `Page ${label}`);
        const folio = document.createElement('figcaption');
        folio.textContent = label;
        figure.append(canvas, folio);
        painter.observe(canvas);
      } else figure.className = 'pt-blank';
      spread.append(figure);
    }
    return spread;
  }));
  kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`);
  document.documentElement.dataset.postext = 'ready';
  return pages.length;
}

/** The desk, the bar and the error reporting, created once. */
function viewer(title) {
  if (!document.getElementById('pt-kit')) {
    document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit">
      :root { color-scheme: dark; }
      body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; }
      #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center;
        gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px);
        border-bottom: 1px solid #23262d; }
      #pt-bar strong { color: #f4f1ea; font-weight: 600; }
      #pt-actions { display: flex; gap: 12px; margin-left: auto; }
      #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
      #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; }
      .pt-spread { display: flex; }
      .pt-spread figure { margin: 0; width: min(460px, 44vw); }
      .pt-spread canvas { display: block; width: 100%; background: #fff;
        box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif;
        letter-spacing: .18em; text-transform: uppercase; color: #6c7079; }
      .pt-blank { visibility: hidden; }
      @media (max-width: 760px) {
        .pt-spread { flex-direction: column; gap: 32px; }
        .pt-spread figure { width: min(460px, 92vw); }
        .pt-blank { display: none; }
      }
    </style>`);
    document.body.insertAdjacentHTML('afterbegin',
      '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>');
    document.getElementById('pt-title').textContent = document.title || 'Postext';
    addEventListener('error', (event) => kitFail(event.error ?? event.message));
    addEventListener('unhandledrejection', (event) => kitFail(event.reason));
  }
  if (title) document.getElementById('pt-title').textContent = title;
  return document.getElementById('pages')
    ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' }));
}

function kitStatus(text) {
  viewer();
  document.getElementById('pt-status').textContent = text;
}

function kitFail(error) {
  document.documentElement.dataset.postext = 'error';
  kitStatus(`Error: ${error?.message ?? error}`);
}

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

## تنويعات

### اترك الذاكرة المؤقتة كما هي

ما زال المثال يحمّل كل الخطوط، لكن البناء الثاني يحتفظ بكل مواضع كسر الأسطر من الأول: يتجاوز الشريطان في الصفحة 4 الخط، ويعدّ التعليق 0 أسطر مكسورة في مواضع أخرى، وتُطبع كلمات التعليقات ثابتة العرض بعضها فوق بعض، كل مقطع مرسوم حيث قاسه الخط الاحتياطي.

```diff
-    clearMeasurementCache(); // the widths measured with a fallback stay cached until cleared
+    // clearMeasurementCache();
```

### انسَ خطًا

احذف الوزن العريض من IBM Plex Mono، الذي ينضّد العنوان التمهيدي ورأس الجدول، فيرمي المثال الخطأ `No FontFace for IBM Plex Mono 700 normal` قبل أن يعرض أي صفحة؛ ولو اكتفيت بـ`document.fonts.check()` لثخّن المتصفح الوزن العادي.

```diff
-  'IBM Plex Mono': ['400', '400i', '700', '700i'], // labels, chips, the table, captions
+  'IBM Plex Mono': ['400', '400i', '700i'], // labels, chips, the table, captions
```

### ضمّن الخطوط نفسها في PDF

تضمّن `renderToPdf` البايتات التي يعيدها `fontProvider` لكل خط، والمزوّد في وصفة [ملف PDF حقيقي بالخطوط نفسها مضمَّنة](https://postext.dev/ar/cookbook/pdf-with-embedded-fonts.md) يقدّم ملفات latin من Fontsource، وهي تتوقف عند Latin-1: لحروف هذا الكتيّب ż وł وř وů، اجعله يعيد ملف الخط الكامل لكل خط من مجلدك.

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

- **حمّل كل أوجه الخط قبل الإخراج.** يقيس الإخراج النص بأوجه الخط التي حمّلها المتصفح ويخزّن العروض مؤقتًا، فالوجه الذي يصل بعد البناء الأول يترك فواصل أسطر خاطئة وملف PDF لم يعد يطابق الشاشة. حمّل كل وزن وكل نمط أولًا، واستدعِ clearMeasurementCache() قبل إعادة البناء إذا تأخر وصول أحدها.
- **fontFamily اسم عائلة واحدة، لا قائمة CSS أبدًا.** قائمة مثل 'Lora, serif' تُقرأ اسمًا لعائلة واحدة غير موجودة، فيُقاس النص بخط بديل دون أي تنبيه وتختلف نتيجة Canvas وHTML وPDF. سمِّ عائلة واحدة.
- **ملفات latin من Fontsource تُسقط الحروف خارج اللاتينية.** يضمّن مزوّد PDF ملفات latin من Fontsource، وهي تغطي الإسبانية ونصوص أوروبا الغربية لكنها لا تغطي → و≈ و✓ و★ ولا الحروف اليونانية أو حروف أوروبا الوسطى؛ فتغيب تلك الحروف عن PDF. أبقِ نص PDF ضمن نطاق latin.
- **يطلب PDF كل وزن وكل نمط من كل عائلة.** يطلب renderToPdf من مزوّد الخطوط الأوجه العريضة والمائلة والعريضة المائلة لكل عائلة قد تستخدمها أي كتلة، حتى التي لا تُطبع أبدًا، ورفضٌ واحد يوقف التصدير. يجب أن يلجأ المزوّد إلى أقرب وزن توفّره العائلة، وأن يعود إلى الوجه القائم حين لا يوجد وجه مائل.
- **يُخزَّن الإعداد مؤقتًا بحسب هويته: ابنِ كائنًا جديدًا.** يخزّن المحرّك الإعدادات المحسوبة مؤقتًا بحسب هوية الكائن، فتعديل الإعداد في مكانه ثم البناء مجددًا يعيد استخدام النتيجة القديمة. ابنِ كائنًا جديدًا في كل بناء، ولهذا يكون إعداد الوصفة دالة مصنِّعة: config().
- **أي كائن headings يُلغي فاصل الصفحة قبل H1.** ينتقل H1 افتراضيًا إلى صفحة فردية (always-odd)، لكن تمرير أي كائن headings يعيد ضبط هذا الافتراض، فتتوالى الفصول دون فاصل ولا يفعل span: 'page' شيئًا. أعد كتابة headings.levels[0].breakBefore: { enabled: true, parity } في كل إعداد.
- **لوحة الألوان المستبدلة لا تصل إلى عناصر التصميم ولا إلى لون الإحالة.** يقرأ postext 1.4.1 الإعداد colorPalette في أنماط النص (المتن والعناوين والقوائم والتعليقات والجداول والإطارات) لكن لا في عناصر الترويسات والتذييلات والافتتاحيات وصفحات الأجزاء، ولا في bodyText.referenceColor: تحتفظ بالقيمة الست عشرية المكتوبة بجانب paletteId الخاص بها. حين تستبدل لوحة الألوان، لنسخة شاشة داكنة أو لإعادة تلوين، أعِد كتابة كل لون مرتبط من colorPalette قبل البناء.
- **lineHeight لنص التصميم مُضاعِف، لا بُعد أبدًا.** في خانة التصميم، يضاعف lineHeight لعنصر النص مقاس خطه (lineHeight: 1.05). في postext 1.4.1 لا يُرفض بُعدٌ مثل pt(15): يُقاس ارتفاع الافتتاحية NaN، ويسقط ما تحجزه من مساحة، بما فيه minHeight، دون تحذير، فيجري النص تحت العنوان.
- **فيض نص التصميم افتراضيًا 'ellipsis-end'.** عنصر نص التصميم الذي لا يتسع له عرضه ينتهي افتراضيًا بعلامة الحذف. اضبط overflow: 'wrap' للعناوين التي يجب أن تنكسر على أسطر أكثر.
- **تحذير إخراج: خط مفقود** (`missingFont`). عائلة خط مذكورة في الإعدادات لم تُحمَّل قط في المتصفح، فقيس النص ورُسم بخط بديل من النظام. الحل: صحّح اسم العائلة (عائلة واحدة، لا قائمة خطوط CSS) وحمّل كل أوجه الخط قبل أول بناء؛ وفي مثال CodePen أدرجها في FONTS. ([التوثيق](https://postext.dev/ar/docs/configuration.md#تحذيرات-الخطوط-المفقودة))
- **تحذير إخراج: وجه خط مفقود** (`missingFontVariant`). عائلة خط مخصصة ليس لها ملف لوزن أو نمط يستعمله المستند، كالمائل أو العريض. الحل: ارفع الوجه المفقود أو عرّفه، أو كفّ عن استعمال ذلك الوزن أو النمط. ([التوثيق](https://postext.dev/ar/docs/configuration.md#تحذيرات-الخطوط-المفقودة))

- `document.fonts.load(font)` من غير نص عيّنة لا تحمّل إلا الملف الذي يغطي المسافة، أي ملف latin؛ مرّر النص، وإلا عاد ż وř إلى خط من خطوط النظام من غير أي تحذير.
- تقصّ `renderPageToCanvas` و`postext-pdf` كل عمود بعد حافته بنقطتين، فيفقد السطر المقيس بخط احتياطي أضيق حروفه الأخيرة ولا يصدر أي تحذير.

## الحقوق

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

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

- [رقم 040 · خطوط العلامة في الإخراج وملف PDF والحزمة](https://postext.dev/ar/cookbook/brand-fonts-identity-manual.md): دليل هوية لمترو متخيَّل منضَّد بملفات خطوط العلامة نفسها، تُنزَّل مرة واحدة ويعيد استخدامها الإخراج ومزوّد خطوط PDF وحزمة .postext. · المستوى 3 (متقدم) · الأدلة والمراجع
- [رقم 056 · محرّر حيّ يجري الإخراج في Web Worker](https://postext.dev/ar/cookbook/web-worker-live-editor.md): نص Markdown بجانب صفحة جيب: عامل يبدأ من blob ينضّد الفصل بخطوطه الخاصة، وكل ضغطة مفتاح تلغي البناء الجاري. · المستوى 3 (متقدم) · الأدب القصصي والمسرح والنثر الأدبي
- [رقم 012 · من نص Markdown إلى صفحة مصمّمة](https://postext.dev/ar/cookbook/first-page-from-markdown.md): نص Markdown ببيانات تمهيدية يُنضَّد صفحات مجلة بعمودين ويُرسم على Canvas، بستة ألوان مسمّاة بدل الأزرق الافتراضي. · المستوى 2 (متوسط) · المجلات والمجلات المستقلة
