Limpiar texto que viene de otro sitio

Los caracteres invisibles, la puntuación sustituida, los finales de línea, las formas de normalización y las reglas de mayúsculas que hacen que el texto pegado se comporte mal, y el orden en el que hay que arreglarlos.

El texto que llega de un PDF, de una celda de una hoja de cálculo, de un campo de un CMS, de un cliente de correo o de una aplicación de chat arrastra las decisiones de formato de lo que sea que lo produjo, y casi ninguna de esas decisiones se ve en pantalla. El resultado es una cadena que parece correcta, se imprime correctamente y luego falla en una comparación, en una búsqueda, en un análisis de JSON o en una consulta a la base de datos sin motivo aparente.

Caracteres que no se ven

Para Unicode son caracteres de verdad. Sencillamente no tienen forma visible, o tienen la misma forma que otra cosa.

CarácterPunto de códigoDe dónde suele venirQué rompe
Espacio de no separaciónU+00A0  en HTML, Word, maquetación de PDFdividir por un espacio, las coincidencias exactas
Espacio estrecho de no separaciónU+202FTipografía francesa, fechas venidas de Wordlo mismo, de forma menos evidente
Espacio ideográficoU+3000Métodos de entrada CJKparece un hueco ancho
Espacio de ancho ceroU+200BPistas de salto de línea de un CMS, texto web copiadoinvisible, y no cuenta como espacio en blanco
No unificador / unificador de ancho ceroU+200C, U+200DTexto persa, textos índicos, secuencias de emojilos límites de palabra, los recuentos de caracteres
Unificador de palabrasU+2060Herramientas de composición tipográficanada visible, todo lo textual
Guion blandoU+00ADDivisión de palabras de Word, exportaciones de PDFuna palabra deja de coincidir consigo misma
Marca de orden de bytes (BOM)U+FEFFLos primeros bytes de un archivo UTF-8el primer campo de la primera fila
Marca de izquierda a derecha / de derecha a izquierdaU+200E, U+200FContenido bidireccionalmarcas sueltas alrededor de los números
Separador de línea y de párrafoU+2028, U+2029Algunas apps de Mac y de maquetaciónla división en líneas, los analizadores de JavaScript antiguos

El motivo por el que falla una búsqueda es que una búsqueda compara puntos de código, no formas. Si un PDF te dio New U+00A0 York y tú escribes New York con un espacio normal U+0020, las dos cadenas son distintas y nada te va a decir por qué.

"New York".includes("New York")   false, the gap is U+00A0
"  text​".trim().length      5, the zero-width space survives

Un espacio de no separación cuenta como espacio en blanco para la mayoría de funciones de recorte y para \s en la mayoría de motores de expresiones regulares, así que desaparece en los extremos de una cadena y sobrevive en el medio, justo donde una división espera un espacio normal. Un espacio de ancho cero no es espacio en blanco en ninguna parte, así que recortar y colapsar lo dejan intacto. Es también la razón por la que el extractor de correos electrónicos puede no devolver nada a partir de texto copiado de un cliente de correo: un solo carácter de ancho cero dentro de la dirección impide que el patrón coincida.

El detector de caracteres ocultos muestra lo que hay de verdad, y el escapador Unicode da los puntos de código exactos de un valor concreto. Aun así, no elimines todo a la primera: un unificador de ancho cero dentro de una secuencia de emoji y un no unificador de ancho cero en persa o en devanagari son contenido, no ruido.

La puntuación que te cambió el procesador de textos

La autocorrección sustituye caracteres por sus versiones tipográficas mientras escribes, y la sustitución acompaña al texto cuando sale del documento.

Lo que escribisteLo que tienes ahoraPunto de código
'comilla simple derechaU+2019
" y "comillas dobles izquierda y derechaU+201C, U+201D
- entre palabrassemirraya o rayaU+2013, U+2014
...puntos suspensivosU+2026
- delante de un númerosigno menos o guion de no separaciónU+2212, U+2011

