واجهة برمجة التطبيقات Intl.Segmenter

كيفية عد الأحرف وتقسيم الكلمات وتجزئة الجمل بشكل صحيح في JavaScript

مقدمة

خاصية string.length في JavaScript تعد وحدات الشفرة، وليس الأحرف المدركة من قبل المستخدم. عندما يكتب المستخدمون رموز تعبيرية أو أحرفاً منقوطة أو نصاً بنظم كتابة معقدة، تُرجع string.length عدداً خاطئاً. تفشل طريقة split() مع اللغات التي لا تستخدم مسافات بين الكلمات. حدود الكلمات في التعبيرات النمطية لا تعمل مع النصوص الصينية أو اليابانية أو التايلاندية.

واجهة برمجة التطبيقات Intl.Segmenter تحل هذه المشاكل. فهي تجزئ النص وفقاً لمعايير Unicode، مع احترام القواعد اللغوية لكل لغة. يمكنك عد الجرافيمات (الأحرف المدركة من قبل المستخدم)، وتقسيم النص إلى كلمات بغض النظر عن اللغة، أو تقسيم النص إلى جمل.

يشرح هذا المقال لماذا تفشل عمليات السلاسل النصية الأساسية مع النصوص الدولية، وما هي مجموعات الجرافيمات والحدود اللغوية، وكيفية استخدام Intl.Segmenter للتعامل مع النص بشكل صحيح لجميع المستخدمين.

لماذا تفشل string.length في عد الأحرف

تستخدم سلاسل JavaScript ترميز UTF-16. كل عنصر في سلسلة JavaScript هو وحدة شفرة بحجم 16 بت، وليس حرفاً كاملاً. خاصية string.length تعد وحدات الشفرة هذه.

بالنسبة لأحرف ASCII الأساسية، وحدة شفرة واحدة تساوي حرفاً واحداً. السلسلة النصية "hello" لها طول 5، وهو ما يتطابق مع توقعات المستخدم.

بالنسبة للعديد من الأحرف الأخرى، يحدث خلل. انظر إلى هذه الأمثلة:

"😀".length; // 2, not 1
"👨‍👩‍👧‍👦".length; // 11, not 1
"किं".length; // 5, not 2
"🇺🇸".length; // 4, not 1

يرى المستخدمون رمزاً تعبيرياً واحداً، أو رمز عائلة واحد، أو مقطعين هنديين، أو علماً واحداً. بينما تعد JavaScript وحدات الشفرة الأساسية.

هذا مهم عندما تبني عدادات أحرف لحقول إدخال النص، أو تتحقق من حدود الطول، أو تقتطع النص للعرض. العدد الذي تُبلغ عنه JavaScript لا يتطابق مع ما يراه المستخدمون.

ما هي مجموعات الحروف البيانية

مجموعة الحروف البيانية هي ما يدركه المستخدمون كحرف واحد. قد تتكون من:

  • نقطة رمز واحدة مثل "a"
  • حرف أساسي بالإضافة إلى علامات تركيبية مثل "é" (e + علامة النبرة الحادة التركيبية)
  • نقاط رمز متعددة مدمجة معاً مثل "👨‍👩‍👧‍👦" (رجل + امرأة + فتاة + صبي مدمجة بفواصل عرض صفري)
  • رموز تعبيرية مع معدلات لون البشرة مثل "👋🏽" (يد تلوح + لون بشرة متوسط)
  • تسلسلات المؤشرات الإقليمية للأعلام مثل "🇺🇸" (مؤشر إقليمي U + مؤشر إقليمي S)

يحدد معيار Unicode مجموعات الحروف البيانية الموسعة في UAX 29. تحدد هذه القواعد المواضع التي يتوقع المستخدمون فيها حدوداً بين الحروف. عندما يضغط المستخدم على مفتاح backspace، يتوقع حذف مجموعة حروف بيانية واحدة. عندما يتحرك المؤشر، يجب أن يتحرك بمجموعات الحروف البيانية.

خاصية string.length في JavaScript لا تحسب مجموعات الحروف البيانية. واجهة Intl.Segmenter تفعل ذلك.

