Los matices de las strings de codificación en Base64 en JavaScript

La codificación y decodificación en Base64 es una forma común de transformar contenido binario para representarlo como texto seguro para la Web. Por lo general, se usa para las URLs de datos, como las imágenes intercaladas.

¿Qué sucede cuando aplicas la codificación y decodificación en base64 a cadenas en JavaScript? En esta publicación, se exploran los matices y las dificultades más comunes que se deben evitar.

btoa() y atob()

Las funciones principales para codificar y decodificar en base64 en JavaScript son btoa() y atob(). btoa() va de una cadena a una codificada en base64, y atob() la decodifica de nuevo.

A continuación, se muestra un ejemplo rápido:

// A really plain string that is just code points below 128.
const asciiString = 'hello';

// This will work. It will print:
// Encoded string: [aGVsbG8=]
const asciiStringEncoded = btoa(asciiString);
console.log(`Encoded string: [${asciiStringEncoded}]`);

// This will work. It will print:
// Decoded string: [hello]
const asciiStringDecoded = atob(asciiStringEncoded);
console.log(`Decoded string: [${asciiStringDecoded}]`);

Lamentablemente, como se indica en los documentos de MDN, esto solo funciona con strings que contienen caracteres ASCII o caracteres que se pueden representar con un solo byte. En otras palabras, esto no funcionará con Unicode.

Para ver qué sucede, prueba el siguiente código:

// Sample string that represents a combination of small, medium, and large code points.
// This sample string is valid UTF-16.
// 'hello' has code points that are each below 128.
// '⛳' is a single 16-bit code units.
// '❤️' is a two 16-bit code units, U+2764 and U+FE0F (a heart and a variant).
// '🧀' is a 32-bit code point (U+1F9C0), which can also be represented as the surrogate pair of two 16-bit code units '\ud83e\uddc0'.
const validUTF16String = 'hello⛳❤️🧀';

// This will not work. It will print:
// DOMException: Failed to execute 'btoa' on 'Window': The string to be encoded contains characters outside of the Latin1 range.
try {
  const validUTF16StringEncoded = btoa(validUTF16String);
  console.log(`Encoded string: [${validUTF16StringEncoded}]`);
} catch (error) {
  console.log(error);
}

Cualquier emoji de la cadena generará un error. ¿Por qué Unicode causa este problema?

Para entenderlo, volvamos a comprender las cadenas, tanto en informática como en JavaScript.

Cadenas en Unicode y JavaScript

Unicode es el estándar global actual para la codificación de caracteres, o la práctica de asignar un número a un carácter específico para que pueda usarse en sistemas informáticos. Para obtener información más detallada sobre Unicode, consulta este artículo de W3C.

Estos son algunos ejemplos de caracteres en Unicode y sus números asociados:

  • h - 104
  • ñ - 241
  • ❤: 2764
  • ❤️ - 2764 con un modificador oculto numerado 65039
  • ⛳: 9971
  • Soporte: 129,472

Los números que representan cada carácter se denominan "puntos de código". Puedes pensar en los "puntos de código" como una dirección para cada carácter. En el emoji de corazón rojo, hay dos puntos de código: uno para un corazón y otro para "variar" el color y hacer que siempre sea rojo.

Unicode tiene dos formas comunes de tomar estos puntos de código y convertirlos en secuencias de bytes que las computadoras pueden interpretar constantemente: UTF-8 y UTF-16.

Esta es una vista muy simplificada:

  • En UTF-8, un punto de código puede usar entre uno y cuatro bytes (8 bits por byte).
  • En UTF-16, un punto de código siempre es de dos bytes (16 bits).

Es importante destacar que JavaScript procesa strings como UTF-16. Esto rompe funciones como btoa(), que operan de forma efectiva bajo el supuesto de que cada carácter de la cadena se asigna a un solo byte. Esto se indica de forma explícita en MDN:

El método btoa() crea una cadena ASCII codificada en Base64 a partir de una cadena binaria (es decir, una cadena en la que cada carácter de la cadena se trata como un byte de datos binarios).

Ahora que sabes que los caracteres en JavaScript a menudo requieren más de un byte, en la siguiente sección, se muestra cómo manejar este caso para la codificación y decodificación en base64.

btoa() y atob() con Unicode

Como sabes, el error que se arroja se debe a que nuestra cadena contiene caracteres que se encuentran fuera de un solo byte en UTF-16.

