كيف تقسم النص إلى أحرف فردية بشكل صحيح؟
استخدم Intl.Segmenter لتقسيم السلاسل النصية إلى أحرف يدركها المستخدم بدلاً من وحدات الشفرة
مقدمة
عندما تحاول تقسيم الرمز التعبيري "👨👩👧👦" إلى أحرف فردية باستخدام طرق السلاسل النصية القياسية في جافا سكريبت، ستحصل على نتيجة مكسورة. بدلاً من رمز تعبيري واحد للعائلة، سترى رموزًا تعبيرية منفصلة للأشخاص وأحرفًا غير مرئية. تحدث نفس المشكلة مع الحروف المشكّلة مثل "é"، ورموز الأعلام التعبيرية مثل "🇺🇸"، والعديد من عناصر النص الأخرى التي تظهر كأحرف فردية على الشاشة.
يحدث هذا لأن تقسيم السلاسل النصية المدمج في جافا سكريبت يتعامل مع السلاسل كتسلسلات من وحدات رمز UTF-16 بدلاً من الأحرف المدركة من قبل المستخدم. يمكن أن يتكون الحرف المرئي الواحد من وحدات رمز متعددة مرتبطة معًا. عندما تقسم حسب وحدات الرمز، فإنك تفصل هذه الأحرف.
توفر جافا سكريبت واجهة برمجة التطبيقات Intl.Segmenter للتعامل مع هذا بشكل صحيح. يشرح هذا الدرس ما هي الأحرف المدركة من قبل المستخدم، ولماذا تفشل طرق السلاسل النصية القياسية في تقسيمها بشكل صحيح، وكيفية استخدام Intl.Segmenter لتقسيم النص إلى أحرف فعلية.
ما هي الأحرف المدركة من قبل المستخدم
الحرف المدرك من قبل المستخدم هو ما يتعرف عليه الشخص كحرف واحد عند قراءة النص. تُسمى هذه بمجموعات الرموز الكتابية في مصطلحات يونيكود. في معظم الأحيان، تتطابق مجموعة الرموز الكتابية مع ما تراه كحرف واحد على الشاشة.
الحرف "a" هو مجموعة رموز كتابية تتكون من نقطة رمز يونيكود واحدة. الرمز التعبيري "😀" هو مجموعة رموز كتابية تتكون من نقطتي رمز تشكلان رمزًا تعبيريًا واحدًا. الرمز التعبيري للعائلة "👨👩👧👦" هو مجموعة رموز كتابية تتكون من سبع نقاط رمز مرتبطة معًا بأحرف خاصة غير مرئية.
عندما تعد الأحرف في النص، تريد عد مجموعات الرموز الكتابية، وليس نقاط الرمز أو وحدات الرمز. عندما تقسم النص إلى أحرف، تريد التقسيم عند حدود مجموعة الرموز الكتابية، وليس في مواقع عشوائية داخل المجموعة.
سلاسل جافا سكريبت هي تسلسلات من وحدات رمز UTF-16. كل وحدة رمز تمثل إما نقطة رمز كاملة أو جزءًا من نقطة رمز. يمكن أن تمتد مجموعة الرموز الكتابية عبر نقاط رمز متعددة، ويمكن أن تمتد كل نقطة رمز عبر وحدات رمز متعددة. هذا يخلق عدم تطابق بين كيفية تخزين جافا سكريبت للنص وكيفية إدراك المستخدمين للنص.
لماذا تفشل طريقة 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);
}
// الناتج:
// "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);
// الناتج: ["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);
// الناتج: ["😀"]
يبقى الرمز التعبيري سليمًا كمجموعة حروف رسومية واحدة. يتعرف المُقسِّم على أن وحدتي الترميز تنتميان إلى نفس الحرف ولا يقوم بتقسيمهما.
تبقى رموز الأعلام كأحرف مفردة بدلاً من تقسيمها إلى مؤشرات إقليمية.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const flag = "🇺🇸";
const characters = [...segmenter.segment(flag)].map(s => s.segment);
console.log(characters);
// الناتج: ["🇺🇸"]
يشكل رمزا المؤشر الإقليمي مجموعة حروف رسومية واحدة تمثل علم الولايات المتحدة. يحتفظ المُقسِّم بهما معًا كحرف واحد.
تبقى رموز العائلة والرموز التعبيرية المركبة الأخرى كأحرف مفردة.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const family = "👨👩👧👦";
const characters = [...segmenter.segment(family)].map(s => s.segment);
console.log(characters);
// الناتج: ["👨👩👧👦"]
تشكل جميع رموز الأشخاص وواصلات عرض الصفر مجموعة حروف رسومية واحدة. يعامل المُقسِّم رمز العائلة بأكمله كحرف واحد، مما يحافظ على مظهره ومعناه.
تقسيم النص مع الحروف المشكّلة
تتعامل واجهة برمجة التطبيقات Intl.Segmenter بشكل صحيح مع الحروف المشكّلة بغض النظر عن كيفية ترميزها في يونيكود.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const precomposed = "café"; // حرف é مركب مسبقاً
const characters = [...segmenter.segment(precomposed)].map(s => s.segment);
console.log(characters);
// النتيجة: ["c", "a", "f", "é"]
عندما يتم ترميز الحرف المشكّل é كنقطة ترميز واحدة، يعامله المقسّم كحرف واحد. وهذا يتوافق مع توقعات المستخدم لكيفية تقسيم الكلمة.
عندما يتم ترميز نفس الحرف كحرف أساسي بالإضافة إلى علامة تشكيل مركبة، يظل المقسّم يعامله كحرف واحد.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const decomposed = "café"; // حرف e + علامة التشكيل الحادة المركبة
const characters = [...segmenter.segment(decomposed)].map(s => s.segment);
console.log(characters);
// النتيجة: ["c", "a", "f", "é"]
يتعرف المقسّم على أن الحرف الأساسي وعلامة التشكيل المركبة تشكلان مجموعة رسومية واحدة. تبدو النتيجة متطابقة مع النسخة المركبة مسبقاً، على الرغم من أن الترميز الأساسي مختلف.
هذا السلوك مهم لمعالجة النصوص في اللغات التي تستخدم علامات التشكيل. يتوقع المستخدمون أن تُعامل الحروف المشكّلة كأحرف كاملة، وليس كحروف أساسية وعلامات منفصلة.
عد الأحرف بشكل صحيح
أحد الاستخدامات الشائعة لتقسيم النص هو حساب عدد الأحرف التي يحتوي عليها. تعطي طريقة split('') عدداً غير صحيح للنص الذي يحتوي على أحرف معقدة.
const text = "👨👩👧👦";
console.log(text.split('').length);
// النتيجة: 7
يظهر رمز تعبيري للعائلة كحرف واحد ولكنه يُحسب كسبعة أحرف عند تقسيمه حسب وحدات الترميز. هذا لا يتوافق مع توقعات المستخدم.
استخدام Intl.Segmenter يعطي عدداً دقيقاً للأحرف.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "👨👩👧👦";
const count = [...segmenter.segment(text)].length;
console.log(count);
// النتيجة: 1
يتعرف المقسّم على الرمز التعبيري للعائلة كمجموعة رسومية واحدة، لذلك يكون العدد واحداً. هذا يتطابق مع ما يراه المستخدمون على الشاشة.
يمكنك إنشاء دالة مساعدة لعد المجموعات الرسومية في أي سلسلة نصية.
function countCharacters(text) {
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
return [...segmenter.segment(text)].length;
}
console.log(countCharacters("hello"));
// النتيجة: 5
console.log(countCharacters("café"));
// النتيجة: 4
console.log(countCharacters("👨👩👧👦"));
// النتيجة: 1
console.log(countCharacters("🇺🇸"));
// النتيجة: 1
تعمل هذه الدالة بشكل صحيح مع نص ASCII، والحروف المشكّلة، والرموز التعبيرية، وأي أحرف يونيكود أخرى. يتطابق العدد دائماً مع عدد الأحرف التي يدركها المستخدم.
الحصول على حرف في موضع محدد
عندما تحتاج إلى الوصول إلى حرف في موضع محدد، يمكنك تحويل النص إلى مصفوفة من مجموعات الرموز الرسومية أولاً.
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 معلمة اللغة المحلية، ولكن بالنسبة لتقسيم الرموز الرسومية، فإن تأثير اللغة المحلية ضئيل. تتبع حدود مجموعات الرموز الرسومية قواعد يونيكود التي تكون في الغالب مستقلة عن اللغة.
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
معرفات اللغات المحلية المختلفة تنتج نفس نتائج تقسيم الرموز الرسومية. يحدد معيار يونيكود حدود مجموعات الرموز الرسومية بطريقة تعمل عبر اللغات المختلفة.
ومع ذلك، فإن تحديد لغة محلية لا يزال ممارسة جيدة للاتساق مع واجهات برمجة التطبيقات الأخرى لـ Intl وفي حالة تقديم إصدارات يونيكود المستقبلية لقواعد خاصة باللغة المحلية.
إعادة استخدام المقسمات لتحسين الأداء
إنشاء نسخة جديدة من 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"]
تشكل المسافات وعلامات التبويب وأحرف السطر الجديد مجموعات رسومية خاصة بها. وهذا يتوافق مع توقعات المستخدم لمعالجة النصوص على مستوى الأحرف.