Limpar texto que veio de outro sítio

Os carateres invisíveis, a pontuação substituída, os fins de linha, as formas de normalização e as regras de caixa que fazem o texto colado portar-se mal, e a ordem pela qual os corrigir.

Texto que chegou de um PDF, de uma célula de folha de cálculo, de um campo de CMS, de um cliente de email ou de uma aplicação de conversação traz consigo as decisões de formatação de quem o produziu, e quase nenhuma dessas decisões é visível no ecrã. O resultado é uma cadeia de carateres que parece correta, imprime corretamente e depois falha uma comparação, uma pesquisa, uma leitura de JSON ou uma consulta a uma base de dados sem razão aparente.

Carateres que não consegue ver

Para o Unicode, estes são carateres a sério. Apenas não têm forma visível, ou têm a mesma forma que outra coisa.

CaráterPonto de códigoCostuma vir deO que estraga
Espaço inquebrávelU+00A0  em HTML, Word, layout de PDFdivisão por espaços, correspondências exatas
Espaço estreito inquebrávelU+202Ftipografia francesa, datas vindas do Wordo mesmo, de forma menos óbvia
Espaço ideográficoU+3000métodos de introdução CJKparece um intervalo largo
Espaço de largura zeroU+200Bsugestões de quebra de linha do CMS, texto web copiadoinvisível, e não conta como espaço em branco
Não-ligador / ligador de largura zeroU+200C, U+200Dtexto persa, texto índico, sequências de emojilimites de palavra, contagens de carateres
Ligador de palavrasU+2060ferramentas de composição gráficanada visível, tudo o que seja textual
Hífen suaveU+00ADhifenização do Word, exportações de PDFuma palavra deixa de coincidir consigo mesma
Marca de ordem de bytesU+FEFFos primeiros bytes de um ficheiro UTF-8o primeiro campo da primeira linha
Marca esquerda-para-direita / direita-para-esquerdaU+200E, U+200Fconteúdo bidirecionalmarcas perdidas à volta dos números
Separador de linha e de parágrafoU+2028, U+2029algumas aplicações Mac e de paginaçãodivisão de linhas, analisadores de JavaScript antigos

A razão por que uma pesquisa falha é que uma pesquisa compara pontos de código, não formas. Se um PDF lhe deu New U+00A0 York e escreveu New York com um espaço vulgar U+0020, as duas cadeias são diferentes e nada lhe vai dizer porquê.

"New York".includes("New York")   false, the gap is U+00A0
"  text​".trim().length      5, the zero-width space survives

Um espaço inquebrável conta como espaço em branco para a maioria das funções de trim e para \s na maioria dos motores de expressões regulares, por isso desaparece nas extremidades de uma cadeia e sobrevive no meio, exatamente onde uma divisão espera um espaço normal. Um espaço de largura zero não é espaço em branco em lado nenhum, por isso o trim e o colapso de espaços deixam-no intacto. É também por isso que o Email Extractor pode não devolver nada a partir de texto copiado de um cliente de correio: um caráter de largura zero dentro do endereço impede o padrão de corresponder.

O Hidden Character Detector mostra o que lá está de facto, e o Unicode Escape dá os pontos de código exatos de um único valor. Não elimine tudo à primeira vista, porém: um ligador de largura zero dentro de uma sequência de emoji e um não-ligador de largura zero em persa ou em devanágari são conteúdo, não ruído.

Pontuação que um processador de texto trocou por si

A correção automática substitui carateres tipográficos à medida que escreve, e a substituição acompanha o texto para fora do documento.

O que escreveuO que tem agoraPonto de código
'aspa simples direitaU+2019
" e "aspas duplas esquerda e direitaU+201C, U+201D
- entre palavrasmeia-risca ou travessãoU+2013, U+2014
...reticências horizontaisU+2026
- antes de um númerosinal de menos ou hífen inquebrávelU+2212, U+2011

Isso é aceitável em prosa e fatal em tudo o que uma máquina tenha de interpretar.

{ “name”: “Ada” }      unexpected token, only U+0022 is a JSON string quote
git commit -m “fix”    the shell sees three words, not one quoted argument
WHERE name = ‘Ada’     SQL syntax error near ‘
10\u201320                  not a number range, U+2013 is not a hyphen

Em CSV a falha é mais silenciosa. Um analisador só reconhece a aspa dupla reta como delimitador de campo, por isso um valor que um processador de texto envolveu em aspas curvas é tratado como não delimitado; qualquer vírgula lá dentro divide então a linha e todas as colunas seguintes deslizam. Uma coluna cujos valores negativos usam U+2212 é importada como texto, e as somas excluem-na sem avisar.

O Find and Replace com uma pequena lista de substituições resolve o problema, mas aplique-o apenas a texto destinado a código, a CSV ou a uma chave. Passá-lo por prosa que vai publicar achata pontuação que era intencional.

Fins de linha e espaços no fim

As ferramentas Windows terminam uma linha com CR LF (U+000D U+000A), as ferramentas Unix apenas com LF, e algumas exportações Mac muito antigas ainda usam só CR. A maioria dos analisadores aguenta. Uma divisão ingénua não.

"UK\r\n" split on "\n"  ->  "UK\r"
"UK\r" === "UK"             false
"UK\r".length               3

Duas cadeias que aparecem exatamente iguais numa tabela, num registo ou numa vista de diferenças podem ainda assim diferir por um retorno de carro, um espaço final ou uma tabulação. Quando o Text Diff marca uma linha como alterada e não se vê alteração nenhuma, a resposta é essa. É também o que mete um espaço perdido dentro das aspas quando uma coluna colada passa pelo Quote Lines ou pelo Join Lines.

Normalize numa única passagem, tratando CR LF e um CR isolado em conjunto (\r\n? substituído por \n), caso contrário uma substituição em dois passos duplica as linhas em branco.

