Важные аспекты Unicode, о которых должен знать каждый разработчик JavaScript

в 8:01, , рубрики: javascript, timeweb_статьи_перевод, Unicode, utf-16, utf-8, web-разработка, webdevelopment, Юникод
Важные аспекты Unicode, о которых должен знать каждый разработчик JavaScript - 1

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

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

Приложив определенные усилия, прочитав кучу статей — я постепенно начал понимать что к чему, и это оказалось не так уж трудно. Хотя, некоторые статьи приходилось перечитывать раза по 3.

Как оказалось, Unicode — это универсальный и удобный стандарт, но работать с ним может быть непросто из-за множества абстрактных терминов.

Если у вас есть пробелы в понимании Unicode, то сейчас самое подходящее время их заполнить! Заварите себе вкусный чай или кофе ☕. И давайте погрузимся в удивительный мир абстракций, символов, астралов (astrals) и суррогатов (surrogates).

В этой статье объясняются основные концепции Unicode, которые создадут необходимую базу для работы с ним.

Вы также узнаете, как JavaScript взаимодействует с Unicode и какие трудности могут возникнуть на этом пути.

А также, каким образом новые функции из ECMAScript 2015 могут помочь в решении этих проблем.

Готовы? Давайте начнем!

Начнем с простого вопроса. Как вам удается читать и понимать эту статью? Очень просто: вы знаете значение букв и слов.

Как вы понимаете значение букв? Очень просто: у вас (читателя) и у меня (писателя) есть соглашение относительно связи между графическим символом (тем, что видно на экране) и буквой русского (в оригинале — английского, разумеется) языка (значением).

То же самое происходит и с компьютерами. Разница в том, что компьютеры не понимают значения букв. Для компьютеров буквы — это просто последовательности битов.

Представьте, что Пользователь 1 отправляет сообщение hello Пользователю 2.

Компьютер Пользователя 1 не понимает значения букв. Он преобразует hello в последовательность чисел 0x68 0x65 0x6C 0x6C 0x6F, где каждая буква однозначно соответствует числу: h — это 0x68, e0x65 и т.д. Эти числа отправляются на компьютер Пользователя 2.

Когда компьютер Пользователя 2 получает последовательность чисел 0x68 0x65 0x6C 0x6C 0x6F, он использует то же сопоставление букв и чисел для декодирования и восстанавливает сообщение, а затем выводит на экран hello.

Соглашение между двумя компьютерами о соответствии между буквами и числами — это то, что стандартизирует Unicode.

С точки зрения Unicode, h — это абстрактный символ, LATIN SMALL LETTER H. Этому символу соответствует число 0x68, которое является кодовой точкой (code point) в обозначении U+0068.

Роль Unicode заключается в предоставлении списка абстрактных символов (набора символов) и присвоении каждому символу уникальной идентификационной кодовой точки (закодированного набора символов).

2. Основные термины Unicode

На сайте www.unicode.org отмечается:

"Unicode предоставляет уникальное число для каждого символа, независимо от платформы, независимо от программы и независимо от языка".

Unicode — это универсальный набор символов, который включает в себя большинство систем письменности и присваивает каждому символу уникальное число (кодовую точку).

Важные аспекты Unicode, о которых должен знать каждый разработчик JavaScript - 2

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

Первая версия Unicode 1.0 была опубликована в октябре 1991 года и содержала 7 161 символ. Последняя версия 14.0 (опубликованная в сентябре 2021 г.) содержит кодировки для 144 697 символов.

Благодаря единому и всеобъемлющему подходу, Unicode решает проблему, когда производители создают множество наборов символов и кодировок, с которыми сложно работать.

Раньше было трудно создать приложение, поддерживающее все эти наборы символов и кодировки.

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

Я все еще помню, как выбирал кодировки наугад для чтения содержимого файлов. Чистая лотерея!

2.1 Символы и кодовые точки

"Абстрактный символ (или просто символ) (abstract character/character) — это единица информации, используемая для систематизации, управления или представления текстовых данных".

Unicode рассматривает символы как абстрактные понятия. У каждого абстрактного символа есть связанное с ним имя, например, LATIN SMALL LETTER A. Отображаемая форма (глиф — glyph) этого символа — a.

"Кодовая точка — это число, присвоенное одному символу".

Кодовые точки — это числа в диапазоне от U+0000 до U+10FFFF.

