Intl.Segmenter API
Как правильно считать символы, разбивать слова и сегментировать предложения в JavaScript
Введение
Свойство string.length в JavaScript считает кодовые единицы, а не символы, которые видит пользователь. Когда пользователь вводит эмодзи, символы с диакритикой или текст на сложных письменностях, string.length возвращает неправильное количество. Метод split() не работает для языков, где слова не разделяются пробелами. Границы слов в регулярных выражениях не подходят для китайского, японского или тайского текста.
API Intl.Segmenter решает эти проблемы. Она сегментирует текст по стандартам Unicode, учитывая языковые правила каждого языка. Вы можете считать графемы (символы, которые видит пользователь), разбивать текст на слова независимо от языка или делить текст на предложения.
В этой статье объясняется, почему базовые строковые операции не подходят для интернационального текста, что такое кластеры графем и языковые границы, а также как использовать Intl.Segmenter, чтобы корректно работать с текстом для всех пользователей.
Почему string.length не подходит для подсчёта символов
Строки в JavaScript используют кодировку UTF-16. Каждый элемент строки — это 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 + комбинирующий акут) - Нескольких кодовых точек, объединённых вместе, например
"👨👩👧👦"(мужчина + женщина + девочка + мальчик, соединённые нулевой ширины joiner'ами) - Эмодзи с модификаторами цвета кожи, например
"👋🏽"(машущая рука + средний оттенок кожи) - Последовательностей региональных индикаторов для флагов, например
"🇺🇸"(региональный индикатор U + региональный индикатор S)
Стандарт Unicode определяет расширенные графемные кластеры в UAX 29. Эти правила определяют, где пользователь ожидает границы между символами. Когда пользователь нажимает backspace, он ожидает удалить один графемный кластер. Курсор тоже должен перемещаться по графемным кластерам.
JavaScript string.length не считает графемные кластеры. А вот API 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
Пользователь видит семь символов: пять букв, один пробел и один эмодзи. Графемный сегментатор возвращает семь сегментов. JavaScript string.length возвращает десять, потому что эмодзи занимает четыре кодовых единицы.
Каждый объект сегмента содержит:
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…"
Это сохраняет целостные графемные кластеры и обеспечивает корректный вывод в Unicode.
Почему split() и регулярные выражения не подходят для сегментации слов
Обычно для разделения текста на слова используют split() с пробелом или шаблоном пробельных символов:
const text = "Hello world";
const words = text.split(" "); // ["Hello", "world"]
Это работает для английского и других языков, где слова разделяются пробелами. Но полностью не подходит для языков, в которых слова не разделяются пробелами.
В китайском, японском и тайском тексте между словами нет пробелов. Разделение по пробелам возвращает всю строку как один элемент:
const text = "你好世界"; // "Hello world" in Chinese
const words = text.split(" "); // ["你好世界"]
Пользователь видит четыре отдельных слова, но split() возвращает один элемент.
Границы слов в регулярных выражениях (\b) тоже не работают для этих языков, потому что движок regex не распознает границы слов в письменностях без пробелов.
Как работает сегментация слов в разных языках
API Intl.Segmenter использует правила границ слов Unicode, определённые в 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."
API 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» как часть предложения, а не как его границу.
Для многоязычного текста границы предложений зависят от локали. API учитывает эти различия:
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
}));
}
Сегментатор кэширует данные локали, поэтому повторное использование экземпляра позволяет избежать повторной инициализации.
Проверка поддержки в браузерах
API Intl.Segmenter достиг статуса Baseline в апреле 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 для отображения количества символов пользователям. Это даёт некорректные результаты для эмодзи, составных символов и сложных письменностей.
Не разделяйте текст по пробелам и не используйте границы слов в регулярных выражениях для многоязычной сегментации слов. Эти методы работают только для части языков.
Не думайте, что границы слов или предложений одинаковы во всех языках. Используйте сегментацию с учётом локали.
Не забывайте проверять свойство isWordLike при подсчёте слов. Учёт знаков препинания и пробелов приводит к завышенным значениям.
Не обрезайте строки по произвольным индексам при усечении. Всегда обрезайте по границам графемных кластеров, чтобы избежать некорректных последовательностей Unicode.
Когда не стоит использовать Intl.Segmenter
Для простых операций только с ASCII, когда вы уверены, что текст содержит только базовые латинские символы, базовые методы строк работают быстрее и вполне подходят.
Если вам нужна длина строки в байтах для сетевых операций или хранения, используйте TextEncoder:
const byteLength = new TextEncoder().encode(text).length;
Если вам нужно узнать количество кодовых единиц для низкоуровневой работы со строками, string.length — это правильный выбор. В прикладном коде это встречается редко.
Для большинства задач обработки текста, связанных с пользовательским интерфейсом, особенно в международных приложениях, используйте Intl.Segmenter.
Как Intl.Segmenter связан с другими API интернационализации
API Intl.Segmenter входит в ECMAScript Internationalization API. К этому семейству также относятся:
Intl.DateTimeFormat: Форматирует даты и время с учётом локалиIntl.NumberFormat: Форматирует числа, валюты и единицы измерения с учётом локалиIntl.Collator: Сортирует и сравнивает строки с учётом локалиIntl.PluralRules: Определяет формы множественного числа для чисел на разных языках
Вместе эти API предоставляют все инструменты для создания приложений, которые корректно работают для пользователей по всему миру. Используйте Intl.Segmenter для сегментации текста, а остальные API 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 }
Эта функция создаёт полезную статистику текста на любом языке, используя корректные правила сегментации для каждого языка.