Artigo Técnico

Falsos positivos de tabelas em texto justificado no PDFium

O PDFium Component versão 3.117.0 deixa de reportar parágrafos justificados como tabelas alinhadas por espaços, ao exigir que cada fronteira de coluna seja um corredor vertical sem texto em qualquer linha que separe, ao saltar as palavras já reclamadas por uma grelha e ao montar o texto das células por sobreposição vertical em vez da distância entre centros das caixas dos glifos. As três alterações vivem dentro do ExtractTables e do ExtractDocumentTables e não precisam de opção nenhuma

O relato que deu origem a isto não tinha nada de glamoroso. Uma página de comunicado de imprensa, sem tabela nenhuma, voltava do ExtractTables com uma tabela por espaços 5x4, com uma confiança confortavelmente acima do MinConfidence predefinido de 0,5, e as células continham fragmentos de texto corrido comum. Um formulário de admissão fez o mesmo com os seus parágrafos de prosa e produziu uma 3x4 e uma 5x3. Os dois documentos estavam justificados. A resposta óbvia é afinar os limites, e a lição útil desta versão é que afinar não resolve, porque a regra que estava a ser afinada fazia a pergunta errada

uses
  PDFium;

// Verificação de regressão: listar todas as tabelas por espaços de um documento para que uma página
// que sabe ser só prosa possa ser confirmada limpa
procedure ReportWhitespaceTables(Pdf: TPdf);
var
  Options: TPdfTableExtractionOptions;
  Tables: TPdfTables;
  I: Integer;
begin
  Options := TPdfTableExtractionOptions.Default;   // MinColumnGap 12pt
  Tables := Pdf.ExtractDocumentTables(Options);
  for I := 0 to High(Tables) do
    if Tables[I].DetectionMode = ptdmWhitespace then
      Writeln(Format('page %d: %dx%d whitespace table, confidence %.2f, ' +
        'first cell "%s"',
        [Tables[I].PageNumber, Tables[I].RowCount, Tables[I].ColumnCount,
         Tables[I].Confidence, Tables[I].Cells[0].Text]));
end;

Porque é que o texto justificado parece uma tabela?

Um parágrafo justificado parece uma tabela porque uma linha justificada é uma fila de palavras separadas por espaços que o motor de layout esticou, e a partir do momento em que um espaço esticado atinge o MinColumnGap o detetor não tem forma, ao nível da linha, de o distinguir de um separador de coluna. A estratégia por espaços do PDFium Component agrupa caixas de palavra em linhas visuais, divide cada linha em grupos de palavras sempre que a distância horizontal à palavra anterior seja pelo menos MinColumnGap (12 pontos por predefinição), e aceita uma tabela quando pelo menos duas linhas consecutivas repetem pelo menos MinColumns âncoras de grupo alinhadas à esquerda dentro do AlignmentTolerance, que é 3 pontos. É essa a regra descrita em a visão geral da deteção de tabelas, e para uma tabela genuinamente alinhada é exatamente a regra certa

Aplique-a agora a vinte linhas de prosa justificada a 10 pontos. Todas as linhas são esticadas até à mesma margem direita, pelo que uma linha que termine numa palavra comprida abre os seus espaços interiores, e num parágrafo com algumas linhas curtas alguns desses espaços passam os 12 pontos. Duas linhas consecutivas só precisam de um espaço esticado cada, a cair dentro de 3 pontos da mesma posição X, para formar um candidato de duas linhas e duas colunas. Ao longo de linhas suficientes isto não é azar; é uma probabilidade que se aproxima da certeza, e a 5x4 no comunicado de imprensa era simplesmente a série em que quatro desses espaços se alinharam em cinco linhas

Diagrama do PDFium Component sobre porque é que a prosa justificada pontuava como tabela: todas as linhas são esticadas até à mesma margem, pelo que espaços isolados cruzam o MinColumnGap num X diferente em cada linha, e dois espaços consecutivos dentro do AlignmentTolerance construíam os candidatos falsos que o teste de corredor agora rejeita
Uma tabela a sério repete as suas âncoras de coluna em todas as linhas, enquanto um parágrafo justificado estica um espaço diferente em cada linha, e é por isso que a afinação ao nível da linha, sozinha, não conseguia separar as duas

Cada limite troca uma classe de documento por outra. Subir o MinColumnGap para 20 pontos perde as colunas compactas dos relatórios financeiros densos, que é exatamente o caso para o qual o predefinido já tinha sido baixado. Subir o MinRows para 3 descarta tabelas reais de duas linhas e limita-se a baixar as probabilidades nos parágrafos longos. Apertar o AlignmentTolerance abaixo dos 3 pontos quebra as caixas de palavra vindas de OCR, cujas arestas esquerdas oscilam mais do que isso. O sinal ao nível da linha é genuinamente ambíguo, pelo que a correção tem de vir de um sinal que as linhas não transportam por si

