Strings et Unicode en JavaScript
Par Delicious Insights • Publié le 8 mai 2020

Cet article est également disponible en anglais.

Voici le cinquième article de notre série quotidienne « 19 pépites de JS pur ». L’occasion de se pencher sur la relation riche et complexe entre les chaînes de caractères JavaScript (le type String) et Unicode. Parce que le temps du ISO-Latin-15 / Windows-1252 est bel et bien révolu, les gens…

Dans la série…

Extrait de la liste des articles :

  1. Formater proprement un nombre
  2. Array#splice
  3. Strings et Unicode (cet article)
  4. Court-circuiter plusieurs niveaux de boucles
  5. Inverser deux valeurs avec la déstructuration
  6. …au-delà, c’est la surprise ! (mais la liste est déjà calée)…

Une relation compliquée…

À la base, le type String « est Unicode », nous dit-on. Après tout, on peut en effet y mettre n’importe quel glyphe défini par Unicode, ça fonctionne. Mais en pratique, le choix d’encodage retenu, ainsi que la terminologie de son API, posent quelques problèmes.

Incidemment, tout ce qu’on va voir dans cette section, ou presque, est tout aussi vrai dans le type String de Java, puisque les deux ont été pondus en même temps, quasi à l’identique. (Mais JavaScript ne ressemble absolument pas à Java, ne me faites pas dire une énormité pareille !)

UTF-16

Les chaînes de caractères en JavaScript sont encodées en UTF-16 (ou en UCS-2, selon l’implémentation ; la différence est négligeable). Chaque position dans la chaîne référence donc une donnée sur 16 bits, ou 2 octets. Ça suffit certes à encoder les code points Unicode de U+0000 à U+FFFF, mais pas au-delà (alors qu’il y en a une pelletée au-delà, avec en pratique environ 144 000 glyphes). Ce bloc de 16 bits est appelé en anglais code unit, et en français codet.

