Nettoyer un texte venu d'ailleurs

Les caractères invisibles, la ponctuation substituée, les fins de ligne, les formes de normalisation et les règles de casse qui font dérailler un texte collé, et l'ordre dans lequel les corriger.

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èrePoint de codeProvenance habituelleCe qu'il casse
Espace insécableU+00A0  en HTML, Word, mise en page PDFle découpage sur un espace, les correspondances exactes
Espace fine insécableU+202Ftypographie française, dates issues de Wordla même chose, de façon moins évidente
Espace idéographiqueU+3000méthodes de saisie CJCressemble à un large espacement
Espace sans chasseU+200Bindications de coupure de ligne d'un CMS, texte web copiéinvisible, et ce n'est pas un blanc
Antiliant et liant sans chasseU+200C, U+200Dpersan, écritures indiennes, séquences d'émojisles limites de mots, les comptes de caractères
Liant de motsU+2060outils de compositionrien de visible, tout ce qui est textuel
Trait d'union conditionnelU+00ADcésure de Word, exports PDFun mot cesse de correspondre à lui-même
Marque d'ordre des octetsU+FEFFles premiers octets d'un fichier UTF-8le premier champ de la première ligne
Marques de sens gauche-droite et droite-gaucheU+200E, U+200Fcontenu bidirectionneldes marques parasites autour des nombres
Séparateurs de ligne et de paragrapheU+2028, U+2029certaines applications Mac et de mise en pagele 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 maintenantPoint de code
'apostrophe typographiqueU+2019
" et "guillemets anglais ouvrant et fermantU+201C, U+201D
- entre deux motstiret demi-cadratin ou cadratinU+2013, U+2014
...points de suspensionU+2026
- devant un nombresigne moins ou trait d'union insécableU+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é commeUnités de code en JavaScript
NFC (composée)U+00E91
NFD (décomposée)U+0065 U+03012

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 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 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 forme SS : 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, I passe en minuscules sous la forme ı et i en 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.

  1. 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.
  2. Normalisez les fins de ligne en LF, en une seule passe.
  3. 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.
  4. Remplacez la ponctuation sosie, si la destination est du code, du CSV, du JSON ou une clé.
  5. Appliquez la normalisation Unicode, NFC par défaut.
  6. 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.
  7. 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.