Articolo tecnico

Estrarre tabelle da pagine PDF in Delphi con PDFium

PDFium Component rileva le tabelle in una pagina PDF e le restituisce come una griglia di celle con span di riga e colonna, righe di intestazione e un valore di confidenza, tramite ExtractTables per una pagina ed ExtractDocumentTables per un intero documento. Ogni tabella si converte in CSV o JSON con una sola chiamata, e le tabelle che continuano oltre un'interruzione di pagina possono essere collegate in una catena di continuazione

Il PDF non ha un oggetto tabella. Una tabella in un PDF è un insieme di text run posizionati in modo che un essere umano li legga come una griglia, a volte con linee disegnate attorno e spesso senza. Recuperare la griglia significa ricostruire un'intenzione che il file non ha mai registrato, motivo per cui ogni strumento di estrazione produce risultati leggermente diversi e perché uno strumento che vi dice la propria confidenza è più utile di uno che non lo fa

Due modalità di rilevamento per due tipi di tabella

Il rilevamento a righe usa le linee disegnate. Ogni segmento di percorso tracciato viene trasformato in coordinate di pagina attraverso la matrice dell'oggetto pagina, le linee orizzontali e verticali vengono intersecate, e le intersezioni formano componenti connesse. Ogni componente diventa la propria griglia ordinata di posizioni X e Y, il che è ciò che impedisce a due tabelle separate sulla stessa pagina di venire unite in un'unica griglia insensata

Il rilevamento a spazi bianchi gestisce tabelle disegnate con allineamento invece di linee. Le word box vengono raggruppate in righe visive, gli spazi vuoti all'interno di una riga la suddividono in colonne candidate, e una tabella viene accettata solo quando almeno MinRows righe ripetono almeno MinColumns ancoraggi allineati a sinistra entro AlignmentTolerance. Il fattore di distanza tra righe è predefinito a 3, che copre la spaziatura di linea di base di circa 30 punti tipica del testo a 12 punti senza permettere a una singola riga contenente più text run di travestirsi da tabella

Diagramma della pipeline di rilevamento tabelle di PDFium Component in Delphi, in cui le intersezioni di linee disegnate e le righe di parole allineate a spazi bianchi alimentano un unico record tabella con punteggio ed esportazione CSV e JSON
Il rilevamento a righe interseca i tratti disegnati, mentre il rilevamento a spazi bianchi conta le righe di word box allineate; i candidati che superano MinRows e MinColumns emergono con un punteggio di confidenza e il DetectionMode allegato
uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfTableExtractionOptions;
  Tables: TPdfTables;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'annual-report.pdf';
    Pdf.LoadDocument;
    Pdf.PageNumber := 12;                    // basato su 1

    Options := TPdfTableExtractionOptions.Default;
    Options.DetectRuledTables := True;
    Options.DetectWhitespaceTables := True;
    Options.MinConfidence := 0.6;            // il default è 0,5
    Options.HeaderRowCount := 1;

    Tables := Pdf.ExtractTables(Options);
    for I := 0 to High(Tables) do
      Writeln(Format('table %d: %dx%d cells, confidence %.2f, mode %d',
        [I, Tables[I].RowCount, Tables[I].ColumnCount,
         Tables[I].Confidence, Ord(Tables[I].DetectionMode)]));

    if Length(Tables) > 0 then
      SaveText('page12-table0.csv', Tables[0].ToCsv);
  finally
    Pdf.Free;
  end;
end;

Come vengono recuperate le celle unite?

Questa è la parte in cui gli estrattori ingenui sbagliano. Una cella unita non può essere identificata dalla sola griglia globale, perché la griglia deriva da tutte le linee della pagina e una regione unita manca semplicemente della linea interna che l'avrebbe separata

La regola usata qui è locale: due celle base adiacenti vengono unite quando nessuna linea di confine copre l'intervallo tra loro. Union-find le unisce, le componenti rettangolari risultanti diventano i valori RowSpan e ColumnSpan, e il testo viene assegnato a una cella base in base al suo punto centrale e poi segue quella cella fino alla sua radice di unione. Farlo in questo modo mantiene anche il costo lineare in parole più celle, invece della scansione quadratica che si ottiene testando ogni parola contro ogni cella

Diagramma del recupero delle celle unite nell'estrazione tabelle di PDFium per Delphi, in cui union-find unisce celle base adiacenti ogni volta che nessuna linea di confine copre l'intervallo tra loro, producendo RowSpan e ColumnSpan
Union-find unisce le celle base vicine il cui intervallo condiviso non porta alcun confine disegnato, così un'intestazione unita torna come una sola cella con ColumnSpan impostato invece di una cella riempita affiancata da celle vuote

L'effetto pratico è che una tabella finanziaria con un'intestazione "Total" unita che copre tre colonne esce con una cella di span tre, invece di una cella popolata e due misteriose celle vuote

Continuazione tra pagine