حساب مجموعات الحروف البيانية باستخدام Intl.Segmenter

أنشئ مقسماً بدقة الحروف البيانية لحساب الحروف التي يدركها المستخدم:

const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
const text = "Hello 👋🏽";
const segments = segmenter.segment(text);
const graphemes = Array.from(segments);

console.log(graphemes.length); // 7
console.log(text.length); // 10

يرى المستخدم سبعة حروف: خمسة أحرف، ومسافة واحدة، ورمز تعبيري واحد. يُرجع مقسم الحروف البيانية سبعة أجزاء. تُرجع خاصية string.length في JavaScript عشرة لأن الرمز التعبيري يستخدم أربع وحدات رمز.

يحتوي كل كائن جزء على:

  • segment: مجموعة الحروف البيانية كسلسلة نصية
  • index: الموضع في السلسلة النصية الأصلية حيث يبدأ هذا الجزء
  • input: مرجع إلى السلسلة النصية الأصلية (ليس مطلوباً دائماً)

يمكنك التكرار عبر الأجزاء باستخدام for...of:

const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
const text = "café";

for (const { segment } of segmenter.segment(text)) {
  console.log(segment);
}
// Logs: "c", "a", "f", "é"

بناء عداد أحرف يعمل عالمياً

استخدم تجزئة الجرافيم لبناء عدادات أحرف دقيقة:

function getGraphemeCount(text) {
  const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
  return Array.from(segmenter.segment(text)).length;
}

// Test with various inputs
getGraphemeCount("hello"); // 5
getGraphemeCount("hello 😀"); // 7
getGraphemeCount("👨‍👩‍👧‍👦"); // 1
getGraphemeCount("किंतु"); // 2
getGraphemeCount("🇺🇸"); // 1

تُرجع هذه الدالة عدادات تطابق إدراك المستخدم. المستخدم الذي يكتب رمز تعبيري عائلي يرى حرفاً واحداً، ويُظهر العداد حرفاً واحداً.

للتحقق من صحة إدخال النص، استخدم عدادات الجرافيم بدلاً من string.length:

function validateInput(text, maxGraphemes) {
  const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
  const count = Array.from(segmenter.segment(text)).length;
  return count <= maxGraphemes;
}

اقتطاع النص بأمان باستخدام تجزئة الجرافيم

عند اقتطاع النص للعرض، يجب ألا تقطع خلال مجموعة جرافيم. القطع عند فهرس وحدة رمز عشوائي يمكن أن يقسم الرموز التعبيرية أو تسلسلات الأحرف المركبة، مما ينتج مخرجات غير صالحة أو معطلة.

استخدم تجزئة الجرافيم لإيجاد نقاط اقتطاع آمنة:

function truncateText(text, maxGraphemes) {
  const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
  const segments = Array.from(segmenter.segment(text));

  if (segments.length <= maxGraphemes) {
    return text;
  }

  const truncated = segments
    .slice(0, maxGraphemes)
    .map(s => s.segment)
    .join("");

  return truncated + "…";
}

truncateText("Hello 👨‍👩‍👧‍👦 world", 7); // "Hello 👨‍👩‍👧‍👦…"
truncateText("Hello world", 7); // "Hello w…"

يحافظ هذا على مجموعات الجرافيم الكاملة وينتج مخرجات يونيكود صالحة.

لماذا تفشل split() والتعبيرات النمطية في تجزئة الكلمات

يستخدم النهج الشائع لتقسيم النص إلى كلمات split() مع نمط مسافة أو مسافة بيضاء:

const text = "Hello world";
const words = text.split(" "); // ["Hello", "world"]

يعمل هذا مع الإنجليزية واللغات الأخرى التي تفصل الكلمات بمسافات. يفشل تماماً مع اللغات التي لا تستخدم مسافات بين الكلمات.

النص الصيني والياباني والتايلاندي لا يتضمن مسافات بين الكلمات. التقسيم على المسافات يُرجع السلسلة بأكملها كعنصر واحد:

const text = "你好世界"; // "Hello world" in Chinese
const words = text.split(" "); // ["你好世界"]

