Деление на графемы

В текстовых данных символ не всегда соответствует одному отображаемому элементу. В Unicode один «видимый символ» может состоять из нескольких кодовых точек: базового знака, диакритических модификаторов, соединителей нулевой ширины, региональных индикаторов или последовательностей сложения. Такие визуальные единицы называются графемными кластерами.

Графемный кластер — минимальная единица текста, которую пользователь воспринимает как один символ при чтении и редактировании. Например, буква «e» с диакритикой может быть представлена как:

  • базовый символ e
  • комбинируемый акцент ◌́

С точки зрения строки это два Unicode-символа, но визуально и семантически это одна графема.

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


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

Стандартная операция разбиения строки в Jav * aScript:

const chars = "école".split("");

разбивает строку по UTF-16 кодовым единицам, а не по графемам. Результат может содержать:

  • разорванные диакритические знаки
  • некорректные последовательности суррогатных пар
  • фрагменты визуально неделимых символов

Это приводит к ошибкам в:

  • подсчёте символов
  • позиционировании курсора
  • обрезке текста
  • валидации длины ввода
  • токенизации текста в редакторах

Unicode grapheme clusters

Unicode определяет правила сегментации текста на графемные кластеры в стандарте Unicode Text Segmentation (UAX #29). Согласно этим правилам:

Графема может включать:

  • базовую букву
  • один или несколько combining marks
  • zero-width joiner sequences
  • региональные индикаторы (флаги)
  • сложные последовательности для некоторых письменностей

Примеры:

  • a + ◌́ → одна графема
  • n + ◌̃ → одна графема
  • последовательности с соединителями → одна графема
  • пары региональных индикаторов → одна графема

Intl.Segmenter и графемная сегментация

Современный JavaScript предоставляет стандартный API для корректной сегментации текста — Intl.Segmenter.

Он позволяет разбивать текст на:

  • символы (графемы)
  • слова
  • предложения

Для графем используется режим granularity: "grapheme".


Создание сегментера

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

Параметр локали влияет на языковые правила, но графемная сегментация в основном опирается на Unicode Standard, а не на язык.


Получение графемных сегментов

Метод segment() возвращает итерируемую структуру сегментов:

const text = "école";

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

const segments = segmenter.segment(text);

for (const part of segments) {
  console.log(part.segment);
}

Каждый элемент содержит:

  • segment — строка графемы
  • index — позиция начала
  • input — исходная строка

Сравнение с split и spread

split

"école".split("")

Разбивает строку по UTF-16 кодам, не учитывая графемные кластеры.

spread оператор

[..."école"]

Поведение аналогично split(""), также не учитывает комбинируемые символы.

Intl.Segmenter

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

[...segmenter.segment("école")].map(s => s.segment);

Возвращает корректные графемы.


Сложные случаи Unicode

Комбинируемые символы

const text = "a\u0301"; // a + combining acute accent

Визуально: «á»

  • split/ spread → 2 элемента
  • Segmenter → 1 графема

Суррогатные пары

Некоторые символы (например, редкие иероглифы или математические символы) кодируются двумя UTF-16 единицами.

const text = "?";
  • split → ломает символ
  • Segmenter → сохраняет целостность

Zero-width joiner sequences

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

const text = "क्\u200Dष";

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


Итерация по графемам

Intl.Segmenter возвращает ленивый итератор, что позволяет эффективно обрабатывать большие строки.

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

const iterator = segmenter.segment("пример текста");

for (const { segment, index } of iterator) {
  console.log(segment, index);
}

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


Использование в валидации длины строки

Ограничение длины текста часто должно учитывать не кодовые единицы, а графемы.

function graphemeLength(text) {
  const segmenter = new Intl.Segmenter("en", {
    granularity: "grapheme"
  });

  let count = 0;

  for (const _ of segmenter.segment(text)) {
    count++;
  }

  return count;
}

Это корректно обрабатывает:

  • диакритические знаки
  • составные символы
  • сложные Unicode-последовательности

Сравнение производительности подходов

Наивный подход

  • split("") — быстро
  • но некорректно для Unicode

Регулярные выражения

  • /./gu учитывает суррогатные пары
  • но не учитывает графемные кластеры

Intl.Segmenter

  • немного медленнее низкоуровневых методов
  • соответствует стандарту Unicode
  • обеспечивает корректную сегментацию текста

Применение в интерфейсах

Графемная сегментация используется в:

  • текстовых редакторах
  • системах подсчёта символов
  • курсорных навигациях
  • поиске и подсветке текста
  • обрезке строк без повреждения символов

Пример безопасной обрезки:

function truncateByGraphemes(text, max) {
  const segmenter = new Intl.Segmenter("en", {
    granularity: "grapheme"
  });

  let result = "";
  let count = 0;

  for (const { segment } of segmenter.segment(text)) {
    if (count >= max) break;
    result += segment;
    count++;
  }

  return result;
}

Поведение локали в Segmenter

Хотя графемная сегментация основана на Unicode, локаль влияет на:

  • правила разбиения слов и предложений
  • особенности языка при других уровнях гранулярности

Для grapheme локаль обычно не изменяет результат, но влияет на совместимость и поведение API в будущем.


Отличие графем от символов в JavaScript

В JavaScript «символ» строки — это UTF-16 code unit. Это техническая единица хранения, а не лингвистическая единица.

Графема же — пользовательская единица восприятия текста.

Различие:

  • UTF-16 unit → низкоуровневое представление
  • code point → Unicode-символ
  • grapheme cluster → визуальный символ

Intl.Segmenter оперирует последним уровнем.


Обработка индексов сегментов

Каждый сегмент содержит index, который позволяет сопоставлять графемы с исходной строкой:

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

for (const part of segmenter.segment("test")) {
  console.log(part.segment, part.index);
}

Это полезно для:

  • выделения текста
  • редактирования строк
  • реализации курсора

Ограничения и особенности

  • API доступен в современных окружениях
  • поведение основано на Unicode Standard
  • не предназначен для арифметической обработки текста
  • результат может отличаться от старых regex-подходов
  • требует аккуратной работы при смешанных языках

Практическая значимость графемной модели

Переход от кодовых единиц к графемам устраняет фундаментальные проблемы работы с текстом:

  • некорректные длины строк
  • разрывы символов при обрезке
  • ошибки курсора
  • повреждение комбинированных символов
  • несовместимость между языками и письменностями

Intl.Segmenter формализует этот уровень представления текста в стандартизированном API JavaScript.