كيف تجد مكان فصل النص عند حدود الأحرف أو الكلمات؟

حدد مواضع الفصل الآمنة للنص للاقتطاع والالتفاف وعمليات المؤشر

مقدمة

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

توفر واجهة برمجة التطبيقات Intl.Segmenter في JavaScript طريقة containing() للعثور على مقطع النص في أي موضع في سلسلة نصية. يخبرك هذا بالحرف أو الكلمة التي تحتوي على فهرس معين، وأين يبدأ هذا المقطع، وأين ينتهي. يمكنك استخدام هذه المعلومات للعثور على نقاط فصل آمنة تحترم حدود مجموعات الحروف الرسومية وحدود الكلمات اللغوية عبر جميع اللغات.

يشرح هذا المقال لماذا يفشل فصل النص في مواضع عشوائية، وكيفية العثور على حدود النص باستخدام Intl.Segmenter، وكيفية استخدام معلومات الحدود للاقتطاع ووضع المؤشر وتحديد النص.

لماذا لا يمكنك فصل النص في أي موضع

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

ضع في اعتبارك هذا المثال:

const text = "Hello 👨‍👩‍👧‍👦 world";
const truncated = text.slice(0, 10);
console.log(truncated); // "Hello 👨‍�"

يستخدم رمز العائلة التعبيري 11 وحدة رمزية. القطع عند الموضع 10 يقسم الرمز التعبيري، مما ينتج عنه مخرجات معطلة مع حرف بديل.

بالنسبة للكلمات، الفصل في الموضع الخاطئ ينشئ أجزاء لا تتطابق مع توقعات المستخدم:

const text = "Hello world";
const fragment = text.slice(0, 7);
console.log(fragment); // "Hello w"

يتوقع المستخدمون أن ينقسم النص بين الكلمات، وليس في منتصف الكلمة. إيجاد الحد قبل أو بعد الموضع 7 ينتج نتائج أفضل.

إيجاد مقطع النص في موضع محدد

تُرجع الدالة containing() معلومات حول مقطع النص الذي يتضمن فهرساً محدداً:

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

const segment = segments.containing(6);
console.log(segment);
// { segment: "👋🏽", index: 6, input: "Hello 👋🏽" }

يمتد الرمز التعبيري في الموضع 6 على أربع وحدات رمزية (من الفهرس 6 إلى 9). تُرجع الدالة containing():

  • segment: مجموعة الجرافيم الكاملة كسلسلة نصية
  • index: موضع بداية هذا المقطع في السلسلة النصية الأصلية
  • input: مرجع إلى السلسلة النصية الأصلية

يخبرك هذا أن الموضع 6 موجود داخل الرمز التعبيري، ويبدأ الرمز التعبيري عند الفهرس 6، والرمز التعبيري الكامل هو "👋🏽".

إيجاد نقاط اقتطاع آمنة للنص

لاقتطاع النص دون كسر الأحرف، ابحث عن حد الجرافيم قبل الموضع المستهدف:

function truncateAtPosition(text, maxIndex) {
  const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
  const segments = segmenter.segment(text);

  const segment = segments.containing(maxIndex);

  // Truncate before this segment to avoid breaking it
  return text.slice(0, segment.index);
}

truncateAtPosition("Hello 👨‍👩‍👧‍👦 world", 10);
// "Hello " (stops before the emoji, not in the middle)

truncateAtPosition("café", 3);
// "caf" (stops before é)

تجد هذه الدالة المقطع في الموضع المستهدف وتقتطع قبله، مما يضمن عدم تقسيم مجموعة جرافيم أبداً.

للاقتطاع بعد المقطع بدلاً من قبله:

function truncateAfterPosition(text, minIndex) {
  const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
  const segments = segmenter.segment(text);

  const segment = segments.containing(minIndex);
  const endIndex = segment.index + segment.segment.length;

  return text.slice(0, endIndex);
}

truncateAfterPosition("Hello 👨‍👩‍👧‍👦 world", 10);
// "Hello 👨‍👩‍👧‍👦 " (includes the complete emoji)

يتضمن هذا المقطع بالكامل الذي يحتوي على الموضع المستهدف.

إيجاد حدود الكلمات لالتفاف النص

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