Eso está bien en prosa y es fatal en cualquier cosa que analice una máquina.

{ “name”: “Ada” }      unexpected token, only U+0022 is a JSON string quote
git commit -m “fix”    the shell sees three words, not one quoted argument
WHERE name = ‘Ada’     SQL syntax error near ‘
10\u201320                  not a number range, U+2013 is not a hyphen

En CSV el fallo es más silencioso. Un analizador reconoce solo la comilla doble recta como delimitador de campo, así que un valor que un procesador de textos envolvió en comillas tipográficas se trata como si no estuviera entrecomillado; cualquier coma que haya dentro parte entonces la fila y todas las columnas posteriores se desplazan. Una columna cuyos negativos usan U+2212 se importa como texto, y las sumas la excluyen sin avisar.

Buscar y reemplazar con una lista corta de sustituciones es la solución, pero aplícala solo a texto que vaya a acabar en código, en un CSV o en una clave. Pasarla sobre prosa que vas a publicar aplana una puntuación que era deliberada.

Finales de línea y espacios sobrantes

Las herramientas de Windows terminan una línea con CR LF (U+000D U+000A), las de Unix con LF a secas, y unas pocas exportaciones muy antiguas de Mac siguen usando CR a secas. La mayoría de analizadores lo llevan bien. Una división ingenua, no.

"UK\r\n" split on "\n"  ->  "UK\r"
"UK\r" === "UK"             false
"UK\r".length               3

Dos cadenas que se muestran idénticas en una tabla, en un registro o en una vista de diferencias pueden diferir en un retorno de carro, un espacio final o un tabulador. Cuando la comparación de textos marca una línea como cambiada y no se ve ningún cambio, esa es la explicación. También es lo que mete un espacio suelto dentro de las comillas cuando una columna pegada pasa por la herramienta de entrecomillar líneas o por la de unir líneas.

Normaliza en una sola pasada, tratando CR LF y un CR suelto a la vez (\r\n? sustituido por \n); si no, una sustitución en dos pasos duplica las líneas en blanco.

Una letra, dos formas de escribirla

Unicode permite que una letra acentuada sea un único punto de código o una letra base seguida de una marca combinante. Las dos son correctas, y no son iguales.

Formaé se almacena comoUnidades de código en JavaScript
NFC (compuesta)U+00E91
NFD (descompuesta)U+0065 U+03012

macOS ha entregado históricamente nombres de archivo descompuestos, y varios generadores de PDF y métodos de entrada emiten texto descompuesto, así que un valor procedente de una de esas fuentes no coincidirá con ese mismo valor escrito en un teclado.

"é" === "é"                  false
"é".normalize("NFC") === "é" true

Los efectos secundarios van más allá de la igualdad. Una ordenación que compara puntos de código coloca las formas descompuestas junto a la e simple y las compuestas muy lejos, así que una lista de nombres vuelve partida en dos grupos. Los recuentos de caracteres difieren entre las dos formas, lo que importa con un límite de 280 caracteres o con una columna VARCHAR(50), y las estadísticas de texto dan cifras distintas para lo que parece el mismo texto. Truncar es peor: cortar una cadena descompuesta a una longitud fija puede partir entre una letra base y su acento, y dejar ese acento pegado a lo que venga después, así que truncar texto sin normalizar puede producir un último carácter visiblemente erróneo.

Las formas de compatibilidad, NFKC y NFKD, van más allá y pliegan caracteres que simplemente se parecen a otros: la ligadura U+FB01 se convierte en fi, la de ancho completo U+FF21 se convierte en A, el signo micro U+00B5 se convierte en la mu griega U+03BC y el superíndice ² se convierte en un 2 normal. Eso es excelente para una clave de búsqueda o de deduplicación y destructivo para cualquier cosa que muestres, porque se convierte sin avisar en x2.

La regla práctica: NFC para almacenar y mostrar, NFKC solo para claves que nadie llega a ver.

