Articolo tecnico

Continuazione delle tabelle PDF tra pagine in Delphi

Una tabella che corre su tre pagine di un export di Word non si avvicina mai a meno di 72 punti dal fondo del foglio. I margini di un pollice sono 72 punti, e il word processor smette di disporre righe al margine. Un linker che unisce i frammenti solo quando stanno entro 36 punti dai bordi pagina vede tre tabelle sconnesse con colonne identiche. La domanda che funziona è diversa: c'è qualcosa oltre a un'intestazione o un piè di pagina correnti fra la fine di un frammento e l'inizio del successivo?

Quella domanda porta con sé due trappole proprie. Un form può chiudere una tabella al fondo della pagina 1 e aprire una tabella diversa con le stesse larghezze di colonna alla cima della pagina 2. E una griglia con righe può spingere una singola riga oltre l'interruzione, troppo corta per un rilevatore che vuole due righe prima di chiamare qualcosa tabella

Due test sui bordi in ExtractDocumentTables

Il PDFium Component marca le continuazioni in TPdf.ExtractDocumentTables, la controparte a livello documento della per-pagina ExtractTables trattata nell'articolo su rilevamento ed estrazione delle tabelle. Con DetectContinuations impostato, com'è in TPdfTableExtractionOptions.Default, il metodo estrae ogni pagina, ordina le tabelle per numero di pagina e poi dall'alto verso il basso dentro ogni pagina, e controlla ogni coppia di vicini in quella lista. Vengono confrontati solo i vicini. Un candidato continuazione è quindi sempre la tabella più bassa della pagina N abbinata alla più alta della pagina N+1, e una piccola tabella piazzata sotto una lunga nella pagina N prende il posto della lunga in quell'abbinamento. Quando una breve tabella di note sta sotto una lunga, collegate quei frammenti nel vostro codice a partire da numeri di pagina e ColumnCount

Il primo test è puramente geometrico. Il bordo inferiore del frammento precedente deve stare entro ContinuationMargin punti dal fondo pagina, e il bordo superiore del frammento successivo entro la stessa distanza dalla cima della pagina successiva. Il predefinito è 36 punti, mezzo pollice. I report dei motori di impaginazione che riempiono un box di contenuto fisso passano senza problemi. Un export di Word con margini di un pollice manca di almeno 36 punti per lato, e allargare il margine finché passa va storto, perché lo stesso numero imposta le fasce di intestazione e piè di pagina del secondo test

Il secondo test, quello basato sul contenuto, è ciò che gestisce l'output dei word processor. I due si combinano in OR: basta che uno dei due apra il cancello

Cosa conta come testo fra due frammenti?

Ogni parola di una pagina conta come testo di corpo a meno che stia interamente dentro la fascia dell'intestazione corrente o quella del piè di pagina, ed entrambe le fasce sono profonde ContinuationMargin punti. Per pagina, ExtractDocumentTables registra il bordo inferiore più basso fra le parole la cui cima sta sopra la fascia del piè di pagina e il bordo superiore più alto fra le parole il cui fondo sta sotto la fascia dell'intestazione, testo delle celle della tabella compreso. Quando la tabella è l'ultimo contenuto di corpo della pagina N, il suo bordo inferiore sta al livello o sotto la parola più bassa, e il test passa entro AlignmentTolerance, che vale 3 punti in predefinito. Il frammento successivo deve soddisfare la condizione speculare alla cima della pagina N+1

Ignorare le fasce ha un fondamento nella specifica. ISO 32000-1 §14.8.2.2 tratta intestazioni correnti, piè di pagina e numeri di pagina come artefatti di paginazione, contenuto che esiste solo perché il documento è stato tagliato in pagine. Un file con tag può dichiararli con /Artifact, come spiega l'articolo sul marked content, ma gli export che hanno più bisogno della cucitura delle tabelle portano raramente tag, così la posizione fa le veci del tag. Con la fascia predefinita, un numero di pagina i cui glifi si fermano a 30 punti sopra il bordo inferiore viene ignorato. Una riga di disclaimer che arriva a 40 punti è testo di corpo, e blocca il collegamento

Su una pagina senza livello di testo, gli estremi registrati ricadono sui limiti della pagina, il test basato sul contenuto passa banalmente, e a decidere restano i cancelli rimanenti. I piè di pagina disegnati come immagini o contorni vettoriali sono invisibili per la stessa ragione e non bloccano mai un collegamento

Perché la continuazione delle tabelle in PDFium Component richiede due test: con i margini da un pollice di Word il test sui margini di pagina pretende bordi dei frammenti dentro finestre di 36 pt che l'impaginazione non raggiunge mai, mentre il test basato sul contenuto confronta i box delle parole e collega quando la tabella è l'ultimo contenuto di corpo a pagina N e il primo a pagina N+1, ignorando le fasce di intestazione e piè di pagina correnti
O uno o l'altro test apre il cancello, e solo allora girano i controlli restanti: pagine adiacenti, nessuna riga di didascalia a larghezza piena sul frammento successivo e confini di colonna che corrispondono entro il doppio di AlignmentTolerance

