Technical Article

Extrair Texto de Ficheiros PDF com o PDFium VCL em Delphi

A extração de texto de PDF parece simples até se deparar com um documento onde a camada de texto está ausente, corrompida ou dividida por dezenas de pequenas sequências de caracteres sem qualquer ordem lógica. O PDFium VCL fornece dois pontos de entrada: o array Character[] para acesso direto e baseado em índices a cada glifo de uma página, e o ReadablePageContent para obter uma perspetiva estruturada que reconstrói parágrafos e cabeçalhos a partir da árvore de etiquetas (tag tree) do PDF ou através de análise heurística. Nenhum deles representa sempre a escolha ideal, pelo que é importante compreender o que cada um expõe.

Abrir o documento e a armadilha da falha silenciosa

O TPdf abre um ficheiro definindo a propriedade FileName e alterando o estado para Active := True. O detalhe crítico: a atribuição Active := True nunca gera exceções. Se o ficheiro estiver em falta, protegido por palavra-passe ou corrompido, o PDFium captura o erro internamente e a propriedade Active simplesmente permanece False. Isto significa que qualquer ciclo de extração de texto deve proteger-se desta forma:

Pdf := TPdf.Create(nil);
try
  Pdf.FileName := 'report.pdf';
  Pdf.Active := True;
  if not Pdf.Active then
  begin
    ShowMessage('Could not open PDF (damaged or wrong password)');
    Exit;
  end;
  // extraction follows here
finally
  Pdf.Active := False;
  Pdf.Free;
end;

Ficheiros protegidos por palavra-passe necessitam que se defina Pdf.Password := '...' antes de alterar para Active := True. Não existe uma segunda oportunidade: assim que o Active falha, deve fechar e reabrir com a palavra-passe correta.

Extração página a página com o Character[]

A abordagem de nível mais baixo percorre cada caráter em cada página. Defina o Pdf.PageNumber para carregar a camada de texto dessa página e, em seguida, percorra os elementos de CharacterCount utilizando a propriedade Character[]. Vale a pena verificar dois sinalizadores (flags) em cada entrada: o CharacterGenerated[i] assinala glifos sintéticos inseridos pelo renderizador (hífens suaves em quebras de linha, por exemplo) que não têm valor Unicode real, e o CharacterMapError[i] indica que o PDFium não conseguiu mapear o glifo para um ponto de código, o que acontece com codificações de fonte que carecem de uma tabela ToUnicode.

procedure ExtractAllText(Pdf: TPdf; Output: TStrings);
var
  Page, I: Integer;
  Line: string;
  Ch: WideChar;
begin
  for Page := 1 to Pdf.PageCount do
  begin
    Pdf.PageNumber := Page;
    Line := '';
    for I := 0 to Pdf.CharacterCount - 1 do
    begin
      if Pdf.CharacterGenerated[I] or Pdf.CharacterMapError[I] then
        Continue;
      Ch := Pdf.Character[I];
      if Ch = #13 then
        Ch := #10;   // normalize CR to LF
      Line := Line + Ch;
    end;
    Output.Add(Line);
  end;
end;

O resultado é uma string linear de pontos de código Unicode na ordem em que o PDFium os enumera, que é a ordem em que aparecem no fluxo de conteúdo, e não necessariamente a ordem de leitura da esquerda para a direita. Para a maioria dos documentos com alfabeto latino produzidos por ferramentas padrão de escritório, este comportamento é adequado. Para PDFs digitalizados que foram processados com OCR com sequências de glifos invulgares, ou para texto da direita para a esquerda, a ordenação pode estar incorreta. É nesses casos que o ReadablePageContent se torna mais útil.

Extração estruturada com o ReadablePageContent

O ReadablePageContent situa-se um nível acima: retorna um registo TPdfReadableContent cujo array Fragments contém fragmentos de conteúdo etiquetados, cada um com um Kind que identifica parágrafos, cabeçalhos, itens de lista, células de tabelas, entre outros. Quando o PDF contém uma árvore de estrutura (verifique através de Pdf.IsTagged), a origem é rosStructure e a ordem de leitura é autoritária. Para ficheiros sem etiquetas, o PDFium reverte para rosHeuristic, que agrupa caracteres pelas suas caixas delimitadoras em unidades de leitura plausíveis, mas sem garantia de precisão.

procedure ExtractStructured(Pdf: TPdf; Output: TStrings);
var
  Page: Integer;
  Content: TPdfReadableContent;
  Fragment: TPdfContentFragment;