U+<hex> — формат кодовых точек, где U+ — префикс, означающий Unicode, а <hex> — число в шестнадцатеричном формате. Например, U+0041 и U+2603.

При работе с Unicode важно понимать, что кодовая точка — это просто числовое значение, которое можно рассматривать как индекс элемента в массиве.

Магия Unicode заключается в связи между кодовой точкой и символом. Например, U+0041 соответствует символу с именем LATIN CAPITAL LETTER A (отображается как A), а кодовая точка U+2603 соответствует символу с именем SNOWMAN (отображается как ).

Не все кодовые точки имеют связанные символы. Всего доступно 1 114 112 кодовых точек (в диапазоне от U+0000 до U+10FFFF), но только 144 697 из них (на сентябрь 2021 года) имеют связанные символы.

2.2 Плоскости Unicode

"Плоскость (plane) — это диапазон из 65 536 кодовых точек Unicode от U+n0000 до U+ffff, где n может принимать значения от 0 до 10 в шестнадцатеричном формате".

Весь набор кодовых точек Unicode разбит на 17 плоскостей:

  • Плоскость 0 содержит кодовые точки от U+0000 до U+FFFF,
  • Плоскость 1 содержит кодовые точки от U+10000 до U+1FFFF
  • ...
  • Плоскость 16 содержит кодовые точки от U+100000 до U+10FFFF.

Важные аспекты Unicode, о которых должен знать каждый разработчик JavaScript - 3

Основная многоязычная плоскость

Плоскость 0 — это особая плоскость, называемая основной многоязычной плоскостью (Basic Multilingual Plane, BMP). Она содержит символы большинства современных языков (латиница, кириллица, греческий и т.д.) и большое количество других символов.

Как упоминалось выше, кодовые точки в BMP находятся в диапазоне от U+0000 до U+FFFF и могут содержать до 4 шестнадцатеричных цифр.

Разработчик чаще всего имеет дело именно с символами из BMP.

Некоторые символы из BMP:

  • e — это U+0065, LATIN SMALL LETTER E
  • |U+007C, VERTICAL BAR
  • U+25A0, BLACK SQUARE
  • U+2602, UMBRELLA

Астральные плоскости

16 плоскостей после BMP (плоскость 1-16) называются астральными(дополнительными) (astral/supplementary) плоскостями.

Кодовые точки в астральных плоскостях, называются астральными кодовыми точками. Они находятся в диапазоне от U+10000 до U+10FFFF.

Астральная кодовая точка может содержать 5 или 6 цифр в шестнадцатеричном формате: U+dddddd или U+ddddddd.

Некоторые символы из астральных плоскостей:

  • 𝄞 — это U+1D11E, MUSICAL SYMBOL G CLEF
  • 𝐁U+1D401, MATHEMATICAL BOLD CAPITAL B
  • 🀵U+1F035, DOMINO TITLE HORIZONTAL-00-04
  • 😀U+1F600, GRINNING FACE

2.3 Кодовые единицы

Итак, символы Unicode, кодовые точки и плоскости являются абстракциями.

Однако на практическом, аппаратном уровне, Unicode реализуется с помощью физического представления кодовых точек — кодовых единиц (code units).

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

"Кодовая единица — это последовательность битов, используемая для кодирования каждого символа в определенной кодировке".

Кодировка символов (character encoding) отвечает за преобразование абстрактных кодовых точек Unicode в физические биты — кодовые единицы. Иными словами, кодировка символов преобразует кодовые точки Unicode в уникальные последовательности кодовых единиц.

Самыми распространенными кодировками являются UTF-8, UTF-16 и UTF-32.

Большинство движков JavaScript используют кодировку UTF-16, поэтому давайте подробнее рассмотрим ее.

UTF-16 (16-разрядный формат преобразования Unicode) — это кодировка переменной длины (variable-length encoding):

  • Кодовые точки из BMP кодируются с использованием одной 16-битной кодовой единицы
  • Кодовые точки из астральных плоскостей кодируются с использованием двух кодовых единиц по 16 бит.

Рассмотрим несколько примеров.

Предположим, вы хотите сохранить на жестком диске символ a (LATIN SMALL LETTER A). Согласно Unicode, этому символу соответствует кодовая точка U+0061.

Теперь обратимся к кодировке UTF-16 и узнаем, как преобразовать U+0061. Согласно спецификации кодирования, для кодовой точки из BMP нужно взять ее шестнадцатеричное значение U+0061 и сохранить его в одной 16-битной кодовой единице: 0x0061.

