FlowingDev

Los fantasmas ocultos de Unicode: Los caracteres secretos que causan estragos en tu texto

Descubre cómo los caracteres Unicode invisibles y las letras de apariencia idéntica pueden romper tu código, causar riesgos de seguridad y crear bugs exasperantemente sutiles.

Probar la herramienta: Inspector de Texto Unicode

En una frase

Unicode es el estándar universal para texto que hace posible nuestra internet global, pero su inmensidad incluye caracteres invisibles, caracteres de apariencia idéntica y otras rarezas que pueden convertir un string aparentemente simple en un campo minado de bugs.

El problema que resuelve

En la sopa primordial de la computación, la vida era simple. Teníamos ASCII. Nos dio 127 caracteres: letras mayúsculas, letras minúsculas, números, puntuación y algunos códigos de control. Era limpio, ordenado y cabía perfectamente en un solo byte. También era desesperadamente anglocéntrico. Si querías escribir ¿Qué pasa?, 你好 o спасибо, estabas frito.

Esto llevó a una era caótica conocida como el "infierno de los codepages". Las computadoras en diferentes regiones usaban distintos conjuntos de caracteres de 8 bits que extendían ASCII. Un documento escrito en Windows-1252 (Europa Occidental) se convertía en un desastre ilegible (lo que cariñosamente llamamos mojibake) al abrirse en un sistema que usaba KOI8-R (ruso). Compartir texto internacionalmente era como jugar al teléfono descompuesto.

Entonces, el Consorcio Unicode llegó al rescate en un caballo blanco. Su misión: un único conjunto de caracteres unificado para todos los sistemas de escritura, modernos e históricos. Un estándar para gobernarlos a todos. Cada caracter —desde la 'A' hasta el '€' y el emoji 'Montón de Caca' (💩)— recibiría su propio número único, un "code point".

Fue un logro monumental que impulsa nuestro mundo moderno. Pero esta gran unificación creó su propio conjunto de problemas maravillosamente nerds. Para dar cabida a las complejidades del lenguaje humano, Unicode tuvo que incluir más que solo letras visibles. Necesitaba:

  • Caracteres de combinación: Una tilde (´) que es un caracter separado, diseñado para colocarse sobre otro (e).
  • Caracteres de ancho cero: Marcadores invisibles que pueden sugerir un salto de línea (U+200B Zero-Width Space) o unir emojis (U+200D Zero-Width Joiner).
  • Caracteres ambiguos: Docenas de diferentes tipos de espacios, guiones y comillas.
  • Caracteres de apariencia idéntica (homógrafos): La letra latina a y la letra cirílica а se ven idénticas en muchas fuentes, pero para una computadora, son tan diferentes como a y b.

De repente, lo que ves no es lo que obtienes. Un string que parece "gato" podría contener un caracter invisible, haciendo que su longitud sea 4, no 3. El nombre de una variable safeString podría estar ocultando una 'α' griega en lugar de una 'a' latina. Este es el problema que resuelve un inspector de texto: se pone unas gafas de rayos X para mostrarte la verdad cruda y sin filtros de lo que tu string está realmente compuesto, revelando los fantasmas ocultos en la máquina.

Cómo funciona por debajo

Para diseccionar un string, necesitamos entender sus tres capas fundamentales: el caracter abstracto, su representación en bytes y las cosas raras que se esconden en el medio.

Code Points: La Dirección de un Caracter

En esencia, Unicode es solo una lista gigante. Cada caracter tiene asignado un número único llamado code point. Esta es la dirección permanente del caracter en el universo Unicode. Los escribimos usando la notación U+XXXX, donde XXXX es un número hexadecimal.

  • U+0041 es A (Letra Latina A Mayúscula)
  • U+00E9 es é (Letra Latina e Minúscula con Acento Agudo)
  • U+20AC es € (Símbolo del Euro)
  • U+1F4A9 es 💩 (Montón de Caca)

Un code point es una idea abstracta. No es un byte ni una fuente. Es solo un número mapeado a un caracter. Cómo almacenamos ese número es otra historia.

Encodings: Almacenando Code Points en Bytes

No puedes guardar un "code point" en un archivo. Tienes que guardar bytes. Un encoding es un conjunto de reglas para convertir una secuencia de code points en una secuencia de bytes.

