Nettoyer un texte venu d'ailleurs
Un texte venu d'un PDF, d'une cellule de tableur, d'un champ de CMS, d'un client de messagerie ou d'une application de discussion transporte les décisions de mise en forme de ce qui l'a produit, et presque aucune de ces décisions n'est visible à l'écran. Le résultat est une chaîne qui a l'air correcte, s'imprime correctement, puis échoue à une comparaison, à une recherche, à une analyse JSON ou à une consultation en base de données sans raison apparente.
Les caractères que vous ne pouvez pas voir
Pour Unicode, ce sont de vrais caractères. Ils n'ont simplement aucune forme visible, ou bien la même forme qu'autre chose.
| Caractère | Point de code | Provenance habituelle | Ce qu'il casse |
|---|---|---|---|
| Espace insécable | U+00A0 | en HTML, Word, mise en page PDF | le découpage sur un espace, les correspondances exactes |
| Espace fine insécable | U+202F | typographie française, dates issues de Word | la même chose, de façon moins évidente |
| Espace idéographique | U+3000 | méthodes de saisie CJC | ressemble à un large espacement |
| Espace sans chasse | U+200B | indications de coupure de ligne d'un CMS, texte web copié | invisible, et ce n'est pas un blanc |
| Antiliant et liant sans chasse | U+200C, U+200D | persan, écritures indiennes, séquences d'émojis | les limites de mots, les comptes de caractères |
| Liant de mots | U+2060 | outils de composition | rien de visible, tout ce qui est textuel |
| Trait d'union conditionnel | U+00AD | césure de Word, exports PDF | un mot cesse de correspondre à lui-même |
| Marque d'ordre des octets | U+FEFF | les premiers octets d'un fichier UTF-8 | le premier champ de la première ligne |
| Marques de sens gauche-droite et droite-gauche | U+200E, U+200F | contenu bidirectionnel | des marques parasites autour des nombres |
| Séparateurs de ligne et de paragraphe | U+2028, U+2029 | certaines applications Mac et de mise en page | le découpage en lignes, les anciens analyseurs JavaScript |
Si une recherche échoue, c'est qu'elle compare des points de code, pas des formes. Si un PDF vous a donné New U+00A0 York et que vous tapez New York avec un espace ordinaire U+0020, les deux chaînes sont différentes et rien ne vous dira pourquoi.
"New York".includes("New York") false, the gap is U+00A0
" text".trim().length 5, the zero-width space survives
Une espace insécable compte comme un blanc pour la plupart des fonctions de rognage et pour \s dans la plupart des moteurs d'expressions régulières : elle disparaît donc aux extrémités d'une chaîne et survit au milieu, exactement là où un découpage attend une espace ordinaire. Une espace sans chasse n'est un blanc nulle part : le rognage et la réduction des suites la laissent donc intacte. C'est aussi pourquoi l'Extracteur d'e-mails peut ne rien renvoyer d'un texte copié depuis un client de messagerie : un seul caractère sans chasse à l'intérieur de l'adresse empêche le motif de correspondre.
Le Détecteur de caractères cachés montre ce qui s'y trouve réellement, et l'Échappement Unicode donne les points de code exacts d'une valeur isolée. Ne supprimez pas tout à vue, cependant : un liant sans chasse à l'intérieur d'une séquence d'émojis et un antiliant sans chasse en persan ou en devanagari sont du contenu, pas du bruit.
La ponctuation qu'un traitement de texte a changée pour vous
La correction automatique substitue des caractères typographiques au fil de la frappe, et la substitution suit le texte hors du document.
| Ce que vous avez tapé | Ce que vous avez maintenant | Point de code |
|---|---|---|
' | apostrophe typographique | U+2019 |
" et " | guillemets anglais ouvrant et fermant | U+201C, U+201D |
- entre deux mots | tiret demi-cadratin ou cadratin | U+2013, U+2014 |
... | points de suspension | U+2026 |
- devant un nombre | signe moins ou trait d'union insécable | U+2212, U+2011 |
C'est très bien dans de la prose et fatal dans tout ce qu'une machine analyse.
{ “name”: “Ada” } unexpected token, only U+0022 is a JSON string quote
git commit -m “fix” the shell sees three words, not one quoted argument
WHERE name = ‘Ada’ SQL syntax error near ‘
10\u201320 not a number range, U+2013 is not a hyphen
En CSV, la défaillance est plus discrète. Un analyseur ne reconnaît que le guillemet droit comme délimiteur de champ : une valeur qu'un traitement de texte a entourée de guillemets courbes passe donc pour non entourée, la moindre virgule qu'elle contient scinde alors la ligne et toutes les colonnes suivantes se décalent. Une colonne dont les valeurs négatives utilisent U+2212 s'importe comme du texte, et les sommes l'excluent en silence.
Rechercher et remplacer avec une courte liste de substitutions règle le problème, mais ne l'appliquez qu'à un texte destiné à du code, à du CSV ou à une clé. Le passer sur de la prose que vous publiez aplatit une ponctuation qui était voulue.
Fins de ligne et blancs de fin
Les outils Windows terminent une ligne par CR LF (U+000D U+000A), les outils Unix par LF seul, et quelques très vieux exports Mac emploient encore CR seul. La plupart des analyseurs s'en accommodent. Un découpage naïf, non.
"UK\r\n" split on "\n" -> "UK\r"
"UK\r" === "UK" false
"UK\r".length 3
Deux chaînes qui s'affichent de façon identique dans un tableau, un journal ou une vue de différences peuvent tout de même différer par un retour chariot, une espace finale ou une tabulation. Quand la Comparaison de textes signale une ligne comme modifiée sans qu'aucune modification soit visible, la réponse est là. C'est aussi ce qui glisse une espace parasite entre les guillemets quand une colonne collée passe par Mettre les lignes entre guillemets ou par Joindre les lignes.
Normalisez en une seule passe, en traitant ensemble CR LF et un CR isolé (\r\n? remplacé par \n), faute de quoi un remplacement en deux étapes double les lignes vides.
Une lettre, deux façons de l'écrire
Unicode permet à une lettre accentuée d'être un point de code unique ou une lettre de base suivie d'une marque combinante. Les deux sont corrects, et ils ne sont pas égaux.
| Forme | é est stocké comme | Unités de code en JavaScript |
|---|---|---|
| NFC (composée) | U+00E9 | 1 |
| NFD (décomposée) | U+0065 U+0301 | 2 |
macOS a historiquement livré des noms de fichiers décomposés, et plusieurs générateurs de PDF et méthodes de saisie produisent du texte décomposé : une valeur issue de l'une de ces sources ne correspondra donc pas à la même valeur tapée au clavier.
"é" === "é" false
"é".normalize("NFC") === "é" true
Les répercussions dépassent la simple égalité. Un tri qui compare des points de code place les formes décomposées à côté du e nu et les formes composées bien plus loin : une liste de noms revient donc en deux paquets. Les comptes de caractères diffèrent d'une forme à l'autre, ce qui compte pour une limite de 280 caractères ou une colonne VARCHAR(50), et les Statistiques de texte annoncent des chiffres différents pour ce qui semble être le même texte. La troncature est pire : couper une chaîne décomposée à une longueur fixe peut trancher entre une lettre de base et son accent, et laisser cet accent se rattacher à ce qui suit ; Tronquer le texte sur une entrée non normalisée peut donc produire un dernier caractère visiblement faux.
Les formes de compatibilité, NFKC et NFKD, vont plus loin et rabattent des caractères qui se contentent d'en ressembler à d'autres : la ligature U+FB01 devient fi, le A pleine chasse U+FF21 devient A, le signe micro U+00B5 devient le mu grec U+03BC, et l'exposant ² devient un simple 2. C'est excellent pour une clé de recherche ou de déduplication et destructeur pour tout ce que vous affichez, car x² devient discrètement x2.
La règle de travail : NFC pour le stockage et l'affichage, NFKC uniquement pour des clés que personne ne voit jamais.
La conversion de casse n'est pas une opération unique
Passer en majuscules n'est pas une correspondance caractère par caractère, et cela ne se fait pas indépendamment de la locale.
- Le
ßallemand U+00DF passe en majuscules sous la formeSS: la chaîne s'allonge donc, et un aller-retour vers les minuscules ne redonne pas l'original. - Le sigma grec passe en minuscules sous la forme
ςen fin de mot etσailleurs, ce qui casse là encore l'aller-retour. - Le turc et l'azéri possèdent un
ısans point U+0131 et une capitale pointéeİU+0130. Dans ces locales,Ipasse en minuscules sous la formeıetien majuscules sous la formeİ.
Ce dernier cas est le bogue de production classique. Un code qui met en minuscules un nom d'en-tête, une extension de fichier ou une chaîne de protocole fonctionne partout jusqu'à ce qu'il tourne sur une machine dont la locale par défaut est le turc : "FILE" y devient fıle et cesse de correspondre à file. En Java et en .NET, les méthodes sans argument utilisent la locale par défaut de la machine : toUpperCase() et ToUpper() sont donc le piège ; nommez une locale invariante pour tout ce qui est lisible par une machine. Pour comparer, le repliement de casse (casefold() en Python) vaut mieux qu'un passage en minuscules, car il traite ß comme ss.
La casse de titre n'a pas de définition unique, ce qui explique pourquoi « une majuscule à chaque mot » donne « The Lord Of The Rings » et « IPhone ». La plupart des guides de style mettent une majuscule au premier et au dernier mot ainsi qu'à tout le reste sauf les articles, les conjonctions de coordination et les prépositions courtes, gardent la majuscule aux deux moitiés d'un composé à trait d'union, et ne touchent jamais aux sigles ni aux noms comportant des majuscules internes comme McDonald, O'Brien ou iPhone.
Les casses de programmation ont leur propre problème de frontières. Convertir HTTPResponseCode en snake case dépend entièrement de la façon dont le découpeur traite une suite de majuscules ; le Convertisseur de casse donne http_response_code, un découpeur naïf donne h_t_t_p_response_code.
Un ordre qui fonctionne
Chaque étape suppose que la précédente a été exécutée. Dans le désordre, elles se contredisent.
- Fixez l'encodage. Un mojibake tel que
cafésignifie que les octets ont été décodés avec le mauvais encodage : redécodez donc les octets d'origine plutôt que de rafistoler les symptômes. Ne retirez un BOM qu'au tout début du texte. - Normalisez les fins de ligne en LF, en une seule passe.
- Retirez les caractères de format que vous avez jugés parasites : traits d'union conditionnels, espaces sans chasse, liant de mots, marques bidi. Faites-le avant la normalisation, car NFC ne peut pas composer une lettre de base et son accent par-dessus un caractère invisible placé entre les deux.
- Remplacez la ponctuation sosie, si la destination est du code, du CSV, du JSON ou une clé.
- Appliquez la normalisation Unicode, NFC par défaut.
- Occupez-vous ensuite des blancs. Convertissez en U+0020 les espaces exotiques restantes, réduisez les suites, puis rognez. Le faire avant l'étape 3 laisse des espaces doubles partout où une espace insécable a rencontré une espace ordinaire, et rogner avant l'étape 2 laisse un retour chariot collé à la dernière valeur de chaque ligne.
- Convertissez la casse en dernier, en nommant explicitement la locale.
Les opérations au niveau du mot viennent elles aussi après l'étape 3. Découper un texte avec Découper le texte alors que des traits d'union conditionnels subsistent donne des comptes gonflés et des demi-mots, et il en va de même pour tout ce qui parcourt un texte à la recherche de termes, y compris Censurer du texte.
Quand une valeur refuse encore de correspondre après tout cela, cessez de la regarder. Affichez sa longueur, échappez-la en points de code, et comparez directement les deux formes échappées ; la différence y saute aux yeux comme jamais à l'écran.