Artigo Técnico

Continuação de tabelas entre páginas de PDF no Delphi

O PDFium Component na versão 3.117.0 liga uma tabela que se quebra numa fronteira de página quando os dois fragmentos tocam as bordas da página ou quando nenhum texto de corpo fica abaixo do primeiro fragmento e acima do segundo, com cabeçalhos e rodapés correntes ignorados. ExtractDocumentTables aplica esse teste por conteúdo como alternativa ao teste mais antigo de margem de página, recusa um fragmento da página seguinte cuja primeira linha seja uma única célula de legenda de largura total, e mantém uma linha única que transborda para a página seguinte como parte da sua cadeia de continuação

O artigo sobre detecção e extração de tabelas apresentava a continuação como quatro portões estritos e tratava tocar a borda da página como um deles. Essa descrição era correta para a release que ele cobria, e também estava errada para a maioria das tabelas que as pessoas realmente passam ao componente. Este artigo é a correção: quais documentos o teste de margem não dá conta, o que o substituiu, e os dois casos laterais que a correção trouxe junto

Por que o teste de margem de página falha em exportações do Word?

O teste de margem de página falha porque um processador de texto para de dispor linhas na margem inferior, e não na borda do papel. Com o ContinuationMargin padrão de 36 pontos, a regra original exigia que a borda inferior do fragmento anterior ficasse dentro de 36 pontos do pé da página e que a borda superior do fragmento seguinte ficasse dentro de 36 pontos do topo. Um documento exportado do Word com suas margens padrão de uma polegada põe a última linha pelo menos 72 pontos acima do pé da página, mais se houver rodapé, então a condição nunca se cumpria. Toda tabela longa desse documento voltava como fragmentos independentes com ContinuationGroup em zero, e o chamador tinha que costurar à mão outra vez. O teste ainda faz sentido para o que ele foi projetado: relatórios gerados por motores de layout que preenchem a página até uma caixa de conteúdo fixa e começam a página seguinte colada ao topo. Não é uma regra ruim, é uma regra incompleta, e é por isso que a versão 3.117.0 a manteve e acrescentou um segundo caminho em vez de substituí-la

O que o teste por conteúdo verifica em vez disso?

O teste por conteúdo verifica se algo além da tabela ocupa o espaço entre os dois fragmentos, usando as caixas de palavra de cada página em vez da geometria da página. Enquanto ExtractDocumentTables percorre o documento, ele registra, por página, a borda inferior mais baixa de qualquer palavra cujo topo fique acima da faixa de rodapé e a borda superior mais alta de qualquer palavra cujo fundo fique abaixo da faixa de cabeçalho. As duas faixas têm ContinuationMargin pontos de profundidade, então a mesma opção agora faz dupla função como folga até a borda da página e como altura das zonas de cabeçalho e rodapé correntes. Um par de fragmentos passa quando a borda inferior do anterior está no nível do texto de corpo mais baixo da sua página ou abaixo dele, e a borda superior do seguinte está no nível do texto de corpo mais alto da página seguinte ou acima dele, cada um dentro de AlignmentTolerance. Em termos simples: a tabela era a última coisa na página N e a primeira coisa na página N+1, e um número de página ou um título de documento na faixa da margem não conta. Essa exclusão não é arbitrária. A ISO 32000-1 §14.8.2.2 classifica cabeçalhos e rodapés correntes como artefatos de paginação, conteúdo que existe por causa da quebra de página e não apesar dela, e a mesma ideia que deixa um leitor de arquivos com tags pulá-los é o que deixa uma tabela continuar por cima deles. O artigo sobre conteúdo marcado cobre como arquivos com tags declaram esses artefatos explicitamente; aqui a classificação é inferida pela posição, porque a maioria das tabelas exportadas não carrega tag nenhuma

Por que a continuação de tabelas do PDFium Component precisa de dois testes: com as margens de uma polegada do Word o teste de margem exige bordas de fragmento dentro de janelas de 36 pt que o layout nunca alcança, enquanto o teste por conteúdo compara caixas de palavra e liga quando a tabela é o último conteúdo de corpo na página N e o primeiro na página N+1, ignorando as faixas de cabeçalho e rodapé correntes
Qualquer um dos testes abre o portão, e só então as verificações restantes rodam: páginas adjacentes, nenhuma linha de legenda de largura total no fragmento seguinte, e limites de coluna batendo dentro do dobro de AlignmentTolerance

