---
title: "بطاقات معاينةٍ لا تتقطّع فيها العربيّة"
description: "دليلٌ عمليّ إلى توليد صورة Open Graph لكلّ مسارٍ بدل صورةٍ واحدةٍ مشتركة. طريقان يعملان: HarfBuzz وresvg وقت البناء، وsatori عند الطلب — ومعهما الوسوم التي تحتاجها، وتحميل الخطوط، وما تفعله حين يفشل التوليد. وفيه ما تُغفله الأدلّة عادةً: ما الذي يتبدّل حين يكون النصّ عربيًّا — ترتيب الكلمات، ووصل الحروف، والحروف التي لا يملكها الخطّ."
author: "Omar Albeik"
date: 2026-08-29
type: tutorial
topics: [typography, tooling, software, engineering]
language: ar
reading_time_minutes: 26
canonical_url: https://omaralbeik.com/ar/blog/generate-a-social-card-for-every-page
translation_url: https://omaralbeik.com/en/blog/generate-a-social-card-for-every-page
source_url: https://omaralbeik.com/ar/blog/generate-a-social-card-for-every-page.md
---

# بطاقات معاينةٍ لا تتقطّع فيها العربيّة

معاينة الرابط صورةٌ ساكنة، لا أكثر. تضع عنوانها في `og:image`، فتجلبها المنصّة
مرّةً وتحتفظ بها، ثمّ ترسمها بعد ذلك كلّ منصّةٍ يمرّ فيها رابطك — من Slack
وX وLinkedIn إلى WhatsApp وiMessage وTelegram. ولا تُستشار الصفحة نفسها في شيءٍ
من ذلك، ولا يتولّى متصفّحٌ عندك رسم تلك الصورة في أيّ لحظة.

وأكثر المواقع تكتفي بصورةٍ واحدةٍ لعناوينها جميعًا، تقول للقارئ إلى أيّ موقعٍ
ينتمي الرابط — وهو ما كان يعرفه من اسم النطاق قبل أن ينظر. أمّا بطاقةٌ تُرسم
لكلّ مسارٍ على حدة فتحمل عنوان الصفحة ووصفها، أي ما يقرّر به القارئ أيفتحها أم
يمضي.

ولي منها اثنتان في الإنتاج، بُنيتا على طريقين متقابلين: omaralbeik.com يرسم
بطاقاته وقت البناء في Node عاديّة، وunblocksyria.com يرسمها عند كلّ طلبٍ على
الحافّة. وكلا الموقعين بلغتين، وفي هذا ذهب أكثر العمل، لأنّ العربيّة هي الموضع
الذي تنكشف فيه اختصارات محرّكات الرسم.

<PostFigure
  src={unfurled}
  alt="رسالتان في تطبيق محادثة. الواردة في اليسار فيها رابطٌ إنجليزيّ انفتحت معاينته: صورة البطاقة، ثمّ العنوان والوصف والنطاق يرسمها التطبيق تحتها، ثمّ الرابط ووقت الإرسال. والصادرة في اليمين النسخة العربيّة من الرابط نفسه، فقاعتها خضراء ومعاينتها كلّها معكوسة لتُقرأ من اليمين إلى اليسار."
  locale="ar"
  eager
/>

## الخلاصة أوّلًا

إن كنت تريد القائمة العمليّة وحدها فهذه هي، وما بعدها شرحٌ تعود إليه حين تقع في
واحدةٍ منها:

- بطاقةٌ لكلّ مسار، و`og:image` بعنوانٍ مطلق، ومعه العرض والارتفاع والنصّ
  البديل و`twitter:card`.
- ارسم وقت البناء، إلّا أن يكون محتوى البطاقة عرضةً للتغيّر بعد النشر.
- حمّل الخطوط من ملفّاتٍ تملكها أنت، واعرف أيّ صيغةٍ يقرؤها محرّكك.
- قرّر ما الذي يُضحّى به أوّلًا قبل أن تضطرّ إلى التضحية.
- لا بدّ لكلّ مسارٍ أن ينتهي بصورة؛ فالـ‏500 يعني غياب الصورة رأسًا، لا صورةً
  بديلة.
- في العربيّة: الترتيب واختيار الوجه والقياس ثلاث مشكلاتٍ منفصلة، والنصّ
  العربيّ الخالص يخرج سليمًا وإن أخطأت الثلاث جميعًا.
- تحقّق من الترتيب آليًّا، فالعين لا تراه.

## الوسوم التي تحتاجها

```html
<meta property="og:image" content="https://omaralbeik.com/og/ar/blog/some-post.png" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="عنوان المقالة — عمر البيك" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="https://omaralbeik.com/og/ar/blog/some-post.png" />
```

سبعة وسومٍ لكلٍّ منها عمل. فالعنوان لا بدّ أن يكون مطلقًا، إذ لا تُكمل المنصّات
العنوان النسبيّ من الصفحة كما يفعل المتصفّح. و`og:image:type` يُغني المنصّة عن
تخمين نوع الملفّ. والعرض والارتفاع يمكّنانها من حجز المساحة قبل أن تصل
البايتات، فلا تقفز البطاقة أثناء التحميل. و`og:image:alt` هو ما ينطق به قارئ
الشاشة. وما لم تضبط `twitter:card` على `summary_large_image` فستحصل على مربّعٍ
صغيرٍ لا على البطاقة التي رسمتها. وأمّا `twitter:image` فلا يزيد على تكرار
`og:image`، وX يتراجع إلى الثاني إن غاب الأوّل — لكنّي أوثر التصريح على
الاتّكال على ذلك.

