12k
All articles

Limpando o Texto que as Pessoas Colam no seu App

Limpe o texto colado em formulários web com normalização Unicode, remoção de caracteres de formato, compressão de espaços e tratamento seguro de ZWJ e NBSP.

OpenReplay Team
OpenReplay Team
Limpando o Texto que as Pessoas Colam no seu App

Textos colados em formulários web frequentemente carregam caracteres invisíveis que ninguém digitou e que nenhuma fonte desenha: zero-width spaces, soft hyphens, byte order marks e non-breaking spaces, todos eles sobrevivem até o seu banco de dados e silenciosamente quebram comparações de correspondência exata, validação de tamanho e busca.

Se você já caçou esse bug, sabe como é. Alguém cola um parágrafo do próprio currículo de um processador de texto em um campo de candidatura a vaga, o campo parece completamente normal na tela, e o seu validador rejeita o valor. Ou o dado é salvo sem problemas e depois ninguém consegue mais encontrar o registro, porque o nome no índice contém um caractere que a caixa de busca não tem como digitar.

Este artigo mostra o que realmente chega ao campo, explica por que corrigir isso um code point por vez nunca converge, e apresenta uma função de limpeza em quatro etapas que trata toda a classe do problema, incluindo os caracteres invisíveis que você não deve apagar.

Pontos Principais

  • Os caracteres invisíveis que chegam com texto colado pertencem à categoria geral Unicode Format, escrita \p{Cf} em uma expressão regular JavaScript, então uma única correspondência de categoria substitui uma lista sempre crescente de code points individuais.
  • normalize("NFC") corrige a grafia, não a invisibilidade: ele faz com que duas codificações da mesma letra acentuada sejam comparadas como iguais e deixa um zero-width space exatamente onde ele estava.
  • O non-breaking space em U+00A0 é um caractere de espaço, não um caractere de formato, então ele sobrevive intacto a uma remoção de \p{Cf} e precisa ser tratado pela etapa de colapso de espaços em branco.
  • U+200D ZERO WIDTH JOINER faz trabalho real: ele une as partes de um emoji com múltiplas pessoas em um único glifo, e joiners carregam significado ortográfico em escritas árabes e índicas.
  • \p{...} só significa uma propriedade Unicode quando a regex carrega a flag u ou v; sem uma delas, é um identity escape para a letra literal p.

Quais Caracteres Invisíveis Realmente Chegam ao Campo?

Uma string colada de um processador de texto ou editor rich-text normalmente mistura substituições tipográficas que você consegue ver com caracteres de formato que você não consegue. Aspas curvas, en dashes e em dashes, e o caractere de reticências são visíveis e, na maior parte, inofensivos. O non-breaking space, o soft hyphen, o zero-width space e o byte order mark não são visíveis de forma alguma, e são eles que quebram verificações de igualdade.

Despeje a string em code points e o argumento se justifica por si:

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

Use o spread na string em vez de chamar split(""): o iterador de strings entrega code points completos, enquanto split("") corta caracteres astrais pela metade na fronteira UTF-16.

Para ver o que realmente existe em uma string que você tem em mãos, cole-a no invisible character cleaner e leia os code points de volta.

Essa classe de falha é definida por ser invisível. O campo renderiza corretamente, então um screenshot do bug não mostra nada de errado e quem reportou não consegue descrever o que fez de diferente. Session replay é uma das poucas técnicas que revela o problema, porque replays de formulários abandonados mostram a colagem e depois o loop de tentativas: alguém limpando um campo e redigitando um valor que parecia idêntico ao que acabou de ser rejeitado.

Por Que uma Blocklist de Caracteres Zero Width Não Para de Crescer?

Uma classe de caracteres com code points específicos é a forma errada de correção, não uma correção mal escrita, porque ela só pode conter os caracteres sobre os quais alguém já abriu um bug. Você remove U+200B após o primeiro relato, adiciona U+FEFF quando uma importação de CSV quebra, adiciona U+00AD quando um sobrenome com hífen deixa de dar match, e a classe segue crescendo porque ela enumera membros de um conjunto em vez de nomear o conjunto.

O conjunto tem um nome. O zero-width space (U+200B), o zero-width non-joiner (U+200C), o zero-width joiner (U+200D), o soft hyphen (U+00AD) e o byte order mark (U+FEFF) todos carregam General_Category=Cf, conforme o Unicode Character Database, e essas atribuições permanecem estáveis ao longo de muitas versões do padrão. O non-breaking space em U+00A0 não está nesse conjunto: ele é Zs, um separador de espaço, e é por isso que uma remoção por categoria isolada deixa no lugar o artefato de processador de texto mais comum de todos.

Normalizar, Remover, Colapsar, Aparar

A correção é uma única passada com quatro etapas ordenadas: normalizar a codificação, remover caracteres de formato, colapsar toda variante de espaço em branco para um espaço comum, e então aparar as extremidades.

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();
}

Remova \n\r de SPACE_RUN se o campo for um textarea em que quebras de linha são conteúdo.

Dois detalhes merecem seu lugar aqui. \p{...} carrega seu significado Unicode apenas quando a regex está em modo Unicode-aware; remova tanto o u quanto o v e o engine lê \p como um literal p escapado, então o padrão compila, executa e não corresponde a nada do que você pretendia. E a etapa de colapso usa uma classe explícita construída sobre \p{Zs} em vez de \s, deixando claro no ponto de chamada que U+00A0 está coberto.

A ordem importa. normalize("NFC") resolve codificação, não invisibilidade, então ele deixa um zero-width space no lugar para a etapa de remoção tratar. Remover antes de colapsar significa que o byte order mark é apagado em vez de convertido em um espaço perdido. E aparar por último captura o espaço inicial que sobra quando um caractere de formato removido estava ao lado de um.

