Technical Article

Extração de Texto, Imagem e Fonte de PDF no Delphi com PDFlibPas

Extrair texto, imagens e fontes de um PDF existente parece ser um problema resolvido até passar um corpus real pelo sistema. Aponte um indexador de pesquisa para quarenta mil ficheiros de clientes e as falhas dividem-se em alguns grupos facilmente reconhecíveis. As palavras colam-se porque ninguém indicou ao extrator que largura de intervalo deve ser considerada como um espaço. Outras páginas regressam como texto ilegível porque uma fonte incorporada como subconjunto (subsetted font) não contém um mapeamento dos seus códigos de glifos para caracteres reais. E o "logótipo da empresa" acaba por se revelar um conjunto de nove objetos de imagem independentes empilhados atrás de uma máscara suave (soft mask). Nada disto é um erro da biblioteca. É apenas a diferença entre chamar uma função de extração e compreender o que a função consegue e não consegue recuperar a partir dos bytes no disco.

A biblioteca PDF losLab, na sua edição Pascal, oferece ao código Delphi e C++Builder mais do que uma forma de ler cada um desses três fluxos, e os níveis diferem nas garantias apresentadas. O segredo consiste em fazer corresponder o nível de extração à tarefa: um indexador de pesquisa, um revisor de redação de dados e uma verificação de preflight PDF/A exigem coisas diferentes da mesma página, e utilizar a chamada incorreta desperdiça esforço ou produz resultados nos quais não pode confiar.

Níveis de extração de texto e o que cada um garante

A função GetPageText aceita um valor de opção de 0 a 8, e esse número escolhe um motor em vez de um formato. Os valores de 0 a 2 executam uma análise ligeira, ideal para uma pré-visualização rápida. Os valores de 3 a 8 são direcionados para o motor ciente do layout, que reconstrói as linhas e o espaçamento a partir do local onde os glifos se encontram realmente na página. Dentro desta gama, as variações são importantes: 4 e 6 dividem a saída em palavras, 5 e 6 emitem larguras por glifo, e 7 devolve texto simples descartando metadados de fonte, cor e blocos. A opção 7 é a indicada para alimentar um indexador de pesquisa, já que este requer apenas palavras.

Nenhuma configuração de opção consegue resgatar um documento que nunca possuiu essa informação de origem. O formato PDF mapeia códigos de caracteres para formas de glifos, e o único elemento que mapeia esses códigos de volta para texto legível é o ToUnicode CMap de uma fonte (ISO 32000-1 §9.10). Quando uma fonte em subconjunto é distribuída sem este mapa, qualquer extrator fica bloqueado. Esta biblioteca, a função de copiar e colar de um visualizador ou qualquer outro conjunto de ferramentas concorrente: todos ficam limitados a tentar adivinhar a partir dos nomes dos glifos ou a não devolver nada. A resposta prática é a deteção, não a insistência. Classifique a página como de baixa confiança e envie-a para OCR, uma vez que indexar lixo silenciosamente é pior do que reconhecer que não consegue ler o conteúdo.

Para os cenários que as opções gerais não abrangem (como tokenização personalizada, análise forense de fluxos de conteúdo ou um canal de texto personalizado), o descodificador está disponível num nível inferior. O TPDFExtractor é construído sobre o dicionário de recursos da página e a coleção de fontes. O seu método ExtractTextW executa as operações de texto brutas do fluxo de conteúdo de volta através do mecanismo da fonte para recuperar o Unicode, e o seu evento OnFindObject entrega-lhe cada objeto à medida que é processado. A maior parte do código não precisa de aceder a esta profundidade. As aplicações que necessitam são aquelas em que os programadores agradecem o facto de esta camada ser pública e não oculta.

Blocos posicionados: a unidade de resultados de pesquisa e revisão de redação de dados

O texto simples indica o que a página diz. Mais cedo ou mais tarde, uma aplicação também precisa de saber onde o diz, para poder destacar um resultado de pesquisa, desenhar uma caixa delimitadora em torno de um candidato a redação (ocultação de dados confidenciais) ou ancorar uma anotação no local correto. O ExtractPageTextBlocks devolve um identificador (handle) para uma lista de trechos de texto, e cada trecho traz o seu texto, a sua caixa delimitadora (bounding box), assim como o nome e o tamanho da fonte em que foi configurado:

var
  Pdf: TPDFlib;
  Blocks, I: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    if Pdf.LoadFromFile('contract.pdf', '') <> 1 then
      raise Exception.Create('load failed');
    Pdf.SelectPage(1);
    Blocks := Pdf.ExtractPageTextBlocks(0);
    for I := 0 to Pdf.GetTextBlockCount(Blocks) - 1 do
      Writeln(Format('%s  [%s %.1f pt at %.0f,%.0f]',
        [Pdf.GetTextBlockText(Blocks, I),
         Pdf.GetTextBlockFontName(Blocks, I),
         Pdf.GetTextBlockFontSize(Blocks, I),
         Pdf.GetTextBlockBound(Blocks, I, 0),
         Pdf.GetTextBlockBound(Blocks, I, 1)]));
    Pdf.ReleaseTextBlocks(Blocks);
  finally
    Pdf.Free;
  end;
end;

Um detalhe nesta área complica mais integrações do que qualquer outro. O SetTextExtractionArea, SetTextExtractionWordGap e SetTextExtractionOptions constituem estados ao nível do documento que persistem, e não argumentos passados em cada chamada. Se configurar uma restrição de área para uma funcionalidade, como ler apenas a secção do cabeçalho para classificar um documento, essa restrição truncará silenciosamente todas as extrações posteriores no mesmo identificador, incluindo os níveis de GetPageText focados no layout. Deve redefinir o estado da extração entre tarefas lógicas ou atribuir a cada tarefa o seu próprio identificador de documento.

O limiar do intervalo de palavras (word-gap threshold) é a solução para o primeiro tipo de problema: as palavras coladas. O SetTextExtractionWordGap indica ao motor de layout quanto espaço horizontal, medido em relação ao próprio espaçamento de glifos da página, separa uma palavra da seguinte. Uma tabela densa requer um intervalo menor do que uma página de marketing com espaçamento largo, pelo que um limiar ajustado por classe de documento supera uma constante global. Este persiste no documento tal como o restante estado da extração, por isso deve ser configurado de forma deliberada em vez de definido uma única vez e esquecido.

Imagens: fluxos originais, não capturas de ecrã

A forma incorreta de extrair imagens de um PDF é renderizar a página e recortá-la. Isso reamostra os píxeis, aplica permanentemente qualquer rotação e descarta o ficheiro original. Em vez disso, o GetPageImageList enumera os recursos de imagem reais que a página referencia, e cada item devolve as suas propriedades e os seus dados originais intactos:

var
  ImgList, I: Integer;
begin
  Pdf.SelectPage(1);
  ImgList := Pdf.GetPageImageList(0);
  for I := 0 to Pdf.GetImageListCount(ImgList) - 1 do
  begin
    Writeln(Pdf.GetImageListItemFormatDesc(ImgList, I, 0));
    Pdf.SaveImageListItemDataToFile(ImgList, I, 0,
      Format('page1-img%.2d.bin', [I]));
  end;
  Pdf.ReleaseImageList(ImgList);
end;

Verifique o GetImageListItemFormatDesc antes de assumir qualquer dado sobre um item, uma vez que as referências da página raramente consistem numa imagem organizada por cada representação visual. Uma soft mask aparece como uma entrada autónoma. O mesmo XObject repete-se frequentemente em várias páginas, pelo que deve efetuar uma eliminação de duplicados por hash de conteúdo antes de arquivar uma exportação geral de imagens, evitando guardar o mesmo logótipo centenas de vezes. Os ficheiros JPEG em CMYK necessitam de gestão de cor posterior, sob pena de serem apresentados com cores invertidas em visualizadores que processam os canais sem ajustes. Quando pretender um inventário global do documento, em vez de processar página a página, o FindImages em conjunto com o SetFindImagesMode analisa o ficheiro completo numa única passagem.

Existe um limite que vale a pena discutir com as partes interessadas antes de definir critérios de aceitação: a extração de imagens devolve apenas recursos rasterizados. Um logótipo ou gráfico desenhado sob a forma de vetores não é uma imagem no sentido técnico do recurso e nunca aparecerá em nenhuma lista de imagens, independentemente da clareza com que se apresenta no ecrã. Caso o requisito seja mesmo fornecer esse gráfico como um ficheiro autónomo, a abordagem correta passa por renderizar a área da página para um bitmap, o que constitui uma operação diferente e com fidelidade própria. Os dois tipos de saída não devem partilhar a mesma pasta de exportação sem uma etiqueta que identifique claramente cada um.

Fontes: uma superfície de auditoria, não uma funcionalidade de exportação

A API de fontes responde a questões relacionadas com fontes. Não disponibiliza os ficheiros de fontes em si, e essa diferença molda tudo o que pode ser desenvolvido com ela. Após o FindFonts analisar o documento, a enumeração percorre as fontes por ID e as chamadas de propriedades reportam os dados da fonte selecionada no momento:

var
  I: Integer;
begin
  Pdf.FindFonts;
  for I := 1 to Pdf.FontCount do        // font indexes start at 1, not 0
    if Pdf.SelectFont(Pdf.GetFontID(I)) = 1 then
      Writeln(Format('%s  type=%d  embedded=%d  subset=%d',
        [Pdf.FontName, Pdf.FontType,
         Pdf.GetFontIsEmbedded, Pdf.GetFontIsSubsetted]));
end;

Atenção aos limites do ciclo (loop). Os índices das fontes vão de 1 a FontCount, enquanto os índices dos blocos de texto e das imagens indicados acima começam em zero. Confundir estas convenções provoca um erro de "off-by-one" que ignorará a primeira fonte ou sairá fora dos limites da lista, e este erro poderá passar em testes simples caso o documento possua várias fontes e o resultado pareça plausível. Tenha também atenção ao escopo. Esta API não suporta exportação de fontes ao nível dos bytes. Nenhuma chamada devolve o programa de fonte incorporado como um ficheiro TTF ou OTF; o modelo projetado assenta apenas na enumeração e na inspeção de metadados. Esse modelo continua a responder ao que a produção exige das fontes: deteção de subconjuntos através do padrão do nome, auditorias de incorporação antes de conversões de arquivo (uma fonte não incorporada impede a conformidade PDF/A, como detalhado em preflight PDF/A e PDF/UA no Delphi), e diagnósticos de codificação quando a confiança na extração é reduzida. Existe também um motivo de licenciamento para o limite residir aqui. Um programa de fonte reduzido a subconjunto é material licenciado e, carecendo da maioria dos seus glifos, é inútil como uma fonte instalável. A posição que pode defender passa por tratá-lo como metadados de auditoria e não como um recurso extraível.

Esta última chamada revela a sua utilidade na triagem. Execute o GetFontEncoding em cada fonte, analise-o juntamente com a flag de subconjunto e conseguirá prever a qualidade da extração antes de recuperar um único caractere. Uma página cujas fontes pertençam todas a subconjuntos com codificações não padronizadas deve ser enviada para OCR logo na primeira inspeção, permitindo que o pipeline de processamento em lote a encaminhe corretamente sem desperdiçar uma tentativa de extração infrutífera.

Extração em escala sem carregar documentos

Num pipeline de processamento em lote, carregar um documento completo apenas para ler uma página constitui um desperdício de I/O, acumulando-se rapidamente ao longo de um corpus. As variantes de chamada única, ExtractFilePageText e ExtractFilePageTextBlocks, aceitam o nome do ficheiro, a palavra-passe e o número da página diretamente, dispensando o carregamento completo. Para ficheiros de escala de gigabytes, existe um nível de processamento ainda inferior. O caminho de acesso direto abre um ficheiro através de leituras de xref em fluxo, pelo que o DAOpenFileReadOnly seguido de DAExtractPageText acede apenas aos objetos requeridos por essa página. Isto envolve uma alteração de convenção importante: as funções DA identificam as páginas através do PageRef, um identificador de referência de objeto obtido de DAFindPage, e nunca pelo número bruto da página. Passar o número no local reservado ao identificador faz com que a chamada atue no objeto incorreto sem gerar qualquer erro, constituindo o tipo de bug mais complexo para depurar. O restante conjunto de ferramentas de acesso direto está detalhado em mesclagem, divisão e acesso direto a PDFs grandes.

Caso exista uma prática que diferencie o código de extração que sobrevive a um corpus real daquele que falha, essa prática passa por tratar a página como uma entrada não confiável e não como uma fonte de dados limpa. O texto que diverge do renderizado no visualizador costuma ser consequência de problemas de codificação, de uma ligadura agregada num único glifo ou de uma fonte em subconjunto sem entradas ToUnicode. A solução consiste em medir o nível de confiança e encaminhar as páginas incorretas para OCR, em vez de tentar contornar os bytes. A API de fontes nunca gerará um ficheiro TTF ou OTF, por conceção, pelo que deve estruturar as tarefas com base em questões de auditoria. E o estado de extração persistente, especialmente o retângulo da área de interesse, pertence ao ciclo de vida do identificador do documento, não sendo um mero parâmetro que possa esquecer após uma chamada. Se adotar estes princípios, a restante API comportar-se-á conforme esperado.

As versões de avaliação, os projetos de demonstração e a referência completa da API de extração estão disponíveis na página do produto losLab PDF Library for Delphi.