Artigo Técnico

Falsos positivos de tabela em texto justificado no Delphi

O PDFium Component na versão 3.117.0 para de reportar parágrafos justificados como tabelas alinhadas por espaços em branco ao exigir que todo limite de coluna seja um corredor vertical sem texto em qualquer linha que ele separe, ao pular palavras já reivindicadas por uma grade e ao montar o texto das células por sobreposição vertical em vez de distância entre centros das caixas de glifo. As três mudanças vivem dentro de ExtractTables e ExtractDocumentTables e não precisam de opção nenhuma

O relatório que deu início a isso não tinha nada de glamouroso. Uma página de press release sem tabela nenhuma voltou de ExtractTables com uma tabela de espaços em branco 5x4, confiança tranquilamente acima do MinConfidence padrão de 0.5, e as células continham pedaços de texto de corpo comum. Um formulário de admissão fez o mesmo com seus parágrafos de redação e produziu uma 3x4 e uma 5x3. Os dois documentos estavam justificados. A resposta óbvia é ajustar os limiares, e a lição útil desta release é que ajuste nenhum resolve, porque a regra sendo ajustada estava fazendo a pergunta errada

uses
  PDFium;

// Verificação de regressão: lista toda tabela por espaços em branco de um
// documento para confirmar que uma página só de prosa está 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;

Por que texto justificado parece uma tabela?

Um parágrafo justificado parece uma tabela porque uma linha justificada é uma fileira de palavras separadas por vãos que o motor de layout esticou, e no momento em que um vão esticado alcança MinColumnGap o detector não tem como dizer, dentro da própria linha, se é isso ou um separador de coluna. A estratégia de espaços em branco do PDFium Component agrupa caixas de palavra em linhas visuais, divide cada linha em grupos de palavras sempre que a distância horizontal até a palavra anterior é de pelo menos MinColumnGap (12 pontos por padrão), e aceita uma tabela quando pelo menos duas linhas consecutivas repetem pelo menos MinColumns âncoras de grupo alinhadas à esquerda dentro de AlignmentTolerance, que é 3 pontos. Essa é a regra descrita na visão geral da detecção de tabelas, e para uma tabela alinhada de verdade ela está exatamente certa

Aplique agora isso a vinte linhas de prosa justificada em 10 pontos. Toda linha é esticada até a mesma margem direita, então uma linha que termina com uma palavra longa abre seus espaços internos, e num parágrafo com algumas linhas curtas alguns desses espaços passam de 12 pontos. Duas linhas consecutivas só precisam de um vão esticado cada, caindo dentro de 3 pontos da mesma posição X, para formar um candidato de duas linhas e duas colunas. Com linhas suficientes isso não é azar; é uma probabilidade que se aproxima da certeza, e a 5x4 do press release era simplesmente a rodada em que quatro vãos desses se alinharam em cinco linhas

Diagrama do PDFium Component de por que prosa justificada pontuou como tabela: toda linha é esticada até a mesma margem, então vãos isolados passam de MinColumnGap em um X diferente em cada linha, e dois vãos consecutivos dentro do AlignmentTolerance montavam os candidatos falsos que o teste de corredor agora rejeita
Uma tabela de verdade repete suas âncoras de coluna em toda linha, enquanto um parágrafo justificado estica um espaço diferente em cada linha, e é por isso que ajustar só o nível da linha não conseguia separar as duas

Todo limiar troca uma classe de documento por outra. Subir MinColumnGap para 20 pontos perde as colunas compactas de relatórios financeiros densos, que é exatamente o caso para o qual o padrão já tinha sido reduzido. Subir MinRows para 3 descarta tabelas de duas linhas de verdade e apenas diminui as chances nos parágrafos longos. Apertar AlignmentTolerance abaixo de 3 pontos quebra caixas de palavra vindas de OCR, cujas bordas esquerdas tremem mais que isso. O sinal no nível da linha é genuinamente ambíguo, então a correção tem que vir de um sinal que as linhas não carregam sozinhas

O que torna um limite de coluna real?

