Limpieza del texto que las personas pegan en tu aplicación
Limpia el texto pegado en formularios web con normalización Unicode, eliminación de caracteres de formato, colapso de espacios y manejo seguro de ZWJ y NBSP.
El texto que se pega en un formulario web suele arrastrar caracteres invisibles que nadie escribió y que ninguna fuente dibuja: espacios de ancho cero, guiones opcionales, marcas de orden de bytes y espacios de no separación, todos los cuales sobreviven hasta tu base de datos y rompen silenciosamente las comparaciones de coincidencia exacta, la validación de longitud y la búsqueda.
Si alguna vez has perseguido este bug, sabes cómo va la historia. Alguien pega un párrafo de su CV desde un procesador de texto en un campo de una solicitud de empleo, el campo se ve completamente normal en pantalla y tu validador lo rechaza. O se guarda sin problemas y luego nadie consigue volver a encontrar el registro, porque el nombre en el índice contiene un carácter que la caja de búsqueda no tiene forma de escribir.
Este artículo muestra qué es lo que realmente acaba en el campo, explica por qué parchear código de carácter por código de carácter nunca converge, y te ofrece una función de limpieza de cuatro pasos que resuelve toda la categoría, incluidos los caracteres invisibles que no debes eliminar.
Puntos clave
- Los caracteres invisibles que llegan con el texto pegado pertenecen a la categoría general Unicode Format, escrita
\p{Cf}en una expresión regular de JavaScript, de modo que una única coincidencia de categoría sustituye a una lista de puntos de código individuales que no deja de crecer. normalize("NFC")corrige la escritura, no la invisibilidad: hace que dos codificaciones de la misma letra acentuada se comparen como iguales y deja el espacio de ancho cero exactamente donde estaba.- El espacio de no separación en U+00A0 es un carácter de espacio, no un carácter de formato, así que sobrevive intacto a una eliminación con
\p{Cf}y debe gestionarse en el paso de colapso de espacios en blanco. - U+200D ZERO WIDTH JOINER cumple una función real: une las partes de un emoji de varias personas en un solo glifo, y los conectores (joiners) tienen significado ortográfico en la escritura árabe y en las escrituras índicas.
\p{...}solo significa una propiedad Unicode cuando la expresión regular lleva el flaguov; sin uno de ellos es un escape de identidad para la letra literal p.
¿Qué caracteres invisibles llegan realmente al campo?
Una cadena pegada desde un procesador de texto o un editor de texto enriquecido suele mezclar sustituciones tipográficas que puedes ver con caracteres de formato que no puedes ver. Las comillas curvas, las rayas y semirrayas, y el carácter de puntos suspensivos son visibles y en general inocuos. El espacio de no separación, el guion opcional, el espacio de ancho cero y la marca de orden de bytes no son visibles en absoluto, y son los que rompen las comprobaciones de igualdad.
Vuelca la cadena a puntos de código y el argumento se sostiene por sí solo:
function inspect(str) {
return [...str]
.map((ch) => ch.codePointAt(0))
.filter((cp) => cp > 0x7f)
.map((cp) => "U+" + cp.toString(16).toUpperCase().padStart(4, "0"));
}
const pasted = "\uFEFFSenior\u00A0Engineer\u200B, 2019\u20132024";
console.log(inspect(pasted)); // [ 'U+FEFF', 'U+00A0', 'U+200B', 'U+2013' ]
console.log(pasted.length); // 28, for 26 characters a human would count
Expande la cadena con el operador de propagación en lugar de llamar a split(""): el iterador de cadenas devuelve puntos de código completos, mientras que split("") corta los caracteres astrales por la mitad en el límite UTF-16.
Para ver qué hay realmente en una cadena que tengas a mano, pégala en el limpiador de caracteres invisibles y lee los puntos de código resultantes.
Esta categoría de fallos se define precisamente por ser invisible. El campo se renderiza correctamente, así que una captura de pantalla del bug no muestra nada anómalo y quien lo reporta no puede describir qué hizo de forma distinta. La repetición de sesiones (session replay) es una de las pocas técnicas que llega a sacarlo a la luz, porque las repeticiones de formularios abandonados muestran el pegado y después el bucle de reintentos: alguien borrando un campo y volviendo a escribir un valor que parecía idéntico al que acababa de ser rechazado.
¿Por qué una lista de bloqueo de caracteres de ancho cero no deja de crecer?
Una clase de caracteres con puntos de código específicos es una solución con la forma equivocada, más que una solución mal escrita, porque solo puede contener los caracteres sobre los que alguien ya ha abierto un bug. Eliminas U+200B tras el primer reporte, añades U+FEFF cuando se rompe una importación CSV, añades U+00AD cuando un apellido con guion deja de coincidir, y la clase sigue creciendo porque enumera los miembros de un conjunto en lugar de nombrar el conjunto.
El conjunto tiene un nombre. El espacio de ancho cero (U+200B), el no-conector de ancho cero (U+200C), el conector de ancho cero (U+200D), el guion opcional (U+00AD) y la marca de orden de bytes (U+FEFF) llevan todos General_Category=Cf, según la Unicode Character Database, y esas asignaciones se han mantenido estables a lo largo de muchas versiones del estándar. El espacio de no separación en U+00A0 no está en ese conjunto: es Zs, un separador de espacio, razón por la cual una eliminación por categoría por sí sola deja intacto el artefacto de procesador de texto más habitual.
Normalizar, eliminar, colapsar, recortar
La solución es una sola pasada con cuatro pasos ordenados: normalizar la codificación, eliminar los caracteres de formato, colapsar cada variante de espacio en blanco a un espacio ordinario y, por último, recortar.
const FORMAT_CHARS = /[\p{Cf}--[\u200C\u200D]]/gv;
const SPACE_RUN = /[\p{Zs}\t\n\r]+/gu;
export function cleanPastedText(input) {
return input
.normalize("NFC") // one canonical spelling per character
.replace(FORMAT_CHARS, "") // BOM, zero-width space, soft hyphen, bidi controls
.replace(SPACE_RUN, " ") // NBSP, thin spaces, tabs, newlines -> one space
.trim();
}
Quita \n\r de SPACE_RUN si el campo es un textarea donde los saltos de línea forman parte del contenido.
Dos detalles se ganan su lugar aquí. \p{...} solo tiene su significado Unicode cuando la expresión regular está en modo consciente de Unicode; omite tanto el flag u como el v y el motor leerá \p como una p literal escapada, de modo que el patrón compila, se ejecuta y no coincide con nada de lo que pretendías. Y el paso de colapso usa una clase explícita construida sobre \p{Zs} en lugar de \s, así queda claro en el punto de uso que U+00A0 está cubierto.
El orden importa. normalize("NFC") resuelve la codificación, no la invisibilidad, así que deja el espacio de ancho cero en su sitio para que lo gestione el paso de eliminación. Eliminar antes de colapsar significa que la marca de orden de bytes se borra en lugar de convertirse en un espacio suelto. Y recortar al final captura el espacio inicial que queda cuando un carácter de formato eliminado estaba junto a uno.
Qué no eliminar
Eliminar a ciegas todos los caracteres de formato daña contenido real. U+200D ZERO WIDTH JOINER es el carácter que une los emojis en un único glifo: en el estándar de emoji de Unicode, lo que convierte una cadena en una secuencia ZWJ de emoji es, precisamente, la presencia de un conector, de modo que quitar los conectores deja varios glifos donde antes había uno.
const family = "👨👩👧";
console.log([...family].length); // 5
console.log([...family.replace(/\p{Cf}/gu, "")].length); // 3 -> 👨👩👧
Los conectores tampoco son decorativos fuera del ámbito de los emojis. Quienes escriben en árabe colocan un no-conector entre dos letras para evitar que se unan de la forma cursiva en que lo harían de otro modo, y la especificación básica de Unicode advierte de que un texto al que se le han quitado estos controles o dice otra cosa o deja de tener sentido. En devanagari, un ZWJ después de un virama selecciona la media forma de una consonante en lugar del conjunto completo. La regla es eliminar los caracteres invisibles que no aportan significado en tu campo y conservar los que hacen un trabajo estructural.
Eso es lo que expresa [\p{Cf}--[\u200C\u200D]]: el flag v añade operadores de conjuntos a las clases de caracteres, y -- es el que resta. En un runtime sin unicodeSets, el equivalente en modo u es /(?![\u200C\u200D])\p{Cf}/gu. No establezcas ambos flags en una misma expresión regular; son mutuamente excluyentes.
¿Por qué deberías usar NFC y no NFKC?
NFC resuelve las dos formas en que Unicode puede escribir el mismo carácter y no cambia nada más. NFKC va más lejos y reescribe los caracteres de compatibilidad: la ligadura ff se convierte en dos f y una Ⓓ encerrada en un círculo se convierte en una D normal, como muestran los ejemplos de normalize(). Esa es una decisión que modifica el contenido, definida por el anexo de normalización de Unicode como equivalencia de compatibilidad y no canónica, y conviene tomarla de forma deliberada en lugar de heredarla como efecto secundario de limpiar un campo de formulario.
Una trampa relacionada: borrar un guion opcional es tarea de tu limpiador, no de normalize("NFKC"). La operación Unicode que mapea U+00AD a una cadena vacía es NFKC_Casefold, una transformación distinta de la que realiza String.prototype.normalize("NFKC").
Ejecuta el limpiador también en el servidor
Limpiar en el navegador es una cortesía hacia quien está escribiendo; la normalización de la que dependen tu base de datos y tu índice de búsqueda tiene que ejecutarse en el servidor, porque se puede enviar una petición sin haber cargado nunca tu página. Todo lo que llega a través de una API pública, una importación CSV, un webhook o un cliente móvil se salta por completo el manejador de entrada, y una sola fila sin limpiar basta para que una restricción de unicidad o una búsqueda por coincidencia exacta se comporte de forma inconsistente. La misma función funciona en Node y en el navegador, así que ejecútala en la frontera por donde los datos entran a la capa de persistencia, y deja que la llamada del lado del cliente sea la retroalimentación rápida, no la garantía.
Deja de añadir puntos de código a una clase de caracteres y empieza a nombrar la categoría. Cuatro líneas, aplicadas en cada punto de entrada, eliminan los caracteres invisibles que no aportan significado en tu campo, mientras conservan los que mantienen unido el texto real. Escribe un fixture que mezcle un acento combinante, un espacio de no separación, un guion opcional, una marca de orden de bytes, un espacio de ancho cero y un emoji con ZWJ, y después comprueba que tu limpiador compone el primero, colapsa el segundo a un espacio normal, borra los tres siguientes y devuelve el emoji sin cambios.
Preguntas frecuentes
¿trim elimina un espacio de no separación o un espacio de ancho cero?
trim elimina espacios en blanco y terminadores de línea únicamente en ambos extremos, y la lista de espacios en blanco de ECMAScript incluye el espacio de no separación U+00A0 y la marca de orden de bytes U+FEFF, así que ambos desaparecen en los bordes. Nunca elimina el espacio de ancho cero U+200B, que es un carácter de formato y no un espacio en blanco, y nunca toca ninguno de estos caracteres en medio de una cadena.
¿Una eliminación de caracteres de formato borra también los selectores de variación de emoji como U+FE0F?
No. Los selectores de variación U+FE00 a U+FE0F llevan General_Category Mn, marca sin espaciado, no Cf, así que una eliminación por categoría de formato los deja en su sitio y los emojis conservan su presentación prevista. Solo los conectores necesitan una lista de conservación. Ampliar un limpiador para eliminar también las marcas borraría los selectores de variación y, con ellos, todos los acentos combinantes.
¿Eliminar caracteres de formato borra también los caracteres de anulación de derecha a izquierda?
Sí. Los controles bidireccionales, incluidos U+200E, U+200F, las incrustaciones y anulaciones de U+202A a U+202E, y los aislantes de U+2066 a U+2069, llevan todos General_Category Cf, así que una única coincidencia de categoría los elimina junto con el espacio de ancho cero. Eso importa en nombres visibles y nombres de archivo, donde una anulación de derecha a izquierda invierte el texto renderizado y puede disfrazar una extensión.
¿Por qué un valor pegado incumple maxlength cuando parece lo bastante corto?
maxlength cuenta unidades de código UTF-16, no caracteres visibles, así que cada pasajero invisible consume presupuesto: una marca de orden de bytes o un espacio de ancho cero cuesta una unidad, y un emoji construido a partir de un par surrogado cuesta dos. Limpia el valor antes de validar su longitud, y cuenta con la forma expandida (spread) si el límite pretende coincidir con lo que la gente ve.
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k