function findWordBreakBefore(text, position, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity: "word" });
  const segments = segmenter.segment(text);

  const segment = segments.containing(position);

  // If we're in a word, break before it
  if (segment.isWordLike) {
    return segment.index;
  }

  // If we're in whitespace or punctuation, break here
  return position;
}

const text = "Hello world";
findWordBreakBefore(text, 7, "en");
// 5 (the space before "world")

const textZh = "你好世界";
findWordBreakBefore(textZh, 6, "zh");
// 6 (the boundary before "世界")

تجد هذه الدالة بداية الكلمة التي تحتوي على الموضع المستهدف. إذا كان الموضع موجوداً بالفعل في مسافة بيضاء، فإنها تُرجع الموضع دون تغيير.

لالتفاف النص الذي يحترم حدود الكلمات:

function wrapTextAtWidth(text, maxLength, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity: "word" });
  const segments = Array.from(segmenter.segment(text));

  const lines = [];
  let currentLine = "";

  for (const { segment, isWordLike } of segments) {
    const potentialLine = currentLine + segment;

    if (potentialLine.length <= maxLength) {
      currentLine = potentialLine;
    } else {
      if (currentLine) {
        lines.push(currentLine.trim());
      }
      currentLine = isWordLike ? segment : "";
    }
  }

  if (currentLine) {
    lines.push(currentLine.trim());
  }

  return lines;
}

wrapTextAtWidth("Hello world from JavaScript", 12, "en");
// ["Hello world", "from", "JavaScript"]

wrapTextAtWidth("你好世界欢迎使用", 6, "zh");
// ["你好世界", "欢迎使用"]

تقوم هذه الدالة بتقسيم النص إلى أسطر تحترم حدود الكلمات وتتناسب مع الطول الأقصى.

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

في محررات النصوص، تحتاج إلى معرفة الكلمة التي يوجد فيها المؤشر لتنفيذ ميزات مثل التحديد بالنقر المزدوج، أو التدقيق الإملائي، أو القوائم السياقية:

function getWordAtPosition(text, position, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity: "word" });
  const segments = segmenter.segment(text);

  const segment = segments.containing(position);

  if (!segment.isWordLike) {
    return null;
  }

  return {
    word: segment.segment,
    start: segment.index,
    end: segment.index + segment.segment.length
  };
}

const text = "Hello world";
getWordAtPosition(text, 7, "en");
// { word: "world", start: 6, end: 11 }

getWordAtPosition(text, 5, "en");
// null (position 5 is the space, not a word)

تُرجع هذه الدالة الكلمة الموجودة عند موضع المؤشر مع فهارس البداية والنهاية الخاصة بها، أو null إذا لم يكن المؤشر في كلمة.

استخدم هذا لتنفيذ تحديد النص بالنقر المزدوج:

function selectWordAtPosition(text, position, locale) {
  const wordInfo = getWordAtPosition(text, position, locale);

  if (!wordInfo) {
    return { start: position, end: position };
  }

  return { start: wordInfo.start, end: wordInfo.end };
}

selectWordAtPosition("Hello world", 7, "en");
// { start: 6, end: 11 } (selects "world")

إيجاد حدود الجمل للتنقل

للتنقل في المستند أو تقسيم تحويل النص إلى كلام، ابحث عن الجملة التي تحتوي على موضع معين:

function getSentenceAtPosition(text, position, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity: "sentence" });
  const segments = segmenter.segment(text);

  const segment = segments.containing(position);

  return {
    sentence: segment.segment,
    start: segment.index,
    end: segment.index + segment.segment.length
  };
}

const text = "Hello world. How are you? Fine thanks.";
getSentenceAtPosition(text, 15, "en");
// { sentence: "How are you? ", start: 13, end: 26 }

تجد هذه الدالة الجملة الكاملة التي تحتوي على الموضع المستهدف، بما في ذلك حدودها.

إيجاد الحد التالي بعد موضع معين

للتحرك للأمام بمقدار جرافيم أو كلمة أو جملة واحدة، كرر عبر المقاطع حتى تجد مقطعاً يبدأ بعد موضعك الحالي:

function findNextBoundary(text, position, granularity, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity });
  const segments = Array.from(segmenter.segment(text));

  for (const segment of segments) {
    if (segment.index > position) {
      return segment.index;
    }
  }

  return text.length;
}

const text = "Hello 👨‍👩‍👧‍👦 world";
findNextBoundary(text, 0, "grapheme", "en");
// 1 (boundary after "H")

findNextBoundary(text, 6, "grapheme", "en");
// 17 (boundary after the family emoji)

findNextBoundary(text, 0, "word", "en");
// 5 (boundary after "Hello")

تجد هذه الدالة المكان الذي يبدأ فيه المقطع التالي، وهو الموضع الآمن لتحريك المؤشر أو اقتطاع النص.

إيجاد الحد السابق قبل موضع معين

للتحرك للخلف بمقدار جرافيم أو كلمة أو جملة واحدة، ابحث عن المقطع الذي يسبق موضعك الحالي:

function findPreviousBoundary(text, position, granularity, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity });
  const segments = Array.from(segmenter.segment(text));

  let previousIndex = 0;

  for (const segment of segments) {
    if (segment.index >= position) {
      return previousIndex;
    }
    previousIndex = segment.index;
  }

  return previousIndex;
}

const text = "Hello 👨‍👩‍👧‍👦 world";
findPreviousBoundary(text, 17, "grapheme", "en");
// 6 (boundary before the family emoji)

findPreviousBoundary(text, 11, "word", "en");
// 6 (boundary before "world")

تجد هذه الدالة المكان الذي يبدأ فيه المقطع السابق، وهو الموضع الآمن لتحريك المؤشر للخلف.

تنفيذ حركة المؤشر مع الحدود

ادمج إيجاد الحدود مع موضع المؤشر لتنفيذ حركة مؤشر صحيحة:

function moveCursorForward(text, cursorPosition, locale) {
  return findNextBoundary(text, cursorPosition, "grapheme", locale);
}

function moveCursorBackward(text, cursorPosition, locale) {
  return findPreviousBoundary(text, cursorPosition, "grapheme", locale);
}

function moveWordForward(text, cursorPosition, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity: "word" });
  const segments = Array.from(segmenter.segment(text));

  for (const segment of segments) {
    if (segment.index > cursorPosition && segment.isWordLike) {
      return segment.index;
    }
  }

  return text.length;
}

function moveWordBackward(text, cursorPosition, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity: "word" });
  const segments = Array.from(segmenter.segment(text));

  let previousWordIndex = 0;

  for (const segment of segments) {
    if (segment.index >= cursorPosition) {
      return previousWordIndex;
    }
    if (segment.isWordLike) {
      previousWordIndex = segment.index;
    }
  }

  return previousWordIndex;
}

const text = "Hello 👨‍👩‍👧‍👦 world";
moveCursorForward(text, 6, "en");
// 17 (moves over the entire emoji)

moveWordForward(text, 0, "en");
// 6 (moves to the start of "world")

تنفذ هذه الدوال حركة المؤشر القياسية في محرر النصوص التي تحترم حدود الجرافيم والكلمات.

إيجاد جميع فرص الفصل في النص

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

function getBreakOpportunities(text, granularity, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity });
  const segments = Array.from(segmenter.segment(text));

  return segments.map(segment => segment.index);
}

const text = "Hello 👨‍👩‍👧‍👦 world";
getBreakOpportunities(text, "grapheme", "en");
// [0, 1, 2, 3, 4, 5, 6, 17, 18, 19, 20, 21, 22]

getBreakOpportunities(text, "word", "en");
// [0, 5, 6, 17, 18, 22]

يُرجع هذا مصفوفة لكل موضع فصل صالح في النص. استخدم هذا لتنفيذ ميزات تخطيط أو تحليل نصوص متقدمة.

التعامل مع الحالات الاستثنائية مع الحدود

عندما يكون الموضع في نهاية النص تمامًا، يُرجع containing() المقطع الأخير:

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

const segment = segments.containing(5);
console.log(segment);
// { segment: "o", index: 4, input: "Hello" }

الموضع في النهاية، لذا يُرجع الحرف البياني الأخير.

عندما يكون الموضع قبل الحرف الأول، يُرجع containing() المقطع الأول:

