Artigo Técnico

Extração de Texto PDF Estruturada no Delphi com PDFium VCL

O PDFiumPas devolve o texto de uma página como uma estrutura em vez de uma string. GetStructuredText produz um TPdfStructuredTextPage que contém blocos, cada um com linhas, cada uma com trechos estilizados, com limites em espaço de página em cada nível e os índices de caracteres de origem preservados, para que qualquer fragmento possa ser reconduzido à página de texto subjacente

A extração de string simples com que a maioria do código começa continua disponível e continua correta para o seu propósito. Deixa de ser suficiente no momento em que precisa de saber quais palavras eram um título, quais pertenciam à coluna esquerda, ou onde na página um resultado de pesquisa realmente se encontra

Porque é uma string simples a saída errada para a maioria dos trabalhos?

Porque as perguntas que as pessoas fazem ao texto extraído quase nunca são "que caracteres estão nesta página". São "qual é o título", "isto é uma tabela", "este parágrafo pertence à secção 4", "onde desenho o realce". Uma única string não responde a nenhuma delas, e cada resposta que reconstrói a partir dela é uma heurística que passa a ser sua responsabilidade

Os esquemas de duas colunas tornam isto concreto. Extraia um artigo de duas colunas como uma string e, consoante a forma como o produtor escreveu o fluxo de conteúdo, pode obter a coluna um seguida da coluna dois, ou pode obter a linha um da coluna um, a linha um da coluna dois, a linha dois da coluna um, e assim sucessivamente ao longo da página. Ambos os casos saem de um PDF conforme. Nenhum está errado ao nível do formato, porque o PDF descreve marcas numa página, não um esquema de documento. Um modelo baseado em blocos permite ao extrator tomar a decisão de ordenação explicitamente e dizer-lhe qual decisão tomou

Ordem de conteúdo ou esquema físico?

TPdfStructuredTextOptions.ReadingOrder escolhe entre roContentOrder e roPhysicalLayout, e a resposta certa depende do que confia mais, o produtor ou a geometria

A ordem de conteúdo devolve o texto na sequência em que o fluxo de conteúdo o desenha. Isso é rápido e, para documentos gerados por um produtor bem comportado, corresponde normalmente à ordem de leitura pretendida. O esquema físico ignora a sequência do fluxo e reconstrói a ordem a partir de onde os caracteres realmente se encontram, agrupando-os em linhas e depois em colunas. É o que se pretende para páginas digitalizadas e depois submetidas a OCR, para saída de ferramentas que emitem texto pela ordem do tipo de letra e não pela ordem de leitura, e para tudo aquilo em que o resultado visual é o único elemento em que se pode confiar

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfStructuredTextOptions;
  Page: TPdfStructuredTextPage;
  B, L: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'article.pdf';
    Pdf.LoadDocument;
    Pdf.PageNumber := 1;                     // baseado em 1

    Options := TPdfStructuredTextOptions.Default;
    Options.ReadingOrder := roPhysicalLayout;
    Options.IncludeFontInfo := True;
    Options.IncludeSemantics := True;
    Options.MaxCharacters := 200000;         // orçamento fail-closed

    Page := Pdf.GetStructuredText(Options);

    for B := 0 to High(Page.Blocks) do
    begin
      if Page.Blocks[B].Kind = cfHeading then
        Emit(Format('H%d: %s',
          [Page.Blocks[B].HeadingLevel, Page.Blocks[B].Text]))
      else
        for L := 0 to High(Page.Blocks[B].Lines) do
          Emit(Page.Blocks[B].Lines[L].Text);
    end;
  finally
    Pdf.Free;
  end;
end;

O que acrescenta a etiquetagem que a geometria não consegue dar?

Intenção. Com IncludeSemantics ativo, os blocos de um PDF etiquetado transportam um Kind extraído da árvore de estrutura, pelo que um título é um título porque o produtor o declarou, e não porque o seu tipo de letra era maior do que a média. Os tipos cobrem as formas que importam para reutilização: cfParagraph, cfHeading com um HeadingLevel, cfListItem, cfTableCell, cfCaption, cfFigure e o valor de recurso não etiquetado cfPlain

O campo Source regista de onde veio cada classificação, rosStructure para a árvore de estrutura e rosHeuristic para a inferência, que é o campo a registar quando se está a decidir até que ponto confiar num pipeline de extração ao longo de um conjunto de documentos. As figuras são um caso especial que vale a pena conhecer: para um bloco cfFigure, o texto provém da descrição alternativa e não de quaisquer glifos, uma vez que uma figura não tem caracteres próprios. O texto alternativo sem correspondência continua a ser representado em vez de ser descartado, o que é o que permite a uma auditoria de acessibilidade ver que existe uma descrição mesmo quando nada na página a desenha. O próprio modelo de etiquetagem é abordado em validação da árvore de estrutura PDF/UA

