Перейти к содержанию

Статья 4. Графемные кластеры: что видит пользователь

Проблема: три разных «длины» строки

Даже зная кодировку, «длина строки» — неоднозначное понятие. У строки есть три разных «длины»:

Уровень Что считаем Пример для "é"
Байты Физические байты в памяти 2 байта, если é хранится как одна кодовая точка U+00E9 → C3 A9; или 3 байта, если как e + combining accent (U+0065 U+0301) → 65 CC 81
Кодовые точки Unicode scalar values 1 (U+00E9) или 2 (U+0065 + U+0301)
Графемные кластеры То, что видит пользователь 1 — всегда

Графемный кластер (grapheme cluster) — это минимальная единица письменной речи с точки зрения пользователя: то, что воспринимается как один «символ» при чтении.


1. Виды графемных кластеров

1. Буква + диакритика (combining marks)

"ё"  =  U+0435 (е) + U+0308 (combining diaeresis)   →  1 кластер, 2 кодовые точки
"à"  =  U+0061 (a) + U+0300 (combining grave)       →  1 кластер, 2 кодовые точки
"ặ"  =  U+0061 + U+0323 + U+0306                    →  1 кластер, 3 кодовые точки

2. Флаги стран (Regional Indicator pairs)

Флаги кодируются парой букв-индикаторов (🇷 + 🇺 = 🇷🇺). Каждый индикатор — отдельная кодовая точка, но пара воспринимается как один символ:

🇷🇺  =  U+1F1F7 (Regional Indicator R) + U+1F1FA (Regional Indicator U)
       2 кодовые точки → 1 кластер

3. Emoji с ZWJ (Zero Width Joiner)

Сложные эмодзи собираются из нескольких простых, соединённых символом U+200D (ZWJ):

👨‍👩‍👧  =  👨 + ZWJ + 👩 + ZWJ + 👧
        =  U+1F468 + U+200D + U+1F469 + U+200D + U+1F467
        =  5 кодовых точек → 1 кластер

👩‍💻  =  👩 + ZWJ + 💻  =  3 кодовые точки → 1 кластер

4. Emoji с модификаторами цвета кожи (Fitzpatrick)

👋🏽  =  👋 (U+1F44B) + 🏽 (U+1F3FD, Fitzpatrick Type-4)
       2 кодовые точки → 1 кластер

5. Хангыль (корейский)

Корейские слоги могут быть записаны как единый пресобранный символ (NFC) или как последовательность джамо (NFD):

"각"  =  U+AC01 (единый слог)               →  1 кластер, 1 кодовая точка
"각"  =  U+1100 + U+1161 + U+11A8 (джамо)   →  1 кластер, 3 кодовые точки

6. Variation Selectors (VS)

Variation Selectors — невидимые символы, которые меняют способ отображения предшествующего символа, не меняя его идентичность:

  • U+FE0E (VS-15) — принудительно текстовое представление (чёрно-белое)
  • U+FE0F (VS-16) — принудительно эмодзи-представление (цветное)
☎    =  U+260E                      →  текстовый символ (чёрный телефон)
☎️   =  U+260E + U+FE0F             →  2 кодовые точки, 1 кластер, цветной эмодзи
☎︎   =  U+260E + U+FE0E             →  2 кодовые точки, 1 кластер, принудительно текст

❤    =  U+2764                      →  текстовое сердце
❤️   =  U+2764 + U+FE0F             →  эмодзи-сердце

Это объясняет, почему «один и тот же» символ может быть разным при подсчёте кодовых точек — нужно проверять наличие VS:

s1 = "☎"   # только U+260E
s2 = "☎️"  # U+260E + U+FE0F

len(s1)  # 1
len(s2)  # 2 — VS-16 считается отдельной кодовой точкой!

# Но оба — 1 графемный кластер:
import grapheme
grapheme.length(s1)  # 1
grapheme.length(s2)  # 1

Помимо эмодзи, VS используются в CJK: диапазон U+E0100..U+E01EF содержит 240 variation selectors для иероглифов с разными региональными начертаниями.


2. Кто «соединяет» кодовые точки в кластеры?

