Artigo Técnico

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

Uma tabela que atravessa três páginas de um export do Word nunca chega a menos de 72 pontos da borda de baixo do papel. Margens de uma polegada são 72 pontos, e o processador de texto para de posicionar linhas na margem. Um linker que junta fragmentos só quando eles sentam a até 36 pontos das bordas da página vê três tabelas sem relação com colunas idênticas. A pergunta que funciona é diferente: algo além de um running header ou rodapé senta entre o fim de um fragmento e o começo do próximo?

Essa pergunta traz duas armadilhas próprias. Um formulário pode terminar uma tabela no pé da página 1 e abrir uma tabela diferente com as mesmas larguras de coluna no topo da página 2. E uma grade com linhas pode empurrar uma única linha para o outro lado da quebra, curta demais para um detector que quer duas linhas antes de chamar algo de tabela

Dois testes de borda no ExtractDocumentTables

O PDFium Component marca continuações em TPdf.ExtractDocumentTables, a contraparte no nível do documento do ExtractTables por página coberto no artigo de detecção e extração de tabelas. Com DetectContinuations definido, como está em TPdfTableExtractionOptions.Default, o método extrai toda página, ordena as tabelas por número de página e então do topo de cada página para baixo, e checa todo par de vizinhos nessa lista. Só vizinhos são comparados. Um candidato a continuação é portanto sempre a tabela mais baixa da página N emparelhada com a tabela mais alta da página N+1, e uma tabela pequena colocada sob uma longa na página N toma o lugar da longa nesse pareamento. Quando uma tabela de nota curta senta abaixo de uma longa, ligue esses fragmentos no seu próprio código a partir dos números de página e do ColumnCount

O primeiro teste é puramente geométrico. A borda de baixo do fragmento anterior precisa ficar a até ContinuationMargin pontos do pé da página, e a borda de cima do fragmento posterior a até a mesma distância do topo da página seguinte. O padrão é 36 pontos, meia polegada. Relatórios de layout engines que enchem uma caixa de conteúdo fixa passam nisso fácil. Um export do Word com margens de uma polegada erra por pelo menos 36 pontos de cada lado, e alargar a margem até passar dá errado, porque o mesmo número define as faixas de header e rodapé do segundo teste

Esse segundo teste, o consciente de conteúdo, é o que cuida da saída de processadores de texto. Os dois se combinam com OR: qualquer um dos dois abre o portão

O que conta como texto entre dois fragmentos?

Toda palavra numa página conta como texto do corpo a menos que esteja inteiramente dentro da faixa do running header ou da faixa do rodapé, e ambas as faixas têm ContinuationMargin pontos de profundidade. Por página, ExtractDocumentTables registra a borda de baixo mais baixa entre palavras cujo topo está acima da faixa do rodapé e a borda de cima mais alta entre palavras cujo pé está abaixo da faixa do header, incluindo o texto de célula da própria tabela. Quando a tabela é o último conteúdo do corpo na página N, a borda de baixo dela senta no nível da palavra mais baixa ou abaixo dela, e o teste passa dentro de AlignmentTolerance, que tem padrão 3 pontos. O fragmento posterior precisa cumprir a condição espelhada no topo da página N+1

Ignorar as faixas tem base na especificação. A ISO 32000-1 §14.8.2.2 trata running heads, rodapés e números de página como artefatos de paginação, conteúdo que existe só porque o documento foi cortado em páginas. Um arquivo taggado pode declará-los com /Artifact, como o artigo de marked-content explica, mas os exports que mais precisam de costura de tabelas raramente carregam tags, então a posição faz o papel da tag. Com a faixa padrão, um número de página cujos glifos topo em 30 pontos acima da borda de baixo é ignorado. Uma linha de disclaimer que alcança 40 pontos é texto do corpo, e ela bloqueia o link

Numa página sem camada de texto, os extremos armazenados caem para os limites da página, o teste consciente de conteúdo passa trivialmente, e os portões restantes decidem. Rodapés desenhados como imagens ou contornos vetoriais são invisíveis pelo mesmo motivo e nunca bloqueiam um link

