Retirer les accents casse la recherche — tant que tu ne le fais pas des deux côtés
Publié le 09/07/2026 · 16 min de lecture · Outils texte & langage
Daniel Okonkwo — Développeur front-end et rédacteur Tech chez OneKitly
Performance web · Formats de fichiers
Vérifié à partir de 5 sources
Retirer les diacritiques est une étape de normalisation, pas une fonctionnalité de recherche : cela n'aide que si la fonction identique s'applique à l'index et à la requête. Fais l'expérience — six requêtes contre six noms. Sans normalisation, « cafe » trouve Cafe Central et « café » trouve Café Amélie : deux résultats, chacun à moitié juste. Replie la requête seule et « café » trouve désormais Cafe Central et rate Café Amélie, la correspondance exacte qu'elle obtenait avant, tandis que « müller » passe d'un résultat à zéro. Replie l'index seul et « café » ne renvoie plus rien. Replie les deux côtés et toutes les graphies renvoient les deux cafés. Une normalisation unilatérale n'assouplit pas la correspondance ; elle déplace le décalage ailleurs. Le repli lui-même, c'est NFD suivi de la suppression des marques combinantes : é est le point de code unique U+00E9, devient U+0065 U+0301 après décomposition, et supprimer U+0301 laisse e. Cela marche pour é, ö, å et ñ, et ne fait rien du tout pour ø, ł, ß, œ et ı, qui sont des points de code uniques sans marque combinante et traversent NFD intacts. Ceux-là exigent une table par langue — ö allemand vers oe, ß vers ss — ou un Intl.Collator en sensibilité base, qui rapproche Ørsted et Orsted sans rien supprimer.
Replier les diacritiques est une étape de normalisation, et une normalisation ne fonctionne que si la même fonction s'applique à l'index et à la requête. NFC contre NFD avec les points de code affichés, et les lettres — ø, ł, ß, œ, ı — qui sortent du repli intactes.
La normalisation est une fonction, et elle doit s'appliquer des deux côtés
Voici tout l'argument en une expérience. Prends un index de six noms — Café Amélie, Cafe Central, Zoë Müller, Zoe Miller, Łukasz Nowak, Ørsted Energi — et six requêtes : cafe, café, muller, müller, lukasz, orsted. Sans aucune normalisation, cafe trouve une entrée et café en trouve une autre, muller ne trouve rien et müller trouve Zoë Müller. Six requêtes, deux résultats. C'est le problème que l'on cherche à corriger.
Applique maintenant le repli à la requête seule, c'est le changement que l'on fait en premier parce que la requête est ce que l'on maîtrise. Le résultat est pire, pas meilleur. La requête café se replie en cafe et correspond à Cafe Central, tandis que Café Amélie — l'entrée qu'elle trouvait parfaitement l'instant d'avant — ne correspond plus du tout, puisque l'index conserve la forme accentuée. La requête müller perd son unique résultat pour la même raison. Deux résultats restent deux résultats, mais ce ne sont pas les mêmes, et l'une des pertes était une correspondance exacte.
Replie l'index seul et l'image miroir se produit : cafe trouve désormais les deux cafés, mais café ne trouve rien, car la requête accentuée ne peut plus correspondre à l'index replié. Replie les deux côtés et le tableau se tient enfin : les quatre graphies de la requête café renvoient les deux entrées, et les deux graphies de Müller renvoient l'entrée unique. La règle établie n'est pas « retire les accents ». C'est : quelle que soit la fonction appliquée, applique la même, à l'indexation et à la requête, depuis le même chemin de code. Une normalisation appliquée d'un seul côté n'est pas une version atténuée de la bonne chose — c'est une règle de correspondance différente, et généralement pire.
NFC, NFD, et ce qu'est réellement le repli
Unicode autorise deux écritures pour une lettre accentuée. En forme composée, NFC, la lettre é est un seul point de code, U+00E9. En forme décomposée, NFD, elle en fait deux : U+0065, le e simple, suivi de U+0301, l'accent aigu combinant. Les deux s'affichent à l'identique. Les deux sont corrects. Ce ne sont pas la même chaîne : en JavaScript, le é composé a une longueur de 1 et le é décomposé une longueur de 2, et l'égalité stricte entre les deux est fausse. Un mot comme café pris dans une source NFC et cherché dans un document NFD n'apparaît tout simplement pas, et le développeur voit une recherche qui échoue sur un texte qu'il lit pourtant à l'écran.
Le repli exploite cela. On normalise en NFD, ce qui sépare toute lettre canoniquement décomposable en une lettre de base et ses marques, puis on supprime tout ce qui relève de la catégorie générale Unicode M — les marques combinantes. é devient U+0065 U+0301 devient e. ñ devient U+006E U+0303 devient n. å devient U+0061 U+030A devient a. C'est tout le mécanisme, et c'est pourquoi l'opération s'appelle proprement un repli plutôt qu'une suppression : rien n'est retiré de l'alphabet, c'est une distinction que l'on jette.
Une conséquence pratique : avant de replier quoi que ce soit, normalise tout dans une seule forme. Si ton index est en NFC et le texte entrant en NFD, replier les deux vers les mêmes lettres de base masque la différence par accident — mais toute comparaison faite avant le repli, ou sur un champ que tu as choisi de ne pas replier, restera fausse. Normalise en NFC à l'entrée, comme règle de stockage, et traite le repli comme un index distinct construit par-dessus.
Les lettres qui ne portent aucune marque
Le mécanisme a un mode d'échec évident : si une lettre n'a pas de décomposition canonique, NFD la laisse telle quelle et il n'y a aucune marque combinante à supprimer, donc le repli ne fait rien. Exécute-le et tu vois exactement de quelles lettres il s'agit. ß reste ß. ø reste ø. ł reste ł. œ reste œ. æ reste æ. ı reste ı. đ reste đ. Dans chacun de ces cas, le diacritique n'est pas une marque appliquée à une lettre de base ; la barre du l, la barre oblique du o, la ligature entre le o et le e font partie du dessin de la lettre, et Unicode encode chacune comme un caractère à part entière.
C'est pourquoi l'expérience aux six noms ne renvoie toujours rien pour lukasz et orsted, même après repli des deux côtés. Łukasz Nowak se replie en Łukasz Nowak, inchangé ; Ørsted Energi se replie en Ørsted Energi, inchangé. Les deux entrées qu'un utilisateur a le moins de chances de taper correctement sont précisément celles que le repli n'aide pas. Un mot comme Łódź est le cas le plus net : replie-le et tu obtiens Łodz, parce que ó et ź se décomposent et ł non ; le résultat n'est donc ni l'original ni la graphie ASCII que quiconque chercherait.
Le correctif est une petite table explicite appliquée avant le passage NFD : ł vers l, ø vers oe ou o, œ vers oe, æ vers ae, đ vers d, ß vers ss. La décomposition de compatibilité, NFKD, traite les ligatures œ et æ mais pas les lettres barrées, ce n'est donc pas non plus une réponse générale. Impossible d'échapper à la question de savoir quelle langue on indexe, et c'est précisément l'objet des trois sections suivantes.
Allemand : oe, pas o — et ss, pas s
Les trémas allemands se décomposent, donc le repli naïf produit quelque chose. Il produit la mauvaise chose. Größe se replie en Große — le tréma disparaît, le s dur reste, et le résultat est un vrai mot allemand qui signifie autre chose. Müller se replie en Muller, Öl en Ol. La convention que les lecteurs allemands attendent réellement, codifiée dans la DIN 5007 variante 2 et utilisée dans les annuaires téléphoniques, est le développement en deux lettres : ä vers ae, ö vers oe, ü vers ue, ß vers ss. Größe devient Groesse, Müller devient Mueller, Straße devient Strasse.
Le s dur mérite son paragraphe, car il se comporte comme aucune autre lettre de cette liste. Il n'a pas de décomposition canonique, donc NFD le laisse ; NFKC le laisse aussi, si bien que Straße reste Straße. Mais sa mise en majuscules en JavaScript donne la chaîne de deux caractères SS, ce qui signifie qu'un pipeline naïf « majuscules puis comparaison » le replie déjà correctement, alors qu'un repli d'accents naïf non. La capitale U+1E9E existe et redescend en ß. Si ton index allemand est construit par mise en majuscules et ta requête par retrait des accents, ß correspondra dans un sens et pas dans l'autre — le problème unilatéral, encore, sous un autre déguisement.
Il existe un moyen d'être correct sans écrire la moindre table. Un Intl.Collator allemand en sensibilité base traite déjà Grosse et Größe comme égaux — c'est la collation allemande standard, où ß présente une différence secondaire avec ss. Demande la variante annuaire, la locale de-u-co-phonebk, et Groesse et Größe se comparent également égaux, parce que cette collation est justement celle qui traite ö comme oe. Le CLDR maintient ces tables ; tu n'as pas à le faire.
Scandinave et turc : des lettres, pas des décorations
En danois, en norvégien et en suédois, æ, ø et å sont des lettres de l'alphabet, et elles viennent après le z. Un collateur le prouve à l'écran : trie A, Aa, Å, Ø et Z avec un collateur danois et tu obtiens A, Z, Ø, Å, Aa ; trie les mêmes cinq avec un collateur anglais et tu obtiens A, Å, Aa, Ø, Z. En danois, å n'a pas été discrètement rangé à côté du a — il a sa propre place à la fin, et le digramme Aa se classe avec lui. Replier å en a ne retire donc pas un accent, cela fusionne deux lettres différentes, et replier ø en o en fusionne deux autres.
Le turc offre le cas le plus net, et il porte sur la casse plutôt que sur les accents. Le turc distingue un ı sans point, U+0131, d'un i pointé, et symétriquement une capitale pointée İ, U+0130, du I ordinaire. La minuscule de I est ı et la minuscule de İ est i — mais uniquement dans la locale turque. Exécute-le en JavaScript et le piège apparaît : la chaîne İSTANBUL mise en minuscules avec les règles par défaut fait neuf caractères, parce que İ se convertit en i suivi du point suscrit combinant U+0307. Mise en minuscules avec toLocaleLowerCase("tr"), elle en fait huit, le istanbul ordinaire qu'un utilisateur taperait. Un index de recherche construit avec la minuscule par défaut ne trouvera jamais cette requête, et les deux chaînes sont identiques à l'écran.
Les deux cas mènent au même endroit. Les lettres qui cassent un repli naïf sont celles qu'une langue considère comme membres à part entière de son alphabet, et la transformation qu'une langue attend est une propriété de la langue, pas du caractère. C'est à cela que sert un argument de locale, et le passer ne coûte rien.
Les mots que ta propre langue ne te laissera pas replier
Le repli est destructeur d'une manière facile à prouver dans toute langue à diacritiques. En français, tâche se replie en tache : une besogne et une salissure deviennent le même jeton. Où se replie en ou, c'est-à-dire que l'adverbe de lieu et la conjonction deviennent indiscernables — et ce sont deux des mots les plus fréquents de la langue. Les deux collisions sont exactement le genre de chose qui fait paraître une liste de résultats cassée au lecteur.
Ce n'est pas un argument contre le repli. C'est un argument pour conserver aussi la forme non repliée. Le schéma qui fonctionne, ce sont deux champs : stocke le texte original tel qu'écrit, normalisé en NFC et rien d'autre, et construis à côté un second champ replié pour la correspondance. Classe les correspondances exactes sur l'original au-dessus des correspondances repliées, et le lecteur qui a tapé l'accent obtient d'abord l'entrée qu'il visait, tandis que celui qui ne l'a pas tapé trouve quand même quelque chose. Le repli comme index supplémentaire est une fonctionnalité ; le repli comme remplacement destructeur de tes données est un bug que tu découvriras plus tard.
Que faire plutôt qu'un repli écrit à la main
Pour comparer et trier, utilise un collateur plutôt qu'une transformation. Un Intl.Collator en sensibilité base déclare cafe et café égaux, Muller et Müller égaux, Orsted et Ørsted égaux, Lukasz et Łukasz égaux — y compris les quatre lettres que le repli NFD ne peut pas toucher, parce que les tables de collation savent ce que sont ces lettres. Et il le fait sans produire une chaîne intermédiaire abîmée qu'il faudrait ensuite stocker quelque part.
Pour les slugs d'URL, où tu as réellement besoin d'une sortie ASCII bornée, garde le repli — mais pilote-le par une table de langue explicite d'abord et NFD ensuite, et vérifie le résultat. Un générateur de slug est l'un des rares endroits où un repli destructeur et irréversible est correct, parce qu'un slug n'est pas une donnée : c'est une étiquette régénérable, et elle a le droit de perdre des distinctions que le texte original portait. Veille seulement à appliquer la table avant NFD, faute de quoi ł et ø traverseront le slug et en ressortiront en caractères inutilisables.
Et quelle que soit ta décision, écris-la une fois. La cause la plus fréquente du bug du titre n'est pas un mauvais repli — c'est un bon repli implémenté deux fois, une fois dans l'indexeur et une fois dans le champ de recherche, par deux personnes, à six mois d'écart. Une fonction, exportée d'un seul module, appelée des deux côtés.
| Lettre | Points de code NFC | Points de code NFD | Le repli naïf donne | Ce qu'exige la langue |
|---|---|---|---|---|
| é | U+00E9 | U+0065 U+0301 | e | e convient pour la recherche, mais fusionne des mots que la langue distingue |
| ö | U+00F6 | U+006F U+0308 | o | oe en allemand ; o est acceptable en suédois et en finnois |
| ß | U+00DF | U+00DF (aucune décomposition) | ß, inchangé | ss |
| ø | U+00F8 | U+00F8 (aucune décomposition) | ø, inchangé | oe ; c'est une lettre à part entière, pas un o décoré |
| å | U+00E5 | U+0061 U+030A | a | aa en danois et en norvégien ; une lettre distincte, classée après z |
| ı | U+0131 | U+0131 (aucune décomposition) | ı, inchangé | i pour un index en écriture latine, mais jamais à confondre avec i à l'intérieur du turc |
| İ | U+0130 | U+0049 U+0307 | I | i, mais seul toLocaleLowerCase("tr") le produit en un seul point de code |
| ł | U+0142 | U+0142 (aucune décomposition) | ł, inchangé | l |
| œ | U+0153 | U+0153 (aucune décomposition) | œ, inchangé | oe ; NFKD le donnerait, NFD non |
Questions fréquentes
- Faut-il stocker le texte replié ou replier à la volée ?
- Stocke-le, comme champ supplémentaire, et jamais en remplacement. Replier à la volée revient à replier tout l'index à chaque requête, ce qui est lent, et rend bien trop facile qu'un chemin de code replie et qu'un autre oublie. Un champ replié stocké coûte peu, est construit une fois par la même fonction qu'appelle le champ de recherche, et laisse l'original intact pour la correspondance exacte et pour l'affichage. La seule chose à ne pas faire est de replier sur place : une fois la forme accentuée disparue de ta base, tu ne peux plus afficher le nom correctement, et aucune astuce ultérieure ne la ramène.
- NFKD vaut-il mieux que NFD ici, puisqu'il décompose aussi les ligatures ?
- Cela résout un problème et en crée plusieurs. NFKD transforme bien œ en oe et fi en fi, ce qui est réellement souhaitable dans un index de recherche. Mais la décomposition de compatibilité réécrit aussi les exposants en chiffres ordinaires, les lettres latines pleine chasse en ASCII, le signe ohm en oméga, et divers caractères d'espacement en espaces simples. Dans un champ d'affichage, c'est destructeur d'une manière que tu n'as pas demandée. Dans un champ de correspondance, c'est en général acceptable et souvent utile. Donc : NFC pour le stockage, NFKD comme entrée d'un champ de correspondance replié si tu veux le comportement sur les ligatures, et une table explicite pour ø, ł et ß, qu'aucune des deux formes ne corrigera.
- Une base de données s'en charge-t-elle si je choisis la bonne collation ?
- En grande partie oui, et c'est en général une meilleure réponse qu'un repli dans le code applicatif. Une collation insensible aux accents implémente la même idée que le collateur, au niveau où la comparaison a réellement lieu ; l'index et la requête sont donc comparés sous une règle unique par construction. Deux réserves. D'abord, la collation s'applique par colonne ou par comparaison : une requête qui compare une colonne collationnée à une expression que tu as repliée toi-même retombe dans le problème unilatéral. Ensuite, les collations insensibles aux accents dépendent de la langue exactement comme décrit plus haut, donc choisis celle qui correspond à ton contenu plutôt qu'un défaut générique.
- Pourquoi deux chaînes visuellement identiques échouent-elles au test d'égalité ?
- Parce que l'une est composée et l'autre décomposée. L'égalité stricte compare des unités de code, et un é composé fait une unité alors qu'un é décomposé en fait deux — le test échoue donc alors que le rendu est identique au pixel près. C'est la surprise Unicode la plus fréquente dans une fonction de recherche, et aussi la plus facile à corriger : normalise les deux opérandes en NFC avant de comparer. Note qu'une comparaison sensible à la locale déclare déjà les deux équivalentes, ce qui est un bon diagnostic : si localeCompare renvoie zéro et l'égalité stricte faux, tu as trouvé un désaccord de normalisation et non une erreur de données.
- Est-il jamais correct de replier le nom d'une personne ?
- Pour la correspondance, oui. Pour l'affichage, non. Une personne nommée Zoë Müller a le droit de voir son nom écrit correctement à l'écran, sur la facture et dans le courriel, et un système qui ne stocke que la forme repliée ne peut pas le garantir, quoi qu'il fasse ensuite. Replie vers une clé de recherche à côté de l'enregistrement, jamais par-dessus, et assure-toi que tous les chemins de sortie lisent le champ original. C'est aussi la raison pratique pour laquelle le schéma à deux champs l'emporte : il rend le chemin d'affichage et le chemin de correspondance structurellement distincts, si bien que personne ne peut imprimer la clé de recherche par accident.
Articles qui pourraient t'intéresser
Tous les guides →Outils similaires
Sources
- Unicode Consortium — Unicode Standard Annex #15: Unicode Normalization Forms — canonical and compatibility decomposition
- Unicode Consortium — Unicode Technical Standard #10: Unicode Collation Algorithm — collation strength and secondary differences
- Unicode Consortium — CLDR — Common Locale Data Repository, locale collation tables and the German phonebook variant
- MDN Web Docs — Intl.Collator — the sensitivity option and locale-aware comparison
- W3C — Internationalization Activity — character encoding, normalization and string matching on the web
Tu as repéré une erreur dans cet article ?