Artigo Técnico

Extrair Texto de um PDF Carregado no Delphi com o HotPDF

O HotPDF Component extrai texto Unicode de qualquer PDF que carregue no Delphi através de duas chamadas: ExtractLoadedPageText devolve o texto do fluxo de leitura de uma página, e ExtractLoadedPageTextLayout (adicionado na v2.263.0) reconstrói a disposição visual da página como texto simples, garantindo que colunas, avanços e o alinhamento de tabelas sobrevivam na saída. Ambos funcionam em documentos que o HotPDF não criou, que é o caso que realmente importa: a fatura que um cliente lhe enviou por e-mail, o relatório que um serviço de digitalização entregou, o contrato gerado por um software que já ninguém sabe o nome

Chegar lá exigiu mais mecanismos do que as duas assinaturas sugerem, porque um PDF não armazena texto como um ficheiro de texto comum. Este artigo descreve ambos os modos de extração e, em seguida, revela os três elementos subjacentes — o leitor CMap, o intérprete de fluxo de conteúdo e a cadeia de alternativas de descodificação de tipos de letra — porque saber como funciona o mapeamento faz a diferença entre conformar-se com uma saída corrompida e diagnosticá-la

Por que razão a extração de texto é mais difícil do que ler strings a partir do ficheiro?

Um fluxo de conteúdo PDF regista códigos de carateres, e não carateres. Os operadores Tj e TJ (norma ISO 32000-1 §9.4.3) contêm strings de bytes cujo significado depende inteiramente do tipo de letra selecionado pelo Tf precedente: o byte 0x41 pode ser a letra A sob WinAnsi, um glifo arbitrário num tipo de letra de subconjunto ou metade de um CID de dois bytes num tipo de letra CJK composto. A norma ISO 32000-1 §9.10 define a extração de texto exatamente como este problema de descodificação — mapear cada código de volta para Unicode utilizando as informações que o dicionário de tipos de letra fornecer — e a norma é explícita ao referir que um ficheiro em conformidade não é obrigado a fornecer informações suficientes para o fazer

Essa última cláusula explica todos os relatórios de erros sobre "por que razão copiar e colar deste PDF produz carateres indecifráveis" que já viu. Um gerador que incorpora um tipo de letra de subconjunto sem tabela /ToUnicode escreveu um ficheiro que é renderizado perfeitamente, mas extraído como texto sem sentido, porque o mapeamento de código para glifo existe, mas o mapeamento de código para Unicode nunca foi enviado. Qualquer API de extração honesta é, portanto, uma cadeia de alternativas baseada nos melhores esforços, e a pergunta útil é quão profunda é essa cadeia

Extração do fluxo de leitura com o ExtractLoadedPageText

Para indexação de pesquisa, correspondência de palavras-chave ou alimentação de texto para um fluxo de análise, ExtractLoadedPageText é a chamada indicada. A assinatura é function ExtractLoadedPageText(PageIndex: Integer; out AText: UnicodeString): boolean — os índices das páginas começam em zero, o resultado é entregue como uma UnicodeString nativa do Delphi, e a função devolve False quando a página não possui um fluxo de conteúdo legível, em vez de gerar uma exceção

var
  Pdf: THotPDF;
  PageCount, I: Integer;
  PageText, AllText: UnicodeString;
begin
  Pdf := THotPDF.Create(nil);
  try
    PageCount := Pdf.LoadFromFile('invoice.pdf');
    AllText := '';
    for I := 0 to PageCount - 1 do
      if Pdf.ExtractLoadedPageText(I, PageText) then
        AllText := AllText + PageText + #13#10;
    // AllText now holds the reading-flow text of the document
  finally
    Pdf.Free;
  end;
end;

As quebras de linha na saída provêm de uma heurística deliberadamente simples: quando a origem vertical de um glifo se desloca mais de metade do tamanho do tipo de letra atual — a assinatura de um passo Td ou T* no fluxo de conteúdo — é inserida uma quebra de linha. Os carateres que o descodificador não consegue resolver tornam-se espaços em vez de desaparecerem, garantindo que os limites das palavras sobrevivam mesmo quando os glifos individuais não o fazem. O que este modo não tenta fazer é o agrupamento de ordem de leitura ou a deteção de colunas múltiplas: uma página de duas colunas é extraída intercalada na ordem do fluxo de conteúdo, que é normalmente, mas nem sempre, a ordem visual

