Una pagina di corpo di testo giustificato può tornare da un detector di tabelle a spaziatura come una griglia di parecchie colonne, ogni cella con mezza frase spezzata e il punteggio di confidenza ben sopra qualunque soglia pensiate di impostare. In quella pagina non c'è nessuna tabella. Il detector ha trovato spazi fra parole abbastanza larghi da passare per separatori di colonna e alcune righe consecutive dove quegli spazi cadono quasi alla stessa X, e sul solo allineamento è esattamente ciò che sembra una tabella. La risposta è smettere di giudicare un confine di colonna una riga alla volta e chiedersi se una striscia di carta bianca scende lungo la pagina fra le colonne
Perché il testo giustificato supera un test di tabella a spaziatura?
Il testo giustificato passa perché una riga giustificata è una fila di parole separate da spazi che il motore di layout ha allungato, e appena uno spazio allungato supera la soglia di colonna, nulla in quella singola riga lo distingue da un gutter. La strategia a spaziatura in PDFium Component funziona come la maggior parte dei detector a spaziatura. TPdf.ExtractTables raggruppa i box delle parole in righe visive, spezza ogni riga ovunque la distanza orizzontale da una parola alla successiva sia di almeno MinColumnGap (12 punti in TPdfTableExtractionOptions.Default), e raggruppa i bordi sinistri dei gruppi di parole risultanti in anchor di colonna entro AlignmentTolerance, che vale 3 punti per difetto. Un anchor sopravvive solo se ricorre su almeno MinRows righe, e ogni riga della candidata deve toccare almeno MinColumns anchor distinti. Per una tabella allineata genuina quella regola è corretta, ed è la regola descritta nella panoramica sul rilevamento delle tabelle
Il calcolo lato prosa è meno indulgente di quanto sembri. Prendete una riga da 10 punti in una colonna di newsletter da 180 punti che finisce 30 punti prima del margine destro perché la parola successiva non ci stava, e la riga contiene solo tre spazi fra parole perché le sue parole sono lunghe. Un motore di giustificazione sparge quei 30 punti sui tre spazi, così ognuno cresce da circa 2,5 punti a 12,5 punti e supera il MinColumnGap predefinito. Una seconda riga ha bisogno solo di uno spazio allungato la cui parola seguente inizia entro 3 punti da quella della prima riga, e il detector ha una candidata di due righe e due colonne
Come viene codificato l'allungamento non conta. ISO 32000-1 §9.3.3 applica la spaziatura fra parole (Tw) solo al codice carattere a byte singolo 32, quindi i produttori che usano font multi-byte di solito posizionano le parole con aggiustamenti numerici in un array TJ (§9.4.3); in ogni caso i box delle parole escono identici. L'ultima riga di ogni paragrafo è composta a bandiera e porta spazi ordinari, ed è per questo che i falsi positivi tendono a coprire il corpo di un paragrafo e a fermarsi una riga prima della sua fine
Un modo rapido di controllare il vostro corpus è far girare il detector su documenti che sapete essere solo prosa, come lettere o comunicati stampa, e stampare ogni tabella in modalità spaziatura che riporta. L'output atteso su quelli è niente
uses
SysUtils, PDFium;
// Elenca ogni tabella a spaziatura del documento caricato, così le pagine
// che sapete contenere solo prosa possono essere verificate in fretta
procedure ReportWhitespaceTables(Pdf: TPdf; MinRows: Integer = 2);
var
Options: TPdfTableExtractionOptions;
Tables: TPdfTables;
I: Integer;
begin
Options := TPdfTableExtractionOptions.Default; // MinColumnGap 12, AlignmentTolerance 3
Options.MinRows := MinRows;
Tables := Pdf.ExtractDocumentTables(Options); // ripristina la pagina attiva
for I := 0 to High(Tables) do
if Tables[I].DetectionMode = ptdmWhitespace then
Writeln(Format('page %d: %dx%d whitespace table, confidence %.2f, ' +
'first cell "%s"',
[Tables[I].PageNumber, Tables[I].RowCount, Tables[I].ColumnCount,
Tables[I].Confidence, Tables[I].Cells[0].Text]));
end;
Quanto costa ogni soglia
Ogni soglia a livello di riga baratta una classe di documenti contro un'altra, ed è la ragione per cui per questo problema conviene lasciarle stare. Alzare MinColumnGap a 20 punti butta via le colonne compatte dei prospetti finanziari densi, dove il gutter fra una colonna di etichette e i suoi valori è spesso appena più largo del default. Alzare MinRows a 3 scarta le tabelle genuine a due righe e abbassa solo le probabilità per i paragrafi lunghi, dato che una pagina di prosa offre tante occasioni perché tre righe si allineino. Stringere AlignmentTolerance sotto i 3 punti comincia a spaccare colonne vere i cui bordi sinistri derivano leggermente, come fanno sulle pagine scansionate con un layer di testo OCR
MinConfidence sembra il filtro naturale ed è il più debole dei quattro. Una tabella a spaziatura in PDFium Component fa punteggio 0,58 più 0,30 volte la quota di celle non vuote, con tetto a 0,88, quindi il punteggio misura quanto la griglia è piena. Un frammento di paragrafo riempie ogni cella, il che significa che un falso positivo da prosa fa punteggio vicino alla cima dell'intervallo mentre una tabella vera con celle vuote fa punteggio più basso
Due righe di prosa giustificata e due righe di una tabella a due colonne possono produrre gruppi di parole identici agli stessi anchor. La prova che le separa sta fra le righe
Un confine di colonna vero è un corridoio verticale
Un confine di colonna vero è una striscia verticale della pagina che resta libera da testo su ogni riga che separa. Una tabella ne ha una fra ogni coppia di colonne per costruzione, perché le sue celle sono state disposte contro posizioni X condivise. Un paragrafo giustificato allunga i suoi spazi a una posizione orizzontale diversa su ogni riga, quindi nessuna striscia sopravvive più di una riga o due. PDFium Component testa esattamente questo dopo che i gruppi di parole di una candidata sono stati assegnati alle colonne degli anchor. Per ogni coppia di colonne adiacenti, ogni riga con contenuto in entrambe le celle contribuisce con l'intervallo che va dal bordo più a destra delle parole della cella sinistra al bordo più a sinistra delle parole della cella destra; gli intervalli vengono interseguiti fra loro sulle righe, e l'intera candidata viene rifiutata quando l'intersezione è più stretta della metà di MinColumnGap — 6 punti al default
Due dettagli contano sulle tabelle vere scomode. Le righe in cui una delle due celle è vuota non votano, quindi una tabella con celle vuote, o una riga di intestazione che copre meno colonne del corpo, passa comunque. E la larghezza del corridoio deriva da MinColumnGap invece di essere un'opzione separata, perché entrambi i valori descrivono la stessa cosa fisica: lo spazio che un progettista lascia fra le colonne. La metà lascia spazio alla riga in cui una voce lunga nella colonna sinistra corre vicina alla successiva
Il controllo è abbastanza piccolo da riprodurre nel vostro codice di layout; le liste di celle qui sotto sono la vostra struttura, un array di indici di parola per cella in ordine riga-principale
uses
Math, PDFium;
type
TIndexList = array of Integer;
TCellIndexes = array of TIndexList; // Row * ColumnCount + Column
// Restituisce False quando una coppia di colonne adiacenti non ha un corridoio
// verticale libero da testo largo almeno MinColumnGap / 2 sulle righe che lo usano
function HasTextFreeCorridors(const Words: TPdfWordBoxes;
const Cells: TCellIndexes; RowCount, ColumnCount: Integer;
MinColumnGap: Double): Boolean;
var
Col, Row, I, LeftCell, RightCell, Supported: Integer;
CorridorLeft, CorridorRight, RowLeft, RowRight: Double;
begin
for Col := 0 to ColumnCount - 2 do
begin
CorridorLeft := -MaxDouble;
CorridorRight := MaxDouble;
Supported := 0;
for Row := 0 to RowCount - 1 do
begin
LeftCell := Row * ColumnCount + Col;
RightCell := LeftCell + 1;
if (Length(Cells[LeftCell]) = 0) or (Length(Cells[RightCell]) = 0) then
Continue; // le celle vuote non votano
RowLeft := -MaxDouble;
RowRight := MaxDouble;
for I in Cells[LeftCell] do
RowLeft := Max(RowLeft, Words[I].Rect.Right);
for I in Cells[RightCell] do
RowRight := Min(RowRight, Words[I].Rect.Left);
CorridorLeft := Max(CorridorLeft, RowLeft);
CorridorRight := Min(CorridorRight, RowRight);
Inc(Supported);
end;
if (Supported > 0) and
(CorridorRight - CorridorLeft < MinColumnGap * 0.5) then
Exit(False);
end;
Result := True;
end;
Tenere le tabelle con griglia fuori dal passaggio a spaziatura
Una tabella con griglia verrebbe trovata due volte se il passaggio a spaziatura vedesse ogni parola della pagina, perché una tabella con griglia pulita è per costruzione anche una tabella a spaziatura perfettamente allineata. Da sola, una regola di sovrapposizione più della metà lascerebbe passare una candidata che unisce le righe inferiori di una tabella con griglia a qualche riga di testo allineato sotto di essa, che resta sotto il rapporto ed esce come una seconda tabella più grande che sborda sul vicino
Quando sia DetectRuledTables sia DetectWhitespaceTables sono attivi, che è il default, ExtractTables esegue quindi prima il passaggio con griglia e rimuove ogni parola il cui punto centrale cade dentro i limiti di una tabella con griglia prima che parta il passaggio a spaziatura. Viene usato il centro invece del contenimento completo così una parola che cavalca un bordo per una frazione di punto segue la tabella a cui appartiene visivamente. La strategia a spaziatura vede allora solo parole libere, e una piccola tabella senza griglia seduta direttamente sotto una griglia viene giudicata sulle sue righe
Perché "Purpose of Request:" può uscire come "of Purpose Request:"?
Le parole dentro una cella escono nell'ordine sbagliato quando il codice che decide "stessa riga" confronta i centri verticali, perché i box delle parole costruiti dai box dei glifi non condividono un centro nemmeno quando condividono una baseline. FPDFText_GetCharBox restituisce il box stretto dell'inchiostro di ogni glifo nello spazio pagina, non un box riempito fino ad ascendente e discendente del font, e il box di una parola è l'unione dei box dei suoi caratteri. In un'etichetta di form come "Purpose of Request:", le parole "Purpose" e "Request:" portano discendenti e "of" no, quindi il box di "of" è più corto e il suo centro sta un paio di punti più in alto. Una routine che ordina per centro Y con una tolleranza stessa-riga di 1 punto e poi per bordo sinistro mette "of" da sola su una riga sopra le altre due e la emette per prima
Il PDF non offre scorciatoie. ISO 32000-1 §9.4.4 posiziona i glifi per spostamento lungo la baseline, e le uniche metriche verticali nel file sono per font: Ascent, Descent e FontBBox nel descrittore del font (§9.8.1). Nulla dice che due glifi condividano una riga. I box stretti che fanno sembrare giusta l'evidenziazione della selezione, trattati in selezione delle righe di testo con i char box di PDFium, sono un input povero per un confronto a distanza dei centri
PDFium Component costruisce invece il testo delle celle per sovrapposizione. Una parola entra in una riga visiva quando la sua sovrapposizione verticale con i limiti correnti della riga è almeno il 25 percento della minore delle due altezze, o quando i due centri sono entro un punto l'uno dall'altro; ogni riga viene poi ordinata per bordo sinistro, e le righe vengono giunte con una coppia CR LF. "Purpose" e "of" si sovrappongono attraverso l'intera x-height, molto più di un quarto del box più corto, quindi atterrano su una riga e si leggono in ordine
Raggruppare le righe di testo per sovrapposizione nel vostro codice
La stessa trappola aspetta in qualunque codice di layout PDF che decide "stessa riga" confrontando i centri contro una tolleranza fissa. I discendenti sono il grilletto più mite. Etichette in grassetto accanto a valori più piccoli, rimandi di note a apice e box OCR rumorosi spostano tutti i centri più di qualunque tolleranza che separi ancora le righe adiacenti di testo da 10 punti su una guida di 12 punti. Il rapporto di sovrapposizione non si cura della dimensione: due box su una stessa baseline si sovrappongono attraverso la loro x-height condivisa, e due box su righe adiacenti non si sovrappongono per niente
TPdf.PageWordBoxes restituisce ogni parola della pagina attiva con il suo rettangolo in spazio pagina in Rect, quindi raggruppare una pagina in righe visive prende un predicato e un breve ciclo
uses
Math, PDFium;
function SameVisualLine(const A, B: TPdfRectangle): Boolean;
var
Overlap, MinHeight: Double;
begin
Overlap := Min(A.Top, B.Top) - Max(A.Bottom, B.Bottom);
MinHeight := Min(A.Top - A.Bottom, B.Top - B.Bottom);
Result := (MinHeight > 0) and (Overlap >= MinHeight * 0.25);
end;
procedure GroupPageIntoLines(Pdf: TPdf; out Lines: TArray<TPdfWordBoxes>);
var
Words: TPdfWordBoxes;
Bounds: TArray<TPdfRectangle>; // unione corrente per riga
I, J, Found: Integer;
begin
Words := Pdf.PageWordBoxes;
Lines := nil;
Bounds := nil;
for I := 0 to High(Words) do
begin
Found := -1;
for J := High(Lines) downto 0 do
if SameVisualLine(Bounds[J], Words[I].Rect) then
begin
Found := J;
Break;
end;
if Found < 0 then
begin
SetLength(Lines, Length(Lines) + 1);
SetLength(Bounds, Length(Bounds) + 1);
Found := High(Lines);
Bounds[Found] := Words[I].Rect;
end;
SetLength(Lines[Found], Length(Lines[Found]) + 1);
Lines[Found][High(Lines[Found])] := Words[I];
Bounds[Found].Left := Min(Bounds[Found].Left, Words[I].Rect.Left);
Bounds[Found].Right := Max(Bounds[Found].Right, Words[I].Rect.Right);
Bounds[Found].Top := Max(Bounds[Found].Top, Words[I].Rect.Top);
Bounds[Found].Bottom := Min(Bounds[Found].Bottom, Words[I].Rect.Bottom);
end;
// ordina ogni riga per Rect.Left prima di leggerla; PageWordBoxes restituisce
// le parole in ordine di content stream, che non è garantito essere visivo
end;
Il predicato è la parte che vale la pena copiare. Per qualsiasi cosa oltre a un dump rapido, partite dal modello di testo strutturato, che porta già con sé blocchi, righe e una sorgente in ordine di lettura, come descritto in estrazione di testo PDF strutturato con ordine di lettura
Che cosa chi usa le tabelle controlla ancora
Chi chiama l'estrazione delle tabelle riceve il test del corridoio, il filtro delle parole con griglia prima e il testo delle celle per sovrapposizione senza toccare le proprie opzioni. La soglia del corridoio è fissata alla metà di MinColumnGap, quindi l'unico valore che mettete a punto per la larghezza del gutter governa entrambi. La strategia a spaziatura mantiene un minimo di due righe anche quando MinRows è impostato a 1; solo la strategia con griglia accetta griglie a riga singola, che ExtractDocumentTables usa per i frammenti di tabella continuati in cima a una pagina. Lo stesso record di opzioni viaggia in TPdfReflowOptions.TableOptions, quindi l'output del reflow eredita le stesse decisioni quando DetectTables è attivo
Il test del corridoio decide sulle righe che hanno contenuto da entrambe le parti di un confine, quindi una candidata a due righe viene giudicata su sole due righe simili. Per gli archivi che sono per lo più prosa e contengono raramente tabelle genuine a due righe — contratti, corrispondenza, saggi — impostate MinRows a 3 e accettate le tabelle a due righe perdute. Per i documenti finanziari e ricchi di form tenete il default di 2, perché le tabelle vere brevi sono comuni lì. Il testo a bandiera mantiene spazi singoli ordinari e produce raramente uno spazio vicino a 12 punti
Il PDF non ha un oggetto tabella generale. ISO 32000-1 §14.8.4.3.4 definisce un elemento di struttura Table, ma solo il PDF con tag lo porta, quindi per tutto il resto la griglia è un'inferenza dalla geometria, e la Confidence di una tabella a spaziatura non supera mai 0,88
Le opzioni di rilevamento, la serializzazione TPdfTable.ToCsv e ToJson e la demo TableExtractionLab per Delphi, C++Builder e Lazarus sono trattate sulla pagina di prodotto di PDFium Component for Delphi