Limpar texto desarrumado: a ordem das operações que realmente importa
Publicado a 08/07/2026 · 15 min de leitura · Ferramentas de texto e idioma
Daniel Okonkwo — Programador front-end e redator de Tecnologia na OneKitly
Desempenho web · Formatos de ficheiro
Verificado a partir de 5 fontes
A limpeza de texto é um pipeline cujos passos não comutam: as mesmas operações noutra ordem produzem outro texto, e o estrago é silencioso. Executado no Node, descodificar entidades HTML antes de retirar etiquetas transforma o texto escapado <b> num elemento <b> real que o limpador depois apaga: «escreva <b> para mostrar uma etiqueta» sai como «escreva para mostrar uma etiqueta». Retirar etiquetas primeiro e descodificar exatamente uma vez reproduz o que um navegador mostra. Colapsar espaços com /\s+/ antes de decidir o tratamento das quebras de linha achata três parágrafos numa única tirada de 80 caracteres, e nenhum passo posterior reconstrói as fronteiras: colapse apenas os espaços horizontais, com /[^\S\n]+/, depois das junções. Desduplicar linhas antes de as aparar não encontra nada, porque «alpha» e «alpha» com um espaço final são cadeias diferentes: cinco linhas continuam cinco; apare primeiro e essas mesmas cinco descem para três. Três caracteres sobrevivem então a qualquer passagem ingénua: U+00A0 (reconhecido por \s, retirado por trim), U+200B (reconhecido por nenhum) e U+FEFF, que o \s reconhece mas a propriedade Unicode White_Space não. Corrija a ordem, depois os invisíveis, e só depois meça.
Retirar etiquetas antes de descodificar entidades, aparar antes de desduplicar, colapsar os espaços no fim. Três ordens executadas no Node, um pipeline de nove passos na sequência certa e os caracteres invisíveis — U+00A0, U+200B, U+FEFF — que sobrevivem a qualquer limpeza ingénua.
Os passos de limpeza não comutam
Um limpador de texto parece um menu: remover espaços a mais, remover quebras de linha, remover linhas duplicadas, remover etiquetas HTML. Cada entrada soa autónoma, por isso as pessoas marcam-nas pela ordem em que a interface as lista. Não são autónomas. Cada passo reescreve a entrada que o seguinte verá, e vários pares dão um resultado diferente conforme qual corre primeiro. As falhas são discretas: continua a obter texto, continua a parecer plausível, e o que perdeu é exatamente o que não estava a olhar.
Três pares causam quase todo o dano real: etiquetas contra entidades, espaços contra quebras de linha e desduplicação contra aparo. Cada um é demonstrado abaixo sobre uma cadeia real, executada em vez de raciocinada. Os nove passos ordenados são simplesmente o que resulta dessas três restrições quando se respeitam todas ao mesmo tempo.
Etiquetas antes das entidades, e descodificar uma só vez
Tomemos um fragmento HTML: <p>Terms &amp; conditions: write <b> to show a bold tag.</p>. Um navegador mostra-o assim: Terms & conditions: write <b> to show a bold tag. As sequências escapadas são texto — o autor queria um e comercial literal e uma etiqueta de negrito literal e visível.
Retire primeiro as etiquetas e descodifique uma vez: obtém exatamente essa saída do navegador. Descodifique primeiro e retire depois: o <b> já se tornou um elemento <b> real quando o limpador corre, por isso o limpador apaga-o. O resultado é «write to show a bold tag», com um espaço duplo onde estava o sujeito da frase. Retire primeiro mas descodifique duas vezes e obtém a falha oposta: «write <b> to show a bold tag» contém agora uma etiqueta viva que nada escapa, e é assim que um extrato em texto simples volta a ser marcação assim que alguém o cola numa página.
A regra por trás dos três resultados é curta: uma entidade é texto escapado, e descodificar promove texto a marcação. Tudo o que trate a marcação de forma especial tem, portanto, de correr antes da promoção. Os navegadores evitam a questão tokenizando uma só vez, numa passagem, e por isso um analisador real nunca tem de decidir. Um pipeline de regex tem, e a decisão é: retirar, depois descodificar, exatamente uma descodificação.
Os espaços depois das quebras de linha, nunca através delas
Tome um documento de três parágrafos, alguns partidos por várias linhas, com uma ou três linhas em branco perdidas entre eles. Aplique primeiro /\s+/ substituído por um único espaço, porque «remover espaços a mais» era a primeira caixa, e obtém uma tirada de 80 caracteres sem qualquer fronteira de parágrafo. Nada a jusante as pode restaurar: as quebras de linha que carregavam a estrutura eram espaços, e pediu para colapsar os espaços.
Passe o mesmo documento pela ordem correta — normalizar finais de linha, aparar cada linha, juntar as linhas partidas, colapsar apenas as séries horizontais com /[^\S\n]+/, e depois reduzir três quebras ou mais a uma linha em branco — e sai com 58 caracteres em dois parágrafos, com a estrutura intacta. A diferença não é que uma regex seja melhor do que a outra. É que /\s+/ inclui \n e \r, e uma classe como [^\S\n] deliberadamente não.
Há uma segunda ordenação, mais subtil, dentro desta. Tome «Paragraph one text.», uma linha que contém um único espaço, e «Paragraph two text.». Divida por /\n\n/ e encontra um parágrafo, não dois, porque a linha separadora não está vazia: contém um espaço. Apare cada linha primeiro e a mesma divisão encontra dois. É por isso que o aparo das linhas vem antes de qualquer decisão sobre parágrafos, enquanto o colapso das séries vem depois: as duas operações sobre espaços ficam em lados opostos do passo das quebras de linha.
Desduplicar por último, porque a igualdade é uma propriedade a jusante
A desduplicação de linhas compara cadeias inteiras. Dê-lhe as cinco linhas «alpha» com um espaço final, «beta», «alpha», «beta» seguida de uma tabulação e «gamma», e não retira nada: cinco linhas entram, cinco saem, porque «alpha» e «alpha» mais um espaço são simplesmente cadeias diferentes, tal como «beta» e «beta» mais uma tabulação. Apare primeiro os espaços finais e a mesma desduplicação faz as cinco descer para três. Os duplicados sempre lá estiveram; eram invisíveis exatamente como um espaço final é invisível.
O mesmo acontece no exemplo trabalhado no fim deste artigo. Colocada em segundo lugar, logo a seguir a retirar as etiquetas, a desduplicação retira zero linhas. A mesma desduplicação colocada em oitavo lugar retira uma, porque entretanto o espaço de largura zero desapareceu e o espaço duplo foi colapsado, por isso as duas linhas que sempre foram a mesma frase tornaram-se finalmente a mesma cadeia. A desduplicação não encontra duplicados; a normalização cria-os, e a desduplicação apanha-os depois.
Uma decisão continua a ser sua: as maiúsculas. A desduplicação compara exatamente, por isso «Total» e «total» são duas linhas. O rebatimento de maiúsculas é uma escolha à parte e com perda, que merece o seu próprio passo e deve ser visível na interface em vez de ficar enterrada no desduplicador — o mesmo argumento que vale para as convenções de nomes, onde a transformação só é segura se souber de que convenção parte.
Os caracteres que sobrevivem a qualquer limpeza ingénua
Três pontos de código produzem quase todo o resíduo. U+00A0, o espaço inquebrável, chega dos processadores de texto, das páginas web e da tipografia francesa e espanhola, onde se coloca antes de dois pontos ou depois de uma aspa de abertura. U+200B, o espaço de largura zero, chega dos editores de CMS e do texto formatado colado, como pista invisível de corte de linha. U+FEFF, o espaço de largura zero inquebrável, é o que resulta de descodificar uma marca de ordem de bytes UTF-8; aparece no início dos ficheiros produzidos por exportações de folha de cálculo e por ferramentas Windows, e às vezes no meio após uma concatenação ingénua.
O que importa para um pipeline de limpeza é quais deles as suas ferramentas veem, e a resposta não é intuitiva. Executado no Node 22: /\s/ reconhece U+00A0 e U+FEFF mas não U+200B. O escape de propriedade Unicode /\p{White_Space}/u reconhece U+00A0 mas não U+FEFF. Divergem, nos dois sentidos: U+0085, o controlo NEL, é reconhecido pela propriedade Unicode e não por /\s/. A razão é que a gramática do ECMAScript define a sua própria produção WhiteSpace, que acrescenta a marca de ordem de bytes por motivos históricos, enquanto a base de caracteres Unicode atribui White_Space pelos seus próprios critérios e não a dá a U+FEFF.
A normalização não substitui nada. Aplicar NFKC leva U+00A0 a um U+0020 normal, o que é realmente útil, mas deixa U+200B exatamente onde estava: a cadeia continua a medir um caráter depois. Por isso o pipeline precisa de um passo de eliminação explícito para os caracteres de formatação — U+200B, U+200C, U+200D, U+FEFF, U+00AD — e de um passo de conversão à parte para os espaços exóticos. Nenhum pode ser delegado a uma regex de espaços, porque para uma regex de espaços metade deles não são espaços.
Um exemplo desarrumado, nove passos, antes e depois
O exemplo tem 126 caracteres e contém, de propósito, todos os problemas discutidos acima: dois espaços iniciais, finais de linha CRLF, um h2 e três elementos p, um e comercial duplamente codificado, um espaço de largura zero colado ao fim de uma frase, três quebras de linha seguidas, uma frase repetida à letra, um espaço interno duplicado, um parágrafo partido em duas linhas com a continuação indentada, e uma marca de ordem de bytes mais dois espaços mesmo no fim.
Passado pelos nove passos por ordem, desce para 58 caracteres: uma linha de título «Q3 & Q4 report», uma linha «Revenue rose 12%.», uma linha em branco, e depois «Costs fell» e «slightly.» como duas linhas de um mesmo parágrafo. Os comprimentos intermédios são 122 depois de normalizar os finais de linha, 92 depois de retirar as etiquetas, 88 depois da única descodificação de entidade, 86 depois de apagados o espaço de largura zero e a marca de ordem de bytes, 80 depois de aparar cada linha, 77 depois do colapso horizontal, 76 depois de reduzir a tripla quebra, e 58 depois da desduplicação.
Dois desses números são o argumento de todo o artigo. A queda de 76 para 58 é a linha duplicada, e só existe porque os passos 4 e 8 correram antes; mova a desduplicação para o segundo lugar e essa queda vale zero. O passo de 86 para 80 é o aparo linha a linha, e é o que depois permite à divisão em parágrafos ver uma linha separadora realmente vazia. Tudo o resto é contabilidade.
Antes de entregar o texto limpo
Guarde o original. Cada passo deste pipeline é destrutivo por desenho, e nenhum é reversível: não consegue recuperar quais espaços eram inquebráveis, qual quebra de linha era um corte e qual era um parágrafo, nem qual de duas linhas idênticas era a que queria manter. O texto limpo é um artefacto derivado, e um artefacto derivado nunca deve ser a única cópia.
Depois passe o pipeline duas vezes sobre a sua própria saída. Uma limpeza bem ordenada é idempotente: a segunda passagem não deve mudar nada. Se mudar, tem um passo que não é estável sob repetição, e na prática é quase sempre a descodificação de entidades — a única operação da lista que se pode criar trabalho novo a si mesma. Uma verificação de idempotência custa uma linha de código e apanha a classe de erro que só aparece quando um documento volta a passar pela ferramenta seis meses depois, porque alguém o reimportou.
Por fim, conte só no fim. Qualquer comprimento, contagem de palavras ou índice de legibilidade tirado a meio do pipeline mede uma cadeia que já não existe, e um comprimento depende em particular do que estiver disposto a chamar caráter: uma questão que convém resolver à parte antes de confiar em qualquer número que um contador lhe dê.
| Caráter | Ponto de código | /\s/ reconhece-o | /\p{White_Space}/u reconhece-o | .trim() retira-o |
|---|---|---|---|---|
| Espaço inquebrável | U+00A0 | Sim | Sim | Sim |
| Espaço inquebrável estreito | U+202F | Sim | Sim | Sim |
| Espaço de largura zero | U+200B | Não | Não | Não |
| Espaço de largura zero inquebrável, a marca de ordem de bytes | U+FEFF | Sim | Não | Sim |
| Linha seguinte | U+0085 | Não | Sim | Não |
| Hífen condicional | U+00AD | Não | Não | Não |
Perguntas frequentes
- Se correr o limpador duas vezes, a ordem ainda importa?
- Sim, e corrê-lo duas vezes pode piorar as coisas. A repetição não recria a informação destruída por um passo anterior: as fronteiras de parágrafo colapsadas continuam colapsadas por muitas passagens que faça. Entretanto, a descodificação de entidades não é idempotente: uma segunda passagem descodifica &amp; outra vez, transformando um e comercial literal deliberado do texto num estrutural. O teste correto não é corrê-lo duas vezes para melhorar o resultado, mas corrê-lo duas vezes para confirmar que a segunda passagem não muda nada.
- Porque é que .trim() retira a marca de ordem de bytes mas não o espaço de largura zero?
- Porque trim é definido contra a produção WhiteSpace do ECMAScript, e não contra a propriedade Unicode White_Space, e essa produção lista explicitamente U+FEFF por razões históricas que vêm de quando a marca de ordem de bytes se encontrava habitualmente no início de um fluxo. U+200B nunca esteve nessa lista: o Unicode classifica-o como caráter de formatação na categoria geral Cf, com o argumento de que marca uma oportunidade de corte de linha e não um espaço entre palavras. Por isso trim retira um e não o outro, e nem /\s/ nem trim o ajudarão alguma vez com U+200B. Apague-o explicitamente.
- Há alguma boa razão para usar /\s+/ num documento inteiro?
- Sim, em exatamente uma situação: quando decidiu que a saída é uma só linha e a estrutura é irrelevante — uma chave de pesquisa, uma impressão digital para comparar, um valor que vai para uma célula CSV de uma linha. Aí, achatar tudo em espaços simples é precisamente o objetivo. Em qualquer sítio onde a saída for lida por uma pessoa, /\s+/ é a classe errada, porque trata a quebra de linha que separa dois parágrafos e os dois espaços depois de um ponto como a mesma coisa. Use-a de propósito para chaves e nunca por omissão para prosa.
- Como é que sequer vejo que existe um caráter invisível?
- Compare o comprimento que espera com o que obtém, e depois despeje os pontos de código. Duas cadeias que se veem idênticas no ecrã podem ter comprimentos diferentes: é essa toda a pista. Quando o comprimento o surpreender, liste cada caráter com o seu ponto de código em hexadecimal e o culpado salta à vista: um U+00A0 onde supunha um U+0020, ou um U+200B colado ao fim de uma frase. Fazê-lo uma vez sobre uma amostra de cada fonte que importa dir-lhe-á que produtores da sua cadeia emitem que caracteres, e poderá então apagar exatamente esses.
- A desduplicação deve preservar a ordem original das linhas?
- Quase sempre sim, e deve conservar a primeira ocorrência em vez da última. Ordenar para localizar duplicados é um hábito herdado dos pipelines de linha de comandos, e reordena em silêncio um documento cuja ordem carregava sentido: uma lista de passos, um registo de alterações, uma transcrição. Conservar a primeira ocorrência também corresponde à forma como as pessoas leem: a linha anterior costuma ser a que tem contexto à volta. Se uma ferramenta oferecer ordenar enquanto desduplica, trate isso como duas operações separadas e peça só a que quer.
Artigos que podem interessar-lhe
Todos os guias →Ferramentas relacionadas
Fontes
- 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
Detetaste um erro neste artigo?