Explorar el Código

Fichiers texte : un fichier « ANSI » n'est plus lu en partie comme de l'UTF-8

Depuis le commit précédent, un fichier qui n'était pas de l'UTF-8 valide
était lu en UTF-8, avec ses seuls octets invalides relus en Windows-1252.
Or, dans un vrai fichier Windows-1252, deux ou trois octets voisins forment
parfois un caractère UTF-8 valide par hasard : « é », espace insécable et
« » » (E9 A0 BB) devenaient « 頻 », « à » suivi de « « » devenait « ࠫ »,
« É’ » devenait « ɒ ». Ces fichiers étaient bien lus avant.

Le décodage mélangé n'est plus utilisé que si le fichier a plus de
caractères UTF-8 valides que de suites d'octets invalides, ou s'il commence
par un BOM UTF-8. Sinon, le fichier entier est relu en Windows-1252, comme
avant. En cas d'égalité, c'est Windows-1252 qui l'emporte : les vrais
fichiers « ANSI » sont bien plus courants que les fichiers mélangés.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet hace 1 día
padre
commit
285865bd0a
Se han modificado 2 ficheros con 39 adiciones y 7 borrados
  1. 25 5
      bot/text_files.py
  2. 14 2
      tests/test_attachments_prep.py

+ 25 - 5
bot/text_files.py

@@ -24,9 +24,8 @@ class TextFileError(UserFacingError):
 def _cp1252_fallback(error: UnicodeDecodeError) -> tuple[str, int]:
     """Relit en Windows-1252 les seuls octets qui ne sont pas de l'UTF-8 valide.
 
-    Un fichier d'un ancien éditeur Windows (Bloc-notes « ANSI ») est ainsi lu
-    correctement, accent par accent, et un octet isolé dans un fichier UTF-8
-    n'abîme que lui-même au lieu de déformer tous les accents du fichier.
+    Un octet isolé dans un fichier UTF-8 n'abîme ainsi que lui-même au lieu de
+    déformer tous les accents du fichier.
     """
     return error.object[error.start:error.end].decode("cp1252", errors="replace"), error.end
 
@@ -34,8 +33,26 @@ def _cp1252_fallback(error: UnicodeDecodeError) -> tuple[str, int]:
 codecs.register_error("rika_cp1252_fallback", _cp1252_fallback)
 
 
+def _decode_invalid_utf8(data: bytes) -> str:
+    """Décode un fichier qui n'est pas de l'UTF-8 valide : Windows-1252 en entier, ou mélangé.
+
+    Dans un vrai fichier Windows-1252 (Bloc-notes « ANSI »), chaque accent est un octet
+    invalide en UTF-8, mais deux ou trois octets voisins forment parfois un caractère
+    UTF-8 valide par hasard (« é », espace insécable et « » » donnent « 頻 »). Le fichier
+    n'est donc lu en mélangé (UTF-8, octets invalides en Windows-1252) que s'il a plus
+    de caractères UTF-8 valides que d'octets invalides, ou s'il commence par un BOM UTF-8.
+    """
+    if not data.startswith(codecs.BOM_UTF8):
+        lenient = data.decode("utf-8", errors="replace")  # un « � » par suite d'octets invalides
+        invalid = lenient.count("�")
+        valid = sum(1 for char in lenient if char > "\x7f") - invalid
+        if invalid >= valid:
+            return data.decode("cp1252", errors="replace")
+    return data.decode("utf-8-sig", errors="rika_cp1252_fallback")
+
+
 def decode_text_file(data: bytes) -> str:
-    """Décode le contenu d'un fichier .txt (UTF-16 avec BOM, sinon UTF-8 avec les octets invalides en Windows-1252)."""
+    """Décode le contenu d'un fichier .txt (UTF-16 avec BOM, UTF-8, sinon Windows-1252 ou mélangé)."""
     if data.startswith((codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)):
         # « Unicode » du Bloc-notes : deux octets par caractère, dont beaucoup de \x00.
         try:
@@ -43,7 +60,10 @@ def decode_text_file(data: bytes) -> str:
         except UnicodeDecodeError:
             raise TextFileError("Ce fichier ne contient pas du texte lisible.")
     else:
-        text = data.decode("utf-8-sig", errors="rika_cp1252_fallback")
+        try:
+            text = data.decode("utf-8-sig")
+        except UnicodeDecodeError:
+            text = _decode_invalid_utf8(data)
     if "\x00" in text:
         raise TextFileError("Ce fichier ne contient pas du texte lisible.")
     text = text.replace("\r\n", "\n").strip()

+ 14 - 2
tests/test_attachments_prep.py

@@ -459,9 +459,21 @@ class DecodeTextFileTest(unittest.TestCase):
         data = "Café, élève, à bientôt".encode("utf-8") + b"\xff"
         self.assertEqual(decode_text_file(data), "Café, élève, à bientôtÿ")
 
+    def test_windows_1252_with_bytes_that_look_like_utf8(self):
+        # « é », espace insécable et « » » font E9 A0 BB : de l'UTF-8 valide par hasard (« 頻 »).
+        for text in ("C’est l’été », dit-il", "Rendez-vous à « midi »", "ÉTÉ’"):
+            with self.subTest(text):
+                self.assertEqual(decode_text_file(text.encode("cp1252")), text)
+
     def test_utf8_and_windows_1252_lines(self):
-        data = "Ligne en UTF-8 : élève\n".encode("utf-8") + "Ligne en ANSI : été".encode("cp1252")
-        self.assertEqual(decode_text_file(data), "Ligne en UTF-8 : élève\nLigne en ANSI : été")
+        # Fichier surtout en UTF-8 auquel un vieux logiciel a ajouté une ligne en Windows-1252.
+        data = "Ligne en UTF-8 : élève, déjà vu\n".encode("utf-8") + "Ligne en ANSI : été".encode("cp1252")
+        self.assertEqual(decode_text_file(data), "Ligne en UTF-8 : élève, déjà vu\nLigne en ANSI : été")
+
+    def test_utf8_with_bom_and_invalid_bytes(self):
+        # Le BOM UTF-8 dit que le fichier est en UTF-8 : il n'est jamais relu en entier en Windows-1252.
+        data = codecs.BOM_UTF8 + "Été".encode("utf-8") + b" \xe9\xe9"
+        self.assertEqual(decode_text_file(data), "Été éé")
 
     def test_utf8_cut_in_the_middle_of_a_character(self):
         # Seul le caractère coupé est abîmé, le reste du texte garde ses accents.