Uma letra, duas formas de a escrever

O Unicode permite que uma letra acentuada seja um único ponto de código ou uma letra base seguida de uma marca combinante. Ambas as formas estão corretas, e não são iguais.

Formaé fica guardado comoUnidades de código em JavaScript
NFC (composta)U+00E91
NFD (decomposta)U+0065 U+03012

O macOS entregou historicamente nomes de ficheiro decompostos, e vários geradores de PDF e métodos de introdução produzem texto decomposto, por isso um valor vindo de uma fonte não coincide com o mesmo valor escrito num teclado.

"é" === "é"                  false
"é".normalize("NFC") === "é" true

As consequências vão além da igualdade. Uma ordenação que compara pontos de código coloca as formas decompostas ao lado do e simples e as formas compostas bem longe, por isso uma lista de nomes volta dividida em dois blocos. As contagens de carateres diferem entre as formas, o que conta para um limite de 280 carateres ou para uma coluna VARCHAR(50), e o Text Statistics dá números diferentes para o que parece ser o mesmo texto. O truncamento é pior: cortar uma cadeia decomposta num comprimento fixo pode separar uma letra base do seu acento, deixando esse acento colado ao que vier a seguir, pelo que o Truncate Text sobre entrada não normalizada pode produzir um último caráter visivelmente errado.

As formas de compatibilidade, NFKC e NFKD, vão mais longe e reduzem carateres que apenas se parecem com outros: a ligadura U+FB01 passa a fi, o de largura completa U+FF21 passa a A, o sinal de micro U+00B5 passa ao mu grego U+03BC e o expoente ² passa a um 2 simples. Isso é excelente para uma chave de pesquisa ou de eliminação de duplicados e destrutivo para tudo o que se mostre ao utilizador, porque transforma-se discretamente em x2.

A regra prática: NFC para armazenamento e apresentação, NFKC só para chaves que ninguém chega a ver.

A conversão de caixa não é uma só operação

Passar a maiúsculas não é um mapeamento caráter a caráter, e não é independente da localidade.

  • O ß alemão U+00DF passa a SS em maiúsculas, por isso a cadeia fica mais longa e a viagem de volta a minúsculas não devolve o original.
  • O sigma grego passa a ς em minúsculas no fim de uma palavra e a σ em qualquer outro sítio, o que volta a quebrar a ida e volta.
  • O turco e o azerbaijano têm um ı sem ponto U+0131 e um İ maiúsculo com ponto U+0130. Nessas localidades, I passa a ı em minúsculas e i passa a İ em maiúsculas.

Este último é o erro clássico em produção. Código que passa a minúsculas o nome de um cabeçalho, uma extensão de ficheiro ou uma cadeia de protocolo funciona em todo o lado até correr numa máquina cuja localidade por omissão é o turco, onde "FILE" passa a fıle e deixa de coincidir com file. Em Java e em .NET os métodos sem argumentos usam a localidade por omissão da máquina, por isso toUpperCase() e ToUpper() são a armadilha; indique explicitamente uma localidade invariante para tudo o que for lido por máquinas. Para comparar, o case folding (casefold() em Python) é melhor do que passar a minúsculas, porque trata ß como ss.

A caixa de título não tem uma definição única, e é por isso que "capitalizar todas as palavras" produz "The Lord Of The Rings" e "IPhone". A maioria dos manuais de estilo capitaliza a primeira e a última palavra mais tudo o que não seja artigo, conjunção coordenativa ou preposição curta, mantém as duas metades de um composto hifenizado capitalizadas e nunca mexe em siglas ou em nomes com maiúsculas interiores como McDonald, O'Brien ou iPhone.

As convenções de caixa usadas em programação têm o seu próprio problema de fronteiras. Converter HTTPResponseCode para snake case depende inteiramente da forma como o separador trata uma sequência de maiúsculas; o Case Converter dá http_response_code, um separador ingénuo dá h_t_t_p_response_code.

Uma ordem que funciona

Cada passo pressupõe que o anterior já correu. Fora de ordem, entram em conflito.

  1. Resolva a codificação. Mojibake como café significa que os bytes foram descodificados com a codificação errada, por isso volte a descodificar os bytes originais em vez de remendar os sintomas. Retire um BOM apenas mesmo no início do texto.
  2. Normalize os fins de linha para LF numa única passagem.
  3. Remova os carateres de formatação que tenha considerado ruído: hífenes suaves, espaço de largura zero, ligador de palavras, marcas bidi. Faça-o antes de normalizar, porque o NFC não consegue compor uma letra base e o seu acento através de um caráter invisível colocado entre os dois.
  4. Substitua a pontuação sósia, se o destino for código, CSV, JSON ou uma chave.
  5. Aplique a normalização Unicode, NFC por omissão.
  6. Só agora trate os espaços em branco. Converta os espaços exóticos que restam para U+0020, colapse as sequências e depois faça trim. Fazer isto antes do passo 3 deixa espaços duplos sempre que um espaço inquebrável se encontrou com um normal, e fazer trim antes do passo 2 deixa um retorno de carro colado ao último valor de cada linha.
  7. Converta a caixa por último, com a localidade indicada explicitamente.

As operações ao nível da palavra também vêm depois do passo 3. Dividir texto com o Split Text enquanto restam hífenes suaves dá contagens inflacionadas e palavras a meio, e o mesmo se aplica a tudo o que procure termos, incluindo o Censor Text.

Quando um valor continua a recusar-se a corresponder depois de tudo isto, deixe de olhar para ele. Imprima o seu comprimento, converta-o em pontos de código com escape e compare diretamente as duas formas com escape; aí a diferença é óbvia de uma maneira que nunca é no ecrã.