Artigo Técnico

Extrair Tabelas de Páginas PDF em Delphi com o PDFium

O PDFium Component deteta tabelas numa página PDF e devolve-as como uma grelha de células com spans de linha e coluna, linhas de cabeçalho e um valor de confiança, através de ExtractTables para uma página e ExtractDocumentTables para um documento inteiro. Cada tabela converte-se em CSV ou JSON com uma única chamada, e as tabelas que continuam através de uma quebra de página podem ser ligadas numa cadeia de continuação

O PDF não tem objeto de tabela. Uma tabela num PDF é um conjunto de sequências de texto posicionadas de forma a que um humano as leia como uma grelha, por vezes com linhas desenhadas à volta e frequentemente sem elas. Recuperar a grelha significa reconstruir uma intenção que o ficheiro nunca registou, razão pela qual todas as ferramentas de extração produzem resultados ligeiramente diferentes, e pela qual uma ferramenta que diz a sua confiança é mais útil do que uma que não o faz

Dois modos de deteção para dois tipos de tabela

A deteção com linhas usa as linhas desenhadas. Cada segmento de caminho traçado é transformado em coordenadas de página através da matriz do objeto de página, as linhas horizontais e verticais são intersetadas, e as interseções formam componentes ligados. Cada componente torna-se a sua própria grelha ordenada de posições X e Y, o que é o que impede que duas tabelas separadas na mesma página sejam fundidas numa grelha sem sentido

A deteção por espaço em branco trata as tabelas desenhadas com alinhamento em vez de linhas. As caixas de palavras são agrupadas em linhas visuais, os espaços dentro de uma linha dividem-na em colunas candidatas, e uma tabela só é aceite quando pelo menos MinRows linhas repetem pelo menos MinColumns âncoras alinhadas à esquerda dentro de AlignmentTolerance. O fator de espaçamento entre linhas assume por predefinição o valor 3, o que cobre o espaçamento de linha de base de cerca de 30 pontos típico de texto a 12 pontos, sem deixar que uma única linha com várias sequências de texto se faça passar por uma tabela

Diagrama do pipeline de deteção de tabelas do PDFium Component em Delphi, em que as interseções de linhas desenhadas e as linhas de palavras alinhadas por espaço em branco alimentam um único registo de tabela pontuado, com exportação para CSV e JSON
A deteção por linhas interseta os traços desenhados, enquanto a deteção por espaço em branco conta linhas de caixas de palavras alinhadas; os candidatos que ultrapassam MinRows e MinColumns surgem com uma pontuação de confiança e o DetectionMode associado
uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfTableExtractionOptions;
  Tables: TPdfTables;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'annual-report.pdf';
    Pdf.LoadDocument;
    Pdf.PageNumber := 12;                    // baseado em 1

    Options := TPdfTableExtractionOptions.Default;
    Options.DetectRuledTables := True;
    Options.DetectWhitespaceTables := True;
    Options.MinConfidence := 0.6;            // a predefinição é 0.5
    Options.HeaderRowCount := 1;

    Tables := Pdf.ExtractTables(Options);
    for I := 0 to High(Tables) do
      Writeln(Format('table %d: %dx%d cells, confidence %.2f, mode %d',
        [I, Tables[I].RowCount, Tables[I].ColumnCount,
         Tables[I].Confidence, Ord(Tables[I].DetectionMode)]));

    if Length(Tables) > 0 then
      SaveText('page12-table0.csv', Tables[0].ToCsv);
  finally
    Pdf.Free;
  end;
end;

Como são recuperadas as células mescladas?

Esta é a parte em que os extratores ingénuos falham. Uma célula mesclada não pode ser identificada apenas a partir da grelha global, porque a grelha é derivada de todas as linhas da página e uma região mesclada simplesmente não tem a linha interior que a teria separado

A regra usada aqui é local: duas células base adjacentes são mescladas quando nenhuma linha de fronteira cobre o intervalo entre elas. Union-find junta-as, os componentes retangulares resultantes tornam-se valores RowSpan e ColumnSpan, e o texto é atribuído a uma célula base pelo seu ponto central e depois segue essa célula até à sua raiz de mesclagem. Fazê-lo desta forma também mantém o custo linear em palavras mais células, em vez do varrimento quadrático que se obtém testando cada palavra contra cada célula

Diagrama da recuperação de células mescladas na extração de tabelas do PDFium para Delphi, em que union-find junta células base adjacentes sempre que nenhuma linha de fronteira cobre o intervalo entre elas, produzindo RowSpan e ColumnSpan
O union-find mescla células base vizinhas cujo intervalo partilhado não transporta nenhuma fronteira desenhada, pelo que um cabeçalho mesclado é devolvido como uma célula com ColumnSpan definido, em vez de uma célula preenchida ladeada de células vazias

