نَسْق — نصّ عربي صحيح في PDF
الإجمالي 1,234.50 ج.متُطبع اليوم في مكتبة PDF الأشهر في جافاسكربت هكذا:م.ج 05.432,1 ﻲﻟﺎﻣﺟﻹا.المبلغ الذي يصل العميل
05.432,1.
هذه ليست دعوى، بل قياسٌ أُجري على ملف PDF مُنتَج، وقُرئت منه معرّفاتُ الرسوم ورُدّت إلى محارفها. وتُعيد إنتاجَه بنفسك بسطرٍ واحد أسفل هذه الصفحة.
المشكلة ليست التشكيل
الظنّ الشائع أن مكتبات جافاسكربت لا تصل الحروف العربية. وهذا لم يعد
صحيحاً: pdf-lib تفوّض إلى fontkit وفيها محرّك OpenType كامل، فتُخرج
الإجمالي موصولةً صحيحة.
الخلل في الترتيب. المكتبة ترى نصّاً من اليمين إلى اليسار فتعكس السلسلة كلها — ومعها الرقمُ الذي بداخلها. والخوارزمية التي تمنع ذلك هي الخوارزمية الثنائية الاتجاه (Unicode UAX #9)، ولا تُطبّقها أيّ مكتبة PDF في جافاسكربت.
| النصّ | ما يُطبع في الملف |
|---|---|
الإجمالي 1,234.50 ج.م |
م.ج 05.432,1 ﻲﻟﺎﻣﺟﻹا |
شركة ABC للتجارة |
ةراﺟﺗﻠﻟ CBA ﺔﻛرﺷ |
المبلغ (نقداً) |
القوسان في اتجاهيهما المعكوسين |
ولا يُصلحه أن تضيف خوارزمية اتجاه وحدها
لأن العكس حينئذ يقع مرّتين: مرّة منك ومرّة من المكتبة.
قِسنا ذلك على حزمتين منشورتين تُخرجان النصّ صحيحاً من دوالّهما، فلمّا مرّ
ناتجهما بـdrawText عاد المبلغ مقلوباً وانكسر معه شكلُ الحروف — إذ
تُشكَّل حينئذ على جوارٍ مقلوب فتنشأ وصلاتٌ لا وجود لها في الأصل:
الأصل الإجمالي 1,234.50 ج.م
pdf-lib وحدها م.ج 05.432,1 ﻲﻟﺎﻣﺟﻹا
تشكيل بصريّ ← pdf-lib اﻹﺟﻤﻻﻲ 05.432,1 ج.م ← أسوأ من تركه
نَسْق م.ج 1,234.50 ﻲﻟﺎﻣﺟﻹا ← صحيح
الحلّ: اقطع، ثم دع المكتبة تُتقن ما تُتقن
drawText صحيحة داخل المقطع الواحد: تُشكّل حروفه وتعكسها، وذلك تماماً
ما يلزم. خطؤها أنها تعامل السطر المختلط بوصفه مقطعاً واحداً.
فنقطع السطر إلى مقاطع كلٌّ منها ذو اتجاه واحد، ونرسم كلَّ مقطع في موضعه. يبقى العكس واحداً داخل المقطع العربي — وذلك صواب — ولا يمسّ المقطعَ الرقمي.
① المستويات الاتجاهية للنصّ كاملاً (UAX #9)
② القطع إلى مقاطع ذات اتجاه واحد
③ قلب الأقواس في المقاطع اليمينية (قاعدة L4)
④ رسم كل مقطع وحده بترتيبه المنطقي
الاستعمال
import { PDFDocument, rgb } from "pdf-lib";
import fontkit from "@pdf-lib/fontkit";
import { drawArabicText } from "nasq/pdf-lib";
const doc = await PDFDocument.create();
doc.registerFontkit(fontkit); // لازم لأي خط عربي
const font = await doc.embedFont(readFileSync("Amiri-Regular.ttf"));
const page = doc.addPage([595, 842]);
drawArabicText(page, "الإجمالي 1,234.50 ج.م", {
font, size: 14, x: 555, y: 700, // x = الحافة اليمنى
});
المحاذاة auto مبدئياً: تُسند الفقرة العربية إلى يمينها واللاتينية إلى
يسارها، باتجاه الفقرة كما يحلّه UAX #9 لا بتخمينٍ منّا.
drawArabicParagraph(page, longText, { font, size: 12, x: 555, y: 700, maxWidth: 400 });
measureArabicText(text, font, size); // العرض بعد الترتيب الصحيح
ومن يرسم بمحرّك آخر يأخذ المقاطع مباشرة:
import { resolveRuns } from "nasq";
resolveRuns("الإجمالي 1,234.50 ج.م");
// [{ text: " ج.م", rtl: true }, { text: "1,234.50", rtl: false }, { text: "الإجمالي ", rtl: true }]
ما نُثبته وما لا نُثبته
الخوارزمية ليست لنا. نبني على bidi-js،
وقرارُ البناء عليها لم يُتَّخذ بالرأي: شغّلناها على BidiCharacterTest.txt
من يونيكود — ٩١٬٧٠٧ حالة — فاجتازت مستوى الفقرة والمستويات والترتيب
البصري كلها. والاختبار باقٍ في الحزمة يعمل عند كل ترقية.
وفجوةٌ قِسناها ونُثبتها: جدول القلب في bidi-js تنقصه ٨ أزواج من
٤٢٨ — كلها في الكتلة U+2E55..U+2E5C (أقواس مزدوجة للترميز اللغوي
المتخصّص)، وصفر قلبٍ خاطئ. لا واحدة منها تظهر في مستند تجاري، وكل
الأقواس الشائعة سليمة. واختبارٌ يُثبّت هذا الحدّ: أي اتساعٍ فيه يُفشله.
ولا نُشكّل الحروف بأنفسنا. التشكيل يقع في fontkit بجداول OpenType
الحقيقية للخط — لا بجدول أشكالٍ تقديمية. فالخطوط التي تعتمد وصلاتٍ خاصة
تعمل كما صمّمها صانعها.
فخّان يجب أن يُعرفا
① التاريخ بالشرطة ينقلب، وبالشرطة المائلة لا ينقلب.
بتاريخ 2026-09-01 ⇒ 01-09-2026 ← منقسم
بتاريخ 2026/09/01 ⇒ 2026/09/01 ← سليم
وهذا سلوك يونيكود الصحيح لا عيبٌ فينا، ويُنتجه كل عارضٍ مطابق ومنه
متصفّحك: الأرقام بعد حرفٍ عربي تصير «رقماً عربي الصنف» (قاعدة W2)، والشرطة
- من صنف ES لا تلتحق بها (W4) فتبقى محايدة وتنقسم الأرقام. أمّا / فمن
صنف CS فتلتحق وينجو التاريخ.
فاستعمل / في الفواتير العربية، أو اعزل التاريخ بمحارف العزل الاتجاهي.
② الأشكال التقديمية ليست تشكيلاً. الحزم التي تحوّل الحروف إلى الكتلة U+FE70..U+FEFF تعمل مع كثير من الخطوط، لكنها تتجاوز جداول OpenType — فلا تنالك وصلات الخط الخاصة، ولا تُضبط مواضع الحركات بـGPOS.
القياس — أعِد إنتاجه بنفسك
npm test # ٣١ اختباراً، منها ما يقرأ الرسوم من داخل الـPDF
npm run fetch-ucd # يجلب جداول يونيكود (تُخزَّن ولا تُودَع)
npm run test:conformance # ٩١٬٧٠٧ حالة من سلسلة يونيكود الرسمية
اختباراتنا تقرأ ما في الملف، لا ما تُعيده الدالة. وهذا هو الفرق العملي
كله: الحزم التي فحصناها تُخرج سلسلةً صحيحة من دوالّها، ثم يعكسها pdf-lib
عند الرسم — فاختبارٌ يفحص المخرَج النصّي يمرّ، والفاتورة تخرج خطأً. لذلك
نفكّ ضغط مجرى المحتوى ونقرأ معرّفات الرسوم ونردّها إلى محارفها.
إنصافٌ للحزم الأخرى
naqqash تُحسن ما تدّعيه وتُصرّح
بحدودها في صفحتها الأولى: «لا تطبّق الخوارزمية الثنائية الاتجاه — استعمل
bidi-js لذلك». فالقياس أعلاه ليس اتهاماً لها، بل بيانٌ لما يكلّفه ذلك
الحدُّ المُعلَن في فاتورةٍ فيها مبلغ. وهي أخفّ منّا وبلا تبعيات، ومن كان
نصّه عربياً خالصاً فهي كافية.
الرخصة: MIT · جزء من منظومة الفوترة العربية
yahya@opus-studio.pro