El rey de los encodings hoy en día es UTF-8. Su genialidad radica en su diseño de longitud variable.

  • Para cualquier caracter que también esté en el conjunto ASCII original (como A, U+0041), UTF-8 usa un solo byte, exactamente el mismo que usaba ASCII. Esto lo hizo retrocompatible y fácil de adoptar.
  • Para otros caracteres, usa una secuencia de 2, 3 o 4 bytes. Los primeros bits de cada byte actúan como señales, diciéndole a la computadora cuántos bytes forman parte del caracter actual.

Veamos ¡Hola!:

Caracter Code Point Bytes UTF-8 (Hex)
¡ U+00A1 C2 A1
H U+0048 48
o U+006F 6F
l U+006C 6C
a U+0061 61
! U+0021 21

Un inspector de texto realiza este proceso a la inversa. Lee los bytes crudos de tu string, los interpreta según un encoding (generalmente UTF-8) y te muestra la secuencia de code points que lo componen.

Los Alborotadores Invisibles

Aquí es donde se pone divertido. El trabajo principal de un inspector de texto es iluminar los caracteres que no parecen ser nada.

Categoría Caracter de Ejemplo y Code Point El Propósito Retorcido
Espacio de ancho cero U+200B No parece nada. Un caracter invisible que sugiere un buen lugar para un salto de línea en una palabra larga o URL.
Unidor de ancho cero U+200D El pegamento mágico para los emojis. 👨 + ZWJ + 👩 + ZWJ + 👧 = 👨‍👩‍👧. Une caracteres que normalmente no se conectarían.
Espacio de no separación U+00A0 Parece un espacio normal, pero prohíbe un salto de línea. Útil para cosas como 100 km o Dr. Strange.
Marca de combinación U+0301 (Combining Acute Accent) Una tilde (´) que es un caracter por sí mismo. Se dibuja sobre el caracter anterior.
Apariencia idéntica (Homógrafo) U+0430 (Cyrillic Small Letter A) Se ve idéntico a la a latina (U+0061) en la mayoría de las fuentes, pero es un code point completamente diferente.

Esto nos lleva al concepto de normalización. El caracter é puede representarse de dos maneras:

  1. Compuesta (NFC): Un solo code point, U+00E9.
  2. Descompuesta (NFD): Dos code points, e (U+0065) seguida de la tilde combinada ´ (U+0301).

Visualmente, son idénticos. Pero para una computadora que hace una comparación simple de byte por byte, "\u00E9" no es igual a "e\u0301". Un inspector de texto puede revelar qué forma tienes y ayudarte a convertir entre ellas.

Historias de la vida real

La Catástrofe del Copiar y Pegar

Un dev junior está trabajando hasta tarde, tratando de arreglar un bug. Encuentra una solución en un blog, una sola línea de JavaScript: const timeout = 100;. La copia, la pega en su editor de código y guarda. La compilación de toda la aplicación falla con un críptico SyntaxError: Invalid or unexpected token.

Se queda mirando la línea. Es perfecta. La reescribe manualmente. Funciona. Pega la línea copiada de nuevo. Se rompe. ¿Se está volviendo loco? Después de una hora de arrancarse los pelos, un dev senior entrecierra los ojos, mira la línea y dice: "Pega eso en un inspector de texto".

El resultado: const[U+00A0]timeout[U+00A0]=[U+00A0]100;. El CSS del blog había embellecido el código, reemplazando los espacios estándar (U+0020) con espacios de no separación (U+00A0). Se ven idénticos, pero el motor de JavaScript no tiene idea de qué es un "espacio de no separación" en ese contexto.

Lección: El texto copiado de la web (o de PDFs, o de documentos de Word) es culpable hasta que se demuestre lo contrario. A menudo está contaminado con comillas "inteligentes" (smart quotes), espacios no estándar y otros duendes invisibles.

El Usuario Fantasma que no Podía Iniciar Sesión

Un nuevo usuario se registra en un servicio con el nombre François. El sistema crea la cuenta felizmente. Al día siguiente, François intenta iniciar sesión. Escribe su nombre, presiona enter... "Nombre de usuario o contraseña inválidos". Lo intenta de nuevo, con cuidado. Mismo resultado. Está bloqueado.

