Кодировка (encoding) и декодирование (decoding) в Base64 — распространенный способ преобразования двоичных данных в безопасный текст. Он часто используется в Data URL, таких как встроенные (inline) изображения.


Прим. пер.: с помощью data URL можно решить проблему (ошибку) отсутствующей фавиконки в браузере.


<link rel="icon" href="data:." />

Что происходит при кодировке и декодировании в base64 строк в JS? В этой статье мы рассмотрим некоторые особенности и ловушки, связанные с этими процессами.


btoa() и atob()


Основными функциями для кодировки и декодирования строк в base64 в JS являются btoa() и atob(), соответственно. btoa() (binary to ASCII) кодирует строку в base64, а atob() (ASCII to binary) декодирует.


Пример:


// Обычная строка, состоящая из кодовых точек (code points) ниже 128.
const asciiString = 'hello';

// Работает
const asciiStringEncoded = btoa(asciiString);
console.log(`Encoded string: [${asciiStringEncoded}]`);
// Encoded string: [aGVsbG8=]

// Работает
const asciiStringDecoded = atob(asciiStringEncoded);
console.log(`Decoded string: [${asciiStringDecoded}]`);
// Decoded string: [hello]

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


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


// Строка, представляющая собой сочетание маленьких, средних и больших кодовых точек.
// Эта строка является валидным UTF-16.
// 'hello' состоит из кодовых точек ниже 128.
// '⛳' - это одна 16-битная кодовая единица.
// '❤️' - это две 16-битных кодовых единицы: U+2764 и U+FE0F (сердце и вариант (variant)).
// '????' - это 32-битная кодовая точка (U+1F9C0), которая также может быть представлена в виде суррогатной пары (surrogate pair) 16-битных кодовых единиц '\ud83e\uddc0'.
const validUTF16String = 'hello⛳❤️????';

// Не работает
try {
  const validUTF16StringEncoded = btoa(validUTF16String);
  console.log(`Encoded string: [${validUTF16StringEncoded}]`);
} catch (error) {
  console.log(error);
  // DOMException: Failed to execute 'btoa' on 'Window': The string to be encoded contains characters outside of the Latin1 range.
}

Любое эмодзи в строке приводит к возникновению ошибки. Почему юникод вызывает проблемы?


Рассмотрим, что такое строки в компьютерной науке и JS.


Строки в юникоде и JS


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


Примеры некоторых символов юникода и соответствующих им чисел:


  • h — 104
  • ñ — 241
  • ❤ — 2764
  • ❤️ — 2764 со скрытым модификатором 65039
  • ⛳ — 9971
  • ???? — 129472

Числа, представляющие символы, называются "кодовыми точками" (code points). Они напоминают адреса символов. В эмодзи красного сердца на самом деле две кодовых точки: одна для сердца, вторая для "варианта" цвета — красного. Подробнее о селекторах варианта можно почитать здесь.


Юникод может преобразовывать эти кодовые точки в последовательность байтов двумя способами: UTF-8 и UTF-16.


В двух словах:


  • в UTF-8 кодовая точка может использовать от 1 до 4 байт (по 8 бит на байт)
  • в UTF-16 кодовая точка всегда использует 2 байта (16 бит)

Важно: JS всегда обрабатывает строки как UTF-16. Это ломает такие функции как btoa(), которые ожидают, что каждый символ строки будет использовать один байт. Цитата с MDN:


Метод btoa() создает закодированную в Base64 строку ASCII из двоичной строки (т.е. строки, каждый символ которой рассматривается как байт двоичных данных).


Теперь мы знаем, что символы в JS часто требуют больше одного байта. Но как быть с кодировкой и декодированием таких символов в base64?


btoa() и atob() с юникодом


В ранее упоминавшейся статье на MDN имеется сниппет для решения "проблемы юникода":


function base64ToBytes(base64) {
  const binString = atob(base64);
  return Uint8Array.from(binString, (m) => m.codePointAt(0));
}

function bytesToBase64(bytes) {
  const binString = String.fromCodePoint(...bytes);
  return btoa(binString);
}

const validUTF16String = 'hello⛳❤️????';

// Работает
const validUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(validUTF16String));
console.log(`Encoded string: [${validUTF16StringEncoded}]`);
// Encoded string: [aGVsbG/im7PinaTvuI/wn6eA]

// Работает
const validUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(validUTF16StringEncoded));
console.log(`Decoded string: [${validUTF16StringDecoded}]`);
// Decoded string: [hello⛳❤️????]

Вот что делает этот код:


  1. Интерфейс TextEncoder используется для преобразования строки UTF-16 в поток байтов UTF-8 с помощью метода TextEncoder.encode().
  2. Этот метод возвращает Uint8Array, редко используемый тип данных в JS, являющийся подклассом TypedArray.
  3. Функция bytesToBase64() принимает этот Uint8Array и использует метод String.fromCodePoint() для преобразования каждого байта массива в кодовую точку и создания строки. Результатом является строка кодовых точек, каждая из которых может быть представлена одним байтом.
  4. Наконец, эта строка передается функции btoa() для кодировки в base64.

Процесс декодирования делает тоже самое, но в обратном порядке.


Это работает, поскольку преобразование строки в Uint8Array гарантирует, что хотя строки в JS представлены как UTF-16, т.е. двумя байтами, кодовая точка, которую представляет каждый байт, всегда меньше 128.


