company_banner

Когда «Zoë» !== «Zoë», или почему нужно нормализовывать Unicode-строки

Автор оригинала: Alessandro Segala
  • Перевод
  • Tutorial
Никогда не слышали о нормализации Unicode? Вы не одиноки. Но об этом надо знать всем. Нормализация способна избавить вас от множества проблем. Рано или поздно нечто подобное тому, что показано на следующем рисунке, случается с любым разработчиком.
«Zoë» — это не «Zoë»

И это, кстати, не пример очередной странности JavaScript. Автор материала, перевод которого мы сегодня публикуем, говорит, что может показать, как та же проблема проявляется при использовании практически каждого из существующих языков программирования. В частности, речь идёт о Python, Go, и даже о сценариях командной оболочки. Как с этим бороться?

Предыстория


Я впервые столкнулся с проблемой Unicode много лет назад, когда писал приложение (на Objective-C), которое импортировало список контактов из адресной книги пользователя и из его социальных сетей, после чего исключало дубликаты. В определённых ситуациях оказывалось так, что некоторые люди присутствуют в списке дважды. Происходило это из-за того, что их имена, по мнению программы, не являлись одинаковыми строками.

Хотя в вышеприведённом примере две строки выглядят абсолютно одинаково, то, как они представлены в системе, те байты, в виде которых они сохранены на диске, различаются. В первом имени "Zoë" символ ë (e с умлаутом) представляет собой одну кодовую точку Unicode. Во втором случае мы имеем дело с декомпозицией, с подходом к представлению знаков с помощью нескольких символов. Если вы, в своём приложении, работаете с Unicode-строками, вам нужно учитывать то, что одни и те же символы могут быть представлены разными способами.

Как мы пришли к эмодзи: в двух словах о кодировании символов


Компьютеры работают с байтами, которые представляют собой всего лишь числа. Для того чтобы получить возможность обрабатывать на компьютерах тексты, люди договорились о соответствии символов и чисел, и пришли к соглашениям о том, как должно выглядеть визуальное представление символов.

Первое подобное соглашение было представлено кодировкой ASCII (American Standard Code for Information Interchange). Эта кодировка использовала 7 бит и могла представлять 128 символов, в состав которых входили латинский алфавит (прописные и строчные буквы), цифры и основные знаки пунктуации. В ASCII также входило множество «непечатаемых» символов, таких, как символ перевода строки, знак табуляции, символ возврата каретки и другие. Например, в ASCII латинская буква M (прописная m) кодируется в виде числа 77 (4D в шестнадцатеричном представлении).

Проблема ASCII заключается в том, что хотя 128 знаков может быть достаточно для представления всех символов, которыми обычно пользуются люди, работающие с англоязычными текстами, этого количества символов недостаточно для представления текстов на других языках и разных особых символов вроде эмодзи.

Решением этой проблемы стало принятие стандарта Unicode, который был нацелен на возможность представления каждого символа, используемого во всех современных и древних текстах, включая и символы вроде эмодзи. Например, в совсем недавно вышедшем стандарте Unicode 12.0 насчитывается более 137000 символов.

Стандарт Unicode может быть реализован с использованием множества способов кодирования символов. Самые распространённые — это UTF-8 и UTF-16. Надо отметить, что в веб-пространстве сильнее всего распространён стандарт кодирования текстов UTF-8.

Стандарт UTF-8 использует для представления символов от 1 до 4 байт. UTF-8 представляет собой надмножество ASCII, поэтому первые его 128 символов совпадают с символами, представленными в кодовой таблице ASCII. Стандарт UTF-16, с другой стороны, использует для представления 1 символа от 2 до 4 байт.

Почему существуют и тот и другой стандарты? Дело в том, что тексты на западных языках обычно эффективнее всего кодируются с использованием стандарта UTF-8 (так как большинство символов в таких текстах могут быть представлены в виде кодов размером в 1 байт). Если же говорить о восточных языках, то можно сказать, что файлы, хранящие тексты, написанные на этих языках, обычно получаются меньше при использовании UTF-16.

Кодовые точки Unicode и кодирование символов


Каждому символу в стандарте Unicode назначен идентификационный номер, который называется кодовой точкой. Например, кодовой точкой эмодзи является U+1F436.

При кодировании этого значка он может быть представлен в виде различных последовательностей байтов:

  • UTF-8: 4 байта, 0xF0 0x9F 0x90 0xB6
  • UTF-16: 4 байта, 0xD83D 0xDC36

