Artigo Técnico

Busca de texto em PDF segura para Unicode no Delphi: NFC e NFD

A PDF Library for Delphi consegue comparar texto por equivalência canônica em vez de por unidade de código, então uma consulta digitada como um caractere pré-composto encontra conteúdo armazenado como uma letra base mais um sinal diacrítico combinante, e vice-versa. Duas opções de busca controlam isso: soCanonicalEquivalent ativa a normalização Unicode durante a comparação, e soGraphemeClusters restringe cada ocorrência e cada passo de curinga a clusters de grafemas completos

O bug que isso corrige é um dos mais reportados e menos compreendidos em busca de documentos. Um usuário busca um nome, não vê resultados, copia o nome de dentro do documento, cola na caixa de busca e o encontra. Nada está quebrado de forma óbvia: as duas strings parecem idênticas, imprimem de forma idêntica e comparam como diferentes, porque uma é U+00E9 e a outra é U+0065 seguido de U+0301

Por que a mesma palavra compara como diferente?

O Unicode permite várias codificações para o mesmo caractere abstrato. Letras latinas com diacríticos existem como pontos de código pré-compostos e como sequências de base mais combinante. Sílabas em Hangul existem como sílabas pré-compostas e como jamos decompostos. Qual delas um PDF contém depende do produtor, da plataforma e às vezes da fonte, e nada disso é visível para a pessoa que faz a busca

O motivo pelo qual o simples nivelamento de caixa não resolve isso é estrutural, não incidental. O nivelamento de caixa e de acentos é um para um no nível da unidade de código: a string nivelada tem o mesmo comprimento que a original, então uma posição de correspondência no texto nivelado é uma posição de correspondência no original. A normalização não é um para um. Um caractere pré-composto vira duas ou três unidades de código, uma sequência decomposta colapsa de volta para uma, e depois dessa transformação as posições deixam de se alinhar com o texto que você extraiu

Mantendo as coordenadas da ocorrência apontando para o texto original

Esta é a parte que determina se a busca normalizada é utilizável, e não apenas correta. Cada unidade de código produzida pela normalização registra a posição inicial e final do texto UTF-16 original que a produziu. Decomposições recursivas herdam o intervalo de origem de seu pai, composições mesclam os intervalos de suas entradas, e quando uma correspondência é encontrada, a biblioteca varre o intervalo de mapeamento em busca do menor início e do maior fim

O efeito é que MatchStart, MatchLength, as strings de contexto e os dois pontos de entrada de substituição continuam endereçando o texto extraído original, não o intermediário normalizado. Sem esse mapeamento, uma busca normalizada poderia dizer que existe uma ocorrência, mas não onde ela estava de forma confiável, o que torna o destaque errado e a redação perigosa

O próprio normalizador é autocontido: tabelas compactas para decomposição canônica, composição e classe de combinação canônica a partir do Unicode 15.1, com o Hangul tratado pelas regras algorítmicas em vez de por entradas de tabela. Nada é carregado de um arquivo de dados externo e nenhuma API de normalização da plataforma é chamada, então um serviço Windows, um daemon Linux e uma build em FPC produzem resultados idênticos para a mesma entrada

Buscando com equivalência canônica

As opções formam um conjunto, então a equivalência canônica se combina com os comportamentos já existentes, como correspondência de palavra inteira, curingas e nivelamento insensível a diacríticos:

uses
  PDFlibrary;

var
  Lib: TPDFlib;
  Hits: array of TPDFlibSearchHit;
  Found, I: Integer;
begin
  Lib := TPDFlib.Create;
  try
    Lib.LoadFromFile('contracts.pdf', '');
    SetLength(Hits, 500);

    Found := Lib.SearchText('Bäcker', [soCanonicalEquivalent, soWholeWord],
      '', Hits);                       // intervalo de página vazio = documento inteiro

    for I := 0 to Found - 1 do
      Log(Format('page %d: "%s" at %d (%d chars)',
        [Hits[I].Page, Hits[I].MatchText, Hits[I].MatchStart,
         Hits[I].MatchLength]));
  finally
    Lib.Free;
  end;
end;

A normalização é opcional por um motivo. Construir o texto NFD e seu mapeamento de posição custa trabalho, e a maioria das buscas em documentos somente ASCII nunca precisa disso. Quando a opção é usada, cada bloco de texto armazena em cache duas formas transformadas, uma com os sinais combinantes removidos e outra sem, então um lote de consultas sobre o mesmo bloco normaliza uma vez, em vez de uma vez por consulta. O nivelamento de caixa continua percorrendo sem mudanças o caminho mais barato, um para um

