Articolo tecnico

Estrazione testo PDF strutturata in Delphi con PDFium VCL

PDFiumPas restituisce il testo di una pagina come struttura anziché come stringa. GetStructuredText produce una TPdfStructuredTextPage contenente blocchi, ciascuno con righe, ciascuna con span stilizzati, con bound in coordinate di pagina a ogni livello e gli indici dei caratteri sorgente preservati così qualunque frammento può essere ricondotto alla text page sottostante

L'estrazione a stringa piatta con cui inizia la maggior parte del codice è ancora presente ed è ancora corretta per il suo scopo. Smette di essere sufficiente nel momento in cui hai bisogno di sapere quali parole erano un titolo, quali appartenevano alla colonna sinistra, o dove sulla pagina si trova davvero una corrispondenza

Perché una stringa piatta è l'output sbagliato per la maggior parte dei compiti?

Perché le domande che le persone pongono al testo estratto quasi non sono mai «quali caratteri ci sono in questa pagina». Sono «qual è il titolo», «è una tabella», «questo paragrafo appartiene alla sezione 4», «dove disegno l'evidenziazione». Una singola stringa non risponde a nessuna di esse, e ogni risposta che ricostruisci da essa è un'euristica che ora è tua

I layout a due colonne rendono concreto il punto. Estrai un articolo a due colonne come stringa e, a seconda di come il producer ha scritto il content stream, potresti ottenere la colonna uno seguita dalla colonna due, oppure la riga uno della colonna uno, la riga uno della colonna due, la riga due della colonna uno, e così via lungo la pagina. Entrambi i risultati escono da un PDF conforme. Nessuno dei due è sbagliato a livello di formato, perché PDF descrive segni su una pagina, non uno schema di documento. Un modello basato su blocchi permette all'estrattore di prendere la decisione di ordinamento in modo esplicito e di dirti quale decisione ha preso

Ordine del contenuto o layout fisico?

TPdfStructuredTextOptions.ReadingOrder seleziona tra roContentOrder e roPhysicalLayout, e la risposta giusta dipende da cosa ti fidi di più, il producer o la geometria

Content order restituisce il testo nella sequenza in cui il content stream lo disegna. È veloce, e per i documenti generati da un producer ben educato, tipicamente l'ordine di lettura previsto. Physical layout ignora la sequenza dello stream e ricostruisce l'ordine da dove i caratteri si trovano realmente, raggruppandoli in righe e poi in colonne. È ciò che vuoi per pagine scansionate e poi passate a OCR, per l'output di strumenti che emettono testo in ordine di font anziché di lettura, e per qualunque caso in cui il risultato visivo è l'unica cosa su cui puoi contare

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfStructuredTextOptions;
  Page: TPdfStructuredTextPage;
  B, L: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'article.pdf';
    Pdf.LoadDocument;
    Pdf.PageNumber := 1;                     // 1-based

    Options := TPdfStructuredTextOptions.Default;
    Options.ReadingOrder := roPhysicalLayout;
    Options.IncludeFontInfo := True;
    Options.IncludeSemantics := True;
    Options.MaxCharacters := 200000;         // fail-closed budget

    Page := Pdf.GetStructuredText(Options);

    for B := 0 to High(Page.Blocks) do
    begin
      if Page.Blocks[B].Kind = cfHeading then
        Emit(Format('H%d: %s',
          [Page.Blocks[B].HeadingLevel, Page.Blocks[B].Text]))
      else
        for L := 0 to High(Page.Blocks[B].Lines) do
          Emit(Page.Blocks[B].Lines[L].Text);
    end;
  finally
    Pdf.Free;
  end;
end;

Cosa aggiunge il tagging che la geometria non può?

L'intento. Con IncludeSemantics attivato, i blocchi da un PDF taggato portano un Kind tratto dall'albero di struttura, così un titolo è un titolo perché lo ha detto il producer, non perché il suo font era più grande della media. I kind coprono le forme che contano per il riutilizzo: cfParagraph, cfHeading con un HeadingLevel, cfListItem, cfTableCell, cfCaption, cfFigure e il fallback non taggato cfPlain

Il campo Source registra da dove è arrivata ciascuna classificazione, rosStructure per l'albero di struttura e rosHeuristic per l'inferenza, ed è il campo da registrare quando stai decidendo quanto fidarti di una pipeline di estrazione lungo un insieme di documenti. Le figure sono un caso speciale che vale la pena conoscere: per un blocco cfFigure il testo proviene dalla descrizione alternativa anziché da qualunque glifo, dato che una figura non ha caratteri propri. Il testo alternativo non corrispondente viene comunque rappresentato invece di essere scartato, il che permette a un audit di accessibilità di vedere che esiste una descrizione anche quando nulla sulla pagina la disegna. Il modello di tagging in sé è trattato in validazione dell'albero di struttura PDF/UA

