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

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

Введение

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

Это происходит потому, что встроенное разделение строк в JavaScript обрабатывает строки как последовательности кодовых единиц 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(''));
// Вывод: ["h", "e", "l", "l", "o"]

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

const emoji = "😀";
console.log(emoji.split(''));
// Вывод: ["\ud83d", "\ude00"]

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

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

const flag = "🇺🇸";
console.log(flag.split(''));
// Вывод: ["\ud83c", "\uddfa", "\ud83c", "\uddf8"]

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

Эмодзи семьи используют символы нулевой ширины для объединения нескольких эмодзи людей в один составной символ.

const family = "👨‍👩‍👧‍👦";
console.log(family.split(''));
// Вывод: ["👨", "‍", "👩", "‍", "👧", "‍", "👦"]

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

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

const combined = "é"; // e + комбинирующий острый акцент
console.log(combined.split(''));
// Вывод: ["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);
}
// Вывод:
// "h"
// "e"
// "l"
// "l"
// "o"

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

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

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

console.log(characters);
// Вывод: ["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);
// Вывод: ["😀"]

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

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

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

const flag = "🇺🇸";
const characters = [...segmenter.segment(flag)].map(s => s.segment);
console.log(characters);
// Вывод: ["🇺🇸"]

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

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

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

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

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

Разделение текста с акцентированными буквами

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

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

const precomposed = "café"; // предварительно составленное é
const characters = [...segmenter.segment(precomposed)].map(s => s.segment);
console.log(characters);
// Вывод: ["c", "a", "f", "é"]

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

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

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

const decomposed = "café"; // e + комбинирующий острый акцент
const characters = [...segmenter.segment(decomposed)].map(s => s.segment);
console.log(characters);
// Вывод: ["c", "a", "f", "é"]

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

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

Правильный подсчёт символов

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

const text = "👨‍👩‍👧‍👦";
console.log(text.split('').length);
// Вывод: 7

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

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

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

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

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

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

console.log(countCharacters("hello"));
// Вывод: 5

console.log(countCharacters("café"));
// Вывод: 4

console.log(countCharacters("👨‍👩‍👧‍👦"));
// Вывод: 1

console.log(countCharacters("🇺🇸"));
// Вывод: 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]);
// Вывод: "👋"

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

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

Корректное обращение строки

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

const text = "Hello 👋";
console.log(text.split('').reverse().join(''));
// Вывод: "�� 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);
// Вывод: "👋 olleH"

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

Понимание параметра локали

Конструктор 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);
// Оба вывода идентичны

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

Тем не менее, указание локали всё же является хорошей практикой для согласованности с другими API Intl и на случай, если будущие версии 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);
});
// Результат:
// ["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));
// Результат: "Hello 👋..."

console.log(truncateByCharacters("Family 👨‍👩‍👧‍👦 Photo", 8));
// Результат: "Family 👨‍👩‍👧‍👦..."

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

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

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

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

for (const segment of segmenter.segment(text)) {
  console.log(`Символ "${segment.segment}" начинается на позиции ${segment.index}`);
}
// Результат:
// Символ "H" начинается на позиции 0
// Символ "e" начинается на позиции 1
// Символ "l" начинается на позиции 2
// Символ "l" начинается на позиции 3
// Символ "o" начинается на позиции 4
// Символ " " начинается на позиции 5
// Символ "👋" начинается на позиции 6

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

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

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

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

const empty = "";
const characters = [...segmenter.segment(empty)].map(s => s.segment);
console.log(characters);
// Вывод: []

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

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

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);
// Вывод: ["a", " ", "b", "\t", "c", "\n", "d"]

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