Os dois testes se combinam com OR. Um relatório de motor de layout cujas tabelas vão até a borda do papel passa pelo primeiro; uma exportação do Word cujas tabelas param na margem passa pelo segundo; um documento que faz as duas coisas passa duas vezes. Só depois que um deles der certo é que os portões restantes rodam, e rodam numa ordem fixa: os números de página precisam ser adjacentes, o fragmento seguinte não pode começar com uma linha de legenda, e os limites das colunas precisam bater dentro do dobro de AlignmentTolerance, que é 6 pontos com os valores padrão. A enumeração é TPdfTableContinuation com os valores ptcNone, ptcStart, ptcMiddle e ptcEnd. Um fragmento marcado como ptcEnd que depois se liga a ainda outra página é promovido a ptcMiddle, então uma tabela de três páginas lê início, meio e fim na ordem das páginas. Os números de grupo começam em 1 e 0 significa sem ligação, e o ToJson emite a mesma informação nos membros continuation e continuationGroup, que é a forma a preferir se um serviço a jusante fizer a costura

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfTableExtractionOptions;
  Tables: TPdfTables;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'itinerary-from-word.pdf';
    Pdf.LoadDocument;

    Options := TPdfTableExtractionOptions.Default;
    Options.DetectContinuations := True;     // padrão; mostrado para clareza
    Options.ContinuationMargin := 54;        // rodapé de duas linhas, ~50 pt de altura

    Tables := Pdf.ExtractDocumentTables(Options);
    for I := 0 to High(Tables) do
      case Tables[I].Continuation of
        ptcStart:
          Writeln(Format('group %d starts on page %d (%d rows)',
            [Tables[I].ContinuationGroup, Tables[I].PageNumber,
             Tables[I].RowCount]));
        ptcMiddle, ptcEnd:
          Writeln(Format('group %d continues on page %d (%d rows)',
            [Tables[I].ContinuationGroup, Tables[I].PageNumber,
             Tables[I].RowCount]));
      else
        Writeln(Format('standalone table on page %d (%d rows)',
          [Tables[I].PageNumber, Tables[I].RowCount]));
      end;
  finally
    Pdf.Free;
  end;
end;

Como uma linha de legenda impede duas tabelas de se fundirem?

Um fragmento da página seguinte cuja primeira linha é uma célula que abrange todas as colunas é tratado como tabela nova, nunca como o resto da anterior. Essa regra existe porque o teste por conteúdo, sozinho, liga com facilidade demais. O caso que a expôs foi um formulário em estilo de transcrição: uma tabela termina perto do pé da página 1, uma segunda tabela com larguras de coluna idênticas começa perto do topo da página 2, nada além do rodapé fica entre elas, e as colunas batem no ponto. Sob o teste de margem as duas nunca se encontravam porque nenhuma tocava uma borda; sob o teste por conteúdo elas se ligaram de imediato, e um formulário com seções virou uma grade incoerente. O que separa as duas é visível na estrutura das células. A segunda tabela abre com uma legenda de seção como RECIPIENT INFORMATION disposta como uma única célula mesclada de largura total, e uma continuação de verdade nunca faz isso, porque a legenda pertence à tabela que já começou na página anterior. TableStartsWithCaptionRow codifica exatamente isso: o fragmento tem pelo menos duas colunas e contém uma célula com RowIndex = 0, ColumnIndex = 0 e ColumnSpan = ColumnCount. A checagem roda só no fragmento seguinte, então uma tabela cuja própria linha de legenda fica na primeira página não é afetada; a legenda está na página N, e só o fragmento da página N+1 é inspecionado

O portão da linha de legenda no PDFium Component: uma continuação de verdade abre com células de dados e entra no mesmo ContinuationGroup, enquanto um fragmento seguinte cuja linha zero tem uma única célula mesclada com RowIndex 0, ColumnIndex 0 e ColumnSpan igual a ColumnCount é recusado como continuação e reportado como tabela nova
A inspeção toca apenas o fragmento seguinte, então uma tabela cuja própria linha de legenda fica na primeira página não é afetada, e o portão roda depois que um dos dois testes de borda já ligou o par

A comparação de colunas que vem depois, TablesHaveMatchingColumns, é mais estrita que mesma contagem de colunas. Ela reconstrói as posições de limite de cada fragmento a partir dos retângulos das células, interpola limites que células mescladas escondem, e rejeita o par quando qualquer limite desvia mais que a tolerância. Duas tabelas de quatro colunas com proporções diferentes portanto ficam separadas mesmo quando todo o resto bate

O que acontece com uma linha única que transborda para a página seguinte?

Uma grade com linhas que leva uma linha para a página seguinte agora é detectada e ligada, desde que acabe numa cadeia de continuação; sozinha, ela é descartada. O MinRows padrão de 2 existe para impedir que um par de linhas perdido seja reportado como tabela, mas uma última linha empurrada para além da quebra é uma linha de verdade que um piso rígido de 2 descartava em silêncio, e o resto da tabela parecia completo quando não estava. A varredura no nível do documento resolve isso em três passos. Quando DetectContinuations e DetectRuledTables estão ambos ligados, a passagem por página roda o detector de grades com o piso de linhas temporariamente baixado para 1, e é por isso que ExtractTables agora aceita MinRows igual a 1 para grades com linhas, enquanto a detecção por espaços em branco mantém um piso interno de 2. As continuações são marcadas sobre o resultado completo. Depois, toda tabela mais curta que o MinRows do chamador e que não faça parte de nenhuma cadeia é removida. O fragmento de uma linha só sobrevive porque foi ligado, e uma grade de uma linha no meio de uma página comum é filtrada exatamente como antes