Por que a continuação de tabelas do PDFium Component precisa de dois testes: com margens de uma polegada do Word o teste de margem de página exige bordas de fragmento dentro de janelas de 36 pt que o layout nunca alcança, enquanto o teste consciente de conteúdo compara word boxes e liga quando a tabela é o último conteúdo do corpo na página N e o primeiro na página N+1, ignorando faixas de running header e rodapé
Qualquer um dos testes abre o portão, e só então as checagens restantes rodam: páginas adjacentes, nenhuma linha de legenda de largura total no fragmento posterior, e fronteiras de coluna batendo dentro de duas vezes AlignmentTolerance

Passar num teste de borda só abre o portão. As páginas precisam ser consecutivas, o fragmento posterior não pode abrir com uma linha de legenda, e as fronteiras de coluna precisam alinhar dentro de duas vezes AlignmentTolerance, 6 pontos nos padrões. Quando tudo isso se sustenta, o fragmento anterior vira ptcStart com um número de grupo novo, ou passa de ptcEnd para ptcMiddle se ele já fechou uma cadeia, e o fragmento posterior vira ptcEnd no mesmo grupo. Uma tabela espalhada por três páginas lê start, middle, end. Números de grupo começam em 1; grupo 0 com ptcNone significa que a tabela está sozinha. ToJson escreve os mesmos dados como "continuationGroup" e "continuation" (none, start, middle ou end), então um serviço que recebe JSON pode costurar por conta própria

A listagem abaixo assume um export do Word com um rodapé de duas linhas com uns 50 pontos de profundidade, então a margem sobe para 54 para manter as duas linhas dentro da faixa

uses
  System.SysUtils, 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;  // DetectContinuations = True
    Options.ContinuationMargin := 54;               // rodapé assumido com ~50 pt de profundidade

    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 x %d',
          [Tables[I].PageNumber, Tables[I].RowCount,
           Tables[I].ColumnCount]));
      end;
  finally
    Pdf.Free;
  end;
end.

Como uma primeira linha de largura total mantém duas tabelas separadas

Um fragmento posterior cuja primeira linha é uma célula única atravessando todas as colunas é reportado como tabela nova, mesmo quando um teste de borda passa e as colunas batem. Formulários são onde o teste consciente de conteúdo exageraria: uma tabela termina perto do pé da página 1, a próxima seção abre na página 2 com larguras de coluna idênticas, e toda checagem geométrica passa. A diferença é a linha de abertura da segunda tabela, uma legenda de seção como RECIPIENT INFORMATION disposta como uma célula mesclada pela largura total. Uma continuação genuína não abre desse jeito, porque a legenda de uma tabela fica na página onde a tabela começa

A regra lê só as células do fragmento posterior. Esse fragmento precisa de pelo menos duas colunas e de uma célula com RowIndex = 0, ColumnIndex = 0 e ColumnSpan = ColumnCount. O fragmento anterior nunca é inspecionado, então uma tabela cuja própria legenda senta no topo da página N liga normalmente à continuação dela na página N+1

O portão de linha de legenda no PDFium Component: uma continuação genuína abre com células de dados e entra no mesmo ContinuationGroup, enquanto um fragmento posterior cuja linha zero guarda uma 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 só o fragmento posterior, então uma tabela cuja própria linha de legenda senta na primeira página dela não é afetada, e o portão roda só depois de um dos dois testes de borda ter passado

A comparação de colunas que vem depois é mais estrita que uma contagem de colunas igual. As posições de fronteira de cada fragmento são reconstruídas dos retângulos de célula dele; uma fronteira escondida sob uma célula mesclada é preenchida espaçando-a uniformemente entre as fronteiras conhecidas mais próximas de cada lado. Então todo par de fronteiras é comparado, e uma diferença acima de 6 pontos rejeita o link. Duas tabelas de quatro colunas com proporções diferentes ficam separadas. Também fica uma continuação real cujo gerador deslocou uma coluna em 8 pontos entre páginas; subir AlignmentTolerance para aceitá-la também afrouxa o agrupamento usado durante a detecção

Uma linha com grade atravessando a quebra de página