Ainsi, les Emojis, mais aussi de nombreux alphabets ésotériques ou anciens (tel que l'ougaritique ou le phénicien), ainsi que de nombreux jeux de graphiques (tuiles de Mahjongg (si !), dominos, cartes à jouer…) sont au-delà du 16-bit, et nécessitent donc l’utilisation de deux valeurs conjuguées, lesquelles sont chacune invalides prises individuellement : l’ensemble s’appelle une surrogate pair. Bon, c’est vrai, c’est surtout du graphisme étendu et des langues mortes (mais genre, mortes), mais quand même.

Caractère, codepoint, codet et surrogate pair

Dans nos esprits, un « caractère » désigne une entité complète ; une « case » de la table Unicode, en quelque sorte, ce qu’on appelle un codepoint.

En fait, cette analogie est erronée, car de nombreux codepoints ne représentent pas un caractère complet, mais plutôt des éléments techniques invisibles (tel que le point de césure où le combinateur de largeur nulle) ou des signes diacritiques (ex. l’accent aigu).

Mais bon, en gros, pour nous, un idéogramme chinois, un chiffre géorgien, un pictogramme babylonien ou un émoji sont tous « un » caractère, dans le cadre de l’examen d’un texte.

En pratique pourtant, en raison de l’encodage UTF-16 / UCS-2, de nombreux codepoints vont nécessiter une surrogate pair, donc deux codets, ce qui, dans l’API JavaScript de String, veut dire « deux char ». Une longueur de 2, en fait.

Eh oui ! charAt désigne un codet, pas un codepoint. Pareil pour charCodeAt, basé codet, ou length, qui donne le nombre de codets. Et d’une façon générale toutes les API de String sont basées codet… Regarde un peu :

'🃑🃑🃑🃞🃞'.length // => 10 (5 cartes à jouer / surrogate pairs)
'😅'.charCodeAt(1) // => 56837 / U+DE05, qui est un “trail surrogate”
'😅'.split('') // => ['\ud83d', '\ude05']

Ah oui, remarque la séquence d’échappement \uXXXX, qu’on peut utiliser depuis toujours dans les littéraux String (tu l’as aussi en CSS), elle autorise 4 chiffres hexadécimaux, donc juste 16 bits, un codet, pas un codepoint ! Tu fais comment pour poser un Emoji non littéral ? Tu dois récupérer son codepoint, obtenir la surrogate pair puis taper le tout : '\ud83d\ude05' === '😅'.

C’est joliment le bordel tout ça, non ?

En fait y’a quand même des trucs qui marchent bien, hein, notamment toLocaleUpperCase(), localeCompare(…) et leurs amis, qui sont au courant des questions d’encodage (et peu impactées en pratique), c’est déjà ça…

ES2015 : codepoints littéraux

ES2015 (anciennement « ES6 ») a apporté beaucoup d’améliorations relatives à Unicode.

Prenons d’abord la question des séquences d’échappement : devoir calculer la surrogate pair dès qu’on était au-delà des 16 premiers bits était bien pénible, alors qu’on obtient facilement le codepoint qui nous intéresse.

On a donc une nouvelle séquence d’échappement Unicode, avec des accolades : \u{…}. Elle prend le codepoint complet, ce qui est tout de suite plus pratique.

'\u{1f605}' === '😅' // => true

Perso, je préfère poser le caractère littéral sauf s’il est invisible (comme '\u202f', le narrow no-break space utilisé comme délimiteur de groupe et séparateur d’unité en formatage numérique français, ou encore un vrai caractère invisible, comme le soft hyphen \u00ad, le trait d’union conditionnel qui indique un point de césure). Dans un tel cas, utiliser la séquence d’échappement explicite a le mérite d’être identifiable en lisant le code.

ES2015 : API orientée codepoints

On trouve par ailleurs trois nouvelles méthodes sur String relatives à Unicode.

codePointAt(index)

Comme charCodeAt(index), sauf que si, à la position donnée (qui est en codets), on tombe sur un début de surrogate pair (ce qu’on appelle un lead surrogate ou high surrogate), au lieu de ne renvoyer que celui-ci, ça ira chercher la suite du codepoint dans le codet suivant (le trail surrogate ou low surrogate).

'😅'.charCodeAt(0) // => 55357 / U+D83D “lead surrogate”
'😅'.charCodeAt(1) // => 56837 / U+DE05, “trail surrogate”
'😅'.codePointAt(0) // => 128517 / U+1F605 (codepoint complet)

String.fromCodePoint(…)

Comme String.fromCharCode(…), mais au lieu de prendre des codets, ça prend des codepoints.

String.fromCharCode(0xd83d, 0xde05) // => '😅'
String.fromCodePoint(0x1f605, 0x25b6, 0xfe0f, 0x1f60d) // => '😅▶️😍'

normalize([form])

Quand on se penche sur Unicode, on découvre le concept de normalisation, avec les notions de canonique, de compatible et de (dé)composé. Je vais pas rentrer ici dans un cours complet sur ces notions, qui ne sont pas très complexes mais plutôt hors-sujet.

Un des principaux points, c’est que de nombreux codepoints représentent en fait des glyphes qui sont aussi composables à partir de glyphes distincts. Par exemple, le « E accent aigu », c’est-à-dire « É », peut en Unicode aussi bien être représenté en accolant le glyphe du E (\u0045) et celui du diacritique accent aigu (\u0301), ou par le glyphe effectif du E accent aigu (\u00c9) :

const obfuscated = '\u0045\u0301lodie' // => 'Élodie'
const plain = 'Élodie'
obfuscated === plain // => false
obfuscated.length // => 7
plain.length // => 6

Il est possible de recourir aux différents types de normalisation (orienté canonique ou compatible, composé ou décomposé) pour utiliser une représentation homogène des textes. C’est bien pratique quand ceux-ci viennent de sources variées, pas forcément encodées (au sens Unicode) pareil, et qu’on veut pouvoir les comparer correctement.

obfuscated.normalize('NFC') === plain // => true
obfuscated === plain.normalize('NFD') // => true

La forme utilisée par défaut est NFC, qui est la forme canonique composée, généralement celle occupant le moins de codets et, le cas échéant, retombant sur le codepoint canonique.

En savoir plus sur normalize(…).

ES2015 : itérabilité basée codepoints

ES2015 expose la notion jusqu’ici interne d’itérabilité, notamment le protocole itérable, qu’on peut implémenter sur nos propres objets et que de nombreux objets natifs ou hôtes (par exemple String, Array, Map, Set, NodeList…) implémentent de base.

Pour les chaînes, l’itérabilité est basée codepoints 🎉. On a eu chaud…

Du coup, toutes les consommations d’itérable peuvent, sur une chaîne, suivre les codepoints. Donc la boucle forof, l’opérateur spread (...), la déstructuration positionnelle, et bien sûr les API qui consomment des itérables (ex. Array.from, new Set, Promise.all…).

const text = 'Un petit 🀄️ avec un 🍵 ?'

for (const codePoint of text) {
  console.log(codePoint)
  // => ne charcutera pas à tort le 🀄️ ou le 🍵
}

Array.from(text)
// => ['U', 'n', ' ', 'p', 'e', 't', 'i', 't', ' ', '🀄️'…]

Du coup, si tu veux absolument compter les codepoints dans un texte, tu peux le convertir en tableau (sur base de son itérabilité) et chopper la longueur :

text.length // => 🔴 25
Array.from(text).length // => ✅ 23
[...text].length // => ✅ 23

Note toutefois que ça ne gère pas la perception qu’on a d’une combinaison plus aboutie comme étant un « caractère » unique. Par exemple, l’emoji « famille hétéro avec un garçon et une fille » (👨‍👩‍👦‍👦) est en fait constitué des emojis « homme » (👨), « femme » (👩), « garçon » (👦) et « fille » (👧) séparés par des combinateurs à largeur nulle (zero width joiners, ou ZWJ, prononcé « zwidje »), pour une longueur totale de 7 codepoints, et même 11 codets (chaque emoji en prend 2) ! Et la normalisation ne nous aide pas sur ce coup.

Si les combos d’emoji à coup de ZWJ (qui permettent de changer le genre, la couleur de peau, celle des cheveux…) t’intéresse, cette page est sympa.

Bonus : Unicode dans les regex

On reviendra dans un prochain article « pépite » sur certains aspects des expression rationelles relatifs à Unicode, mais sache pour le moment qu’on a désormais le drapeau u sur celles-ci, qui permet d’utiliser dans le motif les séquences d’échappement « codepoint » (tu sais, \u{…}) ainsi que celles de propriétés Unicode, dont nous verrons toute la puissance dans le fameux article à venir…

'😅'.match(/\u{1f605}/) // => null
'😅'.match(/\u{1f605}/u) // => MatchResult ([ '😅', index: 0… ])

En revanche attention : le drapeau u n’altère pas la sémantique des classes prédéfinies, type \w (alphanumérique) ou \d (numérique), qui restent basées ASCII.

Découvrez notre cours vidéo : JavaScript : this is it ! 🖥

Tout savoir sur le fonctionnement de this en JavaScript, des règles fondamentales aux ajustements des API, en passant par les fonctions fléchées, le binding et bien plus encore…