Графемы и нормализация

Предпосылки: Кодирование текста, Двоичная система и байты.

Кодирование текста | Порядок байтов

После 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Описание
PrecomposedU+00E9Один code point: «e с акутом»
DecomposedU+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). Они дополнительно сводят визуально «похожие» символы: лигатура (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Графемных кластеров
Hello555
café (NFC)544
café (NFD)654
🇯🇵821
👨‍👩‍👧‍👦2571

Только графемные кластеры совпадают с тем, что видит пользователь.

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 даст неправильный ответ. В Ruby String#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


Кодирование текста | Порядок байтов