Afortunadamente, en el artículo de MDN sobre base64, se incluye código de muestra útil para resolver este “problema de Unicode”. Puedes modificar este código para que funcione con el ejemplo anterior:

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function base64ToBytes(base64) {
  const binString = atob(base64);
  return Uint8Array.from(binString, (m) => m.codePointAt(0));
}

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function bytesToBase64(bytes) {
  const binString = String.fromCodePoint(...bytes);
  return btoa(binString);
}

// Sample string that represents a combination of small, medium, and large code points.
// This sample string is valid UTF-16.
// 'hello' has code points that are each below 128.
// '⛳' is a single 16-bit code units.
// '❤️' is a two 16-bit code units, U+2764 and U+FE0F (a heart and a variant).
// '🧀' is a 32-bit code point (U+1F9C0), which can also be represented as the surrogate pair of two 16-bit code units '\ud83e\uddc0'.
const validUTF16String = 'hello⛳❤️🧀';

// This will work. It will print:
// Encoded string: [aGVsbG/im7PinaTvuI/wn6eA]
const validUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(validUTF16String));
console.log(`Encoded string: [${validUTF16StringEncoded}]`);

// This will work. It will print:
// Decoded string: [hello⛳❤️🧀]
const validUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(validUTF16StringEncoded));
console.log(`Decoded string: [${validUTF16StringDecoded}]`);

En los siguientes pasos, se explica lo que hace este código para codificar la cadena:

  1. Usa la interfaz TextEncoder para tomar la string de JavaScript codificada en UTF-16 y convertirla en una transmisión de bytes con codificación UTF-8 mediante TextEncoder.encode().
  2. Se mostrará un Uint8Array, que es un tipo de datos menos utilizado en JavaScript y es una subclase de TypedArray.
  3. Toma ese Uint8Array y proporciónalo a la función bytesToBase64(), que usa String.fromCodePoint() para tratar cada byte de la Uint8Array como un punto de código y crear una cadena a partir de él, lo que genera una cadena de puntos de código que se puede representar como un solo byte.
  4. Toma esa cadena y usa btoa() para codificarla en base64.

El proceso de decodificación es el mismo, pero a la inversa.

Esto funciona porque el paso entre Uint8Array y una string garantiza que, si bien la string en JavaScript se representa como una codificación UTF-16 de dos bytes, el punto de código que representa cada dos bytes siempre sea menor que 128.

Este código funciona bien en la mayoría de las circunstancias, pero fallará de manera silenciosa en otras.

Caso de error silencioso

Usa el mismo código, pero con una cadena diferente:

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function base64ToBytes(base64) {
  const binString = atob(base64);
  return Uint8Array.from(binString, (m) => m.codePointAt(0));
}

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function bytesToBase64(bytes) {
  const binString = String.fromCodePoint(...bytes);
  return btoa(binString);
}

// Sample string that represents a combination of small, medium, and large code points.
// This sample string is invalid UTF-16.
// 'hello' has code points that are each below 128.
// '⛳' is a single 16-bit code units.
// '❤️' is a two 16-bit code units, U+2764 and U+FE0F (a heart and a variant).
// '🧀' is a 32-bit code point (U+1F9C0), which can also be represented as the surrogate pair of two 16-bit code units '\ud83e\uddc0'.
// '\uDE75' is code unit that is one half of a surrogate pair.
const partiallyInvalidUTF16String = 'hello⛳❤️🧀\uDE75';

// This will work. It will print:
// Encoded string: [aGVsbG/im7PinaTvuI/wn6eA77+9]
const partiallyInvalidUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(partiallyInvalidUTF16String));
console.log(`Encoded string: [${partiallyInvalidUTF16StringEncoded}]`);

// This will work. It will print:
// Decoded string: [hello⛳❤️🧀�]
const partiallyInvalidUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(partiallyInvalidUTF16StringEncoded));
console.log(`Decoded string: [${partiallyInvalidUTF16StringDecoded}]`);

Si tomas ese último carácter después de decodificar ( ) y compruebas su valor hexadecimal, descubrirás que es \uFFFD en lugar de la \uDE75 original. No falla ni arroja un error, pero los datos de entrada y salida cambiaron silenciosamente. ¿Por qué?

Las cadenas varían según la API de JavaScript

Como se describió anteriormente, JavaScript procesa strings como UTF-16. Pero las cadenas UTF-16 tienen una propiedad única.

Tomemos el emoji del queso como ejemplo. El emoji (app) tiene un punto de código Unicode de 129472. Lamentablemente, el valor máximo de un número de 16 bits es 65535. Entonces, ¿de qué manera UTF-16 representa este número mucho más alto?

UTF-16 tiene un concepto denominado pares subrogados. Puedes pensar en ello de esta manera:

  • El primer número del par especifica en qué "libro" buscar. Esto se denomina "subrogado".
  • El segundo número del par es la entrada del “libro”.

