Как правильно разбить текст на отдельные символы?

Используйте Intl.Segmenter, чтобы разбивать строки на воспринимаемые пользователем символы, а не на кодовые единицы

Введение

Если попытаться разбить эмодзи «👨‍👩‍👧‍👦» на отдельные символы с помощью стандартных методов строк в JavaScript, результат получится некорректным. Вместо одного семейного эмодзи вы увидите отдельных человечков и невидимые символы. Та же проблема возникает с буквами с акцентами, например «é», флагами вроде «🇺🇸» и многими другими элементами текста, которые на экране выглядят как один символ.

Это происходит потому, что встроенные методы работы со строками в JavaScript воспринимают строки как последовательности 16-битных кодовых единиц UTF-16, а не как символы, которые видит пользователь. Один видимый символ может состоять из нескольких кодовых единиц, объединённых вместе. Если разбивать строку по кодовым единицам, такие символы будут разрушены.

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

Что такое воспринимаемые пользователем символы

Воспринимаемый пользователем символ — это то, что человек воспринимает как отдельный символ при чтении текста. В терминологии Unicode такие символы называются кластером графем. Обычно кластер графем совпадает с тем, что вы видите как один символ на экране.

Буква «a» — это кластер графем, состоящий из одного кода Unicode. Эмодзи «😀» — это кластер графем из двух кодов, которые формируют один эмодзи. Семейный эмодзи «👨‍👩‍👧‍👦» — это кластер графем из семи кодов, соединённых специальными невидимыми символами.

Когда считаешь символы в тексте, нужно считать графемные кластеры, а не кодовые точки или юниты. Если разбиваешь текст на символы, делай это по границам графемных кластеров, а не где попало внутри кластера.

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

Почему split не работает с составными символами

Метод split('') делит строку по границе каждого кодового юнита. Это работает правильно для простых ASCII-символов, где каждый символ — это один кодовый юнит. Для символов, занимающих несколько кодовых юнитов, этот метод не подходит.

const simple = "hello";
console.log(simple.split(''));
// Output: ["h", "e", "l", "l", "o"]

Простой ASCII-текст делится нормально, потому что каждая буква — это один юнит. Но эмодзи и другие сложные символы разваливаются.

const emoji = "😀";
console.log(emoji.split(''));
// Output: ["\ud83d", "\ude00"]

Эмодзи с улыбающимся лицом состоит из двух кодовых юнитов. Метод split('') разбивает его на две отдельные части, которые сами по себе не являются валидными символами. При отображении эти части показываются как символы-заполнители или вообще не отображаются.

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

const flag = "🇺🇸";
console.log(flag.split(''));
// Output: ["\ud83c", "\uddfa", "\ud83c", "\uddf8"]

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

Эмодзи семьи используют символы невидимого соединителя (zero-width joiner), чтобы объединить несколько эмодзи людей в один составной символ.

const family = "👨‍👩‍👧‍👦";
console.log(family.split(''));
// Output: ["👨", "‍", "👩", "‍", "👧", "‍", "👦"]

Эмодзи семьи разбивается на отдельные эмодзи людей и невидимые соединители. Оригинальный составной символ исчезает, и вместо одной семьи вы видите четыре отдельных человечка.

Акцентированные буквы в Unicode можно представить двумя способами. Некоторые из них — это отдельные кодовые точки, а другие состоят из базовой буквы и комбинирующего диакритического знака.

const combined = "é"; // e + combining acute accent
console.log(combined.split(''));
// Output: ["e", "́"]

Когда буква é представлена двумя кодовыми точками (базовая буква плюс комбинированный акцент), при разбиении она разделяется на отдельные части. Акцент оказывается отдельно, что не соответствует ожиданиям пользователей при разбиении текста на символы.

Как правильно разбивать текст с помощью Intl.Segmenter

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

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

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

Вызовите метод segment() со строкой, чтобы получить итератор сегментов. Каждый сегмент содержит текст и информацию о позиции.

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

