Un archivio scansionato può raggiungere svariati gigabyte in un singolo PDF. Un visualizzatore che apre un file simile solitamente vuole mostrare una pagina, forse il sommario, o magari una pagina a cui l'utente è saltato da un segnalibro. Leggere l'intero file in memoria per renderizzare due pagine è uno spreco sotto ogni punto di vista: brucia spazio di indirizzamento, blocca l'utente dietro una lunga lettura iniziale, e su un processo Delphi a 32 bit può fallire del tutto prima che appaia una singola pagina. PDFium è stato creato tenendo a mente questo aspetto. Può caricare un documento tramite un callback che richiede gli intervalli di byte specifici di cui ha bisogno, nel momento in cui ne ha bisogno, e non richiede mai l'intero file in una sola volta. Un limite va chiarito fin da subito: questo canale di streaming descrive il file con una lunghezza a 32 bit, quindi serve un singolo file fino a 4 GiB, il che copre in pratica quasi ogni archivio scansionato. Un file che supera tale limite non è il territorio di questo articolo; dovrebbe invece essere suddiviso in volumi al momento della scansione o aperto tramite una strategia di accesso diretto, e la protezione che fa rispettare questo limite massimo si merita onestamente una sezione a sé stante più avanti
Il componente espone quel percorso tramite un adattatore di stream. Gli passi un qualsiasi TStream, e PDFium estrae i blocchi da quello stream su richiesta. Il file può risiedere su disco, in un campo blob di un database, o dietro qualsiasi altro discendente di TStream, e nulla di tutto ciò viene copiato preventivamente in memoria
Come PDFium richiede i byte
L'API C di PDFium carica un documento da un oggetto fornito dal chiamante descritto dalla struttura FPDF_FILEACCESS. La struttura ha tre parti che contano qui: un campo lunghezza, un callback di lettura e un parametro utente opaco. Il punto di ingresso che lo consuma è FPDF_LoadCustomDocument. Una volta che PDFium possiede quella struttura analizza il trailer, individua la tabella dei riferimenti incrociati e da quel momento in poi legge solo ciò che una determinata operazione richiede. L'apertura del documento tocca la parte finale (tail) del file e una manciata di oggetti di catalogo. Il rendering della pagina 400 legge i flussi di contenuto e le risorse per quella pagina e nient'altro
Questa è la differenza tra un caricamento bufferizzato e un caricamento in streaming. Un caricamento bufferizzato legge il file da un capo all'altro prima che PDFium veda il byte zero. Un caricamento in streaming inverte la relazione: PDFium guida le letture e i byte che non vengono mai toccati non vengono mai letti. Per un file di svariati gigabyte visualizzato una pagina alla volta, questo è il divario tra un caricamento inutilizzabile e uno istantaneo
L'adattatore di stream
L'adattatore che fa da ponte tra un TStream Delphi e FPDF_FILEACCESS è TPdfStreamAdapter. Il suo costruttore prende lo stream e un flag di proprietà (ownership), cattura la lunghezza dello stream una volta sola, compila il record FPDF_FILEACCESS e collega il callback di lettura. Quando PDFium successivamente richiama con un offset e una dimensione, l'adattatore sposta lo stream a quell'offset e copia esattamente quell'intervallo nel buffer fornito da PDFium
// Verbatim from the component: the stream-to-FPDF_FILEACCESS bridge
constructor TPdfStreamAdapter.Create(AStream: TStream; AOwnsStream: Boolean);
begin
inherited Create;
if AStream = nil then
raise EPdfError.Create('TPdfStreamAdapter: AStream is nil');
FStream := AStream;
FOwnsStream := AOwnsStream;
// FPDF_FILEACCESS.m_FileLen is a 32-bit unsigned long. Refuse a stream
// that would silently truncate past 4 GiB.
if AStream.Size > High(FPDF_DWORD) then
raise EPdfError.Create('TPdfStreamAdapter: stream exceeds the 4 GiB limit');
FillChar(FFileAccess, SizeOf(FFileAccess), 0);
FFileAccess.m_FileLen := FPDF_DWORD(AStream.Size);
FFileAccess.m_GetBlock := GetBlockCallback;
FFileAccess.m_Param := Self;
end;
Il flag di proprietà decide chi libera lo stream. Passa False e il chiamante mantiene lo stream e deve mantenerlo in vita per l'intera durata del documento. Passa True e l'adattatore se ne fa carico, liberando lo stream quando il documento si chiude. In ogni caso lo stream deve sopravvivere a ogni lettura che PDFium eseguirà, perché PDFium trattiene il puntatore FPDF_FILEACCESS e richiamerà in qualsiasi momento mentre il documento è aperto, non solo durante il caricamento iniziale
Perché il callback è una funzione statica
Il callback di lettura che PDFium memorizza in m_GetBlock è un semplice puntatore a funzione C con la convenzione di chiamata cdecl. Un metodo Delphi non può essere utilizzato direttamente, perché un metodo porta con sé un argomento Self nascosto di cui un chiamante C non sa nulla e che non fornirà mai. L'adattatore dichiara quindi il callback come una class function contrassegnata con cdecl; static, che viene compilata in una funzione autonoma con il layout del frame C che PDFium si aspetta e nessun Self implicito
Questo risolve il problema della convenzione di chiamata ma solleva una seconda domanda: senza alcun Self, come fa il callback a raggiungere lo stream specifico da cui dovrebbe leggere? La risposta è il parametro utente opaco. Quando l'adattatore costruisce il record memorizza il puntatore alla propria istanza in m_Param. PDFium restituisce quello stesso puntatore come primo argomento di ogni callback. La funzione statica lo converte nuovamente in un TPdfStreamAdapter e inoltra la lettura allo stream di quell'istanza. Questo è il classico trampolino di lancio standard per passare il contesto di un oggetto attraverso un confine C che non ha alcuna nozione di oggetti
// Verbatim from the component: the cdecl trampoline back to the instance
class function TPdfStreamAdapter.GetBlockCallback(
param : Pointer;
position: FPDF_DWORD;
pBuf : PByte;
size : FPDF_DWORD): Integer; cdecl;
var
Adapter: TPdfStreamAdapter;
begin
Result := 0;
if (param = nil) or (pBuf = nil) or (size = 0) then
Exit;
Adapter := TPdfStreamAdapter(param); // recover the instance from m_Param
if Adapter.FStream = nil then
Exit;
try
Adapter.FStream.Position := Int64(position);
Adapter.FStream.ReadBuffer(pBuf^, Int64(size));
Result := 1;
except
Result := 0; // report failure by return value, never by raising
end;
end;
Il limite massimo di 4 GiB e perché richiede una protezione
Ecco da dove deriva il limite enunciato all'inizio. Il campo lunghezza m_FileLen in FPDF_FILEACCESS è un valore senza segno a 32 bit. La sua massima lunghezza rappresentabile è un byte in meno di 4 GiB. Un TStream riporta la sua dimensione come un Int64, quindi uno stream può descrivere molti più byte di quanti il campo possa contenere. Nel momento in cui la dimensione di uno stream supera tale limite, non esiste alcun modo onesto per dire a PDFium quanto sia lungo il file
La risposta sbagliata è assegnare la dimensione e lasciare che vada in overflow (wrap). Troncare una lunghezza di 5 GiB a un campo di 32 bit produce un numero piccolo e all'apparenza plausibile, e PDFium analizzerà quindi il file credendo che finisca all'incirca a un gigabyte dall'inizio. Il trailer e la tabella dei riferimenti incrociati risiedono alla vera fine del file, ben oltre la lunghezza troncata, quindi l'analisi fallisce in un modo che non ha nulla a che fare con la causa reale. Ti ritroveresti a eseguire il debug di un errore di riferimento incrociato su un file perfettamente valido, senza alcun indizio del fatto che un intero sia andato in overflow due livelli più in alto
L'adattatore rifiuta invece l'input. Il costruttore confronta la dimensione dello stream con High(FPDF_DWORD) e solleva EPdfError nell'istante in cui lo stream è troppo grande per poter essere descritto. Un errore esplicito e immediato nomina il vero problema al momento della costruzione. Un troncamento silenzioso lo nasconderebbe dietro un sintomo fuorviante a cui daresti la caccia molto più tardi. Il limite di 4 GiB è un vincolo effettivo di questo percorso di caricamento, e la cosa onesta da fare è portarlo a galla in modo evidente piuttosto che mascherarlo con operazioni aritmetiche che per caso compilano. Quando un archivio oltrepassa realmente il limite, i rimedi promessi all'inizio risiedono al di fuori di questa API: dividi la scansione in file per volume che rimangano ciascuno sotto il limite, o lascia il documento su disco e servilo attraverso una struttura ad accesso diretto basata su offset a 64 bit piuttosto che attraverso FPDF_FILEACCESS
I fallimenti non devono oltrepassare il confine
Una lettura può fallire. Lo stream potrebbe essere un oggetto supportato da una rete che va in timeout, un handle blob che è stato chiuso a tua insaputa, o un file che è stato troncato dopo l'apertura del documento. Il contratto di PDFium per il callback di lettura è un valore di ritorno: non-zero per il successo, zero per il fallimento. Si tratta di un frame C, e non ha alcun meccanismo per catturare o propagare un'eccezione Pascal
Questo è il motivo per cui il trampolino avvolge la ricerca (seek) e la lettura in un try/except che inghiotte l'eccezione e restituisce zero. Se si permettesse a un'eccezione Delphi di propagarsi al di fuori del callback, questa si srotolerebbe attraverso i frame dello stack cdecl di PDFium, che non sono mai stati pensati per essere srotolati dai meccanismi per le eccezioni di Pascal. Il risultato è un comportamento indefinito nel migliore dei casi e un grave blocco (crash) nel peggiore, nel profondo del parser PDF senza nessuno stack utilizzabile. Restituire zero mantiene il fallimento all'interno del contratto. PDFium rileva la mancata lettura di un blocco, interrompe l'operazione in modo pulito e FPDF_LoadCustomDocument segnala che il documento non è potuto essere caricato, cosa che il componente riporta a galla come EPdfError sul lato Pascal, al quale appartiene
Aprire un documento in questo modo
Il metodo del componente che guida il percorso di streaming è LoadCustomDocument, dichiarato come un metodo distinto piuttosto che come un altro sovraccarico (overload) di LoadDocument in modo che il passaggio di un TMemoryStream non finisca mai accidentalmente nel percorso bufferizzato. Esso costruisce l'adattatore, chiama FPDF_LoadCustomDocument e mantiene l'adattatore in vita per tutta la durata del documento caricato
var
Pdf: TPdf;
FileStream: TFileStream;
begin
Pdf := TPdf.Create(nil);
FileStream := TFileStream.Create('Archive_4GB.pdf', fmOpenRead or fmShareDenyWrite);
try
// Hand stream ownership to Pdf: it frees FileStream when the document closes.
Pdf.LoadCustomDocument(FileStream, True);
// PDFium has read only the trailer and catalog so far.
// Rendering a page pulls just that page's bytes through the callback.
// ... render or inspect pages here ...
finally
Pdf.Free; // closes the document, which frees the adapter and the stream
end;
end;
La stessa chiamata funziona per un TMemoryStream, un flusso blob da un dataset di database, o un discendente personalizzato di TStream. Il caricamento su richiesta dà il meglio di sé quando il file è grande e ne verrà letta solo una parte: un visualizzatore di archivi, un generatore di miniature che campiona poche pagine, un indice di ricerca che estrae una pagina alla volta. Quando il file è piccolo o stai comunque per leggerlo tutto, un caricamento bufferizzato è più semplice e i meccanismi di streaming non ti offrono alcun vantaggio. Il fattore decisivo è il rapporto tra i byte che andrai effettivamente a toccare e i byte contenuti dal file
Una volta che le pagine sono arrivate in streaming su richiesta, la preoccupazione successiva è mantenere le pagine renderizzate reattive mentre l'utente esegue lo zoom e lo scorrimento, argomento trattato nella nostra nota sulla cache di rendering e sulle prestazioni dello zoom. Quando il documento inviato in streaming è uno di quelli che un visualizzatore dovrebbe mostrare senza però consentire all'utente di esportarlo o modificarlo, le tecniche illustrate nella guida alla visualizzazione in anteprima sicura dei PDF si abbinano in modo naturale a questo percorso di caricamento. Entrambi si basano sul caricamento in streaming qui descritto, che viene fornito come parte del Componente PDFium per Delphi e C++Builder insieme alle API di rendering, estrazione testo e annotazione descritte altrove in questo blog