Как определить, где разрывать текст по символам или словам?
Найдите безопасные позиции для разрыва текста при усечении, переносе и работе с курсором
Введение
Когда вы обрезаете текст, устанавливаете курсор или обрабатываете клики в текстовом редакторе, вам нужно определить, где заканчивается один символ и начинается другой, или где начинаются и заканчиваются слова. Неправильное разбиение текста может разделить эмодзи, разорвать составные символы или неправильно разделить слова.
API 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);
// Усечение перед этим сегментом, чтобы избежать его разрыва
return text.slice(0, segment.index);
}
truncateAtPosition("Hello 👨👩👧👦 world", 10);
// "Hello " (останавливается перед эмодзи, а не в середине)
truncateAtPosition("café", 3);
// "caf" (останавливается перед é)
Эта функция находит сегмент в целевой позиции и усекает текст перед ним, гарантируя, что кластер графем не будет разорван.
Чтобы усечь текст после сегмента, а не перед ним:
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 👨👩👧👦 " (включает полный эмодзи)
Это включает весь сегмент, содержащий целевую позицию.
Определение границ слов для переноса текста
При переносе текста на максимальную ширину вы хотите разрывать строки между словами, а не посередине слова. Используйте сегментацию слов, чтобы найти границу слова перед целевой позицией:
function findWordBreakBefore(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 segment.index;
}
// Если мы находимся в пробеле или пунктуации, разрыв здесь
return position;
}
const text = "Hello world";
findWordBreakBefore(text, 7, "en");
// 5 (пробел перед "world")
const textZh = "你好世界";
findWordBreakBefore(textZh, 6, "zh");
// 6 (граница перед "世界")
Эта функция находит начало слова, которое содержит целевую позицию. Если позиция уже находится в пробеле, она возвращает позицию без изменений.
Для переноса текста с учетом границ слов:
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 (позиция 5 — это пробел, а не слово)
Этот код возвращает слово на позиции курсора вместе с его начальным и конечным индексами или 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 } (выделяет "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);
}
Выбор подходящей степени детализации для границ
Используйте разные степени детализации в зависимости от ваших потребностей:
-
Графема: Используйте при реализации перемещения курсора, удаления символов или любых операций, которые должны учитывать то, что пользователи воспринимают как отдельные символы. Это предотвращает разделение эмодзи, комбинированных символов или других сложных графемных кластеров.
-
Слово: Используйте для выделения слов, проверки орфографии, подсчета слов или любых операций, требующих лингвистических границ слов. Это работает для всех языков, включая те, где слова не разделяются пробелами.
-
Предложение: Используйте для навигации по предложениям, сегментации текста для синтеза речи или любых операций, обрабатывающих текст по предложениям. Это учитывает сокращения и другие контексты, где точки не заканчивают предложения.
Не используйте границы слов, когда вам нужны границы символов, и не используйте границы графем, когда вам нужны границы слов. Каждая из них служит своей конкретной цели.
Поддержка операций с границами в браузерах
API Intl.Segmenter и его метод containing() достигли статуса Baseline в апреле 2024 года. Текущие версии Chrome, Firefox, Safari и Edge поддерживают его. Старые браузеры не поддерживают.
Проверьте поддержку перед использованием:
if (typeof Intl.Segmenter !== "undefined") {
const segmenter = new Intl.Segmenter("ru", { granularity: "word" });
const segments = segmenter.segment(text);
const segment = segments.containing(position);
// Используйте информацию о сегменте
} else {
// Резервное решение для старых браузеров
// Используйте приблизительные границы на основе длины строки
}
Для приложений, ориентированных на старые браузеры, предоставьте резервное поведение с использованием приблизительных границ или используйте полифил, реализующий API 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");
// "你好世界…"
Эта функция находит последнее полное слово перед максимальной длиной и добавляет многоточие, создавая аккуратно обрезанный текст, который не разрывает слова.