Um limite de coluna real é uma faixa vertical da página que fica vazia em toda linha que ele separa. Uma tabela tem uma entre cada par de colunas por construção, porque as células foram dispostas contra posições X compartilhadas. Um parágrafo justificado estica seus espaços em branco em posições horizontais diferentes a cada linha, então nenhuma faixa sobrevive à interseção de mais de uma ou duas linhas. O PDFium Component agora testa exatamente isso: depois que os grupos de palavras do candidato foram atribuídos às colunas âncora, para cada par de colunas adjacentes ele toma, em toda linha que tenha conteúdo nas duas células, o intervalo da borda mais à direita das palavras da célula esquerda até a borda mais à esquerda das palavras da célula direita, intersecta esses intervalos entre as linhas, e rejeita o candidato inteiro se a interseção for mais estreita que MinColumnGap vezes 0.5, o que dá 6 pontos no padrão

Diagrama do PDFium Component do teste de corredor livre de texto por trás do ExtractTables: cada linha doa o intervalo da borda direita da sua célula esquerda até a borda esquerda da sua célula direita, a interseção fica mais larga que metade do MinColumnGap numa tabela real e colapsa a nada em texto justificado
Um limite de coluna de verdade fica vazio em toda linha que separa, então intersectar os vãos de cada linha deixa uma faixa compartilhada numa tabela e nenhuma faixa em prosa esticada

Dois detalhes importam. Linhas em que uma das células está vazia não votam, então uma tabela com célula em branco, ou um cabeçalho que abrange menos colunas que o corpo, ainda passa. E a largura do corredor é derivada de MinColumnGap em vez de exposta como opção separada, porque as duas descrevem a mesma coisa física: o vão que um designer deixa entre colunas. A lógica é pequena o bastante para reproduzir se você estiver construindo sobre caixas de palavra cruas em vez da API de tabelas, e o exemplo abaixo espelha a checagem dentro do componente:

uses
  Math, PDFium;

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

// Retorna False quando algum par de colunas adjacentes não tem um corredor
// vertical livre de texto com pelo menos MinColumnGap / 2 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;

Por que tabelas com bordas eram extraídas duas vezes?

Tabelas com bordas eram extraídas duas vezes porque a passagem por espaços em branco enxergava toda palavra da página, incluindo as palavras que a passagem de grades já tinha colocado numa grade, e uma tabela com bordas limpa é por construção também uma tabela de espaços em branco perfeitamente alinhada. Uma checagem de sobreposição já rejeitava um candidato por espaços em branco cujos limites cobrissem mais da metade de uma tabela existente, mas um candidato que combinasse as linhas de baixo da tabela com algumas linhas alinhadas de texto abaixo dela podia ficar abaixo dessa razão e sobreviver como uma segunda tabela, um pouco maior, que sangrava para dentro da vizinha. O ExtractTables agora remove essas palavras antes de a passagem por espaços em branco rodar. Uma palavra é descartada quando seu ponto central está dentro dos limites de qualquer tabela que a passagem de grades produziu; usa-se o centro em vez da contenção total para que uma palavra que atravessa uma borda por uma fração de ponto siga a tabela a que ela visualmente pertence. A estratégia de espaços em branco então trabalha só com as palavras livres, o que também significa que uma tabela pequena sem bordas logo abaixo de uma com bordas é detectada por mérito próprio em vez de ser fundida com a grade acima dela

Por que Purpose of Request: saiu como of Purpose Request:?

As palavras saíram reordenadas porque as caixas de palavra que o PDFium Component monta são uniões de caixas delimitadoras 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 no espaço da página, e não uma caixa preenchida até a ascendente e a descendente da fonte, e a caixa de palavra é a união das caixas dos seus caracteres. Uma palavra sem descendentes é portanto mais baixa e seu centro vertical fica mais alto, de 2 a 3 pontos no formulário em questão. A antiga rotina de texto de célula ordenava as palavras por centro Y primeiro, com uma tolerância de 1 ponto para mesma linha, e depois pela borda esquerda; of passava da tolerância, era ordenado como linha própria acima das outras e saía primeiro

