Réparer un texte au mauvais encodage

Remettre d’aplomb des valeurs sorties d’un import où « Crédit Agricole » a remplacé « Crédit Agricole ».

Pas besoin d’IA

GratuitInstantanéVos données ne sortent pas

Le verdict

N0, parce que la réparation est une identité sur les octets : on la rejoue dans l’autre sens et on retombe sur l’entrée, ce qu’aucun niveau au-dessus ne permet. Les deux extraits ne s’accordent pas partout, et le décompte est publié dans leur docstring : sur les quatre chaînes courtes où ils se séparent le plus visiblement — `ÃŽle-de-France`, `ÃŽlot`, `ÃŽles Canaries`, `Å’uvre` —, c’est l’extrait sans bibliothèque qui répare et `ftfy` qui renonce. Deux choix de la fiche méritent d’être copiés. Le premier est d’appeler la fonction étroite plutôt que la large — `fix_text` redresse aussi les guillemets, et transforme « L’été à Nice », déjà correct, en une version à apostrophe droite. Le second est de rendre un drapeau au lieu de réécrire en place : le point de rupture montre pourquoi, et une réparation que personne n’a notée ne se distingue pas d’une donnée qui a toujours été ainsi.

Aller-retour Windows-1252 vers UTF-8, avec un drapeau sur ce qui a changé

Pas besoin d’IA

Règle et algorithme classiqueN0GratuitInstantané

Python

snippets/repair-text-with-broken-encoding/n0.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
Réparer un texte décodé avec le mauvais jeu de caractères.

Niveau N0. « Crédit Agricole » n'est pas du texte abîmé : c'est
« Crédit Agricole » dont les octets UTF-8 ont été lus comme du
Windows-1252. L'opération est exactement inversible — réécrire les
caractères en octets Windows-1252, relire ces octets en UTF-8 — et c'est une
identité sur les octets, pas un jugement.

`ftfy` le fait en Python, et depuis dix ans. Il connaît aussi les variantes :
deux tours du même accident, un Windows-1252 qui aurait dû être du
cyrillique, un UTF-8 lu en UTF-16. Il n'en existe pas de portage en
JavaScript, si bien que l'extrait de ce langage écrit lui-même la
transformation principale, en une trentaine de lignes.

Les deux s'accordent sur trente-neuf des quarante-sept chaînes de cette
fiche, et ils se séparent dans les deux sens.

`ftfy` pèse la chaîne entière avant de réparer, et renonce quand ce qui en
sortirait est une suite courte ouvrant sur une majuscule accentuée :
`fix_encoding` laisse « ÃŽle-de-France » exactement tel quel, et répare le
même mot à l'intérieur de « Région ÃŽle-de-France ». « ÃŽles Canaries »,
« ÃŽlot » et « Å’uvre » échouent de la même façon — c'est la forme d'une
cellule de tableur et d'une colonne `region`, c'est-à-dire ce dont cette
fiche parle. L'extrait JavaScript n'a pas cette heuristique et répare les
quatre.

`ftfy` lit aussi un caractère de remplacement déjà présent comme un octet :
« Ã » + U+FFFD + « ambe » ressort en U+FFFD + « ambe », et le « Ã » a
disparu. L'extrait JavaScript laisse cette chaîne tranquille.

Dans l'autre sens, `ftfy` porte une règle que cet extrait n'a pas — un « Ã »
suivi d'une espace ordinaire est lu comme le « à » dont l'espace insécable a
été perdue en route, ce qui répare un cas réel de plus et réécrit une phrase
correcte de plus — et il défait n'importe quelle profondeur d'accidents
empilés, là où l'extrait JavaScript s'arrête après quatre tours.