const segment = segments.containing(0);
console.log(segment);
// { segment: "H", index: 0, input: "Hello" }

بالنسبة للسلاسل النصية الفارغة، لا توجد مقاطع، لذا فإن استدعاء containing() على سلسلة نصية فارغة يُرجع undefined. تحقق من السلاسل النصية الفارغة قبل استخدام containing():

function safeContaining(text, position, granularity, locale) {
  if (text.length === 0) {
    return null;
  }

  const segmenter = new Intl.Segmenter(locale, { granularity });
  const segments = segmenter.segment(text);
  return segments.containing(position);
}

اختيار الدقة المناسبة للحدود

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

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

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

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

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

دعم المتصفحات لعمليات الحدود

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

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

if (typeof Intl.Segmenter !== "undefined") {
  const segmenter = new Intl.Segmenter("en", { granularity: "word" });
  const segments = segmenter.segment(text);
  const segment = segments.containing(position);
  // Use segment information
} else {
  // Fallback for older browsers
  // Use approximate boundaries based on string length
}

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

الأخطاء الشائعة عند إيجاد الحدود

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

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

لا تنسَ التحقق من isWordLike عند العمل مع حدود الكلمات. المقاطع غير الكلمات مثل المسافات وعلامات الترقيم يتم إرجاعها أيضاً بواسطة المقسّم.

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

لا تستدعِ containing() بشكل متكرر للعمليات الحرجة من حيث الأداء. إذا كنت بحاجة إلى حدود متعددة، قم بالتكرار عبر المقاطع مرة واحدة وأنشئ فهرساً.

اعتبارات الأداء لعمليات الحدود

إنشاء مقسّم سريع، لكن التكرار عبر جميع المقاطع يمكن أن يكون بطيئاً للنصوص الطويلة جداً. بالنسبة للعمليات التي تحتاج إلى حدود متعددة، فكر في تخزين معلومات المقاطع مؤقتاً:

class TextBoundaryCache {
  constructor(text, granularity, locale) {
    this.text = text;
    const segmenter = new Intl.Segmenter(locale, { granularity });
    this.segments = Array.from(segmenter.segment(text));
  }

  containing(position) {
    for (const segment of this.segments) {
      const end = segment.index + segment.segment.length;
      if (position >= segment.index && position < end) {
        return segment;
      }
    }
    return this.segments[this.segments.length - 1];
  }

  nextBoundary(position) {
    for (const segment of this.segments) {
      if (segment.index > position) {
        return segment.index;
      }
    }
    return this.text.length;
  }

  previousBoundary(position) {
    let previous = 0;
    for (const segment of this.segments) {
      if (segment.index >= position) {
        return previous;
      }
      previous = segment.index;
    }
    return previous;
  }
}

const cache = new TextBoundaryCache("Hello world", "grapheme", "en");
cache.containing(7);
cache.nextBoundary(7);
cache.previousBoundary(7);

يقوم هذا بتخزين جميع المقاطع مؤقتاً مرة واحدة ويوفر عمليات بحث سريعة لعمليات متعددة.

مثال عملي: اقتطاع النص مع علامة الحذف

ادمج إيجاد الحدود مع الاقتطاع لبناء دالة تقطع النص عند آخر كلمة كاملة قبل الطول الأقصى:

function truncateAtWordBoundary(text, maxLength, locale) {
  if (text.length <= maxLength) {
    return text;
  }

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

  let lastWordEnd = 0;

  for (const segment of segments) {
    const segmentEnd = segment.index + segment.segment.length;

    if (segmentEnd > maxLength) {
      break;
    }

    if (segment.isWordLike) {
      lastWordEnd = segmentEnd;
    }
  }

  if (lastWordEnd === 0) {
    return "";
  }

  return text.slice(0, lastWordEnd).trim() + "…";
}

truncateAtWordBoundary("Hello world from JavaScript", 15, "en");
// "Hello world…"

truncateAtWordBoundary("你好世界欢迎使用", 9, "zh");
// "你好世界…"

تعثر هذه الدالة على آخر كلمة كاملة قبل الطول الأقصى وتضيف علامة حذف، مما ينتج نصًا مقتطعًا نظيفًا لا يقطع الكلمات.