Como o PDFium Component mantém uma linha com bordas que transborda numa quebra de página: a passagem por página de grades roda com o piso de linhas em um quando DetectContinuations e DetectRuledTables estão ligados, as continuações são marcadas sobre o resultado completo, e só fragmentos mais curtos que MinRows e fora de toda cadeia são removidos
A linha transbordada sobrevive porque sua cadeia a liga, enquanto uma grade de uma linha isolada numa página comum é filtrada exatamente como antes, e tabelas detectadas por espaços em branco mantêm o piso de duas linhas sem esse alívio
// Reconstrói cada cadeia como um CSV, descartando linhas de cabeçalho repetidas
// nos fragmentos de continuação
procedure ExportChains(const Tables: TPdfTables; const Folder: string);
var
  I, R: Integer;
  Lines: TStringList;
  Csv: TStringList;
begin
  Csv := TStringList.Create;
  Lines := TStringList.Create;
  try
    for I := 0 to High(Tables) do
    begin
      if Tables[I].Continuation in [ptcNone, ptcStart] then
        Csv.Clear;
      Lines.Text := string(Tables[I].ToCsv);
      if (Tables[I].Continuation in [ptcMiddle, ptcEnd]) and
         (Lines.Count > 1) and (Tables[I].RowCount > 1) then
        Lines.Delete(0);            // cabeçalho repetido pelo processador de texto
      for R := 0 to Lines.Count - 1 do
        Csv.Add(Lines[R]);
      if Tables[I].Continuation in [ptcNone, ptcEnd] then
        Csv.SaveToFile(Format('%s\page%d-group%d.csv',
          [Folder, Tables[I].PageNumber, Tables[I].ContinuationGroup]));
    end;
  finally
    Lines.Free;
    Csv.Free;
  end;
end;

Dois detalhes nessa rotina são deliberados. A linha transbordada nunca é removida, porque a guarda em RowCount a mantém, e um processador de texto que repete a linha de cabeçalho em cada página produz um fragmento cuja primeira linha é o cabeçalho outra vez, então descartar a linha zero em fragmentos de meio e de fim está certo para esse caso e errado para um gerador que não repete cabeçalhos. Teste um documento antes de soltar a rotina numa pasta inteira

Onde as regras ainda param

O teste por conteúdo só vale tanto quanto a camada de texto que ele lê. Numa página digitalizada sem texto nenhum, os extremos de texto de corpo registrados caem nos limites da página, a condição de nada entre eles é satisfeita de forma vazia, e só restam os portões de linha de legenda e de colunas; uma grade com linhas nessa página ainda é encontrada como esqueleto vazio, então a cadeia pode se ligar corretamente, mas nada sobre o texto em volta foi de fato verificado. Adicione uma camada de texto primeiro se isso importar. Rodapés renderizados como imagem em vez de texto são invisíveis para a lógica de faixas e inofensivos pelo mesmo motivo

As faixas são um número só. Um rodapé mais profundo que o ContinuationMargin deixa suas linhas de baixo dentro da zona de corpo, o que faz o fragmento anterior parecer seguido de texto e bloqueia a ligação; aumente a opção para a profundidade real da faixa, como o primeiro exemplo faz. Aumente demais e um parágrafo final curto perto do pé da página escorrega para dentro da faixa e é ignorado, o que liga uma tabela ao que vier depois dela. A regra da legenda tem uma falha espelhada: um gerador que escreve uma faixa mesclada de continuação como primeira linha de todo fragmento de continuação terá esses fragmentos recusados como tabelas novas, e o único remédio hoje é costurar você mesmo por ContinuationGroup depois de não afrouxar nada, porque a regra não tem chave de liga e desliga

Tabelas detectadas por espaços em branco não ganham nenhum desse alívio de uma linha. A estratégia de espaços em branco precisa de duas linhas alinhadas para enxergar uma tabela, então uma tabela sem bordas que transborda uma linha ainda é reportada com uma linha a menos. Quando você esbarrar nisso, as caixas de palavra por trás de blocos de texto estruturado e ordem de leitura dão as posições cruas para recuperá-la. No conjunto de amostras que guiou este trabalho, treze exportações de processadores de texto e navegadores, os cinco documentos com tabelas de várias páginas de verdade se ligaram todos em cadeias únicas e o formulário de transcrição que antes se fundia ficou separado, que é o parâmetro pelo qual a release foi medida, e não uma promessa sobre todo layout

A marcação de continuação, a regra da legenda e a passagem de uma linha vivem todas no caminho no nível do documento compartilhado pelas compilações para Delphi, C++Builder e Lazarus; a API completa de extração de tabelas está descrita na página do PDFium Component para Delphi