Cambiar de caja no es una sola operación

Pasar a mayúsculas no es una correspondencia carácter a carácter, y no es independiente de la configuración regional.

  • La ß alemana U+00DF pasa a mayúsculas como SS, así que la cadena se alarga y volver a minúsculas no devuelve el original.
  • La sigma griega pasa a minúscula como ς al final de una palabra y como σ en el resto de posiciones, lo que vuelve a romper la ida y vuelta.
  • El turco y el azerbaiyano tienen una ı sin punto U+0131 y una mayúscula İ con punto U+0130. En esas configuraciones regionales, I pasa a minúscula como ı e i pasa a mayúscula como İ.

Ese último es el fallo clásico en producción. Un código que pasa a minúsculas el nombre de una cabecera, una extensión de archivo o una cadena de protocolo funciona en todas partes hasta que se ejecuta en una máquina cuya configuración regional por defecto es la turca, donde "FILE" pasa a minúsculas como fıle y deja de coincidir con file. En Java y .NET los métodos sin argumentos usan la configuración regional por defecto de la máquina, así que toUpperCase() y ToUpper() son la trampa; indica una configuración regional invariante para todo lo que vaya a leer una máquina. Para comparar, el plegado de caja (casefold() en Python) es mejor que pasar a minúsculas, porque trata la ß como ss.

El título con mayúsculas inglés no tiene una definición única, y por eso «poner en mayúscula cada palabra» produce «The Lord Of The Rings» e «IPhone». La mayoría de guías de estilo ponen en mayúscula la primera y la última palabra y todo lo demás salvo los artículos, las conjunciones coordinantes y las preposiciones cortas, mantienen en mayúscula las dos mitades de un compuesto con guion y no tocan nunca las siglas ni los nombres con mayúsculas internas como McDonald, O'Brien o iPhone.

Los estilos de nomenclatura de programación tienen su propio problema de límites. Convertir HTTPResponseCode a snake case depende por completo de cómo trate el divisor una racha de mayúsculas; el conversor de mayúsculas da http_response_code, y uno ingenuo da h_t_t_p_response_code.

Un orden que funciona

Cada paso da por hecho que el anterior ya se ha ejecutado. Fuera de orden, se estorban entre sí.

  1. Resuelve la codificación. Un mojibake como café significa que los bytes se decodificaron con la codificación equivocada, así que vuelve a decodificar los bytes originales en vez de parchear los síntomas. Elimina un BOM solo al principio del todo del texto.
  2. Normaliza los finales de línea a LF en una sola pasada.
  3. Elimina los caracteres de formato que hayas considerado ruido: guiones blandos, espacio de ancho cero, unificador de palabras, marcas bidi. Hazlo antes de normalizar, porque NFC no puede componer una letra base y su acento si hay un carácter invisible entre medias.
  4. Sustituye la puntuación parecida, si el destino es código, CSV, JSON o una clave.
  5. Aplica la normalización Unicode, con NFC por defecto.
  6. Ahora ocúpate de los espacios en blanco. Convierte a U+0020 los espacios exóticos que queden, colapsa las rachas y después recorta. Hacer esto antes del paso 3 deja espacios dobles allí donde un espacio de no separación se juntó con uno normal, y recortar antes del paso 2 deja un retorno de carro pegado al último valor de cada línea.
  7. Cambia la caja al final, indicando la configuración regional de forma explícita.

Las operaciones a nivel de palabra también van después del paso 3. Dividir texto con la herramienta de división mientras quedan guiones blandos da recuentos inflados y palabras a medias, y lo mismo vale para cualquier cosa que busque términos, incluida la de censurar texto.

Cuando un valor sigue negándose a coincidir después de todo eso, deja de mirarlo. Imprime su longitud, escápalo a puntos de código y compara directamente las dos formas escapadas; ahí la diferencia se ve de una forma que nunca se ve en pantalla.