Quando deve utilizar a extração que preserva o esquema do documento?

A ExtractLoadedPageTextLayout é a chamada correta sempre que a posição tem significado: tabelas, formulários, listagens de código, qualquer coisa que pretenda comparar (diff), pesquisar (grep) ou analisar por coluna. Em vez de nivelar os glifos num fluxo contínuo, agrupa-os em linhas de base, ordena cada linha de base por X e reproduz espaços em branco horizontais e verticais numa grelha de carateres monoespaçados dimensionada a partir da média de avanço do glifo e do tamanho do tipo de letra. Grandes lacunas entre blocos na mesma linha de base tornam-se sequências de espaços; grandes lacunas entre linhas de base tornam-se linhas em branco. O resultado lê-se tal como a página se apresenta

var
  Grid: UnicodeString;
begin
  if Pdf.ExtractLoadedPageTextLayout(0, Grid) then
    TFile.WriteAllText('page1.txt', Grid, TEncoding.UTF8);
  // Columns, indentation and table alignment survive as
  // spaces and blank lines on a character grid
end;

Os dois modos partilham cada byte dos mecanismos de descodificação e diferem apenas na forma como organizam os glifos descodificados, pelo que la escolha não tem custos em termos de fidelidade. Escolha ExtractLoadedPageText quando apenas as palavras importam e ExtractLoadedPageTextLayout quando a organização visual é relevante. A deteção de ordem de leitura em colunas múltiplas permanece fora do âmbito de ambos — uma renderização em grelha de uma página de duas colunas mostra-lhe ambas as colunas lado a lado, fielmente, o que para comparação (diffing) é perfeitamente correto e para reprocessamento de prosa não é

Como é que o HotPDF descodifica códigos de carateres para Unicode?

O HotPDF Component resolve cada código de caráter através de uma cadeia de alternativas ordenada por prioridade: primeiro o CMap /ToUnicode incorporado do tipo de letra, depois a entrada /Encoding (fluxo ou CMap nomeado), depois — para tipos de letra compostos — os ficheiros CMap padrão da Adobe para coleções de carateres como Adobe-GB1, Adobe-CNS1, Adobe-Japan1 e Adobe-KR e, por fim, as tabelas WinAnsi e MacRoman integradas para tipos de letra simples. Uma estratégia que não consiga fornecer uma resposta passa silenciosamente para a seguinte, em vez de gerar uma exceção, e um código que esgote toda a cadeia resolve-se para 0, permitindo ao chamador contar as falhas em vez de tentar adivinhar

O CMap /ToUnicode (norma ISO 32000-1 §9.10.3) está em primeiro lugar porque é o mapeamento que o gerador escreveu especificamente para a extração. O caminho do CMap padrão da Adobe é relevante para documentos CJK que utilizam CMaps predefinidos como UniGB-UTF16-H em vez de incorporar o que quer que seja: o HotPDF disponibiliza os ficheiros da coleção sob a sua diretoria resources\CMap, localiza-os relativamente ao executável em tempo de execução e armazena em cache cada mapa analisado por processo — algo útil de saber porque o maior deles, o mapa Adobe-GB1, tem cerca de 2 MB de texto de origem que não deseja reanalisar por página. Se a diretoria estiver ausente, o descodificador simplesmente ignora os CMaps baseados em disco e trabalha com tabelas incorporadas mais as codificações integradas. Este é o reflexo, no lado da leitura, do problema de modelação (shaping) abordado na modelação de texto de scripts complexos com o HotPDF, onde a mesma distinção entre código e glifo é enfrentada no momento da escrita

Duas armadilhas de sintaxe CMap que vale a pena conhecer

Os ficheiros CMap parecem fáceis de analisar à primeira vista, mas não o são, e dois detalhes justificam a maioria das falhas de analisadores na primeira tentativa. O primeiro é que a contagem de registos surge antes da palavra-chave da secção: uma secção apresenta-se como 2 beginbfchar, e não beginbfchar 2. Um analisador que espere a contagem após a palavra-chave consome o número como um token perdido e, em seguida, encontra zero entradas em cada secção. A abordagem robusta — aquela que o leitor do HotPDF adotou — consiste em ignorar totalmente a contagem e realizar o ciclo até à palavra-chave correspondente endbfchar / endbfrange, o que tem a vantagem de tolerar ficheiros reais cujas contagens estão simplesmente incorretas