Это работает в большинстве случаев, но есть один нюанс.


Случай тихого провала


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


function base64ToBytes(base64) {
  const binString = atob(base64);
  return Uint8Array.from(binString, (m) => m.codePointAt(0));
}

function bytesToBase64(bytes) {
  const binString = String.fromCodePoint(...bytes);
  return btoa(binString);
}

// '\uDE75' - это кодовая точка, которая является половиной суррогатной пары.
const partiallyInvalidUTF16String = 'hello⛳❤️????\uDE75';

// Работает
const partiallyInvalidUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(partiallyInvalidUTF16String));
console.log(`Encoded string: [${partiallyInvalidUTF16StringEncoded}]`);
// Encoded string: [aGVsbG/im7PinaTvuI/wn6eA77+9]

// Работает, но неправильно
const partiallyInvalidUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(partiallyInvalidUTF16StringEncoded));
console.log(`Decoded string: [${partiallyInvalidUTF16StringDecoded}]`);
// Обратите внимание на последний символ
// Decoded string: [hello⛳❤️????�]

Если мы возьмем последний символ после декодирования строки (�) и проверим его шестнадцатеричное (HEX) значение, то получим \uFFFD вместо оригинального \uDE75. Ошибки не возникло, но символ изменился. Почему?


Обработка строк JavaScript


Как упоминалось ранее, JS обрабатывает строки как UTF-16. Но у таких строк есть одно уникальное свойство.


Рассмотрим в качестве примера эмодзи сыра. Кодовой точкой юникода этого эмодзи (????) является 129472. Но максимальным значение 16-битного числа является 65535! Как представляются большие числа в UTF-16?


Для этого используется концепция суррогатной пары (surrogate pair). Об этом можно думать следующим образом:


  • первое число пары определяет "книгу" для поиска. Это называется "суррогатом"
  • второе число пары — это запись в "книге"

Как вы понимаете, наличие книги без записи — это проблема. В UTF-16 это называется одиноким суррогатом (lone surrogate).


Одни интерфейсы JS умеют работать с одинокими суррогатами, другие нет.


В нашем случае для декодирования строки используется TextDecoder. В качестве второго опционального параметра конструктор TextDecoder() принимает объект с настройками, одной из которых является fatal — логическое значение, являющееся индикатором того, должен ли метод decode() выбрасывать TypeError при декодировании невалидных данных. По умолчанию эта настройка имеет значение false. Это означает, что по умолчанию искаженные (malformed) данные заменяются символом замены (replacement character).


Символ �, представленный HEX-значением \uFFFD является символом замены. В UTF-16 строки с одинокими суррогатами считаются "искаженными" или "плохо сформированными".


Существуют разные веб-стандарты (например, 1, 2, 3, 4), определяющие поведение интерфейса при обработке искаженных строк, и TextDecoder является одним из таких интерфейсов. Поэтому проверка правильной формы строки при обработке текста считается хорошей практикой.


Проверка формы строки


Для этой цели все современные браузеры предоставляют функцию String.isWellFormed() (поддержка — 79,65%).


Того же можно добиться с помощью метода encodeURIComponent(), который выбрасывает URIError, если строка содержит одинокий суррогат.


Следующая функция использует isWellFormed(), если она доступна, и encodeURIComponent() в противном случае:


function isWellFormed(str) {
  if (typeof str.isWellFormed !== "undefined") {
    return str.isWellFormed();
  } else {
    try {
      encodeURIComponent(str);
      return true;
    } catch (error) {
      return false;
    }
  }
}

Все вместе


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


function base64ToBytes(base64) {
  const binString = atob(base64);
  return Uint8Array.from(binString, (m) => m.codePointAt(0));
}

function bytesToBase64(bytes) {
  const binString = String.fromCodePoint(...bytes);
  return btoa(binString);
}

function isWellFormed(str) {
  if (typeof str.isWellFormed !== "undefined") {
    return str.isWellFormed();
  } else {
    try {
      encodeURIComponent(str);
      return true;
    } catch (error) {
      return false;
    }
  }
}

const validUTF16String = 'hello⛳❤️????';
const partiallyInvalidUTF16String = 'hello⛳❤️????\uDE75';

if (isWellFormed(validUTF16String)) {
  const validUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(validUTF16String));
  console.log(`Encoded string: [${validUTF16StringEncoded}]`);

  const validUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(validUTF16StringEncoded));
  console.log(`Decoded string: [${validUTF16StringDecoded}]`);
} else {
  // ...
}

if (isWellFormed(partiallyInvalidUTF16String)) {
  // ...
} else {
  console.log(`Cannot process a string with lone surrogates: [${partiallyInvalidUTF16String}]`);
}

Комментарии (2)


  1. Videoman
    23.10.2023 19:12

    Цитата:

    в UTF-16 кодовая точка всегда использует 2 байта (16 бит)

    И тут же противоречие:

    Но максимальным значение 16-битного числа является 65535! Как представляются большие числа в UTF-16? Для этого используется концепция суррогатной пары (surrogate pair)

    Вывод: В UTF-16 кодовая точка, также как и в UTF-8, занимает максимум 4 байта.


  1. kirill-pavlovskii15
    23.10.2023 19:12
    -1

    довольно непростая тема, интересно спасибо