Gli span portano lo stile e la provenienza

Ogni TPdfStructuredTextSpan porta il proprio testo, i bound in coordinate di pagina, FontName, FontSize, FontWeight e Angle, più SourceStartIndex e SourceCharacterCount. Gli span si interrompono dove cambia lo stile, così una frase con tre parole in grassetto diventa tre span, e ricostruire l'enfasi in HTML o Markdown è una questione di leggere proprietà invece di indovinare dai nomi dei font

I due campi di indice sorgente sono quelli che trasformano l'estrazione in una funzionalità anziché in un report. Puntano indietro nella sequenza di caratteri della pagina, il che significa che un blocco trovato in una ricerca può essere convertito in geometria di selezione a livello di carattere o in un rettangolo di evidenziazione senza un secondo passaggio sul testo in un ordine diverso; i meccanismi sono descritti in selezione visiva di righe di testo con character box. Il campo Angle conta più di quanto sembri: il testo ruotato in un timbro o in una filigrana finisce nello stesso spazio di coordinate del testo del corpo, e una pipeline che ignora l'angolo fonderà volentieri un «DRAFT» diagonale nel mezzo di un paragrafo

Budget, e i due contatori di qualità

MaxCharacters è un budget fail-closed, non un'impostazione di troncamento: una pagina che lo supera si ferma invece di restituire in silenzio parte del contenuto. Su un percorso di ingestion non attendibile è questo il comportamento che vuoi, perché una pagina con un milione di caratteri è o un mostro generato a macchina o un tentativo di rendere il tuo estrattore la parte più lenta del sistema

Due contatori sulla pagina restituita descrivono direttamente la qualità dell'estrazione. UnmappedCharacterCount conta i caratteri privi di una mappatura Unicode utilizzabile, che è il sintomo classico di un font subset incorporato senza una CMap /ToUnicode; un testo del genere si renderizza perfettamente ed estrae niente di utile. GeometryFailureCount conta i caratteri per cui non è stato possibile determinare il bounding box, il che degrada l'ordinamento in physical layout. Registra entrambi. Un insieme di documenti dove quei numeri sono costantemente vicini a zero può essere indicizzato con fiducia, e uno dove non lo sono ti sta dicendo che alcuni producer nella tua pipeline hanno bisogno di attenzione prima che qualunque risultato a valle sia affidabile

var
  Page: TPdfStructuredTextPage;
  B, S, L: Integer;
  Emphasised: Boolean;
begin
  Page := Pdf.GetStructuredText(Options);

  if Page.UnmappedCharacterCount > 0 then
    Log(Format('page %d: %d characters without a Unicode mapping',
      [Page.PageNumber, Page.UnmappedCharacterCount]));
  if Page.GeometryFailureCount > 0 then
    Log(Format('page %d: %d characters without geometry',
      [Page.PageNumber, Page.GeometryFailureCount]));

  for B := 0 to High(Page.Blocks) do
    for L := 0 to High(Page.Blocks[B].Lines) do
      for S := 0 to High(Page.Blocks[B].Lines[L].Spans) do
      begin
        Emphasised := Page.Blocks[B].Lines[L].Spans[S].FontWeight >= 600;
        AppendRun(Page.Blocks[B].Lines[L].Spans[S].Text, Emphasised,
          Page.Blocks[B].Lines[L].Spans[S].SourceStartIndex);
      end;
end;

Prestazioni su pagine reali

L'estrazione physical-layout è la modalità costosa, e l'implementazione è costruita per pagine che sono davvero grandi: l'ordinamento dei caratteri gira in O(n log n) invece che con scansioni ripetute, i buffer di righe e span crescono geometricamente invece di riallocare per ogni carattere, il testo Unicode è costruito in buffer invece che per concatenazione di stringhe, e i lookup dei font per oggetti di testo adiacenti sono in cache. Questa combinazione è ciò che mantiene prevedibile invece che quadratica una pagina densa di 5.000 caratteri

Per un job con molte pagine vale comunque la pena scegliere la modalità più economica dove puoi. Usa roContentOrder con la semantica attivata per i documenti taggati di cui ti fidi, e riserva roPhysicalLayout per il materiale scansionato e legacy dove la geometria è l'unico segnale. Se tutto ciò di cui hai bisogno è una stringa semplice, l'API più semplice descritta in estrarre testo da documenti PDF resta il percorso più veloce, e quando devi ricondurre il testo a identificatori di marked content, leggere e scrivere marked content BDC e MCID copre quel livello

Il modello a blocchi si mappa anche in modo pulito su ciò che vogliono le pipeline di retrieval: un titolo con i suoi paragrafi è un chunk con un titolo, e i bound permettono a una citazione di puntare a una posizione su una pagina invece che a un intero documento. PDFiumPas è un componente Delphi e Lazarus attorno al motore PDFium, documentato con esempi sulla pagina del componente Delphi PDFium