Графемные кластеры — это не работа шрифта или рендерера. Границы кластеров определяются на уровне обработки текста — ещё до того, как текст попадёт на экран.

Это делает текстовый движок — библиотека, которая реализует алгоритм UAX #29 — Unicode Text Segmentation:

Платформа Кто выполняет алгоритм
Браузер (Chrome, Firefox, Safari) Движок Blink/Gecko/WebKit → используют ICU
JavaScript Intl.Segmenter() → через V8/SpiderMonkey → ICU
Python Библиотека grapheme — собственная реализация UAX #29
macOS / iOS CoreText (системная библиотека обработки текста)
Windows DirectWrite / Uniscribe
Linux / GTK Pango + ICU
Java java.text.BreakIterator → ICU

Именно эта библиотека решает, куда перемещается курсор, что удаляет Backspace, где можно разбить строку при переносе.

Шрифт отвечает за другое — за визуальное начертание (шейпинг). Если шрифт не поддерживает ZWJ-последовательность 👨‍👩‍👧, вы увидите три отдельных эмодзи — но это всё равно будет один графемный кластер, и курсор перепрыгнет всю группу целиком.


3. Данные UCD: файл GraphemeBreakProperty.txt

Алгоритм UAX #29 опирается на ключевой файл UCD:

Файл: GraphemeBreakProperty.txt

Этот файл назначает каждой кодовой точке свойство Grapheme_Cluster_Break (GCB) — одно из ~15 значений:

Значение GCB Что это Примеры
Control Управляющие символы (обязательный разрыв) U+000A (LF), U+000D (CR)
Extend Комбинирующие знаки (приклеиваются к предыдущему) U+0301 (acute), U+0308 (diaeresis), U+0300 (grave)
ZWJ Zero Width Joiner (склейка эмодзи) U+200D
Regional_Indicator Буквы-индикаторы для флагов U+1F1E6..U+1F1FF (🇦..🇿)
Prepend Символы, присоединяющиеся к следующему Некоторые арабские и брахмические знаки
SpacingMark Видимые комбинирующие знаки Деванагари гласные (ा, ि), тамильские
L Хангыль: начальный согласный (Lead) ㄱ (U+1100), ㄴ (U+1102)
V Хангыль: гласный (Vowel) ㅏ (U+1161), ㅓ (U+1163)
T Хангыль: конечный согласный (Tail) ㄱ (U+11A8), ㄴ (U+11AB)
LV Хангыль: слог без хвоста 가 (U+AC00), 나 (U+B098)
LVT Хангыль: слог с хвостом 각 (U+AC01), 난 (U+B09C)

Дополнительно используется файл emoji-data.txt — он определяет свойство Extended_Pictographic для всех эмодзи-базовых символов (👨, 👩, ❤, ☎ и т. д.).

Формат GraphemeBreakProperty.txt:

# GraphemeBreakProperty.txt (фрагмент)
0300..036F    ; Extend # Mn [112] COMBINING GRAVE ACCENT..COMBINING LATIN SMALL LETTER X
200D          ; ZWJ    # Cf       ZERO WIDTH JOINER
1F1E6..1F1FF  ; Regional_Indicator # So [26] REGIONAL INDICATOR SYMBOL LETTER A..Z

4. Алгоритм UAX #29: как определяются границы

UAX #29 — это конечный автомат. Он идёт по строке слева направо и между каждой парой соседних кодовых точек решает: ставить разрыв (÷) или не ставить (×). Решение основано на значениях Grapheme_Cluster_Break левого и правого символов.

Ключевые правила (упрощённо):

Правило Левый GCB Правый GCB Результат Пример
GB3 CR LF × (не разбивать) \r\n = 1 кластер
GB4/5 Control/CR/LF любой ÷ (разбивать) После \n всегда разрыв
GB9 любой Extend × e + ◌́ = 1 кластер
GB9 любой ZWJ × Перед ZWJ не разбивать
GB9a любой SpacingMark × Деванагари: क + ा = 1
GB9b Prepend любой × Прикрепить к следующему
GB11 ExtPict + Extend* + ZWJ ExtPict × 👨 + ZWJ + 👩 = 1
GB12/13 Regional_Indicator Regional_Indicator × (попарно) 🇷🇺 = 1 кластер
GB999 любой любой ÷ По умолчанию — разбивать

