Troncature de chaîne sûre pour les emojis en JavaScript à l’aide d’Intl.Segmenter
Pourquoi les fonctions slice() et la troncature basée sur le spread corrompent les emojis et les textes accentués, comment certains produits réels ont échoué avec ces méthodes, et comment couper les chaînes de caractères en fonction des frontières graphémiques à la place.
Sources: I Was Truncating Strings Wrong
Presque tous les bases de code disposant d’un champ biographique, d’une prévisualisation de commentaire ou d’un résumé de notifications intègrent un petit outil qui raccourcit le texte jusqu’à atteindre une limite de caractères. Il semble généralement fonctionner correctement, passe tous les tests qui lui sont destinés, mais corrompt ensuite silencieusement le texte de tout utilisateur dont l’entrée contient un emoji proche du point de coupure. Cet article explique ce que JavaScript compte réellement lorsque l’on utilise .length et .slice(), présente des bugs réels dans des produits bien connus ayant la même cause racine, et montre comment effectuer le troncature aux limites que voit réellement l’utilisateur.
L’outil que tout le monde écrit
Voici la version que l’on trouve dans d’innombrables bases de code et tutoriels : si la chaîne est plus longue que la limite, on la coupe et on y ajoute des points de suspension.
function truncate(str, max) {
return str.length > max ? str.slice(0, max) + '…' : str;
}
Pour du texte en anglais, il fonctionne parfaitement, ce qui explique précisément pourquoi il reste en usage depuis si longtemps. Le problème ne se manifeste que lorsque l’entrée d’une personne contient, disons, deux emojis et que la limite se situe entre eux. Au lieu d’une chaîne plus courte, le profil affiche un caractère de remplacement : un diamant noir avec un point d’interrogation. À première vue, rien dans cette fonction ne semble anormal, et un ensemble de tests composé de chaînes ASCII ne le détectera jamais.
Ce que .length compte réellement
Les chaînes de JavaScript sont des séquences d’unités de code UTF-16. À la fois .length et .slice() opèrent sur ces unités de 16 bits, et non sur des caractères au sens humain du terme.
Pour les lettres latines, les chiffres et la plupart des signes de ponctuation, une unité de code équivaut à un caractère, ce qui rend la distinction invisible. Les caractères situés en dehors du Plan multilingue de base, qui inclut la plupart des emojis, nécessitent deux unités de code. Ce couple est appelé une paire de substituts : un substitut haut suivi d’un substitut bas, qui codent ensemble un point de code.
'🐶'.length // 2
Un chien à l’écran, d’une longueur de 2. Si .slice() coupe entre les deux moitiés, on n’obtient pas un emoji plus petit. On obtient un substitut isolé, qui n’est pas un caractère Unicode valide en soi. Les navigateurs le rendent généralement sous forme de caractère de remplacement, tandis que les applications plus strictes lancent une erreur.
Ce est le premier niveau du problème. Il en existe un second, plus subtil, abordé ci-dessous. Tout d’abord, il convient de noter que ce n’est pas une question théorique.
Même bug dans quatre produits réels
Les drapeaux de 14 caractères de Twitter
Pendant des années, Twitter a estimé la longueur d’un tweet en se basant approximativement sur les points de code. Un simple emoji de pouce levé coûtait 2 caractères, l’ajout d’une variante de teint de peau portait ce chiffre à 7, et certains drapeaux atteignaient même 14. La raison en est que les variantes de teint de peau et les drapeaux sont composés de plusieurs points de code assemblés, chacun étant compté séparément.
En octobre 2018, Twitter a modifié la règle de manière à ce que chaque emoji soit compté pour 2, quel que soit le nombre de points de code qu’il contient. Ce coefficient fait toujours partie des règles de calcul dans sa bibliothèque open source twitter-text. Consultez les informations sur ce changement de calcul des emojis ainsi que la documentation relative au comptage des caractères sur X.
Cette distinction est utile car elle sépare deux questions qui ont tendance à être confondues :
- Combien de caractères visibles l’utilisateur voit-il ?
- Comment le produit doit-il tarifer ou limiter les différents types de texte ?
La réponse de Twitter à la deuxième question est une politique de produit. Ce n’est pas la même chose que de mesurer ce qui apparaît à l’écran.
Yjs et lib0 : un emoji divisé qui a provoqué silencieusement l’arrêt de la synchronisation
Yjs est une bibliothèque largement utilisée pour l’édition collaborative, et TipTap s’appuie sur elle. Yjs dépend d’un petit paquet d’outils, lib0, dont l’aide splice utilisait internement .slice().
Lorsqu’une modification collaborative avait lieu entre les deux parties d’un emoji, lib0 générait une chaîne de caractères contenant un substitut orphelin. Cette chaîne était ensuite transmise à encodeURIComponent, qui lance une erreur URIError lorsqu’il rencontre un substitut isolé. Aucun mécanisme n’a capturé cette exception, ce qui a simplement entraîné l’arrêt de la synchronisation. Un rapport détaillé sur la faille liée aux substituts de lib0 est disponible.
Ce qui rend cette situation particulièrement problématique, c’est qu’elle était invisible. L’éditeur continuait de fonctionner localement. Les utilisateurs voyaient leur propre texte et continuaient à taper, sans aucun signe indiquant que leurs modifications n’arrivaient plus aux autres utilisateurs. La correction proposée détecte un caractère supplémentaire inutile à la fin de la chaîne de texte et le remplace par le caractère de remplacement.
React Native TextInput et maxLength
TextInput de React Native dispose d’une propriété maxLength, et des problèmes liés au comportement avec les emojis y ont été signalés depuis 2016, par exemple les tickets 10964 et 28774.
Ces rapports suivent le même schéma : on définit une valeur pour maxLength, on tape jusqu’à cette limite, puis on ajoute un emoji. Au lieu de rejeter l’emoji ou de l’accepter dans son intégralité, la saisie le coupe au niveau d’une frontière de unité de code. Les utilisateurs ont rapporté voir un emoji différent de celui qu’ils avaient tapé, une boîte contenant un « A », ou, sur iOS, le champ entier se vidant.
Si votre application dispose d’un champ de nom d’utilisateur ou de bio avec une longueur maximale et que vous n’avez jamais tapé d’emoji représentant une teinte de peau juste à cette limite, cela vaut la peine d’essayer. Un ensemble de tests manuels rapides couvre les cas principaux :
Hello 👋
Hello 👋🏽
Hello 🇮🇳
Hello 👨👩👧👦
Ces quatre lignes contiennent un emoji simple, un autre avec un modificateur de teinte de peau, un drapeau et une séquence familiale reliés par des connecteurs sans largeur. Si votre code les traite tous comme un seul caractère, il compte probablement autre chose.
truncateString de Gemini CLI
En mars 2026, il a été découvert que la CLI Gemini de Google disposait d’une fonction d’aide truncateString qui tronquait les chaînes à la valeur . Lorsqu’une chaîne se terminait par un emoji représentant un visage en larmes et que la limite de troncature se situait à l’intérieur de cette partie, la fonction renvoyait un seul caractère \uD83D, à savoir le surrogat haut isolé (problème 22753).
Cela va au-delà d’un simple problème esthétique. Un surrogat isolé rend la chaîne invalide en format UTF-16, et son utilisation avec un encodeur JSON ou une API peut provoquer des erreurs ultérieures. La solution est celle à laquelle aboutit systématiquement chaque équipe : ne jamais permettre que le point de troncature se situe à l’intérieur d’une paire de surrogats.
Quatre produits, quatre années différentes, une cause racine commune. Les emojis ne sont que le symptôme le plus visible d’un problème plus large : le texte n’est pas scindé là où les indices de JavaScript le suggèrent.
Trois niveaux de « caractère »
Il est utile de considérer les stratégies de troncature comme fonctionnant à trois niveaux, chacun prenant en compte une unité différente.
Niveau 1 : unités de code avec .length et .slice()
C’est ce à quoi presque tout le monde a recours en premier, car il s’agit de l’API la plus évidente. C’est également le seul des trois qui peut séparer une paire de substituts. Si votre limite est de 20 et que la 20e unité de code se trouve au milieu d’un emoji, le résultat est un texte corrompu plutôt qu’une chaîne plus courte. Les bugs de Yjs, Gemini CLI et React Native se situent tous à ce niveau.
L’écart de compréhension est facile à expliquer : JavaScript indexe les unités de code UTF-16 ; les développeurs ont tendance à croire qu’ils indexent des caractères. Ces deux approches coïncident par hasard pour la plupart des textes en anglais, et cette coïncidence cache le problème.
Niveau 2 : points de code en étalant la chaîne
Une solution courante consiste à transformer la chaîne en un tableau, ce qui permet d’itérer par point de code plutôt que par unité de code.
const codePoints = [...str];
Cela maintient les paires de substituts ensemble, de sorte qu’un seul emoji de chien reste intact. C’est une véritable amélioration, et de nombreux développeurs s’arrêtent là en croyant que le problème est résolu.
C’est faux, car de nombreuses entités perçues comme un seul caractère sont en réalité composées de plusieurs points de code :
- Un emoji de famille est constitué de plusieurs emojis de personnes reliés par des caractères de liaison sans largeur.
- Le drapeau d’un pays est formé de deux symboles indicateurs régionaux, un pour chaque lettre du code pays.
- Une variante de teint de peau ajoute un modificateur Fitzpatrick à l’emoji de base.
- Une lettre accentuée peut être une lettre de base suivie d’un signe combinatoire.
Le troncature par point de code peut encore s’arrêter au milieu de l’une de ces séquences. Vous évitez ainsi des UTF-16 invalides, mais vous pouvez tout de même afficher à l’utilisateur la moitié d’une famille de caractères, une lettre indicatrice régionale isolée, ou une main sans sa teinte de peau prévue.
[...'👋🏽']
// more than one item
L’array contient plus d’un élément, pourtant l’utilisateur ne voit qu’une seule main agitant les doigts. Les points de code et les caractères perçus par l’utilisateur sont deux choses distinctes, et c’est précisément dans cette différence que se cachent les autres bugs.
Niveau 3 : groupes de graphèmes avec Intl.Segmenter
L’unité qui correspond à ce que voit l’utilisateur est le groupe de graphèmes, et la méthode intégrée pour trouver les limites des graphèmes est Intl.Segmenter avec granularity: 'grapheme'. L’outil ci-dessous segmente la chaîne, collecte le texte de chaque segment, puis tronque l’array résultant plutôt que la chaîne brute.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
function truncateGraphemes(str, max) {
const graphemes = Array.from(segmenter.segment(str), s => s.segment);
return graphemes.length > max
? graphemes.slice(0, max).join('') + '…'
: str;
}
truncateGraphemes('Loving my new puppy 👨👩👧👦🐶🐶🐶!', 22);
// → "Loving my new puppy 👨👩👧👦🐶…"
Observez ce qui se passe dans l’appel d’exemple. L’emoji de famille compte comme un seul graphème et reste intact, le premier chien entre dans la limite des 22 caractères, et la segmentation a lieu proprement avant le deuxième chien. Aucun segment n’est jamais divisé, car la fonction ne consulte jamais directement la chaîne de caractères.
La segmentation des graphèmes gère les emojis à plusieurs points de code, les combinaisons d’accentuations, ainsi que les écritures comme le thaï ou le devanagari, où un caractère visible correspond souvent à plusieurs points de code. Intl.Segmenter fait partie de la spécification d’internationalisation ECMA-402. Chrome et Safari l’ont intégré en 2020 et 2021 respectivement, Firefox l’a ajouté en avril 2024, et Node.js le comprend depuis sa version 16 ; par conséquent, dans les environnements actuels, il n’y a aucun motif de l’éviter en utilisant un polyfill.
Il est tentant de recourir à une bibliothèque spécifique aux emojis. Celle-ci ne traite que le symptôme visible. Intl.Segmenter cible le véritable problème, à savoir que les limites entre les caractères sont définies par des règles Unicode et non par des indices d’array.
Détails importants à respecter
Quelques améliorations méritent d’être prises en compte lors de l’adoption de l’approche basée sur les graphèmes :
- L’ellipses constitue un caractère supplémentaire. Si la limite est une contrainte stricte, comme celle d’une colonne de base de données ou d’un champ API, réservez un graphème à cet effet ou omettez-le.
- L’exemple segmente toute la chaîne avant de la tronquer. Pour des entrées très longues, vous pouvez itérer sur les segments et arrêter dès atteinte de la limite, ce qui évite de créer un grand array.
String.prototype.isWellFormed() et toWellFormed(), utiles comme vérification avant d’appeler encodeURIComponent ou d’envoyer des données à une API.Où la troncation tenant compte des graphèmes est importante
Toutes les chaînes n’ont pas besoin de cela. Pour les lignes de journal internes ou les identifiants uniquement en ASCII, la segmentation des graphèmes représente un travail inutile.
Regardez cependant où se sont produits les bugs mentionnés : tweets, documents partagés, champs de formulaire et sortie CLI. Chacun de ces endroits est un lieu où le texte généré par l’utilisateur subit une limite avant d’être affiché à nouveau aux gens. Ce sont là que l’ajout d’une ligne de code supplémentaire s’avère utile. Ce sont aussi les endroits où le bug ne se manifeste jamais lors des tests locaux, car les fichiers de test sont en ASCII, mais apparaît en production précisément sur les profils et messages que les gens consultent le plus.
Cas typiques à auditer :
- Champs de bio et de profil. Une limite de « 20 caractères » signifie 20 caractères visibles pour l’utilisateur, et non 20 unités de code UTF-16.
- Aperçus de commentaires. Un aperçu se terminant par une demi-émoticône donne l’impression d’être défectueux, même si le commentaire complet est correct.
La règle pratique est simple : partout où une chaîne peut contenir du texte Unicode visible par l’utilisateur, remplacez .slice(0, n) par une fonction de troncature basée sur les graphèmes, et préférez Intl.Segmenter à la méthode de propagation. La propagation protège les paires de substituts, tandis que la segmentation correspond à ce qu’un être humain perçoit comme un seul caractère.
Questions fréquentes
Intl.Segmenter fonctionne-t-il dans Node.js ? Oui. Il est disponible sans aucun paramètre depuis Node.js 16. Si vous utilisez encore une version plus ancienne, vérifiez-le avant de penser qu’un polyfill est nécessaire.
Est-il plus lent que slice() ? Un peu, car il effectue une véritable segmentation plutôt qu’un simple découpage par indice. Pour un champ de bio tronqué une fois par affichage, la différence n’est pas perceptible. Si vous tronquez des milliers de chaînes dans une boucle fréquente, effectuez des tests de performance avant et après.
Cela a-t-il de l’importance au-delà des emojis ? Oui. Les accents et de nombreux systèmes d’écriture d’Asie du Sud et du Sud-Est, y compris le thaï, le devanagari, le tamoul et le bengali, ne correspondent pas à un point de code unique pour un caractère visible. Intl.Segmenter gère correctement ces cas ; en revanche, .slice() ainsi que la propagation de chaînes peuvent encore se tromper.
Faut-il utiliser twitter-text à la place ? Uniquement si vous souhaitez suivre la politique de comptage spécifique de Twitter, selon laquelle chaque emoji coûte 2 et chaque URL coûte 23. Il s’agit d’une règle commerciale, et non d’une correction de précision. Pour compter ce que voit l’utilisateur, Intl.Segmenter est l’outil standard intégré.
En résumé
Pendant longtemps, l’approche basée sur l’unité de code .slice() a été la solution par défaut dans les tutoriels sur « comment tronquer une chaîne de caractères », ce qui a entraîné une sous-estimation du nombre maximal de caractères sur Twitter, des échecs de synchronisation silencieux dans des éditeurs collaboratifs, ainsi que des problèmes persistants dans React Native. La solution n’est pas compliquée : déterminez réellement quelle unité concerne votre limite, et lorsque c’est ce que voient les utilisateurs, segmentez la chaîne par groupes de graphèmes avant de procéder au troncement. Si votre base de code dispose d’une fonction d’aide truncate, il suffit de quelques minutes pour identifier auquel des trois niveaux elle opère, et d’une chaîne de test de quatre lignes pour en faire la preuve.