Artigo Técnico

Retângulos preenchidos como bordas de tabela no Delphi

A extração de tabelas do PDFium Component, a partir da versão 3.117.0, trata um retângulo preenchido fino como linha de tabela. Com DetectFilledRulings habilitado, que é o padrão, uma caixa preenchida alinhada aos eixos e não mais grossa que MaxRulingThickness (3 pontos) vira uma linha ao longo do seu eixo maior, uma caixa preenchida maior contribui com suas quatro bordas, e toda coordenada de linha é encaixada dentro de RulingSnapTolerance (4 pontos) antes de a grade ser montada. Tabelas exportadas do Word, do Google Docs e de navegadores portanto chegam ao detector de grades como grades completas em vez de caírem na detecção por espaços em branco como fragmentos

O artigo anterior sobre detecção e extração de tabelas afirmava que a detecção por grades usa as linhas desenhadas e que cada segmento de caminho traçado é transformado em coordenadas de página. Essa frase era verdadeira e incompleta. Contar objetos de caminho num conjunto de 13 documentos de amostra do mundo real mostrou que 9 deles não contêm caminho traçado nenhum, e ainda assim cada uma das suas páginas carrega centenas de retângulos preenchidos de 0,5 a 1 ponto de espessura. O detector só de traço não via nada, toda página caía na detecção por espaços em branco, e a saída era um espalhado de fragmentos pequenos em vez de tabelas. O preset compact-columns adicionado na 3.116.4 suavizou isso no nível do fragmento; a causa raiz era que o detector estava lendo o operador de pintura errado

Por que uma tabela exportada do Word não tem linhas traçadas?

Um processador de texto não pensa numa borda como uma linha; pensa nela como uma caixa com largura, e pinta essa caixa com um preenchimento. A ISO 32000-1 §8.5.2.1 define o operador re como quem anexa um subcaminho de retângulo, e a §8.5.3 separa os operadores de pintura: S traça o caminho com a largura de linha atual, f preenche o interior. Uma borda de célula de 0,5 ponto sai como x y w 0.5 re f, e a maquinaria de traço, largura de linha, junções e padrão de tracejado incluídos, nunca roda. O sombreado de célula é a mesma construção com uma caixa maior. Uma grade traçada desenhada com m, l e S é o que o detector original esperava, e é o que quase nada exportado de um aplicativo de escritório produz:

% uma borda de célula de uma exportação de processador de texto: uma caixa preenchida de 0,5 pt
72 700 468 0.5 re f
% sombreado de célula: uma caixa preenchida do tamanho da célula
72 676 117 24 re f
% a linha de grade traçada para a qual o detector original foi escrito
72 700 m 540 700 l S

Para um detector que pergunta ao FPDFPath_GetDrawMode apenas se a flag de traço está ligada, as duas caixas preenchidas são invisíveis. As palavras dentro das células chegam então à detecção por espaços em branco, onde colunas separadas por um vão de 6 pontos ficam abaixo do MinColumnGap padrão de 12 pontos, e o que volta é o subconjunto de linhas que por acaso se alinha bem o bastante para passar pelo MinRows. Esse é o comportamento de fragmento, e nenhum ajuste de parâmetro o transforma na grade que o autor desenhou

Como o PDFium Component transforma uma caixa preenchida em linha de tabela?

TableCollectObjectRulings inspeciona cada objeto de caminho subcaminho por subcaminho. O modo de pintura vem do FPDFPath_GetDrawMode; um caminho conta como preenchido quando DetectFilledRulings está ligado e o modo de preenchimento não é none. Cada ponto é transformado pela matriz do objeto e coletado, até MaxSubpathPoints (8) por subcaminho, e qualquer segmento de curva marca o subcaminho como curvo. Quando o subcaminho fecha ou um novo MoveTo começa, FlushSubpath decide o que ele era: um subcaminho curvo é descartado, e também qualquer polígono fechado cujos pontos não fiquem todos dentro de PointTolerance (0,05 pontos) das bordas da caixa delimitadora em pelo menos um eixo. Um triângulo, um chevron ou uma aba arredondada nunca vira linha de tabela, e é isso que mantém arte decorativa fora da grade

Diagrama do PDFium Component de como TableCollectObjectRulings transforma subcaminhos fechados em linhas de tabela no Delphi: FlushSubpath descarta contornos curvos e polígonos fora das bordas da caixa delimitadora, MaxRulingThickness divide caixas finas em uma linha por eixo maior, células sombreadas dão quatro linhas de borda e DetectFilledRulings mantém quadrados minúsculos fora
Um subcaminho fechado só sobrevive quando está alinhado aos eixos, e a caixa delimitadora então decide se ele é uma linha, quatro bordas de uma célula sombreada, ou nada

