Nuances de l'encodage des chaînes en base64 en JavaScript

L'encodage et le décodage en base64 sont une forme courante de transformation de contenu binaire pour qu'il soit représenté sous forme de texte adapté au Web. Il est couramment utilisé pour les URL de données, telles que les images intégrées.

Que se passe-t-il lorsque vous appliquez l'encodage et le décodage en base64 aux chaînes en JavaScript ? Cet article décrit les nuances et les pièges courants à éviter.

btoa() et atob()

Les fonctions de base pour l'encodage et le décodage en base64 en JavaScript sont btoa() et atob(). btoa() passe d'une chaîne à une chaîne encodée en base64, et atob() la décode.

Voici un exemple rapide:

// 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}]`);

Malheureusement, comme indiqué dans les documents MDN, cela ne fonctionne que avec des chaînes contenant des caractères ASCII ou des caractères pouvant être représentés par un seul octet. En d'autres termes, cela ne fonctionnera pas avec Unicode.

Pour voir ce qui se passe, essayez le code suivant :

// 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);
}

N'importe quel emoji dans la chaîne provoquera une erreur. Pourquoi Unicode pose-t-il ce problème ?

Pour bien comprendre, prenons un peu de recul et examinons les chaînes de caractères, à la fois en informatique et en JavaScript.

Chaînes en Unicode et en JavaScript

Unicode est la norme mondiale actuelle pour l'encodage des caractères, c'est-à-dire l'attribution d'un nombre à un caractère spécifique afin qu'il puisse être utilisé dans les systèmes informatiques. Pour en savoir plus sur Unicode, consultez cet article du W3C.

Voici quelques exemples de caractères Unicode et les numéros qui leur sont associés :

  • h - 104
  • ñ - 241
  • ❤ – 2764
  • ❤️ : 2764 avec le modificateur masqué numéroté 65039
  • ⛳ - 9971
  • 🧀 - 129472

Les nombres représentant chaque caractère sont appelés "points de code". Vous pouvez considérer les "points de code" comme une adresse pour chaque caractère. Dans l'emoji cœur rouge, il existe en réalité deux points de code: un pour un cœur et un pour "varier" la couleur et la rendre toujours rouge.

Unicode utilise deux méthodes courantes pour convertir ces points de code en séquences d'octets que les ordinateurs peuvent interpréter de manière cohérente: UTF-8 et UTF-16.

Voici une illustration trop simpliste:

  • En UTF-8, un point de code peut utiliser entre un et quatre octets (8 bits par octet).
  • En UTF-16, un point de code est toujours de deux octets (16 bits).

Il est important de noter que JavaScript traite les chaînes au format UTF-16. Cela rompt des fonctions telles que btoa(), qui partent du principe que chaque caractère de la chaîne correspond à un seul octet. Cela est indiqué explicitement sur la page MDN:

La méthode btoa() crée une chaîne ASCII encodée en base64 à partir d'une chaîne binaire (c'est-à-dire une chaîne dans laquelle chaque caractère est traité comme un octet de données binaires).

Vous savez maintenant que les caractères en JavaScript nécessitent souvent plus d'un octet. La section suivante montre comment gérer ce cas pour l'encodage et le décodage en base64.

btoa() et atob() avec Unicode

Comme vous le savez maintenant, l'erreur générée est due au fait que notre chaîne contient des caractères qui se trouvent en dehors d'un seul octet en UTF-16.

Heureusement, l'article MDN sur base64 inclut un exemple de code utile pour résoudre ce "problème Unicode". Vous pouvez modifier ce code pour qu'il fonctionne avec l'exemple précédent:

// 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}]`);

Les étapes suivantes expliquent ce que ce code fait pour encoder la chaîne:

  1. Utilisez l'interface TextEncoder pour prendre la chaîne JavaScript encodée en UTF-16 et la convertir en flux d'octets encodés en UTF-8 à l'aide de TextEncoder.encode().
  2. Cela renvoie un Uint8Array, qui est un type de données moins couramment utilisé en JavaScript et qui est une sous-classe de TypedArray.
  3. Prenez cet élément Uint8Array et transmettez-le à la fonction bytesToBase64(), qui utilise String.fromCodePoint() pour traiter chaque octet de l'élément Uint8Array comme un point de code et en créer une chaîne, ce qui donne une chaîne de points de code pouvant tous être représentés par un seul octet.
  4. Prenez cette chaîne et utilisez btoa() pour l'encoder en base64.

