لماذا تُفسد طابعات ESC/POS الحروف العربية، وكيف تطبعها صورةً أحادية البت بعرض الرأس الحقيقي، وخلل الأبعاد الذي حوّل إيصالًا طوله 12 سم إلى رولٍ كامل ملقى على الأرض.
كيف أُبقي وحدي نظامًا محاسبيًا من 322 ألف سطر صحيحًا: بوابة حفظ واحدة، وقيود مالية لا تُعدَّل، وسجل تدقيق ببصمات متسلسلة، واختبارات على قواعد حقيقية، وتدقيق كشف ما فاتها.
فوترة محطات الكهرباء دون اتصال: معرّف واحد يرافق الدفعة من الهاتف إلى الإيصال، ولماذا لا تُجمع إجماليات الفواتير، وطابور يعرف ما يعيده، وخطأ 500 صنع حلقة لا تنتهي.
العنوان الإنجليزي فكّ تشفيره بجمال: رموز برمجية تومض أمام مؤشر، وكل حرف يستقر في مكانه خلفه.
ثم حوّلت الصفحة إلى العربية.
قبل أن ينتهي التأثير، تعثّرت عيني بكلمة واحدة: «كاملًا». الـ«لا» في آخرها، الشكل الذي يعرفه كل قارئ عربي من أول نظرة، وقفت حرفين منفصلين. وبعد ثانية مرّت اللمعة الذهبية على العنوان، فبدت كل كلمة مكررة مرتين.
كلمة واحدة فيها خطآن، وخطأ ثالث يمتد على السطر كله. والحل الذي يمد إليه كل مطوّر يده، Intl.Segmenter، يعالج واحدًا منها فقط.
وحين أعدت بناء الخطأ في صفحة اختبار من أجل هذا المقال، صار أغرب: العناصر نفسها كانت سليمة في فقرة عادية. احتاج كسرُها إلى خاصية CSS واحدة في عنواني.
في آخر المقال ستعرف ما هذه الخاصية، ولماذا الكلمة أصغر وحدة يمكنك تحريكها بأمان في العربية، وكيف تعيد السطر نصًا عاديًا فينطبق عليه كل ما فوقه بالبكسل، وستخرج بمكوّن جاهز للنسخ.
Array.from() يقسّم النص حسب نقطة الترميز (code point). و«كاملًا» فيها ست نقاط:
js
const hex = (s) => [...s].map((c) => c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0'))hex('كاملًا')// ['0643', '0627', '0645', '0644', '064B', '0627'] ست نقاط ترميز، ستة عناصر
الـ 064B هو التنوين، الحركة التي تعلو اللام. ولأنه نقطة ترميز مستقلة، أخذ عنصرًا مستقلًا، بعيدًا عن الحرف الذي ينتمي إليه.
ولا تفترض أن نصوصك خالية من الحركات. عربية الواجهات تُسقط معظمها، لكن تنوين الفتح في كلمات مثل «كاملًا» و«أيضًا» من صلب الإملاء، والشدّة تظهر في أسماء مثل «محمّد».
ما يراه القارئ حرفًا واحدًا هو عنقود حروف (grapheme cluster)، والمتصفح يقسّمه لك:
js
const seg = new Intl.Segmenter(undefined, { granularity: 'grapheme' })Array.from(seg.segment('كاملًا'), (part) => hex(part.segment).join('+'))// ['0643', '0627', '0645', '0644+064B', '0627'] خمسة عناقيد: الحركة ترافق لامها
الخطأ الأول انحلّ. والآن انظر إلى آخر عنصرين.
#الخطأ الثاني: «لا» انشقّت، وIntl.Segmenter عاجز عن منعها
0644+064B هي اللام بحركتها، و0627 هي الألف. عنقودان، أي عنصران ما زالا منفصلين.
اللام متبوعة بالألف هي الحرف المركّب (ligature) الوحيد الذي تفرضه العربية. يحتفظ Unicode بالشكل الجاهز للتوافق فقط (ﻻ، U+FEFB). أما النص الحقيقي فيخزّن حرفين، والخط يرسمهما شكلًا واحدًا حين يشكّلهما معًا.
عناقيد الحروف تصف ما يراه القارئ حرفًا واحدًا. والحرف المركّب أكبر من ذلك: حرفان يدمجهما الخط في شكل واحد. ولا تقسيم بالعناقيد يستطيع إبقاءه سليمًا، لأنه يمتد على عنقودين.
فمتى يظهر الانشقاق فعلًا؟ أعدت بناء العنوان في صفحة اختبار (خط IBM Plex Sans Arabic بوزن 600، في Chrome 153 وEdge 155، وقد تطابقا بالبكسل) وقست السطر في خمسة أوضاع:
النص
CSS إضافي
«لا»
أعرض سطر
نص عادي
text-wrap: balance
شكل واحد
415.7px
عنصر لكل كلمة
text-wrap: balance
شكل واحد
415.7px
عنصر لكل عنقود
لا شيء
شكل واحد
في حدود 0.4px من النص العادي
عنصر لكل عنقود
text-wrap: balance
حرفان
458.9px
عنصر لكل عنقود
display: inline-block
حرفان، ولا اتصال إطلاقًا
أعرض بـ 33%
في سطر عادي، يشكّل Chrome الحروف عبر العناصر السطرية فتنجو الكلمة. لكن عنواني كان عليه text-wrap: balance، ومعها فقدت العناصر نفسها الحرف المركّب، وعددًا من نقاط الاتصال، و43 بكسلًا من العرض. وصفّ inline-block هو ما تحصل عليه من أي تأثير يحرّك الحروف واحدًا واحدًا، لأن الـ transform يحتاج صندوقًا: فيقف كل حرف وحيدًا.
وحتى في الحالة التي تنجو فيها الكلمة، هناك فخ. مع خطي، الحرف المركّب شكل واحد، وChrome يلوّنه بلون عنصر اللام. في اختباري ظهرت «لا» كاملة لحظة استقرار اللام، وما دامت اللام شفافة بقيت الألف مختفية معها. نصف شكل لا يمكن تحريكه.
فوق عنواني لمعة ذهبية: نسخة ثانية من العنوان كنص عادي، يكشفها شريط متحرك. والنص العادي والعناصر المقسّمة صارا يختلفان بـ 43 بكسلًا في أعرض سطر، فتباعدت الحروف وتوازن السطر بطريقتين، ولم تعد الحروف الذهبية فوق البيضاء.
نص عادي (النسخة الذهبية)415.7px
عنصر لكل كلمة415.7px
عنصر لكل عنقود458.9px
عرض أعرض سطر في العنوان العربي مع text-wrap: balance
الكلمة أصغر قطعة من العربية تحمل تشكيلها كاملًا: اتصال حروفها، وحركاتها، وحروفها المركّبة. لذا تُفك العربية كلمةً كلمة. أما الإنجليزية فتبقى حرفًا حرفًا، لأنها تُقرأ جيدًا بالحروف اللاتينية ولا تكلّف شيئًا إضافيًا.
ts
const ARABIC = /\p{Script=Arabic}/uconst byWord = ARABIC.test(text)// المسافات تبقى نصًا عاديًا بين الكلمات.const units = byWord ? text.split(/( )/).filter(Boolean) : graphemes(text)
موقعي يفحص كتلة العربية الأساسية، من U+0600 إلى U+06FF، وهي تغطي نصوصه. أما \p{Script=Arabic} فهو الخيار الأسلم لنص لا تتحكم فيه: يطابق أيضًا الكتل الملحقة والموسّعة وأشكال العرض.
وأي نص فيه حرف عربي واحد يُفك بالكلمات، بما فيها الكلمات اللاتينية. «مهندس برمجيات Full Stack» تُفك أربع كلمات.
احتاج الإيقاع إلى تعديلين، وكلاهما في المكوّن أدناه:
الحروف اللاتينيةالحروف العربية
عنقود واحد في كل خطوةكلمة واحدة في كل خطوة
20 ms لكل خطوة60 ms لكل خطوة: ثلاث خطوات لكل كلمة
رمز واحد أثناء الانتظارمن 2 إلى 7 رموز، بطول الكلمة تقريبًا
عنواني: 49 حرفًاعنواني: 8 كلمات
التأثير نفسه، بوحدتين
الكلمة تأخذ ثلاث خطوات، فلا تمر خاطفة في زمن حرف واحد. وأثناء انتظارها تُظهر دفعة رموز بطولها تقريبًا، فيحتفظ السطر بنسيجه.
العناصر المقسّمة بالكلمات تطابق النص العادي في حدود 0.03px في صفحة اختباري. والنص العادي يطابق نفسه تمامًا، بعقدة نصية واحدة بدل عنصر لكل كلمة. لذا حين ينتهي التأثير، تختفي العناصر كلها.
العنوان المستقر صار يُشكَّل تمامًا كنص عادي، بحروفه المركّبة، واللمعة الذهبية، وهي نص عادي أيضًا، تنطبق عليه بالبكسل.
tsx
if (settled) return <span className="decode is-done">{text}</span>
اللمعة تنتظر هذه اللحظة بكلاس، ولا تنتظرها بـ :has() أبدًا. ولماذا ضاعف :has() هناك انشغال المعالج والصفحة ساكنة، تجده في مقال الأداء.
واللمعة نفسها حركتا transform. شريط بعرض 30% من العنوان ينزلق في اتجاه، وداخله نسخة نصية بعرض العنوان كله تنزلق عكسه بالمسافة نفسها وعلى الساعة نفسها، فتبقى الحروف الذهبية فوق البيضاء. وهندستها تستخدم left الفيزيائي عمدًا: في العربية يجب أن ينقلب النص الذي داخلها فقط، ويبقى مسار الشريط كما هو.
فك التشفير يسير من اليمين إلى اليسار في العربية دون حالة خاصة واحدة.
الوحدات بترتيب الـ DOM، وهو الترتيب المنطقي: الترتيب الذي تكتب به وتقرأ. الفهرس 0 هو أول كلمة يراها القارئ، على اليمين. والحلقة تمشي على الفهارس، فيتحرك المؤشر كما تتحرك عين القارئ.
وطبقة الرموز لا تحتاج قواعد اتجاه هي الأخرى. تجلس عند left: 50% مع translate: -50% 0، في منتصف وحدتها أيًّا كان الاتجاه.
الأرقام هي الموضع الوحيد الذي يستحق التصريح. الأرقام داخل النص العربي تجري من اليسار إلى اليمين، لذا تجلس الأرقام تحت عنواني في عناصرها الخاصة بـ dir="ltr"، وتُفك عبر رموز أرقام بدل الرموز البرمجية.
هذا التأثير يشغّل حلقة requestAnimationFrame، وهو ما يعدّه مقال الأداء علامة خطر. الفرق أن هذه الحلقة تنتهي.
حلقة واحدة لكل نص، لا تلمس إلا الوحدات داخل نافذة التبدّل. تخرج عند أول وحدة بعد النافذة، وتتوقف حين تستقر آخر وحدة.
رموز جديدة كل 60 ms، نحو 16 مرة في الثانية: سريعة بما يكفي لتبدو تشويشًا، وبطيئة بما يكفي لتُرى الرموز.
الوحدات التي تتبدل في تلك اللحظة فقط تأخذ position: relative، وهي ما يحتاجه الرمز. العنصر المتموضع يصير طبقة رسم مستقلة، ومئة منها جعلت كل إطار من التأثير ثقيلًا.
أطوال دفعات الرموز للكلمات العربية تُحسب مرة واحدة. تشغيل Intl.Segmenter مع كل تبديل لكل كلمة منتظرة ظهر في الـ profile.
ومع تفضيل تقليل الحركة (reduced motion)، يستقر النص فورًا.
وبالقياس قبل/بعد على محاكاة هاتف بمعالج أبطأ أربع مرات، يكلّف التأثير كله نحو 0.35 ثانية من عمل الخيط الرئيسي موزعة على أول ثانيتين، ولا شيء بعدها.
فك التشفير يغيّر اللون فقط، لذا يتعايش مع العناصر السطرية. أما التأثيرات التي تحرّك الحروف فتحتاج صندوقًا لكل قطعة، وكما أظهر الجدول، inline-block لكل حرف يترك كل حرف عربي وحيدًا.
حرّك الكلمات إذن. الحروف العربية لا تتصل عبر مسافة أبدًا، فصندوق لكل كلمة لا يكلّف التشكيل شيئًا. الفقرة تحت عنواني تنساب بهذه الطريقة، كل كلمة ترتفع بعد سابقتها ببضع ملّي ثوانٍ:
tsx
import { Fragment, type CSSProperties } from 'react'/** كل كلمة ترتفع إلى مكانها بعد سابقتها ببضع ms. الكلمات تبقى كاملة، فتحتفظ العربية باتصالها. */export function StreamText({ text, delay = 0, step = 14 }: { text: string; delay?: number; step?: number }) { return ( <> {text.split(' ').map((word, i) => ( <Fragment key={`${text.length}-${i}`}> {i > 0 && ' '} <span className="sw" style={{ ['--sw' as string]: `${delay + i * step}ms` } as CSSProperties}> {word} </span> </Fragment> ))} </> )}
حركة CSS تعمل مرة واحدة، بالشفافية والـ transform فقط، ولا سكربت بعد أول render. وعلامات الترقيم تبقى ملتصقة بكلمتها، لأن التقسيم على المسافات: «كاملًا،» ترتفع قطعة واحدة مع فاصلتها.
التعليقات
شارك في النقاش
سجّل الدخول بحساب Google لتعجب بالمقالات وتعلّق عليها. ضغطة واحدة تكفي.