A segunda armadilha é que os destinos de bfchar e bfrange são strings UTF-16BE, e não inteiros. O destino <D83DDE00> significa U+1F600 — um par de substitutos (surrogate pair) que deve ser recombinado num único ponto de código — e ler esses quatro bytes como um inteiro big-endian produz um valor sem sentido em todos os pontos de código fora do Plano Multilingue Básico. Os emojis em PDFs já não são uma raridade, pelo que um descodificador que ignore a recombinação de substitutos falhará em ficheiros que os seus utilizadores realmente possuem. O HotPDF analisa o literal hexadecimal em bytes brutos primeiro e, em seguida, recombina as unidades de código UTF-16BE, o que também abrange os destinos de múltiplos carateres que os mapeamentos de ligaduras produzem

Descer ao nível dos glifos com o ExtractLoadedPageGlyphs

Ambas as chamadas de texto baseiam-se em ExtractLoadedPageGlyphs, e a THPDFGlyphArray subjacente também está disponível para o seu código. Cada THPDFGlyphRecord contém o ponto de código Unicode resolvido juntamente com o código do caráter bruto, a largura em bytes do código (1, 2, ou 4, decidida pelo codespacerange do CMap), a chave e tamanho do recurso do tipo de letra ativo, a origem X e Y no espaço do utilizador e o avanço horizontal. Isto é suficiente para construir a deteção de limites de palavras, destaque posicionado ou um algoritmo de disposição personalizado sem ter de manipular o fluxo de conteúdo diretamente

var
  Glyphs: THPDFGlyphArray;
  I, Unresolved: Integer;
begin
  if Pdf.ExtractLoadedPageGlyphs(0, Glyphs) then
  begin
    Unresolved := 0;
    for I := 0 to High(Glyphs) do
      if Glyphs[I].Unicode = 0 then
        Inc(Unresolved);
    if Unresolved > 0 then
      ShowMessageFmt('%d of %d glyphs have no Unicode mapping',
        [Unresolved, Length(Glyphs)]);
  end;
end;

Contar os registos com Unicode = 0, como exemplificado acima, é a forma correta de medir a qualidade da extração num determinado documento antes de confiar no texto nos processos seguintes. Os registos de glifos também ancoram cada caráter ao operando de origem no fluxo de conteúdo, que é o que viabiliza a pesquisa e substituição de texto em documentos carregados do HotPDF com base na mesma estrutura

Que PDFs não revelarão o seu texto?

Alguns ficheiros inviabilizam qualquer extrator, e é preferível detetá-los do que disponibilizar a sua saída. Os documentos digitalizados são o caso mais evidente: uma página que é uma imagem grande não contém quaisquer operadores de texto, pelo que a extração devolve corretamente uma string vazia — a solução é OCR, e a extração das imagens da página a partir do PDF carregado é o primeiro passo desse processo. Os tipos de letra de subconjunto sem tabela /ToUnicode são o caso mais difícil: se o caminho do /Encoding e os CMaps padrão também ficarem vazios, esses glifos resolvem-se para 0 e surgem como espaços nas chamadas de texto. Os documentos encriptados são extraídos normalmente, desde que os carregue com a sua palavra-passe através da sobrecarga do LoadFromFile, garantindo que os fluxos são desencriptados antes de o intérprete os ler

Um limite mais restrito merece ser exposto claramente: a cadeia de descodificação lê os fluxos CMap e de conteúdo através do caminho Flate do HotPDF, pelo que um tipo de letra cujo fluxo ToUnicode utilize um filtro pouco comum passa para a estratégia seguinte em vez de inviabilizar a página. Na prática, o FlateDecode cobre quase tudo o que foi produzido nas últimas duas décadas, e essa transição é silenciosa por conceção — obtém o melhor texto que o ficheiro permite em vez de uma exceção. O mesmo mecanismo de objetos do lado da leitura que resolve dicionários de tipos de letra aqui também viabiliza a edição de metadados em documentos carregados, permitindo que um fluxo de receção de documentos extraia, inspecione e anote numa única passagem

A extração de texto, a renderização com preservação de esquema, o acesso ao nível dos glifos e as funcionalidades de pesquisa e substituição construídas sobre eles fazem parte do HotPDF Component padrão para Delphi e C++Builder — sem DLLs externas, sem serviços de texto do SO, apenas Object Pascal que pode acompanhar passo a passo quando um ficheiro invulgar entra na sua fila de processamento