Le processus de décodage est le même, mais dans l'ordre inverse.

Cela fonctionne, car l'étape entre Uint8Array et une chaîne garantit que, bien que la chaîne en JavaScript soit représentée sous la forme d'un encodage UTF-16 à deux octets, le point de code que chaque deux octets représente est toujours inférieur à 128.

Ce code fonctionne bien dans la plupart des cas, mais échoue de manière silencieuse dans d'autres.

Cas d'échec silencieux

Utilisez le même code, mais avec une chaîne différente:

// 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 vous prenez ce dernier caractère après le décodage (�) et vérifiez sa valeur hexadécimale, vous constaterez qu'il s'agit de \uFFFD plutôt que du \uDE75 d'origine. Il ne plante pas et n'affiche pas d'erreur, mais les données d'entrée et de sortie ont changé de manière silencieuse. Pourquoi ?

Les chaînes varient selon l'API JavaScript

Comme décrit précédemment, JavaScript traite les chaînes au format UTF-16. Toutefois, les chaînes UTF-16 ont une propriété unique.

Prenons l'exemple de l'emoji fromage. L'emoji (🧀) a le point de code Unicode 129472. Malheureusement, la valeur maximale pour un nombre de 16 bits est 65 535 ! Comment UTF-16 représente-t-il ce nombre beaucoup plus élevé ?

UTF-16 utilise un concept appelé paires de substitution. Vous pouvez considérer les choses de cette façon:

  • Le premier nombre de la paire indique le "livre" dans lequel effectuer la recherche. C'est ce qu'on appelle une "substitution".
  • Le deuxième chiffre de la paire correspond à l'entrée dans le "livre".

Comme vous pouvez l'imaginer, il peut parfois être problématique de ne disposer que du numéro représentant le livre, mais pas de l'entrée réelle dans ce livre. En UTF-16, on parle de surrogate solitaire.

Cela est particulièrement difficile en JavaScript, car certaines API fonctionnent malgré la présence de surrogates uniques, tandis que d'autres échouent.

Dans ce cas, vous utilisez TextDecoder pour effectuer un décodage à partir de base64. En particulier, les valeurs par défaut pour TextDecoder spécifient ce qui suit:

La valeur par défaut est false, ce qui signifie que le décodeur remplace les données mal formées par un caractère de remplacement.

Le caractère "�" que vous avez observé précédemment, représenté par \uFFFD en hexadécimal, est ce caractère de remplacement. En UTF-16, les chaînes avec des caractères de substitution solitaires sont considérées comme "mal formées" ou "non valides".

Il existe différentes normes Web (exemples 1, 2, 3, 4) qui spécifient précisément à quel moment une chaîne non valide affecte le comportement de l'API, mais TextDecoder est notamment l'une de ces API. Il est recommandé de s'assurer que les chaînes sont correctement formatées avant de procéder au traitement du texte.

Vérifier que les chaînes sont bien formées

Les navigateurs très récents disposent désormais d'une fonction à cette fin : isWellFormed().

Navigateurs pris en charge

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

Source

Vous pouvez obtenir un résultat similaire en utilisant encodeURIComponent(), qui génère une erreur URIError si la chaîne contient un substitut unique.

La fonction suivante utilise isWellFormed() si elle est disponible et encodeURIComponent() dans le cas contraire. Un code similaire peut être utilisé pour créer un polyfill pour 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;
    }
  }
}

Regrouper tous les éléments

Maintenant que vous savez comment gérer à la fois Unicode et les substituts solitaires, vous pouvez tout assembler pour créer du code qui gère toutes les cas, sans remplacer le texte silencieux.

// 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}]`);
}

De nombreuses optimisations peuvent être apportées à ce code, telles que la généralisation dans un polyfill, la modification des paramètres TextDecoder à générer au lieu de remplacer silencieusement les substituts uniques, etc.

Grâce à ces connaissances et à ce code, vous pouvez également prendre des décisions explicites sur la façon de gérer les chaînes mal formées, par exemple en rejetant les données ou en activant explicitement le remplacement des données, ou en générant une erreur pour une analyse ultérieure.

En plus d'être un exemple utile pour l'encodage et le décodage en base64, cet article explique pourquoi le traitement minutieux du texte est particulièrement important, en particulier lorsque les données textuelles proviennent de sources externes ou générées par l'utilisateur.