كيف تقسم النص إلى أحرف فردية بشكل صحيح؟
استخدم Intl.Segmenter لتقسيم السلاسل النصية إلى أحرف يدركها المستخدم بدلاً من وحدات الترميز
مقدمة
عندما تحاول تقسيم الرمز التعبيري "👨👩👧👦" إلى أحرف فردية باستخدام الطرق القياسية للسلاسل النصية في JavaScript، تحصل على نتيجة معطلة. بدلاً من رمز تعبيري واحد للعائلة، ترى رموزاً تعبيرية منفصلة للأشخاص وأحرفاً غير مرئية. تحدث نفس المشكلة مع الأحرف المشكّلة مثل "é"، والرموز التعبيرية للأعلام مثل "🇺🇸"، والعديد من عناصر النص الأخرى التي تظهر كأحرف مفردة على الشاشة.
يحدث هذا لأن تقسيم السلاسل النصية المدمج في JavaScript يتعامل مع السلاسل النصية كتسلسلات من وحدات ترميز UTF-16 بدلاً من الأحرف التي يدركها المستخدم. يمكن أن يتكون حرف مرئي واحد من وحدات ترميز متعددة مرتبطة معاً. عندما تقسم حسب وحدات الترميز، فإنك تفصل هذه الأحرف عن بعضها.
توفر JavaScript واجهة برمجة التطبيقات Intl.Segmenter للتعامل مع هذا بشكل صحيح. يشرح هذا الدرس ما هي الأحرف التي يدركها المستخدم، ولماذا تفشل طرق السلاسل النصية القياسية في تقسيمها بشكل صحيح، وكيفية استخدام Intl.Segmenter لتقسيم النص إلى أحرف فعلية.
ما هي الأحرف التي يدركها المستخدم
الحرف الذي يدركه المستخدم هو ما يتعرف عليه الشخص كحرف واحد عند قراءة النص. تسمى هذه مجموعات الحروف البيانية في مصطلحات Unicode. في معظم الأحيان، تطابق مجموعة الحروف البيانية ما تراه كحرف واحد على الشاشة.
الحرف "a" هو مجموعة حروف بيانية تتكون من نقطة ترميز Unicode واحدة. الرمز التعبيري "😀" هو مجموعة حروف بيانية تتكون من نقطتي ترميز تشكلان رمزاً تعبيرياً واحداً. رمز العائلة التعبيري "👨👩👧👦" هو مجموعة حروف بيانية تتكون من سبع نقاط ترميز مرتبطة معاً بأحرف خاصة غير مرئية.
عند عد الأحرف في النص، تريد عد مجموعات الحروف الرسومية، وليس نقاط الترميز أو وحدات الترميز. عند تقسيم النص إلى أحرف، تريد التقسيم عند حدود مجموعات الحروف الرسومية، وليس في مواضع عشوائية داخل المجموعة.
سلاسل JavaScript هي تسلسلات من وحدات ترميز UTF-16. تمثل كل وحدة ترميز إما نقطة ترميز كاملة أو جزءًا من نقطة ترميز. يمكن أن تمتد مجموعة الحروف الرسومية عبر نقاط ترميز متعددة، ويمكن أن تمتد كل نقطة ترميز عبر وحدات ترميز متعددة. يؤدي هذا إلى عدم تطابق بين كيفية تخزين JavaScript للنص وكيفية إدراك المستخدمين للنص.
لماذا تفشل طريقة split مع الأحرف المعقدة
تقوم طريقة split('') بتقسيم السلسلة عند كل حد لوحدة ترميز. يعمل هذا بشكل صحيح مع أحرف ASCII البسيطة حيث يكون كل حرف وحدة ترميز واحدة. لكنه يفشل مع الأحرف التي تمتد عبر وحدات ترميز متعددة.
const simple = "hello";
console.log(simple.split(''));
// Output: ["h", "e", "l", "l", "o"]
ينقسم نص ASCII البسيط بشكل صحيح لأن كل حرف هو وحدة ترميز واحدة. ومع ذلك، تتفكك الرموز التعبيرية والأحرف المعقدة الأخرى.
const emoji = "😀";
console.log(emoji.split(''));
// Output: ["\ud83d", "\ude00"]
يتكون رمز الوجه المبتسم التعبيري من وحدتي ترميز. تقوم طريقة split('') بتقسيمه إلى قطعتين منفصلتين ليستا أحرفًا صالحة بمفردهما. عند عرضها، تظهر هذه القطع كأحرف بديلة أو لا تظهر على الإطلاق.
تستخدم رموز الأعلام التعبيرية رموز المؤشرات الإقليمية التي تتحد لتشكيل الأعلام. يتطلب كل علم نقطتي ترميز.
const flag = "🇺🇸";
console.log(flag.split(''));
// Output: ["\ud83c", "\uddfa", "\ud83c", "\uddf8"]
ينقسم رمز علم الولايات المتحدة التعبيري إلى أربع وحدات ترميز تمثل مؤشرين إقليميين. لا يعد أي من المؤشرين حرفًا صالحًا بمفرده. تحتاج إلى كلا المؤشرين معًا لتشكيل العلم.
تستخدم رموز العائلة التعبيرية أحرف الربط ذات العرض الصفري لدمج رموز تعبيرية متعددة للأشخاص في حرف مركب واحد.
const family = "👨👩👧👦";
console.log(family.split(''));
// Output: ["👨", "", "👩", "", "👧", "", "👦"]
ينقسم الرمز التعبيري للعائلة إلى رموز تعبيرية فردية للأشخاص وأحرف ربط غير مرئية. يتم تدمير الحرف المركب الأصلي، وترى أربعة أشخاص منفصلين بدلاً من عائلة واحدة.
يمكن تمثيل الأحرف المُشكّلة بطريقتين في يونيكود. بعض الأحرف المُشكّلة عبارة عن نقاط ترميز منفردة، بينما يجمع البعض الآخر حرفاً أساسياً مع علامة تشكيل مُركّبة.
const combined = "é"; // e + combining acute accent
console.log(combined.split(''));
// Output: ["e", "́"]
عندما يتم تمثيل الحرف é كنقطتي ترميز (حرف أساسي بالإضافة إلى علامة تشكيل مُركّبة)، فإن التقسيم يفصله إلى أجزاء منفصلة. تظهر علامة التشكيل بمفردها، وهو ما لا يتوقعه المستخدمون عند تقسيم النص إلى أحرف.
استخدام Intl.Segmenter لتقسيم النص بشكل صحيح
ينشئ المُنشئ Intl.Segmenter مُقسّماً يقسم النص وفقاً لقواعد خاصة باللغة المحلية. مرر معرّف اللغة المحلية كوسيطة أولى وكائن خيارات يحدد مستوى التفصيل كوسيطة ثانية.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
يخبر مستوى التفصيل grapheme المُقسّم بتقسيم النص عند حدود مجموعات الجرافيم. هذا يحترم بنية الأحرف المُدركة من قبل المستخدم ولا يفصلها.
استدعِ الدالة segment() مع سلسلة نصية للحصول على مُكرّر للمقاطع. يتضمن كل مقطع النص ومعلومات الموضع.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "hello";
const segments = segmenter.segment(text);
for (const segment of segments) {
console.log(segment.segment);
}
// Output:
// "h"
// "e"
// "l"
// "l"
// "o"
يحتوي كل كائن مقطع على خاصية segment تحتوي على نص الحرف وخاصية index تحتوي على موضعه. يمكنك التكرار مباشرة على المقاطع للوصول إلى كل حرف.
للحصول على مصفوفة من الأحرف، انشر المُكرّر في مصفوفة واربطه بنص المقطع.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "hello";
const characters = [...segmenter.segment(text)].map(s => s.segment);
console.log(characters);
// Output: ["h", "e", "l", "l", "o"]
يحول هذا النمط المكرر إلى مصفوفة من كائنات المقاطع، ثم يستخرج النص فقط من كل مقطع. النتيجة هي مصفوفة من السلاسل النصية، واحدة لكل مجموعة جرافيمية.
تقسيم الرموز التعبيرية إلى أحرف بشكل صحيح
تتعامل واجهة Intl.Segmenter مع جميع الرموز التعبيرية بشكل صحيح، بما في ذلك الرموز التعبيرية المركبة التي تستخدم نقاط ترميز متعددة.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const emoji = "😀";
const characters = [...segmenter.segment(emoji)].map(s => s.segment);
console.log(characters);
// Output: ["😀"]
يبقى الرمز التعبيري سليماً كمجموعة جرافيمية واحدة. يتعرف المقسم على أن كلتا وحدتي الشفرة تنتميان إلى نفس الحرف ولا يقوم بتقسيمهما.
تبقى رموز الأعلام التعبيرية كأحرف مفردة بدلاً من التقسيم إلى مؤشرات إقليمية.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const flag = "🇺🇸";
const characters = [...segmenter.segment(flag)].map(s => s.segment);
console.log(characters);
// Output: ["🇺🇸"]
يشكل رمزا المؤشر الإقليمي مجموعة جرافيمية واحدة تمثل علم الولايات المتحدة. يحافظ المقسم عليهما معاً كحرف واحد.
تبقى رموز العائلة التعبيرية والرموز التعبيرية المركبة الأخرى كأحرف مفردة.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const family = "👨👩👧👦";
const characters = [...segmenter.segment(family)].map(s => s.segment);
console.log(characters);
// Output: ["👨👩👧👦"]
تشكل جميع رموز الأشخاص التعبيرية وأحرف الربط ذات العرض الصفري مجموعة جرافيمية واحدة. يتعامل المقسم مع رمز العائلة التعبيري بأكمله كحرف واحد، مع الحفاظ على مظهره ومعناه.
تقسيم النص مع الحروف المشكلة
تتعامل واجهة Intl.Segmenter بشكل صحيح مع الحروف المشكلة بغض النظر عن كيفية ترميزها في يونيكود.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const precomposed = "café"; // precomposed é
const characters = [...segmenter.segment(precomposed)].map(s => s.segment);
console.log(characters);
// Output: ["c", "a", "f", "é"]
عندما يتم ترميز الحرف المشكل é كنقطة ترميز واحدة، يتعامل معه المقسم كحرف واحد. هذا يتطابق مع توقعات المستخدم لكيفية تقسيم الكلمة.
عندما يتم ترميز نفس الحرف كحرف أساسي بالإضافة إلى علامة تشكيل مركبة، لا يزال المقسم يتعامل معه كحرف واحد.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const decomposed = "café"; // e + combining acute accent
const characters = [...segmenter.segment(decomposed)].map(s => s.segment);
console.log(characters);
// Output: ["c", "a", "f", "é"]
يتعرف المقسم على أن الحرف الأساسي وعلامة التشكيل المركبة يشكلان مجموعة جرافيمية واحدة. تبدو النتيجة مطابقة للنسخة المركبة مسبقاً، على الرغم من أن الترميز الأساسي مختلف.
هذا السلوك مهم لمعالجة النصوص في اللغات التي تستخدم علامات التشكيل. يتوقع المستخدمون أن يتم التعامل مع الأحرف المُشكّلة كأحرف كاملة، وليس كأحرف أساسية وعلامات منفصلة.
عد الأحرف بشكل صحيح
إحدى حالات الاستخدام الشائعة لتقسيم النص هي حساب عدد الأحرف التي يحتويها. تعطي طريقة split('') عدداً غير صحيح للنصوص التي تحتوي على أحرف معقدة.
const text = "👨👩👧👦";
console.log(text.split('').length);
// Output: 7
يظهر رمز العائلة التعبيري كحرف واحد ولكنه يُحسب كسبعة عند التقسيم حسب وحدات الترميز. هذا لا يتطابق مع توقعات المستخدمين.
استخدام Intl.Segmenter يعطي عدداً دقيقاً للأحرف.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "👨👩👧👦";
const count = [...segmenter.segment(text)].length;
console.log(count);
// Output: 1
يتعرف المُقسّم على رمز العائلة التعبيري كمجموعة جرافيمية واحدة، لذا فإن العدد هو واحد. هذا يتطابق مع ما يراه المستخدمون على الشاشة.
يمكنك إنشاء دالة مساعدة لحساب المجموعات الجرافيمية في أي نص.
function countCharacters(text) {
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
return [...segmenter.segment(text)].length;
}
console.log(countCharacters("hello"));
// Output: 5
console.log(countCharacters("café"));
// Output: 4
console.log(countCharacters("👨👩👧👦"));
// Output: 1
console.log(countCharacters("🇺🇸"));
// Output: 1
تعمل هذه الدالة بشكل صحيح مع نصوص ASCII والأحرف المُشكّلة والرموز التعبيرية وأي أحرف Unicode أخرى. يتطابق العدد دائماً مع عدد الأحرف التي يدركها المستخدم.
الحصول على حرف في موضع محدد
عندما تحتاج إلى الوصول إلى حرف في موضع محدد، يمكنك تحويل النص إلى مصفوفة من المجموعات الجرافيمية أولاً.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "Hello 👋";
const characters = [...segmenter.segment(text)].map(s => s.segment);
console.log(characters[6]);
// Output: "👋"
يقع رمز اليد الملوحة التعبيري في الموضع 6 عند حساب المجموعات الجرافيمية. إذا استخدمت الفهرسة القياسية للمصفوفة على النص، فستحصل على نتيجة غير صالحة لأن الرمز التعبيري يمتد عبر وحدات ترميز متعددة.
هذا النهج مفيد عند تنفيذ عمليات على مستوى الأحرف مثل اختيار الأحرف أو تمييز الأحرف أو الرسوم المتحركة حرفاً بحرف.
عكس النص بشكل صحيح
عكس نص عن طريق عكس مصفوفة وحدات الترميز الخاصة به ينتج نتائج غير صحيحة للأحرف المعقدة.
const text = "Hello 👋";
console.log(text.split('').reverse().join(''));
// Output: "�� olleH"
ينكسر الرمز التعبيري لأن وحدات الترميز الخاصة به يتم عكسها بشكل منفصل. تحتوي السلسلة الناتجة على تسلسلات أحرف غير صالحة.
استخدام Intl.Segmenter لعكس النص يحافظ على سلامة الأحرف.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "Hello 👋";
const characters = [...segmenter.segment(text)].map(s => s.segment);
const reversed = characters.reverse().join('');
console.log(reversed);
// Output: "👋 olleH"
تبقى كل مجموعة جرافيمية سليمة أثناء العكس. يبقى الرمز التعبيري صالحاً لأن وحدات الترميز الخاصة به لا يتم فصلها.
فهم معامل اللغة المحلية
يقبل مُنشئ Intl.Segmenter معامل لغة محلية، ولكن بالنسبة لتقسيم الجرافيمات، فإن اللغة المحلية لها تأثير ضئيل. تتبع حدود مجموعات الجرافيمات قواعد Unicode التي تكون مستقلة عن اللغة في معظمها.
const segmenterEn = new Intl.Segmenter('en', { granularity: 'grapheme' });
const segmenterJa = new Intl.Segmenter('ja', { granularity: 'grapheme' });
const text = "Hello 👋 こんにちは";
const charactersEn = [...segmenterEn.segment(text)].map(s => s.segment);
const charactersJa = [...segmenterJa.segment(text)].map(s => s.segment);
console.log(charactersEn);
console.log(charactersJa);
// Both outputs are identical
تنتج معرّفات اللغة المحلية المختلفة نفس نتائج تقسيم الجرافيمات. يحدد معيار Unicode حدود مجموعات الجرافيمات بطريقة تعمل عبر اللغات.
ومع ذلك، لا يزال تحديد لغة محلية ممارسة جيدة للاتساق مع واجهات برمجة التطبيقات الأخرى في Intl وفي حالة قيام إصدارات Unicode المستقبلية بإدخال قواعد خاصة باللغة المحلية.
إعادة استخدام المُقسّمات لتحسين الأداء
يتضمن إنشاء نسخة جديدة من Intl.Segmenter تحميل بيانات اللغة المحلية وتهيئة الهياكل الداخلية. عندما تحتاج إلى تقسيم سلاسل متعددة بنفس الإعدادات، قم بإنشاء المُقسّم مرة واحدة وأعد استخدامه.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const texts = [
"Hello 👋",
"Café ☕",
"World 🌍",
"Family 👨👩👧👦"
];
texts.forEach(text => {
const characters = [...segmenter.segment(text)].map(s => s.segment);
console.log(characters);
});
// Output:
// ["H", "e", "l", "l", "o", " ", "👋"]
// ["C", "a", "f", "é", " ", "☕"]
// ["W", "o", "r", "l", "d", " ", "🌍"]
// ["F", "a", "m", "i", "l", "y", " ", "👨👩👧👦"]
هذا النهج أكثر كفاءة من إنشاء مُقسّم جديد لكل سلسلة. يصبح الفرق في الأداء كبيراً عند معالجة كميات كبيرة من النص.
دمج تقسيم الجرافيمات مع عمليات أخرى
يمكنك دمج تقسيم الجرافيمات مع عمليات سلاسل أخرى لبناء دوال معالجة نصوص أكثر تعقيداً.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
function truncateByCharacters(text, maxLength) {
const characters = [...segmenter.segment(text)].map(s => s.segment);
if (characters.length <= maxLength) {
return text;
}
return characters.slice(0, maxLength).join('') + '...';
}
console.log(truncateByCharacters("Hello 👋 World", 7));
// Output: "Hello 👋..."
console.log(truncateByCharacters("Family 👨👩👧👦 Photo", 8));
// Output: "Family 👨👩👧👦..."
تحسب دالة الاقتطاع هذه مجموعات الجرافيمات بدلاً من وحدات الترميز. تحافظ على الرموز التعبيرية والأحرف المعقدة الأخرى عند الاقتطاع، لذلك لا يحتوي الناتج أبداً على أحرف مكسورة.
العمل مع مواضع السلاسل
تتضمن كائنات المقاطع التي يتم إرجاعها بواسطة Intl.Segmenter خاصية index تشير إلى الموضع في السلسلة النصية الأصلية. يتم قياس هذا الموضع بوحدات الشفرة، وليس بالمجموعات الجرافيمية.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "Hello 👋";
for (const segment of segmenter.segment(text)) {
console.log(`Character "${segment.segment}" starts at position ${segment.index}`);
}
// Output:
// Character "H" starts at position 0
// Character "e" starts at position 1
// Character "l" starts at position 2
// Character "l" starts at position 3
// Character "o" starts at position 4
// Character " " starts at position 5
// Character "👋" starts at position 6
يبدأ رمز اليد الملوحة التعبيري عند موضع وحدة الشفرة 6، على الرغم من أنه يشغل الموضعين 6 و7 في السلسلة النصية الأساسية. سيبدأ الحرف التالي عند الموضع 8. هذه المعلومات مفيدة عندما تحتاج إلى الربط بين مواضع الجرافيم ومواضع السلسلة النصية لعمليات مثل استخراج السلاسل النصية الفرعية.
التعامل مع السلاسل النصية الفارغة والحالات الحدية
تتعامل واجهة برمجة التطبيقات Intl.Segmenter مع السلاسل النصية الفارغة والحالات الحدية الأخرى بشكل صحيح.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const empty = "";
const characters = [...segmenter.segment(empty)].map(s => s.segment);
console.log(characters);
// Output: []
تنتج السلسلة النصية الفارغة مصفوفة فارغة من المقاطع. لا يلزم معالجة خاصة.
تُعامل أحرف المسافات البيضاء كمجموعات جرافيمية منفصلة.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const whitespace = "a b\tc\nd";
const characters = [...segmenter.segment(whitespace)].map(s => s.segment);
console.log(characters);
// Output: ["a", " ", "b", "\t", "c", "\n", "d"]
تشكل المسافات وعلامات التبويب والأسطر الجديدة كل منها مجموعاتها الجرافيمية الخاصة. يتطابق هذا مع توقعات المستخدم لمعالجة النص على مستوى الأحرف.