Как можно заметить, кодовые точки из BMP помещаются в одну 16-битную кодовую единицу.

2.4 Суррогатные пары

Рассмотрим более сложный случай. Предположим, вы хотите закодировать символ 😀 (GRINNING FACE). Этому символу соответствует кодовая точка U+1F600 из астральной плоскости Unicode.

Поскольку для сохранения информации в астральных кодовых точках требуется 21 бит, UTF-16 сообщает, что нужны две кодовые единицы по 16 бит. Кодовая точка U+1F600 разделена на так называемую суррогатную пару: 0xD83D(верхняя суррогатная кодовая единица — high-surrogate code unit) и 0xDE00(нижняя суррогатная кодовая единица — low-surrogate code unit).

"Суррогатная пара — это структура для кодирования одного абстрактного символа. Она состоит из последовательности двух 16-битных кодовых единиц, где первое значение пары является верхней суррогатной кодовой единицей, а второе значение — нижней суррогатной кодовой единицей".

Для астральной кодовой точки требуются две кодовые единицы — суррогатная пара. Например, для кодирования U+1F600 (😀) в UTF-16 используется суррогатная пара: 0xD83D 0xDE00.

console.log('uD83DuDE00'); // => '😀'

Верхняя суррогатная кодовая единица принимает значения в диапазоне от 0xD800 до 0xDBFF. Нижняя суррогатная кодовая единица принимает значения в диапазоне от 0xDC00 до 0xDFFF.

Алгоритм преобразования суррогатной пары в астральную кодовую точку и наоборот следующий:

function getSurrogatePair(astralCodePoint) {
  let highSurrogate =
     Math.floor((astralCodePoint - 0x10000) / 0x400) + 0xD800;
  let lowSurrogate = (astralCodePoint - 0x10000) % 0x400 + 0xDC00;
  return [highSurrogate, lowSurrogate];
}
getSurrogatePair(0x1F600); // => [0xD83D, 0xDE00]

function getAstralCodePoint(highSurrogate, lowSurrogate) {
  return (highSurrogate - 0xD800) * 0x400
      + lowSurrogate - 0xDC00 + 0x10000;
}
getAstralCodePoint(0xD83D, 0xDE00); // => 0x1F600

Суррогатные пары непросты. При работе со строками в JavaScript обрабатывать их следует особым образом, как описано ниже в статье.

Однако UTF-16 имеет преимущество в экономии памяти. 99% используемых символов берутся из BMP и требуют только одну 16-битную кодовую единицу, что позволяет сэкономить значительный объем памяти.

2.5 Комбинирующие знаки

"Графема (grapheme), или символ (symbol), представляет собой минимальную дискретную единицу письма в рамках определенной системы письма".

Графема — это то, как пользователь представляет символ. Реальное изображение графемы, отображаемое на экране, называется глифом (glyph).

Во многих случаях один символ Unicode — это одна графема. Например, U+0066 LATIN SMALL LETTER F — это английское написание f.

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

Например, å — это атомарная графема (atomic grapheme) в датской системе письма. Она передается с помощью LATIN SMALL LETTER A U+0061 (отображается как a) в сочетании со специальным символом U+030A, COMBINING RING ABOVE (отображается как ◌̊).

U+030A изменяет предшествующий символ и называется комбинирующим знаком (combining mark).

console.log('u0061u030A'); // => 'å'
console.log('u0061');       // => 'a'

"Комбинирующий знак — это символ, который добавляется к основному символу для создания новой графемы".

Комбинирующие знаки включают такие символы, как ударения, диакритические знаки, точки на иврите, арабские знаки гласных и акцентов и т.д.

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

Как и суррогатные пары, комбинирующие знаки сложно обрабатывать в JavaScript.

Комбинирующая последовательность символов (основной символ + комбинирующий знак) отображается пользователю как один символ (например, 'u0061u030A' — это 'å'). Но разработчик должен понимать, что для построения å используются 2 кодовые точки U+0061 и U+030A.

Важные аспекты Unicode, о которых должен знать каждый разработчик JavaScript - 4

3. Unicode в JavaScript

В спецификации ES2015 упоминается, что для текста исходного кода должен быть использован Unicode (версии 5.1 и выше). Исходный текст представлен как последовательность кодовых точек от U+0000 до U+10FFFF. Способ хранения или обмена исходным кодом не определен в ECMAScript, но обычно используется UTF-8 (предпочтительная кодировка для Интернета).

