Articolo tecnico

Prestazioni di estrazione pagine con HotPDF in Delphi

Due minuti per copiare tre pagine da un PDF di 40 pagine non è un problema di tuning delle prestazioni. È il segnale che si sta usando il percorso API sbagliato. La prima volta che ho visto questa tempistica su codice di copia pagine costruito sul HotPDF Delphi Component, il mio istinto è stato guardare prima la struttura del documento e dopo il codice. Quell'ordine si è rivelato capovolto

Cosa era davvero lento

Il PDF in questione era un documento di riferimento da 40 pagine con un albero di pagine non banale: più nodi /Pages intermedi invece di un singolo array piatto. Il codice in questione chiamava LoadFromFile, poi costruiva un nuovo documento con BeginDoc, ciclava sui numeri di pagina selezionati, e a ogni iterazione ricaricava da disco il documento sorgente per tirare una pagina. È il costo completo del parse moltiplicato per quante pagine vuoi. Un file da 12 MB ha colpito il disco sei volte per un'estrazione di tre pagine, perché nessuno ha verificato se il file dovesse restare aperto tra le iterazioni

Il secondo contributore era invisibile nel codice: la LoadFromFile di HotPDF costruisce il modello a oggetti in memoria su cui lavora l'API di editing. È il comportamento giusto per un documento che state per modificare, ma è più lavoro di quanto serva se volete solo il numero di pagine e un sottoinsieme di pagine. Per l'accesso in sola lettura alla struttura, DAOpenFileReadOnly evita di deserializzare l'intero albero di oggetti, il che conta su file compressi con grandi risorse immagine

Entrambi i problemi si riducono a scegliere l'API progettata per un lavoro e usarla per uno diverso

Diagramma che contrappone ripetute chiamate LoadFromFile dentro un ciclo di estrazione a un singolo parse seguito da ExtractPagesToFile per una veloce estrazione di pagine PDF in Delphi
Ricaricare la sorgente a ogni iterazione moltiplica il costo completo del parse, trasformando una copia di tre pagine in un lavoro da due minuti. Un parse seguito da una sola chiamata di estrazione porta lo stesso lavoro a circa 1.3 secondi

Usare ExtractPagesToFile per l'estrazione delle pagine

La strada giusta per copiare un intervallo di pagine da un documento HotPDF caricato verso un nuovo file è ExtractPagesToFile, chiamata dopo LoadFromFile sulla sorgente. Caricate la sorgente una volta sola, e la chiamata costruisce il documento di destinazione, vi copia le pagine selezionate e lo scrive su disco, tutto dalla copia già in memoria:

procedure ExtractPages(const SourceFile, DestFile: string;
  const PageRange: string);
var
  Source: THotPDF;
begin
  Source := THotPDF.Create(nil);
  try
    // Caricate la sorgente una volta: il parse completo avviene qui e solo qui
    if Source.LoadFromFile(SourceFile) <= 0 then
      raise Exception.Create('Source PDF has no pages');

    // Scrivete l'intervallo richiesto ('1-3', '1,5,7-9') dritto su un nuovo file
    Source.ExtractPagesToFile(DestFile, PageRange);
  finally
    Source.Free;
  end;
end;

Il parametro PageRange accetta lo stesso formato dell'esempio da riga di comando: una lista separata da virgole di numeri di pagina o intervalli come '1-3' o '1,5,7-9'. Le pagine sono in base 1. ExtractPagesToFile copia ogni pagina selezionata insieme agli stream di contenuto e alle risorse che referenzia, così per un'estrazione di tre pagine da un documento di 40 pagine il working set resta piccolo

Tempistiche sullo stesso file da 12 MB che prima girava per due minuti: circa 1.3 secondi con questo schema. La maggior parte di quel tempo è la singola chiamata LoadFromFile. La struttura del documento è irrilevante una volta che la tabella degli oggetti è risolta la prima volta

Quando LoadFromFile è troppo: la Direct File API