O que quebra sem os limites de cluster de grafemas?

Unidades de código não são caracteres, e caracteres não são o que os usuários percebem. Um emoji de bandeira são dois pontos de código de indicador regional. Um emoji de família são vários pontos de código unidos por junções de largura zero. Uma conjunta índica é uma consoante, um virama e outra consoante. Uma letra com dois acentos empilhados são três pontos de código. Comparar ou cortar no meio de qualquer um desses produz um fragmento que renderiza como lixo

soGraphemeClusters restringe as duas extremidades de cada ocorrência, literal ou curinga, a limites completos de cluster de grafemas estendido. A segmentação implementa as regras estendidas: pareamento de CR e LF, caracteres de controle, classes de sílaba Hangul, Extend e SpacingMark, Prepend, sequências de emoji ZWJ, pareamento de indicador regional e quebras de conjunta índica. Um limite nunca é produzido dentro de um par substituto (surrogate pair), o que por si só elimina uma classe inteira de resultados corrompidos em qualquer conteúdo além do plano multilíngue básico

A opção também governa o consumo de curingas, que é onde uma implementação ingênua ainda cortaria incorretamente. O curinga de caractere único avança exatamente um cluster completo, e o retrocesso do curinga de repetição se move apenas entre limites de cluster:

// Sem soGraphemeClusters, "?" pode consumir metade de um cluster e
// retornar uma ocorrência cujo texto termina em um sinal combinante pendurado
Found := Lib.SearchText('c?té',
  [soWildcards, soCanonicalEquivalent, soGraphemeClusters], '', Hits);

// Os mesmos limites protegem a substituição, então a redação e a
// reescrita de conteúdo nunca dividem um emoji ou uma letra acentuada
Replaced := Lib.SearchAndReplaceText('naïve', 'plain',
  [soCanonicalEquivalent, soGraphemeClusters], '1-20');

Escolhendo opções para uma carga de trabalho real

Três combinações cobrem a maioria dos casos. Para uma caixa de busca de documentos interna, soCanonicalEquivalent mais soDiacriticInsensitive dá o comportamento tolerante que os usuários esperam, correspondendo tanto às duas formas de codificação quanto às grafias acentuadas e sem acento. Para busca jurídica ou de conformidade, onde um falso positivo tem um custo, use soCanonicalEquivalent com soCaseSensitive e soWholeWord e deixe o nivelamento de acentos desligado, de modo que a equivalência seja exata e independente de codificação

Para qualquer coisa que modifique o documento, adicione soGraphemeClusters sem exceção. Uma busca que retorna um intervalo levemente errado apenas confunde um leitor; uma substituição ou redação que usa o mesmo intervalo errado grava o erro no arquivo. As consequências de errar os intervalos de remoção são abordadas em redação real e remoção de conteúdo

Quando o desempenho importa, prefira os pontos de entrada em lote. SearchTextBatch executa cada consulta não vazia enquanto os blocos de texto de cada página estão residentes em memória, o que evita reextrair uma página por consulta e reutiliza a normalização em cache, e as variantes em streaming emitem ocorrências sem um buffer dimensionado pelo chamador. O modelo de extração subjacente é descrito em busca de texto e enumeração de elementos de página

Scripts onde isso não é opcional

Para o coreano, a equivalência canônica é a diferença entre encontrar um nome e não encontrá-lo, porque sílabas pré-compostas e jamos decompostos são ambos comuns em documentos reais. Para o vietnamita, diacríticos empilhados tornam a forma de composição inteiramente dependente do produtor. Para scripts índicos, o tratamento de conjuntas decide se o limite de uma ocorrência cai em um lugar legível. Para japonês e chinês, o lado da busca é comparativamente simples, embora o lado do layout não seja, como descrito em escrita vertical para japonês e chinês

A regra prática é curta: se o corpus contém qualquer idioma além do inglês, ative a equivalência canônica e meça o custo antes de decidir que é caro demais. Na maioria dos conjuntos de documentos não é, e a alternativa é um recurso de busca que falha silenciosamente exatamente nos nomes que seus usuários mais se importam em encontrar

Busca, extração, redação e reescrita de texto com reconhecimento de Unicode compartilham um único mecanismo para Delphi, C++Builder e Free Pascal; a lista completa de recursos está na página da PDF Library for Delphi