Рекомендуется использовать символы из основной латиницы Unicode (Basic Latin или ASCII) в тексте исходного кода, с экранированием символов, не входящих в ASCII. Такой подход минимизирует возможные проблемы с кодированием.

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

"Строковый тип представляет собой упорядоченную последовательность из нуля или более 16-битных целых значений без знака, известных как "элементы". Максимальная длина строки составляет до 2^53-1 элементов. Строковый тип обычно используется для представления текстовых данных в программе ECMAScript. Каждый элемент строки обрабатывается как значение кодовой единицы UTF-16".

Каждый элемент строки в ECMAScript интерпретируется как кодовая единица. Способ отображения строки не предусматривает детерминированный (однозначный) способ определения содержащихся в ней кодовых точек.

Посмотрим на пример:

console.log('cafeu0301'); // => 'café'
console.log('café');       // => 'café'

Литералы 'cafeu0301' и 'café' имеют небольшое различие в кодовых единицах, но оба представляют одну и ту же последовательность символов café.

"Длина строки определяется количеством элементов (т.е. 16-битных значений), содержащихся в ней. [...] В операциях ECMAScript строковые значения интерпретируются таким образом, что каждый элемент рассматривается как отдельная кодовая единица UTF-16".

Как упоминалось ранее, некоторые символы могут быть представлены двумя или более кодовыми единицами. Поэтому при подсчете количества символов или доступе к символам по индексу необходимо принимать во внимание эту особенность:

const smile = 'uD83DuDE00';
console.log(smile);        // => '😀'
console.log(smile.length); // => 2

const letter = 'eu0301';
console.log(letter);        // => 'é'
console.log(letter.length); // => 2

Строка в переменной smile содержит 2 кодовых единицы: uD83D (верхний суррогат) и uDE00 (нижний суррогат). Поскольку строка представляет собой последовательность кодовых единиц, значение smile.length равно 2. Несмотря на то, что отображение smile содержит только один символ '😀'.

Тоже самое происходит со строкой в letter. Комбинирующий знак U+0301 применяется к предыдущему символу, и результат отображения — один символ 'é'. Однако letter содержит 2 кодовых единицы, поэтому, letter.length равно 2.

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

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

Большинство методов строк JavaScript не поддерживают Unicode. Поэтому, при наличии составных символов Unicode, рекомендуется аккуратно использовать такие методы, как myString.slice(), myString.substring() и т.д.

3.1 Escape-последовательности

Escape-последовательности (escape sequences) в строках используются для выражения кодовых единиц на основе кодовых точек. В языке JavaScript существуют 3 типа escape-последовательностей, один из которых был добавлен в стандарт ECMAScript 2015.

Рассмотрим их более подробно.

Шестнадцатеричная escape-последовательность

Один из типов escape-последовательностей называется шестнадцатеричной escape-последовательностью (hexadecimal escape sequence): x<hex>, где x — префикс, за которым следует шестнадцатеричное число <hex>, состоящее из 2 знаков. Например, 'x30' (символ '0') или 'x5B' (символ '[').

Шестнадцатеричная escape-последовательность может использоваться в строковых литералах или регулярных выражениях:

const str = 'x4Ax61vaScript';
console.log(str);                    // => 'JavaScript'
const reg = /x4Ax61va.*/;
console.log(reg.test('JavaScript')); // => true

Она может экранировать кодовые точки в ограниченном диапазоне: от U+00 до U+FF, поскольку используются только две шестнадцатеричные цифры. Однако, шестнадцатеричная escape-последовательность привлекательна своей короткой формой.

Escape-последовательность Unicode

Для экранирования кодовых точек из всего диапазона BMP, используется escape-последовательность Unicode (unicode escape sequence). Формат такой escape-последовательности — это u<hex>, где u — префикс, за которым следует шестнадцатеричное число <hex> из 4 знаков. Например, 'u0051'(символ 'Q') или 'u222B' (интегральный символ '∫').

Рассмотрим примеры использования escape-последовательностей Unicode:

const str = 'Iu0020learn u0055nicode';
console.log(str);                 // => 'I learn Unicode'
const reg = /u0055ni.*/;
console.log(reg.test('Unicode')); // => true

Escape-последовательность Unicode может экранировать кодовые точки в ограниченном диапазоне: от U+0000 до U+FFFF (все кодовые точки BMP), поскольку допустимы только 4 знака. В большинстве случаев этого достаточно для представления наиболее распространенных символов.

