Articolo tecnico

Reflow del contenuto PDF in HTML responsivo in Delphi

PDFium Component trasforma un PDF a layout fisso in un modello semantico che può essere sottoposto a reflow, usando BuildReflowDocument, ed esporta quel modello come HTML autonomo tramite ToHtml. I titoli restano titoli, gli elementi di elenco restano elementi di elenco, e le tabelle rilevate sulla pagina emergono come vero markup di tabella con celle di intestazione e span preservati. Nulla nell'output fa riferimento a uno script o a un foglio di stile esterno

Il motivo per cui questo è desiderabile è che una pagina PDF è un insieme di glifi posizionati, il che è esattamente sbagliato per uno schermo di telefono, uno screen reader, o un indice di ricerca. Ogni tentativo di risolvere il problema estraendo testo semplice perde la struttura che rendeva leggibile il documento, e ogni tentativo di risolverlo convertendo le pagine in immagini perde del tutto il testo. Un modello di reflow mantiene entrambi: le parole e le relazioni tra loro

Da dove proviene l'informazione semantica?

Tutto parte da GetStructuredText, l'unica fonte di testo e semantica nel componente. Quando il PDF porta un albero di struttura, PDF taggato come definito nella clausola 14.7 di ISO 32000-1, il modello segue la gerarchia logica registrata dal produttore. Quando non ce l'ha, e la maggior parte dei PDF in circolazione non ce l'ha, il modello ricade sull'ordine di layout fisico già calcolato per scopi di ordine di lettura

Questa scelta mantiene un confine rigido: non viene introdotto un secondo parser PDF né un secondo motore di rendering per rispondere a domande a cui quello esistente può già rispondere. Il meccanismo dell'ordine di lettura sottostante è descritto in blocchi di testo strutturato e ordine di lettura, e il modello di reflow è un livello semantico sopra di esso, non una sua sostituzione

Ogni nodo registra da dove proviene la propria informazione, così un consumatore può distinguere un titolo dichiarato dal documento da un titolo inferito dalle euristiche di layout. Le pipeline sensibili alla confidenza dovrebbero leggere quel campo anziché trattare tutti i nodi come ugualmente autorevoli

Un albero appiattito, e perché non è un albero di oggetti

Il modello è un albero appiattito in pre-ordine: un array di nodi dove ogni nodo porta un ParentIndex e una Depth, anziché un record ricorsivo o un grafo di oggetti con proprietà. Pagine, titoli, paragrafi, elenchi, elementi di elenco, figure, didascalie, tabelle, righe e celle risiedono tutti in quell'unico array lineare

Ne derivano due vantaggi. I consumatori possono scorrere l'array in ordine senza ricorsione, il che rende l'emissione di HTML, Markdown o una vista ad albero un semplice ciclo. E il layout resta portabile tra Delphi, C++Builder e Free Pascal, che differiscono nel modo in cui gestiscono i tipi gestiti ricorsivi attraverso un confine ABI. Un record ricorsivo di array dinamici è esattamente il tipo di costrutto che compila ovunque e si comporta in modo sottilmente diverso in ciascuno

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfReflowOptions;
  Doc: TPdfReflowDocument;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'report.pdf';
    Pdf.LoadDocument;

    Options := TPdfReflowOptions.Default;
    Options.FullDocument := True;
    Options.DetectTables := True;
    Options.IncludeCss := True;          // blocco di stile inline, nessun file esterno
    Options.MaxNodes := 200000;          // budget fail-closed
    Options.MaxCharacters := 4000000;

    Doc := Pdf.BuildReflowDocument(Options);

    for I := 0 to High(Doc.Nodes) do
      case Doc.Nodes[I].Kind of
        prnkHeading:
          Writeln(Format('%sH%d: %s', [StringOfChar(' ', Doc.Nodes[I].Depth),
            Doc.Nodes[I].HeadingLevel, Doc.Nodes[I].Text]));
        prnkParagraph:
          Writeln(Format('%sp: %s', [StringOfChar(' ', Doc.Nodes[I].Depth),
            Copy(Doc.Nodes[I].Text, 1, 60)]));
        prnkTable:
          Writeln(Format('table on page %d', [Doc.Nodes[I].PageNumber]));
      end;

    Writeln(Format('%d node(s), %d table(s), %d character(s)',
      [Length(Doc.Nodes), Doc.TableCount, Doc.CharacterCount]));
  finally
    Pdf.Free;
  end;
end;

Come si evita che le tabelle compaiano due volte?

Il rilevamento delle tabelle viene eseguito dopo che il testo strutturato è stato raccolto per una pagina, il che crea un rischio evidente: lo stesso contenuto di cella esiste sia nei blocchi di testo sia nella tabella rilevata. Emettere entrambi produce HTML dove ogni tabella è seguita di nuovo dal proprio contenuto come paragrafi sciolti

