Articolo tecnico

Prestazioni di estrazione delle pagine con HotPDF in Delphi

Impiegare due minuti per copiare tre pagine da un PDF di 40 pagine non è un problema di ottimizzazione delle prestazioni. È il segnale che si sta utilizzando il percorso API errato. Quando ho visto per la prima volta questo tempismo in un esempio di copia delle pagine del componente HotPDF, il mio istinto è stato quello di guardare prima la struttura del documento e poi il codice. Questo ordine si è rivelato fondamentale.

Cosa era effettivamente lento

Il PDF in questione era un documento di riferimento di 40 pagine con un albero delle pagine non banale: nodi intermedi /Pages multipli invece di un singolo array piatto. Il codice di esempio originale chiamava LoadFromFile, poi creava un nuovo documento con BeginDoc, eseguiva un ciclo sui numeri di pagina selezionati e, ad ogni iterazione, caricava nuovamente il documento sorgente dal disco per estrarre una pagina. Questo comporta che il costo dell'intero parsing venga moltiplicato per il numero di pagine desiderate. Un file da 12 MB ha effettuato l'accesso al disco sei volte per estrarre tre pagine, semplicemente perché nessuno ha verificato se il file dovesse rimanere aperto durante le iterazioni.

Il secondo fattore era invisibile nel codice: il metodo LoadFromFile di HotPDF risolve l'intera tabella dei riferimenti incrociati (cross-reference table) e decomprime ogni stream di oggetti al caricamento. Questo è il comportamento corretto per un documento che si sta per modificare, ma comporta più lavoro del necessario se si desidera solo il conteggio delle pagine o un sottoinsieme di esse. Per l'accesso in sola lettura alla struttura, DAOpenFileReadOnly evita di deserializzare l'intero albero degli oggetti, il che è fondamentale per i file compressi con risorse di immagini di grandi dimensioni.

Nessuno di questi è un bug della libreria. In entrambi i casi, si trata di chiamate che scelgono l'API progettata per un determinato compito e la utilizzano per uno diverso.

Utilizzo di InsertPagesFromDocument per l'estrazione delle pagine

Il percorso corretto per copiare un intervallo di pagine da un documento HotPDF a un altro è InsertPagesFromDocument, chiamato dopo LoadFromFile sulla sorgente. Si carica la sorgente una sola volta, si carica o si crea la destinazione una volta sola, si spostano le pagine e si salva. La sorgente rimane in memoria durante tutte le inserzioni di pagina:

procedure ExtractPages(const SourceFile, DestFile: string;
  const PageRange: string);
var
  Source, Dest: THotPDF;
begin
  Source := THotPDF.Create(nil);
  Dest   := THotPDF.Create(nil);
  try
    // Load source once: full parse happens here and only here
    Source.LoadFromFile(SourceFile);

    // Build a minimal destination document
    Dest.FileName := DestFile;
    Dest.BeginDoc;

    // Copy the requested range; '1-3' inserts pages 1 through 3
    // starting at position 1 in the destination
    Dest.InsertPagesFromDocument(Source, PageRange, 1);

    Dest.EndDoc;
  finally
    Source.Free;
    Dest.Free;
  end;
end;

Il parametro PageRange accetta lo stesso formato dell'esempio a riga di comando: un elenco separato da virgole di numeri di pagina o intervalli, come '1-3' o '1,5,7-9'. Le pagine sono indicizzate a partire da 1. Il metodo InsertPagesFromDocument copia gli stream di contenuto, i dizionari delle risorse e la geometria delle pagine senza toccare metadati, segnalibri o allegati di file incorporati, a meno che non siano referenziati dalle pagine copiate. Per un'estrazione di tre pagine da un documento di 40 pagine, si tratta di un working set molto ridotto.

Il tempo di esecuzione sullo stesso file da 12 MB che in precedenza richiedeva due minuti: meno di 1,5 secondi con questo pattern. La maggior parte di questo tempo è impiegata dalla singola chiamata a LoadFromFile. La struttura del documento è irrilevante una volta risolta la tabella degli oggetti per la prima volta.

Quando LoadFromFile è troppo: la Direct File API

Se occorre solo contare le pagine, ispezionare le informazioni del documento o copiare un file senza toccarne il contenuto, la Direct File API evita completamente il parsing completo. Il metodo DAOpenFileReadOnly mappa la tabella dei riferimenti incrociati senza decomprimere gli stream di oggetti, pertanto il conteggio delle pagine è di complessità O(dimensione xref) anziché O(dimensione file):