Разбор примера: флаг 🇷🇺

Вход:         U+1F1F7          U+1F1FA
GCB:          Regional_Indicator  Regional_Indicator

Проверка: GB12 — «Не разбивать между парой Regional Indicator,
если перед текущей позицией чётное число RI»

Результат:  U+1F1F7 × U+1F1FA  →  1 кластер (🇷🇺)

А если подряд 4 индикатора? Строка 🇷🇺🇫🇷 (U+1F1F7 U+1F1FA U+1F1EB U+1F1F7):

U+1F1F7  ×  U+1F1FA  ÷  U+1F1EB  ×  U+1F1F7
   RI    ×    RI     ÷     RI    ×    RI
  (пара: 0 RI до → ×)    (пара: 2 RI до → ÷ затем новая пара)

Результат: 🇷🇺 ÷ 🇫🇷  →  2 кластера (два флага)

Разбор примера: семья 👨‍👩‍👧

Вход:    U+1F468   U+200D   U+1F469   U+200D   U+1F467
GCB:     ExtPict    ZWJ      ExtPict    ZWJ      ExtPict

GB9:  ExtPict × ZWJ    (перед ZWJ не разбивать)
GB11: ZWJ × ExtPict    (ZWJ склеивает эмодзи)
GB9:  ExtPict × ZWJ    (перед ZWJ не разбивать)
GB11: ZWJ × ExtPict    (ZWJ склеивает эмодзи)

Результат:  вся цепочка × × × ×  →  1 кластер (👨‍👩‍👧)

Разбор примера: буква с акцентом (é в NFD)

Вход:    U+0065 (e)    U+0301 (combining acute)
GCB:     Other          Extend

GB9: «Не разбивать перед Extend»

Результат:  U+0065 × U+0301  →  1 кластер (é)

5. Цепочка ответственности: от данных к экрану

UCD файлы
│  GraphemeBreakProperty.txt  — свойства GCB для каждой кодовой точки
│  emoji-data.txt             — свойство Extended_Pictographic
Алгоритм UAX #29
│  Правила GB1..GB999 — конечный автомат для определения границ
Реализация в библиотеке
│  ICU, grapheme, Intl.Segmenter, CoreText, Pango
Приложение
│  Редактор, браузер, терминал используют результат для:
│  курсора, выделения, Backspace, truncate, word-wrap
Шрифт / рендерер
   Harfbuzz, CoreText, DirectWrite — рисуют глифы
   (границы кластеров определены ДО рендеринга)

6. Почему это важно на практике

Курсор в текстовом редакторе. Нажатие должно сдвигать курсор на один кластер, а не на одну кодовую точку. Иначе курсор «застрянет» внутри флага или эмодзи.

Удаление символа. Backspace должен удалять один кластер целиком — иначе флаг 🇷🇺 превратится в 🇷 или артефакт.

Подсчёт «видимых» символов. При обрезке строки для отображения (truncate) нужно считать кластеры, а не байты и не кодовые точки.

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


7. Как считать кластеры в коде

# Python — через библиотеку grapheme
# pip install grapheme
import grapheme

s = "👨‍👩‍👧 café 🇷🇺"
print(len(s))                       # 13 — кодовые точки (врёт!)
print(grapheme.length(s))           # 8  — кластеры (правильно)
print(list(grapheme.graphemes(s)))  # ['👨\u200d👩\u200d👧', ' ', 'c', 'a', 'f', 'é', ' ', '🇷🇺']
// JavaScript — Intl.Segmenter (ES2022)
const segmenter = new Intl.Segmenter();
const s = "👨‍👩‍👧 café 🇷🇺";
const clusters = [...segmenter.segment(s)].map(s => s.segment);
console.log(clusters.length);  // 8
console.log(s.length);         // 18 — кодовые единицы UTF-16 (врёт!)

Зависимости

Для Python-примеров с grapheme нужен pip install grapheme. JavaScript-примеры работают напрямую в браузере через кнопку выше — библиотек не нужно.