O que sobrevive é um retângulo alinhado aos eixos, classificado pela caixa delimitadora. Largura igual ou abaixo de MaxRulingThickness com altura acima disso produz uma linha vertical no centro horizontal, cobrindo a caixa de baixo até em cima; o caso espelhado produz uma linha horizontal. As duas dimensões acima do limite significam uma célula sombreada, e a caixa contribui com quatro linhas, uma por borda. As duas dimensões iguais ou abaixo do limite não contribuem com nada, então um marcador quadrado de 2 pontos não é confundido com uma linha. Um caminho traçado segue a rota mais antiga por AddLine, uma linha por segmento alinhado aos eixos, então uma grade desenhada com S é tratada exatamente como antes, e um caminho pintado com preenchimento e traço ao mesmo tempo produz peças sobrepostas que a passagem de merge junta:

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfTableExtractionOptions;
  Tables: TPdfTables;
  Mode: string;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'itinerary-from-word.pdf';
    Pdf.LoadDocument;
    Pdf.PageNumber := 1;                     // baseado em 1

    Options := TPdfTableExtractionOptions.Default;
    // estes são os padrões da 3.117.0, escritos por extenso para clareza
    Options.DetectFilledRulings := True;     // caixas preenchidas finas viram linhas
    Options.MaxRulingThickness := 3.0;       // pontos; caixas mais grossas contam como sombreado
    Options.RulingSnapTolerance := 4.0;      // pontos; 0 desliga o encaixe
    Options.IncludeFormXObjects := True;

    Tables := Pdf.ExtractTables(Options);
    for I := 0 to High(Tables) do
    begin
      if Tables[I].DetectionMode = ptdmRuled then
        Mode := 'ruled'
      else
        Mode := 'whitespace';
      Writeln(Format('%dx%d %s, confidence %.2f',
        [Tables[I].RowCount, Tables[I].ColumnCount, Mode,
         Tables[I].Confidence]));
    end;
  finally
    Pdf.Free;
  end;
end;

O que RulingSnapTolerance faz por tabelas de células sombreadas?

O RulingSnapTolerance é o que faz uma tabela construída só de sombreamento se conectar numa grade única. Algumas exportações não desenham borda nenhuma: cada célula é uma caixa preenchida com a própria cor, e caixas vizinhas são separadas por um vão branco de 1 a 3 pontos. Cada caixa produz quatro linhas de borda, mas a borda direita de uma célula e a borda esquerda da seguinte ficam a 2 pontos de distância, e o teste de conectividade usa RulingTolerance, que por padrão é 1 ponto. Sem o encaixe, cada célula forma o próprio componente conectado de quatro linhas, nenhum componente alcança MinRows, e a página não reporta nada. TableSnapRulings reúne toda coordenada X em jogo (a posição de cada linha vertical mais o começo e o fim de cada horizontal) e toda coordenada Y do mesmo modo, ordena cada lista, agrupa por encadeamento dos valores cujo vizinho difere no máximo a tolerância, substitui cada grupo pela média, e então move toda posição, começo e fim para o centro de grupo mais próximo. Os dois lados de um vão viram a mesma linha, e a conectividade se sustenta

Diagrama do PDFium Component de RulingSnapTolerance conectando uma tabela de células sombreadas no Delphi: células vizinhas deixam um vão de 2 pt, suas linhas de borda ficam além do RulingTolerance de 1 pt, e TableSnapRulings encadeia os dois valores de X numa média de grupo só para o teste de conectividade finalmente ver uma linha de grade compartilhada
O encaixe roda antes do merge e antes do detector de grades, então dois lados de um vão branco viram uma linha e cada célula deixa de ser uma ilha de quatro linhas

O encaixe roda antes de TableMergeRulings, que ordena as linhas e junta peças colineares que se tocam ou se sobrepõem dentro de RulingTolerance, e os dois rodam antes de TableDetectRuled ver os dados, então a checagem de conectividade par a par é proporcional ao número de linhas de grade e não ao número de fragmentos por célula. Numa grade traçada as passagens são inofensivas, porque coordenadas que já eram idênticas se encaixam nelas mesmas. A única coisa a ter em mente é que o agrupamento por encadeamento não tem limite de largura próprio: uma sequência de coordenadas a 3 pontos uma da outra colapsa num único centro. No padrão de 4 pontos isso só afeta colunas mais estreitas que um caractere, mas se um documento tiver vãos reais de 3 pontos que precisem ficar separados, baixe a tolerância ou zere-a para desligar o encaixe:

// Isola a estratégia de grades e compara o que cada ajuste enxerga numa página
function CountRuledTables(Pdf: TPdf; FilledRulings: Boolean;
  SnapTolerance: Double): Integer;
var
  Options: TPdfTableExtractionOptions;
begin
  Options := TPdfTableExtractionOptions.Default;
  Options.DetectWhitespaceTables := False;
  Options.DetectFilledRulings := FilledRulings;
  Options.RulingSnapTolerance := SnapTolerance;
  Result := Length(Pdf.ExtractTables(Options));
end;

// Uma exportação do Word tipicamente reporta 0, N e depois menos que N:
// só traço não vê nada, o encaixe conecta as células sombreadas,
// e desligar o encaixe deixa cada célula sombreada como ilha própria
Writeln(CountRuledTables(Pdf, False, 4.0));
Writeln(CountRuledTables(Pdf, True, 4.0));
Writeln(CountRuledTables(Pdf, True, 0.0));

