Artigo Técnico

Pesquisa de Texto PDF Segura para Unicode em Delphi: NFC e NFD

A PDF Library for Delphi consegue corresponder texto por equivalência canónica em vez de por unidade de código, pelo que uma pesquisa escrita como um caráter pré-composto encontra conteúdo guardado como uma letra base mais uma marca de combinação, e vice-versa. Duas opções de pesquisa controlam isto: soCanonicalEquivalent ativa a normalização Unicode durante a correspondência, e soGraphemeClusters restringe cada resultado e cada passo de caráter universal a clusters de grafemas completos

O erro que isto corrige é um dos mais reportados e menos compreendidos na pesquisa de documentos. Um utilizador pesquisa por um nome, não vê resultados, copia o nome do documento, cola-o na caixa de pesquisa, e encontra-o. Nada está obviamente quebrado: as duas strings parecem idênticas, imprimem de forma idêntica, e comparam-se como diferentes, porque uma é U+00E9 e a outra é U+0065 seguido de U+0301

Por que razão a mesma palavra se compara como diferente?

O Unicode permite várias codificações para o mesmo carácter abstrato. As letras latinas com diacríticos existem como pontos de código pré-compostos e como sequências de base mais combinação. As sílabas hangul existem como sílabas pré-compostas e como jamo decompostos. Qual delas um PDF contém depende do produtor, da plataforma, e por vezes do tipo de letra, e nada disso é visível para a pessoa que faz a pesquisa

A razão pela qual o simples dobramento de maiúsculas e minúsculas não resolve isto é estrutural, não incidental. O dobramento de maiúsculas e minúsculas e o dobramento de acentos são um-para-um ao nível da unidade de código: a string dobrada tem o mesmo comprimento que a original, pelo que uma posição de correspondência no texto dobrado é uma posição de correspondência no original. A normalização não é um-para-um. Um caráter pré-composto transforma-se em duas ou três unidades de código, uma sequência decomposta reduz-se de volta a uma, e depois dessa transformação, as posições deixam de estar alinhadas com o texto extraído

Manter as coordenadas de resultado a apontar para o texto original

É esta parte que determina se a pesquisa normalizada é utilizável, e não apenas correta. Cada unidade de código produzida pela normalização regista a posição de início e fim do texto UTF-16 original que a produziu. As decomposições recursivas herdam o intervalo de origem do seu progenitor, as composições fundem os intervalos das suas entradas, e quando uma correspondência é encontrada, a biblioteca percorre o intervalo de mapeamento em busca do menor início e do maior fim

O efeito é que MatchStart, MatchLength, as strings de contexto e ambos os pontos de entrada de substituição continuam a apontar para o texto extraído original, e não para o intermediário normalizado. Sem esse mapeamento, uma pesquisa normalizada poderia dizer que existe um resultado, mas não com fiabilidade onde este estava, o que torna o realce errado e a redação perigosa

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

Pesquisar com equivalência canónica

As opções são um conjunto, pelo que a equivalência canónica se combina com os comportamentos já existentes, como a correspondência de palavra inteira, os caracteres universais e o dobramento 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áginas 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 uma razão. Construir o texto NFD e o seu mapeamento de posições custa trabalho, e a maioria das pesquisas sobre documentos apenas ASCII nunca precisa disso. Quando a opção é usada, cada bloco de texto guarda em cache duas formas transformadas, uma com as marcas de combinação removidas e outra sem elas, pelo que um lote de pesquisas sobre o mesmo bloco normaliza uma vez em vez de uma vez por pesquisa. O dobramento de maiúsculas e minúsculas continua a percorrer o caminho um-para-um mais económico, sem alterações

O que quebra sem limites de cluster de grafemas?

As unidades de código não são carateres, e os carateres não são o que os utilizadores percecionam. 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. Um conjunto indiano é uma consoante, um virama e outra consoante. Uma letra com dois acentos empilhados são três pontos de código. Corresponder ou cortar a meio de qualquer um destes produz um fragmento que se renderiza como lixo

soGraphemeClusters restringe ambas as extremidades de cada resultado, literal ou por caráter universal, a limites de cluster de grafemas estendidos completos. A segmentação implementa as regras estendidas: emparelhamento de CR e LF, carateres de controlo, classes de sílaba hangul, Extend e SpacingMark, Prepend, sequências ZWJ de emoji, emparelhamento de indicador regional e quebras de conjunto indiano. Um limite nunca é produzido dentro de um par substituto, o que por si só elimina toda uma classe de resultados corrompidos em qualquer conteúdo para além do plano multilingue básico

A opção também governa o consumo por caráter universal, que é onde uma implementação ingénua ainda cortaria incorretamente. O caráter universal de carácter único avança exatamente um cluster completo, e o retrocesso para o caráter universal de sequência só se move entre limites de cluster:

// Sem soGraphemeClusters, "?" pode consumir metade de um cluster e
// devolver um resultado cujo texto termina numa marca de combinação pendente
Found := Lib.SearchText('c?té',
  [soWildcards, soCanonicalEquivalent, soGraphemeClusters], '', Hits);

// Os mesmos limites protegem a substituição, pelo que 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');

Escolher opções para uma carga de trabalho real

Três combinações cobrem a maioria dos casos. Para uma caixa de pesquisa de documentos interna, soCanonicalEquivalent mais soDiacriticInsensitive dá o comportamento tolerante que os utilizadores esperam, correspondendo tanto ambas as formas de codificação como as grafias acentuadas e não acentuadas. Para pesquisa jurídica ou de conformidade, onde um falso positivo tem um custo, use soCanonicalEquivalent com soCaseSensitive e soWholeWord e deixe o dobramento de acentos desligado, para que a equivalência seja exata e independente da codificação

Para tudo o que modifique o documento, acrescente soGraphemeClusters sem exceção. Uma pesquisa que devolva um intervalo ligeiramente errado apenas induz um leitor em erro; uma substituição ou redação que use o mesmo intervalo errado escreve o erro no ficheiro. As consequências de errar os intervalos de remoção estão descritas em redação verdadeira e remoção de conteúdo

Quando o débito importa, prefira os pontos de entrada em lote. SearchTextBatch executa cada pesquisa não vazia enquanto os blocos de texto de cada página estão residentes, o que evita voltar a extrair uma página por pesquisa e reutiliza a normalização em cache, e as variantes em fluxo emitem resultados sem um buffer dimensionado pelo chamador. O modelo de extração subjacente está descrito em pesquisa de texto e enumeração de elementos de página

Escritas onde isto não é opcional

Para o coreano, a equivalência canónica é a diferença entre encontrar um nome e não o encontrar, porque tanto as sílabas pré-compostas como os jamo decompostos são comuns em documentos reais. Para o vietnamita, os diacríticos empilhados tornam a forma de composição inteiramente dependente do produtor. Para as escritas índicas, o tratamento de conjuntos decide se um limite de resultado cai num local legível. Para o japonês e o chinês, o lado da pesquisa é comparativamente simples, embora o lado do layout não o seja, conforme descrito em escrita vertical para japonês e chinês

A regra prática é curta: se o corpus contiver qualquer língua além do inglês, ative a equivalência canónica e meça o custo antes de decidir que é demasiado caro. Na maioria dos conjuntos de documentos não é, e a alternativa é uma funcionalidade de pesquisa que falha silenciosamente exatamente nos nomes que os utilizadores mais querem encontrar

A pesquisa sensível a Unicode, a extração, a redação e a reescrita de texto partilham um único motor para Delphi, C++Builder e Free Pascal; a lista completa de funcionalidades está na página da PDF Library for Delphi