Artigo Técnico

Extração de texto estruturado de PDF com PDFium

O PDFiumPas retorna o texto de uma página como uma estrutura, em vez de uma string. GetStructuredText produz um TPdfStructuredTextPage contendo blocos, cada um contendo linhas, cada uma contendo spans estilizados, com limites em espaço de página em todo nível e os índices de caractere de origem preservados, de modo que qualquer fragmento pode ser mapeado de volta para a text page subjacente

A extração de string plana com a qual a maioria dos códigos começa continua lá e continua correta para o seu propósito. Ela deixa de ser suficiente no momento em que você precisa saber quais palavras eram um título, quais pertenciam à coluna da esquerda, ou onde na página um resultado de busca realmente está

Por que uma string plana é a saída errada para a maioria dos trabalhos?

Porque as perguntas que as pessoas fazem sobre texto extraído quase nunca são "quais caracteres estão nesta página". São "qual é o título", "isso é uma tabela", "esse parágrafo pertence à seção 4", "onde eu desenho o destaque". Uma única string não responde nenhuma delas, e toda resposta que você reconstrói a partir dela é uma heurística da qual você agora é dono

Layouts de duas colunas tornam o ponto concreto. Extraia um artigo de duas colunas como string e, dependendo de como o produtor escreveu o content stream, você 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 por diante ao longo da página. Ambas as formas saem de um PDF conforme. Nenhuma está errada em nível de formato, porque o PDF descreve marcas em uma página, não um esboço de documento. Um modelo baseado em blocos permite que o extrator tome a decisão de ordenação explicitamente e diga a você qual decisão ele tomou

Ordem de conteúdo ou layout físico?

TPdfStructuredTextOptions.ReadingOrder seleciona entre roContentOrder e roPhysicalLayout, e a resposta certa depende de em que você confia mais, no produtor ou na geometria

A ordem de conteúdo retorna o texto na sequência em que o content stream o desenha. Isso é rápido, e para documentos gerados por um produtor bem-comportado, tipicamente a ordem de leitura pretendida. O layout físico ignora a sequência do stream e reconstrói a ordem a partir de onde os caracteres realmente estão, agrupando-os em linhas e depois em colunas. É isso que você quer para páginas digitalizadas e depois processadas por OCR, para saída de ferramentas que emitem texto em ordem de fonte em vez de ordem de leitura, e para qualquer caso em que o resultado visual é a única coisa em que você 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 o tagueamento adiciona que a geometria não consegue?

Intenção. Com IncludeSemantics ativado, blocos de um PDF tagueado carregam um Kind extraído da árvore de estrutura, então um título é um título porque o produtor disse que é, não porque sua fonte era maior do que a média. Os tipos cobrem as formas que importam para reuso: cfParagraph, cfHeading com um HeadingLevel, cfListItem, cfTableCell, cfCaption, cfFigure e o fallback não tagueado cfPlain

O campo Source registra de onde cada classificação veio, rosStructure para a árvore de estrutura e rosHeuristic para inferência, que é o campo a registrar em log quando você está decidindo o quanto confiar em um pipeline de extração ao longo de um conjunto de documentos. Figuras são um caso especial que vale a pena conhecer: para um bloco cfFigure, o texto vem da descrição alternativa, e não de nenhum glifo, já que uma figura não tem caracteres próprios. Texto alternativo sem correspondência ainda é representado em vez de descartado, o que é o que permite que uma auditoria de acessibilidade veja que uma descrição existe mesmo quando nada na página a desenha. O próprio modelo de tagueamento é abordado em validação de árvore de estrutura PDF/UA

Spans carregam o estilo e a proveniência

Cada TPdfStructuredTextSpan carrega seu texto, seus limites em espaço de página, FontName, FontSize, FontWeight e Angle, além de SourceStartIndex e SourceCharacterCount. Os spans quebram onde o estilo muda, então uma frase com três palavras em negrito vira três spans, e reconstruir ênfase em HTML ou Markdown é uma questão de ler propriedades em vez de adivinhar a partir de nomes de fonte

Os dois campos de índice de origem são os que transformam extração em um recurso, em vez de um relatório. Eles apontam de volta para a sequência de caracteres da página, o que significa que um bloco que você encontrou em uma busca pode ser convertido em geometria de seleção em nível de caractere ou em um retângulo de destaque sem uma segunda passagem, ordenada de forma diferente, sobre o texto; a mecânica é descrita em seleção visual de linha de texto com caixas de caractere. O campo Angle importa mais do que parece: texto rotacionado em um carimbo ou marca d'água cai no mesmo espaço de coordenadas do texto do corpo, e um pipeline que ignora o ângulo vai alegremente mesclar um "DRAFT" diagonal no meio de um parágrafo

Orçamento, e os dois contadores de qualidade

MaxCharacters é um orçamento fail-closed, não uma configuração de truncamento: uma página que o excede para em vez de retornar silenciosamente parte do conteúdo. Em um pipeline de entrada não confiável, esse é o comportamento que você quer, porque uma página com um milhão de caracteres ou é um monstro gerado por máquina, ou é uma tentativa de fazer o seu extrator ser a parte mais lenta do sistema

Dois contadores na página retornada descrevem a qualidade da extração diretamente. UnmappedCharacterCount conta caracteres sem nenhum mapeamento Unicode utilizável, que é o sintoma clássico de uma fonte subset incorporada 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 de layout físico. Registre ambos em log. Um conjunto de documentos onde esses números estão consistentemente perto de zero pode ser indexado com confiança, e um onde não estão está dizendo que alguns produtores no seu pipeline precisam de atenção antes que qualquer resultado a jusante seja confiá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 por layout físico é o modo caro, e a implementação é construída para páginas que são realmente grandes: a ordenação de caracteres roda em O(n log n) em vez de varredura repetida, os buffers de linha e span crescem geometricamente em vez de realocar por caractere, o texto Unicode é construído em buffers em vez de concatenação de strings, e as buscas de fonte para objetos de texto adjacentes são armazenadas em cache. Essa combinação é o que mantém uma página densa de 5.000 caracteres previsível, em vez de quadrática

Para um trabalho pesado em número de páginas, ainda vale a pena escolher o modo mais barato onde você puder. Use roContentOrder com semântica ativada para documentos tagueados em que você confia, e reserve roPhysicalLayout para material digitalizado e legado onde a geometria é o único sinal disponível. Se tudo que você precisa é uma string simples, a API mais simples descrita em extraindo texto de documentos PDF continua sendo o caminho mais rápido, e quando você precisa rastrear o texto de volta a identificadores de conteúdo marcado, lendo e gravando conteúdo marcado BDC e MCID cobre essa camada

O modelo de blocos também mapeia de forma limpa para o que pipelines de retrieval querem: um título com seus parágrafos é um chunk com um título, e os limites permitem que uma citação aponte para uma localização em uma página, em vez de para um documento. O PDFiumPas é um componente Delphi e Lazarus construído em torno do motor PDFium, documentado com exemplos na página do componente PDFium Delphi