كيف تجد أماكن قطع النص عند حدود الأحرف أو الكلمات؟
حدد مواضع قطع النص الآمنة لعمليات الاقتطاع والالتفاف ومؤشر الكتابة
مقدمة
عندما تقوم باقتطاع النص، أو تحديد موضع المؤشر، أو التعامل مع النقرات في محرر النصوص، تحتاج إلى معرفة أين ينتهي حرف ويبدأ آخر، أو أين تبدأ الكلمات وتنتهي. إن تقسيم النص في الموضع الخاطئ قد يؤدي إلى تقسيم الرموز التعبيرية، أو قطع الأحرف المركبة، أو تقسيم الكلمات بشكل غير صحيح.
توفر واجهة برمجة التطبيقات Intl.Segmenter في جافا سكريبت طريقة containing() للعثور على مقطع النص في أي موضع في سلسلة نصية. هذا يخبرك أي حرف أو كلمة تحتوي على فهرس معين، وأين يبدأ ذلك المقطع، وأين ينتهي. يمكنك استخدام هذه المعلومات للعثور على نقاط قطع آمنة تحترم حدود مجموعات الرموز الصوتية وحدود الكلمات اللغوية عبر جميع اللغات.
يشرح هذا المقال سبب فشل تقسيم النص في مواضع عشوائية، وكيفية العثور على حدود النص باستخدام Intl.Segmenter، وكيفية استخدام معلومات الحدود للاقتطاع، وتحديد موضع المؤشر، واختيار النص.
لماذا لا يمكنك تقسيم النص في أي موضع
تتكون سلاسل جافا سكريبت من وحدات رمزية، وليس من أحرف كاملة. يمكن أن يمتد رمز تعبيري واحد، أو حرف مشكّل، أو علم عبر وحدات رمزية متعددة. إذا قمت بقطع سلسلة نصية عند فهرس عشوائي، فأنت تخاطر بتقسيم حرف في المنتصف.
ضع في اعتبارك هذا المثال:
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 (الحد بعد "H")
findNextBoundary(text, 6, "grapheme", "en");
// 17 (الحد بعد رمز العائلة التعبيري)
findNextBoundary(text, 0, "word", "en");
// 5 (الحد بعد "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 (الحد قبل رمز العائلة التعبيري)
findPreviousBoundary(text, 11, "word", "en");
// 6 (الحد قبل "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 (ينتقل فوق الرمز التعبيري بالكامل)
moveWordForward(text, 0, "en");
// 6 (ينتقل إلى بداية "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
}
بالنسبة للتطبيقات التي تستهدف المتصفحات القديمة، قدم سلوك احتياطي باستخدام حدود تقريبية، أو استخدم بديلاً يطبق واجهة برمجة التطبيقات 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");
// "你好世界…"
تجد هذه الدالة آخر كلمة كاملة قبل الحد الأقصى للطول وتضيف علامة الحذف، مما ينتج نصًا مقتطعًا نظيفًا لا يقطع الكلمات.