Чтобы указать астральный символ в строковом литерале JavaScript, нужно использовать две escape-последовательности Unicode (верхний суррогат и нижний суррогат), что создает суррогатную пару:

const str = 'My face uD83DuDE00';
console.log(str); // => 'My face 😀'

uD83DuDE00 — это суррогатная пара, созданная с использованием двух escape-последовательностей.

Escape-последовательность кодовой точки

В ECMAScript 2015 добавлен новый формат escape-последовательности, который позволяет представлять кодовые точки из всего пространства Unicode: от U+0000 до U+10FFFF (BMP и астральные плоскости).

Новый формат называется escape-последовательностью кодовой точки (code point escape sequence): u{<hex>}, где <hex> — шестнадцатеричное число переменной длины от 1 до 6 знаков.

Например, 'u{7A}' (символ 'z') или 'u{1F639}' (символ 😹).

Рассмотрим примеры использования этих последовательностей:

const str = 'Funny cat u{1F639}';
console.log(str);                      // => 'Funny cat 😹'
const reg = /u{1F639}/u;
console.log(reg.test('Funny cat 😹')); // => true

Регулярное выражение /u{1F639}/u включает специальный флаг u, который активирует дополнительные функции Unicode (подробнее ниже).

Удобно, что escape-последовательность кодовой точки устраняет необходимость использовать суррогатную пару для представления астрального символа. Например, экранируем кодовую точку U+1F607 SMILING FACE WITH HALO:

const niceEmoticon = 'u{1F607}';
console.log(niceEmoticon);                    // => '😇'
const spNiceEmoticon = 'uD83DuDE07'
console.log(spNiceEmoticon);                  // => '😇'
console.log(niceEmoticon === spNiceEmoticon); // => true

Строковый литерал, присвоенный переменной niceEmoticon, содержит escape-последовательность кодовой точки 'u{1F607}', которая представляет астральную кодовую точку U+1F607.

Однако, при использовании escape-последовательности кодовой точки, создается суррогатная пара из двух кодовых единиц. Переменная spNiceEmoticon (созданная с использованием суррогатной пары Unicode-экранированных символов 'uD83DuDE07') будет равна niceEmoticon.

Важные аспекты Unicode, о которых должен знать каждый разработчик JavaScript - 5

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

Следующие объекты регулярного выражения эквивалентны:

const reg1 = /x4A u0020 u{1F639}/;
const reg2 = new RegExp('\x4A \u0020 \u{1F639}');
console.log(reg1.source === reg2.source); // => true

3.2 Сравнение строк

Строки в JavaScript — это последовательности кодовых единиц. При сравнении строк можно ожидать сопоставления кодовых единиц: если кодовые единицы обеих строк равны, то считается, что строки равны.

Такой подход быстр и эффективен. Он прекрасно работает с "простыми" строками:

const firstStr = 'hello';
const secondStr = 'u0068ellu006F';
console.log(firstStr === secondStr); // => true

Строки firstStr и secondStr имеют одинаковую последовательность кодовых единиц — они равны.

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

const str1 = 'ça va bien';
const str2 = 'cu0327a va bien';
console.log(str1);          // => 'ça va bien'
console.log(str2);          // => 'ça va bien'
console.log(str1 === str2); // => false

str1 и str2 визуально выглядят одинаково, но содержат разные кодовые единицы.

Это происходит потому, что графема ç может быть представлена двумя способами:

  • С использованием кодовой единицы U+00E7 LATIN SMALL LETTER C WITH CEDILLA
  • С использованием комбинирующей последовательности символов: U+0063 LATIN SMALL LETTER C плюс комбинирующий знак U+0327 COMBINING CEDILLA.

Для правильного сравнения таких строк рекомендуется использовать нормализацию.

Нормализация

"Нормализация (normalization) приводит все варианты представления символов к одному каноническому виду, что позволяет корректно сравнивать строки независимо от конкретного способа их представления".

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

Приложение № 15 к стандарту Unicode содержит детальную информацию о нормализации.

В JavaScript для нормализации строки вызывается метод myString.normalize([normForm]), доступный в ES2015. normForm является необязательным параметром (по умолчанию 'NFC') и может быть одной из следующих форм нормализации:

  • 'NFC' — каноническая композиция (Normalization Form Canonical Composition)
  • 'NFD' — каноническая декомпозиция (Normalization Form Canonical Decomposition)
  • 'NFKC' — композиция совместимости (Normalization Form Compatibility Composition)
  • 'NFKD' — декомпозиция совместимости (Normalization Form Compatibility Decomposition)

