Статья 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)
Флаги кодируются парой букв-индикаторов (🇷 + 🇺 = 🇷🇺). Каждый индикатор — отдельная кодовая точка, но пара воспринимается как один символ:
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)
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-примеры работают напрямую в браузере через кнопку выше — библиотек не нужно.