> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# التعريب i18n للمطورين: لماذا ينقلب النص العربي في الواجهة؟
- URL: https://arabroot.io/articles/التعريب-i18n-والنص-ثنائي-الاتجاه-bidi-للمطورين/
- Published: 2026-10-30T13:00:11.000Z
- Updated: 2026-10-30T19:46:06.000Z
- Description: لماذا ينقلب النص المختلط في الواجهة العربية وكيف تعزله، وكيف تنسق الأرقام والتواريخ وصيغ الجمع العربية بأدوات المتصفح نفسها.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, تطوير الواجهات والجوال Frontend & Mobile

لنفرض أن فريقك يملك منصة لحجز الدورات التدريبية، بنيت من أول يوم بالإنجليزية، ثم قررت الإدارة إطلاق الواجهة العربية خلال شهر لأن أغلب العملاء يكتبون بالعربية. ترجم الفريق النصوص في ملف واحد، وقلب اتجاه الصفحة إلى `dir="rtl"`، وظن أن العمل انتهى، ثم بدأت التذاكر تصل من أول أسبوع: صفحة التعليقات تعرض «3 - user99 تعليقات» بدلاً من «user99 - 3 تعليقات»، ورقم الدعم الفني يظهر وعلامة + في آخره، وصفحة الملفات تكتب «ملفات 2» و«ملفات 11»، والعميل الذي كتب رقم جواله بالأرقام العربية ٠٥٠ يرفضه النموذج لأن الرقم «غير صحيح»، وموعد الدورة يظهر بالتاريخ الميلادي بينما يسأل بعض العملاء عن التاريخ الهجري، والبحث عن «احمد» لا يجد «أحمد»، وأخيراً وصل مرفق اسمه يبدو `invoiceexe.pdf` وهو في الحقيقة ملف تنفيذي. ولا توجد مشكلة واحدة من هذه سببها الترجمة، وإنما سببها أن الكود نفسه لم يكتب ليتعامل مع لغة أخرى، وهذا بالضبط الفرق بين الترجمة والتدويل.

