JavaScript, суррогатные пары и фекалии

Если кратко: JavaScript не поддерживает суррогатные пары. Что в общем и правильно, так как в большинстве случаев это не нужно, а только лишние расходы. Однако, в меньшинстве случаев из-за этого придётся повозиться.

Теперь рассмотрим подробнее, что означает этот набор слов.

UTF-16 vs UCS-2

Как, наверняка, знают все здесь присутствующие, строки в компьютерах представляются, как последовательность байтиков. А каким образом эти байтики соответствуют буковкам, это определяет кодировка.

Старые добрые кодировки, вроде Windows-1251 были однобайтными. Одному символу — один байт. Работать с ними было одно удовольствие. Хочешь узнать длину строки (количество символов): это просто количество байт. Нужен 5-й символ: просто нужен пятый байт от начала строки. Единственный минус: в нашем многополярном мире 256 различных символов, это слишком мало. Особенно для китайцев с их тысячами иероглифов.

Поэтому теперь мы все любим Unicode. В юникоде много разных кодировок на все случаи жизни. Вот, например, UTF-8: в нём можно закодировать любой Unicode-символ хитрой переменного размера последовательностью байт. При этом базовая ASCII в нём остаётся такого же вида, как и в старых кодировках. Можно читать английские тексты не думая о кодировках, исходники программ и HTML/XML файлы сохраняют свою структуру, а наиболее популярные символы занимают меньше места, чем всякие иероглифы, что тоже приятно.

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

Например, в UTF-32 (или UCS-4, что одно и тоже). Четыре байта на символ. Нужна длина: поделите количество байт на 4. Нужен N-й символ — умножьте на 4. Благодать. Единственный минус, тот же Firefox спокойно выедает гигабайт памяти. А если он ещё строки будет хранить по 4 байта на каждый символ, то вообще п.ц.

Поэтому лучше использовать UTF-16 (или UCS-2, что поначалу было одно и тоже). Те же радости фиксированной ширины и при этом вдвое меньше занятого места. Однако, количество различных символов сокращается до 2^16. Сначала это никого не волновало, ибо, как говорили древние: «64К хватит всем». Но в новых стандартах Unicode начали появляться такие обязательные к использованию символы, как мордочка обезьяны, улыбающийся кот с глазами сердечками и воняющий кусок говна.

Разработчики не устояли и придумали хак для того, чтобы с помощью UTF-16 можно было кодировать хотя бы миллион символов. Суррогатные пары. Есть «High Surrogates» (диапазон кодов D800—DB7F) и есть «Low Surrogates» (DC00—DFFF). Вместе верхний и нижний суррогат составляют суррогатную пару, которая кодирует символ выше базовой плоскости (дальше первых 64K). Итого такой символ занимает 4 байта. Например, вышеприведённый символ дымящегося «чего надо» представляется двумя суррогатами D83D и DCA9, которые путём хитрых манипуляций приводятся к итоговому Unicode-коду 1F4A9.

С одной стороны в UTF-16 мы получили возможность использовать символ говна, с другой мы вернулись к переменной длине символа. И нам опять следует на каждый чих проходить всю строку сначала. В UCS-2 же суррогатные пары никак не обрабатываются и D8:3D:DC:A9 в нём будет просто двумя ничего не значащими символами.

В JavaScript

Стандарт ECMAScript по поводу того, какую кодировку следует использовать, как всегда темнит. В п.2. он заявляет «используйте UCS-2 или UTF-16, как вам нравится, хотя лучше UTF-16», а в 4.3.16 начинает что-то рассказывать про 16-битный integer, завершая своим любимым «стандарт не накладывает ограничений». Как обычно — делайте вы что хотите.

Переходим к делу. Мы захотели раскрутить Юникод на полную катушку и решили использовать символ фекалий в квадратных скобочках в своём нике на форуме:

В HTML это выглядит, например, так:

Автор: <div id="nick">[&#128169;]</div>

Содержимое тега div#nick в честном UTF-16 будет представлено в виде последовательности байтов 00:5B:D8:3D:DC:A9:00:5D. При разборе этого мы берём первое 16-битное слово: 005B. Это открывающая скобочка, обычный символ. Дальше — D83D, это начало суррогатной пары, дальше должна идти часть из «Low Surrogates» и она там есть: DCA9. Значит склеиваем в один символ, преобразуя в код 1F4A9. Итого у нас строка из трёх символов: 5B 1F4A9 5D.

Если же мы разбираем эти байты с точки зрения UCS-2, то у нас просто набор 2-байтных слов: 005B D83D DCA9 005D.

Попробуем:

var nick = document.getElementById("nick").firstChild.nodeValue,
    len = nick.length,
    i;
console.log("length = " + len);
for (i = 0; i < len; i += 1) {
    console.log(nick.charCodeAt(i).toString(16));
}

Результат:

length = 4
5b
d83d
dca9
5d

То есть из DOM нам пришёл UTF-16, но JS работает с ним, как с UCS-2. Длина строки у нас вместо 3 стала 4, а если мы захотим получить третий символ, то вместо «]» мы получим хз что (DCA9).

Точно также если мы захотим вывести улыбающегося кота, зная его код (1F63B):

var cat = String.fromCharCode(0x1F63B);
 
document.getElementById("nick").innerHTML = cat;
 
console.log(cat.charCodeAt(0).toString(16)); // f63b

Хрена с два. fromCharCode() использует только младшие 16 бит.

Попаболь

Поэтому, если у вас есть вероятность попадания в ваши строки дымящихся кусков говна и вы хотите их корректно обрабатывать, то вам придётся самим реализовывать поддержку UTF-16 поверх UCS-2.

Таким, например, макаром

Или таким

На сегодня всё.

2 комментария »

  • Спасибо, было интересно почитать, пиши еще!

    adw0rd, 9.02.2015, 13:00

  • Большое спасибо, очень интересно написали! Ахахаха))

    Darmen, 7.09.2016, 17:59

Leave a comment