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 el contenido binario para que se represente como texto seguro para la Web. Se usa comúnmente para URLs de datos, como 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 los errores comunes que se deben evitar.

Las funciones principales para codificar y decodificar en Base64 en JavaScript son btoa() y atob(). btoa() pasa de una cadena a una cadena codificada en base64, y atob() la vuelve a decodificar.

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 la documentación de MDN, esto solo funciona con cadenas 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);
}

Cualquiera de los emojis de la cadena provocará un error. ¿Por qué Unicode causa este problema?

Para comprenderlo, retrocedamos y comprendamos las cadenas, tanto en la ciencia de la computación 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 se pueda usar en sistemas informáticos. Para obtener más información sobre Unicode, consulta este artículo del W3C.

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

  • h - 104
  • ñ: 241
  • ❤: 2764
  • ❤️: 2764 con un modificador oculto con el número 65039
  • ⛳ - 9971
  • 🧀: 129472

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, en realidad hay dos puntos de código: uno para un corazón y otro para “variar” el color y hacerlo siempre rojo.

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

Esta es una vista 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 cadenas como UTF-16. Esto rompe funciones como btoa(), que operan de manera eficaz en la suposición de que cada carácter de la cadena se asigna a un solo byte. Esto se indica explícitamente 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 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 ahora sabes, el error se debe a que nuestra cadena contiene caracteres fuera de un solo byte en UTF-16.

Por suerte, el artículo de MDN sobre base64 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 de TextEncoder para tomar la cadena de JavaScript codificada en UTF-16 y convertirla en un flujo de bytes codificados en UTF-8 mediante TextEncoder.encode().
  2. Esto muestra un Uint8Array, que es un tipo de datos menos usado 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 en 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 pueden representar como un solo byte.
  4. Toma esa cadena y usa btoa() para codificarla en Base64.

El proceso de decodificación es lo mismo, pero al revés.

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

Este código funciona bien en la mayoría de las circunstancias, pero fallará de forma 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 la decodificación ( ) y verificas su valor hexadecimal, verás que es \uFFFD y no el \uDE75 original. No falla ni arroja un error, pero los datos de entrada y salida cambiaron de forma silenciosa. ¿Por qué?

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

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

Tomemos el emoji de queso como ejemplo. El emoji (🧀) tiene un punto de código Unicode de 129472. Lamentablemente, el valor máximo para un número de 16 bits es 65,535. Entonces, ¿cómo representa UTF-16 este número mucho más alto?

UTF-16 tiene un concepto llamado pares de sustitución. Puedes pensarlo de esta manera:

  • El primer número del par especifica en qué "libro" se debe buscar. Esto se denomina "sustituto".
  • El segundo número del par es la entrada en el "libro".

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

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

En este caso, usas TextDecoder cuando vuelves a decodificar 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 el 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 cadenas con sustitutos individuales se consideran “con el formato incorrecto” o “no bien formadas”.

Existen varios estándares web (ejemplos 1, 2, 3 y 4) que especifican con exactitud cuándo una string con formato incorrecto afecta el comportamiento de la API, pero en particular TextDecoder es una de esas APIs. Se recomienda asegurarse de que las cadenas tengan el formato correcto antes de procesar el texto.

Verifica si las cadenas tienen el formato correcto

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

Navegadores compatibles

  • Chrome: 111
  • Edge: 111.
  • Firefox: 119.
  • Safari: 16.4.

Origen

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

La siguiente función usa isWellFormed() si está disponible y encodeURIComponent() si no lo está. Se puede usar un 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 todas las opciones

Ahora que sabes cómo controlar Unicode y los sustitutos solitarios, puedes combinar 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}]`);
}

Se pueden realizar muchas optimizaciones en este código, como generalizarlo en un polyfill, cambiar los parámetros TextDecoder para que se arrojen en lugar de reemplazar los sustitutos solitarios de forma silenciosa y mucho más.

Con este conocimiento y código, también puedes tomar decisiones explícitas sobre cómo controlar cadenas con el formato incorrecto, como rechazar los datos o habilitar explícitamente el reemplazo de datos, o tal vez arrojar un error para analizarlo más adelante.

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