La regola che risolve il problema è geometrica. Quando una tabella rilevata copre più della metà dell'area di un blocco di testo, il nodo tabella sostituisce quel blocco anziché unirsi ad esso. L'indicizzazione delle celle dentro una riga viene costruita contando in bucket, così la costruzione del modello resta lineare in celle più righe invece di riscansionare ogni cella per ogni riga, il che conta su documenti finanziari dove una singola pagina può portare centinaia di celle

La struttura rilevata è onesta riguardo al proprio essere rilevata. Una tabella con linee di delimitazione viene riconosciuta in modo più affidabile di una allineata solo con spazi bianchi, e la confidenza del nodo lo riflette. Per contenuti dove una tabella sbagliata è meglio di nessuna tabella, tieni attivo il rilevamento; per la conversione archivistica dove una tabella sbagliata è peggio, filtra in base alla confidenza

Esportare HTML che resta autonomo

ToHtml percorre il modello già costruito e non torna mai a interpellare PDFium, quindi esportare due volte non costa nulla in più e non può produrre un risultato diverso dallo stesso modello. Il testo e i valori degli attributi vengono sfuggiti in modo uniforme, i livelli dei titoli vengono limitati all'intervallo h1-h6 effettivamente definito da HTML, e le celle di intestazione, RowSpan e ColumnSpan passano invariati

Il CSS opzionale è un semplice blocco di stile inline. Non c'è alcuno script, alcun web font e alcuna risorsa esterna di alcun tipo, il che è ciò che rende l'output sicuro da incorporare in un'email, un visualizzatore di guida o un controllo browser sandboxato:

var
  Html: WideString;
  Stream: TFileStream;
  Bytes: TBytes;
begin
  Options := TPdfReflowOptions.Default;
  Options.FullDocument := True;
  Options.IncludeCss := True;
  Options.IncludePageSections := True;   // mantieni visibili i confini di pagina
  Options.PreserveLineBreaks := False;   // lascia che il browser vada a capo nei paragrafi

  Html := Pdf.BuildReflowDocument(Options).ToHtml;

  Bytes := TEncoding.UTF8.GetBytes(string(Html));
  Stream := TFileStream.Create('report.html', fmCreate);
  try
    if Length(Bytes) > 0 then
      Stream.WriteBuffer(Bytes[0], Length(Bytes));
  finally
    Stream.Free;
  end;
end;

PreserveLineBreaks è l'opzione su cui vale più la pena riflettere. Un'interruzione di riga nel PDF è una decisione di impaginazione presa per una larghezza di pagina fissa, quindi preservarla su uno schermo stretto riproduce esattamente il problema che il reflow esiste per risolvere. Preserva le interruzioni per poesia, elenchi di codice e indirizzi; scartale per la prosa

Budget, cancellazione e stato della pagina

Caratteri, nodi, tabelle e celle hanno ciascuno un limite massimo, e ognuno viene controllato prima dell'allocazione anziché dopo, così un documento malformato o malevolo fallisce in modo pulito invece di consumare memoria finché non lo fa qualcos'altro. Il token di cancellazione viene controllato ai confini di pagina, blocco, tabella, riga e cella, il che mantiene reattiva una scansione annullata di un documento di mille pagine

Un comportamento conta specificamente per le applicazioni GUI: l'intera scansione del documento viene eseguita dentro uno scope che ripristina la pagina attiva, così successo, fallimento del budget e cancellazione lasciano tutti intatta la pagina corrente del chiamante. Un visualizzatore che permette all'utente di esportare mentre guarda la pagina 340 si ritrova ancora sulla pagina 340 in seguito

A cosa serve il reflow, e a cosa non serve

L'output del reflow è un ottimo input per l'indicizzazione di ricerca, le viste di lettura accessibili, la visualizzazione mobile e la migrazione dei contenuti. Non è un convertitore che preserva la fedeltà: posizioni assolute, font esatti, grafica vettoriale e geometria di pagina precisa sono fuori dal suo scopo per progetto. Quando un lavoro richiede che la pagina appaia identica, renderizzala; quando richiede che la pagina sia leggibile altrove, sottoponila a reflow

Specificamente per le tecnologie assistive, il modello di reflow si abbina alle funzionalità di lettura descritte in costruire un lettore accessibile, e i documenti che portano un vero albero di struttura producono modelli notevolmente migliori, il che è un buon argomento per validare il tagging a monte come descritto in validazione dell'albero di struttura PDF/UA

Reflow, testo strutturato, validazione del tagging e rendering condividono un unico oggetto documento tra Delphi, C++Builder e Lazarus; l'intera API è descritta nella pagina di PDFium Component per Delphi