Le tabelle lunghe si interrompono attraverso le pagine, e trattare il frammento di ogni pagina come una tabella indipendente costringe il chiamante a ricucirle. ExtractDocumentTables può invece collegarle, ma solo in condizioni rigorose: il frammento deve essere la tabella più in basso sulla pagina precedente, la successiva deve essere la tabella più in alto sulla pagina seguente, i numeri di pagina devono essere adiacenti, e i confini delle colonne devono corrispondere

Tutte e quattro le condizioni insieme sono ciò che previene l'errore ovvio, ovvero concatenare ogni tabella a quattro colonne di un documento in un'unica mega-tabella immaginaria solo perché condividono un numero di colonne. Quando le condizioni sono soddisfatte, le tabelle condividono un identificatore di gruppo di continuazione e portano metadati di continuazione; quando non lo sono, ottenete tabelle separate e potete decidere da soli

Diagramma della continuazione delle tabelle tra pagine PDF in Delphi, in cui quattro condizioni rigorose decidono se il frammento più in basso su una pagina si unisce al frammento più in alto sulla successiva
L'estrazione a livello documento collega i frammenti solo quando tutte e quattro le condizioni sono soddisfatte, impedendo che tabelle a quattro colonne non correlate si fondano in un'unica mega-tabella immaginaria

L'estrazione a livello documento condivide i budget MaxCells e MaxTables tra le pagine invece di azzerarli per pagina, e ripristina la pagina attiva in un blocco finally, così un'estrazione eseguita in un visualizzatore lascia l'utente a guardare la pagina in cui si trovava

Esportare senza corrompere i dati

Entrambi gli esportatori sono deliberati riguardo l'escaping. Il CSV mette sempre i campi tra virgolette e raddoppia le virgolette interne, il che evita il classico fallimento in cui una cella contenente una virgola diventa silenziosamente due colonne. Per le celle unite, il contenuto viene emesso solo all'ancoraggio in alto a sinistra, così un round trip CSV non duplica un'intestazione che copre più colonne attraverso le colonne che copre

Il JSON preserva Unicode invece di trasformarlo in escape ASCII, esegue l'escape dei caratteri di controllo, e include i metadati di cui un consumatore ha bisogno per giudicare la qualità: modalità di rilevamento, confidenza, limiti, valori di span, flag di intestazione e informazioni di continuazione. Se state alimentando tabelle estratte in un sistema a valle, preferite JSON, perché una riga CSV non può dirvi che la tabella da cui proviene ha ottenuto una confidenza di 0,51:

// Estrazione a livello di documento, mantenendo solo le tabelle degne di fiducia
Tables := Pdf.ExtractDocumentTables(Options);
for I := 0 to High(Tables) do
begin
  if Tables[I].Confidence < 0.75 then
  begin
    Log(Format('page %d table needs review (%.2f)',
      [Tables[I].PageNumber, Tables[I].Confidence]));
    Continue;
  end;
  if Tables[I].ContinuationGroup > 0 then
    AppendToGroup(Tables[I].ContinuationGroup, Tables[I].ToJson)
  else
    EmitStandalone(Tables[I].ToJson);
end;

Regolazione, e sapere quando fermarsi

Tre impostazioni contano più delle altre. MinConfidence è il gate di qualità, e 0,5 è deliberatamente permissivo; alzatelo per l'ingestione automatizzata e abbassatelo per un'interfaccia di revisione in cui un essere umano conferma ogni risultato. MinColumnGap decide cosa conta come confine di colonna in modalità spazi bianchi, e le tabelle strettamente impostate in report densi potrebbero richiedere di ridurlo rispetto al default di 12 punti. MaxRowGapFactor decide quando la distanza verticale termina una tabella, il che conta per tabelle con righe vuote occasionali

Siate onesti riguardo i limiti. Le tabelle a righe si estraggono in modo affidabile. Le tabelle allineate in modo pulito a spazi bianchi si estraggono bene. Le tabelle con testo ruotato, tabelle annidate, o celle il cui contenuto va a capo in quella che sembra un'altra riga richiederanno revisione indipendentemente da come sono impostati i parametri. Per queste, il modello di testo strutturato vi fornisce la materia prima per costruire un lettore specifico per il dominio, descritto in blocchi di testo strutturato e ordine di lettura

Un abbinamento utile: quando un documento scansionato non ha alcun testo, il rilevamento delle tabelle non ha nulla su cui lavorare finché non esiste un livello di testo. Aggiungetene uno prima, come descritto in aggiungere un livello di testo ricercabile a PDF scansionati, poi estraete. Le word box che un provider OCR restituisce sono esattamente l'input di cui il rilevamento a spazi bianchi ha bisogno

L'estrazione delle tabelle, il testo strutturato e il reflow leggono tutti dallo stesso modello di pagina in Delphi, C++Builder e Lazarus; l'API completa è descritta nella pagina di PDFium Component per Delphi