Linhas dentro de form XObjects

Ferramentas de layout de página costumam embrulhar uma tabela, ou o corpo inteiro da página, num form XObject e pintá-lo com Do. A ISO 32000-1 §8.10.1 especifica que a matriz do form é concatenada com a matriz de transformação corrente quando o form é pintado, então um retângulo dentro do form vive no espaço do form e só chega à página depois de duas ou mais transformações. TableCollectObjectRulings desce recursivamente nos objetos de form quando IncludeFormXObjects está ligado: ela lê a matriz do objeto, combina com a matriz do pai por TableMultiplyMatrix, cuja ordem de argumentos significa mapear pela primeira matriz e depois pela segunda, e enumera os filhos com FPDFFormObj_CountObjects e FPDFFormObj_GetObject, passando a matriz combinada para baixo. Aninhamento mais profundo que MaxFormDepth (8) é pulado em silêncio, o que é uma proteção contra arquivos patológicos e não um limite que alguma exportação real chegue perto. O motivo de a ordem da multiplicação importar é o mesmo discutido em prepend versus append de matrizes: trocar os operandos move o termo de translação, e uma linha que deveria cair no topo da página cai na origem

Diagrama do PDFium Component de linhas dentro de um form XObject no Delphi: um retângulo fino escrito como 72 700 468 0.5 re f vive no espaço do form e só chega à página depois que TableMultiplyMatrix combina a CTM do pai com a matriz do form, descendo por FPDFFormObj_CountObjects até MaxFormDepth
O retângulo é escrito no espaço do form e só chega ao topo da página depois que as matrizes são multiplicadas numa ordem que mantém o termo de translação onde ele pertence

Por que o orçamento de linhas quadruplicou?

O MaxRulingSegments padrão subiu de 4096 para 16384 na 3.117.0 porque bordas por célula chegam em número muito maior que linhas de grade traçadas. Uma tabela traçada de 30 linhas e 6 colunas são 38 segmentos de linha. A mesma tabela exportada como caixas preenchidas chega a quatro bordas por célula, 720 peças antes do merge, e um formulário com células sombreadas dobra isso. Duas tabelas dessas numa página teriam esgotado o orçamento antigo. O orçamento é imposto em TableAppendRuling por Check, que levanta EPdfError com a mensagem Table ruling-segment budget exceeded; não há resultado degradado, nem grade parcial, e a passagem por espaços em branco também não roda. Se você definir um orçamento mais apertado para entrada não confiável, capture a exceção e decida, em vez de ler um resultado vazio como ausência de tabelas:

Options := TPdfTableExtractionOptions.Default;
Options.MaxRulingSegments := 2048;        // apertado de propósito para entrada não confiável
try
  Tables := Pdf.ExtractTables(Options);
except
  on E: EPdfError do
  begin
    Log(E.Message);                       // 'Table ruling-segment budget exceeded'
    Options.MaxRulingSegments := 16384;   // o padrão da 3.117.0
    Tables := Pdf.ExtractTables(Options);
  end;
end;

Resultados medidos e onde a abordagem para

Nos mesmos 13 documentos de amostra, a extração passou de 43 tabelas, 9 delas por grades e 34 fragmentos ou falsos positivos por espaços em branco, para 41 tabelas por grades e nenhum falso positivo por espaços em branco. Parte dessa limpeza pertence a duas mudanças companheiras na 3.117.0: palavras já reivindicadas por uma grade são removidas antes de a detecção por espaços em branco rodar, então uma tabela nunca é reportada duas vezes, e um limite de coluna por espaços em branco agora precisa ser um corredor livre de texto por todas as linhas que separa, que foi o que impediu parágrafos justificados de pontuar como tabelas 5x4. O leitor de retângulos preenchidos é o que moveu as próprias tabelas da coluna de fragmentos para a coluna de grades

Vale dizer os limites sem rodeios. Uma página sem camada de texto continua produzindo o esqueleto da grade, com toda célula vazia, porque as linhas vêm da geometria e o texto vem da página de texto; páginas digitalizadas precisam de OCR antes. Formas preenchidas com curvas, cantos arredondados ou contornos não retangulares são descartadas por completo, então uma tabela cujas bordas sejam desenhadas como contornos de retângulo arredondado continua precisando da detecção por espaços em branco. Uma tabela sem bordas e sem sombreamento não muda com nada disso e segue sendo província da estratégia de espaços em branco descrita no artigo de extração de tabelas; quando nem isso basta, as caixas de palavra e os blocos de texto estruturado e ordem de leitura são a matéria-prima para um leitor específico do domínio. A demo TableExtractionLab que vem com o componente expõe DetectFilledRulings no painel de opções, que é o jeito mais rápido de ver como uma dada exportação fica com e sem ele; a API completa está descrita na página do PDFium Component para Delphi