Uma tabela exportada do Word ou do Google Docs pode carregar centenas de peças de borda numa página e nenhuma linha traçada sequer. Descomprima a página com qpdf --qdf e o content stream se lê como uma coluna de pares re f, cada um descrevendo uma caixa de meio ponto de altura. Um detector de tabelas que só procura segmentos traçados não acha nada, cai na detecção por espaços em branco alinhando palavras, e devolve um espalhado de fragmentos de duas e três linhas onde o autor desenhou uma grade limpa. As bordas estão todas lá. Elas são pintadas com um fill, então o detector tem que ler retângulos preenchidos como linhas de tabela e depois puxar bordas quase encostadas para linhas de grade compartilhadas
Por que uma tabela do Word não tem linhas traçadas
Um processador de texto trata uma borda de célula como uma caixa com largura, e pinta essa caixa com o operador de fill. A ISO 32000-1 §8.5.2.1 define re como anexar um subcaminho de retângulo fechado, e a §8.5.3 separa os operadores de pintura: S traça o caminho com a largura de linha corrente, f preenche o interior. Uma borda de 0,5 ponto sai como x y w 0.5 re f, e a maquinaria de traço (largura de linha, junções, padrão de tracejado) nunca roda. Sombreamento de célula é a mesma construção com uma caixa maior. A forma traçada que um detector orientado a linhas espera — m, l, S — é a que exportações de office raramente usam:
% uma borda de célula de uma exportação de processador de texto: caixa preenchida de 0,5 pt
72 700 468 0.5 re f
% sombreamento de célula: caixa preenchida do tamanho da célula
72 676 117 24 re f
% a linha de grade traçada que um detector só de traço procura
72 700 m 540 700 l S
Se você fosse escrever o próprio detector na API bruta do PDFium, o primeiro rascunho costuma chamar FPDFPath_GetDrawMode, manter o caminho quando a flag de traço está ligada, e seguir em frente. As duas caixas preenchidas acima falham nesse teste. As palavras dentro das células chegam à detecção por espaços em branco, e numa exportação compacta as colunas ficam uns 6 pontos separadas. No PDFium Component o MinColumnGap padrão é 12 pontos, então essas colunas não se registram. Baixar o MinColumnGap até elas se registrarem faz o espaçamento esticado de parágrafos justificados se registrar também, e a grade que o autor desenhou nunca volta, porque a informação que a define mora nas caixas preenchidas
Como o PDFium Component lê uma caixa preenchida como linha de tabela
O PDFium Component lê a geometria de tabelas de todo objeto de caminho na página, um subcaminho por vez, e como TPdfTableExtractionOptions.DetectFilledRulings tem default True, um caminho cujo modo de fill é qualquer coisa diferente de none é examinado por linhas ao lado dos caminhos traçados. O filtro que vem depois é estrito de propósito. Um subcaminho preenchido se qualifica só quando tem entre quatro e oito pontos, não contém segmento de curva, e todo ponto senta num canto da caixa delimitadora do subcaminho a menos de 0,05 ponto. Um retângulo desenhado com re passa, e um desenhado como cinco pontos explícitos com um canto repetido também. Um triângulo, um chevron, um contorno em L ou uma aba arredondada falha no teste e não contribui com nada
O retângulo que sobrevive é classificado pela caixa delimitadora contra MaxRulingThickness, que tem default 3 pontos. Largura igual ou abaixo do limite com altura acima dá uma linha vertical no centro horizontal, cobrindo toda a altura da caixa; o caso espelhado dá uma linha horizontal. Quando as duas dimensões excedem o limite, a caixa é tratada como célula sombreada e contribui com quatro linhas, uma por borda. Quando as duas dimensões estão iguais ou abaixo, a caixa não contribui com nada, então um marcador quadrado de 2 pontos numa lista nunca vira um coto de grade de tabela. A cor de fill não participa de nada disso. Uma caixa branca numa página branca produz as mesmas linhas que uma preta, que é por isso que um painel de fundo do tamanho da tabela pode aparecer como uma moldura externa
Caminhos traçados mantêm a rota própria deles. Todo segmento reto cujos endpoints diferem em não mais que RulingTolerance (1 ponto por padrão) num eixo vira uma linha ao longo do outro, segmentos de curva são pulados, e o segmento de fechamento de um subcaminho fechado conta também. Um caminho pintado com fill e traço ao mesmo tempo contribui pelas duas rotas, e as peças sobrepostas são fundidas depois pelo passo de merge. Um retângulo arredondado traçado ainda dá linhas para os lados retos; só os arcos nos cantos caem fora. A chamada de extração em si não precisa de preparação especial:
uses
System.SysUtils, PDFium;
var
Pdf: TPdf;
Options: TPdfTableExtractionOptions;
Tables: TPdfTables;
Mode: string;
I, J, WithText: Integer;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'itinerary-from-word.pdf';
Pdf.LoadDocument;
Pdf.PageNumber := 1; // baseado em 1
Options := TPdfTableExtractionOptions.Default;
// os campos de linha preenchida, escritos por extenso nos valores padrão
Options.DetectFilledRulings := True;
Options.MaxRulingThickness := 3.0; // pontos; precisa ser maior que 0
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';
WithText := 0;
for J := 0 to High(Tables[I].Cells) do
if Tables[I].Cells[J].Text <> '' then
Inc(WithText);
Writeln(Format('%dx%d %s, confidence %.2f, %d of %d cells hold text',
[Tables[I].RowCount, Tables[I].ColumnCount, Mode,
Tables[I].Confidence, WithText, Length(Tables[I].Cells)]));
end;
finally
Pdf.Free;
end;
end;
Definir MaxRulingThickness como 0 não desliga a feature. O ExtractTables valida as opções de geometria de antemão e levanta EPdfError com "Table extraction geometry options are invalid" para um limiar de zero ou abaixo. Para obter o comportamento só de traço, defina DetectFilledRulings como False. Deixe ligado para qualquer coisa que saiu de uma suíte de office ou de um diálogo de impressão de navegador; o filtro estrito de retângulo é o que mantém o custo baixo, já que fills decorativos quase nunca passam nele
O que RulingSnapTolerance faz por tabelas de células sombreadas?
O RulingSnapTolerance é o que deixa 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 separada das vizinhas por um vão branco de 1 a 3 pontos. Cada caixa rende as quatro linhas de borda dela, mas a borda direita de uma célula e a borda esquerda da seguinte ficam, digamos, 2 pontos apart, e o teste que decide se duas linhas pertencem à mesma grade admite só RulingTolerance, 1 ponto. Sem encaixe, cada célula é uma ilha de quatro linhas descrevendo uma grade 1x1, nada alcança MinColumns ou MinRows (ambos 2 por padrão), e a passagem de grades não reporta tabela nenhuma numa página que claramente tem uma
O passo de encaixe 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. Cada lista é ordenada e dividida em clusters onde dois vizinhos diferem em mais que a tolerância; cada cluster é substituído pela média, e toda posição, começo e fim então se move para o centro mais próximo. Bordas em 230.0 e 232.0 viram ambas 231.0, as duas células compartilham uma linha, e as linhas horizontais se esticam com elas para que os cantos se encontrem
Peças colineares que se tocam ou se sobrepõem dentro de RulingTolerance são fundidas em linhas únicas. Só então o detector de grades roda, comparando linhas par a par; uma tabela de 30 linhas que chegou como 720 peças de borda chega a essa comparação como umas poucas dúzias de linhas
Numa grade traçada o passo não muda nada, porque coordenadas que já batem se encaixam nelas mesmas. A pegadinha é que o encadeamento não tem limite de largura próprio. Uma sequência de valores a 3 pontos um do outro — 100, 103, 106 e 109 — colapsa num único centro em 104.5 mesmo que os valores externos estejam 9 pontos apart. Com o default de 4 pontos isso só morde colunas mais estreitas que um caractere, então saída de office pode manter o default de 4. Baixe para 1 ou 2 quando um formulário tiver colunas deliberadamente estreitas (tick boxes, campos de número de conta com um dígito por célula) e defina 0 para desligar o encaixe. Rodar a estratégia de grades sozinha sob três ajustes mostra de que mecanismo uma página depende:
// Só a estratégia de grades, para a detecção por espaços em branco não mascarar o resultado
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;
// Esperado para uma tabela desenhada só como células sombreadas com vãos de 2 pt:
// só traço -> 0 (nenhuma geometria traçada)
// caixas preenchidas, encaixe 4 -> 1 (as bordas do vão compartilham uma linha)
// caixas preenchidas, encaixe 0 -> 0 (cada célula continua ilha 1x1)
Writeln('stroke only: ', CountRuledTables(Pdf, False, 4.0));
Writeln('filled boxes, snap 4: ', CountRuledTables(Pdf, True, 4.0));
Writeln('filled boxes, snap 0: ', CountRuledTables(Pdf, True, 0.0));
Quando a terceira contagem cai abaixo da segunda, a página depende do encaixe, e essa é a página para conferir a olho nu antes de baixar a tolerância
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 diz 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 é escrito no espaço do form e só chega ao espaço da página depois de duas ou mais transformações. Com IncludeFormXObjects ligado — o default —, o PDFium Component desce em cada objeto de form, combina a matriz do objeto com a matriz do pai, e entrega a matriz combinada a cada filho, então uma borda desenhada como 72 700 468 0.5 re f dentro de um form transladado pousa onde o viewer a mostra. A descida para em oito níveis de forms aninhados
A ordem dessa combinação importa. Um ponto dentro do form passa primeiro pela matriz do objeto e depois pela matriz do pai; troque os operandos e o termo de translação é aplicado no espaço errado, então uma linha que pertence ao topo da página acaba perto da origem ou fora da página. A mesma armadilha aparece sempre que você constrói matrizes por conta própria, e o artigo sobre prepend versus append de matrizes a percorre com um pivô de rotação
Por que o orçamento padrão de linhas é 16384?
O MaxRulingSegments padrão de 16384 reflete quantas peças uma exportação de bordas por célula produz. Uma tabela traçada de 30 linhas e 6 colunas precisa de 38 segmentos de linha: 31 horizontais e 7 verticais. A mesma tabela exportada como caixas preenchidas pode carregar quatro peças de borda por célula, 720 antes do merge, e sombrear cada célula adiciona mais quatro linhas de borda por célula, 1440 no total. Duas dessas tabelas numa página já precisam de perto de 3000
Estourar o orçamento é um erro, e ele aparece como um. O ExtractTables levanta EPdfError com a mensagem "Table ruling-segment budget exceeded", não retorna grade parcial nenhuma, e a passagem por espaços em branco daquela página também não roda. Um orçamento apertado é razoável para entrada não confiável, em que uma página maliciosa poderia guardar milhões de caixinhas, mas então o chamador precisa distinguir "complexo demais para este orçamento" de "sem tabelas nesta página":
function TryExtractTables(Pdf: TPdf; Budget: Integer;
out Tables: TPdfTables): Boolean;
var
Options: TPdfTableExtractionOptions;
begin
Options := TPdfTableExtractionOptions.Default;
Options.MaxRulingSegments := Budget; // 16384 por padrão
try
Tables := Pdf.ExtractTables(Options);
Result := True;
except
on E: EPdfError do
begin
// LogWarning é a sua rotina de log, não faz parte da biblioteca
LogWarning(Format('Page %d: %s', [Pdf.PageNumber, E.Message]));
Tables := nil;
Result := False; // orçamento estourado ou opções inválidas
end;
end;
end;
// Um resultado False significa "não decidido", o que é diferente de um array vazio
if not TryExtractTables(Pdf, 4096, Tables) then
QueueForReview(Pdf.FileName, Pdf.PageNumber); // também uma rotina sua
O mesmo tipo de exceção cobre o orçamento de palavras e opções inválidas, então a mensagem registrada no log é o que distingue umas das outras numa fila de revisão
Onde o caminho de grades passa o bastão
Detecção por grades roda primeiro, detecção por espaços em branco roda depois, e duas regras evitam que uma pise na outra. Palavras cujos centros caem dentro de uma grade que a passagem de grades já reportou são removidas antes de a detecção por espaços em branco começar, então uma tabela nunca é reportada duas vezes. Um limite de coluna por espaços em branco também precisa ser um corredor vertical livre de texto por todas as linhas que ele separa, o que impede parágrafos justificados de pontuar como tabelas; o artigo sobre falsos positivos de tabelas por espaços em branco cobre essa regra. Ler retângulos preenchidos move tabelas exportadas de office do caminho de espaços em branco para o de grades
Como as linhas vêm de objetos de caminho e o texto vem da página de texto, uma página sem camada de texto ainda produz o esqueleto da grade com toda célula vazia. A confiança de uma grade de linhas é 0,88 mais 0,12 vezes a fração de células que guardam texto, então um esqueleto vazio reporta exatamente 0,88 e a contagem de células da primeira listagem mostra 0 células com texto. Essa combinação é o sinal de rodar OCR primeiro, como descrito em adicionar uma camada de texto pesquisável com OCR. Formas preenchidas com curvas ou contornos não retangulares não contribuem com nada, então uma tabela cujas células são retângulos arredondados preenchidos cai na detecção por espaços em branco. Uma tabela sem bordas nem sombreamento pertence à estratégia de espaços em branco descrita no artigo de detecção e extração de tabelas, e 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 escrito para uma família de documentos
A demo TableExtractionLab que vem com o componente tem uma check box para DetectFilledRulings, então você pode carregar uma exportação e ver a grade aparecer e sumir sem escrever código nenhum. Opções, tipos de record e o resto da API de extração estão listados na página do produto PDFium Component para Delphi