Limpiar texto desordenado: el orden de las operaciones que sí importa
Publicado el 8/7/2026 · 15 min de lectura · Herramientas de texto e idioma
Daniel Okonkwo — Desarrollador front-end y redactor de Tecnología en OneKitly
Rendimiento web · Formatos de archivo
Verificado con 5 fuentes
La limpieza de texto es un pipeline cuyos pasos no conmutan: las mismas operaciones en otro orden producen otro texto, y el daño es silencioso. Ejecutado en Node, decodificar entidades HTML antes de quitar etiquetas convierte el texto escapado <b> en un elemento <b> real que el limpiador borra después: «escribe <b> para mostrar una etiqueta» sale como «escribe para mostrar una etiqueta». Quitar etiquetas primero y decodificar exactamente una vez reproduce lo que muestra un navegador. Colapsar espacios con /\s+/ antes de decidir el tratamiento de los saltos de línea aplasta tres párrafos en una sola tirada de 80 caracteres, y ningún paso posterior reconstruye las fronteras: colapsa solo los espacios horizontales, con /[^\S\n]+/, después de las uniones. Deduplicar líneas antes de recortarlas no encuentra nada, porque «alpha» y «alpha» con un espacio final son cadenas distintas: cinco líneas siguen siendo cinco; recorta primero y esas mismas cinco bajan a tres. Tres caracteres sobreviven entonces a cualquier pasada ingenua: U+00A0 (reconocido por \s, eliminado por trim), U+200B (reconocido por ninguno) y U+FEFF, que \s sí reconoce pero la propiedad Unicode White_Space no. Arregla el orden, luego los invisibles, y solo después mide.
Quitar etiquetas antes de decodificar entidades, recortar antes de deduplicar, colapsar los espacios al final. Tres órdenes ejecutados en Node, un pipeline de nueve pasos en la secuencia correcta y los caracteres invisibles — U+00A0, U+200B, U+FEFF — que sobreviven a cualquier limpieza ingenua.
Los pasos de limpieza no conmutan
Un limpiador de texto parece un menú: quitar espacios sobrantes, quitar saltos de línea, quitar líneas duplicadas, quitar etiquetas HTML. Cada entrada suena autónoma, así que la gente las marca en el orden en que la interfaz las lista. No son autónomas. Cada paso reescribe la entrada que verá el siguiente, y varias parejas dan un resultado distinto según cuál corra primero. Los fallos son silenciosos: sigues obteniendo texto, sigue pareciendo plausible, y lo que perdiste es justo lo que no estabas mirando.
Tres parejas causan casi todo el daño real: etiquetas contra entidades, espacios contra saltos de línea y deduplicación contra recorte. Cada una se demuestra abajo sobre una cadena real, ejecutada en lugar de razonada. Los nueve pasos ordenados son simplemente lo que se deduce de esas tres restricciones cuando se respetan todas a la vez.
Etiquetas antes que entidades, y decodificar una sola vez
Tomemos un fragmento HTML: <p>Terms &amp; conditions: write <b> to show a bold tag.</p>. Un navegador lo muestra así: Terms & conditions: write <b> to show a bold tag. Las secuencias escapadas son texto: el autor quería un ampersand literal y una etiqueta de negrita literal y visible.
Quita primero las etiquetas y decodifica una vez: obtienes exactamente esa salida del navegador. Decodifica primero y quita después: el <b> ya se ha convertido en un elemento <b> real cuando corre el limpiador, así que el limpiador lo borra. El resultado es «write to show a bold tag», con un espacio doble donde estaba el sujeto de la frase. Quita primero pero decodifica dos veces y obtienes el fallo contrario: «write <b> to show a bold tag» contiene ahora una etiqueta viva que ya nada escapa, y así es como un extracto en texto plano vuelve a ser marcado en cuanto alguien lo pega en una página.
La regla detrás de los tres resultados es corta: una entidad es texto escapado, y decodificar asciende el texto a marcado. Todo lo que trate el marcado de forma especial tiene que correr, por tanto, antes del ascenso. Los navegadores evitan la cuestión tokenizando una sola vez, en una pasada, y por eso un analizador real nunca tiene que decidirlo. Un pipeline de regex sí, y la decisión es: quitar, luego decodificar, exactamente una decodificación.
Los espacios después de los saltos de línea, nunca a través de ellos
Toma un documento de tres párrafos, algunos partidos en varias líneas, con una o tres líneas en blanco perdidas entre ellos. Aplica primero /\s+/ sustituido por un solo espacio, porque «quitar espacios sobrantes» era la primera casilla, y obtienes una tirada de 80 caracteres sin ninguna frontera de párrafo. Nada aguas abajo puede restaurarlas: los saltos de línea que llevaban la estructura eran espacios, y pediste colapsar los espacios.
Pasa el mismo documento por el orden correcto — normalizar finales de línea, recortar cada línea, unir las líneas partidas, colapsar solo las series horizontales con /[^\S\n]+/, y luego reducir tres saltos o más a una línea en blanco — y sale con 58 caracteres en dos párrafos, con la estructura intacta. La diferencia no es que una regex sea mejor que la otra. Es que /\s+/ incluye \n y \r, y una clase como [^\S\n] deliberadamente no.
Hay una segunda ordenación, más sutil, dentro de esta. Toma «Paragraph one text.», una línea que contiene un solo espacio, y «Paragraph two text.». Divide por /\n\n/ y encuentras un párrafo, no dos, porque la línea separadora no está vacía: contiene un espacio. Recorta cada línea primero y la misma división encuentra dos. Por eso el recorte de líneas va antes de cualquier decisión sobre párrafos, mientras que el colapso de series va después: las dos operaciones sobre espacios quedan a lados opuestos del paso de los saltos de línea.
Deduplicar al final, porque la igualdad es una propiedad de aguas abajo
La deduplicación de líneas compara cadenas enteras. Dale las cinco líneas «alpha» con un espacio final, «beta», «alpha», «beta» seguida de un tabulador y «gamma», y no quita nada: cinco líneas entran, cinco salen, porque «alpha» y «alpha» más un espacio son simplemente cadenas distintas, igual que «beta» y «beta» más un tabulador. Recorta primero los espacios finales y la misma deduplicación baja las cinco a tres. Los duplicados siempre estuvieron ahí; eran invisibles exactamente como es invisible un espacio final.
Lo mismo ocurre en el ejemplo trabajado al final de este artículo. Colocada en segundo lugar, justo después de quitar las etiquetas, la deduplicación quita cero líneas. La misma deduplicación colocada en octavo lugar quita una, porque para entonces el espacio de ancho cero ha desaparecido y el espacio doble se ha colapsado, así que las dos líneas que siempre fueron la misma frase por fin son la misma cadena. La deduplicación no encuentra duplicados; la normalización los crea, y la deduplicación luego los recoge.
Una decisión sigue siendo tuya: las mayúsculas. La deduplicación compara exactamente, así que «Total» y «total» son dos líneas. El plegado de mayúsculas es una elección aparte y con pérdida, que merece su propio paso y debe verse en la interfaz en vez de quedar enterrada en el deduplicador — el mismo argumento que vale para las convenciones de nombres, donde la transformación solo es segura si sabes de qué convención partes.
Los caracteres que sobreviven a cualquier limpieza ingenua
Tres puntos de código producen casi todo el residuo. U+00A0, el espacio duro, llega de los procesadores de texto, de las páginas web y de la tipografía francesa y española, donde se coloca antes de dos puntos o después de una comilla de apertura. U+200B, el espacio de ancho cero, llega de los editores de CMS y del texto enriquecido pegado, como pista invisible de corte de línea. U+FEFF, el espacio de ancho cero irrompible, es lo que resulta de decodificar una marca de orden de bytes UTF-8; aparece al principio de los ficheros producidos por exportaciones de hoja de cálculo y por herramientas de Windows, y a veces en medio tras una concatenación ingenua.
Lo que importa para un pipeline de limpieza es cuáles de ellos ven tus herramientas, y la respuesta no es intuitiva. Ejecutado en Node 22: /\s/ reconoce U+00A0 y U+FEFF pero no U+200B. El escape de propiedad Unicode /\p{White_Space}/u reconoce U+00A0 pero no U+FEFF. Discrepan, en ambos sentidos: U+0085, el control NEL, lo reconoce la propiedad Unicode y no /\s/. La razón es que la gramática de ECMAScript define su propia producción WhiteSpace, que añade la marca de orden de bytes por motivos históricos, mientras que la base de caracteres Unicode asigna White_Space con sus propios criterios y no se la da a U+FEFF.
La normalización no sustituye a nada. Aplicar NFKC lleva U+00A0 a un U+0020 normal, lo que es realmente útil, pero deja U+200B exactamente donde estaba: la cadena sigue midiendo un carácter después. Así que el pipeline necesita un paso de borrado explícito para los caracteres de formato — U+200B, U+200C, U+200D, U+FEFF, U+00AD — y un paso de conversión aparte para los espacios exóticos. Ninguno se puede delegar en una regex de espacios, porque para una regex de espacios la mitad de ellos no son espacios.
Un ejemplo desordenado, nueve pasos, antes y después
El ejemplo tiene 126 caracteres y contiene, a propósito, todos los problemas comentados arriba: dos espacios iniciales, finales de línea CRLF, un h2 y tres elementos p, un ampersand doblemente codificado, un espacio de ancho cero pegado al final de una frase, tres saltos de línea seguidos, una frase repetida literalmente, un espacio interno duplicado, un párrafo partido en dos líneas con la continuación indentada, y una marca de orden de bytes más dos espacios justo al final.
Pasado por los nueve pasos en orden, baja a 58 caracteres: una línea de título «Q3 & Q4 report», una línea «Revenue rose 12%.», una línea en blanco, y luego «Costs fell» y «slightly.» como dos líneas de un mismo párrafo. Las longitudes intermedias son 122 tras normalizar los finales de línea, 92 tras quitar las etiquetas, 88 tras la única decodificación de entidad, 86 una vez borrados el espacio de ancho cero y la marca de orden de bytes, 80 tras recortar cada línea, 77 tras el colapso horizontal, 76 tras reducir el triple salto, y 58 tras la deduplicación.
Dos de esos números son el argumento de todo el artículo. La caída de 76 a 58 es la línea duplicada, y solo existe porque los pasos 4 y 8 corrieron antes; mueve la deduplicación al segundo puesto y esa caída vale cero. El paso de 86 a 80 es el recorte línea a línea, y es lo que después permite a la división en párrafos ver una línea separadora realmente vacía. Todo lo demás es contabilidad.
Antes de entregar el texto limpio
Conserva el original. Cada paso de este pipeline es destructivo por diseño, y ninguno es reversible: no puedes recuperar qué espacios eran duros, qué salto de línea era un corte y cuál un párrafo, ni cuál de dos líneas idénticas era la que querías conservar. El texto limpio es un artefacto derivado, y un artefacto derivado nunca debe ser la única copia.
Luego pasa el pipeline dos veces sobre su propia salida. Una limpieza bien ordenada es idempotente: la segunda pasada no debe cambiar nada. Si cambia algo, tienes un paso que no es estable bajo repetición, y en la práctica casi siempre es la decodificación de entidades: la única operación de la lista que puede crearse trabajo nuevo a sí misma. Una comprobación de idempotencia cuesta una línea de código y atrapa la clase de fallo que solo aparece cuando un documento vuelve a pasar por la herramienta seis meses después, porque alguien lo reimportó.
Por último, cuenta solo al final. Cualquier longitud, recuento de palabras o índice de legibilidad tomado a mitad del pipeline mide una cadena que ya no existe, y una longitud depende en particular de qué estés dispuesto a llamar carácter: una cuestión que conviene zanjar aparte antes de fiarte de cualquier cifra que te dé un contador.
| Carácter | Punto de código | /\s/ lo reconoce | /\p{White_Space}/u lo reconoce | .trim() lo elimina |
|---|---|---|---|---|
| Espacio duro | U+00A0 | Sí | Sí | Sí |
| Espacio duro estrecho | U+202F | Sí | Sí | Sí |
| Espacio de ancho cero | U+200B | No | No | No |
| Espacio de ancho cero irrompible, la marca de orden de bytes | U+FEFF | Sí | No | Sí |
| Línea siguiente | U+0085 | No | Sí | No |
| Guion blando | U+00AD | No | No | No |
Preguntas frecuentes
- Si ejecuto el limpiador dos veces, ¿sigue importando el orden?
- Sí, y ejecutarlo dos veces puede empeorar las cosas. La repetición no recrea la información que destruyó un paso anterior: las fronteras de párrafo colapsadas siguen colapsadas por muchas pasadas que hagas. Mientras tanto, la decodificación de entidades no es idempotente: una segunda pasada decodifica &amp; otra vez, convirtiendo un ampersand literal deliberado del texto en uno estructural. La prueba correcta no es ejecutarlo dos veces para mejorar el resultado, sino ejecutarlo dos veces para confirmar que la segunda pasada no cambia nada.
- ¿Por qué .trim() elimina la marca de orden de bytes pero no el espacio de ancho cero?
- Porque trim se define contra la producción WhiteSpace de ECMAScript, no contra la propiedad Unicode White_Space, y esa producción lista explícitamente U+FEFF por razones históricas que vienen de cuando la marca de orden de bytes se encontraba habitualmente al principio de un flujo. U+200B nunca ha estado en esa lista: Unicode lo clasifica como carácter de formato en la categoría general Cf, con el argumento de que marca una oportunidad de corte de línea y no un espacio entre palabras. Así que trim quita uno y no el otro, y ni /\s/ ni trim te ayudarán jamás con U+200B. Bórralo explícitamente.
- ¿Hay alguna buena razón para usar /\s+/ sobre un documento entero?
- Sí, en exactamente una situación: cuando has decidido que la salida es una sola línea y la estructura es irrelevante — una clave de búsqueda, una huella para comparar, un valor que va a una celda CSV de una línea. Ahí, aplanarlo todo a espacios simples es precisamente el objetivo. En cualquier sitio donde la salida vaya a leerla una persona, /\s+/ es la clase equivocada, porque trata el salto de línea que separa dos párrafos y los dos espacios tras un punto como la misma cosa. Úsala a propósito para claves y nunca por defecto para prosa.
- ¿Cómo veo siquiera que hay un carácter invisible?
- Compara la longitud que esperas con la que obtienes, y luego vuelca los puntos de código. Dos cadenas que se ven idénticas en pantalla pueden tener longitudes distintas: esa es toda la pista. Cuando la longitud te sorprenda, lista cada carácter con su punto de código en hexadecimal y el culpable salta a la vista: un U+00A0 donde suponías un U+0020, o un U+200B pegado al final de una frase. Hacerlo una vez sobre una muestra de cada fuente que importas te dirá qué productores de tu cadena emiten qué caracteres, y entonces podrás borrar exactamente esos.
- ¿Debe la deduplicación conservar el orden original de las líneas?
- Casi siempre sí, y debe conservar la primera aparición, no la última. Ordenar para localizar duplicados es una costumbre heredada de los pipelines de línea de comandos, y reordena en silencio un documento cuyo orden llevaba significado: una lista de pasos, un registro de cambios, una transcripción. Conservar la primera aparición también coincide con cómo lee la gente: la línea anterior suele ser la que tiene contexto alrededor. Si una herramienta ofrece ordenar mientras deduplica, trátalo como dos operaciones separadas y pide solo la que quieres.
Artículos que podrían interesarte
Todas las guías →Herramientas relacionadas
Fuentes
- Ecma International — ECMAScript Language Specification — WhiteSpace production and RegExp character class escapes
- Unicode Consortium — Unicode Standard Annex #44: Unicode Character Database — the White_Space and General_Category properties
- Unicode Consortium — Unicode Standard Annex #15: Unicode Normalization Forms (NFC, NFD, NFKC, NFKD)
- MDN Web Docs — String.prototype.trim() and RegExp character classes
- WHATWG — HTML Standard — named character references and the tokenizer
¿Has detectado un error en este artículo?