API Intl.Segmenter

Как правильно подсчитывать символы, разделять слова и сегментировать предложения в JavaScript

Введение

Свойство JavaScript string.length подсчитывает кодовые единицы, а не символы, воспринимаемые пользователем. Когда пользователи вводят эмодзи, символы с акцентами или текст на сложных письменностях, string.length возвращает неправильное количество. Метод split() не работает для языков, которые не используют пробелы между словами. Границы слов, определяемые регулярными выражениями, не работают для китайского, японского или тайского текста.

API Intl.Segmenter решает эти проблемы. Оно сегментирует текст в соответствии со стандартами Unicode, учитывая лингвистические правила каждого языка. Вы можете подсчитывать графемы (символы, воспринимаемые пользователем), разбивать текст на слова независимо от языка или делить текст на предложения.

В этой статье объясняется, почему базовые операции со строками не подходят для работы с международным текстом, что такое кластеры графем и лингвистические границы, а также как использовать Intl.Segmenter для корректной обработки текста для всех пользователей.

Почему string.length не подходит для подсчета символов

Строки JavaScript используют кодировку UTF-16. Каждый элемент строки JavaScript — это 16-битная кодовая единица, а не полный символ. Свойство string.length подсчитывает именно эти кодовые единицы.

Для базовых символов ASCII одна кодовая единица равна одному символу. Строка "hello" имеет длину 5, что соответствует ожиданиям пользователей.

Для многих других символов это правило не работает. Рассмотрим следующие примеры:

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

Пользователи видят один эмодзи, один семейный эмодзи, два слога на хинди или один флаг. JavaScript подсчитывает подлежащие кодовые единицы.

Это важно, когда вы создаете счетчики символов для текстовых полей, проверяете ограничения длины или обрезаете текст для отображения. Количество, которое возвращает JavaScript, не совпадает с тем, что видят пользователи.

Что такое графемные кластеры

Графемный кластер — это то, что пользователи воспринимают как один символ. Он может состоять из:

  • Одной кодовой точки, например, "a"
  • Базового символа и диакритических знаков, например, "é" (e + комбинированный острый акцент)
  • Нескольких кодовых точек, объединённых вместе, например, "👨‍👩‍👧‍👦" (мужчина + женщина + девочка + мальчик, соединённые нулевой ширины соединителями)
  • Эмодзи с модификаторами цвета кожи, например, "👋🏽" (рука, машущая + средний тон кожи)
  • Последовательностей региональных индикаторов для флагов, например, "🇺🇸" (региональный индикатор U + региональный индикатор S)

Стандарт Unicode определяет расширенные графемные кластеры в UAX 29. Эти правила определяют, где пользователи ожидают границы между символами. Когда пользователь нажимает клавишу Backspace, он ожидает удалить один графемный кластер. Когда курсор перемещается, он должен перемещаться по графемным кластерам.

Метод string.length в JavaScript не учитывает графемные кластеры. Для этого используется 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

Пользователь видит семь символов: пять букв, один пробел и один эмодзи. Сегментатор графем возвращает семь сегментов. Метод 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);
}
// Вывод: "c", "a", "f", "é"

Создание счетчика символов, который работает на международном уровне

Используйте сегментацию графем для создания точных счетчиков символов:

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

// Тест с различными входными данными
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" на китайском
const words = text.split(" "); // ["你好世界"]

Пользователь видит четыре отдельных слова, но split() возвращает один элемент.

Границы слов в регулярных выражениях (\b) также не работают для этих языков, так как движок регулярных выражений не распознает границы слов в письменностях без пробелов.

Как работает сегментация слов в разных языках

Intl.Segmenter API использует правила границ слов 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"); // ["สวัสดี", "ครับ"] (тайский)

Эта функция работает для любого языка, обрабатывая как системы письма с пробелами, так и без них.

Точное подсчет слов

Создайте счетчик слов, который работает для всех языков:

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); // Индекс 7 находится в "world"
console.log(segment);
// { segment: "world", index: 6, isWordLike: true }

Если индекс находится в пробеле или знаке препинания, containing() возвращает этот сегмент:

const segment = segments.containing(5); // Индекс 5 — это пробел
console.log(segment);
// { segment: " ", index: 5, isWordLike: false }

Используйте это для функций редактирования текста, подсветки поиска или контекстных действий на основе позиции курсора.

Сегментация предложений для обработки текста

Сегментация предложений разделяет текст на границах предложений. Это полезно для суммаризации, обработки текста в речь или навигации по длинным документам.

Простые подходы, такие как разделение по точкам, не работают, так как точки встречаются в аббревиатурах, числах и других контекстах, которые не являются границами предложений:

const text = "Dr. Smith bought 100.5 shares. He sold them later.";
text.split(". "); // Неправильно: разрывает на "Dr." и "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 = "こんにちは。お元気ですか。"; // Использует японскую точку

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" });

// Повторное использование этих сегментаторов для нескольких строк
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") {
  // Используйте Intl.Segmenter
  const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
  // ...
} else {
  // Альтернатива для старых браузеров
  const count = text.length; // Не точно, но доступно
}

Для производственных приложений, ориентированных на старые браузеры, рассмотрите возможность использования полифила или предоставления упрощенной функциональности.

Распространённые ошибки, которых следует избегать

Не используйте 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 является частью API интернационализации ECMAScript. Другие 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
  };
}

// Работает для любого языка
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 }

Эта функция создает значимую статистику текста на любом языке, используя правильные правила сегментации для каждого локаля.