Caracteres invisíveis: por que o texto colado quebra o código

Um e-mail colado que não passa na validação, uma comparação que dá falso entre textos iguais, um JSON que não abre: muitas vezes a culpa é de um caractere invisível, como o espaço de largura zero (U+200B), o BOM (U+FEFF) ou o espaço não separável (U+00A0). Este guia mostra quais são os mais comuns, de onde vêm, como encontrá-los e como removê-los em JavaScript, Python e PHP.

Por Leonardo Gaertner · Publicado em

Os suspeitos de sempre

  • U+00A0, espaço não separável: vem do   de páginas e e-mails e de números formatados, como "1 000". Parece um espaço, mas não é igual a ele.
  • U+200B, espaço de largura zero: não aparece. Vem de editores, sites e apps de mensagem, que o usam para permitir quebra de linha em palavras longas.
  • U+FEFF, BOM (marca de ordem de bytes): fica no começo de arquivos salvos em "UTF-8 com BOM", como no Bloco de Notas antigo e no Excel. Quebra JSON e cabeçalhos de CSV.
  • U+00AD, hífen opcional: só aparece quando a palavra quebra no fim da linha, mas continua no texto copiado.
  • U+200D e U+200C: unem e separam caracteres, como os emojis de família. Dentro de um código ou de uma senha, só atrapalham.

Os sintomas

O texto parece certo, mas a comparação falha, a busca não encontra, a validação de e-mail recusa ou o JSON.parse acusa um erro na posição 0. O contador de caracteres dá uma pista: ele conta um caractere a mais do que você vê. Para ver qual é, cole o trecho na ferramenta de informações de caractere, que mostra o código de cada um e destaca os invisíveis.

Encontrando e removendo em código

A estratégia segura tem duas partes: trocar os espaços especiais por um espaço comum (para não grudar palavras) e apagar os caracteres de largura zero. Use isso na entrada de dados que vêm de cópia e cola, como e-mails, nomes de usuário, códigos e chaves.

JavaScript
// Espaços que parecem comuns, mas não são, e caracteres que nem aparecem na tela.
const ESPACOS_ESPECIAIS = /[   -    ]/g;
const INVISIVEIS = /[​‌‍⁠­]/g;

// Lista o que encontrou, com o código de cada caractere (U+200B...)
function encontrarInvisiveis(texto) {
  return [...texto.matchAll(new RegExp(`${ESPACOS_ESPECIAIS.source}|${INVISIVEIS.source}`, 'g'))].map((m) => ({
    posicao: m.index,
    codigo: 'U+' + m[0].codePointAt(0).toString(16).toUpperCase().padStart(4, '0'),
  }));
}

// Troca os espaços especiais por espaço comum e remove os invisíveis.
function limparInvisiveis(texto) {
  return texto.replace(ESPACOS_ESPECIAIS, ' ').replace(INVISIVEIS, '');
}

const colado = 'maria​@exemplo.com';
console.log(colado === 'maria@exemplo.com'); // false
console.log(encontrarInvisiveis(colado)); // [ { posicao: 5, codigo: 'U+200B' } ]
console.log(limparInvisiveis(colado) === 'maria@exemplo.com'); // true

Lista as posições e limpa o texto.

Python
import re

ESPACOS_ESPECIAIS = re.compile("[   -    ]")
INVISIVEIS = re.compile("[​‌‍⁠­]")


def encontrar_invisiveis(texto: str) -> list[tuple[int, str]]:
    """Posição e código (U+200B...) de cada espaço especial ou caractere invisível."""
    padrao = re.compile(f"{ESPACOS_ESPECIAIS.pattern}|{INVISIVEIS.pattern}")
    return [(m.start(), f"U+{ord(m.group()):04X}") for m in padrao.finditer(texto)]


def limpar_invisiveis(texto: str) -> str:
    return INVISIVEIS.sub("", ESPACOS_ESPECIAIS.sub(" ", texto))


colado = "maria​@exemplo.com"
print(colado == "maria@exemplo.com")  # False
print(encontrar_invisiveis(colado))  # [(5, 'U+200B')]
print(limpar_invisiveis(colado) == "maria@exemplo.com")  # True
PHP
<?php
// Troca os espaços especiais por espaço comum e remove os caracteres invisíveis.
// O modificador u faz o PHP tratar o texto como UTF-8.
function limparInvisiveis(string $texto): string
{
    $texto = preg_replace('/[\x{00A0}\x{1680}\x{2000}-\x{200A}\x{202F}\x{205F}\x{3000}]/u', ' ', $texto);
    return preg_replace('/[\x{200B}\x{200C}\x{200D}\x{2060}\x{FEFF}\x{00AD}]/u', '', $texto);
}

$colado = "maria\u{200B}@exemplo.com";
var_dump($colado === 'maria@exemplo.com'); // bool(false)
var_dump(limparInvisiveis($colado) === 'maria@exemplo.com'); // bool(true)

O modificador u é obrigatório para o PHP ler o texto como UTF-8.

Para limpar um texto sem programar, a ferramenta de remover espaços faz a mesma troca e diz quantos caracteres especiais encontrou.

Cuidados

  • Não remova o U+200D de textos com emoji: ele junta 👨‍👩‍👧 em um só desenho. Limpe só campos técnicos, como e-mails e códigos.
  • O U+00A0 às vezes é desejado, como entre o número e a unidade ("10 km"), para não quebrar a linha. Em texto para exibição, pode ficar.
  • Ao ler arquivos, prefira tratar o BOM na leitura: em Python, open(arquivo, encoding="utf-8-sig") remove o BOM sozinho.

Achou um erro neste guia? Avise por e-mail.

Perguntas frequentes

Por que meu JSON dá erro na posição 0?

Quase sempre por causa do BOM, o caractere U+FEFF no começo do arquivo. Salve o arquivo como UTF-8 sem BOM ou remova o primeiro caractere antes de ler.

Como saber se um texto tem caracteres invisíveis?

Cole o texto na ferramenta de informações de caractere: os invisíveis aparecem destacados, com o código U+ de cada um. Em código, procure pelos códigos listados neste guia.

trim() remove esses caracteres?

Em parte. O trim do JavaScript remove o espaço não separável e o BOM das pontas, mas não remove o espaço de largura zero, e nada do meio do texto. O strip do Python também não remove o U+200B.