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
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 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
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