Como puedes imaginar, a veces puede ser problemático tener solo el número que representa al libro, y no la entrada real en ese libro. En UTF-16, esto se conoce como subrogado único.

Esto es particularmente desafiante en JavaScript, ya que algunas APIs funcionan a pesar de tener subrogados solitarios, mientras que otras fallan.

En este caso, usas TextDecoder para decodificar de nuevo desde base64. En particular, los valores predeterminados de TextDecoder especifican lo siguiente:

El valor predeterminado es false, lo que significa que el decodificador sustituye los datos con formato incorrecto por un carácter de reemplazo.

Ese carácter que observaste antes, que se representa como \uFFFD en hexadecimal, es ese carácter de reemplazo. En UTF-16, las strings con subrogados solitarios se consideran “mal formadas” o “sin formato”.

Hay varios estándares web (ejemplos 1, 2, 3, 4) que especifican con exactitud cuándo una cadena con formato incorrecto afecta el comportamiento de la API, pero, en particular, TextDecoder es una de esas APIs. Una práctica recomendada es asegurarse de que las cadenas estén bien formadas antes de realizar el procesamiento de texto.

Verifica si hay cadenas bien formadas

Los navegadores más recientes ahora tienen una función con este fin: isWellFormed().

Navegadores compatibles

  • 111
  • 111
  • 119
  • 16.4

Origen

Puedes lograr un resultado similar con encodeURIComponent(), que arroja un error URIError si la cadena contiene un subrogado único.

La siguiente función usa isWellFormed() si está disponible y encodeURIComponent() si no lo está. Se puede usar código similar para crear un polyfill para isWellFormed().

// Quick polyfill since older browsers do not support isWellFormed().
// encodeURIComponent() throws an error for lone surrogates, which is essentially the same.
function isWellFormed(str) {
  if (typeof(str.isWellFormed)!="undefined") {
    // Use the newer isWellFormed() feature.
    return str.isWellFormed();
  } else {
    // Use the older encodeURIComponent().
    try {
      encodeURIComponent(str);
      return true;
    } catch (error) {
      return false;
    }
  }
}

Combina toda la información

Ahora que sabes cómo controlar tanto Unicode como los subrogados solitarios, puedes juntar todo para crear un código que controle todos los casos y lo haga sin reemplazo de texto silencioso.

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function base64ToBytes(base64) {
  const binString = atob(base64);
  return Uint8Array.from(binString, (m) => m.codePointAt(0));
}

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function bytesToBase64(bytes) {
  const binString = String.fromCodePoint(...bytes);
  return btoa(binString);
}

// Quick polyfill since Firefox and Opera do not yet support isWellFormed().
// encodeURIComponent() throws an error for lone surrogates, which is essentially the same.
function isWellFormed(str) {
  if (typeof(str.isWellFormed)!="undefined") {
    // Use the newer isWellFormed() feature.
    return str.isWellFormed();
  } else {
    // Use the older encodeURIComponent().
    try {
      encodeURIComponent(str);
      return true;
    } catch (error) {
      return false;
    }
  }
}

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

if (isWellFormed(validUTF16String)) {
  // This will work. It will print:
  // Encoded string: [aGVsbG/im7PinaTvuI/wn6eA]
  const validUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(validUTF16String));
  console.log(`Encoded string: [${validUTF16StringEncoded}]`);

  // This will work. It will print:
  // Decoded string: [hello⛳❤️🧀]
  const validUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(validUTF16StringEncoded));
  console.log(`Decoded string: [${validUTF16StringDecoded}]`);
} else {
  // Not reached in this example.
}

if (isWellFormed(partiallyInvalidUTF16String)) {
  // Not reached in this example.
} else {
  // This is not a well-formed string, so we handle that case.
  console.log(`Cannot process a string with lone surrogates: [${partiallyInvalidUTF16String}]`);
}

Existen muchas optimizaciones que se pueden realizar en este código, como generalizar en un polyfill, cambiar los parámetros TextDecoder para que se arrojen en lugar de reemplazar silenciosamente los subrogados solitarios y mucho más.

Con este conocimiento y código, también puedes tomar decisiones explícitas sobre cómo manejar strings con formato incorrecto, como rechazar los datos, habilitar explícitamente su reemplazo o lanzar un error para su análisis posterior.

Además de ser un ejemplo valioso para la codificación y decodificación en base64, esta publicación proporciona un ejemplo de por qué el procesamiento cuidadoso de texto es particularmente importante, en especial cuando los datos de texto provienen de fuentes externas o generadas por el usuario.