procedure InspectPDF(const FileName: string);
var
  Pdf: THotPDF;
  Handle, PageCount: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Handle := Pdf.DAOpenFileReadOnly(FileName, '');
    if Handle <= 0 then
      Exit;
    try
      PageCount := Pdf.DAGetPageCount(Handle);
      Writeln('Pages: ', PageCount);

      // DACopyFile is a byte-preserving copy, no re-serialization
      Pdf.DACopyFile(FileName, 'archive-copy.pdf');
    finally
      Pdf.DACloseFile(Handle);
    end;
  finally
    Pdf.Free;
  end;
end;

Un'avvertenza: DAOpenFileReadOnly accetta un parametro per la password ma ricorre a un parsing completo nel caso di input crittografati, poiché la decrittografia richiede l'albero degli oggetti per risolvere il dizionario di cifratura. Se i file sorgente sono crittografati, decrittografateli prima con DecryptFile per ottenere una copia non crittografata, quindi apriteli con la Direct File API. La funzione a livello di file DecryptFile utilizza un percorso di riscrittura AES-256 diretto per la crittografia standard ed è più veloce di LoadFromFile seguita da SaveLoadedDocument per i file di grandi dimensioni, poiché non compila il modello a oggetti completo in memoria.

Memoria durante l'elaborazione di grandi batch

I processi batch che elaborano dozzine di file in un ciclo presentano un pattern che sembra corretto ma accumula memoria: la creazione di THotPDF all'interno del ciclo, la chiamata a LoadFromFile, l'esecuzione del lavoro e la chiamata a Free. Questo approccio è strutturalmente corretto. Il problema sorge quando il lavoro interno alloca oggetti temporanei, cattura eccezioni e lascia tali oggetti attivi nei percorsi di errore. Il gestore di memoria di Delphi non esegue la compattazione, quindi un centinaio di leak nei percorsi di errore durante un'esecuzione batch può aumentare l'uso della memoria al punto da rallentare l'allocazione per tutto il resto.

La soluzione non è complessa. Ogni istanza di THotPDF e ogni TStream o TBitmap intermedio coinvolto nel lavoro con i PDF deve risiedere in un blocco try/finally in cui Free rappresenta l'ultima istruzione. Impostare i puntatori locali a nil prima del blocco try, in modo che la sezione finally possa utilizzare in sicurezza if Assigned(x) then x.Free nel caso in cui l'inizializzazione fallisca a metà. Questa è la disciplina standard di gestione della proprietà in Delphi e rappresenta la soluzione definitiva per questa classe di problemi.

Un altro elemento da verificare nei contesti batch: il metodo AddImage registra le immagini in un elenco interno che persiste per l'intera durata dell'istanza THotPDF. Se si riutilizza una singola istanza su più documenti chiamando ripetutamente LoadFromFile, le registrazioni delle immagini dei documenti precedenti rimangono nell'elenco. È quindi necessario creare una nuova istanza per ogni documento o chiamare il metodo di cancellazione dell'elenco delle immagini tra un documento e l'altro.

Misurare prima di apportare modifiche

Prima di ricorrere a questi pattern, effettuate delle misurazioni. Il componente TStopwatch di Delphi, disponibile in System.Diagnostics, incapsula QueryPerformanceCounter ed è sufficientemente preciso per il profiling temporale dell'I/O dei file. Provate a racchiudere solo LoadFromFile per vedere quanto tempo richiede. Se rappresenta il 90% del tempo totale, la soluzione risiede nella Direct File API o nella riduzione del numero di volte in cui si esegue il parsing dello stesso file. Se è inferiore al 20%, il collo di bottiglia si trova altrove e vi state concentrando sul problema sbagliato.

L'estrazione da due minuti che ha dato il via a questo post è risultata essere causata interamente dal pattern di caricamento ripetuto. La struttura del documento non ha influito in alcun modo; un albero delle pagine piatto avrebbe riscontrato lo stesso comportamento. Passare a una singola chiamata a LoadFromFile seguita da una chiamata a InsertPagesFromDocument ha ridotto il tempo a 1,3 secondi sullo stesso hardware, senza alcuna altra modifica.

L'API di manipolazione delle pagine mostrata in questo post fa parte del componente HotPDF per Delphi e C++Builder.