Passare un test sui bordi apre solo il cancello. Le pagine devono essere consecutive, il frammento successivo non deve aprirsi con una riga di didascalia, e i confini di colonna devono allinearsi entro il doppio di AlignmentTolerance, 6 punti ai predefiniti. Quando tutto ciò regge, il frammento precedente diventa ptcStart con un numero di gruppo fresco, o passa da ptcEnd a ptcMiddle se aveva già chiuso una catena, e il frammento successivo diventa ptcEnd nello stesso gruppo. Una tabella distribuita su tre pagine si legge start, middle, end. I numeri di gruppo partono da 1; gruppo 0 con ptcNone significa che la tabella sta da sola. ToJson scrive gli stessi dati come "continuationGroup" e "continuation" (none, start, middle o end), così un servizio che riceve JSON può cucire da sé

Il listato qui sotto assume un export di Word con un piè di pagina su due righe profondo circa 50 punti, così il margine sale a 54 per tenere entrambe le righe dentro la fascia

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;               // piè di pagina assunto ~50 pt

    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.

Come una prima riga a tutta larghezza tiene separate due tabelle

Un frammento successivo la cui prima riga è una singola cella che copre tutte le colonne viene riferito come nuova tabella, anche quando un test sui bordi passa e le colonne corrispondono. I form sono dove il test basato sul contenuto andrebbe oltre: una tabella finisce vicino al fondo della pagina 1, la sezione successiva apre alla pagina 2 con larghezze di colonna identiche, e ogni controllo geometrico passa. La differenza è la riga di apertura della seconda tabella, una didascalia di sezione tipo RECIPIENT INFORMATION disposta come un'unica cella unita a tutta larghezza. Una continuazione autentica non si apre così, perché la didascalia di una tabella resta sulla pagina dove la tabella comincia

La regola legge solo le celle del frammento successivo. Quel frammento deve avere almeno due colonne e una cella con RowIndex = 0, ColumnIndex = 0 e ColumnSpan = ColumnCount. Il frammento precedente non viene mai ispezionato, così una tabella la cui propria didascalia sta alla cima della pagina N si collega normalmente alla sua continuazione alla pagina N+1

Il cancello della riga di didascalia in PDFium Component: una continuazione autentica si apre con celle di dati ed entra nello stesso ContinuationGroup, mentre un frammento successivo la cui riga zero contiene un'unica cella unita con RowIndex 0, ColumnIndex 0 e ColumnSpan pari a ColumnCount viene rifiutato come continuazione e segnalato come nuova tabella
L'ispezione tocca solo il frammento successivo, così una tabella la cui riga di didascalia sta sulla sua prima pagina resta indenne, e il cancello gira solo dopo che uno dei due test sui bordi è passato

Il confronto di colonne che segue è più severo di un eguale numero di colonne. Le posizioni dei confini di ogni frammento vengono ricostruite dai rettangoli delle sue celle; un confine nascosto sotto una cella unita viene colmato distribuendolo uniformemente fra i confini noti più vicini da entrambi i lati. Poi ogni coppia di confini viene confrontata, e una differenza sopra 6 punti rifiuta il collegamento. Due tabelle a quattro colonne con proporzioni diverse restano separate. Così anche una continuazione autentica il cui generatore ha spostato una colonna di 8 punti fra le pagine; alzare AlignmentTolerance per accettarla allenta anche il clustering usato durante il rilevamento

Una riga con griglia oltre un'interruzione di pagina

Una griglia con righe che spinge una singola riga sulla pagina successiva conserva quella riga quando si collega in una catena e la perde quando non si collega. Il predefinito MinRows di 2 impedisce che un paio di righe vaghe venga riferito come tabella. Una riga spinta oltre l'interruzione è diversa: è un dato vero, e buttarla via lascerebbe la tabella cucita corta di una riga senza che resti un frammento che spieghi il buco

La scansione del documento la gestisce in tre passi. Quando DetectContinuations e DetectRuledTables sono entrambi attivi, il rilevatore con righe gira su ogni pagina con la soglia sulle righe abbassata a 1. Le continuazioni vengono poi marcate sull'intero risultato. Infine, ogni tabella con meno righe del MinRows del chiamante che non sia finita in una catena viene rimossa. Una griglia di una riga nel mezzo di una pagina ordinaria passa per quel filtro e sparisce, che è esattamente ciò per cui esiste MinRows

Come PDFium Component conserva una riga con griglia che sborda su un'interruzione di pagina: il passaggio per pagina sulle griglie gira con la soglia sulle righe a uno quando DetectContinuations e DetectRuledTables sono attivi, le continuazioni vengono marcate sull'intero risultato, e vengono rimosse solo le parti più corte di MinRows che stanno fuori da ogni catena
La riga sbordata sopravvive perché la sua catena la collega, mentre una griglia autonoma di una riga su una pagina ordinaria viene filtrata come al solito, e le tabelle rilevate sulle spaziature tengono la loro soglia di due righe senza alcun sollievo