O efeito prático é que uma tabela financeira com um cabeçalho "Total" mesclado a abranger três colunas sai com uma célula de span três, em vez de uma célula preenchida e duas vazias e misteriosas

Continuação entre páginas

As tabelas longas quebram entre páginas, e tratar o fragmento de cada página como uma tabela independente obriga quem chama a costurá-los. ExtractDocumentTables pode ligá-los, em vez disso, mas apenas sob condições estritas: o fragmento tem de ser a tabela mais baixa na página anterior, o seguinte tem de ser a tabela mais alta na página seguinte, os números de página têm de ser adjacentes, e as fronteiras de coluna têm de corresponder

As quatro condições em conjunto são o que impede o erro óbvio, que é encadear todas as tabelas de quatro colunas de um documento numa mega-tabela imaginária, só porque acontece partilharem uma contagem de colunas. Quando as condições se verificam, as tabelas partilham um identificador de grupo de continuação e transportam metadados de continuação; quando não se verificam, obtêm-se tabelas separadas e pode decidir-se por si próprio

Diagrama da continuação de tabelas entre páginas PDF em Delphi, em que quatro portões estritos decidem se o fragmento mais baixo de uma página se junta ao fragmento mais alto da seguinte
A extração ao nível do documento só liga fragmentos quando todos os quatro portões se verificam, impedindo que tabelas de quatro colunas sem relação se fundam numa mega-tabela imaginária

A extração ao nível do documento partilha os orçamentos MaxCells e MaxTables entre páginas, em vez de os repor por página, e repõe a página ativa num bloco finally, pelo que uma execução de extração num visualizador deixa o utilizador a olhar para a página em que estava

Exportar sem corromper os dados

Ambos os exportadores são deliberados quanto ao escape. O CSV coloca sempre os campos entre aspas e duplica as aspas internas, o que evita a falha clássica em que uma célula com uma vírgula se transforma silenciosamente em duas colunas. Para células mescladas, o conteúdo só é emitido na âncora superior esquerda, pelo que um ciclo de leitura/gravação em CSV não duplica um cabeçalho com span pelas colunas que abrange

O JSON preserva o Unicode em vez de o escapar para ASCII, escapa carateres de controlo, e inclui os metadados que um consumidor precisa para avaliar a qualidade: modo de deteção, confiança, limites, valores de span, flags de cabeçalho e informação de continuação. Se estiver a alimentar tabelas extraídas para um sistema a jusante, prefira JSON, porque uma linha CSV não lhe consegue dizer que a tabela de onde veio teve uma pontuação de confiança de 0,51:

// Extração ao nível do documento, mantendo apenas as tabelas que valem a pena confiar
Tables := Pdf.ExtractDocumentTables(Options);
for I := 0 to High(Tables) do
begin
  if Tables[I].Confidence < 0.75 then
  begin
    Log(Format('page %d table needs review (%.2f)',
      [Tables[I].PageNumber, Tables[I].Confidence]));
    Continue;
  end;
  if Tables[I].ContinuationGroup > 0 then
    AppendToGroup(Tables[I].ContinuationGroup, Tables[I].ToJson)
  else
    EmitStandalone(Tables[I].ToJson);
end;

Ajuste fino, e saber quando parar

Três definições importam mais do que as restantes. MinConfidence é o portão de qualidade, e 0,5 é deliberadamente permissivo; aumente-o para ingestão automatizada e reduza-o para uma interface de revisão em que um humano confirma cada resultado. MinColumnGap decide o que conta como fronteira de coluna no modo de espaço em branco, e tabelas com espaçamento apertado em relatórios densos podem precisar de o reduzir a partir da predefinição de 12 pontos. MaxRowGapFactor decide quando a distância vertical termina uma tabela, o que importa para tabelas com linhas em branco ocasionais

Seja honesto quanto aos limites. As tabelas com linhas extraem-se de forma fiável. As tabelas alinhadas por espaço em branco de forma limpa extraem-se bem. As tabelas com texto rodado, tabelas aninhadas, ou células cujo conteúdo quebra para algo que parece outra linha vão precisar de revisão, independentemente de como os parâmetros forem definidos. Para essas, o modelo de texto estruturado dá-lhe a matéria-prima para construir um leitor específico para o domínio, descrito em blocos de texto estruturado e ordem de leitura

Um emparelhamento útil: quando um documento digitalizado não tem texto nenhum, a deteção de tabelas não tem nada com que trabalhar até existir uma camada de texto. Acrescente uma primeiro, como descrito em acrescentar uma camada de texto pesquisável a PDFs digitalizados, e depois extraia. As caixas de palavras que um fornecedor de OCR devolve são exatamente a entrada de que a deteção por espaço em branco precisa

A extração de tabelas, o texto estruturado e o reflow leem todos a partir do mesmo modelo de página em Delphi, C++Builder e Lazarus; a API completa está descrita na página do PDFium Component para Delphi