Графемы и нормализация
Предпосылки: Кодирование текста, Двоичная система и байты.
← Кодирование текста | Порядок байтов →
После UTF-8 кажется, что строка стала понятной сущностью: последовательность code points, каждый кодируется байтами, длина считается перебором. Но пользователь копирует café из одного места и вставляет в другое — и поиск не находит совпадения, хотя на экране буквы идентичны. Нажимает Backspace на флаге 🇯🇵 — исчезает не половина, а весь флаг. Спрашивает длину строки 👨👩👧👦 — получает 7, 11 или 25 в зависимости от языка программирования. Один и тот же «символ» оказывается разными вещами: один code point, несколько code points, несколько байтов на code point. Там, где они расходятся, ломаются сравнение, поиск, курсор, длина.
Идея: три уровня «одного символа»
Человек смотрит на экран и говорит «одна буква». Программа видит три возможных уровня:
- Байт — единица памяти.
éв UTF-8 может занимать 2 байта или 3 — в зависимости от способа записи. - Code point — единица каталога Unicode.
é— это U+00E9 или последовательность U+0065 + U+0301. - Графемный кластер — то, что человек воспринимает как один символ.
é— это всегда один кластер, независимо от того, сколько под ним code points.
Пока операции работают на уровне, который совпадает с пользовательским восприятием, всё хорошо. Как только они опускаются ниже — начинаются сюрпризы.
Канонические формы: NFC и NFD
Буква é в Unicode может быть записана двумя способами:
| Форма | Code points | Описание |
|---|---|---|
| Precomposed | U+00E9 | Один code point: «e с акутом» |
| Decomposed | U+0065 + U+0301 | Два code points: «e» + «combining acute accent» |
Визуально — одинаковы. На уровне байтов — разные последовательности. Если программа сравнивает строки побайтово, "café" (precomposed) ≠ "café" (decomposed), хотя человек видит один и тот же текст.
Unicode определяет нормализацию (normalization) — приведение к канонической форме перед сравнением:
- NFC (Canonical Decomposition, followed by Canonical Composition) — результат: составные символы.
é= U+00E9. - NFD (Canonical Decomposition) — результат: базовый символ + комбинирующие символы.
é= U+0065 + U+0301.
Есть ещё две формы — NFKC и NFKD (Compatibility Composition / Decomposition). Они дополнительно сводят визуально «похожие» символы: лигатура fi (U+FB01) при NFKC превратится в две буквы f + i. Полезно для поиска, где хочется, чтобы find нашлось по запросу find. Опасно для хранения: преобразование необратимо.
Правило: перед сравнением, поиском, индексированием — нормализовать обе стороны к одной форме. NFC — частый выбор по умолчанию (компактнее и совпадает с precomposed-вариантом для большинства европейских языков).
Нормализация на практике
Поведение файловых систем расходится:
- HFS+ (macOS до 2017) — хранил имена файлов в полностью разложенной канонической форме (fully decomposed, близко к NFD, но с версионными оговорками: таблицы разложения были зафиксированы на Unicode 2.1 в Mac OS 8.1–10.2, на Unicode 3.2 с Mac OS X 10.3). Файл, созданный в Windows как
café.txtв NFC, при копировании на HFS+ пересохранялся в разложенной форме. Обратное копирование возвращало исходную форму. - APFS (macOS с 2017) — хранит имена как последовательность байт без преобразования: что записали, то и прочитали. Сравнение имён у современного APFS нормализационно-нечувствительное —
café(NFC) иcafé(NFD) считаются одним именем. В ранних переходных версиях (iOS 10.3, первые релизы на macOS Sierra/High Sierra) поведение отличалось в деталях. - Windows (NTFS) — автоматической нормализации не делает; сравнение имён от регистра зависит по настройке (по умолчанию не зависит). Нормализация строк — ответственность приложения (
NormalizeString,IsNormalizedString). Два файла с визуально одинаковыми именами в разных нормализациях для NTFS — два разных файла. - Linux (ext4, btrfs) — имя файла это последовательность байт, оканчивающаяся нулём. Ядро не знает ни про Unicode, ни про нормализацию.
Следствие: перенос файлов é-в-имени между системами может привести к тому, что файл виден в списке, но не открывается по имени, скопированному из соседнего документа. Это не баг приложения — приложение сравнивает одну форму записи с другой.
То же касается строк в базах данных, URL-ах, ключах хеш-таблиц: две строки, одинаковые на взгляд, не равны друг другу — пока одна из сторон не нормализует вход.
Графемные кластеры
Человек смотрит на экран и видит «один символ». Но под капотом этот символ может состоять из нескольких кодовых позиций.
Флаг 🇯🇵 — это не один code point, а два: U+1F1EF (Regional Indicator Symbol Letter J) + U+1F1F5 (Regional Indicator Symbol Letter P). Рендеринг объединяет их в один флаг. Если поставить курсор рядом с 🇯🇵 и нажать Delete — исчезает весь флаг, не половина.
Семейный emoji 👨👩👧👦 — семь кодовых позиций: четыре фигуры, соединённые тремя символами ZWJ (Zero-Width Joiner, U+200D — «невидимый соединитель»): 👨 + ZWJ + 👩 + ZWJ + 👧 + ZWJ + 👦. В UTF-8 это 25 байтов. Но пользователь видит один символ.
Графемный кластер (grapheme cluster) — минимальная единица, которую человек воспринимает как отдельный символ. Правила сегментации текста на кластеры закреплены в UAX #29 (Unicode Annex #29, Text Segmentation): как именно группируются базовый символ с комбинирующими, regional indicators с regional indicators, ZWJ-последовательности. Стандарт различает legacy и extended grapheme clusters — в современных приложениях используется extended-вариант, который правильно обрабатывает emoji и региональные флаги.
Три разных способа измерить «длину строки»:
| Строка | Байтов (UTF-8) | Code points | Графемных кластеров |
|---|---|---|---|
Hello | 5 | 5 | 5 |
café (NFC) | 5 | 4 | 4 |
café (NFD) | 6 | 5 | 4 |
🇯🇵 | 8 | 2 | 1 |
👨👩👧👦 | 25 | 7 | 1 |
Только графемные кластеры совпадают с тем, что видит пользователь.
Emoji и ZWJ-последовательности
Emoji оказались главным практическим потребителем механизма графемных кластеров. Три категории, о которых полезно помнить:
- Модификаторы тона кожи.
👋+ U+1F3FD (Medium Skin Tone) →👋🏽. Два code point, один кластер. - Regional Indicators. Пара символов A–Z из блока U+1F1E6..U+1F1FF даёт флаг страны. Два code point, один кластер. Добавить третий regional indicator между флагами — получить новую пару.
- ZWJ-последовательности. Части emoji склеиваются через U+200D. Семья, профессии (
👨⚕️—👨+ ZWJ +⚕), новые комбинации emoji (🏳️🌈— флаг + ZWJ + радуга) — всё строится этим механизмом. Стандарт Unicode ведёт список зарегистрированных ZWJ-последовательностей; приложение, не знающее какой-то последовательности, покажет составляющие части по отдельности.
Практика: чего делать не нужно
Следствия из разницы байт / code point / кластер:
- Длина строки.
"café".lengthв JavaScript — это число UTF-16 code units (не code points и не кластеров); для emoji даст неправильный ответ. В RubyString#length— это code points;String#grapheme_clusters.length— графемные кластеры. Для отображения пользователю нужны кластеры, для протоколов с ограничением в байтах — байты. - Сравнение. Никогда не сравнивать строки пользовательского ввода побайтово без нормализации. Привести обе стороны к NFC (или NFD, если согласовано с окружением).
- Поиск. Индекс для поиска по тексту хранит нормализованную форму; запрос нормализуется той же функцией. Без этого
caféв документе не находится по запросуcafé. - Курсор и выделение. Движение курсора на «одну букву», выделение «пяти букв», обрезка до длины N — операции над графемными кластерами, не над байтами или code point. Иначе пользователь видит разрушенные emoji и оторванные акценты.
- Обрезка по длине в байтах. Если протокол (SMS, твит, имя файла) ограничивает байты, обрезать всё равно нужно по границе кластера — иначе в конце строки окажется половинка символа. Для Ruby-строк этим занимается
String#grapheme_clusters, в других языках — библиотека ICU или аналоги; механика ruby-строки разобрана в string.
BOM: последний артефакт кодировок
Прежде чем выйти за границу текста, стоит разобраться с маленьким символом, который чаще всего смущает разработчика при открытии файла в UTF-16. BOM (Byte Order Mark — маркер порядка байтов) — символ U+FEFF, помещённый в начало файла или потока. Его задача — сообщить декодеру, в каком порядке записаны байты многобайтовой кодировки.
Для UTF-16 без явной метки (UTF-16BE / UTF-16LE) BOM — основной способ определить порядок байтов. 16-битное число можно записать двумя способами: FE FF (big-endian) или FF FE (little-endian). Декодер читает первые два байта: если FE FF — интерпретирует как big-endian, если FF FE — как little-endian. Без BOM и без внешней метки декодер вынужден угадывать.
Для UTF-32 — аналогично, только маркер занимает 4 байта: 00 00 FE FF (big-endian) или FF FE 00 00 (little-endian).
Для UTF-8 BOM выглядит как три байта EF BB BF (UTF-8 представление U+FEFF). Его функция не в указании порядка — у UTF-8 нет проблемы порядка байтов. BOM здесь служит подсказкой: «этот файл в UTF-8, а не в какой-то кодовой странице». На практике BOM в UTF-8 чаще мешает, чем помогает: программы, не ожидающие его, принимают три байта за содержимое файла. Современная рекомендация (Unicode FAQ) — не использовать BOM в UTF-8.
Сам выбор FE FF или FF FE — это не особенность UTF, а частный случай общего вопроса: какой из двух байтов 16-битного числа ложится в память первым. От ответа зависит, прочтётся ли 32-битное целое как 1 или как 16777216, увидит ли сервер порт 80 или 20480, откроется ли ELF-файл без ошибки или упадёт.
Sources
- The Unicode Consortium, 2024, Unicode Standard Annex #15: Unicode Normalization Forms. https://www.unicode.org/reports/tr15/
- The Unicode Consortium, 2024, Unicode Standard Annex #29: Unicode Text Segmentation. https://www.unicode.org/reports/tr29/
- The Unicode Consortium, 2024, Unicode Emoji (UTS #51). https://www.unicode.org/reports/tr51/
- Apple, 2017, Apple File System Reference. https://developer.apple.com/support/apple-file-system/
- The Unicode Consortium, 2024, Unicode FAQ: UTF-8, UTF-16, UTF-32 & BOM. https://www.unicode.org/faq/utf_bom.html