Gérer les encodages de texte en JavaScript
L’encodage du texte a toujours été plus complexe qu’il ne parait. ASCII, Latin 1, Mac OS Roman, ISO 8850-n, UTF-8… On a tendance à s’y perdre. J’y dédis un article de mon livre Computer Science.
Cette complexité est sans compter le fait que différents langages de programmation gèrent ces encodages différemment. Vous êtes un développeur moderne, vous vous dites peut-être que de nos jours, tout se passe en UTF-8 et qu’il est inutile de s’appesantir sur le sujet ? Grossière erreur !
Vous connaissez le JavaScript. On utilise la méthode length
pour récupérer la longueur d’une chaine de caractère. Essayez donc '🤔'.length
. Si vous pensiez à 1, vous êtes dans l’erreur.
Par ailleurs, saviez-vous que 'A' !== 'А'
? Oui Monsieur, parfaitement ! Le premier est le “a” majuscule latin tandis que le second est en cyrillique. Une vérification sans considération des noms d’utilisateurs d’un site par exemple, peut vite poser problème…
Et vous n’avez encore rien vu ! Convaincu de l’utilité de faire le tour de la question ? Allez, on se lance !
C’est certain, lorsque l’on fait du web, en ne s’encombre que très rarement de ces considérations. Tout est édité en UTF-8, tout est sauvegardé en UTF-8 et tout est rendu sur les pages en UTF-8. Pas problème.
Cet article plonge dans le détail de la gestion des encodages en JavaScript. Aussi, si les plans Unicode, la BMP ou les différents UTF-n sont des notions obscures pour vous, vous devriez lire rapidement le chapitre dédié à ce sujet de mon livre Computer Science.
Compter les caractères
On a vu en exemple que length
ne tombe pas juste avec les Emojis. Il n’y a pas qu’eux. Cela vient du fait que length
ne compte pas comme nous. Pour nous, '🤔'.length
devrait faire 1 car “🤔” constitue un glyphe unique.
En JavaScript, les chaînes de caractères sont encodées en UTF-16, donc sur deux octets. Ainsi, pour length
, tout ce qui fait deux octets ou moins est considéré comme de longueur 1, car cela constitue un mot UTF-16. Cependant, pour les caractères moins courants, ceux qui ne sont pas dans la table BMP, il faudra plusieurs octets pour les encoder, et c’est précisément le nombre de mots UTF-16 que nous retourne length
.
De manière assez simple, si l’on veut que JavaScript compte “correctement” le nombre de glyphes, il faut utiliser l’itérateur de String
.
Array.from('🤔').length // 1
Voilà qui résout nos problèmes ! En résumé length
compte le nombre de mots UTF-16 tandis que l’itérateur compte le nombre de points de code.
L’habit ne fait pas le moine
Comme le dit le proverbe, on ne peut se fier aux apparences ! Deux caractères visuellement identiques peuvent être différents, par exemple ê !== ê
. Nous sommes cette fois-ci en présence du caractère Latin Small Letter E with Circumflex pour le premier mais d’un ensemble de deux caractères pour le second :
- Latin Small Letter E –> e
- Combining Circumflex Accent –> ̂
Malheureusement, cette fois, le String iterator ne viendra pas à notre secours.
Array.from('ê').length // 2
😭 ! C’est le cas de le dire 😝
Que pouvons-nous faire ? Si vous avez lu mon article sur Unicode, vous n’êtes pas sans savoir que le standard Unicode propose une notion d’équivalence afin de savoir si, quand bien même deux caractères sont différents, on peut les considérer comme équivalents.
C’est en effet le cas de l’exemple précédent. On pourrait pratiquement être tenté de penser qu’une comparaison sans vérification de type fonctionne :
* ê !== ê // false
* ê != ê // true
Ça n’est pas le cas, mais c’est un peu l’idée. Unicode fournit deux notions d’équivalence.
- L’équivalence canonique signifie que deux caractères sont équivalents visuellement et sémantiquement parfaitement identiques. C’est le cas de notre exemple. Ainsi, tout caractère pré-composé est canoniquement équivalent à sa forme composée.
- L’équivalence de compatibilité est moins stricte mais permet d’effectuer des comparaisons, notamment en permettant l’usage de jeux de caractères plus restreints. Cela permettra par exemple de comparer les ligatures à leur équivalent non-lié (“…” à “…”), ou encore les chiffres en indice ou exposant à la version normale…
L’équivalence canonique est un sous-ensemble plus strict de l’équivalence de compatibilité. Par conséquent, toute séquence canonique est aussi compatible.
Pour comparaison, Unicode définit quatre formes normales, deux sont des formes canoniques (NFx) et deux sont des formes de compatibilité (NFKxx), chacune d’elle offrant la forme composée et la pré-composée.
- NFD
- Normalization Form Canonical Decomposition. Les caractères sont convertis dans leur équivalent composé.
- NFC
- Normalization Form Canonical Composition. C'est l'inverse de la précédente, les caractères sont convertis dans leur équivalent pré-composé.
- NFKD
- Normalization Form Compatibility Decomposition. Les caractères sont décomposés par équivalence canonique et de compatibilité, et sont réordonnés.
- NFKC
- Normalization Form Compatibility Composition. Les caractères sont décomposés par équivalence canonique et de compatibilité, sont réordonnés et sont composés par équivalence canonique.
Quelques exemples.
const composed = 'ê'; // U+0065 U+0302
const preComposed = 'ê'; // U+00EA
const realExp = 'n²';
const fakeExp = 'n2';
console.log (composed === preComposed); // false
console.log(composed.normalize('NFC') === preComposed); // true
console.log(preComposed.normalize('NFD') === composed); // true
console.log(realExp.normalize('NFKC') === fakeExp); // true
La normalisation nous permet de trier, rechercher et comparer. Elle offre donc de grands services.
Quand y’a problème
La normalisation nous rend de bons services… mais elle ne résout pas tout non plus. Quelle que soit la normalisation utilisée, certains caractères proches, visuellement ou sémantiquement, ne sont pas compatibles.
En exemple, nous pouvons citer des lettres similaires d’alphabets différents, A !== А
; certaines ligatures œ !== oe
mais encore des signes enregistrés comme points de code différents pour des raisons variées α !== ⍺
.
Dans ce dernier exemple, nous sommes en présence de la lettre grecque alpha (U+03B1) pour le premier, et du signe mathématiques alpha (U+237A) pour le second.
C’est un peu la jungle car bien que le signe Micro “μ” (U+03B5) ne soit pas égal à la lettre grecque Mu “µ” (U+03BC), elle est équivalente, mais ceci n’est pas vrai pour les deux alpha.
const greakAlpha = 'α';
const scienceAlpha = '⍺';
const greakMicro = 'µ';
const scienceMicro = 'μ';
console.log(greakAlpha.normalize('NFKC') === scienceAlpha.normalize('NFKC')); // false
console.log(greakMicro.normalize('NFKC') === scienceMicro.normalize('NFKC')); // true
Cette règle est dûe au fait que le signe Micro était déjà présent dans la table Latin-1, tandis que dans ce jeux de caractère, alpha n’était pas une option, version grecque ou scientifique.
Pour palier à ces problèmes, le groupe Unicode publie une table des signes qui peuvent être confondus. C’est assez fastidieu à gérer manuellement, nous en conviendrons.
Il existe donc un module JavaScript qui reprend la table en question et nous offre une fonction de comparaison. Tous les signes sont listés dans le fichier chars
du code source. À titre d’exemple, voici la ligne 67, des “a”, .
aɑαа⍺a𝐚𝑎𝒂𝒶𝓪𝔞𝕒𝖆𝖺𝗮𝘢𝙖𝚊𝛂𝛼𝜶𝝰𝞪
Rien d’extrêmement complexe en soit, mais nous sommes fort reconnaissants à ce module de nous dispenser de cette fastidieuse tâche. Il n’est pas conçu pour directement comparer deux lettres, mais pour vérifier sur une chaîne n’est pas semblable à ensemble d’autres chaînes de caractères.
const homoglyphSearch = require('homoglyph-search');
const userNames = ['Antoine', 'Charlotte', 'Luc'];
console.log(homoglyphSearch.search('αntoine', userNames));
// [ 'Antoine', 'Charlotte', 'Luc' ]
// [ { match: 'αntoine', word: 'Antoine', index: 0 } ]
Quoi qu’il en soit, gardez à l’esprit que vous ne pourrez jamais être sur à 100% de votre comptage et de vos comparaisons. D’autant plus avec le système combinatoire de l’Unicode.
🤦🏼♂️ // 5 points Unicode, 17 octets (U+1F926 U+1F3FC U+200D U+2642 U+FE0F)
🤦🏼♂ // 4 points Unicode, 14 octets (U+1F926 U+1F3FC U+200D U+2642)
Il est quasi certain que ces deux glyphes s’affichent de manière identique sur votre système. Ils sont pourtant composés de manière différente. Autant donc se résigner et accepter que tout n’est pas sous votre contrôle.
Représenter un caractère en JavaScript
Vous le savez peut-être, mais nous avons plusieurs moyens de représenter un caractère en JavaScript. La manière de faire la plus courante est de simplement utiliser la séquence d’octets du caractère. Cette manière est totalement transparente pour nous car il suffit d’entrer le caractère souhaité.
L’autre façon est d’utiliser une séquence d’échappement. Le JavaScript en compte quatre :
- séquence octale,
- séquence hexadécimale,
- séquence Unicode,
- séquence points de code Unicode.
Quelles différences ? Les deux premières permettent de représenter les 256 caractères de la table ASCII, mais l’octale est dépréciée en faveur de l’hexadécimale. Prenons “@” comme exemple. Un rapide coup d’œil à la table ASCII nous apprends que “@” porte le point de code 64. Soit 100 en octal et 40 en hexadécimal.
// Les séquences octales comportent de 2 à 4 caractères
// Il est possible de forcer 4 caractères par des zéro à gauche
// Cela permet d'éviter des confusion si plusieurs séquences se suivent
console.log('\100'); // @
// Les séquences hexadécimales sont toujours de quatre caractères
// Elle commence toujours par "x" pour hexa
console.log('\x64'); // @
Ces deux premières séquences sont assez peu utilisées car assez limitantes. Les deux autres options offrent bien plus de possibilités. La séquence Unicode reprend l’encodage UTF-16. Il est donc possible de représenter tous les caractères de la BMP avec une séquence, et les autres caractères en combinant deux séquences avec le mécanisme de surrogate pairs de l’UTF-16.
Les séquences de points de code Unicode permettent, quant à elles, de représenter tout caractère de la table directement grâce à son numéro Unicode.
Voyons maintenant d’autres exemples avec un caractère plus cool : 😎
// Utilise directement la séquence d'octets
console.log('😎'); // 😎
// Séquence Unicode UTF-16,
// on utilise ici le mécanisme de surrogate pour les caractères nécessitant plus de deux octets
console.log('\uD83D\uDE0E'); // 😎
// On précise ici le numéro unicode directement entre \u{UNICODE_NUMBER}
console.log('\u{1F60E}'); // 😎
Dans ces deux derniers encodages, vous avez certainement noté que nous avons le “u” signifiant unicode. Dans les cas où le caractère représenté est dans la BMP, les deux séquences seront très similaires car le numéro Unicode est égal à son encodage UTF-16. Reprenons notre exemple précédent.
// Séquence hexadécimale
console.log('\x40'); // @
// Séquence Unicode hexa/UTF-16, longueur fixe de 6 caractères
console.log('\u0040'); // @
// Séquence numéro de code Unicode
// La taille est ici variable, les zéro non-significatifs sont facultatifs
console.log('\u{40}'); // @
Créer un caractère à partir de points de code
Pour créer un ou des caractères à partir d’un ou plusieurs points de code, nous avons deux méthodes à notre disposition :
String.fromCharCode
permet de créer un ou plusieurs caractères à partir d’un point ou suite de points UTF-16String.fromCodePoint
permet de créer un ou plusieurs caractères à partir d’un point ou suite de point Unicode.
La seconde méthode est un apport de l’ES6. La majeure différence entre les deux est que fromCodePoint
permet de créer des caractères n’appartenant pas à la BMP directement à partir de leur point de code Unicode. fromCodePoint
devra obligatoirement recourir aux surrogate pairs pour représenter des caractères hors de la BMP.
console.log(String.fromCharCode(0xD83D, 0xDE0E)); // 😎
console.log(String.fromCodePoint(0x1F60E)); // 😎
Récupérer un point de code à partir d’une chaîne
Il s’agit là de l’opération inverse de la précédente. Nous avons également deux méthodes nous permettant d’effectuer cette action :
charCodeAt
retourne un entier (décimal) compris entre 0 et 65535 qui correspond au code UTF-16 d’un caractère de la chaîne situé à une position donnée.codePointAt
retourne un entier (décimal) qui correspond au code Unicode du caractère de la chaîne à la position donnée.
De même que fromCodePoint
, codePointAt
est un ajout de l’ES6. Il prend donc en charge l’Unicode tandis que charCodeAt
ne retournera que le point de code correspondant à une des surrogates s’il s’agit d’un caractère n’appartenant pas à la BMP.
Vous notez par ailleurs que ces deux méthodes retournent les points de code en décimal. On travaille le plus couramment en hexa, on utilisera donc toString
pour immédiatement récupérer les valeurs en hexa.
// Le résultat retourné est le même avec des caractères de la BMP
console.log('😎'.charCodeAt('@').toString(16)); // 40
console.log('😎'.codePointAt('@').toString(16)); // 40
// On récupère ici le premier surrogate
console.log('😎'.charCodeAt('0').toString(16)); // d83d
// Et le second à l'index 1, le caractère encodé en UTF-16 est bien D83D DE0E
console.log('😎'.charCodeAt('0').toString(16)); // de0e
// Ici on obtient directement le point de code Unicode du caractère
console.log('😎'.codePointAt('0').toString(16)); // 1f60e
Naviguer entre les encodages
Il y a quelques temps, j’ai travaillé sur une API de SMS marketing. Vous l’ignorez peut-être, mais lorsque le SMS a été inventé, l’UTF-8 n’était pas encore trop à la mode, et il ne l’est toujours pas dans le monde du SMS. En front, vous travaillez donc en UTF-8, vous devez quand-même gérer le comptage des caractères et encoder le tout en back…
Sans oublier que vous recevez aussi des SMS. Vous devez interpréter l’encodage, le convertir en UTF-8 avant de le stocker et de l’afficher. C’est là qu’on réalise tout l’intérêt de maîtriser un minimum le sujet.
Encodage des SMS
Dans le monde du SMS, aujourd'hui encore, la table d'encodage la table GSM-7.Dans cette table, tout est encodé sur 7 bits, comme au tout début de l’ASCII. Nous sommes d’accord, cela ne fait pas beaucoup de caractères. C’est pourquoi il est possible pour les téléphones modernes d’utiliser l’UCS-2. Ce dernier est un encodage de longueur fixe sur deux octets.
Il est le premier à avoir été normalisé par le consortium Unicode. L’UCS-2 est aujourd’hui déprécié et n’est plus en usage en dehors de la téléphonie. L’inconvénient de UCS-2 est que chaque caractère est encodé sur deux octets, donc cela prend nettement plus d’espace. Par ailleurs, il ne permet de représenter que les 65k caractères de la BMP, ce qui est aujourd’hui restrictif.
Malgré cela, vous réalisez que vous et moi, envoyons et recevons constamment des messages avec des caractères en dehors de la BMP : nos fameux Emojis qui font bien plus de deux octets ! L’UCS-2 est un sous-ensemble de l’UTF-16BE.
En pratique, l’UCS-2 est très peu supporté. Ainsi, côté logiciel, tous les smartphones décodent les SMS UCS-2 avec l’algorithme UTF-16. De ce fait, tout UCS-2 valide est décodé correctement, mais c’est aussi le cas de messages encodés en UTF-16, bien que techniquement, ils ne respectent pas le standard.
Le problème est posé. On doit compter correctement le nombre de caractères, jongler entre plusieurs encodages, envoyer dans un encodage mais stocker dans un autre. À tout cela s’ajoute une doc lacunaire quand elle n’est pas tout bonnement fausse !
Premier contact
Je n’y connais alors rien au standard SMS et je lis machinalement la doc de la société qui fait transiter nos SMS. La doc mentionne le fait que la table est retreinte, mais explique qu’il est possible “d’encoder en Unicode”.
L’élément
<binary>
est utilisé pour le contenu du message à la place de l’élément<text>
dans le cas où le message doit être envoyé en unicode.
<binary>
peut avoir un attribut unicodeunicode="1"
qui précise que les octets dans le champ binary sont codés sous le forme hh : Le caractère Q est par exemple codé 0051 (en Unicode un caractère est codé sur 2 octets)unicode="2"
qui précise que les octets dans le champ binary sont codés sous le forme %hh : Le caractère Q est par exemple codé %00%51 (en Unicode un caractère est codé sur 2 octets)
Tout va bien, Unicode est un encodage qui fonctionne sur deux octets. Les mecs connaissent leur sujet 🤣
Fort heureusement, un petit tour sur la Unicode table nous apprend que “Q” vaut 0051
quand il est encodé en UTF-16BE. Ça tombe bien, ça matche avec l’UCS-2.
On a plus loin un autre exemple :
Que je m’aime ! 😂
Encodé en Unicode binary0051007500650020006a00650020006d002700610069006d0065002000210020d83dde02
Oui, vous remarquez qu’on a une chaîne de caractère “binaire” qui contient en fait de l’hexa. Le binaire ne contient que des 0 et des 1, comme chacun sait. Donc la chaîne en question encodée en UTF-16BE et représentée en binaire, ça donne plutôt ça.
0101000100000000011101010000000001100101000000000010000000000000011010100000000001100101000000000010000000000000011011010000000000100111000000000110000100000000011010010000000001101101000000000110010100000000001000000000000000100001000000000010000011011000001111011101111000000010
Le décor est planté : on est face à un sujet pas évident et rien d’autre pour nous épauler qu’une doc d’amateur écrite par une personne pas très au fait des standards.
Grâce aux deux exemples, on sait néanmoins que si on sort de la table GSM, on peut compter sur l’UTF-16 – et pas l’UCS-2 – car il nous est possible d’utiliser des Emojis. L’UCS-2 nous limiterait à la BMP, et nous priverait de nos chers Emojis. On sait dès lors que l’on peut encoder tous les caractères Unicode.
Cela mène cependant à un nouveau détail. La doc nous affirme que “en Unicode un caractère est codé sur 2 octets”. De nouveau, on flaire l’ignorance du rédacteur de la documentation. En effet, si “Q” est bien encodé sur deux octets (00 51), “🤣” en requiert déjà quatre (D8 3D DE 02), soit deux mots UTF-16. Ce que nous confirme notre length
favori.
'🤣'.length // 2
Deux points de code de deux octets chacun : 2 x 2 = 4. Tout caractère n’est donc pas en “Unicode” encodé sur deux octets. Ou alors, il faut redéfinir le terme octet !
On sait donc qu’à défaut de pouvoir faire confiance à la doc, on peut compter sur length
pour évaluer le nombre de “caractères” que contient un SMS. Profitons-en d’ailleurs pour un nouvel exercice de comptage.
'👨👩👧👦'.length // 11
Avec une confusion entre point de code et caractère, on comprend rapidement que la factuaration risque d’être sport, mais c’est un autre débat…
Ce que l’on sait de manière certaine, c’est que cette chimère encodée sur deux octets est de l’UTF-16BE. De ce fait, le comptage des caractères peut s’effectuer directement en front, c’est assez simple.
- On se constitue un array avec tous les caractères valides dans la table GSM.
- On normalise le texte entré en NFD. Ainsi tous les caractères sont dans leur forme composée, moins de caractères à envoyer et plus de chance de matcher la table GSM.
- Si tout matche, chaque caractère compte pour 1 et le SMS peut contenir 160 caractères. Sinon, on encode en UCS-2/UTF-16 et le SMS ne peut plus contenir que 70 caractères (ils sont encodés sur 16 bits et non plus 7).
- Si le SMS dépasse le nombre de caractères que peut contenir un seul message, on utilise un User Data Header spécifiant que le message est composé de plusieurs segments. Ce header prend de la place, ainsi, il ne nous reste plus que 153 caractères par message en encodage GSM et 67 en UCS.
const maxGSMChars = 160;
const maxUCSChars = 70;
const multiSegmentGSMChars = 153
const multiSegmentUCSChars = 67
// On normalise l'input
const normalisedMsg = inputMsg.normalize('NFD');
// Notre fonction vérifie si tous les caractères contenus dans normalisedMsg appartiennent à la table GSM
// elle retourne un bool
const encodingType = checkGSMCompatibility(normalisedMsg) ? 'GSM' : 'UCS';
// Dans les deux cas, String.length correspond maintenant à notre définition de longueur de caractère
let numberOfSegments;
if (
(encodingType === 'GSM' && normalisedMsg.length <= maxCharsPerMsg)
|| (encodingType === 'GSM' && normalisedMsg.length <= maxCharsPerMsg)
) {
numberOfSegments = 1;
}
else {
const charsPerSegment = encodingType === 'GSM' ? multiSegmentGSMChars : multiSegmentUCSChars;
numberOfSegments = normalisedMsg.length / charsPerSegment;
}
Transcoding
Le front a fait son boulot et il n’est pas démesurément complexe. Côté back, nous avons un peu de pain sur la planche.
Premièrement, on peut réutiliser l’algo précédent pour vérifier si oui ou non, le texte du SMS peut être envoyé en mode texte directement avec la table GSM. Si oui, nous communiquons le texte à l’API de l’opérateur sans autre forme de procès.
Bien qu’il en soit proche, le texte ainsi envoyé n’est pas dans l’encodage de la table GSM à proprement parler, il est en UTF-8 (car c’est dans cet encodage que nous avons receuillis les données sur notre page web).
Vous le savez peut-être (si vous avez vu mon le chapitre sur Unicode de mon livre ), l’UTF-8 est rétro-compatible avec l’ASCII. Donc tout texte ASCII est un texte UTF-8 valide.
Bien que très proche de l’ASCII, la table GSM n’est pas exactement la même. Ce n’est pas à nous de nous occuper de cette conversion, mais si cela avait été le cas, il aurait simplement fallu réencoder quelques caractères. Le “@” a par exemple comme valeur 40 en ASCII et 00 en GSM 03.38.
Encoder en UTF-16
Le plus gros du travail consistera à encoder les messages qui doivent être envoyés en UTF-16. Notre texte est en interne stocké en UTF-16 par JavaScript, mais nous travaillons avec des séquences d’octets de manière totalement transparente. Il va falloir forcer la conversion en UTF-16 et récupérer une représentation en hexa.
Les différentes méthodes que nous avons précédemment vu nous permettent d’effectuer cette conversion sans aucune difficulté.
function encodeToUTF16(message) {
// On procède octet par octet
return message.split('').map((char) => {
// Pour chaque octet, on récupère sont point de code
const word = char.codePointAt(0).toString(16);
// Si le point de code ne fait qu'un seul octet, on ajoute des 0
// Ceci permet d'obtenir la longueur fixe de l'UTF-16
if (word.length === 2) return `00${word}`;
return word;
})
.join('');
}
On utilise ici codePointAt
mais on pourrait tout aussi bien utiliser charCodeAt
étant donné que l’on itère octet par octet et non par point de code Unicode.
Décoder du Latin-1 URL encoded
Là vous vous dites très certainement quelque-chose dans le genre de WTF. Celui-ci est pour le lulz.
En effet, lorsqu’un SMS envoyé reçoit une réponse, celle-ci nous est retournée sur un endpoint de notre choix, via une requête GET
. POST
aurait été plus indiqué, mais pour une raison que j’ignore c’est du GET
et tout est passé en paramètre de l’URL.
// On a donc un endoint appelé de la sorte
/sms/response/?FROM=tel&MESSAGE=msg&RET_ID=campaignId
Rien de bien sorcier pensez-vous. Cependant, msg
peut contenir toute sorte de caractères. C’est un SMS, il est donc en théorie soit au format GSM, soit en UCS-2… Néanmoins, comme les données ne sont pas envoyés en POST
mais passées directement en paramètres, il n’est pas possible de spécifier l’encodage utilisé.
La RFC3986 précise que pour les URL, tout caractère réservé ou qui sort de la table ASCII doit être encodé en UTF-8 puis mis au format URL.
When a new URI scheme defines a component that represents textual
data consisting of characters from the Universal Character Set [UCS],
the data should first be encoded as octets according to the UTF-8
character encoding [STD63]; then only those octets that do not
correspond to characters in the unreserved set should be percent-
encoded. For example, the character A would be represented as “A”,
the character LATIN CAPITAL LETTER A WITH GRAVE would be represented
as “%C3%80”, and the character KATAKANA LETTER A would be represented
as “%E3%82%A2”.
L’URL encoding est assez simple, tout caractère réservé ou qui n’est pas ASCII, est représenté par la valeur de l’octet en hexadécimal. Bien entendu, cette valeur varie selon l’encodage utilisé. C’est pourquoi, comme il n’y a pas moyen de savoir quel encodage est utilisé, le standard dit de toujours les considérer comme étant de l’UTF-8.
console.log(encodeURIComponent('😎')); // %F0%9F%98%8E
La valeur de “😎” est bien F0 9F 98 8E
en UTF-8. En UTF-16BE, ce serait D8 3D DE 0E
. D’où l’importance de bien respecter le standard, sans quoi, on doit jouer aux devinettes.
Notre opérateur farceur, dans sa grande créativité, a décidé que ce serait plus drôle d’encoder en Latin-1 avant d’effectuer l’URL encoding. Évidemment, la doc reste muette à ce sujet – sinon ce ne serait pas drôle – ce qui évidemment faisait planter l’API lorsque des caractères non-ASCII devaient être décodés.
En effet, decodeURIComponent
retourne une erreur si l’encodage est invalide. Je me retrouve donc obligé de parser et décoder manuellement les paramètres GET retournés par notre cher opérateur.
Par chance, les octets du Latin-1 matchent avec les code points Unicode, la conversion est donc assez aisée.
Par exemple, l’apostrophe droit, dont le code unicode est U+0027, vaut 27
en hexa du Latin-1, et sera donc encodée %27
avec l’URL encoding. Vous pouvez consulter la table Latin-1 sur le site de Standford.
Pour récupérer automatiquement le bon caractère en JavaScript, on utilise une simple REGEX pour obtenir la valeur après le “%”, puis on fait la correspondance directement avec la fonction fromCharCode
.
Cette fonction retourne le caractère correspondant à un point de code Unicode. Cependant, elle retourne un codepoint en décimal et non en hexadécimal. Il suffit pour cela d’utiliser parseInt
et le tour est joué. Voici donc le code correspondant :
function decodeLatin1URIComponent(str) {
str.replace(/\+/g, ' ').replace(/%([a-f0-9]{2})/gi, (m, m1) => String.fromCharCode(parseInt(m1, 16)));
}
Vous l’avez peut-être remarqué, on remplace les “+” par des espaces avant la conversion. En effet, le “+” est un caractère réservé qui compte pour un espace dans la norme URL encoding. On le remplace donc avant la conversion, car après, on ne serait plus en mesure de savoir s’il s’agit d’un plus “espace” ou réellement du signe “+”.
Encoder en UTF-8
L’UTF-8 est un encodage de taille variable. Il y a plusieurs moyens d’y parvenir en JavaScript. Si l’on est dans un environnement dans lequel le JavaScript moderne est supporté, aucun problème.
const encoder = new TextEncoder();
const utf8Arr = encoder.encode('😎');
console.log(utf8Arr); // 240 159 152 142
Et voilà, le tour est joué. Il est possible que vous souhaitiez travailler en hexa, on va se faire une petite fonction pour ça.
function encodeToUnicodeUtf8(str) {
const encoder = new TextEncoder();
const utf8Arr = encoder.encode(str);
return utf8Arr.reduce((acc, curr) => {
acc.push(curr.toString(16));
return acc;
}, []);
}
console.log(encodeToUnicodeUtf8('😎')); // F0 9F 98 8E
Voilà qui est mieux. Maintenant, admettons que vous désiriez une solution qui ne s’appuie pas sur les toutes dernières API ? Il y a la méthode de Google. Elle consiste à comparer chaque point de code au plan Unicode auquel il appartient et de l’encoder en fonction de sa place.
Il y a une intéressante discussion sur StackOverflow avec plusieurs implémentations à ce sujet.
Cependant, il y a une troisième voie. Peut-être que la partie précédente sur l’URL encoding vous a inspiré. On va pouvoir tricher un peu en s’épaulant de l’URL encoding. Nous avons encodeURIComponent
dont nous avons précédemment parlé, mais aussi encodeURI
.
Contrairement au premier, encodeURI
n’encode pas les caractères réservés. Il encode donc les espaces et les caractères n’appartenant pas à l’ASCII. Voici donc mon implémentation rapide d’un encodeur UTF-8 basé sur encodeURI
.
function encodeToUnicodeUtf8(str) {
if (!str.length) return [];
const url = encodeURI(str);
const safeString = url.replace(/%20/g, ' '); // On remet l'espace comme un espace
const utf8Arr = [];
let multiByte = false;
let multiByteChar = '';
Array.from(safeString).forEach((str) => {
// S'il y a un % c'est qu'on part sur du multibyte encodé par encodeURI
if (str === '%') multiByte = true;
// Premier caractère de l'octet
else if (multiByte && !multiByteChar) multiByteChar = str;
// Second caractère de l'octet encodé
else if (multiByte) {
utf8Arr.push(multiByteChar + str);
multiByteChar = '';
multiByte = false;
}
// Le caractère n'a pas été encodé, on récupère son codepoint
else utf8Arr.push(str.charCodeAt().toString(16));
});
return utf8Arr;
}
Conclusion
J’espère que cet article vous a permis de comprendre l’importance de l’encodage dans la gestion du texte et des communications inter-programmes. Si vous souhaitez encore approfondir la question, je vous recommande la lecture de deux articles : It’s Not Wrong that “🤦🏼♂️”.length == 7 et JavaScript has a Unicode problem.
On a vite tendance à oublier les subtilités de la gestion des différents encodages dès que l’on n’a plus à le gérer explicitement. Aussi, n’hésitez pas à le mettre en favoris pour vite y revenir quand le besoin se présentera 😉
Commentaires
Rejoignez la discussion !