O que é que torna uma fronteira de coluna real?

Uma fronteira de coluna real é uma faixa vertical da página que fica vazia em todas as linhas que separa. Uma tabela tem uma entre cada par de colunas por construção, porque as células foram dispostas contra posições X partilhadas. Um parágrafo justificado estica os seus espaços entre palavras em posições horizontais diferentes em cada linha, pelo que nenhuma faixa sobrevive à interseção de mais do que uma linha ou duas. O PDFium Component testa agora exatamente isso: depois de os grupos de palavras do candidato terem sido atribuídos a colunas âncora, para cada par de colunas adjacentes toma, em todas as linhas que tenham conteúdo nas duas células, o intervalo desde a aresta mais à direita das palavras da célula esquerda até à aresta mais à esquerda das palavras da célula direita, interseta esses intervalos ao longo das linhas e rejeita o candidato inteiro se a interseção for mais estreita do que o MinColumnGap vezes 0,5, que são 6 pontos com os valores predefinidos

Diagrama do PDFium Component do teste de corredor sem texto por trás do ExtractTables: cada linha doa o intervalo desde a aresta direita da sua célula esquerda até à aresta esquerda da sua célula direita, a interseção mantém-se mais larga do que metade do MinColumnGap numa tabela a sério e colapsa em nada no texto justificado
Uma fronteira de coluna genuína está vazia em todas as linhas que separa, pelo que intersetar os espaços de cada linha deixa uma faixa partilhada numa tabela e faixa nenhuma em prosa esticada

Dois pormenores importam. As linhas em que uma das células está vazia não votam, pelo que uma tabela com uma célula em branco, ou com um cabeçalho que abranja menos colunas do que o corpo, continua a passar. E a largura do corredor é derivada do MinColumnGap em vez de exposta como opção separada, porque as duas descrevem a mesma coisa física: o espaço que um designer deixa entre colunas. A lógica é pequena o suficiente para se reproduzir se estiver a construir sobre caixas de palavra cruas em vez da API de tabelas, e o exemplo abaixo espelha a verificação dentro do componente:

uses
  Math, PDFium;

type
  TIndexList = array of Integer;
  TCellIndexes = array of TIndexList;   // Row * ColumnCount + Column

// Devolve False quando algum par de colunas adjacentes não tem um corredor
// vertical sem texto com pelo menos MinColumnGap / 2 de largura nas linhas que o usam
function HasTextFreeCorridors(const Words: TPdfWordBoxes;
  const Cells: TCellIndexes; RowCount, ColumnCount: Integer;
  MinColumnGap: Double): Boolean;
var
  Col, Row, I, LeftCell, RightCell, Supported: Integer;
  CorridorLeft, CorridorRight, RowLeft, RowRight: Double;
begin
  for Col := 0 to ColumnCount - 2 do
  begin
    CorridorLeft := -MaxDouble;
    CorridorRight := MaxDouble;
    Supported := 0;
    for Row := 0 to RowCount - 1 do
    begin
      LeftCell := Row * ColumnCount + Col;
      RightCell := LeftCell + 1;
      if (Length(Cells[LeftCell]) = 0) or (Length(Cells[RightCell]) = 0) then
        Continue;                             // células vazias não votam
      RowLeft := -MaxDouble;
      RowRight := MaxDouble;
      for I in Cells[LeftCell] do
        RowLeft := Max(RowLeft, Words[I].Rect.Right);
      for I in Cells[RightCell] do
        RowRight := Min(RowRight, Words[I].Rect.Left);
      CorridorLeft := Max(CorridorLeft, RowLeft);
      CorridorRight := Min(CorridorRight, RowRight);
      Inc(Supported);
    end;
    if (Supported > 0) and
       (CorridorRight - CorridorLeft < MinColumnGap * 0.5) then
      Exit(False);
  end;
  Result := True;
end;

Porque é que as tabelas com grelha eram extraídas duas vezes?

