Detectar la codificación es adivinar: lo medimos
mb_detect_encoding y un detector en Python puro, con 9,000 muestras: aciertan con texto largo y fallan con corto. Velocidad con 10 MB y el bug por bloques.
Serie: curso de texto y codificación 6/6 (avanzado). Para quien importa archivos de sistemas que no controla (la exportación de un sistema contable viejo, el CSV de un proveedor, un archivo de correos) y escuchó "detecta la codificación y listo". Al terminar vas a saber cuántas veces acierta la detección, por qué falla con textos cortos, qué tan rápida es de verdad la conversión y cómo armar un flujo de importación que no dependa de la suerte.
Si alguna vez confiaste en mb_detect_encoding($data), o abriste un CSV y en lugar de año apareció año y ¿Qué? se volvió ¿Qué?, esta clase es para ti. El problema de fondo es simple: una secuencia de bytes no dice qué codificación la produjo. Detectar es hacer estadística sobre una muestra, y una muestra de cuatro caracteres es muy poca evidencia. Aquí lo medimos con muestras de semilla fija en cuatro idiomas, comparamos el detector de PHP con uno de 40 líneas en Python, cronometramos la conversión de 10 MB y corregimos el bug de decodificación por bloques que anunció la clase 1.
Entorno: Mac con Apple Silicon (M1 Pro), macOS 26.6, Python 3.9.6 (solo la biblioteca estándar), PHP 8.5.6 con mbstring e iconv, y el comando iconv de macOS. La biblioteca de Python más popular para esto, chardet (y sus parientes), no está instalada en esta Mac y para esta clase no se instaló nada, así que no se probó. Windows y Excel no se ejecutaron.
Por qué detectar solo puede ser adivinar
Toma cuatro strings cortos, guarda cada uno en su codificación antigua típica y decodifica los mismos bytes con cada candidata:
| Original | Guardado como | Bytes | Leído como cp1252 | Leído como cp932 | Leído como cp949 |
|---|---|---|---|---|---|
| It’s | cp1252 | 49 74 92 73 |
It’s | It痴 | It뭩 |
| año | cp1252 | 61 f1 6f |
año | a + U+E0EB (uso privado) | error |
| 文字化け | cp932 | 95 b6 8e 9a 89 bb 82 af |
•¶Žš‰»‚¯ | 文字化け | 빒럻돸궚 |
| 한글 | cp949 | c7 d1 b1 db |
ÇÑ±Û | ヌムアロ | 한글 |
Casi todas las lecturas decodifican sin error. Windows-1252 convierte prácticamente cualquier byte en algún carácter, e ISO-8859-1 (latin-1) convierte todos los bytes, así que "decodificó" no prueba nada. Un detector solo puede preguntarse: ¿qué lectura se parece más a texto real? Eso es una apuesta, y mejora con más texto.
La dirección contraria es la que más ves en español: UTF-8 leído como Windows-1252. año se volvió año, ¿Qué? se volvió ¿Qué? y ¡Olé! se volvió ¡Olé!. Cada ñ o vocal acentuada ocupa dos bytes en UTF-8, y cp1252 los muestra como dos letras (el mecanismo completo está en la clase 2).
Hay una señal muy fuerte: que los bytes sean UTF-8 válido. UTF-8 tiene reglas estrictas sobre qué byte puede seguir a cuál, y el texto en codificaciones antiguas casi nunca las cumple. De nuestras 10,000 muestras antiguas (español, inglés, japonés y coreano), 0 eran UTF-8 válido. Comprueba UTF-8 primero y ya resolviste la mayoría de los archivos reales. La excepción es el texto que ya es mojibake: los bytes cp1252 de é son exactamente los bytes UTF-8 de é.
mb_detect_encoding: tres trampas antes de la precisión
El detector de PHP tiene comportamientos que conviene conocer antes de mirar cualquier porcentaje.
Trampa 1: la lista por defecto. Sin lista de candidatos, PHP usa mb_detect_order(), que aquí era ASCII, UTF-8. Y en modo no estricto devuelve el candidato "más cercano" aunque ninguno encaje:
$b = mb_convert_encoding("文字化け", "SJIS-win", "UTF-8");
var_dump(mb_detect_encoding($b)); // string(5) "ASCII"
var_dump(mb_detect_encoding("\xff\xfe", ["UTF-8", "ASCII"])); // string(5) "UTF-8"
var_dump(mb_detect_encoding("\xff\xfe", ["UTF-8", "ASCII"], true)); // bool(false)
Bytes Shift_JIS reportados como ASCII, y dos bytes que nunca pueden aparecer en UTF-8 reportados como UTF-8. Pasa siempre una lista explícita y true para el modo estricto.
Trampa 2: el orden de los candidatos es parte de la respuesta. Cuando dos candidatos encajan, deciden el orden y la puntuación interna de PHP. Los resultados de abajo cambian muchísimo entre una lista "japonés primero" y otra "coreano y occidental primero".
Trampa 3: detecta la familia, no la variante. 0x93 Hola 0x94 es “Hola” en Windows-1252, pero en ISO-8859-1 es Hola entre dos caracteres de control C1 invisibles ('\x93Hola\x94'), y el byte 0x80 es € en cp1252 y otro control en latin-1. Un archivo hecho en Windows casi nunca es latin-1 de verdad aunque lo declare. Lo mismo pasa con Shift_JIS vs CP932 en japonés y EUC-KR vs CP949 en coreano.
Un detector con la biblioteca estándar, versión 1 y versión 2
Para comparar, escribimos un detector solo con la biblioteca estándar de Python. Versión 1: probar UTF-8; si no, decodificar con cada candidata antigua y puntuar el resultado por tipo de carácter (kana y hangul +2, kanji +1, ASCII imprimible +1, katakana de medio ancho −1, controles y uso privado −5), promediado por carácter. Fue mala con el español en Windows-1252: 107 de 200 en muestras de 4 caracteres, porque é seguida de una letra es un kanji válido en Shift_JIS.
La versión 2 cambió dos cosas tras mirar sus errores: puntuar por byte (para que un kanji de dos bytes no le gane a dos letras latinas de un byte) y premiar letras como é, ñ, ü (U+00C0–U+00FF) y comillas tipográficas, además de penalizar las sílabas hangul fuera de las 2,350 comunes de KS X 1001:
import unicodedata
CANDIDATES = ["cp1252", "cp932", "euc_jp", "cp949"] # on a tie, earlier wins
def char_score(ch):
o = ord(ch)
if o < 0x80: return 1.0 if (ch.isprintable() or ch in "\t\r\n") else -5.0
if 0x3041 <= o <= 0x30FF: return 2.0 # kana
if 0xAC00 <= o <= 0xD7A3: # hangul syllable
b = ch.encode("cp949")
return 2.0 if (b[0] >= 0xB0 and b[1] >= 0xA1) else -1.0 # rare outside KS X 1001
if 0x4E00 <= o <= 0x9FFF: return 1.5 # kanji / hanja
if 0x3000 <= o <= 0x303F or 0xFF01 <= o <= 0xFF5E: return 1.5
if 0x2010 <= o <= 0x2026: return 1.0 # dashes, smart quotes
if 0xFF61 <= o <= 0xFF9F or 0x3131 <= o <= 0x318E: return -1.0
cat = unicodedata.category(ch)
if cat in ("Cc", "Co", "Cn"): return -5.0
if cat.startswith("L"): return 1.0 if 0xC0 <= o <= 0xFF else 0.0
return 0.0
def guess(b):
if b.startswith(b"\xef\xbb\xbf"): return "utf-8-sig"
try:
b.decode("utf-8"); return "utf-8"
except UnicodeDecodeError:
pass
best, best_score = None, float("-inf")
for enc in CANDIDATES:
try: s = b.decode(enc)
except UnicodeDecodeError: continue
score = sum(char_score(c) for c in s) / len(b)
if score > best_score: best, best_score = enc, score
return best
Advertencia honesta: la versión 2 se ajustó mirando los fallos sobre un conjunto de textos. Para no corregir nuestra propia tarea, todos los números de abajo vienen de un segundo conjunto, reservado, de textos (párrafos cotidianos sobre teléfonos, cocina y el clima) que el ajuste nunca vio. Ambos conjuntos los escribimos nosotros, así que los datos reales se comportarán distinto.
Midiendo la precisión: texto corto vs largo
Método: para cada idioma, tomamos substrings aleatorios de 4, 8, 16, 64 y 256 caracteres (random.seed(2026)), descartamos los que son solo ASCII (cualquier apuesta acierta) y los que la codificación no puede representar (㈱ y ① no existen en EUC-JP), codificamos y preguntamos a cada detector. Una apuesta es correcta si decodificar con ella devuelve exactamente el texto original. N = 200 por celda, 9,000 muestras en total. La lista A de PHP era UTF-8, SJIS-win, EUC-JP, UHC, Windows-1252; la lista B, UTF-8, UHC, Windows-1252, SJIS-win, EUC-JP (UHC es el nombre de CP949 en PHP).
Aciertos sobre 200, muestras de 4 caracteres:
| Caso | stdlib v1 | stdlib v2 | PHP lista A | PHP lista B |
|---|---|---|---|---|
| Español, Windows-1252 | 107 | 200 | 153 | 170 |
| Inglés, Windows-1252 | 135 | 200 | 135 | 135 |
| Japonés, CP932 | 60 | 185 | 200 | 200 |
| Japonés, EUC-JP | 195 | 195 | 200 | 60 |
| Coreano, CP949 | 200 | 199 | 47 | 200 |
Muestras de 16 caracteres:
| Caso | stdlib v1 | stdlib v2 | PHP lista A | PHP lista B |
|---|---|---|---|---|
| Español, Windows-1252 | 117 | 200 | 166 | 200 |
| Inglés, Windows-1252 | 125 | 200 | 125 | 200 |
| Japonés, CP932 | 80 | 200 | 200 | 200 |
| Japonés, EUC-JP | 200 | 200 | 200 | 152 |
| Coreano, CP949 | 200 | 200 | 132 | 200 |
Con 256 caracteres todos los detectores acertaron 200/200 salvo dos casos: v1 y la lista A de PHP acertaron solo 179 veces el inglés en CP1252. Las 4,000 muestras UTF-8 fueron correctas para todos.
Así se veían los fallos en español: con cualquiera de las dos listas, PHP leyó léfo como l馭o, éfon como 馭on y uién como ui駭. El byte 0xE9 (é) seguido de una letra es un kanji válido en Shift_JIS, y el candidato japonés puntuó al menos igual de bien. En inglés, It’s se volvió It痴 por el mismo motivo con la comilla 0x92. Nuestra v2 falló en la dirección contraria: sus 15 fallos con japonés de 4 caracteres se leyeron todos como cp1252, porque el primer byte del hiragana, 0x82, es la comilla baja ‚ en cp1252 y v2 premia las comillas tipográficas. Una regla pensada para el inglés perjudicó al japonés. Texto corto más una lista de candidatos de varias regiones es el peor caso, y un apellido, un código de producto o una celda de CSV son exactamente eso.
Error común: detectar con el principio de un archivo grande
El error. Detectar sobre 10 MB es lento (nuestra v2 tardó 894 ms con 10 MiB de CP932), así que razonablemente miras los primeros 64 KiB: guess(data[:65536]).
Lo que pasa en realidad. El corte cae en medio de un carácter dos de cada tres veces. Nuestro guess estricto ve entonces UTF-8 inválido y texto antiguo inválido:
big_cp949_utf8.txt [:65535] None [:65536] None [:65537] utf-8
big_cp932.txt [:65535] None [:65536] cp932 [:65537] None
big_cp949.txt [:65535] cp1252 [:65536] cp949 [:65537] cp1252
Un archivo UTF-8 quedó como "desconocido" y uno coreano en CP949 como Windows-1252: peor que no tener respuesta, porque la importación sale "bien" y guarda mojibake.
Solución. Decodifica el prefijo con un decodificador incremental y final=False, que trata un carácter sin terminar al final como "faltan bytes" en vez de como error:
import codecs
def decode_prefix(b, enc):
return codecs.getincrementaldecoder(enc)().decode(b, final=False)
Usando decode_prefix en lugar de b.decode dentro de guess, los 15 cortes dieron la respuesta correcta, y adivinar con 64 KiB tardó 6 ms en vez de 894.
El bug del streaming: un carácter partido entre bloques
La misma partición ocurre al decodificar un stream bloque a bloque. Un archivo japonés de 1 MB (en UTF-8, y el mismo texto en CP932) leído en bloques de 4,096 bytes:
with open(path, "rb") as f:
while chunk := f.read(4096):
chunk.decode(enc) # el bug: cada bloque se decodifica por separado
strict: chunk #1 failed: 'utf-8' codec can't decode bytes in position 4094-4095: unexpected end of data
replace: U+FFFD count = 412
strict: chunk #3 failed: 'cp932' codec can't decode byte 0x83 in position 4095: incomplete multibyte sequence
replace: U+FFFD count = 86
En CP932 es peor de lo que dice el conteo. Cuando se reemplaza un primer byte, su segundo byte empieza el bloque siguiente y se lee como un carácter aparte: el segundo byte 0x65 de テ se convirtió en la letra e. La salida tuvo 85 caracteres más que el original y 59 katakana de medio ancho que no estaban en el archivo, sin ningún error. Con texto en español en UTF-8 pasa igual: una ñ partida (C3 | B1) se convierte en dos U+FFFD.
Solución en Python: un decodificador incremental guarda los bytes sin terminar para la siguiente llamada.
import codecs
dec = codecs.getincrementaldecoder(enc)()
with open(path, "rb") as f:
parts = [dec.decode(c) for c in iter(lambda: f.read(4096), b"")]
parts.append(dec.decode(b"", final=True)) # lanza error si el archivo termina a mitad de carácter
Resultado para ambos archivos: U+FFFD count = 0, idéntico a decodificar el archivo entero. Abrir el archivo en modo texto (open(path, encoding="utf-8")) usa el mismo mecanismo. En PHP, mb_convert_encoding sobre cada bloque de fread agregó 412 (UTF-8) y 87 (CP932) signos ?; añadir al handle el filtro de stream convert.iconv.CP932/UTF-8 produjo una salida idéntica a la conversión completa. JavaScript en jsc no tiene TextDecoder, así que su opción {stream: true} no se ejecutó aquí.
Velocidad de conversión con 10 MiB
Cada archivo eran 10 MiB de texto real en la codificación antigua, convertido a UTF-8 en memoria; mediana de 5 ejecuciones. La fila del comando iconv incluye arrancar el proceso y leer el archivo (cat del mismo archivo tardó 4 ms).
| Conversión a UTF-8 | CP932 | CP949 | CP1252 |
|---|---|---|---|
Python data.decode(enc).encode("utf-8") |
36 ms | 33 ms | 10 ms |
PHP mb_convert_encoding |
22 ms | 37 ms | 19 ms |
PHP iconv() |
283 ms | 197 ms | 186 ms |
comando iconv (macOS) |
284 ms | 212 ms | 196 ms |
Las cuatro salidas fueron idénticas byte a byte (comprobado con cmp y ===). Los codecs de Python y mbstring rindieron entre unos 280 y 1,000 MB/s; la libiconv de macOS fue unas diez veces más lenta. Aun así es menos de un tercio de segundo para 10 MiB: para una importación nocturna cualquiera sirve; dentro de una petición web, prefiere mbstring o los codecs de Python. Dos números más: validar 15.6 MB de UTF-8 tomó 10.3 ms en Python (decode), y mb_check_encoding de PHP tardó de 3.5 a 4.1 ms con un string nuevo pero unos 0.0002 ms en la segunda llamada sobre el mismo string, porque PHP guarda en el string una marca de "UTF-8 válido". Mide la primera llamada, no la mediana.
Un flujo que no depende de la suerte
- Consigue que declaren la codificación.
Content-Type: text/csv; charset=...en HTTP (lección de headers HTTP (en inglés)), la declaración de XML o HTML, un BOM, o simplemente una línea en el acuerdo con el proveedor. Una codificación declarada le gana a cualquier detector. - Busca un BOM y luego comprueba si es UTF-8 válido. Esto acertó 4,000 de 4,000 muestras UTF-8 y descartó las 10,000 antiguas.
- Usa la codificación conocida del origen. Un sistema antiguo siempre escribe en la misma codificación. Ten una tabla "origen → codificación" en vez de adivinar por archivo. Si el origen es Windows, escribe
cp1252, nolatin-1, o perderás el€y las comillas. - Decodifica en modo estricto.
errors="strict"en Python,mb_check_encodingantes demb_convert_encodingen PHP. Una importación fallida sale más barata que mojibake silencioso. - Adivina solo como último recurso, con al menos unos cientos de bytes, una lista de candidatos limitada a las regiones de las que realmente recibes datos, y manda el resultado a revisión humana o a una carpeta de cuarentena.
- Haz streaming con decodificadores incrementales, nunca
decodepor bloque. - Normaliza y guarda en UTF-8: NFC (clase 3),
utf8mb4en MySQL (clase 5), y para quien abre CSV en Excel, sigue la clase 4 (el comportamiento de Excel se describe allí según la documentación; no se ejecutó).
Límites honestos y notas de versión
- Nuestras muestras vienen de dos pequeños conjuntos de párrafos escritos por nosotros; los datos reales (direcciones, códigos de producto, idiomas mezclados) puntuarán distinto. Toma las tablas como demostración del efecto del texto corto, no como benchmark de PHP.
- La puntuación de
mb_detect_encodingse rehízo en PHP 8.1; en PHP 7 u 8.0 los resultados pueden diferir. Solo se ejecutó 8.5.6. chardet,charset-normalizery elCharsetDetectorde ICU no estaban disponibles y no se probaron. Linux y GNU iconv no se ejecutaron.- Herramientas distintas discrepan incluso con el mismo nombre de codificación: el codec
euc_krde Python aceptó sílabas coreanas fuera del estándar mediante secuencias de 8 bytes, mientras queiconv -t EUC-KRde macOS las rechazó conIllegal byte sequence. - Las velocidades son de un solo Apple M1 Pro; lo que importa es el orden (mbstring y codecs rápidos, libiconv de macOS más lenta).
Ejercicios
1. ¿Por qué mb_detect_encoding($b) devolvió "ASCII" para bytes Shift_JIS, y qué cambio lo arregla?
2. Dale al decodificador incremental un byte a la vez: decodifica "¿Año nuevo? ¡Señor Muñoz!".encode("utf-8") llamando a codecs.getincrementaldecoder("utf-8")() una vez por byte. ¿Es igual a decodificar todo de una vez?
Respuestas
1. Sin lista, PHP usó el orden de detección ASCII, UTF-8, y en modo no estricto devuelve el candidato más cercano aunque ninguno sea válido. Pasa una lista explícita y el modo estricto: mb_detect_encoding($b, ["UTF-8", "SJIS-win"], true) devolvió "SJIS-win".
2. Sí. Imprimió ¿Año nuevo? ¡Señor Muñoz! True. El decodificador guarda un primer byte como 0xC3 hasta que llega el siguiente, así que incluso un tamaño de bloque de 1 es seguro. Se ve bien en dos pasos: dec.decode(b"caf\xc3") devolvió 'caf', y la llamada siguiente con b"\xa9!" devolvió 'é!'; llamar a decode(b"\xc3", final=True) en un decodificador nuevo lanzó unexpected end of data.
Cierre de la serie
Esta fue la última clase. De bytes y puntos de código (1), pasando por el mojibake (2), la normalización (3), CSV y Excel (4) y la longitud de los strings (5), hasta la detección de hoy, la regla fue siempre la misma: sabe con qué codificación y qué unidad estás trabajando, y hazlo explícito en cada frontera.
- Anterior: curso de texto y codificación 5/6: longitud de un string en 8 lenguajes
- Volver al inicio: clase 1/6: caracteres, puntos de código y bytes UTF-8
- El resto de la serie: mojibake: por qué aparece «Ã±» y cómo arreglarlo, normalización Unicode y nombres de archivo, CSV, el BOM y Excel
- Relacionado: tuberías del shell y herramientas de texto, Python: tipos y mutabilidad, PHP: tipos, strings y arrays, URLs con caracteres no ASCII en .htaccess (en inglés), y la hoja de ruta para aprender a programar
Preguntas frecuentes
¿Qué tan preciso es mb_detect_encoding con texto en español?
Depende del largo y del orden de la lista de candidatos. Con muestras de 4 caracteres en Windows-1252, acertó 153 de 200 con las codificaciones japonesas primero en la lista y 170 de 200 con Windows-1252 antes; el resto se leyó como kanji de Shift_JIS (léfo se volvió l馭o). Desde 8 caracteres, con Windows-1252 antes, acertó 200 de 200 en nuestras muestras. UTF-8 acertó 4,000 de 4,000.
¿Cuál es la diferencia entre ISO-8859-1 y Windows-1252?
Coinciden en casi todo, pero en los bytes 0x80 a 0x9F Windows-1252 tiene caracteres visibles (€, comillas tipográficas “ ”, rayas) y ISO-8859-1 tiene caracteres de control invisibles. Los bytes 93 48 6F 6C 61 94 son “Hola” en cp1252 y Hola entre dos controles C1 en latin-1. Además, latin-1 decodifica cualquier byte, así que «decodificó sin error» no prueba nada.
¿Por qué aparecen caracteres raros al leer un archivo por bloques?
Un carácter de varios bytes quedó partido entre dos bloques y cada bloque se decodificó por separado. Un archivo UTF-8 de 1 MB leído en bloques de 4,096 bytes falló en el primer límite en modo estricto y produjo 412 U+FFFD con errors='replace'. Con un decodificador incremental (codecs.getincrementaldecoder en Python) o el filtro de stream convert.iconv en PHP, el resultado fue idéntico al de convertir el archivo entero.