وأمّا القياس فـ‏1200×630 هو ما تقبله المنصّات جميعًا، غير أنّ كلًّا منها تقصّه
على نحوها — وX تقصّه إلى 16:9 فيما جرّبت — فأبقِ النصّ داخل هامشٍ يسلم منها
كلّها. هامشي 96 بكسلًا، بلغته بالتجربة والنظر في القصّات لا بالنقل عن أحد؛ فما
تنشره المنصّات قياس الصورة لا المساحة الآمنة داخلها. فتحقّق من هامشك أنت.

على أنّ هذا نصف الرأس المتعلّق بالصورة وحده؛ ويبقى `og:title` و`og:description`
و`og:url` و`og:type` لازمةً كما هي.

<PostFigure
  src={cardEn}
  alt="بطاقة معاينة رابط: كلمة ARTICLE بحروفٍ زرقاء صغيرة فوق خطٍّ قصير، ثمّ عنوان المقالة بخطٍّ مشرشرٍ كبير، ووصفٌ في سطرين تحته، وتذييلٌ فيه شعار الموقع والنطاق والتاريخ ومدّة القراءة."
  caption="بطاقةٌ واحدة وما عليها. لا لقطة شاشة ولا تدرّج لونيّ، فمعاينة الرابط ينبغي أن تشبه الصفحة التي تفتحها."
  locale="ar"
/>

## وقت البناء أم عند الطلب

يحسم الأمر سؤالٌ واحد: هل يتبدّل محتوى البطاقة بعد النشر الذي ولّدها؟

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

وإن كان يتبدّل فارسمها عند الطلب. ففي unblocksyria.com تحمل بطاقة الخدمة حالة
الحجب وما بلغته المراسلات، وكلاهما متغيّر. والصورة المولَّدة وقت البناء صورةٌ
للفهرس يوم النشر، وهي في خدمةٍ رُفع عنها الحجب بعد ذلك ليست قديمةً فحسب، بل
كاذبة.

<PostFigure
  src={ubsServiceEn}
  alt="بطاقة خدمة من Unblock Syria على أرضيّةٍ خضراء داكنة: شعار Coursera، والاسم Coursera بخطٍّ مشرشرٍ فاتحٍ كبير، وشارتان بيضويّتان مكتوبٌ عليهما Blocked وContacted."
  caption="بطاقةٌ يستحيل رسمها وقت البناء، فالشارتان حالةٌ حيّة: أمحجوبةٌ الخدمة، وإلى أين بلغت المراسلات. وكلتاهما تتبدّل بغير نشرٍ جديد."
  locale="ar"
/>

<PostFigure
  src={ubsServiceAr}
  alt="البطاقة نفسها بالعربيّة. التخطيط كلّه معكوس: شعار Coursera في اليمين، والاسم مكتوبٌ بالعربيّة «كورسيرا»، والشارتان تقرآن «محظور» و«تم التواصل»."
  caption="المسار نفسه بالعربيّة. تنعكس البطاقة كاملةً — الشعار والاسم والشارتان والعلامة — ويبقى اسم المنظّمة اللاتينيّ على حاله داخلها؛ ولهذا تُحمَّل عائلتا الخطّ في كلّ بطاقةٍ مهما تكن اللغة."
  locale="ar"
/>

## التوليد وقت البناء

المولّد سكربت Node عاديّ يعمل بعد البناء:

```json
"build": "astro build && node scripts/og-render.mjs"
```

وهو كلّه في هذا القدر: يقرأ الصفحات المبنيّة، ويرسم لكلٍّ منها بطاقة، ويكتبها
حيث أعلنت الصفحة أنّها ستكون:

```js
for (const card of await scanCards("dist/client")) {
  const png = await cardPng(card);
  const file = join("dist/client/og", `${card.path.replace(/^\//, "")}.png`);
  await mkdir(dirname(file), { recursive: true });
  await writeFile(file, png);
}
```

تسحب `scanCards` العنوان والوصف والعنوان الفرعيّ من `<head>` كلّ صفحة، فتسقط
بذلك قائمةٌ ثانيةٌ كان عليك أن تبقيها متّسقة، ويستحيل على البطاقة أن تخالف
الصفحة التي تعاينها.

وإنّما جُعل خارج إطار العمل عن قصد. فـ‏Astro مع Cloudflare يولّد المسارات
مسبقًا داخل workerd، حيث لا نظام ملفّاتٍ يُحمَّل منه خطّ، ولا سبيل إلى تحميل
مكتبةٍ أصليّة تحوّل الرسم إلى صورة؛ ومسار `og.png.ts` يفشل وقت البناء إمّا
بملفّ `.node` لا يمكن حزمه وإمّا بملفٍّ مفقود، بحسب المكتبة التي تختارها. أمّا
تشغيله بعد البناء في Node فيجعل resvg نفسه الذي يرسم أيقونات الموقع هو الذي
يرسم البطاقات.

## التوليد عند الطلب

`ImageResponse` من `next/og`: يرتّب satori العناصر، ثمّ تُحوَّل النتيجة صورةً.

المسار ملفٌّ يصدّر صورةً افتراضيًّا:

```tsx
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";

export default async function Image({ params }) {
  const { locale, slug } = await params;
  const service = await getService(slug, locale);
  return renderCard(<ServiceCard service={service} locale={locale} />);
}
```

وأصعب ما في الأمر الخطوط. فلا نظام ملفّات على Workers، و`import.meta.url` لا
يفضي إلى ملفٍّ يُقرأ. وجلبها من نطاقك أنت يعني طلبًا فرعيًّا يعود عبر
الحافّة كلّما جاءت منصّةٌ تطلب الصورة، وأسوأ ما فيه أن ينقلب حلقة جلبٍ لا
تنتهي. فلم يبقَ إلّا أن تُضمَّن الوجوه بترميز base64 داخل الحزمة.

ثمّ إنّ لذلك ثمنًا: وزنان من وجهٍ لاتينيٍّ ومثلهما من عربيّ تبلغ 129 كيلوبايت من
ثنائيّ الخطّ، تصير 172 بعد base64، تُشحن في كلّ حزمة. وأنا أوثر أن أدفعها عند
النشر على أن أزيد قفزةً شبكيّةً في المسار الوحيد الذي لا بديل له. وإن قدرت على
تجزئة الوجوه إلى ما ترسمه بطاقاتك فعلًا صغر الرقم كثيرًا.

ولتكن WOFF لا WOFF2، فـ‏satori لا يقرأ الثانية، ولا يتراجع عنها إلى وجهٍ
افتراضيّ بل يرمي استثناءً.

```ts
const QOMRA_REGULAR_B64 = "d09GRgABAAAAAG10...";

export const qomraRegular = Uint8Array.from(
  atob(QOMRA_REGULAR_B64),
  (c) => c.charCodeAt(0),
).buffer;
```

## اجعل كلّ مسارٍ ينتهي بصورة

المنصّة التي يأتيها 500 على `og:image` لا ترجع إلى صورة موقعك الافتراضيّة، بل
ترسم البطاقة بلا صورةٍ أصلًا.

وهذا ممّا يُصمَّم له، لأنّ الشيفرة البديهيّة تضع الإخفاق حيث لا تبلغه يدك:

```tsx
// لا تفعل: تكون `try/catch` قد خرجت قبل أن يقع الإخفاق.
export default async function Image({ params }) {
  try {
    return new ImageResponse(<ServiceCard ... />, { width, height, fonts });
  } catch {
    return new ImageResponse(<BrandCard />, { width, height, fonts });
  }
}
```

ذلك أنّ `arrayBuffer()` هي الموضع الذي تُرسم عنده الصورة فعلًا؛ فنادِها قبل
أن تعود، يقع الإخفاق داخل `try` حيث تستطيع أن ترسم غيرها:

```ts
export function renderCard(element: ReactElement): Promise<ArrayBuffer> {
  return new ImageResponse(element, { width: OG_WIDTH, height: OG_HEIGHT, fonts })
    .arrayBuffer();
}
```

عندها يجد المنادي ما يرجع إليه:

```tsx
let png: ArrayBuffer;
try {
  png = await renderCard(<ServiceCard data={data} logo={logo} />);
} catch {
  try {
    // الشعار البعيد أرجح ما يكون قد أخفق.
    png = await renderCard(<ServiceCard data={data} logo={null} />);
  } catch {
    // لا ترسم شيئًا من بيانات الصفحة، فلا صفحة تستطيع كسرها.
    png = await renderCard(<BrandCard locale={locale} />);
  }
}
return new Response(png, { headers: { "content-type": "image/png" } });
```

<PostFigure
  src={ubsFallback}
  alt="بطاقة Unblock Syria التعريفيّة: العلامة، ورمز المفتاح، وسطر Syria is Free. Now Unblock It."
  caption="الطبقة الأخيرة، والوحيدة التي لا تتوقّف صحّتها على الصفحة التي تنوب عنها."
  locale="ar"
/>

على أنّ الإخفاقين لا يُخزَّنان تخزينًا واحدًا: فالمُعرّف الذي لا وجود له جوابٌ
دائم يُخزَّن كسائر البطاقات، أمّا خطأ 5xx من واجهتك فعارضٌ لا يجوز تخزينه، وإلّا
ثبّتَّ البطاقة العامّة على رابط صفحةٍ حقيقيّةٍ طوال نافذة
`stale-while-revalidate`، بعد أن تكون الواجهة قد عادت بزمنٍ طويل.

وللتوليد وقت البناء العلّة نفسها في موضعٍ آخر: فالمسار الذي يُولَّد عند الطلب
لا HTML مبنيًّا له يُقرأ، فيعلن بطاقةً لم تُرسم قطّ. ويكفيه فحصان في آخر
السكربت: أن تكون كلّ بطاقةٍ معلنة موجودةً على القرص، وأن تكون كلّ صفحةٍ عليها
`prerender = false` إمّا متنازلةً عن بطاقتها وإمّا مستثناةً من الفهرسة.

## خطآن في عنوان الصورة

كلاهما كلّفني جولةً كاملةً من المعاينات الفاسدة، ولا يظهر أثرهما في شيءٍ ممّا
ترسمه.

فعنوان الصورة يُشتقّ عادةً من مسار الصفحة الأساسيّ بإلحاق `/opengraph-image`
به. غير أنّ هذا المسار في القوائم المقسّمة إلى صفحاتٍ يحمل الاستعلام معه، فأنتج
`/articles?page=2` عنوانًا هو `/en/articles?page=2/opengraph-image`، واللاحقة
فيه جزءٌ من *قيمة* الاستعلام لا من المسار، فيؤول العنوان إلى صفحة الـ‏HTML
نفسها. وهكذا كانت كلّ منصّةٍ تتجاوز الصفحة الأولى تُسلَّم مستندًا حيث تنتظر
صورة. والعلاج أن تُسقط الاستعلام: لا بطاقة لكلّ صفحةٍ من قائمة.

```ts
// لا تفعل — في ‎/articles?page=2 ينتج هذا
// ‎/en/articles?page=2/opengraph-image
const image = new URL(`${canonicalPath}/opengraph-image`, SITE_URL);

// افعل — البطاقة تخصّ المسار، لا الاستعلام أبدًا.
const image = new URL(canonicalPath, SITE_URL);
image.search = "";
image.pathname = `${image.pathname.replace(/\/$/, "")}/opengraph-image`;
```

وأمّا الثاني فتصادم أسماء: احجز `opengraph-image` مُعرّفًا لا يُتاح لأحد. فإطار
العمل الذي يحلّ المقطع الثابت قبل المتغيّر المجاور له سيدع صفًّا مُعرّفه كذلك
يحجب بطاقة مجموعته، فتجيب صفحة التفصيل بصورةٍ بدل أن تجيب بنفسها.

## ضبط النصّ في مساحته

اللفّ يحتاج عروضًا حقيقيّةً لا تقديرًا. فإذا توفّرت لك دالّة قياسٍ صحيحة صار
ما بقي عاديًّا: لفٌّ جشعٌ إلى حدٍّ من الأسطر، وحذفٌ بثلاث نقاطٍ إن فضل نصّ،
وكسرٌ داخل الكلمة لكلّ رمزٍ يجاوز عرض العمود، إذ ليس في العنوان الشبكيّ مسافةٌ
يُكسر عندها.

يخطئ التقدير هنا خطأً فادحًا: فضبط حجم العنوان بعدد حروفه يبلغ خطؤه بين
الكتابتين نحو الضعف. وعلى بطاقات Unblock Syria وضع «المرصد الأورومتوسطي لرصد
الزلازل» عند 28 بكسلًا إلى جانب «EMSC» عند 104، وكلاهما اسمٌ عاديٌّ لمنظّمةٍ من
النوع نفسه.

<PostFigure
  src={sizingByCount}
  alt="صفّان. في الأعلى بالأحمر حروف EMSC مرسومةً كبيرةً جدًّا بجانب سطرٍ عربيٍّ أصغر بكثير. وفي الأسفل بالأسود الاسمان نفسهما بالحجم البصريّ نفسه."
  caption="الاسمان نفسهما مضبوطين على وجهين. عدّ الحروف ليس قياسًا بل تخمينٌ عن كتابة، وهو هنا مخطئٌ في الاتّجاهين معًا."
  locale="ar"
/>

ولا حدّ للأسطر في satori كذلك، فالجملة في أربعة أسطرٍ تمدّ الكتلة إلى ما
تحتها؛ والحدّ يؤخذ من المقاييس نفسها التي يؤخذ منها كلّ شيء.

ثمّ تأتي الحال التي لا يتّسع فيها شيء: فعنوانٌ في ثلاثة أسطرٍ لا يحتمل معه
سطرين من الوصف بالحجم الكامل، إذ تتجاوز الكتلة الخطّ فوقها والشعار تحتها. فنزّل
الحجم درجةً، وقرّر ترتيب الأفضليّة قبل أن تحتاج إليه: يبقى الوصف كاملًا، ثمّ
يبقى العنوان غير مختصر، ثمّ يبقى الحجم كبيرًا.

```js
for (const descLines of card.description ? [2, 1, 0] : [0]) {
  for (const size of [64, 57, 50]) {
    const block = layout(card, { size, descLines });
    if (block.height <= BAND) return block;   // يفوز أوّل ما يتّسع
  }
}
```

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

<PostFigure
  src={stressArLong}
  alt="بطاقة عربيّة بعنوانٍ طويلٍ جدًّا يملأ ثلاثة أسطرٍ بحجمٍ مصغَّر، بلا وصفٍ تحته، وليس أسفله إلّا التذييل."
  caption="عنوانٌ لم يقصّه أحد: نزل الحجم درجةً وذهب الوصف كي لا تتجاوز الكتلة إطارها. واحتفظ بمجموعةٍ من أمثاله، فالبطاقات الحقيقيّة مهذّبةٌ كلّها، ولذلك لا تكشف عللًا."
  locale="ar"
/>

## من اليمين إلى اليسار

يُختصر هذا عادةً في عبارة «دعم RTL» وكأنّه مشكلةٌ واحدة، وهو ثلاث: تُخفق كلٌّ
منها وحدها، ولكلٍّ منها علاجٌ غير علاج أختها.

### ترتيب الكلمات

<PostFigure
  src={rasterizerText}
  alt="الجملة العربيّة نفسها مرّتين. في الأعلى مرسومةً بعنصر النصّ في resvg: الكلمات اللاتينيّة مربّعاتٌ فارغة، والكلمة العربيّة الأولى انتقلت إلى أقصى اليسار. وفي الأسفل مرسومةً مساراتٍ: الكلمات اللاتينيّة سليمة والجملة تُقرأ من اليمين إلى اليسار بترتيبها الصحيح."
  caption="الجملة نفسها بعنصر النصّ في resvg، ثمّ بالطريق الذي تصفه هذه المقالة. في السطر الأعلى إخفاقان معًا: المقاطع في الترتيب الخطأ، والكلمات اللاتينيّة مربّعاتٌ فارغة لأنّ الوجه العربيّ لا يرسمها ولم يتراجع شيءٌ إلى سواه."
  locale="ar"
/>

لا يطبّق resvg خوارزميّة يونيكود للاتّجاهين، و`direction` و`unicode-bidi`
مُهمَلان عنده إهمالًا تامًّا: تضبطهما على `embed` أو `isolate` أو `plaintext`
فيخرج الخرج مطابقًا بايتًا ببايت، فليس ثمّة ما تضبطه لتخرج من المأزق. وفي
satori الثغرة نفسها من الجهة الأخرى، إذ يرصف الكلمات بالترتيب المنطقيّ من
اليسار إلى اليمين، فيخرج كلّ اسمٍ عربيٍّ مقلوبًا.

والعجيب أنّ العربيّة الخالصة تبدو سليمةً على أيّ حال، وهذا ما يجعل اكتشاف
العلّة مكلفًا؛ فـ‏HarfBuzz يستنتج الاتّجاه من الكتابة، فيخرج السطر الذي لا
لاتينيّ فيه صحيحًا، ولا يظهر الخلل حتّى يدخل الجملةَ اسمُ منتجٍ أو رقم إصدار —
وهي في موقعٍ عن البرمجيّات أكثر الجمل.

فتولَّ الترتيب بنفسك وقت البناء: يحلّ bidi-js الترتيب البصريّ، وهي القاعدة L2
من الخوارزميّة — من أعلى مستوى تضمينٍ نزولًا إلى أدنى مستوًى فرديّ، تُعكس كلّ
سلسلةٍ متّصلةٍ من المقاطع عنده أو فوقه. ثمّ تكسر الأسطر بالترتيب المنطقيّ، لأنّ
هناك موضع الكسر، وتعيد ترتيب كلّ سطرٍ للعرض على حدة. وهكذا يصنع المتصفّح.

تبدأ بالتقسيم إلى مقاطع، والمقطع أطول امتدادٍ يمكن تسليمه إلى محرّك التشكيل
دفعةً واحدة، أي ما اتّحد اتّجاهه وخطّه:

```js
const bidi = bidiFactory();

function logicalRuns(text, stack, baseDir) {
  const { levels } = bidi.getEmbeddingLevels(text, baseDir);
  const runs = [];

  for (let i = 0; i < text.length; ) {
    const char = String.fromCodePoint(text.codePointAt(i));
    const level = levels[i];
    const face = faceFor(stack, text.codePointAt(i));
    const last = runs[runs.length - 1];

    if (last && last.level === level && last.face === face) last.text += char;
    else runs.push({ level, face, text: char });

    i += char.length;
  }
  return runs;
}
```

ثمّ القاعدة L2 نفسها، وهي أقصر من وصفها:

```js
function reorder(runs) {
  const levels = runs.map((r) => r.level);
  const highest = Math.max(...levels);
  const odd = levels.filter((l) => l % 2 === 1);
  const lowestOdd = odd.length ? Math.min(...odd) : highest + 1;

  const out = [...runs];
  for (let level = highest; level >= lowestOdd; level--) {
    for (let start = 0; start < out.length; start++) {
      if (out[start].level < level) continue;
      let end = start;
      while (end + 1 < out.length && out[end + 1].level >= level) end++;
      out.splice(start, end - start + 1, ...out.slice(start, end + 1).reverse());
      start = end;
    }
  }
  return out;
}
```

تشكيل المقطع ستّة أسطر، اثنان منها فخّ:

```js
function shapeRun(run) {
  const buffer = new HbBuffer();
  buffer.addText(run.text);
  buffer.guessSegmentProperties();
  buffer.setDirection(run.level % 2 === 1 ? Direction.RTL : Direction.LTR);
  shape(run.face.font, buffer);
  return buffer.getGlyphInfosAndPositions();
}
```

خمّن أوّلًا، فبه تُضبط الكتابة واللغة، ثمّ صرّح بالاتّجاه؛ وإلّا استنتجه محرّك
التشكيل من المحارف فنقض عليك مرحلة الاتّجاهين التي فرغت منها للتوّ. والاتّجاه
تعدادٌ رقميّ، فتمرير السلسلة `"rtl"` ليس خطأ نوعٍ يُنبّهك إليه المترجم، بل يعني
`INVALID` بصمت، فيعود كلّ عرضٍ أفقيٍّ صفرًا.

وعلى الحافّة، حيث لا سبيل إلى تشغيل HarfBuzz بنفسك، يؤدّي `row-reverse` الغرض
نفسه، بشرطٍ واحد:
أن تعيد ترتيب المقاطع الاتّجاهيّة لا الكلمات المفصولة بالمسافات، فخوارزميّة
الاتّجاهين لا تعكس ما بداخل المقطع اللاتينيّ. وإلّا صار «متجر Google Play» في
يدك «Play Google»، وهو الخطأ في الموضع الوحيد الذي يقع فيه الاسم المختلط فعلًا.

### أيّ وجهٍ يرسم الحرف

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

فـ‏Fontsource يقسّم العائلة ملفًّا لكلّ كتابة، وأنت تحمّل ملفّاتٍ لا عائلات؛
والملفّ العربيّ من IBM Plex Sans Arabic لا يرسم حروف «libVLC»، وشقيقه
اللاتينيّ تنزيلٌ منفصلٌ من العائلة نفسها. وsatori أشدّ من ذلك: يختار وجهًا لكلّ
رسمٍ ولا يتراجع إن لم يجده فيه، فوسمٌ عربيٌّ أمام مجموعة خطوطٍ لاتينيّة يرمي
استثناءً ويردّ المسار 500.

فعامل الخطوط وقت البناء قوائمَ مرتّبة، واختر منها لكلّ مقطع؛ واسأل كلّ وجهٍ
عمّا يغطّيه بدل أن تثق باسمه:

```js
export function loadFace(sfnt) {
  const face = new Face(new Blob(sfnt), 0);
  const font = new Font(face);
  font.setScale(face.upem, face.upem);
  return { font, upem: face.upem, covers: new Set(face.collectUnicodes()) };
}

const faceFor = (stack, codePoint) =>
  stack.find((face) => face.covers.has(codePoint)) ?? stack[0];
```

فـ`collectUnicodes` تقرأ جدول cmap في الخطّ، وهو ما يقدر الملفّ على رسمه حقًّا.
وإذا قدّمت الوجه العربيّ وأتبعته رفيقه اللاتينيّ بقيت الكلمة اللاتينيّة داخل
الجملة العربيّة في الخطّ نفسه، فلا يتبدّل الصوت في منتصف السطر.

وأمّا على الحافّة فالتراجع بالسلسلة كلّها لا بالحروف الناقصة؛ إذ يستبدل satori
لكلّ رسمٍ على حدة، واسمٌ فيه ثلاثة حروفٍ من خطٍّ آخر يُقرأ عطلًا في التصيير لا
اختيارًا. وبهذه القاعدة نفسها تُحسم البطاقة الكرديّة: ليس في الوجه العربيّ Ç
ولا ç ولا ê ولا î ولا û ولا ş، وهي أكثر ما في الكرديّة، فتُضبط البطاقة بالوجه
اللاتينيّ كلّها.

### الوصل والقياس

العربيّة كتابةٌ موصولة: شكل الحرف يتبع جاريه، والأشكال الموصولة أضيق من
المنفصلة.

يختار satori الأشكال الصحيحة، وإنّما يخطئ في القياس: فصندوق الكلمة يُقاس من
عروض الحروف المنفصلة ثمّ يُرسم بالموصولة، فيحمل كلّ صندوقٍ فرق ما بينهما فراغًا
ميّتًا في طرفه.

فعبر فهرس Unblock Syria تخسر الكلمة الوسيطة 0.91em، وتخسر أسوؤها 3.49em؛ وهما
في عنوانٍ بحجم 52 بكسلًا فجوةٌ من 47 بكسلًا وأخرى من 181، والثانية قرابة خُمس
عمود النصّ تنفتح في وسط اسم.

على أنّ سبع عشرة كلمةً تذهب في الاتّجاه المعاكس فتُرسم *أعرض* من صندوقها،
لأنّ الشكل الأخير في بضعة حروفٍ أعرض من المنفصل. والخطأ الذي ينقلب اتّجاهه لا يُلغى
بمعامل. فاحسب العرض لكلّ كلمةٍ من مقاييس الخطّ واضبطه على الصندوق، وولّد جدول
المقاييس من الخطّ المضمَّن، واجعل اختبارًا يعيد اشتقاقه من البايتات نفسها، وإلّا
ترك تبديلُ خطٍّ خلفه جدولًا قديمًا لا ينبّهك إليه أحد.

<Figure
  caption="كلّ اسم خدمةٍ عربيٍّ في الفهرس، مقيسًا بالعروض التي تُرسم بها البطاقات. أكثر الكلمات تخسر بين ربع em وواحدٍ ونصف، والعمود الأحمر هو السبع عشرة التي تتجاوز صندوقها؛ ولهذا لا يصلح تصحيحٌ واحد."
  lockDirection
>
  <DeadAir locale="ar" />
</Figure>

ثمّ علاجٌ خاطئ يستحقّ أن يُسمّى باسمه، لأنّه يبدو صائبًا ثمّ يُخفق في صمت.
التشكيل المسبق أن تستبدل بكلّ حرفٍ الشكلَ الذي يقتضيه موضعه، ثمّ تسلّم المحرّك
النتيجة بالترتيب البصريّ؛ فيرتّبها المحرّك مرّةً ثانية، فلا تعود الأشكال
التي ثبّتّها موافقةً للمواضع التي انتهت إليها الحروف. وهكذا خرجت «التجاري»
«التجاير».

انظر ما الذي جرى: الحرفان الأخيران تبادلا موضعيهما، وهما الحرفان اللذان فصل
بينهما حرفٌ لا يوصل. فالراء لا توصل بما بعدها، فتُرسم «التجاري» أربع قطع — ا،
لتجا، ر، ي — آخرها قطعتان من حرفٍ واحدٍ متجاورتان؛ فإذا أُعيد ترتيب القطع حطّت
الياء قبل الراء.

ثمّ يستر العطلُ نفسه بنفسه: فالياء *توصل* بما بعدها، فتتّصل القطعتان
المتبادلتان «ير»، وتخرج الكلمة شكلًا عربيًّا سليمًا لا حروفًا مبعثرة. لا استثناء
يُرمى، وكلّ رسمٍ حرفٌ حقيقيّ، والوصلات كلّها سليمة، ولا ينتبه إلى أنّ الكلمة
صارت كلمةً أخرى إلّا من يقرأ الكتابة.

<PostFigure
  src={preshape}
  alt="صفّان لكلمةٍ عربيّةٍ واحدة. في الأعلى بالأحمر حروفها في الترتيب الخطأ. وفي الأسفل بالأسود الكلمة نفسها على صورتها الصحيحة."
  caption="كلمةٌ واحدة بترتيبين. لا رسمٌ ناقصٌ ولا وصلٌ مكسور، بل كلمةٌ حقيقيّة صارت كلمةً أخرى؛ ولذلك لا يلتقطها شيءٌ بعدها."
  locale="ar"
/>

أمّا المحايدات فوجهٌ آخر من العلّة نفسها. فالرقم أو النقطتان أو القوس الملتصق
بكلمةٍ عربيّة ليس حرفًا عربيًّا، والمحرّك الذي لا اتّجاهين فيه يرسمه بالترتيب المنطقيّ
فيضعه في الجهة الخطأ من الكلمة: فالنقطتان موضعهما يسار «ليجندز» لا يمينها.
وتزيد الأقواس أنّها تُعكس في السياق اليمينيّ، ينصّ يونيكود على ذلك ولا يفعله
satori، فخرجت «جو (جولانج)» بأقواسٍ متّجهةٍ إلى خارجها.

<PostFigure
  src={ubsNeutrals}
  alt="بطاقة من Unblock Syria بالعربيّة للغة البرمجة Go. الاسم يُقرأ «جو (جولانج)» بأقواسٍ معكوسةٍ على الوجه الصحيح، بجانب شعار Go وشارةٍ مكتوبٌ عليها «محدود»."
  caption="تُخزَّن فاتحًا ثمّ مغلقًا وتُرسم على العكس، ولا شيء في المحرّك يفعل ذلك من تلقائه."
  locale="ar"
/>

لا يقع من هذا شيءٌ وقت البناء: يشكّل HarfBuzz كلّ مقطع، ويخرج كلّ رسمٍ
`<path>`، فلا يفعل resvg إلّا أن يملأ متّجهات، ولا يشغّل محرّك نصّه أصلًا؛ وفي
ذلك زوال علّة
تراجع الخطوط عنده أيضًا.

```js
// السطر اليمينيّ يبدأ من الحافّة اليمنى، فيبدأ القلم على بُعد عرض السطر كلّه
// إلى يسارها. أخطئ هذا تحطّ عربيّةٌ صحيحة في النصف الخطأ من البطاقة.
let pen = dir === "rtl" ? x - measure(text, { stack, size }) : x;

for (const run of reorder(logicalRuns(text, stack, dir))) {
  const scale = size / run.face.upem;

  for (const glyph of shapeRun(run)) {
    const d = run.face.font.glyphToPath(glyph.codepoint);
    if (d) {
      const gx = pen + glyph.xOffset * scale;
      const gy = y - glyph.yOffset * scale;
      // مقلوبٌ في المحور الرأسيّ: وحدات الخطّ تصعد، وSVG ينزل.
      parts.push(
        `<path d="${d}" fill="${fill}" ` +
          `transform="translate(${gx} ${gy}) scale(${scale} ${-scale})"/>`,
      );
    }
    pen += glyph.xAdvance * scale;
  }
}
```

### وبعد أن تُرسم صحيحةً

<PostFigure
  src={cardAr}
  alt="النسخة العربيّة من التصميم نفسه. العنوان يُقرأ من اليمين إلى اليسار وفيه الكلمتان اللاتينيّتان libVLC وSwift 6 بترتيبهما الصحيح، وشعار الموقع والنطاق في اليمين."
  caption="البطاقة العربيّة من المولّد نفسه. ينعكس التخطيط كلّه عن قيمةٍ واحدة هي الحافّة التي تبدأ منها القراءة، فتختلف اللغتان في ما تصنعه الرسوم لا في مواضعها."
  locale="ar"
/>

يبقى أمرٌ ليس عطلًا، غير أنّ البطاقة بدونه خاطئة. فالتبعيد بين الحروف والحروف
الكبيرة أداتان لاتينيّتان: العربيّة موصولة فالتبعيد يقطع وصلها، وليس فيها حروفٌ
كبيرة تُرفع. فيُضبط العنوان الفرعيّ العربيّ ضبطًا بسيطًا بحجمٍ يوازن أخاه
الإنجليزيّ.

كذلك تُضبط العربيّة أصغر وأوسع تباعدًا — 58 و52 و46 مقابل 64 و57 و50 في
العنوان، وتباعد أسطرٍ 1.52 مقابل 1.22 — لأنّ صواعدها ونوازلها أبعد مدًى،
والأسطر المضبوطة على تباعد اللاتينيّة تتصادم.

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

أوّلها ما لا شأن له بالنصّ أصلًا:

- **وسومٌ تُضاف من المتصفّح.** برامج المنصّات لا تشغّل جافاسكربت عندك، فإن
  أضافها إطار العمل بعد أن تعمل الصفحة في المتصفّح لم تجد المنصّة إلّا هيكلًا
  فارغًا. موضعها الـ‏HTML الذي يرسله الخادم.
- **`og:image` نسبيّ.** أكثر هذه وقوعًا: منصّةٌ تُكمله ومنصّةٌ لا تعرض صورةً
  أصلًا.
- **WebP أو AVIF.** دعمها بين المنصّات متفاوتٌ على نحوٍ لا يتفاوت في
  المتصفّحات؛ فالزم PNG أو JPEG.
- **وسمان لصورةٍ واحدة.** نظام محتوًى وقالبٌ يُصدر كلٌّ منهما `og:image` خاصّته،
  ولا تدري أيّهما يفوز.
- **الحماية من الروبوتات.** الذي يجلب صورتك روبوت، وقاعدةٌ افتراضيّة تحجبه.
  فإن ظهرت الصفحة عندك والمعاينة فارغة، فاجلب عنوان الصورة بوكيل استخدام
  المنصّة قبل أن تغيّر شيئًا.
- **تخزينٌ لا عطل.** المعاينة التي كانت صحيحةً ثمّ فسدت مخزّنةٌ في الغالب، فأعد
  قراءتها من أداة المنصّة نفسها بدل أن تعيد النشر.

ثمّ ما يخصّ العربيّة، وأكثره تلخيصٌ لما مضى، جُمع هنا لأنّ بينها قاسمًا يستحقّ
أن يُقال وحده: إن كنت لا تقرأ الكتابة بدا لك خرجها كلّه سليمًا.

- **عكس السلسلة.** عكس النقاط الرمزيّة ليس تصييرًا من اليمين إلى اليسار، بل هو
  وضعٌ للترتيب البصريّ في حقلٍ موضوعٍ للترتيب المنطقيّ، فينكسر به البحث والنسخ
  وقارئات الشاشة، ثمّ يعكسه بعدك أوّلُ من يرتّب النصّ.
- **كتابة أشكال العرض في بياناتك.** كتلة U+FE70–FEFF موضوعةٌ للتوافق مع
  الترميزات القديمة ذهابًا وإيابًا،
  [ويونيكود يقول ذلك في جدولها](https://www.unicode.org/charts/PDF/UFE70.pdf)،
  واستعمالها علاجًا للتشكيل هو فخّ التشكيل المسبق بعينه.
- **عدّ `text-align: right` دعمًا للعربيّة.** المحاذاة ليست ترتيبًا، والترتيب
  ليس تشكيلًا، وإصلاح واحدةٍ منها لا يصنع للأخريين شيئًا.
- **التبعيد والحروف الكبيرة.** أدواتٌ لاتينيّة، والتبعيد منها يقطع الوصل فيحيل
  الكلمة صفًّا من حروفٍ متفرّقة.
- **الاختبار بالعربيّة وحدها.** وفيه يقع الجميع: فالسطر الذي لا لاتينيّ فيه ولا
  أرقام ولا ترقيم يخرج صحيحًا مهما يكن الخلل تحته، لأنّ محرّك التشكيل
  يستنتج الاتّجاه من الكتابة. فأدخل في سلسلة اختبارك اسم منتجٍ ورقم إصدار.

فأمّا الأربعة الأولى فأسطرٌ معدودة:

```js
// لا تفعل: ليس شيءٌ من هذه دعمًا لليمين إلى اليسار.
const visual = [...name].reverse().join("");        // يكسر البحث والنسخ
const shaped = toPresentationForms(name);           // كتلة توافقٍ قديمة
element.style.textAlign = "right";                  // محاذاة، لا ترتيب
element.style.letterSpacing = "0.08em";             // يقطع الوصل

// افعل: أبقِ السلسلة بالترتيب المنطقيّ، ودع الطبقة التي ترسمها تحلّ
// الاتّجاه والتشكيل والوجه — ثمّ تحقّق من النتيجة.
const runs = reorder(logicalRuns(name, stack, "rtl"));
```

ثمّ سلسلة الاختبار، وهي أيسر ما في الباب كلّه:

```js
// تخرج صحيحةً مهما يكن الخلل تحتها.
const bad = "ربط المكتبة مباشرة";

// تمتحن الترتيب واختيار الوجه والمحايدات معًا.
const good = "ربط libVLC مباشرةً من Swift 6 — إصدار 3.0.21";
```

## كيف تتحقّق ممّا لا تراه

الاتّجاهان وحدهما لا يُفحصان بالنظر إلّا لمن يقرأ الكتابة، فالسطر العربيّ
المقلوب يبقى شكله عربيًّا.

فخذ له بصمةً بدل النظر: صيِّر عيّنة، وامسح الصورة عمودًا عمودًا تحسب أين وقع
الحبر، ثمّ جزّئ ما مسحته كلماتٍ عند كلّ امتدادٍ من البكسلات الخالية. والحيلة
أنّه لا ينظر إلى النصّ في شيء:

```js
async function fingerprint(png, gap) {
  const { data, info } = await sharp(png).greyscale().raw()
    .toBuffer({ resolveWithObject: true });

  const widths = [];
  let start = -1, blank = 0;

  for (let x = 0; x < info.width; x++) {
    let inked = false;
    for (let y = 0; y < info.height; y++) {
      if (data[y * info.width + x] < 128) { inked = true; break; }
    }
    if (inked) { if (start === -1) start = x; blank = 0; }
    else if (start !== -1 && ++blank >= gap) {
      widths.push(x - blank + 1 - start);
      start = -1; blank = 0;
    }
  }
  if (start !== -1) widths.push(info.width - 1 - start);
  return widths;
}
```

ثمّ قارن التسلسل بمرجعٍ من متصفّحٍ يرصف السلسلة نفسها بالحجم نفسه والوجه نفسه:
فالمقطع في الترتيب الخطأ يغيّر التسلسل، والرسم الذي يُخفق في التشكّل يغيّر
طوله. خزّن أرقام المتصفّح عيّناتٍ، واسمح ببكسلٍ أو اثنين، وأسقط البناء عند
الاختلاف — ولا يحتاج من يشغّله أن يقرأ كلمةً واحدة.

وأمّا الوسوم فأدوات التحقّق عند المنصّات أسرع ما يفحصها؛ شغّلها مرّةً لكلّ
تصميم بطاقةٍ لا لكلّ مقالة.

وبعد، فمولّد البطاقات برنامجٌ يرصف النصّ، لا زبون له إلّا أنت. والترتيب والتشكيل
واختيار الوجه والقياس يتولّاها المتصفّح عنك في كلّ موضعٍ إلّا هذا: الصورة
الوحيدة التي تشحنها ولا يصيّرها متصفّح. فمن كانت صفحاته لاتينيّةً وحدها لم يمسّ
ثلثي ما مضى، وأمّا نحن فتأتينا هذه الأقسام في ترتيب وقوع العلل تقريبًا.