As tabelas com grelha eram extraídas duas vezes porque a passagem por espaços via antes todas as palavras da página, incluindo as que a passagem por grelhas já tinha colocado numa grelha, e uma tabela com grelha bem feita é por construção também uma tabela por espaços perfeitamente alinhada. Havia já uma verificação de sobreposição que rejeitava um candidato por espaços cujos limites cobrissem mais de metade de uma tabela existente, mas um candidato que combinasse as linhas inferiores da tabela com umas linhas de texto alinhadas por baixo podia ficar abaixo dessa proporção e sobreviver como uma segunda tabela, ligeiramente maior, que sangrava para dentro da vizinha. O ExtractTables remove agora essas palavras antes de a passagem por espaços correr. Uma palavra é descartada quando o seu ponto central fica dentro dos limites de qualquer tabela que a passagem por grelhas tenha produzido; usa-se o centro em vez da contenção total para que uma palavra que atravesse uma fronteira por uma fração de ponto siga a tabela a que pertence visualmente. A estratégia por espaços trabalha então só sobre as palavras livres, o que também significa que uma tabela pequena sem grelha mesmo por baixo de uma com grelha é detetada pelos seus próprios méritos em vez de fundida com a grelha de cima

Porque é que «Purpose of Request:» saía como «of Purpose Request:»?

As palavras saíam reordenadas porque as caixas de palavra que o PDFium Component constrói são uniões de caixas envolventes de glifos, e «of» não tem descendente enquanto «Purpose» e «Request:» têm. O FPDFText_GetCharBox devolve a caixa justa da tinta do glifo em espaço de página, e não uma caixa preenchida até à ascendente e à descendente do tipo de letra, e a caixa da palavra é a união das caixas dos seus caracteres. Uma palavra sem descendentes é por isso mais curta e o seu centro vertical fica mais alto, 2 a 3 pontos no formulário em questão. A antiga rotina de texto de célula ordenava as palavras primeiro pelo centro em Y, com uma tolerância de 1 ponto para «mesma linha», e depois pela aresta esquerda; o «of» passava a tolerância, ordenava-se como a sua própria linha acima das outras e era emitido primeiro

Isto não é tanto uma peculiaridade do PDFium como uma consequência da forma como o PDF posiciona texto. A ISO 32000-1 §9.2.2 e §9.4.4 definem a colocação de glifos como um deslocamento horizontal ao longo da linha de base em espaço de texto, e as únicas métricas verticais que o ficheiro transporta são por tipo de letra: as entradas Ascent, Descent e FontBBox do descritor do tipo de letra na §9.8.1. Nada no ficheiro diz que dois glifos partilham uma linha; isso tem de ser inferido da geometria, e as caixas justas dos glifos que fazem o realce de seleção parecer bem, como descrito em seleção de linhas de texto com char boxes do PDFium, são a entrada errada para uma comparação por distância entre centros

A correção na versão 3.117.0 muda a pergunta de «a que distância estão os centros» para «quanto é que as caixas se sobrepõem na vertical». O texto da célula é montado agrupando primeiro as palavras da célula em linhas visuais, em que uma palavra se junta a uma linha quando a sua sobreposição vertical com os limites correntes da linha é pelo menos 25 por cento da menor das duas alturas, depois ordenando cada linha pela aresta esquerda por inserção, e por fim juntando as linhas com uma quebra de linha. «Purpose» e «of» sobrepõem-se ao longo da altura-x completa, o que é muito mais do que 25 por cento da caixa mais curta, pelo que ficam na mesma linha e se ordenam por X como pretendido

Diagrama do PDFium Component da correção da ordem em Purpose of Request: as caixas justas de glifo do FPDFText_GetCharBox dão ao «of», sem descendente, um centro mais alto que a antiga tolerância de 1 pt no centro em Y ordenava como linha própria, enquanto uma regra de 25 por cento de sobreposição vertical o mantém na linha de base e restaura a ordem das palavras
O centro em Y desloca-se com as ascendentes e descendentes que a tinta por acaso tenha, enquanto duas caixas na mesma linha de base se sobrepõem ao longo da altura-x partilhada, faça o que fizerem as suas alturas

Agrupar linhas de texto por sobreposição e não por distância entre centros

A regra que vale a pena retirar deste bug é geral: qualquer código de composição de texto em PDF que decida «mesma linha» comparando centros verticais contra uma tolerância fixa vai falhar em tipos de letra reais, e a falha é silenciosa: nada dá erro, as palavras saem simplesmente pela ordem errada. Descendentes misturadas são o gatilho mais ligeiro. Um rótulo a negrito de 12 pontos ao lado de valores a 10 pontos, um marcador de nota de rodapé em sobrescrito, um símbolo de moeda desenhado a partir de um tipo de letra de recurso, e caixas de palavra de OCR com ruído de altura por palavra deslocam todos os centros mais do que qualquer tolerância que ainda separe linhas adjacentes de texto a 10 pontos com entrelinha de 12. A razão de sobreposição é invariante ao tamanho: duas caixas na mesma linha de base sobrepõem-se ao longo da altura-x partilhada, faça o que fizerem as suas ascendentes e descendentes, e duas caixas em linhas adjacentes não se sobrepõem nada