O Que Não Remover

Uma remoção cega de todo caractere de formato danifica conteúdo real. U+200D ZERO WIDTH JOINER é o caractere que une emojis em um único glifo: no padrão de emoji Unicode, o que faz de uma string uma sequência ZWJ de emoji em primeiro lugar é a presença de um joiner, então tirar os joiners deixa vários glifos onde havia apenas um.

const family = "👨‍👩‍👧";

console.log([...family].length);                          // 5
console.log([...family.replace(/\p{Cf}/gu, "")].length);  // 3 -> 👨👩👧

Joiners também não são decoração fora do contexto de emoji. Escritores em árabe colocam um non-joiner entre duas letras para impedir que elas se liguem da forma cursiva que normalmente assumiriam, e a especificação central do Unicode alerta que um texto desprovido desses controles ou diz outra coisa ou deixa de fazer sentido. Em devanágari, um ZWJ após um virama seleciona a meia-forma de uma consoante em vez do conjunto completo. A regra é remover os caracteres invisíveis que não carregam significado no seu campo e preservar aqueles que fazem trabalho estrutural.

É isso que [\p{Cf}--[\u200C\u200D]] expressa: a flag v adiciona operadores de conjunto às classes de caracteres, e -- é o que subtrai. Em um runtime sem unicodeSets, o equivalente em modo u é /(?![\u200C\u200D])\p{Cf}/gu. Não defina as duas flags na mesma regex; elas são mutuamente exclusivas.

Por Que Usar NFC e Não NFKC?

NFC resolve as duas maneiras pelas quais o Unicode pode grafar o mesmo caractere e não altera mais nada. NFKC vai além e reescreve caracteres de compatibilidade: a ligadura ff se torna dois f’s e um Ⓓ circulado se torna um D simples, como mostram os exemplos de normalize(). Essa é uma decisão que altera conteúdo, definida pelo anexo de normalização do Unicode como equivalência de compatibilidade e não canônica, e vale a pena tomá-la deliberadamente em vez de herdá-la como efeito colateral da limpeza de um campo de formulário.

Uma armadilha relacionada: apagar um soft hyphen é trabalho do seu cleaner, não de normalize("NFKC"). A operação Unicode que mapeia U+00AD para uma string vazia é NFKC_Casefold, uma transformação diferente daquela que String.prototype.normalize("NFKC") executa.

Execute o Cleaner Também no Servidor

Limpar no navegador é uma cortesia para quem está digitando; a normalização da qual seu banco de dados e seu índice de busca dependem tem que rodar no servidor, porque uma requisição pode ser enviada sem nunca carregar sua página. Qualquer coisa que chega por uma API pública, uma importação de CSV, um webhook ou um cliente mobile ignora o handler de input inteiramente, e uma única linha não limpa é suficiente para fazer uma constraint de unicidade ou uma consulta de correspondência exata se comportar de forma inconsistente. A mesma função funciona no Node e no navegador, então execute-a na fronteira em que os dados entram na persistência, e deixe que a chamada client-side seja o feedback rápido, não a garantia.

Pare de adicionar code points a uma classe de caracteres e comece a nomear a categoria. Quatro linhas, aplicadas em todo ponto de entrada, removem os caracteres invisíveis que não carregam significado no seu campo enquanto preservam aqueles que mantêm o texto real coeso. Escreva uma fixture que misture um acento combinante, um non-breaking space, um soft hyphen, um byte order mark, um zero-width space e um emoji com ZWJ, e então verifique que seu cleaner compõe o primeiro, colapsa o segundo em um espaço comum, apaga os três seguintes e retorna o emoji inalterado.

Perguntas Frequentes

O trim remove um non-breaking space ou um zero-width space?

O trim remove espaços em branco e terminadores de linha apenas das duas extremidades, e a lista de espaços em branco do ECMAScript inclui o non-breaking space U+00A0 e o byte order mark U+FEFF, então ambos desaparecem nas bordas. Ele nunca remove o zero-width space U+200B, que é um caractere de formato e não espaço em branco, e nunca toca em nenhum desses caracteres no meio de uma string.

A remoção de caracteres de formato também remove variation selectors de emoji como U+FE0F?

Não. Os variation selectors U+FE00 a U+FE0F carregam General_Category Mn, marca sem espaçamento, e não Cf, então uma remoção da categoria de formato os deixa no lugar e os emojis mantêm a apresentação pretendida. Apenas os joiners precisam de uma keep-list. Ampliar um cleaner para remover marcas também apagaria os variation selectors e todos os acentos combinantes junto com eles.

Remover caracteres de formato também remove caracteres de right-to-left override?

Sim. Os controles bidirecionais, incluindo U+200E, U+200F, os embeddings e overrides de U+202A a U+202E, e os isolates de U+2066 a U+2069, todos carregam General_Category Cf, então uma única correspondência de categoria os remove junto com o zero-width space. Isso importa para nomes de exibição e nomes de arquivo, onde um right-to-left override inverte o texto renderizado e pode disfarçar uma extensão.

Por que um valor colado falha no maxlength quando parece curto o suficiente?

O maxlength conta unidades de código UTF-16, não caracteres visíveis, então cada passageiro invisível gasta orçamento: um byte order mark ou um zero-width space custa uma unidade, e um emoji construído a partir de um par surrogate custa duas. Limpe o valor antes de validar seu tamanho, e conte usando a forma com spread se o limite deve corresponder ao que as pessoas veem.

Open-source session replay

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

We use cookies to improve your experience. By using our site, you accept cookies.