Улучшим предыдущий пример, применив нормализацию строк для их корректного сравнения:

const str1 = 'ça va bien';
const str2 = 'cu0327a va bien';
console.log(str1.normalize() === str2.normalize()); // => true
console.log(str1 === str2);                         // => false

При вызове str2.normalize() возвращается каноническая форма str2 ('cu0327' заменяется на 'ç'). Теперь сравнение str1.normalize() === str2.normalize() возвращает значение true.

Нормализация не влияет на str1, поскольку она уже находится в канонической форме.

3.3 Длина строки

Один из распространенных способов определения длины строки — использование свойства myString.length. Это свойство указывает количество кодовых единиц, содержащихся в строке.

При вычислении длины строки, содержащей кодовые точки из BMP, результат будет ожидаемым:

const color = 'Green';
console.log(color.length); // => 5

Каждая кодовая единица color соответствует отдельной графеме. Ожидаемая длина строки равна 5.

Длина строки и суррогатные пары

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

Рассмотрим пример:

const str = 'catu{1F639}';
console.log(str);        // => 'cat😹'
console.log(str.length); // => 5

При рендеринге переменной str мы видим 4 символа cat😹.

Однако smile.length вычисляется как 5, потому что U+1F639 — это астральная кодовая точка, закодированная двумя кодовыми единицами (суррогатной парой).

На данный момент не существует простого и эффективного способа решения этой проблемы.

Однако, в ECMAScript 2015 появились алгоритмы, которые позволяют распознавать астральные символы. Астральный символ считается одним символом, даже если он кодируется с использованием суррогатной пары.

String.prototype[@@iterator]() — это строковый итератор, поддерживающий Unicode. Строку можно преобразовать в массив с помощью spread-оператора [...str] или функции Array.from(str) (обе потребляют итератор строки). Затем можно посчитать количество элементов в массиве.

Однако следует отметить, что такое решение может привести к незначительным потерям производительности при интенсивном использовании.

Улучшенный пример с использованием spread-оператора:

const str = 'catu{1F639}';
console.log(str);             // => 'cat😹'
console.log([...str]);        // => ['c', 'a', 't', '😹']
console.log([...str].length); // => 4

[...str] создает массив из 4 символов/элементов. Суррогатная пара, представляющая U+1F639 CAT FACE WITH TEARS OF JOY 😹, остается нетронутой, так как строковый итератор поддерживает Unicode.

Длина строки и комбинирующие знаки

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

Проблема решается путем нормализации строки. В лучшем случае комбинирующая последовательность символов будет преобразована в один символ:

const drink = 'cafeu0301';
console.log(drink);                    // => 'café'
console.log(drink.length);             // => 5
console.log(drink.normalize())         // => 'café'
console.log(drink.normalize().length); // => 4

Строка в переменной drink содержит 5 кодовых единиц (таким образом, drink.length равно 5), но при рендеринге отображается только 4 символа.

При нормализации drink, комбинирующая последовательность символов 'eu0301' имеет каноническую форму 'é'. Таким образом, drink.normalize().length содержит ожидаемые 4 символа.

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

Рассмотрим такой случай:

const drink = 'cafeu0327u0301';
console.log(drink);                    // => 'cafȩ́'
console.log(drink.length);             // => 6
console.log(drink.normalize());        // => 'cafȩ́'
console.log(drink.normalize().length); // => 5

drink содержит 6 кодовых единиц и drink.length равняется 6. Однако, при рендеринге drink имеет только 4 символа.

Функция нормализации drink.normalize() преобразует комбинирующую последовательность 'eu0327u0301' в каноническую форму из двух символов 'ȩu0301' (удаляя только один комбинирующий знак).

К сожалению, drink.normalize().length принимает значение 5 и по-прежнему не соответствует визуальному количество символов.

3.4 Позиционирование символов

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

Когда строка содержит только символы BMP, позиционирование символов работает корректно:

const str = 'hello';
console.log(str[0]); // => 'h'
console.log(str[4]); // => 'o'

Каждый символ представлен одной кодовой единицей, и доступ к символу по индексу работает корректно.

Позиционирование символов и суррогатные пары

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

Астральный символ кодируется с использованием 2 кодовых единиц. Из-за этого доступ к символам строки по индексу может вернуть верхний или нижний суррогат, которые сами по себе являются недопустимыми символами.