Uma grade com linhas que empurra uma única linha para a página seguinte mantém essa linha quando ela entra numa cadeia e a perde quando não entra. O MinRows padrão de 2 impede um par de réguas soltas de ser reportado como tabela. Uma linha empurrada para o outro lado da quebra é diferente: ela é dado de verdade, e descartá-la deixaria a tabela costurada uma linha curta sem fragmento restante para explicar a lacuna

A varredura do documento cuida disso em três passos. Quando DetectContinuations e DetectRuledTables estão ambos ligados, o detector de grades roda em toda página com o piso de linhas dele rebaixado para 1. As continuações são então marcadas sobre o resultado inteiro. Por fim, toda tabela com menos linhas que o MinRows do chamador que não acabou numa cadeia é removida. Uma grade de uma linha no meio de uma página comum passa por esse filtro e some, que é exatamente para isso que o MinRows existe

Como o PDFium Component mantém uma linha com grade que transborda uma quebra de página: o passe de grades por página roda com o piso de linhas em um quando DetectContinuations e DetectRuledTables estão definidos, as continuações são marcadas sobre o resultado completo, e só fragmentos mais curtos que MinRows que sentam fora de toda cadeia são removidos
A linha transbordada sobrevive porque a cadeia dela a liga, enquanto uma grade de uma linha sozinha numa página comum é filtrada como de costume, e tabelas detectadas por whitespace mantêm o piso de duas linhas sem tal alívio

Deixe MinRows em 2. Defini-lo como 1 você mesmo desliga o filtro final, e toda grade de uma linha em toda página volta

A exceção vale só para grades com linhas. A estratégia de whitespace precisa de pelo menos duas linhas alinhadas para reconhecer uma tabela, então ela mantém um piso interno de 2 diga o que disser o MinRows; ExtractTables aceita MinRows = 1, mas só a detecção de grades o honra. Para uma tabela sem grade que transborda uma linha, os word boxes da página ou os blocks descritos em structured text blocks e ordem de leitura lhe dão as posições para recuperar essa linha você mesmo

Costurando uma cadeia de volta numa tabela só

As cadeias voltam contíguas. Links só são feitos entre vizinhos no array ordenado, então um fragmento ptcStart é seguido direto pelos fragmentos ptcMiddle e ptcEnd dele, e um loop que reinicia no ptcNone ou ptcStart e escreve no ptcNone ou ptcEnd nunca precisa procurar nada por número de grupo

A linha de header repetida é a parte espinhosa: o Word pode repeti-la em toda página, outros geradores não. A flag IsHeader em TPdfTableCell não decide isso, porque é atribuída por posição, RowIndex < HeaderRowCount, separadamente para cada fragmento. A linha 0 de uma continuação é marcada como header tanto se repete o header quanto se carrega dados, e o transbordo de uma linha da seção anterior também é marcado. A rotina abaixo compara o texto da primeira linha de cada continuação com a primeira linha da cadeia e a descarta só numa correspondência exata

Ele também constrói as linhas a partir de Cells em vez de partir a saída de ToCsv em linhas. Uma célula cujo texto quebra dentro da célula volta com um CR LF entre as linhas visuais dela, ToCsv mantém essa quebra dentro do campo citado, e apagar a primeira linha do texto CSV cortaria uma célula de header de duas linhas ao meio

// Helpers do lado do leitor (não fazem parte da biblioteca): reconstrua cada cadeia
// como um arquivo tab-separated, descartando headers repetidos em páginas posteriores
function RowText(const T: TPdfTable; Row: Integer): string;
var
  Parts: array of string;
  I: Integer;