В JavaScript-коде, представленном ниже, все три команды выводят в консоль браузера один и тот же символ.

// Так соответствующая последовательность байтов просто включается в код
console.log('') // =>
// Тут используется кодовая точка Unicode (ES2015+)
console.log('\u{1F436}') // =>
// Тут используется представление этого символа в стандарте UTF-16
// с применением двух кодовых единиц (по 2 байта каждая)
console.log('\uD83D\uDC36') // =>


Во внутренних механизмах большинства JavaScript-интерпретаторов (включая Node.js и современные браузеры) используется UTF-16. Это означает, что рассматриваемый нами значок с собакой хранится с использованием двух кодовых единиц UTF-16 (по 16 бит каждая). Поэтому то, что выводит следующий код, не должно показаться вам непонятным:

console.log(''.length) // => 2

Комбинирование символов


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

Некоторые символы в кодировке Unicode предназначены для модификации других символов. Их называют комбинируемыми символами (combining characters). Они применяются к базовым символам (base characters) Например:

  • n + ˜ = ñ
  • u + ¨ = ü
  • e + ´ = é

Как видно из предыдущего примера, комбинируемые символы позволяют добавлять к базовым символам диакритические знаки. Но на этом возможности Unicode по трансформации символов не ограничиваются. Например, некоторые последовательности символов могут быть представлены в виде лигатур (так ae может превратиться в æ).

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

Например, букву é можно представить двумя способами:

  • С помощью одной кодовой точки U+00E9.
  • С помощью комбинации буквы e и знака акута, то есть — с помощью двух кодовых точек — U+0065 и U+0301.

Символы, получившиеся в результате применения любого из этих способов представления буквы é, будут выглядеть одинаково, но при их сравнении окажется, что символы это разные. Строки, содержащие их, будут иметь разную длину. Убедиться в этом можно, выполнив следующий код в консоли браузера.

console.log('\u00e9') // => é
console.log('\u0065\u0301') // => é
console.log('\u00e9' == '\u0065\u0301') // => false
console.log('\u00e9'.length) // => 1
console.log('\u0065\u0301'.length) // => 2

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

Нормализация строк


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

Существуют четыре стандартных формы (алгоритма) нормализации:

  • NFC: Normalization Form Canonical Composition.
  • NFD: Normalization Form Canonical Decomposition.
  • NFKC: Normalization Form Compatibility Composition.
  • NFKD: Normalization Form Compatibility Decomposition.

Чаще всего используется форма нормализации NFC. При использовании этого алгоритма все символы сначала подвергаются декомпозиции, после чего все комбинирующиеся последовательности подвергаются повторной композиции в порядке, определяемом стандартом. Для практического применения можно выбрать любую форму. Главное — применять её последовательно. В результате поступление на вход программы одних и тех же данных всегда будет приводить к одному и тому же результату.

В JavaScript, начиная со стандарта ES2015 (ES6), имеется встроенный метод для нормализации строк — String.prototype.normalize([form]). Пользоваться им можно в среде Node.js и практически во всех современных браузерах. Аргумент form этого метода представляет собой строковой идентификатор формы нормализации. По умолчанию используется форма NFC.

Вернёмся к ранее рассмотренному примеру, применив на этот раз нормализацию:

const str = '\u0065\u0301'
console.log(str == '\u00e9') // => false
const normalized = str.normalize('NFC')
console.log(normalized == '\u00e9') // => true
console.log(normalized.length) // => 1

Итоги


Если вы разрабатываете веб-приложение и используете в нём то, что вводит пользователь, всегда выполняйте нормализацию полученных текстовых данных. В JavaScript для выполнения нормализации можно воспользоваться стандартным методом строк normalize().

Уважаемые читатели! Сталкивались ли вы с проблемами, возникающими при работе со строками, решить которых можно с помощью нормализации?

RUVDS.com
RUVDS – хостинг VDS/VPS серверов