begin
  for Page := 1 to Pdf.PageCount do
  begin
    Content := Pdf.ReadablePageContent(Page);
    for Fragment in Content.Fragments do
    begin
      case Fragment.Kind of
        cfHeading   : Output.Add('# ' + Fragment.Text);
        cfParagraph : Output.Add(Fragment.Text);
        cfListItem  : Output.Add('- ' + Fragment.Text);
      else
        Output.Add(Fragment.Text);
      end;
    end;
  end;
end;

Se Content.Source = rosHeuristic e o seu texto parecer confuso, a camada de texto do documento provavelmente não foi gravada com a ordem de leitura em mente. Nesse ponto, a única solução fiável é exportar novamente a partir da aplicação de origem com a etiquetagem correta, ou executar um passo de pós-processamento que ordene os pontos de origem dos caracteres por Y e depois por X.

O que o CharacterOrigin e o CharacterRectangle disponibilizam

Ambas as propriedades retornam a posição de um caráter no espaço da página (pontos, com origem no canto inferior esquerdo e o Y a crescer para cima). O CharacterOrigin[i] representa o ponto de ancoragem da linha de base do glifo; o CharacterRectangle[i] representa a caixa delimitadora completa. Estes são os blocos de construção para qualquer funcionalidade além de texto simples: detetar limites de colunas, agrupar caracteres em linhas comparando coordenadas Y dentro de uma tolerância, ou construir um mapa de posicionamento para seleção de texto num visualizador. Se necessitar de encontrar qual o caráter que está sob o clique do rato, a função CharacterIndexAtPos(X, Y, ToleranceX, ToleranceY) efetua essa pesquisa diretamente sem ter de percorrer os retângulos manualmente.

Instalar a DLL no local correto

O PDFium VCL delega toda a análise de PDF para uma DLL nativa, pdfium32.dll ou pdfium64.dll, dependendo da sua plataforma de destino. O componente disponibiliza o script CopyDlls.bat que copia o ficheiro correto para a pasta de sistema do Windows. Executá-lo uma vez como Administrador na máquina de desenvolvimento é suficiente; para a distribuição, deve copiar a DLL juntamente com o executável da aplicação. As variantes com suporte a V8 (pdfium32v8.dll, pdfium64v8.dll) são substancialmente maiores e apenas necessárias se os seus PDFs contiverem JavaScript que precise de ser executado. Para extração simples de texto, a versão padrão é a escolha indicada.

Se a DLL estiver ausente em tempo de execução, a definição Active := True falhará silenciosamente da mesma forma que acontece com um ficheiro em falta, porque o componente captura o erro de carregamento internamente. Teste sempre num sistema limpo antes de distribuir a aplicação.

Utilizar o FontSize[] com o Character[] para análise de layout

Além de texto simples, a API ao nível do caráter expõe o FontSize[i], que retorna o tamanho em pontos renderizado de cada glifo. Combinado com o CharacterOrigin[i] e o CharacterRectangle[i], isto permite distinguir texto do corpo de cabeçalhos sem depender da árvore de estrutura. Uma sequência de caracteres onde o tamanho da fonte ultrapassa um determinado limite é quase certamente um cabeçalho num documento não etiquetado. A mesma técnica aplica-se à deteção de legendas (texto pequeno sob a caixa delimitadora de uma imagem) ou notas de rodapé (texto pequeno perto da margem inferior da página). Nenhuma destas leituras requer renderização; todas leem diretamente da camada de texto que o PDFium constrói durante a ativação.

Uma nuance: o FontSize[i] reflete o tamanho após a aplicação da matriz de transformação de corrente (CTM) da página, de modo que um documento onde o autor redimensionou a página inteira reportará tamanhos ajustados proporcionalmente. Se estiver a comparar tamanhos em páginas com dimensões diferentes, normalize em relação à altura do MediaBox de cada página antes de tomar decisões de limite.

Gravar a saída num ficheiro

O TStringList do Delphi gere a gravação de UTF-8 de forma limpa desde a versão XE. Defina WriteBOM := False se necessitar de um ficheiro sem a marca de ordem de bytes (BOM) (muitos sistemas a jusante esperam esta configuração):

var
  Lines: TStringList;
begin
  Lines := TStringList.Create;
  try
    ExtractAllText(Pdf, Lines);
    Lines.WriteBOM := False;
    Lines.SaveToFile('output.txt', TEncoding.UTF8);
  finally
    Lines.Free;
  end;
end;

Para documentos muito grandes onde a memória seja uma preocupação, grave diretamente utilizando um TStreamWriter com TEncoding.UTF8 dentro do ciclo de páginas, em vez de acumular tudo numa lista primeiro.

As APIs Character[], CharacterCount, CharacterOrigin[], CharacterRectangle[], ReadablePageContent e CharacterIndexAtPos apresentadas aqui fazem parte do Componente PDFium VCL para Delphi e C++Builder.