Nettoyer le texte que les utilisateurs collent dans votre application
Nettoyez le texte collé dans les formulaires web avec normalisation Unicode, suppression des caractères de format, espaces réduits et ZWJ/NBSP préservés.
Le texte collé dans un formulaire web contient régulièrement des caractères invisibles que personne n’a saisis et qu’aucune police ne dessine : espaces sans largeur, traits d’union conditionnels, marques d’ordre des octets (BOM) et espaces insécables, qui tous survivent jusque dans votre base de données et cassent silencieusement les comparaisons strictes, la validation de longueur et la recherche.
Si vous avez déjà traqué ce bug, vous connaissez le scénario. Quelqu’un colle un paragraphe de son CV depuis un traitement de texte dans un champ de candidature, le champ paraît parfaitement normal à l’écran, et votre validateur le rejette. Ou bien l’enregistrement se fait sans problème, mais personne ne retrouve jamais la fiche, parce que le nom présent dans l’index contient un caractère que le champ de recherche n’a aucun moyen de saisir.
Cet article montre ce qui arrive réellement dans le champ, explique pourquoi corriger le problème un point de code à la fois ne converge jamais, et vous fournit une fonction de nettoyage en quatre étapes qui traite toute la catégorie, y compris les caractères invisibles qu’il ne faut surtout pas supprimer.
Points clés à retenir
- Les caractères invisibles qui accompagnent le texte collé appartiennent à la catégorie générale Unicode Format, notée
\p{Cf}dans une expression régulière JavaScript : une seule correspondance de catégorie remplace donc une liste toujours plus longue de points de code individuels. normalize("NFC")corrige l’orthographe, pas l’invisibilité : elle rend équivalents deux encodages d’une même lettre accentuée et laisse une espace sans largeur exactement là où elle était.- L’espace insécable U+00A0 est un caractère d’espacement, pas un caractère de format : elle survit donc intacte à un nettoyage
\p{Cf}et doit être prise en charge par l’étape de réduction des espaces. - U+200D ZERO WIDTH JOINER joue un rôle réel : il assemble les composants d’un emoji multi-personnages en un seul glyphe, et les liants portent une valeur orthographique dans les écritures arabe et indiennes.
\p{...}ne désigne une propriété Unicode que si l’expression régulière porte le drapeauuouv; sans l’un des deux, il s’agit d’un échappement d’identité correspondant à la lettre p littérale.
Quels caractères invisibles arrivent réellement dans le champ ?
Une chaîne collée depuis un traitement de texte ou un éditeur de texte enrichi mélange généralement des substitutions typographiques que vous voyez et des caractères de format que vous ne voyez pas. Les guillemets courbes, les tirets demi-cadratin et cadratin, ainsi que le caractère de points de suspension, sont visibles et le plus souvent sans conséquence. L’espace insécable, le trait d’union conditionnel, l’espace sans largeur et la marque d’ordre des octets ne sont pas visibles du tout, et ce sont eux qui font échouer les tests d’égalité.
Affichez la chaîne sous forme de points de code et la démonstration se fait d’elle-même :
function inspect(str) {
return [...str]
.map((ch) => ch.codePointAt(0))
.filter((cp) => cp > 0x7f)
.map((cp) => "U+" + cp.toString(16).toUpperCase().padStart(4, "0"));
}
const pasted = "\uFEFFSenior\u00A0Engineer\u200B, 2019\u20132024";
console.log(inspect(pasted)); // [ 'U+FEFF', 'U+00A0', 'U+200B', 'U+2013' ]
console.log(pasted.length); // 28, pour 26 caractères qu'un humain compterait
Utilisez l’opérateur de décomposition (spread) sur la chaîne plutôt que split("") : l’itérateur de chaîne renvoie des points de code complets, alors que split("") coupe en deux les caractères des plans astraux à la frontière UTF-16.
Pour voir ce que contient réellement une chaîne que vous avez sous la main, collez-la dans le nettoyeur de caractères invisibles et relisez les points de code.
Cette catégorie de défaillance se définit par son invisibilité. Le champ s’affiche correctement, donc une capture d’écran du bug ne montre rien d’anormal et la personne qui le signale est incapable de décrire ce qu’elle a fait différemment. Le session replay est l’une des rares techniques qui le mette en évidence, car les rejeux de formulaires abandonnés montrent le collage puis la boucle de nouvelles tentatives : quelqu’un vide un champ et ressaisit une valeur qui semblait identique à celle qui venait d’être rejetée.
Pourquoi une liste de blocage des caractères sans largeur ne cesse-t-elle de s’allonger ?
Une classe de caractères constituée de points de code spécifiques est une correction de mauvaise forme, et non une correction mal écrite, car elle ne peut contenir que les caractères pour lesquels quelqu’un a déjà ouvert un ticket. Vous supprimez U+200B après le premier signalement, vous ajoutez U+FEFF quand un import CSV casse, vous ajoutez U+00AD quand un nom de famille à trait d’union cesse de correspondre, et la classe continue de grossir parce qu’elle énumère les membres d’un ensemble au lieu de nommer l’ensemble.
Cet ensemble a un nom. L’espace sans largeur (U+200B), l’antiliant sans largeur (U+200C), le liant sans largeur (U+200D), le trait d’union conditionnel (U+00AD) et la marque d’ordre des octets (U+FEFF) portent tous General_Category=Cf, d’après la base de données de caractères Unicode, et ces assignations sont restées stables au fil de nombreuses versions de la norme. L’espace insécable U+00A0 ne fait pas partie de cet ensemble : elle relève de Zs, un séparateur d’espace, et c’est la raison pour laquelle un simple nettoyage par catégorie laisse en place l’artefact de traitement de texte le plus courant.
Normaliser, supprimer, réduire, élaguer
La correction consiste en une seule passe avec quatre étapes ordonnées : normaliser l’encodage, supprimer les caractères de format, réduire toutes les variantes d’espace à une espace ordinaire, puis élaguer les extrémités.
const FORMAT_CHARS = /[\p{Cf}--[\u200C\u200D]]/gv;
const SPACE_RUN = /[\p{Zs}\t\n\r]+/gu;
export function cleanPastedText(input) {
return input
.normalize("NFC") // une seule orthographe canonique par caractère
.replace(FORMAT_CHARS, "") // BOM, espace sans largeur, trait d'union conditionnel, contrôles bidi
.replace(SPACE_RUN, " ") // NBSP, espaces fines, tabulations, sauts de ligne -> une espace
.trim();
}
Retirez \n\r de SPACE_RUN si le champ est un textarea où les sauts de ligne font partie du contenu.
Deux éléments méritent ici leur place. \p{...} ne prend son sens Unicode que si l’expression régulière est en mode compatible Unicode ; retirez à la fois u et v et le moteur interprète \p comme un p littéral échappé : le motif se compile, s’exécute et ne correspond à rien de ce que vous visiez. Et l’étape de réduction utilise une classe explicite bâtie sur \p{Zs} plutôt que \s, afin qu’il soit clair au point d’appel que U+00A0 est bien couvert.
L’ordre compte. normalize("NFC") résout l’encodage, pas l’invisibilité : elle laisse donc l’espace sans largeur en place pour que l’étape de suppression s’en charge. Supprimer avant de réduire garantit que la marque d’ordre des octets est effacée plutôt que convertie en une espace parasite. Et élaguer en dernier permet de rattraper l’espace de début laissée lorsqu’un caractère de format supprimé se trouvait juste à côté d’une espace.
Ce qu’il ne faut pas supprimer
Une suppression aveugle de tous les caractères de format endommage du contenu réel. U+200D ZERO WIDTH JOINER est le caractère qui assemble les emoji en un seul glyphe : dans la norme Unicode sur les emoji, ce qui fait d’une chaîne une séquence ZWJ d’emoji, c’est précisément la présence d’un liant ; retirer les liants laisse donc plusieurs glyphes là où il n’y en avait qu’un.
const family = "👨👩👧";
console.log([...family].length); // 5
console.log([...family.replace(/\p{Cf}/gu, "")].length); // 3 -> 👨👩👧
Les liants ne sont pas non plus purement décoratifs en dehors des emoji. En arabe, on place un antiliant entre deux lettres pour les empêcher de se souder comme le ferait normalement l’écriture cursive, et la spécification centrale Unicode avertit qu’un texte privé de ces contrôles dit autre chose ou perd tout sens. En devanagari, un ZWJ après un virama sélectionne la demi-forme d’une consonne au lieu de la ligature conjointe complète. La règle est de supprimer les caractères invisibles qui ne portent aucun sens dans votre champ et de conserver ceux qui accomplissent un travail structurel.
C’est exactement ce qu’exprime [\p{Cf}--[\u200C\u200D]] : le drapeau v ajoute des opérateurs d’ensembles aux classes de caractères, et -- est celui qui soustrait. Sur un runtime dépourvu de unicodeSets, l’équivalent en mode u est /(?![\u200C\u200D])\p{Cf}/gu. N’activez pas les deux drapeaux sur une même expression régulière ; ils sont mutuellement exclusifs.
Pourquoi utiliser NFC et non NFKC ?
NFC résout les deux façons dont Unicode peut orthographier un même caractère et ne change rien d’autre. NFKC va plus loin et réécrit les caractères de compatibilité : la ligature ff devient deux f et un Ⓓ encerclé devient un simple D, comme le montrent les exemples de normalize(). Il s’agit d’une décision qui modifie le contenu, définie par l’annexe Unicode sur la normalisation comme relevant de l’équivalence de compatibilité et non de l’équivalence canonique : elle mérite d’être prise délibérément plutôt que subie comme effet de bord du nettoyage d’un champ de formulaire.
Un piège connexe : supprimer un trait d’union conditionnel relève de votre fonction de nettoyage, pas de normalize("NFKC"). L’opération Unicode qui transforme U+00AD en chaîne vide est NFKC_Casefold, une transformation distincte de celle qu’effectue String.prototype.normalize("NFKC").
Exécutez aussi le nettoyeur côté serveur
Nettoyer dans le navigateur est une politesse envers la personne qui saisit ; la normalisation dont dépendent votre base de données et votre index de recherche doit s’exécuter sur le serveur, car une requête peut être envoyée sans que votre page ait jamais été chargée. Tout ce qui arrive via une API publique, un import CSV, un webhook ou un client mobile contourne entièrement le gestionnaire de saisie, et une seule ligne non nettoyée suffit à rendre incohérent le comportement d’une contrainte d’unicité ou d’une recherche par correspondance exacte. La même fonction fonctionne dans Node et dans le navigateur : exécutez-la à la frontière où les données entrent en persistance, et faites de l’appel côté client un retour rapide plutôt qu’une garantie.
Cessez d’ajouter des points de code à une classe de caractères et commencez à nommer la catégorie. Quatre lignes, appliquées à chaque point d’entrée, suppriment les caractères invisibles qui ne portent aucun sens dans votre champ tout en conservant ceux qui assurent la cohésion du texte réel. Écrivez une fixture mêlant un accent combinant, une espace insécable, un trait d’union conditionnel, une marque d’ordre des octets, une espace sans largeur et un emoji ZWJ, puis vérifiez que votre nettoyeur compose le premier, réduit la deuxième à une espace ordinaire, supprime les trois suivants et renvoie l’emoji inchangé.
FAQ
trim supprime-t-il une espace insécable ou une espace sans largeur ?
trim supprime les espaces et les terminateurs de ligne aux deux extrémités uniquement, et la liste des espaces ECMAScript inclut l'espace insécable U+00A0 et la marque d'ordre des octets U+FEFF : les deux disparaissent donc aux extrémités. Il ne supprime jamais l'espace sans largeur U+200B, qui est un caractère de format et non un espace, et il ne touche jamais à aucun de ces caractères au milieu d'une chaîne.
La suppression des caractères de format retire-t-elle aussi les sélecteurs de variante d'emoji comme U+FE0F ?
Non. Les sélecteurs de variante U+FE00 à U+FE0F portent la General_Category Mn, marque sans espacement, et non Cf : une suppression par catégorie de format les laisse donc en place et les emoji conservent la présentation prévue. Seuls les liants nécessitent une liste de conservation. Élargir un nettoyeur pour supprimer également les marques effacerait les sélecteurs de variante ainsi que tous les accents combinants.
La suppression des caractères de format retire-t-elle aussi les caractères de forçage droite-à-gauche ?
Oui. Les contrôles bidirectionnels, y compris U+200E, U+200F, les incorporations et forçages de U+202A à U+202E, ainsi que les isolats de U+2066 à U+2069, portent tous la General_Category Cf : une seule correspondance de catégorie les supprime donc en même temps que l'espace sans largeur. C'est important pour les noms d'affichage et les noms de fichiers, où un forçage droite-à-gauche inverse le texte affiché et peut déguiser une extension.
Pourquoi une valeur collée échoue-t-elle à la validation maxlength alors qu'elle semble assez courte ?
maxlength compte les unités de code UTF-16, pas les caractères visibles : chaque passager invisible consomme donc du budget, une marque d'ordre des octets ou une espace sans largeur coûtant une unité, et un emoji construit à partir d'une paire de substitution en coûtant deux. Nettoyez la valeur avant de valider sa longueur, et comptez avec la forme décomposée (spread) si la limite est censée correspondre à ce que les utilisateurs voient.
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k