يرى المستخدم أربع كلمات متميزة، لكن split() تُرجع عنصراً واحداً.

حدود الكلمات في التعبيرات النمطية (\b) تفشل أيضاً مع هذه اللغات لأن محرك التعبيرات النمطية لا يتعرف على حدود الكلمات في الكتابات بدون مسافات.

كيف تعمل تجزئة الكلمات عبر اللغات

تستخدم واجهة برمجة التطبيقات Intl.Segmenter قواعد حدود الكلمات في يونيكود المحددة في UAX 29. تفهم هذه القواعد حدود الكلمات لجميع الكتابات، بما في ذلك تلك التي بدون مسافات.

إنشاء مقسّم بدقة الكلمات:

const segmenter = new Intl.Segmenter("zh", { granularity: "word" });
const text = "你好世界";
const segments = Array.from(segmenter.segment(text));

segments.forEach(({ segment, isWordLike }) => {
  console.log(segment, isWordLike);
});
// "你好" true
// "世界" true

يحدد المقسّم حدود الكلمات بشكل صحيح بناءً على اللغة والنص. تشير خاصية isWordLike إلى ما إذا كان المقطع كلمة (حروف، أرقام، رموز إيديوغرافية) أو محتوى غير كلمة (مسافات، علامات ترقيم).

بالنسبة للنص الإنجليزي، يُرجع المقسّم كلاً من الكلمات والمسافات:

const segmenter = new Intl.Segmenter("en", { granularity: "word" });
const text = "Hello world!";
const segments = Array.from(segmenter.segment(text));

segments.forEach(({ segment, isWordLike }) => {
  console.log(segment, isWordLike);
});
// "Hello" true
// " " false
// "world" true
// "!" false

استخدم خاصية isWordLike لتصفية مقاطع الكلمات من علامات الترقيم والمسافات البيضاء:

function getWords(text, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity: "word" });
  const segments = segmenter.segment(text);
  return Array.from(segments)
    .filter(s => s.isWordLike)
    .map(s => s.segment);
}

getWords("Hello, world!", "en"); // ["Hello", "world"]
getWords("你好世界", "zh"); // ["你好", "世界"]
getWords("สวัสดีครับ", "th"); // ["สวัสดี", "ครับ"] (Thai)

تعمل هذه الدالة مع أي لغة، وتتعامل مع النصوص المفصولة بمسافات والنصوص غير المفصولة بمسافات.

عد الكلمات بدقة

بناء عداد كلمات يعمل دولياً:

function countWords(text, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity: "word" });
  const segments = segmenter.segment(text);
  return Array.from(segments).filter(s => s.isWordLike).length;
}

countWords("Hello world", "en"); // 2
countWords("你好世界", "zh"); // 2
countWords("Bonjour le monde", "fr"); // 3

ينتج هذا عدد كلمات دقيق للمحتوى بأي لغة.

إيجاد الكلمة التي تحتوي على موضع المؤشر

تجد طريقة containing() المقطع الذي يتضمن فهرساً محدداً في السلسلة النصية. هذا مفيد لتحديد الكلمة التي يوجد فيها المؤشر أو المقطع الذي يحتوي على موضع النقر.

const segmenter = new Intl.Segmenter("en", { granularity: "word" });
const text = "Hello world";
const segments = segmenter.segment(text);

const segment = segments.containing(7); // Index 7 is in "world"
console.log(segment);
// { segment: "world", index: 6, isWordLike: true }

إذا كان الفهرس ضمن مسافة بيضاء أو علامة ترقيم، تُرجع containing() ذلك المقطع:

const segment = segments.containing(5); // Index 5 is the space
console.log(segment);
// { segment: " ", index: 5, isWordLike: false }

استخدم هذا لميزات تحرير النصوص، أو تمييز البحث، أو الإجراءات السياقية بناءً على موضع المؤشر.

تقسيم الجمل لمعالجة النصوص

يقسم تجزئة الجمل النص عند حدود الجمل. هذا مفيد للتلخيص، أو معالجة تحويل النص إلى كلام، أو التنقل في المستندات الطويلة.