En la base de datos, su nombre se guardó usando caracteres descompuestos: F, r, a, n, c, o, i, s y una U+0327 (Cedilla de Combinación). El formulario de inicio de sesión, sin embargo, enviaba el caracter precompuesto ç (U+00E7). Visualmente, c + ¸ es lo mismo que ç. Pero el servidor estaba haciendo una comparación de strings simple: François (descompuesto) no es igual a François (compuesto). La consulta WHERE username = '...' falló.

Lección: Siempre normaliza la entrada del usuario a una forma consistente (NFC es la opción más común) antes de guardarla en una base de datos o realizar comparaciones.

El Dominio Engañoso

Un empleado recibe un correo electrónico que parece ser del departamento de TI de su empresa. "Actualización de Seguridad Requerida: Por favor, inicie sesión en microsоft.com/update para proteger su cuenta". El enlace parece legítimo. El nombre de dominio está ahí mismo. Hace clic, ingresa sus credenciales en una página que se ve exactamente como la real, y sigue con su día.

Acaba de ser víctima de phishing. El dominio no era microsoft.com. Era microsоft.com. La segunda 'o' no era la 'o' latina (U+006F), sino la 'o' cirílica (U+043E). Esto es un Ataque Homógrafo de IDN. Para el ojo humano, es una falsificación perfecta. Para el sistema DNS, es una dirección completamente diferente que lleva al servidor del estafador.

Lección: Desconfía profundamente de los identificadores que mezclan juegos de caracteres. Aunque los navegadores modernos tienen algunas protecciones, el principio de los ataques homógrafos es una amenaza constante en nombres de usuario, reglas de validación y en cualquier otro lugar donde los strings se usen por seguridad.

Errores y trampas comunes

  • Asumir que string.length cuenta caracteres. En muchos lenguajes (como JavaScript), cuenta unidades de código (code units), no los caracteres que percibimos. Por ejemplo, "👍🏽".length es 4 en JS, porque está compuesto por el emoji "pulgar hacia arriba" (👍, 2 unidades) y el "modificador de tono de piel medio" (🏽, 2 unidades).
  • Tratar todos los espacios en blanco como si fueran iguales. Ejecutar trim() en un string no eliminará un U+200B Zero-Width Space escondido en el medio. Una regex para \s+ podría no capturar el U+00A0 Non-Breaking Space. Tienes que saber qué estás buscando.
  • Ignorar la normalización. Como vimos con François, comparar strings que se ven iguales pero tienen diferentes representaciones de bytes subyacentes es un bug clásico y frustrante. string1.normalize() === string2.normalize() es tu amigo.
  • Confiar en tus ojos. No puedes depurar estos problemas simplemente mirando el texto renderizado. Un inspector de texto que muestra los code points individuales y sus nombres es la única forma de estar seguro de lo que realmente hay ahí.
  • Crear tu propio limpiador de "caracteres malos". Intentar escribir una regex para eliminar todos los caracteres "raros" es una misión inútil. O se te escaparán algunos o, lo que es peor, eliminarás caracteres legítimos necesarios para otros idiomas, destrozando los nombres y el texto de tus usuarios.

Por qué debe estar en tu radar

Deberías usar un inspector de texto Unicode cada vez que el texto se comporte de forma inesperada. Es una herramienta de depuración indispensable. Piensa en él cuando:

  • Una comparación de strings falla cuando "obviamente" debería funcionar.
  • Obtienes un error de sintaxis en una línea de código que parece perfectamente válida.
  • Estás validando la entrada proporcionada por el usuario, como nombres de usuario, correos electrónicos o URLs.
  • Estás trabajando con datos de múltiples sistemas, especialmente si involucran diferentes idiomas.
  • Necesitas entender por qué string.length te está dando un número "incorrecto".
  • Estás construyendo cualquier sistema que necesite ser robusto, seguro y funcionar para una audiencia global.

En resumen, cada vez que una computadora y un humano no están de acuerdo sobre lo que dice un fragmento de texto, la computadora probablemente tiene razón sobre los bytes, y un inspector de texto es tu traductor.

Para profundizar

Teoría lista. Hora de ensuciarse las manos — 100% en tu navegador.

Probar la herramienta: Inspector de Texto Unicode