begin
  SetLength(Parts, T.ColumnCount);
  for I := 0 to High(T.Cells) do
    if (T.Cells[I].RowIndex = Row) and (T.Cells[I].ColumnIndex >= 0) and
       (T.Cells[I].ColumnIndex < T.ColumnCount) then
      Parts[T.Cells[I].ColumnIndex] :=
        StringReplace(string(T.Cells[I].Text), #13#10, ' ', [rfReplaceAll]);
  Result := '';
  for I := 0 to High(Parts) do
  begin
    if I > 0 then
      Result := Result + #9;
    Result := Result + Parts[I];
  end;
end;

procedure ExportChains(const Tables: TPdfTables; const Folder: string);
var
  I, R, FirstRow: Integer;
  Header: string;
  Lines: TStringList;
begin
  Lines := TStringList.Create;
  try
    for I := 0 to High(Tables) do
    begin
      FirstRow := 0;
      if Tables[I].Continuation in [ptcNone, ptcStart] then
      begin
        Lines.Clear;
        Header := RowText(Tables[I], 0);
      end
      else if (Trim(Header) <> '') and (RowText(Tables[I], 0) = Header) then
        FirstRow := 1;                     // header repetido nesta página
      for R := FirstRow to Tables[I].RowCount - 1 do
        Lines.Add(RowText(Tables[I], R));
      if Tables[I].Continuation in [ptcNone, ptcEnd] then
        Lines.SaveToFile(Format('%s\table%d-page%d.tsv',
          [Folder, I, Tables[I].PageNumber]));
    end;
  finally
    Lines.Free;
  end;
end;

Comparação exata é a chamada certa. Um header repetido pelo processador de texto bate caractere por caractere, enquanto uma correspondência difusa eventualmente engoliria uma linha de dados que se parece com o header. Um header extra solto é fácil de achar na saída; uma linha de dados faltando não

Medindo a faixa do rodapé em vez de chutar

ContinuationMargin faz três trabalhos com um número: folga de borda de página para o primeiro teste, profundidade da faixa do rodapé e profundidade da faixa do header para o segundo. Um rodapé mais fundo que a faixa deixa a linha de cima dele na zona do corpo, o fragmento anterior parece ter texto depois dele, e a cadeia quebra naquela página. Uma faixa posta funda demais começa a engolir conteúdo real: uma linha curta de encerramento logo acima da margem de baixo cai dentro dela, e a tabela acima dessa linha pode então ligar a qualquer coisa que abra a página seguinte

O próprio documento pode fornecer o número. TPdf.PageWordBoxes devolve toda palavra na página atual com um Rect em page-space cujo Top é maior que o Bottom dele, então o Top mais alto entre as palavras que sentam abaixo da margem do corpo é a profundidade do rodapé. A sonda abaixo recebe um valor logo abaixo da margem de baixo do layout, 70 para uma página de uma polegada, e mantém 36 pontos como piso para documentos sem rodapé

// Helper do lado do leitor (não faz parte da biblioteca): o topo de palavra mais alto
// achado abaixo de BodyMargin em qualquer página, ou seja, a profundidade da zona de rodapé
function MeasureFooterDepth(Pdf: TPdf; BodyMargin: Double): Double;
var
  P, I: Integer;
  Words: TPdfWordBoxes;
begin
  Result := 0;
  for P := 1 to Pdf.PageCount do
  begin
    Pdf.PageNumber := P;
    Words := Pdf.PageWordBoxes;
    for I := 0 to High(Words) do
      if (Words[I].Rect.Bottom < BodyMargin) and
         (Words[I].Rect.Top > Result) then
        Result := Words[I].Rect.Top;
  end;
end;

// usa System.Math para Max
Options := TPdfTableExtractionOptions.Default;
Options.ContinuationMargin := Max(36.0, MeasureFooterDepth(Pdf, 70) + 2);
Tables := Pdf.ExtractDocumentTables(Options);

A faixa tem a mesma profundidade nas duas bordas, então meça um running header fundo contra o topo da página também e use o valor maior. Mantenha a folga em uns poucos pontos; cada ponto extra é um ponto de texto do corpo que o teste consciente de conteúdo para de ver

Um layout derrota a regra da legenda por construção: um gerador que escreve um banner "continued" mesclado como a primeira linha de todo fragmento de continuação. Cada tal fragmento é reportado como tabela nova, então ligue-os no seu próprio código a partir de números de página consecutivos, ColumnCount igual e o texto do banner na linha 0

Marcação de continuação, a regra da legenda e a exceção de uma linha rodam todos dentro do ExtractDocumentTables, que se comporta igual nos builds Delphi, C++Builder e Lazarus. O conjunto completo de opções de extração de tabelas está listado na página do PDFium Component para Delphi