Os trechos transportam o estilo e a proveniência

Cada TPdfStructuredTextSpan guarda o seu texto, os seus limites em espaço de página, FontName, FontSize, FontWeight e Angle, além de SourceStartIndex e SourceCharacterCount. Os trechos quebram onde o estilo muda, pelo que uma frase com três palavras a negrito se torna três trechos, e reconstruir a ênfase em HTML ou Markdown passa a ser uma questão de ler propriedades em vez de adivinhar a partir de nomes de tipos de letra

Os dois campos de índice de origem são os que transformam a extração numa funcionalidade em vez de um relatório. Apontam de volta para a sequência de caracteres da página, o que significa que um bloco que correspondeu numa pesquisa pode ser convertido em geometria de seleção ao nível do caráter ou num retângulo de realce sem uma segunda passagem, ordenada de forma diferente, sobre o texto; a mecânica está descrita em seleção visual de linhas de texto com caixas de carateres. O campo Angle importa mais do que parece: texto rodado num carimbo ou numa marca de água cai no mesmo espaço de coordenadas que o texto do corpo, e um pipeline que ignore o ângulo mistura alegremente um "RASCUNHO" na diagonal no meio de um parágrafo

Orçamento, e os dois contadores de qualidade

MaxCharacters é um orçamento fail-closed, não uma definição de truncatura: uma página que o exceda para em vez de devolver silenciosamente parte do conteúdo. Numa via de admissão não fiável, esse é o comportamento que se pretende, porque uma página com um milhão de caracteres é ou uma máquina que gerou um monstro ou uma tentativa de tornar o seu extrator a parte mais lenta do sistema

Dois contadores na página devolvida descrevem diretamente a qualidade da extração. UnmappedCharacterCount conta caracteres sem mapeamento Unicode utilizável, o sintoma clássico de um tipo de letra em subconjunto incorporado sem um CMap /ToUnicode; texto assim renderiza perfeitamente e extrai como nada útil. GeometryFailureCount conta caracteres cuja caixa delimitadora não pôde ser determinada, o que degrada a ordenação do esquema físico. Registe ambos. Um conjunto de documentos em que esses números estão consistentemente próximos de zero pode ser indexado com confiança, e um em que não estão está a dizer-lhe que alguns produtores no seu pipeline precisam de atenção antes de qualquer resultado a jusante ser fiável

var
  Page: TPdfStructuredTextPage;
  B, S, L: Integer;
  Emphasised: Boolean;
begin
  Page := Pdf.GetStructuredText(Options);

  if Page.UnmappedCharacterCount > 0 then
    Log(Format('page %d: %d characters without a Unicode mapping',
      [Page.PageNumber, Page.UnmappedCharacterCount]));
  if Page.GeometryFailureCount > 0 then
    Log(Format('page %d: %d characters without geometry',
      [Page.PageNumber, Page.GeometryFailureCount]));

  for B := 0 to High(Page.Blocks) do
    for L := 0 to High(Page.Blocks[B].Lines) do
      for S := 0 to High(Page.Blocks[B].Lines[L].Spans) do
      begin
        Emphasised := Page.Blocks[B].Lines[L].Spans[S].FontWeight >= 600;
        AppendRun(Page.Blocks[B].Lines[L].Spans[S].Text, Emphasised,
          Page.Blocks[B].Lines[L].Spans[S].SourceStartIndex);
      end;
end;

Desempenho em páginas reais

A extração com esquema físico é o modo dispendioso, e a implementação foi construída para páginas genuinamente grandes: a ordenação de caracteres funciona em O(n log n) em vez de varrimentos repetidos, os buffers de linhas e trechos crescem geometricamente em vez de realocar por carácter, o texto Unicode é construído em buffers em vez de concatenação de strings, e as pesquisas de tipo de letra para objetos de texto adjacentes são colocadas em cache. Essa combinação é o que mantém uma página densa de 5000 caracteres previsível em vez de quadrática

Para um trabalho com muitas páginas, ainda vale a pena escolher o modo mais barato sempre que possível. Use roContentOrder com semântica ativa para documentos etiquetados em que confia, e reserve roPhysicalLayout para material digitalizado e legado onde a geometria é o único sinal disponível. Se só precisar de uma string simples, a API mais simples descrita em extrair texto de documentos PDF continua a ser o caminho mais rápido, e quando precisa de reconduzir o texto a identificadores de conteúdo marcado, ler e escrever conteúdo marcado BDC e MCID cobre essa camada

O modelo em blocos também se adapta bem ao que os pipelines de recuperação pretendem: um título com os seus parágrafos é um fragmento com um nome, e os limites permitem que uma citação aponte para um local numa página em vez de para um documento. O PDFiumPas é um componente para Delphi e Lazarus construído em torno do motor PDFium, documentado com exemplos na página do componente PDFium para Delphi