Комментарии 40

    +5
    Пару раз в жизни встречал тексты, где вместо буквы «й» было сочетание «и + ˜». Всякий раз хотелось пожелать мучительной смерти создателю такой подрянки.
      0
      Мне многократно попадались на этом сайте и тексты, и комментарии, где «ы» было записано парой «ь»-«i». Так и не понял что это. Подавление поиска?
        +4
        Мало того, в этом подавлении поиска есть украинский след.

        В украинской раскладке — сюрприз! — нет буквы «ы», потому что в украинском этот звук обозначается буквой «и».
          –1

          Украина

          0

          мак

            0
            Это маководам лучи добра.
              0
              Apple старались.
                0

                Стандартная ситуация для кросс-платформы, где фигурирует мак.
                Самое типичное — файлы с русскими именами на NFC-шаре между маком и линуксом. И именно с теми самыми двумя буквами в имени — ё и й. Внезапно обнаруживается, что записанные из линукса такие файлы мак не видит. Зато может сам записать такие, причём БЕЗ диалога о существующем файле и его замене.
                И после всего этого ты смотришь на два файла с одинаковым именем и думаешь, WTF?


                История реальная; набираю нотные партитуры в лилипонде; попеременно то с убунты, то с макбука...

                0
                Хотя в вышеприведённом примере две строки выглядят абсолютно одинаково, то, как они представлены в системе, те байты, в виде которых они сохранены на диске, различаются.

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


                Почему существуют и тот и другой стандарты? Дело в том, что тексты на западных языках обычно эффективнее всего кодируются с использованием стандарта UTF-8.

                Если бы дело было только в этом, то UTF-16 не использовался бы в большинства JavaScript-интерпретаторов. Просто при передаче важнее компактность (а UTF-8 в целом получается компактнее), а при работе со строками важнее скорость определения длины строки и положения символа в заданной позиции.

                  +1

                  Если бы было так, то использовали бы UTF-32. Использование же UTF-16 — это по большей части легаси.

                    +1
                    Юникод совсем недавно прорвало за границы 2-байтной кодировки. Точнее, стандарт предполагал это давно, но массовое использование этих символов началось совсем недавно.
                      0

                      И после "прорыва" ситуация стала печальной: UTF-16 объединяет недостатки UTF-8 и UTF-32.
                      Он занимает много места (кроме иероглифов) и является кодировкой, где каждый code point кодируется последовательностью байт переменной длины ("спасибо" суррогатным парам). К тому же, старый и не очень софт не поддерживает UTF-16 (только UCS-2). Плюс, проблемы с endianness.
                      Поэтому, в современном софте имеет смысл использовать UTF-8 для хранения текста, UTF-32 для обработки, а про UTF-16 просто забыть.

                        +1
                        Зависит от задачи. Если вам не нужна работа с текстом на уровне кодпоинтов (или вообще букв), то нет никакого смысла тратить время на перекодировку и память на хранение в менее компактном формате. Например парсить JSON, XML, HTML можно одинаково эффективно и в UTF-8, и в UTF-16 и в UTF-32.
                          0
                          > UTF-32 для обработки

                          А предварительная нормализация разве гарантирует, что каждый символ будет представлен одной кодовой точкой?

                          Иначе в общем случае использование UTF-32 не дает возможность произвольного доступа к символам и не избавляет от необходимости пробегать по всей строке, а памяти жрет чрезмерно.
                            0

                            UTF-32 даёт нам возможность более вычислительно более простого итерирования по кодовым точкам. При сборке символа из кодовых точек нам же всё равно придётся итерироваться по ним. А так как у UTF-32 фиксированое количество байт на точку, нам нужно будет заботиться только о смещении относительно начала, а не об обработке суррогатных пар.

                      0
                      Если бы дело было только в этом, то UTF-16 не использовался бы в большинства JavaScript-интерпретаторов. Просто при передаче важнее компактность (а UTF-8 в целом получается компактнее), а при работе со строками важнее скорость определения длины строки и положения символа в заданной позиции.

                      Определение длины строки в символах и позиции символа при использовании что UTF-8, что UTF-16 одинаково геморройно, потому что требует итерации по всей строке. Не забывайте, что в UTF-16 есть кодпоинты, которые представляются двумя кодюнитами.


                      Поэтому строки в JS — это последовательность UTF-16 code units, а не UTF-16 code points. Просто пока вы не работаете с суррогатными парами (эмодзи всякие), вы этого не замечаете.

                      +4
                      Никогда не понимал смысла втягивать в кодировки эмодзи. Зачем? 137 тысяч эмодзи!
                        +3
                        137 тысяч — это общее количество символов в текущем Юникоде (версия 12.0). Из них эмодзи «всего» лишь 1273.
                          0
                          согласен, картинок с сумочками, котиками и пивными кружками можно придумать бесконечное количество, даже диапазона юникода не хватит, зачем было сувать их в стандарт
                          0

                          А может кто-нибудь пояснить смысл неоднозначного представления символа? Если это один и тот же символ, это выглядит как отвратительное решение. Если разные, то не должно быть нормализаций.
                          //тут же вопрос — о и o — одна и та же буква? :)

                            0
                            разные, так же как с и c
                            console
                              0

                              Это наследие однобайтовых кодировок. В таких кодировках можно записать не более 256 символов, поэтому приходилось выкручиваться, создавая отдельные символы для частей букв (u, ¨) и затем соединяя их в буквы.

                                +1
                                Я первый раз услышал про такое соединение именно в контексте Юникода. Правда есть такие кодировки? В Windows-1252, например, все символы с умляутами отдельно: en.wikipedia.org/wiki/Windows-1252#Character_set
                                  0

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


                                  This feature was introduced in the standard to allow compatibility with preexisting standard character sets, which often included similar or identical characters

                                  Возможно, я перепутал понятия «кодировка» и «набор символов».

                                    0
                                    В 7 битном ASCII умляутов нет, а для верхних 128 символов были на каждом компе три разных кодировки. В итоге в Европах просто на какое-то время отказались от умляутов в целях совместимости, чтобы условное письмо могли прочесть в соседней деревне.
                                    0
                                    Я первый раз услышал про такое соединение именно в контексте Юникода. Правда есть такие кодировки? В Windows-1252, например, все символы с умляутами уже соединены: en.wikipedia.org/wiki/Windows-1252#Character_set
                                      0
                                      Я первый раз услышал про такое соединение именно в контексте Юникода. Правда есть такие кодировки? В Windows-1252, например, все символы с умляутами уже соединены:
                                        0
                                        Прошу прощения, кто-нибудь, удалите лишние дубли, что-то пошло не так :(
                                      0
                                      Например, ударения.
                                      Ещё всякий матан с гробиками и стрелочками со всех сторон символа.
                                      Ещё есть языки с размытой нормой, например ss и ß в немецком это одна и то же «буква», но в одних регионах пишут так, а в других эдак.
                                      +1
                                      Пример в заголовке статьи неудачный: конкретно Zoё может быть не Zоё, потому что буква «о» английская и русская используются.
                                        0
                                        Вот, кстати, интересный вопрос: а такая нормализация поможет сделать две такие строки равными?
                                          0
                                          trim().normalize().translit().toLowerCase()
                                        0
                                        Возможно я что-то путаю, но насколько я помню UTF-8 символ может состоять из максимум 6 байт а не 4.
                                          +1
                                          Было дело, но они что-то поменяли в стандартах. Видимо нету смысла закладывать такое количество символов.
                                          0
                                          Эта проблема очень ярко проявлялась на iOS после перехода на APFS (начиная с iOS 10.3 и вплоть до iOS 11.0, в которой проблема была решена, более полугода спустя). Много нервов помотали пользователи которые через iTunes добавляли файлы в папку документов приложения, приложение файлы видело но не могло открыть. Причем у некоторых пользователей все работало, а у некоторых ничего не получалось. В итоге выяснилось, что проблема возникает с файлами, загруженными из iTunes из под Windows для файлов имеющих в названии символы Й, Ё ну и всякие умляуты и подобное. Причем у пользователей на OSX такой проблемы небыло. Пользователям рекомендовал переименовывать файлы таким образом, чтобы в названии были только латинские символы и цифры. Но виноват, в глазах пользователей, всеравно был разработчик приложения. Сильно тогда слили рейтинг приложения из за этой проблемы.
                                            0
                                            Правильно ли я понял, что проблема возникает только в том случае, когда кодировки отличаются?
                                              0
                                              Век живи, век учись.
                                                +2
                                                const str = '\u0065\u0301'
                                                console.log(str == '\u00e9') // => false
                                                const normalized = str.normalize('NFC')
                                                console.log(normalized == '\u00e9') // => true
                                                console.log(normalized.length) // => 1


                                                Некорректно. Нужно сравнивать не normalized и '\u00e9', а

                                                
                                                const str1 = '\u0065\u0301'
                                                const str2 = '\u00e9'
                                                
                                                const normalized1 = str1.normalize('NFC')
                                                const normalized2 = str2.normalize('NFC')
                                                
                                                console.log(normalized1 == normalized2)
                                                
                                                  0
                                                  В PHP для этого используется класс Normalizer.
                                                    0

                                                    под капотом все используют icu.

                                                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                                  Самое читаемое