Isso não é uma peculiaridade do PDFium, e sim uma consequência de como o PDF posiciona texto. A ISO 32000-1 §9.2.2 e §9.4.4 definem o posicionamento de glifos como deslocamento horizontal ao longo da linha de base no espaço de texto, e as únicas métricas verticais que o arquivo carrega são por fonte: as entradas Ascent, Descent e FontBBox do descritor de fonte na §9.8.1. Nada no arquivo diz que dois glifos compartilham uma linha; isso precisa ser inferido da geometria, e as caixas justas de glifo que fazem o realce de seleção parecer certo, como descrito em seleção de linhas de texto com char boxes do PDFium, são a entrada errada para uma comparação de distância entre centros

A correção na versão 3.117.0 troca a pergunta de quão distantes estão os centros para quanto 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 entra numa linha quando sua sobreposição vertical com os limites correntes da linha é de pelo menos 25 por cento da menor das duas alturas, depois ordenando por inserção cada linha pela borda esquerda, e então juntando as linhas com uma quebra de linha. Purpose e of se sobrepõem ao longo de toda a altura-x, o que é muito mais que 25 por cento da caixa mais baixa, então caem na mesma linha e se ordenam por X como pretendido

Diagrama do PDFium Component da correção da reordenação de Purpose of Request: 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 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 Y se move conforme as ascendentes e descendentes que a tinta por acaso carrega, enquanto duas caixas numa mesma linha de base se sobrepõem ao longo da altura-x compartilhada, sejam quais forem suas alturas

Agrupe linhas de texto por sobreposição, não por distância entre centros

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

A mesma regra é fácil de aplicar fora da extração de tabelas. TPdf.PageWordBoxes devolve toda palavra da página ativa com seu retângulo no espaço da página, então agrupar uma página em linhas visuais é um laço 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;
  // ordene cada linha por Rect.Left antes de lê-la; PageWordBoxes devolve
  // palavras na ordem do content stream, que não é garantidamente visual
end;

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

O que importa nesse trecho é o predicado, não o laço; para qualquer coisa além de um despejo rápido, comece pelo modelo de texto estruturado, que já carrega blocos, linhas e uma origem de ordem de leitura, como coberto em extração de texto estruturado de PDF com ordem de leitura. Quem já chama a API de tabelas recebe as três correções sem mexer nas opções. O limiar do corredor é fixo em metade do MinColumnGap, a estratégia de espaços em branco mantém seu piso de duas linhas mesmo com MinRows definido como 1 (o que a estratégia de grades agora aceita), e a filtragem de palavras já usadas por grades é incondicional sempre que as duas estratégias estão ligadas. No conjunto de 13 documentos de amostra usado na release, a passagem por espaços em branco antes devolvia 34 fragmentos e falsos positivos ao lado de 9 tabelas por grades; depois da release não devolve nenhum, e a contagem de tabelas por grades subiu para 41, embora a maior parte desse aumento venha da mesma release ensinando o detector de grades a ler bordas desenhadas como retângulos preenchidos, o que é outra história

Os limites honestos: o teste de corredor precisa de pelo menos uma linha com conteúdo nos dois lados de um limite para rejeitar alguma coisa, então um candidato de duas linhas cujos dois vãos esticados por acaso fiquem dentro de 6 pontos um do outro ainda passa. É uma coincidência estreita em vez da quase certeza que era antes, mas documentos cheios de prosa sem tabelas reais de duas linhas podem fechar isso definindo MinRows como 3. Texto alinhado à esquerda com bordas irregulares nunca foi o problema e não é afetado. E o PDF continua sem objeto de tabela; a ISO 32000-1 §14.8.4.3 define um elemento de estrutura Table, mas só o PDF com tags o carrega, então para todo o resto a grade continua sendo uma inferência a partir da geometria, e o valor de confiança em cada TPdfTable existe porque inferência merece uma nota

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 vem junto, está descrita na página do PDFium Component para Delphi