Se vi serve solo contare le pagine, ispezionare le informazioni del documento o copiare un file senza toccarne i contenuti, la Direct File API evita del tutto il parsing completo. DAOpenFileReadOnly mappa la tabella dei riferimenti incrociati senza decomprimere gli object stream, quindi il conteggio delle pagine è 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 è una copia che preserva i byte, nessuna riserializzazione
      Pdf.DACopyFile(FileName, 'archive-copy.pdf');
    finally
      Pdf.DACloseFile(Handle);
    end;
  finally
    Pdf.Free;
  end;
end;

L'avvertenza: DAOpenFileReadOnly accetta un parametro password ma ripiega su un parsing completo per gli input cifrati, perché la decifratura richiede l'albero degli oggetti per risolvere il dizionario di cifratura. Se i vostri file sorgente sono cifrati, decifrateli prima con DecryptFile per ottenere una copia non cifrata, poi apritela con la Direct File API. La funzione a livello di file DecryptFile segue un percorso di riscrittura AES-256 diretto per la cifratura standard ed è più veloce di LoadFromFile seguito da SaveLoadedDocument per i file grandi, perché non costruisce il modello a oggetti completo in memoria

Diagramma decisionale che sceglie tra il percorso completo di editing HotPDF e la Direct File API di sola lettura, con la decifratura gestita per prima per gli input cifrati
I compiti in sola lettura appartengono al percorso di accesso diretto ai file, dove il conteggio delle pagine scala con la tabella dei riferimenti incrociati anziché con la dimensione del file. Gli input cifrati ricadono su un parsing completo a meno che non vengano decifrati prima

La memoria durante l'elaborazione di grandi batch

I job batch che elaborano decine di file in un ciclo hanno un pattern che sembra corretto ma accumula memoria: creare THotPDF dentro il ciclo, chiamare LoadFromFile, fare il lavoro, chiamare Free. Strutturalmente va bene. Il problema è quando il lavoro interno alloca oggetti temporanei, cattura eccezioni e lascia quegli oggetti temporanei vivi sui percorsi di errore. Il memory manager di Delphi non compatta, quindi un centinaio di leak sui percorsi di errore lungo un'esecuzione batch può spingere la memoria abbastanza in alto da rallentare l'allocazione per tutto il resto

La soluzione non è esotica. Ogni THotPDF e ogni TStream o TBitmap intermedio che partecipa al lavoro PDF appartiene a un blocco try/finally in cui Free è l'ultima istruzione. Impostate i puntatori locali a nil prima del try, in modo che il ramo finally possa usare if Assigned(x) then x.Free in sicurezza quando l'inizializzazione fallisce a metà. Questa è la normale disciplina di ownership di Delphi ed è tutta la storia per questa classe di problemi

Un'altra cosa da sapere nei contesti batch: AddImage registra ogni immagine presso il documento in costruzione, e HotPDF rilascia quelle registrazioni quando EndDoc termina quel file. Il registro segue il ciclo BeginDoc/EndDoc, non LoadFromFile, così lo schema pulito per un batch è un'istanza di THotPDF per documento: la create, fate il lavoro, salvate, la liberate

Misurare prima di cambiare qualsiasi cosa

Prima di ricorrere a uno qualsiasi di questi pattern, misurate. Il TStopwatch di Delphi in System.Diagnostics incapsula QueryPerformanceCounter ed è abbastanza preciso per profilare a orologio l'I/O su file. Cronometrate LoadFromFile da solo e vedete quanto pesa. Se è il 90% del tempo totale, la soluzione è la Direct File API oppure ridurre quante volte fate il parsing dello stesso file. Se è sotto il 20%, il collo di bottiglia è altrove e state inseguendo la cosa sbagliata

L'estrazione da due minuti che ha dato origine a questo post si è rivelata interamente lo schema del carico ripetuto. La struttura del documento non contribuiva nulla; un albero di pagine piatto avrebbe girato allo stesso modo. Passare a una singola LoadFromFile seguita da una chiamata ExtractPagesToFile l'ha portata a circa 1.3 secondi sullo stesso hardware senza toccare nulla altro

L'API di manipolazione delle pagine mostrata qui fa parte del HotPDF Delphi Component per Delphi e C++Builder