Рассмотрим пример с астральным символом:

const omega = 'u{1D6C0} is omega';
console.log(omega);        // => '𝛀 is omega'
console.log(omega[0]);     // => '' (непечатаемый символ)
console.log(omega[1]);     // => '' (непечатаемый символ)

Поскольку U+1D6C0 MATHEMATICAL BOLD CAPITAL OMEGA — астральный символ, он кодируется с использованием суррогатной пары из 2 кодовых единиц.

omega[0] обращается к кодовой единице верхнего суррогата, а omega[1] — к нижнему суррогату, что в итоге разбивает суррогатную пару.

Существует 2 подхода для правильного доступа к астральным символам в строке:

  • Использование строкового итератора, поддерживающего Unicode, и создание массива символов [...str][index]
  • Получение числа кодовой точки с помощью number = myString.codePointAt(index), затем преобразование числа в символ с помощью String.fromCodePoint(number) (рекомендуемый вариант).

Рассмотрим оба подхода:

const omega = 'u{1D6C0} is omega';
console.log(omega);                        // => '𝛀 is omega'
// Вариант 1
console.log([...omega][0]);                // => '𝛀'
// Вариант 2
const number = omega.codePointAt(0);
console.log(number.toString(16));          // => '1d6c0'
console.log(String.fromCodePoint(number)); // => '𝛀'

[...omega] возвращает массив символов, содержащихся в строке omega. Суррогатные пары вычисляются правильно, поэтому доступ к первому символу работает ожидаемым образом. [...smile][0] — это '𝛀'.

Вызов omega.codePointAt(0) поддерживает Unicode, поэтому он возвращает число астральной кодовой точки 0x1D6C0, соответствующее первому символу в строке omega. Функция String.fromCodePoint(number) преобразует это число в символ '𝛀'.

Прим. пер.: обратите внимание, что omega.codePointAt(1) будет работать некорректно, поскольку в методе codePointAt индексация выполняется по кодовым единицам UTF-16, а не по кодовым точкам Юникода. В свою очередь, [...omega][1] работает правильно и возвращает пробел.

Позиционирование символов и комбинирующие знаки

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

Доступ к символам по индексу в строке — это доступ к кодовым единицам. Однако при работе с последовательностью комбинирующих знаков, нужно обращаться к ней как к целому, не разделяя ее на отдельные кодовые единицы.

Рассмотрим пример:

const drink = 'cafeu0301';
console.log(drink);        // => 'café'
console.log(drink.length); // => 5
console.log(drink[3]);     // => 'e'
console.log(drink[4]);     // => ◌́

drink[3] обращается только к основному символу e, без комбинирующего знака U+0301 COMBINING ACUTE ACCENT (отображается как ◌́ ). drink[4] обращается к изолированному комбинирующему знаку ◌́.

В таких случаях следует применять нормализацию строки. Комбинирующая последовательность символов U+0065 LATIN SMALL LETTER E + U+0301, COMBINING ACUTE ACCENT, может быть заменена на один символ U+00E9 LATIN SMALL LETTER E WITH ACUTE é.

Улучшим предыдущий пример:

const drink = 'cafeu0301';
console.log(drink.normalize());        // => 'café'
console.log(drink.normalize().length); // => 4
console.log(drink.normalize()[3]);     // => 'é'

Однако стоит отметить, что не все комбинирующие последовательности символов имеют канонические эквиваленты в виде одного символа. Поэтому решение с нормализацией не универсально.

Тем не менее, в большинстве случаев для языков Европы и Северной Америки такое решение работает.

3.5 Соответствие регулярных выражений

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

Символы BMP обрабатываются ожидаемо, поскольку каждый символ представлен одной кодовой единицей:

const greetings = 'Hi!';
const regex = /^.{3}$/;
console.log(regex.test(greetings)); // => true

greetings содержит 3 символа, закодированных 3 кодовыми единицами. Регулярное выражение /.{3}/, которое ожидает 3 кодовых единицы, совпадает с greetings.

Однако, при обработке астральных символов (закодированных суррогатными парами) возникают сложности:

const smile = '😀';
const regex = /^.$/;
console.log(regex.test(smile)); // => false

smile представлен астральным символом U+1F600 GRINNING FACE, который закодирован суррогатной парой 0xD83D+0xDE00.

Однако, регулярное выражение /^.$/, ожидающее одну кодовую единицу, не совпадает со строкой smile. Это приводит к провалу проверки с помощью regexp.test(smile) — возвращается false.