تفشل الأساليب الأساسية مثل التقسيم على النقاط لأن النقاط تظهر في الاختصارات والأرقام وسياقات أخرى ليست حدود جمل:

const text = "Dr. Smith bought 100.5 shares. He sold them later.";
text.split(". "); // Incorrect: breaks at "Dr." and "100."

تفهم واجهة برمجة التطبيقات Intl.Segmenter قواعد حدود الجمل:

const segmenter = new Intl.Segmenter("en", { granularity: "sentence" });
const text = "Dr. Smith bought 100.5 shares. He sold them later.";
const segments = Array.from(segmenter.segment(text));

segments.forEach(({ segment }) => {
  console.log(segment);
});
// "Dr. Smith bought 100.5 shares. "
// "He sold them later."

يتعامل المقسّم بشكل صحيح مع "Dr." و"100.5" كجزء من الجملة، وليس كحدود جمل.

بالنسبة للنصوص متعددة اللغات، تختلف حدود الجمل حسب اللغة. تتعامل واجهة برمجة التطبيقات مع هذه الاختلافات:

const segmenterEn = new Intl.Segmenter("en", { granularity: "sentence" });
const segmenterJa = new Intl.Segmenter("ja", { granularity: "sentence" });

const textEn = "Hello. How are you?";
const textJa = "こんにちは。お元気ですか。"; // Uses Japanese full stop

Array.from(segmenterEn.segment(textEn)).length; // 2
Array.from(segmenterJa.segment(textJa)).length; // 2

متى تستخدم كل مستوى تفصيل

اختر مستوى التفصيل بناءً على ما تحتاج إلى عده أو تقسيمه:

  • الحرف البياني: استخدمه لعد الأحرف، أو اقتطاع النص، أو تحديد موضع المؤشر، أو أي عملية تحتاج فيها إلى مطابقة إدراك المستخدم للأحرف.

  • الكلمة: استخدمه لعد الكلمات، أو البحث والتمييز، أو تحليل النص، أو أي عملية تحتاج إلى حدود كلمات لغوية عبر اللغات.

  • الجملة: استخدمه لتقسيم تحويل النص إلى كلام، أو التلخيص، أو التنقل في المستندات، أو أي عملية تعالج النص جملة تلو الأخرى.

لا تستخدم تقسيم الحروف البيانية عندما تحتاج إلى حدود الكلمات، ولا تستخدم تقسيم الكلمات عندما تحتاج إلى عد الأحرف. يخدم كل مستوى تفصيل غرضاً مميزاً.

إنشاء وإعادة استخدام المقسمات

إنشاء مقسم غير مكلف، ولكن يمكنك إعادة استخدام المقسمات لتحسين الأداء:

const graphemeSegmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
const wordSegmenter = new Intl.Segmenter("en", { granularity: "word" });

// Reuse these segmenters for multiple strings
function processTexts(texts) {
  return texts.map(text => ({
    text,
    graphemes: Array.from(graphemeSegmenter.segment(text)).length,
    words: Array.from(wordSegmenter.segment(text)).filter(s => s.isWordLike).length
  }));
}

يخزن المقسم بيانات اللغة مؤقتاً، لذا فإن إعادة استخدام نفس النسخة يتجنب التهيئة المتكررة.

التحقق من دعم المتصفح

وصلت واجهة برمجة التطبيقات Intl.Segmenter إلى حالة الأساس في أبريل 2024. تعمل في الإصدارات الحالية من Chrome وFirefox وSafari وEdge. المتصفحات القديمة لا تدعمها.

تحقق من الدعم قبل الاستخدام:

if (typeof Intl.Segmenter !== "undefined") {
  // Use Intl.Segmenter
  const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
  // ...
} else {
  // Fallback for older browsers
  const count = text.length; // Not accurate, but available
}

بالنسبة لتطبيقات الإنتاج التي تستهدف المتصفحات القديمة، فكر في استخدام polyfill أو توفير وظائف محدودة.

الأخطاء الشائعة التي يجب تجنبها

لا تستخدم string.length لعرض عدد الأحرف للمستخدمين. ينتج نتائج غير صحيحة للرموز التعبيرية والأحرف المركبة والنصوص المعقدة.