A mesma regra é fácil de aplicar fora da extração de tabelas. O TPdf.PageWordBoxes devolve todas as palavras da página ativa com o seu retângulo em espaço de página, pelo que agrupar uma página em linhas visuais é um ciclo curto:

uses
  Math, PDFium;

function SameVisualLine(const A, B: TPdfRectangle): Boolean;
var
  Overlap, MinHeight: Double;
begin
  Overlap := Min(A.Top, B.Top) - Max(A.Bottom, B.Bottom);
  MinHeight := Min(A.Top - A.Bottom, B.Top - B.Bottom);
  Result := (MinHeight > 0) and (Overlap >= MinHeight * 0.25);
end;

procedure GroupPageIntoLines(Pdf: TPdf; out Lines: TArray<TPdfWordBoxes>);
var
  Words: TPdfWordBoxes;
  Bounds: TArray<TPdfRectangle>;   // união corrente por linha
  I, J, Found: Integer;
begin
  Words := Pdf.PageWordBoxes;
  Lines := nil;
  Bounds := nil;
  for I := 0 to High(Words) do
  begin
    Found := -1;
    for J := High(Lines) downto 0 do
      if SameVisualLine(Bounds[J], Words[I].Rect) then
      begin
        Found := J;
        Break;
      end;
    if Found < 0 then
    begin
      SetLength(Lines, Length(Lines) + 1);
      SetLength(Bounds, Length(Bounds) + 1);
      Found := High(Lines);
      Bounds[Found] := Words[I].Rect;
    end;
    SetLength(Lines[Found], Length(Lines[Found]) + 1);
    Lines[Found][High(Lines[Found])] := Words[I];
    Bounds[Found].Left := Min(Bounds[Found].Left, Words[I].Rect.Left);
    Bounds[Found].Right := Max(Bounds[Found].Right, Words[I].Rect.Right);
    Bounds[Found].Top := Max(Bounds[Found].Top, Words[I].Rect.Top);
    Bounds[Found].Bottom := Min(Bounds[Found].Bottom, Words[I].Rect.Bottom);
  end;
  // ordenar cada linha por Rect.Left antes de a ler; o PageWordBoxes devolve
  // as palavras por ordem do content stream, que não é garantidamente a visual
end;

O que muda para quem já chama isto, e onde estão os limites

O que interessa naquele excerto é o predicado e não o ciclo; para algo mais do que um despejo rápido, comece pelo modelo de texto estruturado, que já transporta blocos, linhas e uma fonte de ordem de leitura, como se aborda em extração de texto estruturado de PDF com ordem de leitura. Quem já chama a extração de tabelas recebe as três correções sem tocar nas suas opções. O limite do corredor está fixo em metade do MinColumnGap, a estratégia por espaços mantém o seu piso de duas linhas mesmo quando o MinRows está a 1 (o que a estratégia por grelhas agora aceita), e a filtragem de palavras primeiro pelas grelhas é incondicional sempre que as duas estratégias estão ativadas. No conjunto de 13 documentos usado para a versão, a passagem por espaços devolvia antes 34 fragmentos e falsos positivos a par de 9 tabelas por grelhas; depois da versão não devolve nenhum, e a contagem de tabelas por grelhas subiu a 41, embora a maior parte dessa subida venha de a mesma versão ter ensinado o detetor de grelhas a ler fronteiras desenhadas como retângulos preenchidos, o que é uma história à parte

Os limites honestos: o teste de corredor precisa de pelo menos uma linha com conteúdo dos dois lados de uma fronteira para rejeitar alguma coisa, pelo que um candidato de duas linhas cujos dois espaços esticados calhem a ficar dentro de 6 pontos um do outro continua a passar. Isso é uma coincidência estreita e não a quase-certeza que era antes, mas documentos com muita prosa e sem tabelas genuínas de duas linhas podem fechar essa brecha pondo o MinRows a 3. O texto irregular alinhado à esquerda nunca foi o problema e não é afetado. E o PDF continua a não ter objeto de tabela; a ISO 32000-1 §14.8.4.3 define um elemento de estrutura Table, mas só o PDF etiquetado o transporta, pelo que para tudo o resto a grelha continua a ser uma inferência a partir da geometria, e o valor de confiança em cada TPdfTable existe porque uma inferência merece uma pontuação

A extração de tabelas, o texto estruturado e as caixas de palavra leem todos do mesmo modelo de página em Delphi, C++Builder e Lazarus; a API completa, incluindo TPdfTableExtractionOptions e a demo TableExtractionLab que a acompanha, está descrita na página do PDFium Component para Delphi