A vueltas con la Codificación de Caracteres
Veamos...¿qué diferencia hay entre los siguientes textos?
01. Niño, ¿cuántos €uros te costó?
02. Niño, ¿cuántos â¬uros te costó?
03. Ni¤o, ¨cu ntos uros te cost¢?
04. Ni±o, ┐cußntos Çuros te cost¾?
05. Nio, cuntos uros te cost?
Pues es sencillo. La frase es la misma, pero hay una diferencia entre la codificación (o juego de caracteres) escogida para guardar el texto y el juego de caracteres con el que ese texto se presenta en pantalla.
Investigando...
Ya llevo cierto tiempo estudiando diferentes aspectos sobre Criptografía.
En el camino de estos estudios, me topé con los algoritmos que generan valores HASH (las "funciones Hash", o "funcionaes resumen", generan una secuencia de números que "resumen" un conjunto de información; normalmente se usan para corroborar que un texto u otra información no ha sido alterada, ya que un simple cambio en la información produce un valor de hash totalmente diferente).
Concretamente he profundizado en el estudio del algoritmo SHA-1, llegando incluso a desarrollar una implementación de este algoritmo en Javascript, como comento en detalle en este otro post.
Precisamente en este trabajo con el algoritmo SHA-1 me encontré que un mismo texto generaba Hash diferentes si se calculaba con la rutina que había desarrollado comparado con el resultado de la función SHA1 de PHP.
Tras comprobar que no se trataba de bugs en mi desarrollo (o tras depurar los bugs encontrados), seguía sin conseguir el mismo Hash que generaba PHP al intentar cifrar textos que contenían ciertos caracteres, como el símbolo de €uro.
Así que tocaba investigar.
Y tras unas cuantas horas, y días, "googleando" por aquí y por allá, creo haber llegado a entender bastantes aspectos de la codificación de caracteres.
Una de las páginas que me ha dado la clave para entender por qué aparecen "caracteres extraños" en los textos, y que me inspiró para escribir este artículo, es esta, en la que el autor hace un estudio de las diferentes formas en las que aparece el nombre de la ciudad "A Coruña" en las cartas y envios que recibe.
He llegado a la conclusión, por cierto, que es éste un tema muy, muy embarullado. Existen un sinfín de codificaciones, aparecidas en las últimas décadas, buscando dar cobertura a las necesidades de los diferentes idiomas. En ocasiones buscando la unificación y la homogeneidad, y en otras haciendo lo que se puede dentro de la limitación de los 8 bits. Muchas de ellas muy parecidas pero no iguales, para complicar más la cosa.
Diferentes Codificaciones (juegos de caracteres)
Así tenemos a: ASCII / ANSI, DOS-850, Windows 1252, ISO-8859-1, Unicode UTF-16 ó Unicode UTF-8 entre muchos otros. La verdad es que la Wikipedia está muy bien nutrida de artículos sobre este tema y se puede extraer mucha información al respecto.
En cada Juego de Caracteres un valor numérico representa un caracter imprimible (o un caracter de control).
En unos juegos de caracteres, como ISO-8859-1, el valor que representa cada caracter está comprendido entre 0 y 255 (en decimal) (entre 0x00 y 0xFF hexadecimal) ya que solo se utiliza un byte por caracter.
En otras codificaciones como UTF-16 se usan hasta 4 bytes para cada caracter, con lo que los valores posibles son entre 0 y 4.294.967.296 (en decimal) (0x00 a 0xFFFFFFFF en hexadecimal), aunque en este caso no se usa todo este rango.
UTF-8, por su parte, utiliza un número variable de bytes para representar cada caracter, pudiendo necesitar entre 1 y 4 bytes.
Volvamos sobre la pregunta
Bueno, OK. Y tras todo este rollo ¿cuáles son las diferencias en la codificación de la frase anterior?
Niño, ¿cuántos €uros te costó?
Pues veamos su codificación en el juego de caracteres en el que fue grabada esta página:
Windows 1252, también llamado "ANSI" al editar y grabar la página con el Bloc de Notas de Windows
y en otras herramientas de edición como el Notepad++ ...
Nota. Actualización mayo-2019.
Esta página es en realidad un PHP. Para que todo funcione he tenido que meter la siguiente directiva al principio de la página, para forzar a que se abra esta página en ISO-8859-1.
< ? php header ('Content-type: text/html; charset=iso-8859-1'); ? >
N | i | ñ | o | , | ¿ | c | u | á | n | t | o | s | |||
Codific. (HEX) | Windows 1252 | 4E | 69 | F1 | 6F | 2C | 20 | BF | 63 | 75 | E1 | 6E | 74 | 6F | 73 |
€ | u | r | o | s | t | e | c | o | s | t | ó | ? | ||||
Codific. (HEX) | Windows 1252 | 80 | 75 | 72 | 6F | 73 | 20 | 74 | 65 | 20 | 63 | 6F | 73 | 74 | F3 | 3F |
Y ahora veamos los diferentes resultados presentados en la cabecera de este post, que ocurren cuando la codificación usada para grabar el texto no es la misma que la usada para presentarlo.
Veamos cada ejemplo...:
02. Grabado en UTF-8. Presentado en Windows-1252
N | i | ñ | o | , | ¿ | c | u | á | n | t | o | s | |||
Codific. (HEX) | Grabado UTF-8 |
4E | 69 | C3B1 | 6F | 2C | 20 | C2BF | 63 | 75 | C3A1 | 6E | 74 | 6F | 73 |
Visualizado Windows 1252 |
N | i | ñ | o | , | ¿ | c | u | á | n | t | o | s |
€ | u | r | o | s | t | e | c | o | s | t | ó | ? | ||||
Codific. (HEX) | Grabado UTF-8 |
E282AC | 75 | 72 | 6F | 73 | 20 | 74 | 65 | 20 | 63 | 6F | 73 | 74 | C3B3 | 3F |
Visualizado Windows 1252 |
€ | u | r | o | s | t | e | c | o | s | t | ó | ? |
03. Grabado en DOS-850. Presentado en Windows-1252
N | i | ñ | o | , | ¿ | c | u | á | n | t | o | s | |||
Codific. (HEX) | Grabado DOS-850 |
4E | 69 | A4 | 6F | 2C | 20 | A8 | 63 | 75 | A0 | 6E | 74 | 6F | 73 |
Visualizado Windows 1252 |
N | i | ¤ | o | , | ¨ | c | u | n | t | o | s |
€ | u | r | o | s | t | e | c | o | s | t | ó | ? | ||||
Codific. (HEX) | Grabado DOS-850 |
0 | 75 | 72 | 6F | 73 | 20 | 74 | 65 | 20 | 63 | 6F | 73 | 74 | A2 | 3F |
Visualizado Windows 1252 |
u | r | o | s | t | e | c | o | s | t | ¢ | ? |
Una curiosidad:
El símbolo del €uro no existe en la codificación DOS-850,
pero sí en la DOS-858.
Precisamente esta codificación, la DOS-858, fue creada en el año 1998 a partir de la DOS-850
solo para introducir el símbolo del €uro.
Se utilizó el valor hexadecimal "D5", que en la codificación DOS-850 se usa para el caracter "ı".
04. Grabado en Windows-1252. Presentado en DOS-850
N | i | ñ | o | , | ¿ | c | u | á | n | t | o | s | |||
Codific. (HEX) | Grabado Windows 1252 |
4E | 69 | F1 | 6F | 2C | 20 | BF | 63 | 75 | E1 | 6E | 74 | 6F | 73 |
Visualizado DOS-850 |
N | i | ± | o | , | ┐ | c | u | ß | n | t | o | s |
€ | u | r | o | s | t | e | c | o | s | t | ó | ? | ||||
Codific. (HEX) | Grabado Windows 1252 |
80 | 75 | 72 | 6F | 73 | 20 | 74 | 65 | 20 | 63 | 6F | 73 | 74 | F3 | 3F |
Visualizado DOS-850 |
Ç | u | r | o | s | t | e | c | o | s | t | ¾ | ? |
05. Grabado en Windows-1252. Presentado en UTF-8
N | i | ñ | o | , | ¿ | c | u | á | n | t | o | s | |||
Codific. (HEX) | Grabado Windows 1252 |
4E | 69 | F1 | 6F | 2C | 20 | BF | 63 | 75 | E1 | 6E | 74 | 6F | 73 |
Visualizado UTF-8 |
N | i | o | , | c | u | n | t | o | s |
€ | u | r | o | s | t | e | c | o | s | t | ó | ? | ||||
Codific. (HEX) | Grabado Windows 1252 |
80 | 75 | 72 | 6F | 73 | 20 | 74 | 65 | 20 | 63 | 6F | 73 | 74 | F3 | 3F |
Visualizado UTF-8 |
u | r | o | s | t | e | c | o | s | t | ? |
Un poco de codificación
Y para finalizar vamos a ver como he conseguido generar mediante programación en PHP las tablas anteriores que representan las diferentes codificaciones.
Para ello he hecho uso de la función iconv de PHP, que convierte cadenas de caracteres de una codificación a otra.
Esta función "iconv" toma un texto considerando que se encuentra en una determinada codificación original, y busca los mismos caracteres considerando una codificación destino. Lo que devuelve por tanto es el mismo caracter, pero con un valor numérico diferente.
Esto es, que si utilizamos la función ord() de PHP antes y después de la conversión de "iconv", el valor devuelto debe ser diferente, si es que en las codificaciones usadas en "iconv" el caracter tiene un código diferente.
Ejemplo:
Supongamos que tenemos el caracter "ñ" y nuestra página está grabada en Windows-1252.
Este caracter tiene el valor 0xF1 (hexadecimal) en codificación Windows-1252.
Entonces, si utilizamos la función ...
$c_utf8 = iconv("Windows-1252", "UTF-8", "ñ");
... el valor devuelto será el mismo caracter, una "ñ", pero con un valor diferente ya que el valor en codificación UTF-8 para este caracter es diferente.
De hecho devuelve 2 bytes, ya que el valor de la "ñ" en UTF-8 es 0xC3B1 (hexadecimal)
Para no volvernos locos con esta función, lo mejor es pensar en ella como una función que trabaja con valores numéricos en vez de caracteres.
O sea, en nuestro ejemplo, pensar que le entra un 0xF1 y devuelve un 0xC3B1, porque internamente es precisamente lo que hace.
... Y si luego ese valor va a ser representado como caracter en una página web, pensar en el "charset" de nuestra página y pensar como va a representarse este valor 0xC3B1 en ese charset (en nuestro ejemplo, con charset=windows-1252 ó iso-8859-1 la representación será "ñ").
<!-- Ejemplo etiqueta META con charset --> <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1" />
Nota. Actualización mayo-2019.
Esta página es en realidad un PHP. Para que todo funcione he tenido que meter la siguiente directiva al principio de la página, para forzar a que se abra esta página en ISO-8859-1.
< ? php header ('Content-type: text/html; charset=iso-8859-1'); ? >
La función "iconv" puede manejar valores por defecto para la conversión.
Se pueden recuperar con la función iconv_get_encoding(). En el servidor donde está alojada esta página están vigentes los siguientes:
input_encoding = ISO-8859-1
output_encoding = ISO-8859-1
internat_encoding = ISO-8859-1
Ejemplo: 02.Grabado en UTF-8. Presentado en Windows-1252
Código fuente para presentar el texto ...
<?php $txt1 = "Niño, ¿cuántos"; $txt2 = "€uros te costó?"; ?> <?php // Muestra el texto en una fila de la tabla for ($i=0; $i<strlen($txt1); $i++) { $c = substr($txt1, $i, 1); if ($c == " ") $c = " "; echo "<td>" . $c . "</td>"; } ?>
Resultado ...
N | i | ñ | o | , | ¿ | c | u | á | n | t | o | s |
Código fuente para mostrar codificación UTF-8 ...
<?php // Grabado en UTF-8 for ($i=0; $i<strlen($txt1); $i++) { $c = substr($txt1, $i, 1); $c_utf8 = iconv("Windows-1252", "UTF-8", $c); $c_utf8_hex = ""; for ($j=0; $j<strlen($c_utf8); $j++) { $c_utf8_hex .= strtoupper(dechex(ord(substr($c_utf8, $j, 1)))); } echo "<td>" . $c_utf8_hex . "</td>"; } ?>
Resultado ...
Grabado UTF-8 |
4E | 69 | C3B1 | 6F | 2C | 20 | C2BF | 63 | 75 | C3A1 | 6E | 74 | 6F | 73 |
Código fuente para mostrar visualización Windows 1252 ...
<?php // Visualizado en Windows 1252 for ($i=0; $i<strlen($txt1); $i++) { $c = substr($txt1, $i, 1); // Como esta página se muestra en Windows-1252 (o ISO-8859-1) // al convertir el caracter a UTF-8 y luego mostrarlo // normalmente en la página, tiene el efecto de que un caracter // codificado en UTF-8 se muestre como Windows-1252 // Por ejemplo, el caracter "¿" es el 0xBF en Windows-1252, que // al convertirlo a UTF-8 es el 0xC2BF, que al mostrar un 0xC2BF en // Windows-1252 aparece los caracteres "¿". echo "<td>" . iconv("Windows-1252", "UTF-8", $c) . "</td>"; } ?>
Resultado ...
Visualizado Windows 1252 |
N | i | ñ | o | , | ¿ | c | u | á | n | t | o | s |
Otro Ejemplo: 04.Grabado en Windows-1252. Presentado en DOS-850
Código fuente para mostrar codificación Windows 1252 ...
<?php // Grabado en Windows 1252 for ($i=0; $i<strlen($txt1); $i++) { $c = substr($txt1, $i, 1); // Código hexadecimal del caracter en Windows 1252 $c_hex = strtoupper(dechex(ord($c))); echo "<td>" . $c_hex . "</td>"; } ?>
Resultado ...
Grabado Windows 1252 |
4E | 69 | F1 | 6F | 2C | 20 | BF | 63 | 75 | E1 | 6E | 74 | 6F | 73 |
Código fuente para mostrar visualicación DOS-850 ...
<?php // Visualizado en DOS-850 for ($i=0; $i<strlen($txt1); $i++) { $c = substr($txt1, $i, 1); // Se considera como si el caracter estuviera codificado en CP850 y se toma el correspondiente en UTF-16 // Por ejemplo, el caracter "¿" guardado en Windows 1252 es 0xBF hexadecimal. // Este código interpretado en CP-850 es el caracter 0x2510 en UNICODE y UTF-16 (9488 en decimal), y es un caracter no imprimible en Windows 1252 // (Ver tabla DOS-850 en la Wikipedia) // UTF-16 aporta 4 caracteres, por lo que se coge el 1ro y el 2do, // (la "LE" de "UTF-16LE" quiere decir "little endian", o sea, los bits de menor peso primero // o sea, 0x2510 se lee como 4 bytes de valores: 0x10 0x25 0x00 0x00 en este orden) // y se representa directamente en decimal con el formato de html ddd; $c_850_utf16 = iconv("CP850", "UTF-16LE", $c); $c1 = substr($c_850_utf16, 0, 1); $c2 = substr($c_850_utf16, 1, 1); $c_dec = ord($c2) * 256 + ord($c1); echo "<td>" . "" . $c_dec . ";" . "</td>"; } ?>
Resultado ...
Visualizado DOS-850 |
N | i | ± | o | , | ┐ | c | u | ß | n | t | o | s |
Ojo con el "Little endian" y "Big endian"
Como veréis en el código, la conversión a "UTF-16" se ha forzado al código "UTF-16LE" donde "LE" quiere decir "Little Endian", o sea, los bits menos significativos se leen primero.
Esto es, un valor UNICODE 0x2510 se lee en "UTF-16LE" como 4 bytes en el siguiente orden: 0x10 0x25 0x00 0x00
También se podría haber usado "UTF-16BE" (Big Endian).
En todo caso lo mejor es no dejarlo al azar, o sea, evitar la codificación directa a UTF-16 sin especificar LE ni BE, porque
cada servidor puede operar de manera diferente al ejecutar la función "iconv" (como me pasó a mí la subir la página al servidor después de haberla probado en local).
... y se acabó ...
Bueno... Pues aquí lo dejo. Espero que haya sido de utilidad para alguno de vosotros.