Deux choses que ce fichier fait exprès. Il appelle `fix_encoding` et non `fix_text` : le second redresse aussi les guillemets, ce qui transforme « L'été à Nice », déjà correct, en une version à apostrophe droite — une réécriture silencieuse de la typographie française. Et il rend
`changed` au lieu de réparer en place, parce qu'une réparation que personne
n'a notée ne se distingue pas d'une donnée qui a toujours été ainsi.
"""

from __future__ import annotations

import ftfy

# The character a decoder writes when it gave up: the byte is gone, and no
# round trip brings it back.
REPLACEMENT = "�"


def repair_encoding(text) -> dict:
    """
    Undo a wrong decoding, and say whether anything was undone.

    `changed` is the flag a caller logs: the repair is a rewrite of somebody's
    data, and it is wrong often enough — see this entry's breaking point — that
    it must not happen in silence.

    `lossy` says the text that comes back carries replacement characters.
    Those are bytes a decoder threw away, usually before this function ever
    saw the string, and nothing here restores them. When the input already
    carries one, `ftfy` reads it as a byte and can drop the character in front
    of it — see the header — so a `lossy` text is one to import again, not one
    to repair.
    """
    if not isinstance(text, str):
        return _report(None, False, False, f"text is expected, not {type(text).__name__}")
    repaired = ftfy.fix_encoding(text)
    return _report(repaired, repaired != text, REPLACEMENT in repaired, None)


def _report(text, changed: bool, lossy: bool, reason: str | None) -> dict:
    return {"text": text, "changed": changed, "lossy": lossy, "reason": reason}

JavaScript

snippets/repair-text-with-broken-encoding/n0.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * Réparer un texte décodé avec le mauvais jeu de caractères.
 *
 * Niveau N0. « Crédit Agricole » n'est pas du texte abîmé : c'est
 * « Crédit Agricole » dont les octets UTF-8 ont été lus comme du
 * Windows-1252. L'opération est exactement inversible — réécrire les
 * caractères en octets Windows-1252, relire ces octets en UTF-8 — et c'est une
 * identité sur les octets, pas un jugement.
 *
 * `ftfy` le fait en Python, et depuis dix ans. Il connaît aussi les variantes :
 * deux tours du même accident, un Windows-1252 qui aurait dû être du
 * cyrillique, un UTF-8 lu en UTF-16. Il n'en existe pas de portage en
 * JavaScript, si bien que ce fichier écrit lui-même la transformation
 * principale, en une trentaine de lignes.
 *
 * Les deux s'accordent sur trente-neuf des quarante-sept chaînes de cette
 * fiche, et ils se séparent dans les deux sens.
 *
 * Ce fichier répare quatre chaînes courtes que `ftfy` refuse :
 * « ÃŽle-de-France », « ÃŽles Canaries », « ÃŽlot » et « Å’uvre ». `ftfy` pèse
 * la chaîne entière et renonce quand ce qui en sortirait est une suite courte
 * ouvrant sur une majuscule accentuée — il répare le même mot à l'intérieur de
 * « Région ÃŽle-de-France ». En travaillant segment par segment, ce fichier
 * n'a pas cette heuristique. Il laisse aussi tranquille un texte qui porte déjà
 * un caractère de remplacement, là où `ftfy` lit ce caractère comme un octet et
 * supprime celui qui le précède.
 *
 * Dans l'autre sens, `ftfy` porte une règle que ce fichier n'a pas — un « Ã »
 * suivi d'une espace ordinaire est lu comme le « à » dont l'espace insécable a
 * été perdue en route, ce qui répare un cas réel de plus et réécrit une phrase
 * correcte de plus — et il défait n'importe quelle profondeur d'accidents
 * empilés, là où ce fichier s'arrête après ROUNDS tours.
 *
 * Deux choses que ce fichier fait exprès. Il ne répare que l'encodage et laisse les guillemets tranquilles : le `fix_text` plus large de `ftfy` les redresse, ce qui transforme « L'été à Nice », déjà correct, en une version à apostrophe droite — une réécriture silencieuse de la typographie française. Et il rend
 * `changed` au lieu de réparer en place, parce qu'une réparation que personne
 * n'a notée ne se distingue pas d'une donnée qui a toujours été ainsi.
 */

// Windows-1252, positions 0x80 to 0x9F, where it differs from Latin-1. The
// five code points that are unassigned there keep their Latin-1 value, which
// is what `ftfy` calls a sloppy Windows-1252 and what real files contain.
const WINDOWS_1252 =
  '€\u0081‚ƒ„…†‡ˆ‰Š‹Œ\u008d'
  + 'Ž\u008f\u0090‘’“”•–—˜™š›'
  + 'œ\u009džŸ';

// The character a decoder writes when it gave up: the byte is gone, and no
// round trip brings it back.
export const REPLACEMENT = '�';

// How many times the same accident may have been applied. Two is common — a
// file repaired once and re-imported — and four is already generous.
export const ROUNDS = 4;

const strict = new TextDecoder('utf-8', { fatal: true });

/** How many bytes a UTF-8 sequence starting with this byte should have. */
const sequenceLength = (byte) => (byte >= 0xf0 ? 4 : byte >= 0xe0 ? 3 : byte >= 0xc2 ? 2 : 0);

/** The byte this character would have been in Windows-1252, or null. */
function byteOf(character) {
  const special = WINDOWS_1252.indexOf(character);
  if (special >= 0) return 0x80 + special;
  const code = character.codePointAt(0);
  return code <= 0xff ? code : null;
}

/**
 * Undo a wrong decoding, and say whether anything was undone.
 *
 * `changed` is the flag a caller logs: the repair is a rewrite of somebody's
 * data, and it is wrong often enough — see this entry's breaking point — that
 * it must not happen in silence.
 *
 * `lossy` says the text that comes back carries replacement characters. Those
 * are bytes a decoder threw away before this function ever saw the string, and
 * nothing here restores them: a `lossy` text is one to import again, not one to
 * repair.
 *
 * @param {string} text
 */
export function repairEncoding(text) {
  if (typeof text !== 'string') {
    return report(null, false, false, `text is expected, not ${kindOf(text)}`);
  }
  let repaired = text;
  for (let round = 0; round < ROUNDS; round += 1) {
    const next = onePass(repaired);
    if (next === repaired) break;
    repaired = next;
  }
  return report(repaired, repaired !== text, repaired.includes(REPLACEMENT), null);
}

/**
 * One round: every run of characters whose Windows-1252 bytes form a valid
 * multi-byte UTF-8 sequence is decoded; everything else is left exactly as it
 * is. Working run by run rather than on the whole string is what lets a text
 * where only some fields are broken come back with the rest untouched.
 */
function onePass(text) {
  const characters = [...text];
  const bytes = characters.map(byteOf);
  const pieces = [];
  let i = 0;
  while (i < characters.length) {
    const length = bytes[i] === null ? 0 : sequenceLength(bytes[i]);
    const candidate = length > 0 && i + length <= characters.length
      && bytes.slice(i + 1, i + length).every((b) => b !== null && b >= 0x80 && b <= 0xbf);
    if (candidate) {
      try {
        pieces.push(strict.decode(Uint8Array.from(bytes.slice(i, i + length))));
        i += length;
        continue;
      } catch {
        // Overlong, surrogate or out of range: not a sequence, keep the character.
      }
    }
    pieces.push(characters[i]);
    i += 1;
  }
  return pieces.join('');
}

const kindOf = (value) => (value === null ? 'null' : typeof value);

const report = (text, changed, lossy, reason) => ({ text, changed, lossy, reason });

Essayer

interactif

Le code ci-dessus tourne dans votre navigateur. Écrivez dans le champ : le résultat se refait sous vos yeux.

Collez une valeur sortie d’un import. Rien ne part sur le réseau : la réparation est une opération sur les octets, faite ici même.

à vous d’écrire

Recalculé à chaque frappe.

Ou partez d’un exemple

La saisie libre et les autres exemples ont besoin de JavaScript. L’exemple affiché ci-dessous, lui, a été calculé à la construction du site.

Ce que le code en fait

3 passages attrapés

Ce que le code a repéré

Crédit Agricole, 12 rue des Frères-Lumière

Ce qu’il renvoie

Crédit Agricole, 12 rue des Frères-Lumière

Ce qu’il en conclut

Réparé

Les octets ont été réécrits en Windows-1252 puis relus en UTF-8.

Ce que ça implique

  • Vos données ne sortent pas
  • Résultat toujours identique
  • Se teste unitairement, cas par cas
  • Dépend d’une bibliothèque
  • Empreinte négligeable

Ce que cela ajoute à votre périmètre réglementaire

  • Aucun périmètre spécifique ajouté : le texte est réécrit dans votre processus et n’est transmis à personne
  • La réparation modifie une donnée enregistrée : le drapeau rendu est ce qui permet d’en garder la trace

Point de rupture

Le réparateur ne sait pas que le texte parle de l’encodage : « on voit « é » au lieu de « é » » ressort en « on voit « é » au lieu de « é » », et l’exemple cité a disparu. Témoin dans le même test : vingt chaînes déjà correctes, dont « São Paulo » et « Ångström », ressortent inchangées.

Quand passer au niveau suivant

Il n’y a pas de niveau au-dessus. Le jour où le texte porte le caractère de remplacement — « Cr�dit » —, l’octet a été jeté avant que ce code le voie : aucun niveau de cette fiche ne le fait revenir, et la réponse est de réimporter la source.

Les quatre options

  1. RecommandéN0Aller-retour Windows-1252 vers UTF-8, avec un drapeau sur ce qui a changéGratuit · Instantané
  2. N1Niveau sans objet ici
  3. N2Niveau sans objet ici
  4. N3Niveau sans objet ici

Les autres options

N1Niveau sans objet ici

Niveau sans objet ici

Modèle classique légerN1

Il n’y a rien à apprendre : la transformation est une table de 256 octets et une norme, appliquée dans un sens ou dans l’autre, et elle est exactement inversible. Un classifieur entraîné sur des paires abîmé-correct approcherait une bijection, et le seul endroit où il pourrait faire mieux — décider si une chaîne donnée doit être réparée — est précisément celui où une erreur écrit une donnée fausse dans votre base.

N2Niveau sans objet ici

Niveau sans objet ici

Petit modèle spécialisé auto-hébergéN2

Même argument, avec un modèle à charger. Et une objection propre à ce besoin : la réparation s’applique à une colonne entière, souvent des millions de lignes, dans un traitement par lots où le coût par ligne est la seule chose qui compte. Un aller-retour d’octets se mesure en microsecondes ; une inférence par ligne, non.

N3Niveau sans objet ici

Niveau sans objet ici

API de LLM généralisteN3

C’est le niveau que cette fiche existe pour refuser. Un modèle à qui l’on demande de réparer un texte rend un texte plausible, et il n’existe aucun moyen de distinguer, dans sa sortie, ce qu’il a décodé de ce qu’il a deviné — sur un nom propre, une référence ou une adresse, la différence est la donnée elle-même. L’aller-retour d’octets, lui, se vérifie : on le rejoue dans l’autre sens et on retrouve l’entrée.

Pour aller plus loin

Source des chiffres

Métadonnées