وهذا المقال من مرحلة [هندسة الواجهات وتطبيقات الويب Frontend](https://arabroot.io/start/#stage-web-frontend)، ويتعامل مع النص والأرقام والتواريخ فقط، أما تخطيط الصفحة نفسها للعربية بالخصائص المنطقية في CSS فله مقال مستقل في نفس المرحلة، وأما البايتات وUTF-8 فشرحنا الترميز في [مقال الترميز Encoding](https://arabroot.io/articles/%D8%A7%D9%84%D8%AA%D8%B1%D9%85%D9%8A%D8%B2-base64-%D9%88-base32-%D9%88-base58/) ولن نعيده هنا. وسوف نناقش في هذا المقال ما يلي:

- الفرق بين التدويل i18n والتوطين l10n، وما هو الـ Locale، وكيف تقرأ وسماً مثل `ar-SA-u-ca-islamic-umalqura-nu-latn`.
- خوارزمية النص ثنائي الاتجاه BiDi في Unicode: أنواع الحروف والمستويات، ولماذا يقفز الرقم إلى طرف الجملة، ولماذا تنقلب `C++` ورقم الجوال.
- العزل Isolation بالعنصر `<bdi>` والخاصية `dir` والحروف الخفية، والفرق بينه وبين التضمين القديم، ومتى تكفي العلامات LRM وRLM.
- الحروف الخفية كأداة للخداع: أسماء الملفات المزيفة وهجوم Trojan Source.
- تنسيق الأرقام والعملة بالواجهة `Intl.NumberFormat`، والتواريخ بالتقويم الميلادي وتقويم أم القرى، والتاريخ النسبي.
- صيغ الجمع الست في العربية، وملفات الترجمة، ولماذا لا تبني الجملة بجمع النصوص.
- ترتيب الأسماء العربية والبحث فيها، وقراءة الأرقام التي يكتبها المستخدم بالأرقام العربية.
- تجربة عملية كاملة في Node.js وفي Chrome مع المخرجات الحقيقية، ثم الأخطاء الشائعة وقائمة تحقق.

## ما الفرق بين التدويل i18n والتوطين l10n؟

التدويل Internationalization، واختصاراً i18n لأن بين الحرف الأول والأخير ثمانية عشر حرفاً، هو أن تكتب الكود بحيث لا يفترض لغة أو منطقة بعينها، فلا توجد جملة ثابتة داخل الكود، ولا يحفظ التاريخ كنص منسق، ولا يجمع الكود الجملة من قطع، ولا يفترض أن النص يكتب من اليسار. وهذا عمل تقوم به مرة واحدة في الكود، وغالباً يقوم به المطورون. أما التوطين Localization، واختصاراً l10n، فهو تجهيز المنتج لمنطقة ولغة محددة: ترجمة الجمل، واختيار صيغة الجمع المناسبة، والعملة، والتقويم، وشكل الأرقام، واتجاه الواجهة، ويتكرر هذا العمل لكل لغة تضيفها، وغالباً يشارك فيه المترجمون وفريق المنتج. والعلاقة بينهما بسيطة: التدويل يجعل التوطين ممكناً، فإذا لم يكن الكود مدولاً فكل لغة جديدة تعني تعديل الكود نفسه، وهذا ما حدث مع الفريق في القصة أعلاه، فقد قام بالتوطين (الترجمة) دون التدويل.

وبين الاثنين يوجد مفهوم ثالث يحمل كل القرارات، وهو الـ Locale، أي مجموعة التفضيلات التي تحدد كيف يرى المستخدم النص والأرقام والتواريخ. والـ Locale يكتب بوسم لغة Language Tag حسب المعيار [BCP 47](https://www.rfc-editor.org/rfc/rfc5646?ref=arabroot.io)، وأبسط صورة له لغة فقط مثل `ar`، ثم لغة ومنطقة مثل `ar-SA` للعربية في السعودية و`ar-EG` للعربية في مصر. ويمكن أن يحمل الوسم أيضاً امتداد Unicode، وهو الحرف `u` وبعده أزواج من المفاتيح والقيم كما في [RFC 6067](https://www.rfc-editor.org/rfc/rfc6067?ref=arabroot.io) و[المعيار UTS #35](https://www.unicode.org/reports/tr35/?ref=arabroot.io)، فالمفتاح `ca` يحدد التقويم Calendar، والمفتاح `nu` يحدد نظام الأرقام Numbering System، والقيم المسموحة لكل مفتاح موجودة في بيانات CLDR، مثل [قائمة التقاويم](https://github.com/unicode-org/cldr/blob/main/common/bcp47/calendar.xml?ref=arabroot.io) التي فيها `islamic-umalqura` و[قائمة أنظمة الأرقام](https://github.com/unicode-org/cldr/blob/main/common/bcp47/number.xml?ref=arabroot.io) التي فيها `arab` للأرقام العربية الهندية ٠١٢٣ و`latn` للأرقام 0123\. والصورة التالية تبين أجزاء الوسم الذي استخدمناه في التجربة وما يملكه كل من التدويل والتوطين:

![الوسم ar-SA-u-ca-islamic-umalqura-nu-latn مقسماً إلى اللغة ar والمنطقة SA وامتداد Unicode والتقويم أم القرى ونظام الأرقام اللاتيني، وتحته عمودان: ما يقوم به التدويل مرة واحدة في الكود، وما يقوم به التوطين لكل Locale](https://arabroot.io/content/images/2026/10/i18n-l10n-arabic-01-locale.webp)

الـ Locale ليس اللغة فقط، فالمنطقة والتقويم ونظام الأرقام جزء منه

والشيء الذي يفاجئ كثيراً من المطورين هو أن اختيار الوسم وحده يغير النتيجة، فقد طلبنا في التجربة من [Intl.Locale](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global%5FObjects/Intl/Locale?ref=arabroot.io) ومن الدوال الأخرى أن تخبرنا بما تختاره لكل وسم، فكانت النتيجة كما يلي:

```text
== 1) locale tags
ar                           numberingSystem=latn calendar=gregory max=ar-Arab-EG
ar-SA                        numberingSystem=arab calendar=gregory max=ar-Arab-SA
ar-EG                        numberingSystem=arab calendar=gregory max=ar-Arab-EG
ar-MA                        numberingSystem=latn calendar=gregory max=ar-Arab-MA
ar-SA-u-nu-latn              numberingSystem=latn calendar=gregory max=ar-Arab-SA-u-nu-latn
ar-SA-u-ca-islamic-umalqura  numberingSystem=arab calendar=islamic-umalqura max=ar-Arab-SA-u-ca-islamic-umalqura
```

في المخرج أعلاه لاحظ التالي:

- الوسم `ar` وحده اختار الأرقام اللاتينية `latn`، والدالة `maximize()` أكملته إلى `ar-Arab-EG`، أي أن العربية بلا منطقة تعني في بيانات CLDR العربية المكتوبة بالحرف العربي في مصر، وهذا لا يناسب منتجاً يستهدف السعودية.
- الوسم `ar-SA` اختار الأرقام العربية الهندية `arab` والتقويم الميلادي `gregory`، بينما اختار `ar-MA` الأرقام اللاتينية، وسوف نرى بعد قليل أن المغرب يستخدم أيضاً فواصل مختلفة.
- عندما أضفنا `-u-nu-latn` تغير نظام الأرقام فقط وبقي كل شيء آخر سعودياً، وعندما أضفنا `-u-ca-islamic-umalqura` تغير التقويم فقط، وهذه هي الفائدة الحقيقية من الامتداد: تعدل تفضيلاً واحداً دون أن تختار منطقة أخرى.

ومن أين يأتي الـ Locale في تطبيق ويب؟ المصدر الأول هو اختيار المستخدم نفسه إذا كان مسجلاً، فتحفظه في ملفه الشخصي أو في Cookie كما شرحنا في [مقال الجلسات](https://arabroot.io/articles/%D8%A7%D9%84%D8%AC%D9%84%D8%B3%D8%A7%D8%AA-%D9%88%D9%85%D9%84%D9%81%D8%A7%D8%AA-%D8%AA%D8%B9%D8%B1%D9%8A%D9%81-%D8%A7%D9%84%D8%A7%D8%B1%D8%AA%D8%A8%D8%A7%D8%B7-%D9%88-jwt-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)، والمصدر الثاني هو ترويسة [Accept-Language](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Language?ref=arabroot.io) التي يرسلها المتصفح مع كل طلب كما رأينا في [مقال HTTP](https://arabroot.io/articles/http-%D9%88-https-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)، و`navigator.languages` في JavaScript. ولا تقم باستنتاج اللغة من عنوان IP، والسبب أن الموقع الجغرافي لا يحدد اللغة، فالمهندس الهندي الذي يعمل داخل المملكة يريد غالباً الواجهة الإنجليزية، والطالب السعودي في الخارج يريد العربية، وأسوأ تجربة هي أن يتغير اتجاه الواجهة كلما سافر المستخدم أو استخدم VPN. وتذكر أيضاً أن تضع الخاصية [lang](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global%5Fattributes/lang?ref=arabroot.io) على عنصر `html`، لأن قارئ الشاشة يختار النطق منها كما ذكرنا في [مقال سهولة الوصول](https://arabroot.io/articles/%D8%B3%D9%87%D9%88%D9%84%D8%A9-%D8%A7%D9%84%D9%88%D8%B5%D9%88%D9%84-accessibility-%D9%83%D9%8A%D9%81-%D8%AA%D8%A8%D9%86%D9%8A-%D9%85%D9%88%D9%82%D8%B9%D8%A7-%D9%8A%D8%B3%D8%AA%D8%AE%D8%AF%D9%85%D9%87-%D8%A7%D9%84%D8%AC%D9%85%D9%8A%D8%B9/)، والمتصفح يختار منها الخط وقواعد تقسيم الكلمات.

## لماذا ينقلب النص المختلط؟ خوارزمية BiDi

نعود إلى أول تذكرة في القصة، فالقالب في الكود كان بسيطاً: اسم المستخدم ثم شرطة ثم عدد التعليقات ثم كلمة «تعليقات»، والنص المحفوظ في الذاكرة صحيح تماماً، ومع ذلك ظهر على الشاشة بترتيب آخر. والسبب أن الشاشة لا تعرض الحروف بترتيب حفظها، وإنما بترتيب تحسبه خوارزمية النص ثنائي الاتجاه Unicode Bidirectional Algorithm، وهي موصوفة في الملحق [UAX #9](https://www.unicode.org/reports/tr9/?ref=arabroot.io)، وكل متصفح وكل نظام تشغيل يطبقها. والفكرة الأساسية أن هناك ترتيبين: الترتيب المنطقي Logical Order، وهو ترتيب الحروف في الذاكرة كما كتبت وكما تقرؤها، والترتيب المرئي Visual Order، وهو مكان كل حرف على الشاشة من اليسار إلى اليمين. والخوارزمية تأخذ الأول وتنتج الثاني على أربع مراحل تقريباً.

### المرحلة الأولى: لكل حرف نوع

كل حرف في Unicode له خاصية اسمها Bidi\_Class، والأنواع تنقسم إلى ثلاث مجموعات:

- **الحروف القوية Strong:** ولها اتجاه ثابت، فالحروف اللاتينية من النوع L، أي من اليسار إلى اليمين، والحروف العبرية من النوع R، والحروف العربية من النوع AL، وهو نوع يتصرف مثل R مع فرق في معاملة الأرقام كما سيأتي.
- **الحروف الضعيفة Weak:** ولها اتجاه ولكنها تتأثر بما حولها، وأهمها الأرقام الأوروبية 0 إلى 9 من النوع EN، والأرقام العربية الهندية ٠ إلى ٩ من النوع AN، وعلامات مثل + و- من النوع ES، والنقطة والفاصلة والنقطتان من النوع CS.
- **الحروف المحايدة Neutral:** وليس لها اتجاه أصلاً، مثل المسافة وعلامات الترقيم والأقواس وعلامة التعجب، وهي تأخذ اتجاهها من الحروف القوية على جانبيها.

### المرحلة الثانية: اتجاه الفقرة Paragraph Direction

لكل فقرة اتجاه أساسي يسمى اتجاه الفقرة Paragraph Direction، وتحدده الخوارزمية في القاعدتين P2 وP3 من أول حرف قوي في الفقرة إذا لم يحدده أحد، أما في HTML فالخاصية `dir` على العنصر أو أحد آبائه هي التي تحدده، ولذلك كان سطر `dir="rtl"` الذي أضافه الفريق صحيحاً وضرورياً، ولكنه حل نصف المشكلة فقط. ومن اتجاه الفقرة تبدأ المستويات Embedding Levels، فالفقرة التي اتجاهها RTL تبدأ بالمستوى 1، والنص اللاتيني داخلها يرفع إلى المستوى 2، والقاعدة البسيطة أن المستوى الفردي يعرض من اليمين إلى اليسار والمستوى الزوجي من اليسار إلى اليمين.

### المرحلة الثالثة: حل الحروف الضعيفة Weak والمحايدة Neutral

هنا يحدث كل ما يفاجئ المطورين. فالقاعدة W2 تقول إن الرقم الأوروبي EN الذي يسبقه حرف عربي AL يتحول إلى رقم عربي AN، والقاعدة W7 تقول إن الرقم الأوروبي الذي يكون أقرب حرف قوي قبله لاتينياً L يتحول إلى L، أي يصبح جزءاً من الكتلة اللاتينية. ثم تأتي القاعدتان N1 وN2 للحروف المحايدة، فالحرف المحايد الواقع بين حرفين قويين من نفس الاتجاه يأخذ اتجاههما، والواقع بين اتجاهين مختلفين يأخذ اتجاه الفقرة. والآن طبق هذا على النص `user99 - 3 تعليقات` في فقرة اتجاهها RTL:

1. الحروف `user` من النوع L، فتأخذ المستوى 2.
2. الرقمان `99` يسبقهما الحرف `r` وهو L، فيتحولان إلى L بالقاعدة W7.
3. الرقم `3` أقرب حرف قوي قبله هو أيضاً `r`، لأن المسافة والشرطة ليست حروفاً قوية، فيتحول هو الآخر إلى L.
4. المسافة والشرطة والمسافة تقع بين L وL، فتأخذ اتجاه L، وبهذا أصبحت `user99 - 3` كلها كتلة واحدة من اليسار إلى اليمين في المستوى 2.
5. المسافة الأخيرة تقع بين L وكلمة «تعليقات» العربية، فتأخذ اتجاه الفقرة RTL، والكلمة العربية في المستوى 1.

### المرحلة الرابعة: إعادة الترتيب وعكس الأقواس Mirroring

في المرحلة الأخيرة تعكس الخوارزمية بالقاعدة L2 كل سلسلة متصلة من المستويات من الأعلى إلى الأدنى، فتعرض الكتلة `user99 - 3` من اليسار إلى اليمين كما هي، ثم توضع كاملة في بداية السطر من جهة اليمين لأنها أول شيء في الفقرة، ثم تأتي كلمة «تعليقات» على يسارها. والنتيجة أن الرقم 3 أصبح في أقصى اليمين، فيقرأ المستخدم العربي الجملة من اليمين ويرى الرقم أولاً، ثم الاسم، ثم الكلمة. والصورة التالية تبين هذه الخطوات على نفس المثال، والترتيب الذي فيها مقاس من الشاشة في التجربة العملية وليس استنتاجاً:

![حروف النص user99 - 3 تعليقات بترتيبها في الذاكرة، وتحت كل حرف نوعه ومستواه: الحروف اللاتينية L، والأرقام EN تتحول إلى L، والمسافات محايدة، والكلمة العربية R، ثم النتيجة على الشاشة حيث التصق الرقم 3 بالاسم وانتقل إلى أقصى اليمين، وبجانبها النتيجة الصحيحة بعد عزل الاسم بالعنصر bdi](https://arabroot.io/content/images/2026/10/i18n-l10n-arabic-02-bidi-steps.webp)

الرقم 3 يأخذ اتجاه أقرب حرف قوي قبله، فيلتصق باسم المستخدم

وفي نفس المرحلة يحدث شيء آخر، وهو عكس الحروف المتقابلة Mirroring، فالحروف التي لها الخاصية Bidi\_Mirrored، مثل الأقواس `( )` و`[ ]` وعلامتي `<` و`>`، ترسم بشكلها المعكوس إذا وقعت في مستوى فردي، والقائمة الكاملة في الملف [BidiMirroring.txt](https://www.unicode.org/Public/UCD/latest/ucd/BidiMirroring.txt?ref=arabroot.io). ولهذا تكتب في الكود العربي القوس المفتوح `(` في بداية الجملة فيظهر على يمينها بالشكل الصحيح. ومنذ [الإصدار 6.3 من Unicode](https://www.unicode.org/versions/Unicode6.3.0/?ref=arabroot.io) أضيفت إلى الخوارزمية قاعدة أزواج الأقواس Paired Brackets، فأصبح القوسان المتقابلان يأخذان نفس الاتجاه، بعد أن كانت الأقواس حول نص لاتيني في جملة عربية تظهر أحياناً في أماكن غريبة. ولكن العكس نفسه يفاجئ في حالة واحدة، وهي العبارات الرياضية، فالنص `3 > 2` داخل جملة عربية يظهر على الشاشة كأنه `2 < 3` إذا قرأته من اليسار، وهو صحيح للقارئ العربي الذي يقرأ من اليمين، ولكنه يربك أي شخص يقرأ العبارة كمعادلة، ولذلك تعامل مع المعادلات والكود كنص لاتيني معزول كما سيأتي.

### الحالات المشهورة: رقم الجوال واسم C++ والوقت والمسار

بنفس القواعد الأربع تستطيع أن تفسر كل التذاكر التي وصلت للفريق، وقد عرضنا كل حالة منها في Chrome وقسنا ترتيبها على الشاشة كما في [التجربة العملية](#lab-chrome):

- **رقم الجوال الدولي `+966 50 123 4567`:** كل مجموعة أرقام EN، والمسافات بينها محايدة، والجملة عربية، فتعرض كل مجموعة من اليسار إلى اليمين ولكن المجموعات نفسها ترتب من اليمين إلى اليسار، وعلامة + تتحول إلى محايد يأخذ اتجاه الجملة، فظهر الرقم على الشاشة بترتيب المجموعات المعكوس وعلامة + في آخره من جهة اليمين، أي `4567 123 50 966+` إذا قرأته من اليسار.
- **اسم اللغة `C++`:** الحرف `C` من النوع L، وعلامتا + بعده محايدتان بين L وكلمة عربية، فتأخذان اتجاه الفقرة وتنتقلان إلى الجهة الأخرى من الحرف، فيظهر `++C`، وهي من أشهر الأخطاء في إعلانات الوظائف العربية.
- **الوقت `05:30 PM`:** الرقم يسبقه حرف عربي فيبقى رقماً، و`PM` كتلة لاتينية منفصلة، والمسافة بينهما تأخذ اتجاه الجملة، فتظهر الكتلتان بترتيب معكوس `PM 05:30`.
- **السعر `1,499.50 SAR`:** نفس القصة، فالرقم والعملة كتلتان، وظهر على الشاشة `SAR 1,499.50`.
- **مسار الملف `/home/sara/تقارير/2026.pdf`:** وهو أسوأها، لأن المسار فيه حروف عربية في وسطه، فتقطعت أجزاؤه وانتقلت `pdf.2026` إلى أول السطر.

وقد يتساءل البعض: إذا كانت الخوارزمية تنتج هذه الأخطاء، فلماذا لا تصلحها Unicode؟ والإجابة أن الخوارزمية لا تعرف المعنى، فهي لا تعرف أن `user99` اسم مستخدم وأن 3 عدد تعليقات، ولا تعرف أن `+966 50 123 4567` رقم واحد وليس أربعة أرقام في جملة عربية، والمعلومة الناقصة هنا عند المطور فقط، ووظيفة المطور أن يقول للمتصفح أين يبدأ النص المستقل وأين ينتهي، وهذا هو العزل.

## العزل Isolation: كيف تخبر المتصفح أين يبدأ النص المستقل؟

العزل يعني أن تضع النص الذي يأتي من مصدر آخر، مثل اسم المستخدم أو رقم الجوال أو اسم الملف، داخل صندوق، والصندوق له صفتان: اتجاه ما بداخله يحسب وحده دون أن يتأثر بما حوله، وما حوله يرى الصندوق كله كأنه حرف محايد واحد. وفي HTML يوجد العنصر [bdi](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/bdi?ref=arabroot.io)، واسمه اختصار Bi-Directional Isolate، وهو يحدد اتجاه ما بداخله من أول حرف قوي فيه، وبهذا يصلح القالب في القصة بتعديل واحد:

```html
<!-- قبل: الرقم يلتصق بالاسم اللاتيني -->
<p>${user} - ${count} تعليقات</p>

<!-- بعد: الاسم معزول، فلا يؤثر على الرقم ولا يتأثر بالجملة -->
<p><bdi>${user}</bdi> - ${count} تعليقات</p>
```

وعند عرض الصفحة في Chrome ظهر الاسم في أقصى اليمين، ثم الشرطة، ثم الرقم 3، ثم كلمة «تعليقات»، وهو الترتيب الذي أراده الفريق. ولاحظ أن العنصر `<bdi>` لم يحدد اتجاهاً معيناً، وإنما ترك المتصفح يكتشفه، وهذا مهم في أسماء المستخدمين لأن بعضهم يكتب اسمه بالعربية وبعضهم بالإنجليزية، فالاسم العربي `عمر_2` في صفحة إنجليزية يحتاج نفس الحماية، وقد عرضناه في التجربة في صفحة اتجاهها LTR، فظهر بدون عزل بالشكل التالي:

![لقطة من Chrome لصفحة إنجليزية فيها سطران: الأول بدون عزل حيث انتقل الرقم 3 والنقطتان إلى مكان خاطئ حول الاسم العربي، والثاني مع العنصر bdi حيث ظهر الاسم ثم النقطتان ثم 3 reviews بالترتيب الصحيح](https://arabroot.io/content/images/2026/10/i18n-l10n-arabic-08-ltr-page.webp)

نفس المشكلة في الاتجاه المعاكس: اسم عربي في صفحة إنجليزية

وفي السطر الأول من الصورة تحول الرقم 3 إلى رقم عربي AN لأنه جاء بعد حرف عربي بالقاعدة W2، فدخل هو والنقطتان في الكتلة العربية وانعكس ترتيبها، أما في السطر الثاني فالاسم معزول، فظهرت الجملة الإنجليزية كما كتبت.

### أدوات العزل: عناصر HTML وخصائص CSS والحروف الخفية

هناك أربع طرق للعزل، وكلها تؤدي نفس العمل في الخوارزمية، والفرق في المكان الذي تستخدم فيه كل طريقة:

- **العنصر `<bdi>`:** للنص الذي لا تعرف اتجاهه مسبقاً، مثل الأسماء والتعليقات وعناوين المنتجات التي يكتبها المستخدمون.
- **الخاصية** [**dir**](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global%5Fattributes/dir?ref=arabroot.io) **على أي عنصر:** مثل `<span dir="ltr">` لرقم الجوال أو المسار أو الكود، لأنك تعرف أن اتجاهها LTR دائماً. وقد تأكدنا في التجربة أن Chrome يعطي أي عنصر عليه `dir` القيمة `unicode-bidi: isolate` تلقائياً، ولذلك أصلح `<span dir="ltr">` المشكلة بينما لم يصلحها `<span>` بدون الخاصية.
- **الخاصية** [**unicode-bidi**](https://developer.mozilla.org/en-US/docs/Web/CSS/unicode-bidi?ref=arabroot.io) **في CSS:** بالقيمة `isolate` أو `plaintext`، وتستخدمها عندما لا تستطيع تعديل HTML، مثل محتوى يأتي من محرر نصوص.
- **حروف العزل الخفية في النص نفسه:** وهي LRI بالرمز U+2066، وRLI بالرمز U+2067، وFSI بالرمز U+2068، وكلها تغلق بالحرف PDI بالرمز U+2069، وتستخدمها حيث لا يوجد HTML أصلاً: الإشعارات Push Notifications، والرسائل النصية SMS، وعنوان البريد Subject، والنص داخل ملف PDF أو صورة، وعنوان الصفحة `<title>` الذي لا يقبل عناصر.

والحرف FSI، أي First Strong Isolate، هو المقابل النصي للعنصر `<bdi>`، لأنه يحدد الاتجاه من أول حرف قوي داخل الصندوق، ولذلك إذا كانت عندك دالة واحدة تبني نصوص الإشعارات من بيانات المستخدمين فاجعلها تحيط كل قيمة متغيرة بالحرفين FSI وPDI، وهذا ما فعلناه في القسم الثامن من التجربة. وتنصح W3C في مقال [الحروف الخفية أم HTML](https://www.w3.org/International/questions/qa-bidi-unicode-controls?ref=arabroot.io) باستخدام العناصر والخصائص في HTML كلما أمكن، والسبب أن الحروف الخفية لا تراها في الكود ولا في قاعدة البيانات، فيصعب اكتشافها عند تتبع خطأ، وقد تنقص أو تزيد عند قص النص فتبقى بداية صندوق بلا نهاية.

### لماذا لا تكفي حروف التضمين Embedding القديمة؟

قبل الإصدار 6.3 كانت الأداة المتاحة هي التضمين Embedding بالحروف LRE وRLE وإغلاقهما بالحرف PDF، وما زالت تجدها في مكتبات قديمة وفي إجابات كثيرة على الإنترنت. والتضمين يرفع مستوى النص بداخله، ولكنه لا يعزله، فالحروف التي بعده تحسب حدودها من المستوى الأعلى، وبالتالي يبقى الرقم 3 بعد الاسم المضمن متأثراً باتجاهه. وقد جربنا ذلك في Chrome، فوضعنا الاسم بين LRE وPDF مرة، وبين LRI وPDI مرة، فبقي الخطأ كما هو مع التضمين، واختفى مع العزل. والصورة التالية تلخص الفرق، ومعه جدول الحروف الخفية وما يقابلها في HTML:

![مقارنة بين التضمين القديم LRE وPDF حيث بقي الرقم 3 ملتصقاً بالاسم، والعزل LRI وPDI أو العنصر bdi حيث ظهر الترتيب الصحيح، وتحتهما جدول الحروف الخفية: علامات LRM وRLM وALM، وحروف العزل U+2066 إلى U+2069، وحروف التضمين القديمة، وحروف الفرض LRO وRLO](https://arabroot.io/content/images/2026/10/i18n-l10n-arabic-03-isolates.webp)

التضمين يرفع المستوى فقط، أما العزل فيقطع تأثير النص على ما حوله

وتبقى العلامات LRM بالرمز U+200E وRLM بالرمز U+200F وALM بالرمز U+061C، وهي حروف قوية لا ترى، فهي لا تفتح صندوقاً وإنما تضع حرفاً قوياً في مكان معين، فتغير اتجاه الحروف المحايدة بجانبها. ومثالها المعروف علامة التعجب في آخر جملة عربية داخل صفحة إنجليزية، فإذا وضعت RLM بعدها أصبحت بين حرفين من نوع R فتأخذ اتجاههما. والعلامات مفيدة في الحالات الصغيرة، ولكن لا تقم ببناء حل كامل عليها، والسبب أن كل حالة جديدة تحتاج علامة في مكان جديد، بينما العزل يحل كل الحالات بقاعدة واحدة. وستجد هذه العلامات في مخرج `Intl` نفسه كما سيأتي في قسم الأرقام، فمكتبة ICU تضعها في السعر والنسبة المئوية والرقم السالب لتحمي ترتيبها.

### مشكلة ظهرت في التجربة: المسار المعزول بقي مقلوباً

عندما عزلنا مسار الملف بالعنصر `<bdi dir="ltr">` توقعنا أن ينتهي الأمر، ولكن القياس من الشاشة أظهر أن المسار ما زال يعرض بالشكل `/home/sara/2026/تقارير.pdf`، أي أن الرقم 2026 انتقل قبل اسم المجلد العربي. والسبب أن العزل حمى المسار من الجملة الخارجية، ولكن داخل الصندوق نفسه ما زالت الخوارزمية تعمل، فالرقم 2026 يسبقه اسم مجلد عربي، فتحول إلى AN بالقاعدة W2، وأصبح هو والمجلد العربي كتلة واحدة من اليمين إلى اليسار. والحل كان أن نعزل كل جزء من المسار وحده، فالمسار يتكون من أجزاء مستقلة تفصلها علامة `/`، تماماً كما أن الجملة تتكون من كلمات:

```html
<bdi dir="ltr">/<bdi>home</bdi>/<bdi>sara</bdi>/<bdi>تقارير</bdi>/<bdi>2026.pdf</bdi></bdi>
```

وبعد هذا التعديل ظهر المسار على الشاشة بترتيبه الصحيح. ونفس الدرس ينطبق على الروابط URL التي فيها مسارات عربية، وعلى عناوين البريد، وعلى أي نص له بنية داخلية: العزل الخارجي يحمي الجملة، والعزل الداخلي يحمي البنية. وفي الكود يمكنك أن تبني هذا بدالة صغيرة تقسم المسار على `/` وتعزل كل جزء، بدلاً من كتابته بيدك في كل قالب.

### التعليقات التي يكتبها المستخدم والقيمة dir="auto"

في صفحة التعليقات مشكلة من نوع آخر، فالتعليق نفسه قد يكون عربياً أو إنجليزياً، ولا تعرف اتجاه الفقرة مسبقاً. والقيمة `dir="auto"` تجعل المتصفح يحدد اتجاه الفقرة من أول حرف قوي فيها، وهي مناسبة للتعليقات والرسائل وحقول الإدخال، ولكن انتبه إلى حالة شائعة في المجتمع التقني العربي، وهي التعليق العربي الذي يبدأ بكلمة إنجليزية:

![لقطة من Chrome لثلاثة تعليقات: الأول بدون dir داخل صفحة عربية يظهر صحيحاً، والثاني مع dir=auto ويبدأ بكلمة React فأصبحت الفقرة من اليسار إلى اليمين وانتقلت علامة التعجب إلى الطرف الخطأ، والثالث مع dir=auto ويبدأ بكلمة عربية فظهر صحيحاً](https://arabroot.io/content/images/2026/10/i18n-l10n-arabic-07-dir-auto.webp)

dir="auto" يحكم من أول حرف قوي، فالتعليق العربي الذي يبدأ بكلمة React يصبح فقرة LTR

التعليق الثاني في الصورة يبدأ بكلمة React، فحسب Chrome اتجاه الفقرة LTR، فوضع React في أقصى اليسار وعلامة التعجب في أقصى اليمين، والقارئ العربي يرى الجملة مقطعة. وهذا ليس خطأ في المتصفح، فهذا ما تقوله القاعدة P2، ولكنه يعني أن `dir="auto"` تخمين وليس معرفة. ولهذا إذا كان التطبيق عربياً في الأساس، فالأفضل أن تضع في نموذج التعليق زراً يختار به المستخدم الاتجاه وتحفظه مع التعليق، أو أن تجعل الاتجاه الافتراضي للتعليقات RTL وتستخدم `auto` فقط في الأماكن التي يغلب عليها الإنجليزية.

### الحروف الخفية كأداة للخداع

الحرف RLO، أي Right-to-Left Override بالرمز U+202E، يفرض اتجاه اليمين إلى اليسار على كل حرف بعده مهما كان نوعه، وهذا يعني أنه يستطيع أن يجعل النص يظهر عكس ما هو مخزن تماماً. واستغلال هذا في أسماء الملفات تقنية موثقة في قاعدة MITRE ATT&CK باسم [Right-to-Left Override](https://attack.mitre.org/techniques/T1036/002/?ref=arabroot.io)، فالملف المخزن باسم `invoice` ثم RLO ثم `fdp.exe` يظهر على الشاشة كأنه `invoiceexe.pdf`، وهذا ما رأيناه حرفياً في Chrome في التجربة، بينما الدالة `endsWith('.exe')` في الكود أعادت `true`، أي أن الكود يعرف الحقيقة والمستخدم يرى غيرها. ولاحظ أن `<bdi>` لم يحم المستخدم هنا، لأن الحرف الخفي داخل الصندوق نفسه، والحل الوحيد أن تحذف هذه الحروف من أسماء الملفات وأسماء المستخدمين عند الإدخال، أو ترفضها.

💡

في نوفمبر 2021 نشر الباحثان Nicholas Boucher وRoss Anderson من جامعة Cambridge هجوماً أسمياه [Trojan Source](https://trojansource.codes/?ref=arabroot.io)، وسجل برقم [CVE-2021-42574](https://nvd.nist.gov/vuln/detail/CVE-2021-42574?ref=arabroot.io). الفكرة أن حروف BiDi الخفية داخل تعليق أو نص في الكود تجعل محرر الكود يعرض الكود بترتيب يختلف عن الترتيب الذي يقرؤه المترجم Compiler، فيرى المراجع شرطاً داخل تعليق بينما هو كود يعمل فعلاً، أو العكس. وفي نفس الوقت أضافت GitHub [تحذيراً يظهر على أي ملف فيه هذه الحروف](https://github.blog/changelog/2021-10-31-warning-about-bidirectional-unicode-text/?ref=arabroot.io)، وأضافت لغات ومحررات كثيرة تحذيرات مشابهة. والدرس للمطور العربي أن هذه الحروف أدوات عرض فقط، ومكانها في النص الذي يراه المستخدم، وليس في الكود ولا في المعرفات.

## الأرقام والعملة: لا تقم بتنسيقها بيدك

التذكرة التالية في القصة كانت عن الأسعار، فقد كتب الفريق السعر بقالب ثابت `price + ' ر.س'`، فظهر الرقم بأرقام لاتينية وبدون فاصل الآلاف، وانتقلت العملة إلى طرف آخر في بعض الصفحات. والواجهة [Intl.NumberFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global%5FObjects/Intl/NumberFormat?ref=arabroot.io) تحل كل هذا من بيانات CLDR، وقد طبعنا بها نفس الرقم بعدة وسوم فكانت النتيجة كما يلي:

```text
== 2) numbers
en-US                      "1,234,567.891"
ar-SA                      "١٬٢٣٤٬٥٦٧٫٨٩١"
ar-EG                      "١٬٢٣٤٬٥٦٧٫٨٩١"
ar-MA                      "1.234.567,891"
ar-SA-u-nu-latn            "1,234,567.891"
ar-AE                      "1,234,567.891"
currency ar-SA             "‏١٬٤٩٩٫٥٠ ر.س.‏"
currency ar-SA-u-nu-latn   "‏1,499.50 ر.س.‏"
currency en-SA             "SAR 1,499.50"
percent ar-SA              "١٥٪؜"
compact ar-SA              "٢٫٥ مليون"
compact ar-SA long         "٢٫٥ مليون"
negative ar-SA             "؜-٤٢"
codepoints of ar-SA 15%    "U+0661 U+0665 U+066A U+061C"
```

في المخرج أعلاه لاحظ التالي:

- الوسم `ar-SA` كتب الرقم بالأرقام العربية الهندية، وفاصل الآلاف فيه ليس الفاصلة العادية وإنما الحرف `٬` بالرمز U+066C، والفاصلة العشرية هي الحرف `٫` بالرمز U+066B كما يظهر في آخر سطر من القسم السابع في التجربة، وهذا مهم لأن أي كود يحاول قراءة هذا النص كرقم سوف يفشل.
- الوسم `ar-MA` استخدم النقطة لفاصل الآلاف والفاصلة للكسور، أي عكس السعودية تماماً، ولو كتبت الفواصل بيدك لكان الرقم خاطئاً في واحدة من الدولتين.
- الوسم `ar-AE` كتب الرقم بالأرقام اللاتينية، فاختيار الأرقام في بيانات CLDR يختلف بين الدول العربية، ولذلك لا تفترض أن «العربية» تعني الأرقام العربية الهندية.
- السعر بالعملة `SAR` ظهر بالرمز `ر.س.` بعد الرقم، وفي بدايته ونهايته حرف خفي هو RLM، وهو ما تحدثنا عنه في القسم السابق، فمكتبة ICU تحمي ترتيب السعر داخل الجمل. وفي الوسم `en-SA` ظهرت العملة `SAR` قبل الرقم، فمكان رمز العملة جزء من التوطين وليس من الترجمة.
- النسبة المئوية استخدمت الحرف `٪` بالرمز U+066A ثم الحرف الخفي ALM بالرمز U+061C كما يبين سطر الرموز، والرقم السالب بدأ أيضاً بالحرف ALM قبل علامة السالب، حتى لا تنتقل العلامة إلى الطرف الآخر من الرقم.
- الصيغة المختصرة `compact` كتبت «٢٫٥ مليون»، وهي مناسبة للوحات المؤشرات Dashboards والأرقام الكبيرة في الواجهة.

وهنا قرار منتج يجب أن يتخذه الفريق صراحة: الأرقام العربية الهندية أم اللاتينية؟ والتقنية تدعم الاثنين بنفس السهولة، فالامتداد `-u-nu-latn` أعطى نفس التنسيق السعودي بالأرقام اللاتينية. والمهم ألا يترك القرار للصدفة، فلا تختلط الأرقام في نفس الشاشة، ولا يظهر السعر بالأرقام العربية الهندية في صفحة المنتج وباللاتينية في الفاتورة. وأياً كان قرارك، فالرقم يحفظ في قاعدة البيانات رقماً، وليس نصاً منسقاً، والتنسيق يحدث عند العرض فقط، والسبب أن النص المنسق لا يمكن جمعه ولا ترتيبه ولا تحويله إلى Locale آخر، وأن الفاتورة التي ترسل بالبريد قد تحتاج أرقاماً غير التي في الواجهة.

## التاريخ والتقويم الهجري: احفظ UTC ونسق عند العرض

مواعيد الدورات في المنصة كانت تحفظ كنص منسق باللغة الإنجليزية، فلما جاءت الواجهة العربية اضطر الفريق إلى تحويل النص إلى تاريخ ثم تنسيقه من جديد، وظهرت أخطاء في المواعيد التي تقع قرب منتصف الليل. والقاعدة التي تمنع هذا كله هي أن تحفظ اللحظة نفسها بتوقيت UTC، أي `2026-10-30T14:30:00Z`، ثم تنسقها عند العرض بالـ Locale والمنطقة الزمنية Time Zone الخاصة بالمستخدم، وهذا ما تقوم به الواجهة [Intl.DateTimeFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global%5FObjects/Intl/DateTimeFormat?ref=arabroot.io). وقد نسقنا في التجربة لحظة واحدة بعدة وسوم وتقاويم، فكانت النتيجة كما يلي:

```text
== 3) dates
iso (stored)               "2026-10-30T14:30:00.000Z"
ar-SA                      "الجمعة، ٣٠ أكتوبر ٢٠٢٦ في ٥:٣٠ م"
ar-SA-u-ca-gregory         "الجمعة، ٣٠ أكتوبر ٢٠٢٦ في ٥:٣٠ م"
ar-SA-u-ca-islamic-umalqura "الجمعة، ١٩ جمادى الأولى ١٤٤٨ هـ في ٥:٣٠ م"
ar-SA-u-ca-islamic-umalqura-nu-latn "الجمعة، 19 جمادى الأولى 1448 هـ في 5:30 م"
ar-EG                      "الجمعة، ٣٠ أكتوبر ٢٠٢٦ في ٥:٣٠ م"
en-US                      "Friday, October 30, 2026 at 5:30 PM"
islamic (civil?)           "٢٠ جمادى الأولى ١٤٤٨ هـ"
islamic-civil              "١٨ جمادى الأولى ١٤٤٨ هـ"
umalqura parts             "month=5 day=19 year=1448 era=AH"
same instant UTC           "٢:٣٠ م"
hour12 ar-SA               "٥:٣٠ م"
```

في المخرج أعلاه لاحظ التالي:

- اللحظة المحفوظة هي الساعة 14:30 بتوقيت UTC، وقد ظهرت في كل الأسطر الساعة 5:30 مساءً لأننا حددنا `timeZone: 'Asia/Riyadh'`، أما السطر `same instant UTC` فطلب نفس اللحظة بتوقيت UTC فظهرت ٢:٣٠ م. ولو لم نحدد المنطقة الزمنية لأخذ التنسيق منطقة الجهاز الذي يعمل عليه الكود، وهي على السيرفر غالباً UTC، وفي متصفح المستخدم منطقة جهازه.
- الوسم `ar-SA` وحده أعطى التاريخ الميلادي، والوسم مع `-u-ca-islamic-umalqura` أعطى «١٩ جمادى الأولى ١٤٤٨ هـ» لنفس اللحظة، وأسماء الأيام والأشهر وكلمة «في» و«م» كلها من بيانات CLDR.
- ظهر في بيانات ICU ثلاثة تقاويم هجرية، وأعطت لنفس اللحظة ثلاثة أيام مختلفة: `islamic-umalqura` أعطى 19، و`islamic` أعطى 20، و`islamic-civil` أعطى 18.

وسبب الاختلاف في التقاويم الهجرية أنها ليست تقويماً واحداً، فوثائق [IslamicCalendar في ICU](https://unicode-org.github.io/icu-docs/apidoc/released/icu4j/com/ibm/icu/util/IslamicCalendar.html?ref=arabroot.io) تشرح أن التقويم المدني `islamic-civil` يستخدم دورة ثابتة من أشهر طولها 29 و30 يوماً، فيمكن حسابه مسبقاً، أما التقويم الديني وتقويم أم القرى الذي تستخدمه السعودية فيعتمدان على رؤية الهلال أو حسابه، ولذلك قد يختلفان عن التقويم المدني بعدة أيام. والنتيجة العملية أن المنتج الذي يستهدف المستخدم في السعودية يجب أن يحدد `islamic-umalqura` صراحة، وليس `islamic`، والسبب أن الفرق بينهما يوم كامل في مثالنا، وهو فرق كاف ليصل موعد الدورة في يوم غير الذي يتوقعه العميل. والصورة التالية تلخص الفكرة:

![لحظة واحدة محفوظة في قاعدة البيانات بتوقيت UTC وهي 2026-10-30T14:30:00Z، ومنها خمسة أسطر منسقة: ar-SA بالتاريخ الميلادي، وتقويم أم القرى بالأرقام العربية ثم اللاتينية، والإنجليزية، ونفس اللحظة بتوقيت UTC، وتحتها ملاحظة بأن التقاويم الهجرية الثلاثة في ICU أعطت 19 و20 و18 جمادى الأولى](https://arabroot.io/content/images/2026/10/i18n-l10n-arabic-05-utc-edge.webp)

القيمة في قاعدة البيانات واحدة، وكل ما تراه على الشاشة تنسيق لها

وفي هذا القسم شيئان آخران. الأول أن التقويم الافتراضي لكل Locale جزء من بيانات CLDR، وهذه البيانات تتغير بين الإصدارات، ولذلك لا تعتمد على أن `ar-SA` سوف يعطي التقويم الميلادي أو الهجري، وإنما حدد التقويم دائماً بالامتداد `ca` أو بالخيار `calendar` في الدالة. والثاني أن التقويم يؤثر على حدود اليوم والشهر في التقارير، فإذا كانت الإدارة تريد تقرير مبيعات «شهر رمضان» فالمطلوب أن تحسب بداية الشهر ونهايته بتقويم أم القرى وبتوقيت السعودية، ثم تحولهما إلى UTC وتستعلم بهما قاعدة البيانات، وليس العكس. وفي المستقبل سوف يسهل الكائن [Temporal](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global%5FObjects/Temporal?ref=arabroot.io) في JavaScript هذا النوع من الحسابات، لأنه يدعم التقاويم والمناطق الزمنية في الحساب نفسه وليس في التنسيق فقط، فراجع دعم المتصفحات له قبل الاعتماد عليه.

### التاريخ النسبي والقوائم

جزء كبير من أي واجهة هو التاريخ النسبي مثل «قبل 3 ساعات» و«أمس»، والقوائم مثل «أحمد وسارة وليلى»، وكلاهما يبنيه المطورون عادة بجمع النصوص فيخطئ في العربية. والواجهة [Intl.RelativeTimeFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global%5FObjects/Intl/RelativeTimeFormat?ref=arabroot.io) تعرف أن اليوم السابق «أمس» واليوم الذي قبله «أول أمس»، وأن الأسبوعين «أسبوعين» وليس «2 أسابيع»، و[Intl.ListFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global%5FObjects/Intl/ListFormat?ref=arabroot.io) تعرف أن «و» في العربية تلتصق بالكلمة التالية بدون مسافة، كما ترى في القسم السادس من التجربة. ولاحظ في نفس القسم أن «قبل 3 ساعات» ظهرت بالأرقام اللاتينية، والسبب أننا استخدمنا الوسم `ar` وحده، وقد رأينا في القسم الأول أنه يختار `latn`، فلو استخدمنا `ar-SA` لظهرت بالأرقام العربية الهندية، وهذا يعني أن خلط الوسوم بين أجزاء التطبيق ينتج شاشة فيها نوعان من الأرقام. فاجعل الـ Locale قيمة واحدة تمر لكل دوال التنسيق، ولا تكتب الوسم بيدك في كل مكان.

## صيغ الجمع: لماذا ظهرت «ملفات 2»؟

الكود الذي كتب «ملفات 2» كان يشبه هذا السطر الذي تجده في أغلب المشاريع التي بدأت بالإنجليزية:

```javascript
const label = (n === 1 ? 'ملف' : 'ملفات') + ' ' + n;
```

وهذا السطر صحيح في الإنجليزية لأن فيها فئتين فقط للجمع، المفرد وكل ما عداه، ولكن العربية فيها ست فئات حسب [جدول قواعد الجمع في CLDR](https://www.unicode.org/cldr/charts/latest/supplemental/language%5Fplural%5Frules.html?ref=arabroot.io): صفر `zero`، ومفرد `one`، ومثنى `two`، وقليل `few` للأعداد التي باقي قسمتها على 100 من 3 إلى 10، وكثير `many` للأعداد التي باقي قسمتها على 100 من 11 إلى 99، وفئة أخرى `other` لكل ما بقي مثل 100 و1000 والأعداد العشرية. والواجهة [Intl.PluralRules](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global%5FObjects/Intl/PluralRules?ref=arabroot.io) لا تترجم شيئاً، وإنما تخبرك بالفئة التي ينتمي إليها العدد، ثم تختار أنت الجملة المناسبة لهذه الفئة من ملف الترجمة. وقد سألناها في التجربة عن مجموعة أعداد:

```text
== 4) plural categories
ar categories              ["few","many","one","two","zero","other"]
en categories              ["one","other"]
0      zero
1      one
2      two
3      few
10     few
11     many
99     many
100    other
101    other
102    other
103    few
111    many
1000   other
1.5    other
```

لاحظ أن 103 عادت إلى الفئة `few` مثل 3، لأن القاعدة تنظر إلى باقي القسمة على 100، فالعربي يقول «103 ملفات» كما يقول «3 ملفات»، وأن 111 عادت إلى `many` مثل 11، وأن 100 و101 و102 في الفئة `other`، فنقول «100 ملف» و«101 ملف» و«102 ملف» عند كتابة الرقم بالأرقام، وأن العدد العشري 1.5 في `other`. والصورة التالية تجمع الفئات الست مع الجملة التي اخترناها لكل فئة:

![ست بطاقات لفئات الجمع في العربية: zero للعدد 0 بجملة لا توجد ملفات، وone للعدد 1 بجملة ملف واحد، وtwo للعدد 2 بجملة ملفان، وfew لباقي القسمة من 3 إلى 10 بجملة 3 ملفات، وmany لباقي القسمة من 11 إلى 99 بجملة 11 ملفاً، وother لكل ما بقي بجملة 100 ملف، وتحتها مقارنة بالطريقة البسيطة التي تعطي ملفات 2](https://arabroot.io/content/images/2026/10/i18n-l10n-arabic-04-plurals.webp)

الفئة تحددها القاعدة، والجملة يكتبها المترجم في ملف الترجمة

ثم بنينا من هذه الفئات رسالة كاملة، والمهم في الكود أن الجملة كلها في ملف الترجمة، بما فيها مكان الرقم، وليس الكلمة فقط:

```text
== 5) a message per category
0    لا توجد ملفات
1    ملف واحد
2    ملفان
3    ٣ ملفات
11   ١١ ملفاً
100  ١٠٠ ملف
102  ١٠٢ ملف
naive 11 -> ملفات 11 | naive 2 -> ملفات 2
```

والسطر الأخير يبين ما كانت تعرضه المنصة قبل الإصلاح. ولاحظ أن الفئة `two` لا تحتاج الرقم أصلاً، فالعربي يقول «ملفان» ولا يقول «2 ملفان»، وأن `zero` جملة مختلفة تماماً، وهذا ما لا يمكن الوصول إليه بإضافة لاحقة إلى كلمة.

### ملفات الترجمة Message Catalogs والجمل المركبة

في التطبيقات الحقيقية لا تكتب الجمل في الكود كما فعلنا في التجربة، وإنما في ملفات ترجمة Message Catalogs، ملف لكل لغة، وكل جملة لها مفتاح ثابت. والصيغة الأكثر انتشاراً لوصف الجمل التي فيها جمع أو اختيار هي [ICU MessageFormat](https://unicode-org.github.io/icu/userguide/format%5Fparse/messages/?ref=arabroot.io)، وتستخدمها مكتبات مثل [FormatJS](https://formatjs.github.io/docs/core-concepts/icu-syntax/?ref=arabroot.io)، وشكل الجملة السابقة فيها كما يلي:

```text
{count, plural,
  zero {لا توجد ملفات}
  one {ملف واحد}
  two {ملفان}
  few {# ملفات}
  many {# ملفاً}
  other {# ملف}
}
```

والعلامة `#` هي مكان الرقم بعد تنسيقه حسب الـ Locale. أما مكتبة i18next فتستخدم طريقة مختلفة، فهي تضيف لاحقة الفئة إلى المفتاح، مثل `files_zero` و`files_few`، وتعتمد في اختيار الفئة على `Intl.PluralRules` نفسها كما تذكر [وثائق الجمع فيها](https://www.i18next.com/translation-function/plurals?ref=arabroot.io)، ولذلك إذا كان التطبيق يعمل في بيئة لا تدعم `Intl.PluralRules` فالجمع العربي لن يعمل فيها. وهناك مقترح في TC39 لإضافة [Intl.MessageFormat](https://github.com/tc39/proposal-intl-messageformat?ref=arabroot.io) إلى JavaScript نفسها، وما زال في المرحلة الأولى Stage 1، فلا تعتمد عليه الآن.

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

- **جمع الجملة من قطع:** مثل `t('you_have') + n + t('files')`، والسبب في أنه خطأ أن ترتيب الكلمات يختلف بين اللغات، والمترجم يرى قطعاً لا يعرف كيف تجتمع، فاكتب الجملة كاملة بمتغيراتها في مفتاح واحد.
- **إعادة استخدام كلمة في سياقين:** كلمة Open في الإنجليزية قد تكون «افتح» على زر و«مفتوح» على حالة طلب، فاجعل لكل سياق مفتاحاً، واكتب للمترجم ملاحظة عن مكان ظهوره.
- **تجاهل التذكير والتأنيث:** الرسالة «قام X بالتعليق» تصبح «قامت» للمستخدمة، والصيغة `select` في ICU MessageFormat تختار الجملة حسب قيمة متغير مثل الجنس، وإذا لم يكن الجنس معروفاً فاكتب الجملة بصيغة لا تحتاجه، مثل «تعليق جديد من X».
- **نسيان العزل داخل الجملة المترجمة:** اسم المستخدم داخل جملة مترجمة يحتاج عزلاً مثل أي مكان آخر، فاجعل دالة التنسيق تعزل كل متغير نصي تلقائياً، بدلاً من الاعتماد على كل مطور في كل قالب.

## ترتيب الأسماء العربية والبحث فيها

شكوى البحث في القصة كانت أن «احمد» لا يجد «أحمد»، وشكوى أخرى أن قائمة المدربين مرتبة بشكل غريب. والسبب في الثانية أن الدالة `sort()` بدون مقارن ترتب حسب رموز UTF-16، فتأتي الحروف اللاتينية الكبيرة قبل الصغيرة، ثم الحروف العربية حسب رموزها، فتأتي «آمنة» قبل «أحمد»، وتنفصل «أحمد» عن «احمد» فيقع بينهما «إبراهيم» و«ابراهيم». والواجهة [Intl.Collator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global%5FObjects/Intl/Collator?ref=arabroot.io) ترتب حسب خوارزمية الترتيب في [UTS #10](https://www.unicode.org/reports/tr10/?ref=arabroot.io) وبيانات اللغة، ومخرجها في القسم السادس من التجربة يبين الفرق: الأسماء العربية أولاً لأن الـ Locale عربي، و«إبراهيم» بجانب «ابراهيم»، و«أحمد» بجانب «احمد»، ثم الأسماء الإنجليزية بدون تفريق بين الحرف الكبير والصغير.

أما البحث فيحتاج شيئاً إضافياً، وهو مستوى الحساسية Sensitivity، فالمقارن الافتراضي يعتبر «أحمد» و«احمد» مختلفين وإن رتبهما متجاورين، أما الخيار `sensitivity: 'base'` فيقارن الحروف الأساسية فقط، فأعاد `true` للمقارنة بينهما. وهذا يكفي للمقارنة داخل المتصفح في قائمة صغيرة، أما البحث في قاعدة البيانات فيحتاج توحيد النص Normalization عند الحفظ والبحث، مثل توحيد أشكال الهمزة وحذف التطويل، وهذا موضوع خاص بمحركات البحث يستحق مقالاً مستقلاً.

## الأرقام التي يكتبها المستخدم

آخر تذكرة كانت عن العميل الذي كتب رقم جواله بالأرقام العربية الهندية، ولوحة المفاتيح العربية في بعض الأجهزة تكتب هذه الأرقام افتراضياً، فرفض النموذج الرقم. والسبب ظاهر في القسم السابع من التجربة:

```text
== 7) parsing user digits
"٠٥٠١٢٣٤٥٦٧"     Number= NaN parseInt= NaN toAscii= "0501234567"
"۰۵۰۱۲۳۴۵۶۷"     Number= NaN parseInt= NaN toAscii= "0501234567"
"١٬٢٣٤٫٥"        Number= NaN parseInt= NaN toAscii= "1٬234٫5"
\d matches ٣ ?             false
\d with u flag ?           false
\p{Nd} matches ٣ ?         true
ar-SA separators           ["group=U+66C","decimal=U+66B"]
```

في المخرج أعلاه لاحظ التالي:

- `Number` و`parseInt` أعادا `NaN` للأرقام العربية الهندية وللأرقام الفارسية `۰۵۰`، وهي أرقام لها رموز أخرى من U+06F0 إلى U+06F9 يكتبها بعض المستخدمين إذا كانت لوحة مفاتيحهم فارسية أو أردية.
- التعبير `\d` في JavaScript لا يطابق `٣` حتى مع العلامة `u`، لأنه يعني 0 إلى 9 فقط، ولذلك يفشل أي تحقق Validation بتعبير مثل `/^05\d{8}$/`، أما الفئة `\p{Nd}` في [تعبيرات Unicode](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular%5Fexpressions/Unicode%5Fcharacter%5Fclass%5Fescape?ref=arabroot.io) فتطابق كل الأرقام العشرية في كل الأنظمة.
- الدالة `toAscii` حولت الرقم إلى `0501234567` فأصبح صالحاً، ولكنها في السطر الثالث حولت الأرقام فقط وتركت فاصل الآلاف `٬` والفاصلة العشرية `٫` كما هما، فالمبلغ «١٬٢٣٤٫٥» يحتاج أيضاً تحويل هذين الحرفين قبل قراءته، وقد طبعنا رمزيهما في السطر الأخير.

والقاعدة هنا أن تقبل الأرقام بأي نظام يكتبه المستخدم، ثم تحولها إلى الأرقام اللاتينية في أول نقطة تستقبلها، أي في المتصفح قبل التحقق، وفي السيرفر مرة أخرى قبل الحفظ، والسبب أن السيرفر لا يستطيع الاعتماد على تحقق المتصفح أصلاً، وأن الطلب قد يأتي من تطبيق جوال أو من API مباشرة. وفي حقول الأرقام استخدم `inputmode="numeric"` لتظهر لوحة الأرقام على الجوال، ولا تحاول منع المستخدم من كتابة الأرقام العربية، فهي ليست خطأ منه.

## التجربة العملية Lab: في Node.js ثم في Chrome

التجربة من ثلاثة ملفات في المجلد `lab` بجانب المقال: الملف `intl.mjs` يطبع نتيجة كل دالة من دوال `Intl` ويعمل بدون تعديل في Node.js وفي المتصفح، والصفحة `bidi.html` فيها جدول بكل حالات النص المختلط قبل العزل وبعده، والملف `visual.mjs` يفتح الصفحة في Chrome ويقيس مكان كل حرف على الشاشة ليطبع الترتيب المرئي. ولا تحتاج التجربة إلى أي مكتبة، فكل ما فيها من الواجهات المدمجة في JavaScript.

### الجزء الأول: Intl في Node.js

الملف `intl.mjs` كاملاً كما يلي:

```javascript
// Intl lab for the i18n-l10n-arabic article. Runs in Node (node intl.mjs) and, pasted as a script, in Chrome.
// Every section prints real output from the ICU data the runtime ships with.
const out = [];
const log = (...a) => out.push(a.join(' '));
const show = (label, v) => log(label.padEnd(26), JSON.stringify(v));

if (typeof process !== 'undefined' && process.versions) {
  log('== 0) runtime');
  show('node', process.versions.node);
  show('icu', process.versions.icu);
  show('unicode', process.versions.unicode);
  show('cldr', process.versions.cldr);
  show('tz', process.versions.tz);
}

log('== 1) locale tags');
for (const tag of ['ar', 'ar-SA', 'ar-EG', 'ar-MA', 'ar-SA-u-nu-latn', 'ar-SA-u-ca-islamic-umalqura']) {
  const l = new Intl.Locale(tag);
  const r = new Intl.NumberFormat(tag).resolvedOptions();
  const d = new Intl.DateTimeFormat(tag).resolvedOptions();
  log(tag.padEnd(28), 'numberingSystem=' + r.numberingSystem, 'calendar=' + d.calendar, 'max=' + l.maximize().toString());
}

log('== 2) numbers');
const n = 1234567.891;
for (const tag of ['en-US', 'ar-SA', 'ar-EG', 'ar-MA', 'ar-SA-u-nu-latn', 'ar-AE']) show(tag, new Intl.NumberFormat(tag).format(n));
show('currency ar-SA', new Intl.NumberFormat('ar-SA', { style: 'currency', currency: 'SAR' }).format(1499.5));
show('currency ar-SA-u-nu-latn', new Intl.NumberFormat('ar-SA-u-nu-latn', { style: 'currency', currency: 'SAR' }).format(1499.5));
show('currency en-SA', new Intl.NumberFormat('en-SA', { style: 'currency', currency: 'SAR' }).format(1499.5));
show('percent ar-SA', new Intl.NumberFormat('ar-SA', { style: 'percent' }).format(0.15));
show('compact ar-SA', new Intl.NumberFormat('ar-SA', { notation: 'compact' }).format(2500000));
show('compact ar-SA long', new Intl.NumberFormat('ar-SA', { notation: 'compact', compactDisplay: 'long' }).format(2500000));
show('negative ar-SA', new Intl.NumberFormat('ar-SA').format(-42));
show('codepoints of ar-SA 15%', [...new Intl.NumberFormat('ar-SA', { style: 'percent' }).format(0.15)].map(c => 'U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')).join(' '));

log('== 3) dates');
const t = new Date(Date.UTC(2026, 9, 30, 14, 30, 0)); // 2026-10-30T14:30:00Z
show('iso (stored)', t.toISOString());
const full = { dateStyle: 'full', timeStyle: 'short', timeZone: 'Asia/Riyadh' };
for (const tag of ['ar-SA', 'ar-SA-u-ca-gregory', 'ar-SA-u-ca-islamic-umalqura', 'ar-SA-u-ca-islamic-umalqura-nu-latn', 'ar-EG', 'en-US']) show(tag, new Intl.DateTimeFormat(tag, full).format(t));
show('islamic (civil?)', new Intl.DateTimeFormat('ar-SA-u-ca-islamic', { dateStyle: 'long', timeZone: 'Asia/Riyadh' }).format(t));
show('islamic-civil', new Intl.DateTimeFormat('ar-SA-u-ca-islamic-civil', { dateStyle: 'long', timeZone: 'Asia/Riyadh' }).format(t));
show('umalqura parts', new Intl.DateTimeFormat('en-u-ca-islamic-umalqura', { year: 'numeric', month: 'numeric', day: 'numeric', timeZone: 'Asia/Riyadh' }).formatToParts(t).filter(p => p.type !== 'literal').map(p => p.type + '=' + p.value).join(' '));
show('same instant UTC', new Intl.DateTimeFormat('ar-SA', { timeStyle: 'short', timeZone: 'UTC' }).format(t));
show('hour12 ar-SA', new Intl.DateTimeFormat('ar-SA', { hour: 'numeric', minute: '2-digit', timeZone: 'Asia/Riyadh' }).format(t));

log('== 4) plural categories');
const pr = new Intl.PluralRules('ar');
show('ar categories', pr.resolvedOptions().pluralCategories);
show('en categories', new Intl.PluralRules('en').resolvedOptions().pluralCategories);
for (const k of [0, 1, 2, 3, 10, 11, 99, 100, 101, 102, 103, 111, 1000, 1.5]) log(String(k).padEnd(6), pr.select(k));

log('== 5) a message per category');
const files = { zero: 'لا توجد ملفات', one: 'ملف واحد', two: 'ملفان', few: '{n} ملفات', many: '{n} ملفاً', other: '{n} ملف' };
const nf = new Intl.NumberFormat('ar-SA');
const msg = (k) => files[pr.select(k)].replace('{n}', nf.format(k));
for (const k of [0, 1, 2, 3, 11, 100, 102]) log(String(k).padEnd(4), msg(k));
const naive = (k) => (k === 1 ? 'ملف' : 'ملفات') + ' ' + k;
log('naive 11 ->', naive(11), '| naive 2 ->', naive(2));

log('== 6) relative time, lists, sorting');
const rtf = new Intl.RelativeTimeFormat('ar', { numeric: 'auto' });
for (const [v, u] of [[-1, 'day'], [1, 'day'], [-2, 'day'], [-3, 'hour'], [-11, 'minute'], [2, 'week']]) show(`${v} ${u}`, rtf.format(v, u));
show('list and', new Intl.ListFormat('ar', { type: 'conjunction' }).format(['أحمد', 'Sara', 'ليلى']));
show('list or', new Intl.ListFormat('ar', { type: 'disjunction' }).format(['بطاقة', 'Apple Pay', 'تحويل']));
const names = ['يوسف', 'أحمد', 'إبراهيم', 'احمد', 'آمنة', 'ابراهيم', 'زياد', 'Zaid', 'adam', 'عمر'];
show('default sort()', [...names].sort());
show('Collator ar', [...names].sort(new Intl.Collator('ar').compare));
const base = new Intl.Collator('ar', { sensitivity: 'base' });
show('أحمد == احمد base', base.compare('أحمد', 'احمد') === 0);
show('أحمد == احمد default', new Intl.Collator('ar').compare('أحمد', 'احمد') === 0);

log('== 7) parsing user digits');
const toAscii = (s) => s.replace(/[٠-٩]/g, d => d.charCodeAt(0) - 0x0660).replace(/[۰-۹]/g, d => d.charCodeAt(0) - 0x06F0);
for (const s of ['٠٥٠١٢٣٤٥٦٧', '۰۵۰۱۲۳۴۵۶۷', '١٬٢٣٤٫٥']) log(JSON.stringify(s).padEnd(16), 'Number=', Number(s), 'parseInt=', parseInt(s, 10), 'toAscii=', JSON.stringify(toAscii(s)));
show('\\d matches ٣ ?', /\d/.test('٣'));
show('\\d with u flag ?', /\d/u.test('٣'));
show('\\p{Nd} matches ٣ ?', /\p{Nd}/u.test('٣'));
const parts = new Intl.NumberFormat('ar-SA').formatToParts(1234.5);
show('ar-SA separators', parts.filter(p => p.type === 'group' || p.type === 'decimal').map(p => p.type + '=U+' + p.value.codePointAt(0).toString(16).toUpperCase()));

log('== 8) bidi control characters in user input');
const BIDI_CONTROLS = /[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/g;
const upload = 'invoice\u202Efdp.exe';
show('name (controls shown)', upload.replace(BIDI_CONTROLS, c => '<U+' + c.codePointAt(0).toString(16).toUpperCase() + '>'));
show('length', upload.length);
show('endsWith(".exe")', upload.endsWith('.exe'));
show('controls found', (upload.match(BIDI_CONTROLS) || []).map(c => 'U+' + c.codePointAt(0).toString(16).toUpperCase()));
show('cleaned', upload.replace(BIDI_CONTROLS, ''));
const isolate = (s) => '\u2068' + s.replace(BIDI_CONTROLS, '') + '\u2069';
show('isolated for display', [...isolate('user99')].map(c => c.codePointAt(0) > 127 ? 'U+' + c.codePointAt(0).toString(16).toUpperCase() : c).join(' '));

if (typeof module === 'undefined' && typeof window !== 'undefined') window.__out = out.join('\n');
else console.log(out.join('\n'));
```

ونشغله في Container من Image رسمي صغير لـ Node.js 22، ثم يحذف Docker الـ Container تلقائياً بعد انتهائه بالخيار `--rm`:

```bash
docker run --rm --name sfe-i18n-node -v "$PWD:/lab" -w /lab node:22-alpine node intl.mjs > node-output.txt
```

والمخرج يبدأ بإصدارات البيئة، وهي أهم سطور التجربة لأن كل ما بعدها يعتمد عليها:

```text
== 0) runtime
node                       "22.23.4"
icu                        "78.3"
unicode                    "17.0"
cldr                       "48.0"
tz                         "2026e"
```

كل نتائج `Intl` تأتي من مكتبة ICU المدمجة في Node.js، وإصدارها هنا 78.3، ومعها بيانات CLDR 48.0 وUnicode 17.0 وقاعدة المناطق الزمنية 2026e، وإذا شغلت نفس الملف على إصدار آخر من Node.js فقد تختلف بعض النصوص، مثل التقويم الافتراضي أو شكل رمز العملة، لأن بيانات CLDR تتغير. ولهذا اطبع `process.versions.icu` في أي تقرير خطأ يتعلق بالتنسيق، ولاحظ أن [وثائق Node.js](https://nodejs.org/api/intl.html?ref=arabroot.io) تذكر أن بعض طرق بناء Node.js تأتي ببيانات ICU مختصرة لا تحتوي إلا الإنجليزية، فإذا رأيت العربية تخرج بتنسيق إنجليزي في بيئة معينة فهذا أول ما تتحقق منه. وقد مررنا على بقية الأقسام في أماكنها من المقال، وبقي القسم الثامن عن الحروف الخفية في مدخلات المستخدم:

```text
== 8) bidi control characters in user input
name (controls shown)      "invoice<U+202E>fdp.exe"
length                     15
endsWith(".exe")           true
controls found             ["U+202E"]
cleaned                    "invoicefdp.exe"
isolated for display       "U+2068 u s e r 9 9 U+2069"
```

طول الاسم 15 حرفاً مع أن المرئي منه 14، والحرف الزائد هو RLO كما يبين السطر `controls found`، والدالة `endsWith('.exe')` أكدت أن الملف تنفيذي، وبعد حذف الحروف الخفية ظهر الاسم الحقيقي `invoicefdp.exe`. والسطر الأخير يبين شكل اسم المستخدم بعد الدالة `isolate`، فهو محاط بالحرفين U+2068 وU+2069، أي FSI وPDI، وهذا هو الشكل الذي ترسله في نص إشعار أو رسالة SMS.

### الجزء الثاني: نفس الكود في Chrome، ثم العرض على الشاشة

الملف `chrome.mjs` يشغل Chrome بدون واجهة Headless عبر بروتوكول DevTools، ويفتح الصفحة `bidi.html`، ثم ينفذ نفس الملف `intl.mjs` داخل الصفحة ويحفظ مخرجه، ثم يلتقط صور الجدول. وأول ما فعلناه هو مقارنة مخرج المتصفح بمخرج Node.js بعد حذف سطور الإصدارات:

```bash
node lab/chrome.mjs
tail -n +7 node-output.txt > node.txt
tail -n +2 chrome-output.txt > chrome.txt
diff node.txt chrome.txt
```

والمخرج سوف يكون كما يلي:

```text
37c37
< ar categories              ["few","many","one","two","zero","other"]
---
> ar categories              ["zero","one","two","few","many","other"]
```

كل النصوص المنسقة متطابقة بين Node.js 22 وChrome 156، فالأرقام والتواريخ والتقاويم الهجرية الثلاثة والترتيب والجمع أعطت نفس الحروف، والفرق الوحيد هو ترتيب أسماء الفئات في `resolvedOptions().pluralCategories`، فقد أعادها Node.js بترتيب أبجدي تقريباً، وأعادها Chrome بالترتيب الطبيعي من `zero` إلى `other`. والسبب تعديل في المعيار ECMA-402 جعل هذا الترتيب محدداً في [التعديل رقم 918](https://github.com/tc39/ecma402/pull/918?ref=arabroot.io)، وقد طبقه محرك V8 الأحدث في Chrome، أما V8 في Node.js 22 فأقدم منه. والدرس العملي أن هذه القائمة تصلح لمعرفة الفئات المتاحة، ولكن لا تقم ببناء واجهة المترجم أو أعمدة ملف الترجمة على ترتيبها، والسبب أنه اختلف أمامك بين بيئتين في نفس اليوم.

بعد ذلك صورنا جدول الحالات في الصفحة، وفي كل سطر النص بدون عزل في العمود الأوسط، ونفس النص بعد العزل في العمود الأيسر، والخلفية الحمراء للخطأ والخضراء للإصلاح:

![لقطة من Chrome لجدول من أحد عشر سطراً: اسم مستخدم لاتيني ثم رقم، ونفس الحالة مع LRE وPDF، وspan بدون dir، ورقم جوال دولي، واسم لغة C++، والسعر بالعملة، ومسار ملف فيه اسم عربي، والمسار مع عزل كل جزء، والوقت بصيغة إنجليزية، والأقواس وعلامة المقارنة، واسم ملف فيه الحرف RLO، وفي كل سطر النص بدون عزل بخلفية حمراء ومع العزل بخلفية خضراء](https://arabroot.io/content/images/2026/10/i18n-l10n-arabic-06-bidi-cases.webp)

كل حالة بدون عزل ومع العزل، كما رسمها Chrome 156

ولأن الصورة وحدها لا تكفي للتأكد من الترتيب، خاصة مع الأرقام والعلامات، كتبنا `visual.mjs` ليقيس موقع كل حرف على الشاشة بالدالة `getBoundingClientRect` على Range لكل حرف، ثم يرتب الحروف من اليسار إلى اليمين. ولأن الكلمات العربية لو طبعت كما هي داخل سطر المخرج لأعادت شاشتك ترتيبها من جديد، يطبع الملف كل كلمة عربية برمز مثل `A1` ويكتب معناها في سطر الحالة. والأمر والمخرج كما يلي:

```bash
node lab/visual.mjs
```

```text
case 1  A1=تعليقات
  bug: A1 user99 - 3
  fix: A1 3 - user99
case 2  A1=تعليقات
  bug: A1 <U+202A>user99<U+202C> - 3
  fix: A1 3 - <U+2069>user99<U+2066>
case 3  A1=تعليقات
  bug: A1 user99 - 3
  fix: A1 3 - user99
case 4  A1=اتصل A2=على A3=للدعم
  bug: A3 4567 123 50 966+ A2 A1
  fix: A3 +966 50 123 4567 A2 A1
case 5  A1=نبحث A2=عن A3=مطور A4=بخبرة A5=سنتين
  bug: A5 A4 ++C A3 A2 A1
  fix: A5 A4 C++ A3 A2 A1
case 6  A1=المجموع A2=شامل A3=الضريبة
  bug: A3 A2 SAR 1,499.50 :A1
  fix: A3 A2 1,499.50 SAR :A1
case 7  A1=حفظنا A2=الملف A3=في A4=تقارير A5=أمس
  bug: A5 pdf.2026/A4/home/sara/ A3 A2 A1
  fix: A5 /home/sara/2026/A4.pdf A3 A2 A1
case 8  A1=حفظنا A2=الملف A3=في A4=تقارير A5=أمس
  bug: A5 /home/sara/2026/A4.pdf A3 A2 A1
  fix: A5 /home/sara/A4/2026.pdf A3 A2 A1
case 9  A1=يبدأ A2=البث A3=بتوقيت A4=السعودية
  bug: A4 A3 PM 05:30 A2 A1
  fix: A4 A3 05:30 PM A2 A1
case 10  A1=الإصدار A2=أسرع، A3=لأن
  bug: 2 > 3 A3 A2 )v2.1( A1
  fix: 3 > 2 A3 A2 )v2.1( A1
case 11  A1=مرفق
  bug: invoiceexe.pdf<U+202E> :A1
  fix: invoicefdp.exe :A1
box 1 (rtl): !A3 A2 A1 React   A1=ممتازة A2=للمشاريع A3=الكبيرة
box 2 (ltr): React A3 A2 A1!   A1=ممتازة A2=للمشاريع A3=الكبيرة
box 3 (rtl): A5 A4 React A3 A2 A1   A1=هل A2=تنصحون A3=بـ A4=للمشاريع A5=الكبيرة؟
box 4 (ltr): 3 :2_A1 reviews   A1=عمر
box 5 (ltr): 2_A1: 3 reviews   A1=عمر
```

اقرأ كل سطر من اليسار إلى اليمين كما يظهر على الشاشة، والقارئ العربي يقرؤه من اليمين، ثم لاحظ التالي:

- **الحالة 1:** بدون عزل ظهر `user99 - 3` كتلة واحدة، فالرقم 3 هو أقرب شيء إلى يمين الشاشة، ومع العزل أصبح الاسم في أقصى اليمين وبعده الرقم ثم كلمة «تعليقات» `A1`.
- **الحالة 2:** التضمين بالحروف U+202A وU+202C ترك الخطأ كما هو، والعزل بالحرفين U+2066 وU+2069 أصلحه. ولاحظ أن الحرفين ظهرا في المخرج بترتيب معكوس `<U+2069>user99<U+2066>`، لأن الحروف الخفية نفسها تأخذ مكاناً في الترتيب المرئي.
- **الحالة 3:** `<span>` بدون `dir` لا يعزل شيئاً، و`<span dir="ltr">` يعزل لأن Chrome يعطيه `unicode-bidi: isolate`، وقد طبع `chrome.mjs` هذه القيمة من `getComputedStyle` للعناصر الثلاثة `bdi` و`span[dir]` و`div[dir=auto]`.
- **الحالات 4 إلى 6 و9:** الجوال ظهر بمجموعات معكوسة و+ في آخره، و`C++` أصبحت `++C`، والسعر والوقت انقلبت كتلتاهما، وكلها صلحت بالعزل.
- **الحالتان 7 و8:** هذه المشكلة التي شرحناها في قسم المسار، فالعزل الخارجي في الحالة 7 لم يكف، وعزل كل جزء في الحالة 8 أعطى الترتيب الصحيح `/home/sara/A4/2026.pdf`.
- **الحالة 10:** القوسان ظهرا في المخرج بترتيب `)v2.1(` لأن المخرج يطبع الحرف المخزن في كل موقع، والمتصفح رسم كل قوس بشكله المعكوس، فرأى المستخدم `(v2.1)` صحيحة، وهذا هو العكس Mirroring بعينه. وعلامة `>` كذلك رسمت معكوسة، فظهرت العبارة على الشاشة `2 < 3`، ومع العزل بالاتجاه LTR ظهرت `3 > 2`.
- **الحالة 11:** الحرف RLO جعل `fdp.exe` تظهر `exe.pdf`، والعزل لم يغير ذلك، والإصلاح الوحيد كان حذف الحرف.
- **الصناديق 1 إلى 5:** التعليق الذي يبدأ بكلمة React مع `dir="auto"` أخذ الاتجاه `ltr` وانتقلت علامة التعجب إلى اليمين، والاسم العربي في الصفحة الإنجليزية تغير مكان الرقم 2 والنقطتين حوله بدون عزل، ورجع صحيحاً مع `<bdi>`.

### ماذا يحدث إذا غيرنا الوسم Language Tag أو اتجاه الصندوق؟

بعد أن عملت التجربة قمنا بتغيير شيء واحد في كل مرة لنرى أثره، وكل النتائج التالية موجودة في المخرجات أعلاه:

- **ماذا يحدث إذا استخدمنا `ar` بدلاً من `ar-SA`؟** تتغير الأرقام إلى اللاتينية كما في القسم الأول، ولهذا ظهر التاريخ النسبي في القسم السادس بالأرقام اللاتينية بينما بقية الشاشة بالأرقام العربية الهندية.
- **ماذا يحدث إذا استخدمنا `islamic` بدلاً من `islamic-umalqura`؟** يتقدم التاريخ يوماً كاملاً في مثالنا، من 19 إلى 20 جمادى الأولى، دون أي رسالة خطأ.
- **ماذا يحدث إذا وضعنا الاسم بين LRE وPDF بدلاً من LRI وPDI؟** يبقى الخطأ كما في الحالة 2، فالتضمين ليس عزلاً.
- **ماذا يحدث إذا وضعنا `dir="auto"` على تعليق عربي يبدأ بكلمة إنجليزية؟** تصبح الفقرة LTR كما في الصندوق 2.
- **ماذا يحدث إذا عزلنا المسار كله باتجاه LTR؟** تبقى أجزاؤه العربية مع الأرقام بعدها مقلوبة كما في الحالة 7، حتى نعزل كل جزء.

وإذا أردت أن تثبت هذه الحالات في مشروعك فاكتبها كاختبارات تلتقط صورة الصفحة بالـ Locale العربي، فأداة Playwright تسمح بتحديد `locale` و`timezoneId` لكل اختبار كما في [وثائق المحاكاة](https://playwright.dev/docs/emulation?ref=arabroot.io)، وقد شرحنا بناء هذه الاختبارات في [مقال اختبارات E2E مع Playwright](https://arabroot.io/articles/%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1%D8%A7%D8%AA-e2e-%D9%85%D8%B9-playwright-%D8%A8%D8%A7%D9%84%D8%AA%D9%81%D8%B5%D9%8A%D9%84/)، وأضف إليها اختباراً بتاريخ قريب من منتصف الليل بتوقيت السعودية واختباراً لكل فئة من فئات الجمع.

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

1. **اعتبار التعريب ترجمة فقط:** الترجمة آخر خطوة، وقبلها يجب أن يكون الكود خالياً من الجمل الثابتة ومن التنسيق اليدوي ومن الجمل المركبة.
2. **إدخال اسم المستخدم في الجملة دون عزل:** كل نص لا تتحكم فيه، من اسم أو عنوان أو رقم أو مسار، يدخل الصفحة داخل `<bdi>` أو عنصر عليه `dir`.
3. **استخدام `<span>` بدون `dir` للعزل:** العنصر بدون الخاصية لا يعزل شيئاً، كما رأينا في الحالة 3.
4. **استخدام حروف التضمين القديمة LRE وRLE:** استبدلها بحروف العزل LRI وRLI وFSI مع PDI.
5. **الثقة في `dir="auto"` للنص العربي المختلط:** أول حرف قوي يحكم الفقرة كلها، فاحفظ الاتجاه الذي اختاره المستخدم إن أمكن.
6. **ترك الحروف الخفية في أسماء الملفات والمستخدمين:** احذفها عند الإدخال، فهي أداة خداع وليست تنسيقاً.
7. **حفظ التاريخ أو الرقم كنص منسق:** احفظ UTC والرقم الخام، ونسق عند العرض فقط.
8. **الاعتماد على التقويم الافتراضي أو استخدام `islamic` بدلاً من `islamic-umalqura`:** حدد التقويم صراحة، وحدد المنطقة الزمنية صراحة.
9. **شرط المفرد والجمع:** العربية فيها ست فئات، فاستخدم `Intl.PluralRules` وملف ترجمة فيه الفئات الست.
10. **رفض الأرقام العربية في النماذج:** حولها إلى اللاتينية قبل التحقق، ولا تنس الفاصلين U+066B وU+066C.
11. **الترتيب بالدالة `sort()` بدون مقارن:** استخدم `Intl.Collator` بالـ Locale الصحيح.
12. **اختلاف الوسم بين أجزاء التطبيق:** اجعل الـ Locale قيمة واحدة تمر إلى كل دوال التنسيق.

## قائمة تحقق Checklist قبل إطلاق الواجهة العربية

الجدول التالي يجمع المشكلات التي رأيناها مع الأداة المناسبة لكل منها، واستخدمه كقائمة مراجعة قبل الإطلاق:

| المشكلة                               | الأداة                                                     | أين تطبقها                       |
| ------------------------------------- | ---------------------------------------------------------- | -------------------------------- |
| اتجاه الصفحة واللغة                   | <html lang="ar" dir="rtl">                                 | القالب الرئيسي                   |
| اسم أو عنوان يكتبه المستخدم داخل جملة | <bdi>                                                      | كل قالب يعرض بيانات مستخدمين     |
| رقم جوال، مسار، رابط، كود، معادلة     | dir="ltr" على عنصر، وعزل كل جزء في المسارات                | القوالب                          |
| فقرة اتجاهها غير معروف                | dir="auto"، أو حفظ الاتجاه مع النص                         | التعليقات والرسائل وحقول الإدخال |
| نص بدون HTML                          | FSI ثم النص ثم PDI                                         | الإشعارات والبريد وSMS           |
| الحروف الخفية في المدخلات             | حذف U+202A إلى U+202E وU+2066 إلى U+2069                   | السيرفر عند الحفظ                |
| الأرقام والعملة والنسب                | Intl.NumberFormat مع قرار صريح nu                          | عند العرض                        |
| التاريخ والتقويم الهجري               | UTC في القاعدة، وIntl.DateTimeFormat مع calendar وtimeZone | عند العرض                        |
| الجمع                                 | Intl.PluralRules وملف ترجمة بالفئات الست                   | ملفات الترجمة                    |
| الترتيب والبحث                        | Intl.Collator مع sensitivity المناسبة                      | القوائم والفلاتر                 |
| الأرقام التي يكتبها المستخدم          | تحويل إلى اللاتينية ثم التحقق                              | المتصفح والسيرفر                 |
| اختلاف البيئات                        | طباعة process.versions.icu واختبارات بلقطات شاشة           | CI                               |

وإذا كنت تستعد لمقابلة عمل في الواجهات، فأسئلة الاتجاه والتعريب تظهر كثيراً، وقد جمعنا أمثلة منها في [أسئلة مقابلة مطور Frontend](https://arabroot.io/articles/%D8%A7%D8%B3%D8%A6%D9%84%D8%A9-%D9%85%D9%82%D8%A7%D8%A8%D9%84%D8%A9-%D9%85%D8%B7%D9%88%D8%B1-frontend-%D9%85%D9%86-%D8%A7%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6-%D8%A7%D9%84%D9%89-%D8%A7%D9%84%D8%AE%D8%A8%D9%8A%D8%B1/).

## تدريب عملي

- عدل الملف `intl.mjs` ليطبع السعر 1499.5 بالوسوم `ar-SA` و`ar-EG` و`ar-AE` مع العملة المحلية لكل دولة، وقارن مكان رمز العملة والأرقام في كل منها.
- أضف إلى الصفحة `bidi.html` سطراً لعنوان بريد إلكتروني اسمه عربي، مثل `سارة@example.com`، وجرب العزل الخارجي وحده ثم عزل كل جزء، كما فعلنا مع المسار.
- اكتب دالة `formatCount(n, key)` تقرأ الجمل الست من ملف JSON وتختار منها بـ `Intl.PluralRules`، واختبرها على 0 و1 و2 و5 و11 و100 و103.
- احسب بداية شهر رمضان 1448 ونهايته بتقويم أم القرى وبتوقيت السعودية، ثم حولهما إلى UTC، واكتب استعلام SQL يجلب الطلبات بينهما.

## الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

- التدويل i18n عمل في الكود يتم مرة واحدة، والتوطين l10n عمل لكل لغة ومنطقة، والترجمة وحدها لا تجعل التطبيق عربياً.
- الـ Locale وسم BCP 47 يحمل اللغة والمنطقة والتقويم ونظام الأرقام، و`ar` وحده ليس `ar-SA`.
- الشاشة تعرض النص بترتيب تحسبه خوارزمية BiDi من أنواع الحروف، فالرقم يلتصق بأقرب حرف قوي قبله، والمحايد يأخذ اتجاه جيرانه، والأقواس تعكس.
- الحل هو العزل: `<bdi>` للنص المجهول، و`dir="ltr"` للجوال والمسار والكود، وحروف FSI وPDI حيث لا يوجد HTML، والتضمين القديم لا يكفي، والنص الذي له بنية داخلية يحتاج عزل كل جزء.
- الحروف الخفية تستخدم للخداع في أسماء الملفات والكود، فاحذفها من المدخلات.
- الأرقام والتواريخ تحفظ خاماً وتنسق عند العرض بالواجهة `Intl`، مع تحديد التقويم `islamic-umalqura` والمنطقة الزمنية صراحة.
- العربية فيها ست فئات للجمع، فاكتب الجملة كاملة لكل فئة في ملف الترجمة، ولا تجمع الجمل من قطع.
- Node.js وChrome أعطيا نفس النصوص في تجربتنا، ولكن بيانات ICU وCLDR تتغير بين الإصدارات، فاطبع إصدارها واختبر الشاشات بالـ Locale العربي.

وفي مرحلة [هندسة الواجهات وتطبيقات الويب](https://arabroot.io/start/#stage-web-frontend) تجد المقالات التي يكملها هذا المقال، ومنها تخطيط الصفحة للعربية بالخصائص المنطقية:

[![](https://arabroot.io/content/images/2026/10/css-layout-rtl-cover-cover.webp)دليلCSS للعربية واتجاه RTL: الخصائص المنطقية Logical Propertiesكيف تكتب ملف CSS واحداً يعمل بالعربية والإنجليزية، ولماذا تخطئ margin-left في الصفحة العربية، مع قياسات حقيقية من Chrome.](https://arabroot.io/articles/css-%D9%84%D9%84%D9%88%D8%A7%D8%AC%D9%87%D8%A7%D8%AA-%D8%A7%D9%84%D8%B9%D8%B1%D8%A8%D9%8A%D8%A9-%D8%A7%D9%84%D8%AE%D8%B5%D8%A7%D8%A6%D8%B5-%D8%A7%D9%84%D9%85%D9%86%D8%B7%D9%82%D9%8A%D8%A9-%D9%88-rtl/)[![](https://arabroot.io/content/images/2026/10/browser-internals-dom-cover-cover.webp)دليلكيف يرسم المتصفح صفحتك؟ من HTML إلى البكسلاتماذا يفعل المتصفح بين وصول HTML وظهور الصفحة، ولماذا تتجمد صفحة فيها ألفا صف، وكيف تقيس كل مرحلة في Chrome وتصلحها.](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D9%8A%D8%B9%D9%85%D9%84-%D8%A7%D9%84%D9%85%D8%AA%D8%B5%D9%81%D8%AD-%D9%85%D9%86-%D8%A7%D9%84%D8%AF%D8%A7%D8%AE%D9%84-dom-%D9%88%D8%A7%D9%84%D8%AA%D8%AE%D8%B7%D9%8A%D8%B7-%D9%88%D8%A7%D9%84%D8%B1%D8%B3%D9%85/)[![](https://arabroot.io/content/images/2026/10/js-event-loop-cover-cover.webp)دليلحلقة الأحداث Event Loop: لماذا تتأخر النقرة في صفحتك؟كيف ترتب JavaScript المهام والوعود والرسم على خيط واحد، ولماذا تنتظر النقرة خلف مهمة طويلة، وكيف تقسم العمل لتبقى الصفحة سريعة.](https://arabroot.io/articles/%D8%AD%D9%84%D9%82%D8%A9-%D8%A7%D9%84%D8%A7%D8%AD%D8%AF%D8%A7%D8%AB-event-loop-%D9%81%D9%8A-javascript/)[![](https://arabroot.io/content/images/2026/10/encodings-cover-cover.webp)دليلالترميز Encoding للمطورين: Base64 وأخواتهلماذا نحول البايتات إلى حروف حتى تمر في البريد والروابط، وكيف تفك أي سلسلة منها، ولماذا لا يحمي Base64 أي سر.](https://arabroot.io/articles/%D8%A7%D9%84%D8%AA%D8%B1%D9%85%D9%8A%D8%B2-base64-%D9%88-base32-%D9%88-base58/)[![](https://arabroot.io/content/images/2026/10/accessibility-basics-cover-cover.webp)دليلسهولة الوصول Accessibility: موقع وتطبيق يستخدمه الجميعكيف تبني صفحات وتطبيقات يستخدمها الجميع بلوحة المفاتيح وقارئ الشاشة، من عناصر HTML الأصلية إلى النوافذ والنماذج العربية.](https://arabroot.io/articles/%D8%B3%D9%87%D9%88%D9%84%D8%A9-%D8%A7%D9%84%D9%88%D8%B5%D9%88%D9%84-accessibility-%D9%83%D9%8A%D9%81-%D8%AA%D8%A8%D9%86%D9%8A-%D9%85%D9%88%D9%82%D8%B9%D8%A7-%D9%8A%D8%B3%D8%AA%D8%AE%D8%AF%D9%85%D9%87-%D8%A7%D9%84%D8%AC%D9%85%D9%8A%D8%B9/)[![](https://arabroot.io/content/images/2026/10/interview-frontend-cover-cover.webp)دليلأسئلة مقابلة مطور Frontend من المبتدئ إلى الخبيربنك أسئلة لمقابلة مطور Frontend، لكل سؤال إجابة المبتدئ وإجابة الخبير وأسئلة متابعة، مع تمارين عملية شغلناها فعلاً.](https://arabroot.io/articles/%D8%A7%D8%B3%D8%A6%D9%84%D8%A9-%D9%85%D9%82%D8%A7%D8%A8%D9%84%D8%A9-%D9%85%D8%B7%D9%88%D8%B1-frontend-%D9%85%D9%86-%D8%A7%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6-%D8%A7%D9%84%D9%89-%D8%A7%D9%84%D8%AE%D8%A8%D9%8A%D8%B1/)

## سجل التحديثات

- أكتوبر 2026: كتابة المقال، وتنفيذ التجربة في Node.js 22.23.4 مع ICU 78.3 وCLDR 48.0 داخل Container من node:22-alpine، وفي Chrome 156 بدون واجهة.