Java и Unicode: как соотносятся графема, кодпоинт и чар?

Собственный поисковый матчер для Reedy уже вполне готов и отлично работает – всё ускорилось на порядок, по сравнению с “наивной” реализацией на регулярках.

Попутно пришлось, наконец, серьезнее разобраться с Юникодом, чтобы поиск хорошо работал с любыми языками и символами.

Unit-tesing Java Unicode Search

В Java, как и в ряде других языков, есть возможности для полноценной работы с Юникодом, но при этом используется последовательный ряд абстракций.

Так, стандартный класс String – фактически, промежуточное представление между настоящими символами Юникода (Unicode Code Point) и байтами – UTF-16 кодирование.

Нам предоставляется прямой доступ к любому символу в строке по его индексу и это очень здорово и удобно, но каждый “символ” – это Java Char или UTF-16 Code Unit, а не настоящий символ юникода.

В большинстве случаев мы игнорируем разницу, потому что почти для всех широко используемых символов это одно и тоже. Но вот всякие редкие математические символы или эмоджи устраивают подлянку. Они представлены не одним, а двумя char’ами. Так что нужно быть осторожнее при работе с ними.

Реальные Unicode Code Point’ы уже не получить так просто по индексу, но перебрать (проитерировать) все кодпоинты в строке или массиве char’ов не сложно. В Kotlin, Java и прочих JVM-языках методы для этого доступны в классах Character и String.

* * *

На этом веселье не заканчивается.

То, что распознаётся человеком как одна буква, один символ, в Юникоде может быть представлено не одним кодпоинтом, а двумя или более (например шестьюдесятью :)

Это так называемая графема («extended grapheme cluster» в терминологии Юникода). Если нам нужно, например, корректно разбить текст на слова, приходится всё это учитывать и обрабатывать. Просто проверяя, что char или кодпоинт является или не является буквой, мы разобьём слово посередине составной “ö”, “Й” или “च्”, получив полную фигню.

Для работы с графемами в Kotlin/Java есть класс java.text.BreakIterator. Он предоставляет простое API для итерирования по графемам.

* * *

Ещё стоит не забывать, что одна и та же “буква” может быть корректно представлена в Юникоде по-разному. Например буква “ñ” может быть одним кодпоинтом или в виде двух: “n” и “  ̃”. Эти два кодпоинта браузер отобразит как один.

Варианты представления – так называемые “формы” Юникода. Для работы с ними также есть простые API.

Мы редко тут сталкиваемся с проблемами, потому что почти всегда и везде используется форма NFC, наиболее короткая, в ней всё кодируется одним символом, если это возможно.

NFD and NFC Normalization Forms in Unicode

Но если мы делаем хороший поиск, надо находить искомый символ как бы он ни был закодирован. Так что приходится и тут заморочиться :)

* * *

Несколько примеров напоследок.

𝚆𝐖𝑊𝑾𝑤𝒘
Это не латинские буквы, а те самые составные математические символы!
Тут 12 char’ов, но всего 6 кодпоинтов и 6 же графем.

ññ
Две уже показанные выше буквы. Одна цельная, одна составная.
Всего 3 char’а, 2 кодпоинта, 2 графемы.

अनुच्छेद
8 char’ов, 5 кодпоинтов, но графемы только 4!

И каноничный адовый:

Z͑ͫ̓ͪ̂ͫ̽͏̴̙̤̞͉͚̯̞̠͍A̴̵̜̰͔ͫ͗͢L̠ͨͧͩ͘G̴̻͈͍͔̹̑͗̎̅͛́Ǫ̵̹̻̝̳͂̌̌͘!͖̬̰̙̗̿̋ͥͥ̂ͣ̐́́͜͞

75 char’ов! 75 кодпоинтов! Всего 7 графем :)

Юникод – это весело!