for (const segment of segments) {
  console.log(segment.segment);
}
// Output:
// "h"
// "e"
// "l"
// "l"
// "o"

Каждый объект сегмента содержит свойство segment с текстом символа и свойство index с его позицией. Можно итерироваться по сегментам напрямую, чтобы получить доступ к каждому символу.

Чтобы получить массив символов, разверните итератор в массив и примените map к тексту сегмента.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "hello";
const characters = [...segmenter.segment(text)].map(s => s.segment);

console.log(characters);
// Output: ["h", "e", "l", "l", "o"]

Этот паттерн преобразует итератор в массив объектов-сегментов, а затем извлекает только текст из каждого сегмента. В итоге получается массив строк — по одной на каждый графемный кластер.

Как правильно разбивать эмодзи на символы

API Intl.Segmenter корректно обрабатывает все эмодзи, включая составные эмодзи, которые используют несколько кодовых точек.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

const emoji = "😀";
const characters = [...segmenter.segment(emoji)].map(s => s.segment);
console.log(characters);
// Output: ["😀"]

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

Флаг-эмодзи остаются одним символом и не разбиваются на региональные индикаторы.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

const flag = "🇺🇸";
const characters = [...segmenter.segment(flag)].map(s => s.segment);
console.log(characters);
// Output: ["🇺🇸"]

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

Семейные и другие составные эмодзи остаются одним символом.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

const family = "👨‍👩‍👧‍👦";
const characters = [...segmenter.segment(family)].map(s => s.segment);
console.log(characters);
// Output: ["👨‍👩‍👧‍👦"]

Все эмодзи людей и невидимые соединители образуют один графемный кластер. Сегментатор воспринимает всю семейную эмодзи как один символ, сохраняя её внешний вид и смысл.

Как разбивать текст с буквами с акцентами

API Intl.Segmenter корректно работает с буквами с диакритическими знаками вне зависимости от их кодировки в Unicode.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

const precomposed = "café"; // precomposed é
const characters = [...segmenter.segment(precomposed)].map(s => s.segment);
console.log(characters);
// Output: ["c", "a", "f", "é"]

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

Когда та же буква закодирована как базовая буква плюс комбинированный диакритический знак, сегментатор всё равно воспринимает её как один символ.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

const decomposed = "café"; // e + combining acute accent
const characters = [...segmenter.segment(decomposed)].map(s => s.segment);
console.log(characters);
// Output: ["c", "a", "f", "é"]

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

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

Корректный подсчёт символов

Одна из частых задач при разбиении текста — посчитать, сколько в нём символов. Метод split('') даёт неверные результаты для текста со сложными символами.

const text = "👨‍👩‍👧‍👦";
console.log(text.split('').length);
// Output: 7

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

Использование Intl.Segmenter даёт точный подсчёт символов.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "👨‍👩‍👧‍👦";
const count = [...segmenter.segment(text)].length;
console.log(count);
// Output: 1

Сегментатор воспринимает эмодзи семьи как один кластер графем, поэтому результат — один. Это совпадает с тем, что видит пользователь на экране.

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

function countCharacters(text) {
  const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
  return [...segmenter.segment(text)].length;
}

console.log(countCharacters("hello"));
// Output: 5

console.log(countCharacters("café"));
// Output: 4

console.log(countCharacters("👨‍👩‍👧‍👦"));
// Output: 1

console.log(countCharacters("🇺🇸"));
// Output: 1

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

Получение символа по позиции

Если нужно получить символ по определённой позиции, сначала можно преобразовать текст в массив кластеров графем.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "Hello 👋";
const characters = [...segmenter.segment(text)].map(s => s.segment);

console.log(characters[6]);
// Output: "👋"

Эмодзи с машущей рукой находится на позиции 6 при подсчёте по кластерам графем. Если использовать стандартную индексацию массива по строке, получится некорректный результат, потому что эмодзи занимает несколько кодовых единиц.

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

Корректное реверсирование текста

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

const text = "Hello 👋";
console.log(text.split('').reverse().join(''));
// Output: "�� olleH"

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

