<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
	<channel>
		<title>حسوب I/O - مساهمات المستخدم kirnu</title>
		<description>المساهمات التي أرسلها kirnu - حسوب I/O</description>
		<language>ar</language>
		<generator>حسوب I/O</generator>
		<item>
			<title>البحث في النص العربي: لماذا لا يجد البحث عن «احمد» كلمة «أحمد»، وكيف تصلح ذلك في JavaScript</title>
			<pubDate>Fri, 09 Oct 2026 08:50:13 +0000</pubDate>
			<link>https://io.hsoub.com/programming/185812-%D8%A7%D9%84%D8%A8%D8%AD%D8%AB-%D9%81%D9%8A-%D8%A7%D9%84%D9%86%D8%B5-%D8%A7%D9%84%D8%B9%D8%B1%D8%A8%D9%8A-%D9%84%D9%85%D8%A7%D8%B0%D8%A7-%D9%84%D8%A7-%D9%8A%D8%AC%D8%AF-%D8%A7%D9%84%D8%A8%D8%AD%D8%AB-%D8%B9%D9%86-%D8%A7%D8%AD%D9%85%D8%AF-%D9%83%D9%84%D9%85%D8%A9-%D8%A3%D8%AD%D9%85%D8%AF-%D9%88%D9%83%D9%8A%D9%81-%D8%AA%D8%B5%D9%84%D8%AD-%D8%B0%D9%84%D9%83-%D9%81%D9%8A-javascript</link>
			<description><![CDATA[يكتب المستخدم في خانة البحث «احمد» فلا تظهر نتيجة، مع أن الاسم موجود في الصفحة بصيغة «أحمد». أو يبحث عن «مدرسه» والنص فيه «مدرسة»، أو عن «محمد» والنص مشكول «مُحَمَّد» أو مكتوب بالتطويل «مـحـمـد». بالنسبة للمستخدم هي الكلمة نفسها، أما دوال المقارنة في JavaScript فتراها نصوصاً مختلفة. في هذا المقال أشرح أربعة فخاخ شائعة في البحث داخل النص العربي بـ JavaScript، ثم أبني دالة توحيد للبحث، ودالة تجد مواضع التطابق لتلوينها، وأخيراً متى لا يصح التوحيد. إفصاح: أعمل على مكتبة مفتوحة المصدر للنصوص العربية فيها دوال للتوحيد، أذكرها باختصار في آخر المقال. كل الأمثلة قبل ذلك بـ JavaScript فقط دون مكتبات. الفخ الأول: includes تقارن المحارف كما هي &#39;محمد أحمد&#39;.includes(&#39;احمد&#39;); // false &#39;مُحَمَّد&#39;.includes(&#39;محمد&#39;); // false &#39;مـحـمـد&#39;.includes(&#39;محمد&#39;); // false includes وindexOf والتعابير النمطية تبحث عن تطابق المحارف كما هي، دون أي توحيد خاص بالعربية: «أ» (U+0623) غير «ا» (U+0627)، وعلامات التشكيل محارف مستقلة تُدرج بين الحروف، وكذلك التطويل «ـ» (U+0640). ويختلف المستخدمون في كتابة الهمزات والتاء المربوطة والألف المقصورة، فيفشل البحث الحرفي كثيراً. الفخ الثاني: كلمتان متطابقتان في الشكل ومختلفتان في المحارف const a = &#39;أحمد&#39;; const b = &#39;ا\u0654حمد&#39;; // ألف + همزة فوق (U+0654) منفصلة a === b; // false a === b.normalize(&#39;NFC&#39;); // true قد يصلك الحرف «أ» محرفاً واحداً، أو محرفين: ألفاً ثم علامة همزة. الشكل على الشاشة واحد، لكن المقارنة تفشل. يحدث هذا مع نصوص منسوخة من بعض البرامج أو أسماء ملفات من بعض الأنظمة، وnormalize(&#39;NFC&#39;) يوحّد هذه الصيغ المتكافئة في Unicode. وهناك حالة أخرى لا يكفي فيها NFC: النص المنسوخ من بعض ملفات PDF قد يأتي بـ«صور العرض» (Presentation Forms)، وهي محارف مخصّصة لبعض الأشكال الطباعية للحروف العربية ولتركيبات حرفية معيّنة: const fromPdf = &#39;\uFEE3\uFEA4\uFEE4\uFEAA&#39;; // «محمد» بصور العرض fromPdf === &#39;محمد&#39;; // false fromPdf.normalize(&#39;NFC&#39;) === &#39;محمد&#39;; // false fromPdf.normalize(&#39;NFKC&#39;) === &#39;محمد&#39;; // true NFKC يعيدها إلى حروفها الأساسية، لكنه يغيّر أيضاً محارف أخرى (مثل ﷺ التي تصبح جملة كاملة)، فنستعمله في مفتاح البحث فقط، كما سيأتي. الفخ الثالث: Intl.Collator يساعد في المقارنة لا في البحث قد يبدو Intl.Collator الحل، فهو يقارن النصوص بحسب قواعد اللغة. عند التشغيل على Node 24.21 / ICU 78.3: const collator = new Intl.Collator(&#39;ar&#39;, { sensitivity: &#39;base&#39; }); collator.compare(&#39;أحمد&#39;, &#39;احمد&#39;); // 0 collator.compare(&#39;مُحَمَّد&#39;, &#39;محمد&#39;); // 0 collator.compare(&#39;مـحـمـد&#39;, &#39;محمد&#39;); // 0 collator.compare(&#39;على&#39;, &#39;علي&#39;); // 0 collator.compare(&#39;مدرسة&#39;, &#39;مدرسه&#39;); // -1 0 تعني متساويتين. هو مفيد للترتيب ولمقارنة كلمتين كاملتين، لكن فيه ثلاث مشكلات للبحث:  يقارن نصاً كاملاً بنص كامل، ولا يبحث عن كلمة داخل نص. ليس في JavaScript دالة بحث مبنية عليه.  قواعده ليست قواعدك: هنا يعامل «على» و«علي» كأنهما واحدة، لكنه يفرّق بين «مدرسة» و«مدرسه».  نتائجه تعتمد على إصدار ICU في المتصفح أو الخادم، فقد تختلف بين بيئة وأخرى.  الفخ الرابع: محارف لا تُرى النص المنسوخ من صفحات أو من تطبيقات المراسلة قد يحمل محارف غير مرئية داخل الكلمة، مثل علامات الاتجاه (U+200F وU+061C) أو المسافة ذات العرض الصفري (U+200B): &#39;محمد\u200F&#39;.includes(&#39;محمد&#39;); // true &#39;مح\u200Bمد&#39;.includes(&#39;محمد&#39;); // false الأولى نجحت لأن العلامة جاءت في آخر الكلمة، والثانية فشلت لأن المحرف غير المرئي في وسطها. لن يراه المستخدم، ولن يفهم لماذا لا تظهر النتيجة. الحل: مفتاح بحث موحّد الفكرة: لا تبحث في النص كما هو. احسب لكل نص «مفتاح بحث» موحّداً، واحسب المفتاح نفسه لعبارة البحث، ثم قارن المفتاحين. أما النص الأصلي فيبقى للعرض. القواعد التالية سياسة بحث مناسبة لأغلب النصوص العربية العامة، لا قائمة شاملة، فعدّلها بحسب تطبيقك: // تُحذف من المفتاح: علامات التشكيل (ومنها الهمزة المنفصلة U+0654 وU+0655)، والتطويل، // ومحارف مختارة غير مرئية أو للتحكم في الاتجاه const DROP = /[\u064B-\u065F\u0670\u0640\u200B-\u200F\u061C\u202A-\u202E\u2066-\u2069]/; // تُوحَّد: صور الألف، والألف المقصورة، والتاء المربوطة، والكاف الفارسية (ک) والياء الفارسية (ی) const MAP = { &#39;أ&#39;: &#39;ا&#39;, &#39;إ&#39;: &#39;ا&#39;, &#39;آ&#39;: &#39;ا&#39;, &#39;ٱ&#39;: &#39;ا&#39;, &#39;ى&#39;: &#39;ي&#39;, &#39;ة&#39;: &#39;ه&#39;, &#39;ک&#39;: &#39;ك&#39;, &#39;ی&#39;: &#39;ي&#39; }; // مفتاح محرف واحد: NFKC يعيد صور العرض إلى حروفها، ثم الحذف والتوحيد function keyOf(ch) { let out = &#39;&#39;; for (const c of ch.normalize(&#39;NFKC&#39;)) { if (!DROP.test(c)) out += MAP[c] ?? c; } return out; } function searchKey(text) { let key = &#39;&#39;; for (const ch of text.normalize(&#39;NFC&#39;)) key += keyOf(ch); return key; } searchKey(&#39;أحمد&#39;); // &#39;احمد&#39; searchKey(&#39;مُحَمَّد&#39;); // &#39;محمد&#39; searchKey(&#39;مـحـمـد&#39;); // &#39;محمد&#39; searchKey(&#39;مدرسة&#39;); // &#39;مدرسه&#39; searchKey(&#39;مستشفى&#39;); // &#39;مستشفي&#39; searchKey(&#39;ا\u0654حمد&#39;); // &#39;احمد&#39; searchKey(&#39;مح\u200Bمد&#39;); // &#39;محمد&#39; searchKey(fromPdf); // &#39;محمد&#39; searchKey(&#39;ذهب محمد أحمد إلى المدرسة&#39;).includes(searchKey(&#39;احمد&#39;)); // true ملاحظات على الدالة:  تُطبَّق على الطرفين: النص وعبارة البحث. إن وحّدت طرفاً واحداً فقط فلن يتطابقا.  لا تُخزَّن بدل النص الأصلي، فهي تفقد معلومات: «مدرسه» في المفتاح قد تكون «مدرسة» أو «مدرسه» في الأصل.  لا تمس «ؤ» و«ئ» والهمزة المفردة «ء»، لأن حذف الهمزة منها يغيّر الكلمة أكثر مما يفيد البحث. وإن احتجت ذلك فأضفها إلى MAP بقرار واعٍ.  البحث هنا عن جزء من نص، فـ«علي» تطابق داخل «عليكم» أيضاً. إن أردت كلمات كاملة فقسّم النص إلى كلمات بطريقة مناسبة لتطبيقك، أو تحقق من حدود الكلمة حول التطابق.  NFKC داخل المفتاح يوحّد أيضاً محارف أخرى، مثل الأرقام والحروف اللاتينية كاملة العرض. ونطبّقه هنا محرفاً محرفاً حتى نحتفظ بالمواضع، وهذا يكفي لصور العرض العربية، لكنه ليس مطابقاً تماماً لتطبيق NFKC على النص كله.  تلوين النتائج المفتاح يختلف عن النص في الطول والمواضع (حُذفت منه الحركات والتطويل، وقد يطول بسبب NFKC)، فموضع التطابق في المفتاح ليس موضعه في النص. الحل أن نحفظ أثناء بناء المفتاح بداية ونهاية المحرف الذي جاء منه كل جزء من المفتاح. ونمرّ على النص محرفاً محرفاً بـ for...of، ونحسب المواضع بوحدات UTF-16 لأن slice وindexOf يعتمدان عليها: // يعيد مواضع التطابق [البداية، النهاية) في النص بعد NFC، ومعها المحارف المحذوفة المتتالية بعد آخر حرف (كعلامات التشكيل) function findMatches(text, query) { text = text.normalize(&#39;NFC&#39;); let key = &#39;&#39;; const starts = []; const ends = []; let i = 0; for (const ch of text) { const k = keyOf(ch); key += k; for (let u = 0; u &amp;lt; k.length; u++) { starts.push(i); ends.push(i + ch.length); } i += ch.length; } const q = searchKey(query); const matches = []; if (!q) return matches; for (let at = key.indexOf(q); at !== -1; at = key.indexOf(q, at + q.length)) { const start = starts[at]; let end = ends[at + q.length - 1]; while (end &amp;lt; text.length &amp;amp;&amp;amp; DROP.test(text[end])) end++; // لا نفصل الحركة عن حرفها const prev = matches[matches.length - 1]; if (prev &amp;amp;&amp;amp; start &amp;lt; prev[1]) prev[1] = Math.max(prev[1], end); // تطابقان في المحرف نفسه (مثل ﷺ): ندمجهما else matches.push([start, end]); } return matches; } const escapeHtml = (s) =&amp;gt; s.replace(/[&amp;amp;&amp;lt;&amp;gt;&quot;&#39;]/g, (c) =&amp;gt; `&amp;amp;#${c.charCodeAt(0)};`); function highlight(text, query) { text = text.normalize(&#39;NFC&#39;); let html = &#39;&#39;; let last = 0; for (const [start, end] of findMatches(text, query)) { html += escapeHtml(text.slice(last, start)) + &#39;&amp;lt;mark&amp;gt;&#39; + escapeHtml(text.slice(start, end)) + &#39;&amp;lt;/mark&amp;gt;&#39;; last = end; } return html + escapeHtml(text.slice(last)); } highlight(&#39;قال مُحَمَّد: أهلاً يا محمد&#39;, &#39;محمد&#39;); // &#39;قال &amp;lt;mark&amp;gt;مُحَمَّد&amp;lt;/mark&amp;gt;: أهلاً يا &amp;lt;mark&amp;gt;محمد&amp;lt;/mark&amp;gt;&#39; highlight(&#39;ذهبت إلى المـدرسـة&#39;, &#39;مدرسه&#39;); // &#39;ذهبت إلى ال&amp;lt;mark&amp;gt;مـدرسـة&amp;lt;/mark&amp;gt;&#39; highlight(&#39;😀 مُحَمَّد&#39;, &#39;محمد&#39;); // &#39;😀 &amp;lt;mark&amp;gt;مُحَمَّد&amp;lt;/mark&amp;gt;&#39; النتيجة تعرض النص بعد NFC بحركاته وتطويله، مع أن البحث تجاهلها (وNFC لا يغيّر شكل النص على الشاشة). والدالة تهرّب HTML قبل إدراج &amp;lt;mark&amp;gt;، لتقليل خطر حقن HTML عبر نص المستخدم. هذا التهريب يكفي لإدراج نص داخل عنصر كما هنا، لا داخل خصائص HTML أو روابط. في قاعدة البيانات لا تحسب المفتاح لكل سجل عند كل بحث. خزّنه في عمود مستقل عند الحفظ، وابحث فيه:  عمود name للعرض، وعمود name_search = searchKey(name)، ويُحدَّث كلما تغيّر الاسم.  وحّد عبارة البحث بالدالة نفسها قبل الاستعلام، في الخادم لا في المتصفح وحده، ومرّرها معاملاً في استعلام مُعدّ مسبقاً. وإن استعملت LIKE فهرّب % و_ في عبارة البحث حتى تُعامَلا محرفين عاديين.  الفهرس العادي يسرّع المطابقة التامة والبحث ببداية الكلمة، لكنه لا يسرّع LIKE &#39;%كلمة%&#39; عادة. للبحث عن كلمات في النصوص الطويلة فكّر في فهرس البحث النصي (Full-text)، أما البحث عن أي جزء من الكلمة فيحتاج عادة إلى فهرس n-gram أو trigram، بحسب قاعدة بياناتك.  إن غيّرت قواعد searchKey لاحقاً فأعد حساب العمود لكل السجلات، وإلا اختلف المفتاحان.  وفي أكاديمية حسوب مقال مفصّل عن حل المشكلة نفسها داخل MySQL بالـ collation والتعابير النمطية، عنوانه «تجاهل حساسية الأحرف العربية والتشكيل في أنظمة قواعد البيانات». متى لا تُوحّد التوحيد يزيد النتائج، وبعضها ليس ما يريده المستخدم:  «على» (حرف الجر) و«علي» (الاسم) تصيران مفتاحاً واحداً.  «حماة» (المدينة) و«حماه» تصيران مفتاحاً واحداً.  في نصوص القرآن الكريم أو الشعر المشكول قد يريد الباحث الحركات نفسها.  لذلك:  اجعل التوحيد للبحث فقط، وأبقِ النص الأصلي للعرض والتخزين.  رتّب النتائج: المطابق حرفياً أولاً، ثم المطابق بعد التوحيد. إن بحث المستخدم عن «على» فاعرض «على» قبل «علي».  وفّر خياراً للبحث الدقيق حين يكون للحركات أو للهمزات معنى.  الخلاصة  includes تطابق المحارف كما هي، وIntl.Collator يقارن نصوصاً كاملة ولا يبحث داخلها.  وحّد الصيغ بـ NFC، وصور العرض بـ NFKC داخل مفتاح البحث، ثم طبّق سياستك: حذف التشكيل والتطويل وبعض المحارف غير المرئية، وتوحيد صور الألف والتاء المربوطة والألف المقصورة.  طبّق المفتاح على النص وعلى عبارة البحث، واحفظ المواضع لتلوين النتائج.  خزّن المفتاح في عمود مستقل، ولا تستبدل به النص الأصلي.  للإفصاح: في مكتبة ‎@kirnu/arabic-core المفتوحة المصدر الدالتان normalizeArabic (لصور الألف والياء والتاء المربوطة والحرفين الفارسيين ک وی والتطويل، كل قاعدة بخيار مستقل) وremoveTashkeel (للتشكيل)، ومعهما اختبارات. قواعدهما قريبة من قواعد هذا المقال لكنها ليست مطابقة لها تماماً. الكود: https://github.com/getkirnu/arabic-core ولتجربة التوحيد على نص دون كتابة كود: https://getkirnu.com/ar/text/arabic-normalizer/?utm_source=hsoub سؤال للنقاش: ما القواعد التي تطبّقونها في البحث العربي في تطبيقاتكم؟ وهل توحّدون التاء المربوطة والألف المقصورة، أم تتركونهما؟]]></description>
		</item><item>
			<title>الأرقام العربية في نماذج الويب: لماذا يُرفض رقم جوال صحيح، وكيف تصلح ذلك في JavaScript</title>
			<pubDate>Thu, 08 Oct 2026 14:01:40 +0000</pubDate>
			<link>https://io.hsoub.com/webdev/185801-%D8%A7%D9%84%D8%A3%D8%B1%D9%82%D8%A7%D9%85-%D8%A7%D9%84%D8%B9%D8%B1%D8%A8%D9%8A%D8%A9-%D9%81%D9%8A-%D9%86%D9%85%D8%A7%D8%B0%D8%AC-%D8%A7%D9%84%D9%88%D9%8A%D8%A8-%D9%84%D9%85%D8%A7%D8%B0%D8%A7-%D9%8A%D8%B1%D9%81%D8%B6-%D8%B1%D9%82%D9%85-%D8%AC%D9%88%D8%A7%D9%84-%D8%B5%D8%AD%D9%8A%D8%AD-%D9%88%D9%83%D9%8A%D9%81-%D8%AA%D8%B5%D9%84%D8%AD-%D8%B0%D9%84%D9%83-%D9%81%D9%8A-javascript</link>
			<description><![CDATA[يكتب المستخدم رقم جواله بالأرقام العربية: ٠٥٠١٢٣٤٥٦٧، فيرفضه النموذج مع أنه صحيح تماماً. أو يقبله النموذج ويخزّنه كما هو، فيصبح في قاعدة البيانات رقمان «مختلفان» لشخص واحد: ‎0501234567 و‎٠٥٠١٢٣٤٥٦٧. السبب أن الأرقام العربية-الهندية (المشرقية) والأرقام الغربية تمثّل القيم نفسها، لكنها في الحاسوب محارف مختلفة. في هذا المقال أشرح من أين تأتي المشكلة، وأربعة فخاخ شائعة في JavaScript، ثم كيف توحّد الأرقام وتتحقق منها وتخزّنها بصيغة واحدة. إفصاح: أعمل على مكتبة مفتوحة المصدر للنصوص العربية فيها دالة لتحويل الأرقام، أذكرها باختصار في آخر المقال. كل الأمثلة قبل ذلك بـ JavaScript فقط دون مكتبات. ثلاث مجموعات من الأرقام  الأرقام الغربية 0–9: المحارف من U+0030 إلى U+0039.  الأرقام العربية-الهندية ٠–٩: من U+0660 إلى U+0669، وقد تُدخلها لوحات المفاتيح العربية أو إعدادات اللغة العربية في الجهاز.  الأرقام العربية-الهندية الممتدة (الفارسية والأردية) ۰–۹: من U+06F0 إلى U+06F9. تشبه العربية في أغلبها، لكن بعضها يختلف في الشكل، مثل ۴ و۵ و۶ (وفي الأردية ۷ أيضاً)، وكلها محارف مستقلة.  وترتبط بتنسيق الأرقام أيضاً علامات خاصة: الفاصلة العشرية العربية ٫ (U+066B)، وفاصل الآلاف العربي ٬ (U+066C)، وعلامة النسبة المئوية ٪ (U+066A). الفخ الأول: \d لا تطابق الأرقام العربية /^\d+$/.test(&#39;٠٥٠١٢٣٤٥٦٧&#39;); // false /^\d+$/u.test(&#39;٠٥٠١٢٣٤٥٦٧&#39;); // false في JavaScript تعني \d الأرقام الغربية 0–9 فقط، حتى مع الخيار u. لذلك يرفض كل تحقق مبني على \d رقماً كُتب بالأرقام العربية. الفخ الثاني: Number وparseInt تعيدان NaN Number(&#39;١٢٣&#39;); // NaN parseInt(&#39;١٢٣&#39;); // NaN parseFloat(&#39;١٢٫٥&#39;); // NaN Number(&#39;12٣&#39;); // NaN لا تحوّل JavaScript الأرقام العربية إلى قيمة عددية، ويكفي رقم عربي واحد داخل رقم غربي ليفشل التحويل كله. الفخ الثالث: ‎\p{Nd} تقبل أكثر مما تريد قد يبدو الحل في \p{Nd} (أي رقم عشري في Unicode): /^\p{Nd}+$/u.test(&#39;٠٥٠١٢٣٤٥٦٧&#39;); // true /^\p{Nd}+$/u.test(&#39;৫৫৫&#39;); // true (أرقام بنغالية) هي تقبل الأرقام العربية فعلاً، لكنها تقبل أيضاً أرقام أنظمة كتابة كثيرة أخرى: 770 محرفاً من 77 مجموعة أرقام في Node 24. ولا تحوّل شيئاً: ما زال Number يعيد NaN بعدها. التحقق وحده لا يكفي، والصحيح أن توحّد الأرقام أولاً  ثم تتحقق منها. الفخ الرابع: علامات اتجاه غير مرئية عند نسخ رقم من صفحة عربية، أو من ناتج Intl، قد تأتي معه علامات تحكم بالاتجاه لا تُرى. عند التشغيل على Node 24.21 / ICU 78.3: const date = new Intl.DateTimeFormat(&#39;ar-SA&#39;, { timeZone: &#39;UTC&#39; }).format(new Date(Date.UTC(2024, 2, 11))); date; // &#39;١١‏/٣‏/٢٠٢٤&#39; date.includes(&#39;‏&#39;); // true: علامة الاتجاه من اليمين إلى اليسار (RLM) بعد اليوم والشهر const percent = new Intl.NumberFormat(&#39;ar-EG&#39;, { style: &#39;percent&#39; }).format(0.25); percent.includes(&#39;؜&#39;); // true: علامة الحرف العربي (ALM) بعد علامة النسبة المئوية قد تُفشل هذه العلامات تحققاً صارماً يتوقع أرقاماً فقط مثل ^...$، مع أن النص يبدو سليماً على الشاشة. في حقول الأرقام يمكنك حذفها قبل التحقق، أو رفضها صراحة بحسب سياسة الإدخال لديك. أما في النص العربي العام فلا تحذفها، فهي تضبط ترتيب الكلمات حين تختلط العربية بالإنجليزية. الحل: وحّد الأرقام، ثم تحقق، ثم خزّن صيغة واحدة التوحيد والتحقق خطوتان مختلفتان: التوحيد يغيّر طريقة تمثيل القيمة، أما التحقق فيقرر هل القيمة مقبولة في تطبيقك. الخطوة الأولى دالة لا تفعل إلا التوحيد: تحوّل الأرقام العربية والفارسية إلى غربية، وتحذف علامات الاتجاه (لحقول الأرقام فقط): // لحقول الأرقام: يحذف علامات الاتجاه ويحوّل ٠–٩ و۰–۹ إلى 0–9، ولا يمس أي محرف آخر. function normalizeDigits(input) { return input .replace(/[‎‏؜‪-‮⁦-⁩]/g, &#39;&#39;) // علامات الاتجاه .replace(/[٠-٩]/g, (d) =&amp;gt; String(d.charCodeAt(0) - 0x0660)) // ٠–٩ → 0–9 .replace(/[۰-۹]/g, (d) =&amp;gt; String(d.charCodeAt(0) - 0x06f0)); // ۰–۹ → 0–9 } normalizeDigits(&#39;٠٥٠١٢٣٤٥٦٧&#39;); // &#39;0501234567&#39; normalizeDigits(&#39;٠50١٢٣٤٥٦٧&#39;); // &#39;0501234567&#39; (أرقام مختلطة) normalizeDigits(&#39;۰۵۰&#39;); // &#39;050&#39; أما الفواصل فلا تحذفها بلا تحقق، حتى لا يصبح ١٬٢ مثلاً 12 بصمت. للمبالغ والأعداد العشرية تحقق من الصيغة أولاً: // مبلغ مثل ١٬٢٥٠٫٥ أو 1250.5: يقبل فواصل الآلاف في مواضعها الصحيحة فقط، ويعيد null لغير ذلك. function parseAmount(raw) { const s = normalizeDigits(raw).replace(/٫/g, &#39;.&#39;).replace(/٬/g, &#39;,&#39;); if (!/^(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?$/.test(s)) return null; return Number(s.replace(/,/g, &#39;&#39;)); } parseAmount(&#39;١٬٢٥٠٫٥&#39;); // 1250.5 parseAmount(&#39;١٢٫٥&#39;); // 12.5 parseAmount(&#39;١٬٢&#39;); // null وفي أرقام الجوال لا يكفي توحيد الأرقام: ‎0501234567 و‎+966501234567 و‎00966501234567 رقم واحد بثلاث صيغ. لذلك حوّله إلى صيغة دولية واحدة (E.164) وخزّنها: // رقم جوال سعودي بصيغة محلية أو دولية → &#39;+9665XXXXXXXX&#39;، أو null إن لم تطابق الصيغة. // نسمح بالمسافات والشرطات لأنها تنسيق فقط. function toSaudiE164(raw) { const s = normalizeDigits(raw).replace(/[\s-]/g, &#39;&#39;); if (/^05\d{8}$/.test(s)) return &#39;+966&#39; + s.slice(1); if (/^(?:\+|00)9665\d{8}$/.test(s)) return &#39;+966&#39; + s.replace(/^(?:\+|00)966/, &#39;&#39;); return null; } toSaudiE164(&#39;٠٥٠١٢٣٤٥٦٧&#39;); // &#39;+966501234567&#39; toSaudiE164(&#39;+٩٦٦ ٥٠ ١٢٣ ٤٥٦٧&#39;); // &#39;+966501234567&#39; toSaudiE164(&#39;00966501234567&#39;); // &#39;+966501234567&#39; toSaudiE164(&#39;0501234567‏&#39;); // &#39;+966501234567&#39; toSaudiE164(&#39;٠٥٠١٢٣٤٥&#39;); // null هذا مثال مبسّط يتحقق من شكل الرقم فقط، لا من أنه مخصّص لمشترك فعلاً أو ما زال يعمل. ثلاث قواعد عملية:   خزّن الصيغة الموحّدة  لا ما كتبه المستخدم: استخدم الأرقام الغربية للأعداد، وللجوال صيغة E.164، حتى لا يتكرر الرقم نفسه بصيغتين ولا يفشل البحث عنه.   وحّد وتحقق في الخادم أيضاً ، فالتحقق في المتصفح وحده يمكن تجاوزه، والطلبات قد تأتي من تطبيقات أخرى.   لا تعتمد على inputmode وحده:  الخاصية inputmode=&quot;numeric&quot; تطلب لوحة أرقام فقط، ولا تضمن نوع الأرقام التي تصلك.  عرض الأرقام: اختر نظام الأرقام صراحة الاتجاه المعاكس مهم أيضاً: إذا أردت عرض الأرقام بالعربية أو بالغربية، فلا تعتمد على الافتراضي. عند التشغيل على Node 24.21 / ICU 78.3 يعطي new Intl.NumberFormat(locale).format(1234567.89):  ar: ‏1,234,567.89  ar-SA وar-EG: ‏١٬٢٣٤٬٥٦٧٫٨٩  ar-AE: ‏1,234,567.89  ar-MA: ‏1.234.567,89 (النقطة للآلاف والفاصلة للكسر)  الأرقام الافتراضية تختلف من بلد لآخر، وقد تتغير بين إصدارات ICU. لذلك حدّد نظام الأرقام في وسم اللغة: new Intl.NumberFormat(&#39;ar-SA-u-nu-latn&#39;).format(1234567.89); // &#39;1,234,567.89&#39; new Intl.NumberFormat(&#39;ar-u-nu-arab&#39;).format(1234567.89); // &#39;١٬٢٣٤٬٥٦٧٫٨٩&#39; واستخدم هذا للعرض فقط: لا تحلّل نصاً منسّقاً لتعيده إلى رقم، بل احتفظ بالقيمة العددية الأصلية. الخلاصة  \d وNumber لا تفهمان الأرقام العربية، و\p{Nd} تقبل أكثر من اللازم ولا تحوّل شيئاً.  وحّد الأرقام أولاً، ثم تحقق من الصيغة، ثم خزّن صيغة واحدة (للجوال E.164)، في المتصفح وفي الخادم.  لا تحذف الفواصل بلا تحقق، ولا تحذف علامات الاتجاه إلا من حقول الأرقام.  عند العرض حدّد -nu-latn أو -nu-arab صراحة.  للإفصاح: في مكتبة ‎@kirnu/arabic-core المفتوحة المصدر دالة toWesternDigits لتحويل الأرقام العربية والفارسية (ومعها الفواصل بين الأرقام)، ومعها اختبارات. الكود: https://github.com/getkirnu/arabic-core ولمن يحتاج شرحاً مبسّطاً لغير المبرمجين عن الفرق بين الأرقام العربية والإنجليزية: https://getkirnu.com/ar/guides/arabic-english-numbers/?utm_source=hsoub سؤال للنقاش: هل تواجهون المشكلة نفسها في تطبيقاتكم؟ وأين توحّدون الأرقام: في المتصفح، أم في الخادم، أم في قاعدة البيانات نفسها؟]]></description>
		</item><item>
			<title>التاريخ الهجري في JavaScript: ما يقدّمه Intl وما لا يقدّمه، وأربعة أخطاء شائعة</title>
			<pubDate>Thu, 08 Oct 2026 12:27:30 +0000</pubDate>
			<link>https://io.hsoub.com/programming/185798-%D8%A7%D9%84%D8%AA%D8%A7%D8%B1%D9%8A%D8%AE-%D8%A7%D9%84%D9%87%D8%AC%D8%B1%D9%8A-%D9%81%D9%8A-javascript-%D9%85%D8%A7-%D9%8A%D9%82%D8%AF%D9%85%D9%87-intl-%D9%88%D9%85%D8%A7-%D9%84%D8%A7-%D9%8A%D9%82%D8%AF%D9%85%D9%87-%D9%88%D8%A3%D8%B1%D8%A8%D8%B9%D8%A9-%D8%A3%D8%AE%D8%B7%D8%A7%D8%A1-%D8%B4%D8%A7%D8%A6%D8%B9%D8%A9</link>
			<description><![CDATA[التقويم الهجري الرسمي في السعودية هو تقويم أم القرى ، وتواريخ مثل «1 رمضان 1448» تظهر في العقود والنماذج الحكومية والتقويمات الدراسية وأنظمة الموارد البشرية في الخليج. تستطيع JavaScript عرض التاريخ الهجري دون أي مكتبة بفضل Intl، لكنها لا تقدّم دالة للاتجاه المعاكس (من الهجري إلى الميلادي)، وفيها بعض الفخاخ التي قد تؤدي إلى الخطأ الشائع: ظهور التاريخ متقدماً أو متأخراً بيوم واحد. إفصاح: أعمل على مكتبة مفتوحة المصدر للتاريخ الهجري والنصوص العربية، أذكرها باختصار في آخر المقال. جميع الأمثلة قبل ذلك تستخدم Intl المدمج فقط. من الميلادي إلى الهجري باستخدام Intl const fmt = new Intl.DateTimeFormat(&#39;en-u-ca-islamic-umalqura&#39;, { timeZone: &#39;UTC&#39;, year: &#39;numeric&#39;, month: &#39;numeric&#39;, day: &#39;numeric&#39;, }); function toHijri(date: Date) { const p = Object.fromEntries(fmt.formatToParts(date).map((x) =&amp;gt; [x.type, x.value])); return { year: parseInt(p.year), month: Number(p.month), day: Number(p.day) }; } toHijri(new Date(Date.UTC(2024, 2, 11))); // { year: 1445, month: 9, day: 1 } أي 1 رمضان 1445 استخدم formatToParts بدل تحليل النص الناتج، لأن ترتيب النص والقيم المعروضة يتغيران باختلاف اللغة ونظام الترقيم، أما مع formatToParts فتصل إلى السنة والشهر واليوم مباشرة من خلال نوع كل جزء (year وmonth وday). استخدمتُ timeZone: &#39;UTC&#39; في الأمثلة حتى تكون النتائج ثابتة ولا تعتمد على المنطقة الزمنية للجهاز الذي يشغّل الكود. في تطبيق حقيقي، استخدم المنطقة الزمنية التي ينتمي إليها التاريخ الذي تعرضه (انظر الخطأ الثالث). عرض التاريخ الهجري بالعربية لعرض التاريخ للمستخدم بأسماء الأشهر العربية، غيّر اللغة فقط: const d = new Date(Date.UTC(2024, 2, 11)); new Intl.DateTimeFormat(&#39;ar-SA-u-ca-islamic-umalqura&#39;, { timeZone: &#39;UTC&#39;, dateStyle: &#39;long&#39; }).format(d); // ١ رمضان ١٤٤٥ هـ new Intl.DateTimeFormat(&#39;ar-SA-u-ca-islamic-umalqura-nu-latn&#39;, { timeZone: &#39;UTC&#39;, dateStyle: &#39;long&#39; }).format(d); // 1 رمضان 1445 هـ الجزء -nu-latn يطلب الأرقام الغربية (0–9) بدل الأرقام المشرقية (٠–٩). استخدم هذا للعرض فقط، أما الحسابات والمقارنات فاعتمد فيها على formatToParts كما في الدالة السابقة. هذا هو الاتجاه المباشر. لكن هناك أربعة أخطاء شائعة قد تؤدي إلى نتيجة خاطئة. الخطأ الأول: «islamic» ليس تقويماً واحداً مكتبة ICU التي تعتمد عليها Intl فيها أكثر من تقويم هجري، ولا تتفق دائماً. هذه نتيجة كل منها ليوم 11 مارس 2024:  islamic-umalqura: ‏1 رمضان 1445  islamic: ‏1 رمضان 1445  islamic-civil: ‏1 رمضان 1445  islamic-tbla: ‏2  رمضان 1445  islamic-civil وislamic-tbla تقويمان حسابيان جدوليان، يعتمد كل منهما على دورة وقواعد ثابتة لتحديد السنوات الكبيسة وأطوال الأشهر. أما islamic-umalqura فيستخدم بيانات ICU لتقويم أم القرى المعتمد في السعودية. إذا كان مستخدموك في السعودية، أو كنت تطابق وثائق حكومية، فاطلب islamic-umalqura صراحة. الخطأ الثاني: الاعتماد على التقويم الافتراضي للغة من السهل أن تكتب new Intl.DateTimeFormat(&#39;ar-SA&#39;) وتتوقع تاريخاً هجرياً. في بيئتي الاختبارية (Node 24.21 / ICU 78.3) يعرض هذا يوم 11 مارس 2024 هكذا: ١١‏/٣‏/٢٠٢٤ ، أي تاريخاً ميلادياً، وقيمة resolvedOptions().calendar هي &quot;gregory&quot;. التقويم الافتراضي لكل لغة جزء من بيانات ICU، وهذه البيانات قد تتغير بين الإصدارات. لذلك ضع التقويم دائماً في وسم اللغة (-u-ca-islamic-umalqura) أو مرّر الخيار calendar: &#39;islamic-umalqura&#39;. الخطأ الثالث: المناطق الزمنية كائن Date يمثّل لحظة زمنية مطلقة، أما «1 رمضان» فتاريخ في التقويم يُستخرج من تلك اللحظة وفق منطقة زمنية وتقويم محددين. خذ منتصف ليلة 11 مارس 2024 بتوقيت الرياض: const d = new Date(&#39;2024-03-11T00:00:00+03:00&#39;); // مع timeZone: &#39;UTC&#39; ← 29 شعبان 1445 (ما زال 10 مارس بتوقيت UTC) // مع timeZone: &#39;Asia/Riyadh&#39; ← 1 رمضان 1445 اختر طريقة واحدة والتزم بها: إما أن تنشئ التواريخ بـ Date.UTC(...) وتعرضها دائماً بتوقيت UTC (وهذا ما تفعله الأمثلة هنا)، وإما أن تمرر المنطقة الزمنية للمستخدم في كل مرة. الخلط بين الطريقتين هو السبب وراء شكاوى «التاريخ متأخر بيوم». الخطأ الرابع: التقويم الرسمي ليس هو التاريخ المُعلن تقويم أم القرى تقويم محسوب مسبقاً. أما بداية رمضان والعيدين فتُعلن في السعودية رسمياً بعد نظر المحكمة العليا في شهادات رؤية الهلال، ولكل دولة جهتها وطريقتها في الإعلان. لذلك قد يختلف التاريخ المُعلن عن التاريخ المحسوب في أم القرى بيوم واحد، وقد يختلف من بلد لآخر. قد يكون تحويلك صحيحاً تماماً ومع ذلك لا يطابق التاريخ المُعلن، فوضّح ذلك في الواجهة حيث يهم الأمر (العدّ التنازلي، وإمساكية رمضان). من الهجري إلى الميلادي: الاتجاه الذي لا يدعمه Intl Intl.DateTimeFormat مخصص للتنسيق، ولا يوفّر واجهة تأخذ تاريخاً هجرياً مثل «1 رمضان 1448» وتعيد كائن Date. (والإجابة المنتشرة «استخدم Intl.DateTimeFormat مع تقويم هجري» تحل الاتجاه الآخر فقط.) لكن يمكن بناء هذا الاتجاه فوق Intl: نقدّر التاريخ تقديراً أولياً، ثم ننسّقه بالتقويم الهجري، ونتحرك يوماً بعد يوم حتى يطابق الناتج التاريخ المطلوب. الدالة التالية هي تطبيقي لهذا البحث: Intl يقوم بعمل التقويم، والحلقة تبحث عن اليوم الصحيح فقط. // من الهجري إلى الميلادي باستخدام Intl وحده: تقدير أولي، ثم تصحيح حتى يتطابق الناتج. function hijriToGregorianIntl(year: number, month: number, day: number): Date { // نقطة انطلاق تقريبية قرب بداية التقويم الهجري، ومتوسط السنة الهجرية ≈ 354.367 يوماً. // هذا تقدير أولي فقط، ثم تصححه الحلقة اعتماداً على Intl. const estimate = Date.UTC(622, 6, 16) + ((year - 1) * 354.367 + (month - 1) * 29.53 + (day - 1)) * 86_400_000; let t = Math.floor(estimate / 86_400_000) * 86_400_000; // منتصف الليل UTC، فتُعيد الدالة بداية اليوم for (let i = 0; i &amp;lt; 60; i++) { const h = toHijri(new Date(t)); const monthDiff = (year * 12 + month) - (h.year * 12 + h.month); // رقم متسلسل للشهر عبر السنوات const diff = monthDiff * 29.5 + (day - h.day); if (h.year === year &amp;amp;&amp;amp; h.month === month &amp;amp;&amp;amp; h.day === day) return new Date(t); t += Math.sign(diff) * Math.max(1, Math.round(Math.abs(diff))) * 86_400_000; } throw new RangeError(&#39;No such Hijri date in this calendar&#39;); } hijriToGregorianIntl(1448, 9, 1).toISOString(); // &#39;2027-02-08T00:00:00.000Z&#39; hijriToGregorianIntl(1446, 9, 30); // RangeError: رمضان 1446 كان 29 يوماً في تقويم أم القرى قارنتُ هذه الدالة بتطبيق يعتمد على جدول لكل تاريخ هجري صحيح من 1356 إلى 1500 هـ (51,383 تاريخاً، بما فيها كل انتقال من سنة إلى سنة): تطابقت كلها، بمتوسط استدعاءين تقريباً لـ Intl في كل تحويل، ولم تتجاوز ثلاثة استدعاءات قط. (Node 24.21 / ICU 78.3.) ولاحظ السطر الأخير: الشهر الهجري 29 أو 30 يوماً، ويختلف ذلك من سنة لأخرى (رمضان في تقويم أم القرى كان 30 يوماً في 1445، و29 في 1446، و30 في 1447). لذلك تحقق من صحة التاريخ الهجري المُدخل  ولا تفترض أن كل الأشهر 30 يوماً. دعم البيئات المختلفة، وحدود بيانات أم القرى تقويم islamic-umalqura يأتي من بيانات ICU في البيئة نفسها. النسخ الرسمية من Node تأتي مع بيانات ICU الكاملة افتراضياً منذ الإصدار 13، والمتصفحات الحالية تدعم التقاويم الهجرية عبر Intl، لكن النتيجة قد تختلف في بيئة مبنية بـ small-icu أو بنسخة ICU مخصصة، وقد تختلف بيانات التقويم بين إصدارات ICU. تحقق أولاً من: new Intl.DateTimeFormat(&#39;en-u-ca-islamic-umalqura&#39;).resolvedOptions().calendar; // &#39;islamic-umalqura&#39; إن كانت البيئة تدعمه وانتبه إلى أن دعم المعرّف لا يعني أن كل السنوات محسوبة ببيانات أم القرى: بيانات ICU لهذا التقويم تغطي تقريباً 1300–1600 هـ، وخارجها تُحسب التواريخ بالتقويم الحسابي islamic-civil بصمت، مع أن resolvedOptions() ما زالت تقول islamic-umalqura. في بيئتي الاختبارية مثلاً، تطابقت نتيجة التقويمين في كل يوم من 1850 إلى 1881 (قبل 1300 هـ). إن كان تطبيقك يتعامل مع تواريخ تاريخية قديمة، فلا تفترض أنها أم القرى. متى يكون الجدول أفضل من Intl؟ البحث السابق يعمل، لكن جدول التقويم أفضل في هذه الحالات:   عندما تحوّل تواريخ كثيرة:  في قياس محلي بسيط (Node 24.21 على حاسوب محمول بنظام Windows، وتحويل التواريخ الـ51,383 السابقة في حلقة)، استغرق بحث Intl نحو 15 ميكروثانية للتاريخ الواحد، والبحث في الجدول نحو 0.3 ميكروثانية. هذا ليس قياساً عاماً، وستختلف الأرقام عندك، لكن الفارق هو المهم.   عندما تحتاج نتيجة ثابتة لا تتغير باختلاف البيئة:  الجدول يعطي النتيجة نفسها في كل متصفح وكل إصدار من Node، مهما كانت بيانات ICU في كل بيئة.   عندما تحتاج أطوال الأشهر أو التحقق من المدخلات أو الفرق بين تاريخين  دون تجربة التواريخ على Intl.  للإفصاح: هذا ما دفعني إلى العمل على ‎@kirnu/arabic-core، وهي مكتبة TypeScript مفتوحة المصدر بلا اعتماديات، فيها جدول أم القرى من 1343 إلى 1500 هـ مع التحويل في الاتجاهين والتحقق من التواريخ، وقد طابقت islamic-umalqura في كل يوم فحصته من 1937 إلى 2077. الكود والاختبارات: https://github.com/getkirnu/arabic-core سؤال للنقاش: في مشاريعكم، هل تعتمدون على بيانات ICU في المتصفح، أم تضمّنون جدول تقويم ثابتاً عندما يكون اتساق النتيجة بين البيئات أهم من حجم الحزمة؟ وإن واجهتم خطأ في التاريخ الهجري لم أذكره هنا، شاركوه في التعليقات.]]></description>
		</item><item>
			<title>كيف تحسب ضريبة القيمة المضافة وتكتب المبلغ بالحروف دون أخطاء؟</title>
			<pubDate>Thu, 08 Oct 2026 08:16:06 +0000</pubDate>
			<link>https://io.hsoub.com/financial/185794-%D9%83%D9%8A%D9%81-%D8%AA%D8%AD%D8%B3%D8%A8-%D8%B6%D8%B1%D9%8A%D8%A8%D8%A9-%D8%A7%D9%84%D9%82%D9%8A%D9%85%D8%A9-%D8%A7%D9%84%D9%85%D8%B6%D8%A7%D9%81%D8%A9-%D9%88%D8%AA%D9%83%D8%AA%D8%A8-%D8%A7%D9%84%D9%85%D8%A8%D9%84%D8%BA-%D8%A8%D8%A7%D9%84%D8%AD%D8%B1%D9%88%D9%81-%D8%AF%D9%88%D9%86-%D8%A3%D8%AE%D8%B7%D8%A7%D8%A1</link>
			<description><![CDATA[خطآن يتكرران كثيراً في الفواتير والشيكات: حساب الضريبة من المبلغ الشامل بطريقة خاطئة، وكتابة المبلغ بالحروف بصيغة غير صحيحة. وكلاهما قد يسبب فرقاً في الحساب أو رفض شيك. هذه خلاصة عملية للحالتين، بأمثلة على نسبة ١٥٪ المعمول بها في السعودية. أولاً: إضافة الضريبة إلى السعر إذا كان السعر قبل الضريبة معروفاً، فالضريبة = السعر × النسبة، والإجمالي = السعر + الضريبة:  سعر قبل الضريبة ١٠٠٠ ريال ← الضريبة ١٥٠ ← الإجمالي ١١٥٠ ريالاً.  سعر قبل الضريبة ٨٧٠ ريالاً ← الضريبة ١٣٠٫٥٠ ← الإجمالي ١٠٠٠٫٥٠ ريال.  والنسبة تختلف من بلد لآخر: ٥٪ في الإمارات (١٠٠٠ يصبح ١٠٥٠)، و١٤٪ في مصر (١٠٠٠ يصبح ١١٤٠). ثانياً: استخراج الضريبة من سعر شامل لها (هنا يقع الخطأ) إذا كان المبلغ ١١٥٠ شاملاً للضريبة، فكثيرون يضربونه في ١٥٪ فيحصلون على ١٧٢٫٥٠، وهذا خطأ، لأن الـ١٥٪ محسوبة على السعر قبل  الضريبة لا على الإجمالي. الطريقة الصحيحة: السعر قبل الضريبة = المبلغ الشامل ÷ ١٫١٥  ١١٥٠ ÷ ١٫١٥ = ١٠٠٠ ← إذن الضريبة ١٥٠ ريالاً لا ١٧٢٫٥٠.  ومع الكسور: ٩٩٫٩٩ شاملة ← ٨٦٫٩٥ قبل الضريبة، والضريبة ١٣٫٠٤.  قرّب إلى هللتين (رقمين عشريين)، واحسب الضريبة على أنها الفرق بين الشامل وما قبل الضريبة، حتى يتطابق المجموع مع المبلغ الأصلي تماماً. ثالثاً: كتابة المبلغ بالحروف في الشيكات يُكتب المبلغ بالحروف بين «فقط» و«لا غير» حتى لا يُضاف شيء قبله أو بعده:  ١١٥٠ ريالاً ← «فقط ألف ومئة وخمسون ريالاً سعودياً لا غير»  ١٠٠٠٫٥٠ ريال ← «فقط ألف ريال سعودي وخمسون هللة لا غير»  ١٠٥٠ درهماً ← «فقط ألف وخمسون درهماً إماراتياً لا غير»  لاحظ أن صورة كلمة «ريال» تتغير حسب آخر جزء من الرقم:  من ٣ إلى ١٠: جمع ← «ثلاثة ريالات سعودية»  من ١١ إلى ٩٩: مفرد منصوب ← «خمسة عشر ريالاً سعودياً»، ولذلك ١١٥٠ تُكتب «ريالاً» لأنها تنتهي بخمسين  مئة فأكثر: مفرد مجرور ← «ألف ريال سعودي»، و«ألفا ريال سعودي» للألفين  والهللة مؤنثة، فيتغير العدد معها: ٠٫١٥ تُكتب «خمس عشرة هللة» لا «خمسة عشر». ملخص سريع  السعر قبل الضريبة × ١٫١٥ = الإجمالي.  الإجمالي ÷ ١٫١٥ = السعر قبل الضريبة، وليس الإجمالي × ١٥٪.  اكتب المبلغ بالحروف بين «فقط … لا غير»، وانتبه لصورة اسم العملة بعد الرقم.  لمن يريد التحقق السريع: أعمل على موقع كرنو، وفيه حاسبة ضريبة وكتابة المبلغ بالحروف مجاناً داخل المتصفح: https://getkirnu.com/ar/calculators/vat/?utm_source=hsoub وإن كانت لديكم حالات أخرى تسبب لبساً في الفواتير، شاركوها في التعليقات.]]></description>
		</item><item>
			<title>كيف تحول الأرقام إلى كلمات عربية برمجياً؟ شرح التفقيط وقواعد العدد والمعدود</title>
			<pubDate>Wed, 07 Oct 2026 20:19:29 +0000</pubDate>
			<link>https://io.hsoub.com/webdev/185788-%D9%83%D9%8A%D9%81-%D8%AA%D8%AD%D9%88%D9%84-%D8%A7%D9%84%D8%A3%D8%B1%D9%82%D8%A7%D9%85-%D8%A5%D9%84%D9%89-%D9%83%D9%84%D9%85%D8%A7%D8%AA-%D8%B9%D8%B1%D8%A8%D9%8A%D8%A9-%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D8%A7-%D8%B4%D8%B1%D8%AD-%D8%A7%D9%84%D8%AA%D9%81%D9%82%D9%8A%D8%B7-%D9%88%D9%82%D9%88%D8%A7%D8%B9%D8%AF-%D8%A7%D9%84%D8%B9%D8%AF%D8%AF-%D9%88%D8%A7%D9%84%D9%85%D8%B9%D8%AF%D9%88%D8%AF</link>
			<description><![CDATA[تحويل الأرقام إلى كلمات بالإنجليزية يكاد يكون جدول ترجمة: 15 هي fifteen، و15 dollars هي fifteen dollars. أما كتابة الأرقام بالحروف العربية فشيء آخر، لأن الرقم نفسه يُكتب بأكثر من صورة حسب ما يأتي بعده وموقعه في الجملة: «خمسة عشر ريالاً» لكن «خمس عشرة هللة»، و«اثنا عشر» في جملة و«اثني عشر» في أخرى. لهذا تفشل أغلب الحلول السريعة لتحويل الأرقام إلى حروف، تلك التي تستبدل كل رقم بكلمة ثابتة. في هذا المقال أشرح القواعد التي يحتاجها أي مطوّر لبناء محرك تفقيط عربي، مع أمثلة مولدة بواسطة محرك ‎@kirnu/arabic-core، وهو المحرك المفتوح المصدر المستخدم في أدوات كرنو (https://getkirnu.com/ar/?utm_source=hsoub)، والذي أساهم في تطويره. ستجد في آخر المقال طريقة استخدامه في JavaScript وTypeScript لمن يريد حلاً جاهزاً. لماذا لا يكفي تحويل الرقم إلى كلمة؟ التفقيط بالعربية ليس دالة تأخذ رقماً وتعيد كلمة، لأن الناتج الصحيح يعتمد على معلومات ليست في الرقم نفسه:   جنس المعدود:  «ثلاثة كتب» لكن «ثلاث ورقات».   الحالة الإعرابية:  «اثنا عشر» في الرفع و«اثني عشر» في النصب والجر.   نطاق العدد:  القاعدة في ٣–١٠ تختلف عنها في ١١–١٩ وفي العقود والمئات.   شكل المعدود:  «ثلاثة ريالات» بالجمع، لكن «خمسة عشر ريالاً» بالمفرد المنصوب، و«مئة ريال» بالمفرد المجرور.   العملة ووحدتاها الأساسية والفرعية:  الهللة مؤنثة، والفلس مذكر، والدينار الكويتي ألف فلس لا مئة.   الكسور:  12.5 تُقرأ «اثنا عشر فاصلة خمسة».   صيغة الشيك عند الحاجة:  «فقط … لا غير».  برمجياً يعني هذا أن دالة التفقيط تحتاج مدخلات أكثر من الرقم (الجنس والحالة الإعرابية على الأقل)، ويحتاج تحويل المبالغ إلى كلمات بيانات عن كل عملة: اسم وحدتها الكبرى والصغرى وجنس كل منهما وعدد الخانات العشرية. والمحرك الذي تعتمد عليه الأمثلة هنا يدعم سبع عملات عربية بهذه التفاصيل: الريال السعودي، والريال القطري، والريال العماني، والدرهم الإماراتي، والجنيه المصري، والدينار الكويتي، والدينار البحريني.  لنمرّ على هذه القواعد واحدة واحدة، وكيف تتحول كل منها إلى منطق في الكود. ١. الأعداد ٣–١٠ تخالف المعدود في التذكير والتأنيث أشهر قاعدة وأكثرها نسياناً: الأعداد من ثلاثة إلى عشرة تأتي عكس  جنس المعدود. مع المذكر تأتي بالتاء، ومع المؤنث بدونها:  3: «ثلاثة» مع المذكر (كتاب)، و«ثلاث» مع المؤنث (ورقة).  5: «خمسة» مع المذكر، و«خمس» مع المؤنث.  8: «ثمانية» مع المذكر، و«ثماني» مع المؤنث.  النتيجة الأولى برمجياً: دالة التفقيط تحتاج معرفة جنس المعدود ، فلا يكفي تمرير الرقم وحده. أما العددان ١ و٢ فعلى العكس، يوافقان المعدود: «كتاب واحد» و«ورقة واحدة»، و«كتابان» و«ورقتان». وعندما يصبح العدد مركباً، مثل ١٥، تجتمع القاعدتان في كلمة واحدة، وهذا موضوع القسم التالي. ٢. الأعداد المركبة ١١–١٩: جزآن بقاعدتين في «خمسة عشر» الجزء الأول (خمسة) يخالف المعدود كما سبق، أما الجزء الثاني (عشر) فيوافقه: «خمسة عشر كتاباً» و«خمس عشرة ورقة». أي أن جنس المعدود يغيّر الكلمتين في اتجاهين متعاكسين، ولا يمكن معالجة كل جزء بمعزل عن الآخر. والعددان ١١ و١٢ لهما صيغ خاصة:  ١١: «أحد عشر» للمذكر، «إحدى عشرة» للمؤنث.  ١٢: «اثنا عشر» للمذكر، «اثنتا عشرة» للمؤنث.  وصيغة «اثنا عشر» نفسها تتغير بحسب موقعها في الجملة، وهذا هو المدخل الثاني الذي تحتاجه الدالة. ٣. الإعراب يغيّر الكلمة نفسها «اثنا عشر» في الرفع تصبح «اثني عشر» في النصب والجر، و«ألفان» تصبح «ألفين»، و«اثنان وعشرون» تصبح «اثنين وعشرين». في جملة مثل «استلمت اثني عشر طلباً» لا يصح «اثنا عشر». لذلك تحتاج الدالة خياراً ثانياً هو الحالة الإعرابية ، وأبسط افتراض صحيح هو الرفع عند كتابة الرقم وحده. حتى الآن تحدثنا عن العدد نفسه. لكن في أغلب الاستخدامات الفعلية، مثل الفواتير والشيكات، يأتي بعد العدد معدود، وهنا تبدأ مجموعة أخرى من القواعد. ٤. المعدود نفسه يتغير مع الرقم حتى لو كتبت العدد صحيحاً، يبقى اسم العملة أو الشيء المعدود، وهو يتغير في أربعة نطاقات:   ١ و٢:  مفرد أو مثنى، والعدد بعده أو يُستغنى عنه: «ريال سعودي واحد»، «ريالان سعوديان».   ٣–١٠:  جمع مجرور: «ثلاثة ريالات سعودية».   ١١–٩٩:  مفرد منصوب: «خمسة عشر ريالاً سعودياً».   ١٠٠ فأكثر:  مفرد مجرور: «مئة ريال سعودي»، «ألفا ريال سعودي».  لاحظ أن الصفة تتبع المعدود أيضاً: «سعودي» و«سعودية» و«سعودياً». والنطاق يُحدَّد بآخر جزأين من الرقم لا بالرقم كله، فـ1250 تنتهي بخمسين فيأتي المعدود منصوباً: «ألف ومئتان وخمسون ريالاً سعودياً». أي أن الكود يحتاج تحليل الرقم إلى أجزائه قبل اختيار صورة المعدود. ٥. المئات والآلاف والإملاء القواعد نفسها تتكرر مع المئات والآلاف، مع بعض الصيغ الخاصة:  «مئتان» (لا «اثنتا مئة»)، ثم «ثلاثمئة» كلمة واحدة في الإملاء الحديث.  الآلاف تتبع قاعدة المعدود: «ألفان»، «ثلاثة آلاف»، «أحد عشر ألفاً».  «مئة» هي الإملاء الحديث و«مائة» هو القديم، وكلاهما مستعمل، فالأفضل أن يكون خياراً: 1250 تصبح «ألف ومئتان وخمسون» أو «ألف ومائتان وخمسون».  حرف العطف يربط الأجزاء: 1001011 هي «مليون وألف وأحد عشر».  ٦. العملات والكسور عند تحويل المبالغ إلى كلمات تنطبق كل القواعد السابقة مرتين: مرة على الوحدة الكبرى (الريال أو الدينار)، ومرة على الوحدة الصغرى، بما في ذلك جنسها:  الريال السعودي: 100 هللة، و«هللة» مؤنثة، لذلك 0.25 هي «خمس وعشرون هللة» لا «خمسة وعشرون».  الدينار الكويتي: 1000 فلس (ثلاث خانات عشرية)، فـ1.250 هي «دينار كويتي واحد ومئتان وخمسون فلساً».  الجنيه المصري: 100 قرش، فـ3.03 هي «ثلاثة جنيهات مصرية وثلاثة قروش».  أما الكسور خارج سياق العملات فتُقرأ بعد كلمة «فاصلة»: 12.5 هي «اثنا عشر فاصلة خمسة». وفي الشيكات يُحاط المبلغ بعبارة «فقط … لا غير» حتى لا يُضاف شيء قبله أو بعده: فقط ألف ومئتان وخمسون درهماً إماراتياً وخمسون فلساً لا غير ٧. اكتب الاختبارات قبل الكود بعد كل هذه القواعد يتضح لماذا يصعب الحفاظ على محرك تفقيط صحيح: أي تعديل صغير قد يُصلح حالة ويكسر عشراً. الطريقة التي نجحت معنا: جدول من الحالات المكتوبة يدوياً والمراجَعة لغوياً (الرقم، الجنس، الحالة، النتيجة المتوقعة)، تُشغَّل كلها مع كل تعديل. والحالات الحدّية تستحق اختباراتها الخاصة: ١١ و١٢، والأعداد المنتهية بـ٠١ و٠٢، والآلاف المثناة، والصفر، والأرقام العربية المشرقية ١٢٣ مقابل 123. استخدام ‎@kirnu/arabic-core في JavaScript وTypeScript جمعت كل ما سبق في مكتبة TypeScript مفتوحة المصدر بترخيص MIT، بلا اعتماديات، تعمل في المتصفح وNode. إن كنت تبحث عن حل Arabic number to words في JavaScript أو TypeScript، فهي المحرك نفسه الذي تعمل به أدوات كرنو: npm install @kirnu/arabic-core import { tafgeet, currencyToWords } from &#39;@kirnu/arabic-core&#39;; // تفقيط بجنس مذكر (الافتراضي) tafgeet(15); // خمسة عشر // تفقيط بجنس مؤنث tafgeet(15, { gender: &#39;feminine&#39; }); // خمس عشرة // استخدام الحالة الإعرابية tafgeet(12, { case: &#39;accusative&#39; }); // اثني عشر // أرقام عربية مشرقية مع كسر عشري tafgeet(&#39;١٢٫٥&#39;); // اثنا عشر فاصلة خمسة // تحويل مبلغ إلى كلمات بصيغة الشيك currencyToWords(&#39;1250.50&#39;, &#39;SAR&#39;, { cheque: true }); // فقط ألف ومئتان وخمسون ريالاً سعودياً وخمسون هللة لا غير تدعم المكتبة العملات السبع المذكورة أعلاه، وتحتوي أيضاً على إزالة التشكيل وتطبيع النص والتحويل بين التاريخ الهجري (أم القرى) والميلادي. ويمكن تجربة التفقيط دون كود في أداة التفقيط على كرنو (https://getkirnu.com/ar/text/tafgeet/?utm_source=hsoub).  الكود: https://github.com/getkirnu/arabic-core  الحزمة: https://www.npmjs.com/package/@kirnu/arabic-core  إن وجدت حالة تخرج خطأ، أو عملة تحتاجها، افتح Issue في GitHub. الملاحظات اللغوية مرحّب بها بالقدر نفسه.]]></description>
		</item>
	</channel>
</rss>