لا تقسم على المسافات أو تستخدم حدود كلمات regex لتقسيم الكلمات متعدد اللغات. هذه الأساليب تعمل فقط لمجموعة فرعية من اللغات.

لا تفترض أن حدود الكلمات أو الجمل متطابقة عبر اللغات. استخدم التجزئة الواعية بالإعدادات المحلية.

لا تنسَ التحقق من خاصية isWordLike عند عد الكلمات. يؤدي تضمين علامات الترقيم والمسافات البيضاء إلى إنتاج أعداد مضخمة.

لا تقطع السلاسل النصية عند فهارس عشوائية عند الاقتطاع. اقطع دائمًا عند حدود مجموعات الحروف الرسومية لتجنب إنتاج تسلسلات Unicode غير صالحة.

متى لا تستخدم Intl.Segmenter

للعمليات البسيطة التي تقتصر على ASCII حيث تعلم أن النص يحتوي فقط على أحرف لاتينية أساسية، فإن طرق السلاسل النصية الأساسية أسرع وكافية.

عندما تحتاج إلى طول البايت لسلسلة نصية لعمليات الشبكة أو التخزين، استخدم TextEncoder:

const byteLength = new TextEncoder().encode(text).length;

عندما تحتاج إلى العدد الفعلي لوحدات الشفرة للمعالجة منخفضة المستوى للسلاسل النصية، فإن string.length صحيح. هذا نادر في كود التطبيقات.

لمعظم معالجة النصوص التي تتضمن محتوى موجهًا للمستخدم، خاصة في التطبيقات الدولية، استخدم Intl.Segmenter.

كيف يرتبط Intl.Segmenter بواجهات برمجة التطبيقات الأخرى للتدويل

واجهة برمجة التطبيقات Intl.Segmenter هي جزء من واجهة برمجة تطبيقات التدويل في ECMAScript. تشمل واجهات برمجة التطبيقات الأخرى في هذه المجموعة:

  • Intl.DateTimeFormat: تنسيق التواريخ والأوقات وفقًا للإعدادات المحلية
  • Intl.NumberFormat: تنسيق الأرقام والعملات والوحدات وفقًا للإعدادات المحلية
  • Intl.Collator: فرز ومقارنة السلاسل النصية وفقًا للإعدادات المحلية
  • Intl.PluralRules: تحديد صيغ الجمع للأرقام في اللغات المختلفة

توفر هذه واجهات برمجة التطبيقات معًا الأدوات اللازمة لبناء تطبيقات تعمل بشكل صحيح للمستخدمين في جميع أنحاء العالم. استخدم Intl.Segmenter لتجزئة النصوص، واستخدم واجهات برمجة التطبيقات الأخرى في Intl للتنسيق والمقارنة.

مثال عملي: بناء مكون لإحصائيات النص

ادمج تقسيم الحروف البيانية والكلمات لبناء مكون لإحصائيات النص:

function getTextStatistics(text, locale) {
  const graphemeSegmenter = new Intl.Segmenter(locale, {
    granularity: "grapheme"
  });
  const wordSegmenter = new Intl.Segmenter(locale, {
    granularity: "word"
  });
  const sentenceSegmenter = new Intl.Segmenter(locale, {
    granularity: "sentence"
  });

  const graphemes = Array.from(graphemeSegmenter.segment(text));
  const words = Array.from(wordSegmenter.segment(text))
    .filter(s => s.isWordLike);
  const sentences = Array.from(sentenceSegmenter.segment(text));

  return {
    characters: graphemes.length,
    words: words.length,
    sentences: sentences.length,
    averageWordLength: words.length > 0
      ? graphemes.length / words.length
      : 0
  };
}

// Works for any language
getTextStatistics("Hello world! How are you?", "en");
// { characters: 24, words: 5, sentences: 2, averageWordLength: 4.8 }

getTextStatistics("你好世界!你好吗?", "zh");
// { characters: 9, words: 5, sentences: 2, averageWordLength: 1.8 }

تنتج هذه الدالة إحصائيات ذات مغزى للنص بأي لغة، باستخدام قواعد التقسيم الصحيحة لكل لغة.