Использование Intl.Segmenter для реверса текста сохраняет целостность символов.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "Hello 👋";
const characters = [...segmenter.segment(text)].map(s => s.segment);
const reversed = characters.reverse().join('');
console.log(reversed);
// Output: "👋 olleH"

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

Понимание параметра locale

Конструктор Intl.Segmenter принимает параметр локали, но для сегментации графем локаль почти не влияет. Границы кластеров графем определяются по правилам Unicode, которые в основном не зависят от языка.

const segmenterEn = new Intl.Segmenter('en', { granularity: 'grapheme' });
const segmenterJa = new Intl.Segmenter('ja', { granularity: 'grapheme' });

const text = "Hello 👋 こんにちは";

const charactersEn = [...segmenterEn.segment(text)].map(s => s.segment);
const charactersJa = [...segmenterJa.segment(text)].map(s => s.segment);

console.log(charactersEn);
console.log(charactersJa);
// Both outputs are identical

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

Тем не менее, указывать локаль всё равно полезно — для согласованности с другими Intl API и на случай, если в будущих версиях Unicode появятся правила, зависящие от локали.

Повторное использование сегментаторов для производительности

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

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

const texts = [
  "Hello 👋",
  "Café ☕",
  "World 🌍",
  "Family 👨‍👩‍👧‍👦"
];

texts.forEach(text => {
  const characters = [...segmenter.segment(text)].map(s => s.segment);
  console.log(characters);
});
// Output:
// ["H", "e", "l", "l", "o", " ", "👋"]
// ["C", "a", "f", "é", " ", "☕"]
// ["W", "o", "r", "l", "d", " ", "🌍"]
// ["F", "a", "m", "i", "l", "y", " ", "👨‍👩‍👧‍👦"]

Этот подход эффективнее, чем создавать новый сегментатор для каждой строки. Разница в производительности становится заметной при обработке большого объёма текста.

Комбинирование сегментации графем с другими операциями

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

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

function truncateByCharacters(text, maxLength) {
  const characters = [...segmenter.segment(text)].map(s => s.segment);

  if (characters.length <= maxLength) {
    return text;
  }

  return characters.slice(0, maxLength).join('') + '...';
}

console.log(truncateByCharacters("Hello 👋 World", 7));
// Output: "Hello 👋..."

console.log(truncateByCharacters("Family 👨‍👩‍👧‍👦 Photo", 8));
// Output: "Family 👨‍👩‍👧‍👦..."

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

Работа с позициями в строке

Объекты сегментов, возвращаемые Intl.Segmenter, содержат свойство index, которое указывает позицию в исходной строке. Эта позиция измеряется в кодовых единицах, а не в кластерах графем.

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

for (const segment of segmenter.segment(text)) {
  console.log(`Character "${segment.segment}" starts at position ${segment.index}`);
}
// Output:
// Character "H" starts at position 0
// Character "e" starts at position 1
// Character "l" starts at position 2
// Character "l" starts at position 3
// Character "o" starts at position 4
// Character " " starts at position 5
// Character "👋" starts at position 6

Эмодзи с машущей рукой начинается с позиции 6 по кодовым единицам, хотя в строке занимает позиции 6 и 7. Следующий символ начнётся с позиции 8. Эта информация полезна, если нужно сопоставить позиции графем и позиции в строке, например, для извлечения подстрок.

Обработка пустых строк и крайних случаев

API Intl.Segmenter корректно обрабатывает пустые строки и другие крайние случаи.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

const empty = "";
const characters = [...segmenter.segment(empty)].map(s => s.segment);
console.log(characters);
// Output: []

Пустая строка возвращает пустой массив сегментов. Никакой специальной обработки не требуется.

Символы пробелов считаются отдельными графемными кластерами.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

const whitespace = "a b\tc\nd";
const characters = [...segmenter.segment(whitespace)].map(s => s.segment);
console.log(characters);
// Output: ["a", " ", "b", "\t", "c", "\n", "d"]

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