Lasciate MinRows a 2. Impostarlo voi a 1 spegne il filtro finale, e ogni griglia di una riga su ogni pagina torna indietro

L'eccezione vale solo per le griglie con righe. La strategia sulle spaziature ha bisogno di almeno due righe allineate per riconoscere una tabella, quindi mantiene una soglia interna di 2 a prescindere da ciò che dice MinRows; ExtractTables accetta MinRows = 1, ma solo il rilevamento con righe lo onora. Per una tabella senza righe che sborda di una riga, i box delle parole della pagina o i blocchi descritti in blocchi di testo strutturato e ordine di lettura vi danno le posizioni per recuperare quella riga da voi

Ricucire una catena in un'unica tabella

Le catene tornano contigue. I collegamenti vengono fatti solo fra vicini nell'array ordinato, così un frammento ptcStart è seguito direttamente dai suoi frammenti ptcMiddle e ptcEnd, e un loop che si azzera su ptcNone o ptcStart e scrive in output su ptcNone o ptcEnd non deve mai cercare nulla per numero di gruppo

La riga di intestazione ripetuta è la parte insidiosa: Word può ripeterla su ogni pagina, altri generatori no. Il flag IsHeader su TPdfTableCell non può dirimere la questione, perché viene assegnato per posizione, RowIndex < HeaderRowCount, separatamente per ogni frammento. La riga 0 di una continuazione è marcata come intestazione che ripeta l'intestazione o porti dati, e la riga singola sbordata della sezione precedente è marcata anch'essa. La routine qui sotto confronta il testo della prima riga di ogni continuazione con la prima riga della catena e la scarta solo su corrispondenza esatta

Costruisce anche le righe da Cells invece di spezzare l'output di ToCsv in righe. Una cella il cui testo va a capo dentro la cella torna con un CR LF fra le sue righe visive, ToCsv conserva quell'interruzione dentro il campo quotato, e cancellare la prima riga del testo CSV taglierebbe a metà una cella di intestazione su due righe

// Helper lato lettura (non parte della libreria): ricostruisce ogni catena
// come un unico file con tab, scartando le intestazioni ripetute sulle pagine successive
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;                     // intestazione ripetuta su questa pagina
      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;

Il confronto esatto è la scelta giusta. Un'intestazione ripetuta dal word processor corrisponde carattere per carattere, mentre una corrispondenza sfocata prima o poi inghiottirebbe una riga di dati che somiglia all'intestazione. Un'intestazione in più è facile da beccare nell'output; una riga di dati mancante no

Misurare la fascia del piè di pagina invece di indovinare

ContinuationMargin fa tre lavori con un solo numero: tolleranza sul bordo pagina per il primo test, profondità della fascia del piè di pagina e profondità della fascia dell'intestazione per il secondo. Un piè di pagina più profondo della fascia lascia la sua riga superiore nella zona del corpo, il frammento precedente sembra seguito da testo, e la catena si rompe su quella pagina. Una fascia impostata troppo in profondità comincia a inghiottire contenuto vero: una breve riga di chiusura appena sopra il margine inferiore cade dentro, e la tabella sopra quella riga può allora collegarsi a qualunque cosa apra la pagina successiva

Il documento stesso può fornire il numero. TPdf.PageWordBoxes restituisce ogni parola della pagina corrente con un Rect in spazio pagina il cui Top è maggiore del suo Bottom, così il Top più alto fra le parole che stanno sotto il margine del corpo è la profondità del piè di pagina. La sonda qui sotto prende un valore appena sotto il margine inferiore del layout, 70 per una pagina da un pollice, e tiene 36 punti come minimo per i documenti senza piè di pagina

// Helper lato lettura (non parte della libreria): la cima della parola più alta
// trovata sotto BodyMargin su qualunque pagina, cioè la profondità della zona piè di pagina
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;

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

La fascia è profonda uguale a entrambi i bordi, quindi misurate anche una intestazione corrente profonda contro la cima della pagina e usate il valore maggiore. Tenete la tolleranza su un paio di punti; ogni punto in più è un punto di testo di corpo che il test basato sul contenuto smette di vedere

Un layout batte la regola della didascalia per costruzione: un generatore che scrive un banner unito «continued» come prima riga di ogni frammento di continuazione. Ogni frammento di questo tipo viene riferito come nuova tabella, così collegateveli nel vostro codice a partire da numeri di pagina consecutivi, ColumnCount eguali e il testo del banner nella riga 0

La marcatura delle continuazioni, la regola della didascalia e l'eccezione della riga singola girano tutti dentro ExtractDocumentTables, che si comporta allo stesso modo nelle build Delphi, C++Builder e Lazarus. L'insieme completo delle opzioni di estrazione delle tabelle è elencato nella pagina di PDFium Component per Delphi