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 du contenu binaire pour qu'il soit représenté sous forme de texte adapté au Web. Il est communément 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 à des chaînes en JavaScript ? Cet article passe en revue les nuances et les pièges courants à éviter.

btoa() et atob()

Les principales fonctions à encoder et décoder en base64 en JavaScript sont btoa() et atob(). btoa() passe d'une chaîne à une chaîne encodée en base64, puis atob() effectue le décodage.

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 la documentation MDN, cela ne fonctionne qu'avec les 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 fonctionne 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 lequel des emoji de la chaîne générera une erreur. Pourquoi Unicode est-il à l'origine de ce problème ?

Pour comprendre, prenons du recul afin de comprendre les chaînes, à la fois en informatique et en JavaScript.

Chaînes en Unicode et JavaScript

Unicode est la norme internationale actuelle pour l'encodage des caractères, c'est-à-dire la pratique consistant à attribuer un nombre à un caractère spécifique afin de pouvoir l'utiliser dans les systèmes informatiques. Pour en savoir plus sur Unicode, consultez cet article du W3C.

Voici quelques exemples de caractères en Unicode et des nombres associés:

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

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

Unicode permet de transformer 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 vue trop simpliste:

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

Il est important de noter que JavaScript traite les chaînes au format UTF-16. Cela interrompt les fonctions telles que btoa(), qui reposent sur l'hypothèse que chaque caractère de la chaîne correspond à un seul octet. Ceci est indiqué explicitement sur 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 de la chaîne est traité comme un octet de données binaires).

Maintenant que vous savez que les caractères en JavaScript nécessitent souvent plusieurs octets, la section suivante montre comment gérer ce cas de figure 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 situent en dehors d'un seul octet en UTF-16.

Heureusement, l'article du MNN sur base64 inclut des exemples de code utiles 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 comment ce code encode la chaîne:

  1. Utilisez l'interface TextEncoder pour convertir la chaîne JavaScript encodée en UTF-16 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 cette Uint8Array et fournissez-la à la fonction bytesToBase64(), qui utilise String.fromCodePoint() pour traiter chaque octet de Uint8Array comme un point de code et créez une chaîne à partir de celui-ci, ce qui se traduit par une chaîne de points de code qui peuvent 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 la même chose, mais à l'envers.

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 sur 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 il échouera en mode silencieux dans d'autres.

Cas de défaillance silencieuse

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 le dernier caractère après avoir décodé ( ) et vérifiez sa valeur hexadécimale, vous constaterez qu'il s'agit de \uFFFD et non de \uDE75 d'origine. Elle n'échoue pas et ne génère pas d'erreur, mais les données d'entrée et de sortie ont été modifiées silencieusement. Pourquoi ?

Les chaînes varient selon l'API JavaScript

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

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

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

  • Le premier chiffre de la paire indique le "livre" à rechercher. C'est ce qu'on appelle un "substitut".
  • Le deuxième chiffre de la paire correspond à l'entrée du "livre".

Comme vous pouvez l'imaginer, il peut parfois être problématique d'avoir uniquement le numéro représentant le livre, et non l'entrée réelle dans ce livre. En UTF-16, il s'agit d'un substitut de longueur.

Cela est particulièrement difficile dans JavaScript, car certaines API fonctionnent bien qu'elles comportent des substituts uniques tandis que d'autres échouent.

Dans ce cas, vous utilisez TextDecoder lors du décodage à partir de base64. Plus spécifiquement, les valeurs par défaut pour TextDecoder spécifient les éléments suivants:

La valeur par défaut est false, ce qui signifie que le décodeur remplace les données incorrectes 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. Dans UTF-16, les chaînes contenant des substituts seuls sont considérées comme "malformées" ou "mal formatées".

Différentes normes Web (exemples 1, 2, 3, 4) spécifient exactement quand une chaîne non valide affecte le comportement de l'API. Cependant, TextDecoder en fait partie. Il est recommandé de s'assurer que les chaînes sont bien formées avant d'effectuer le 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 à cet effet : isWellFormed().

Navigateurs pris en charge

  • 111
  • 111
  • 119
  • 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 disponible et encodeURIComponent() dans le cas contraire. Un code similaire peut être utilisé afin de 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 les caractères Unicode et les substituts uniques, vous pouvez tout mettre en place pour créer du code qui gère tous les cas sans remplacement de 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 pour qu'ils génèrent au lieu de remplacer silencieusement les substituts isolés, etc.

Avec ces connaissances et ce code, vous pouvez également prendre des décisions explicites sur la façon de gérer les chaînes mal formulées, par exemple en refusant les données ou en autorisant explicitement leur remplacement, ou encore en générant une erreur pour une analyse ultérieure.

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