Еще более сложная ситуация возникает при определении классов символов для астральных плоскостей. JavaScript выбрасывает исключение:

const regex = /[😀-😎]/;
// => SyntaxError: Invalid regular expression: /[😀-😎]/:
// Range out of order in character class

Поскольку астральные кодовые точки кодируются в суррогатные пары, JavaScript представляет регулярное выражение, используя кодовые единицы /[uD83DuDE00-uD83DuDE0E]/. Каждая кодовая единица рассматривается как отдельный элемент, поэтому регулярное выражение игнорирует концепцию суррогатной пары.

В данном случае, часть uDE00-uD83D класса символов недопустима, так как uDE00 больше, чем uD83D. В результате, возникает ошибка.

Флаг u в регулярных выражениях

С появлением флага u в ECMAScript 2015 регулярные выражения стали совместимыми с Unicode. Этот флаг позволяет корректно обрабатывать астральные символы.

Вы можете использовать escape-последовательности Unicode внутри регулярных выражений с помощью /u{1F600}/u. Эта escape-последовательность короче, чем указание пары с верхним и нижним суррогатами /uD83DuDE00/.

Применим флаг u и посмотрим, как оператор . (включая квантификаторы (quantifiers) ?, +, * и {3}, {3,}, {2,3}) соответствует астральному символу:

const smile = '😀';
const regex = /^.$/u;
console.log(regex.test(smile)); // => true

Теперь регулярное выражение /^.$/u, поддерживающее Unicode благодаря флагу u, будет соответствовать астральному символу 😀.

Флаг u также позволяет корректно обрабатывать астральные символы внутри классов:

const smile = '😀';
const regex = /[😀-😎]/u;
const regexEscape = /[u{1F600}-u{1F60E}]/u;
const regexSpEscape = /[uD83DuDE00-uD83DuDE0E]/u;
console.log(regex.test(smile));         // => true
console.log(regexEscape.test(smile));   // => true
console.log(regexSpEscape.test(smile)); // => true

Теперь [😀-😎] будет восприниматься как диапазон астральных символов. /[😀-😎]/u будет совпадать с символом '😀'.

Регулярные выражения и комбинирующие знаки

К сожалению, с флагом u или без него, регулярное выражение обрабатывает комбинирующие знаки как отдельные кодовые единицы.

Если необходимо сопоставить комбинирующую последовательность символов, нужно сопоставить основной символ и комбинирующий знак по-отдельности.

Рассмотрим следующий пример:

const drink = 'cafeu0301';
const regex1 = /^.{4}$/;
const regex2 = /^.{5}$/;
console.log(drink);              // => 'café'
console.log(regex1.test(drink)); // => false
console.log(regex2.test(drink)); // => true

Отображаемая строка содержит 4 символа café.

Тем не менее, регулярное выражение /^.{5}$/ соответствует строке 'cafeu0301' как последовательности из 5 элементов.

4. Заключение

Одна из наиболее важных концепций Unicode в JavaScript заключается в том, чтобы рассматривать строки как последовательности кодовых единиц, а не графем или символов, как часто делают разработчики.

Это может вызвать путаницу при работе со строками, содержащими суррогатные пары или комбинирующие символы. Можно столкнуться с такими трудностями при:

  • Определении длины строки
  • Позиционировании символов
  • Сопоставлении регулярных выражений

Важно отметить, что большинство методов строк в JavaScript не поддерживают Unicode: например, myString.indexOf(), myString.slice() и др.

Однако, в ECMAScript 2015 появились удобные возможности, такие как escape-последовательности кодовых точек u{1F600} в строках и регулярных выражениях.

Флаг регулярного выражения u позволяет сопоставлять строки с поддержкой Unicode, упрощая сопоставление астральных символов.

Итератор строк String.prototype[@@iterator]() поддерживает Unicode. Таким образом, можно использовать spread-оператор [...str] или метод Array.from(str) для создания массива символов и выполнения операций, таких как определение длины строки или доступ к символам по индексу, не разбивая суррогатные пары. Однако стоит отметить, что эти операции могут негативно сказаться на производительности.

Если вам требуется более продвинутая обработка символов Unicode, вы можете использовать библиотеку punycode или создать специализированные регулярные выражения.

Я надеюсь, что данная статья помогла вам лучше разобраться с Unicode!

Парочка статей для тех, кому хочется больше Юникода:


Автор: Igor Agapov

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js