Articol tehnic

Redarea în Flux a PDF-urilor Uriașe la Cerere Cu PDFium în Delphi

O arhivă scanată poate ajunge la câțiva gigaocteți într-un singur PDF. Un vizualizator care deschide un astfel de fișier dorește, de obicei, să afișeze o singură pagină, poate cuprinsul, poate o pagină la care utilizatorul a sărit dintr-un marcaj. Citirea întregului fișier în memorie pentru a reda două pagini reprezintă o risipă din toate punctele de vedere: consumă spațiu de adrese, blochează utilizatorul în spatele unei citiri inițiale lungi, iar pe un proces Delphi pe 32 de biți poate eșua de-a dreptul înainte să apară vreo pagină. PDFium a fost construit cu gândul la acest lucru. Poate încărca un document printr-un callback care cere intervalele specifice de octeți de care are nevoie, atunci când are nevoie de ele, și nu solicită niciodată întregul fișier dintr-o dată. O graniță aparține din capul locului: acest canal de flux descrie fișierul cu o lungime pe 32 de biți, deci servește un singur fișier de până la 4 GiB, ceea ce acoperă aproape fiecare arhivă scanată în practică. Un fișier care trece de această linie nu este teritoriul acestui articol; el trebuie împărțit în volume la momentul scanării sau deschis în schimb printr-o strategie de acces direct, iar protecția care aplică plafonul primește cu adevărat propria sa secțiune mai jos

Componenta expune acea cale printr-un adaptor de flux. Îi transmiteți orice TStream, iar PDFium extrage blocuri din acel flux la cerere. Fișierul se poate afla pe disc, într-un câmp blob al bazei de date sau în spatele oricărui alt descendent TStream, și nimic din el nu este copiat în memorie în prealabil

Cum solicită PDFium octeți

API-ul C al PDFium încarcă un document dintr-un obiect furnizat de apelant descris de structura FPDF_FILEACCESS. Structura are trei părți care contează aici: un câmp de lungime, un callback de citire și un parametru opac de utilizator. Punctul de intrare care îl consumă este FPDF_LoadCustomDocument. Odată ce PDFium deține acea structură, analizează trailerul, localizează tabelul de referințe încrucișate și, de atunci înainte, citește doar ceea ce o anumită operație necesită. Deschiderea documentului atinge coada fișierului și câteva obiecte de catalog. Redarea paginii 400 citește fluxurile de conținut și resursele pentru acea pagină și nimic altceva

Aceasta este diferența dintre o încărcare în buffer și o încărcare în flux. O încărcare în buffer citește fișierul cap la coadă înainte ca PDFium să vadă octetul zero. O încărcare în flux inversează relația: PDFium conduce citirile, iar octeții care nu sunt atinși niciodată nu sunt citiți niciodată. Pentru un fișier de mai mulți gigaocteți vizualizat o pagină la un moment dat, acesta este decalajul dintre o încărcare inutilizabilă și una instantanee

Adaptorul de flux

Adaptorul care face legătura între un TStream Delphi și FPDF_FILEACCESS este TPdfStreamAdapter. Constructorul său preia fluxul și un semnalizator de proprietate, capturează lungimea fluxului o singură dată, completează înregistrarea FPDF_FILEACCESS și conectează callback-ul de citire. Când PDFium apelează ulterior cu un offset și o dimensiune, adaptorul caută în flux la acel offset și copiază exact acel interval în bufferul furnizat de 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;

Semnalizatorul de proprietate decide cine eliberează fluxul. Transmiteți False și apelantul păstrează fluxul și trebuie să-l mențină în viață pe toată durata de viață a documentului. Transmiteți True și adaptorul preia controlul, eliberând fluxul atunci când documentul se închide. Oricum, fluxul trebuie să supraviețuiască oricărei citiri pe care o va efectua PDFium, deoarece PDFium deține pointerul FPDF_FILEACCESS și va apela invers în orice moment în care documentul este deschis, nu doar în timpul încărcării inițiale

De ce callback-ul este o funcție statică

Callback-ul de citire pe care PDFium îl stochează în m_GetBlock este un simplu pointer de funcție C cu convenția de apelare cdecl. O metodă Delphi nu poate fi utilizată direct, deoarece o metodă poartă un argument Self ascuns despre care un apelant C nu știe nimic și nu îl va furniza niciodată. Prin urmare, adaptorul declară callback-ul ca o class function marcată cdecl; static, care se compilează într-o funcție independentă cu structura de cadru C pe care o așteaptă PDFium și fără un Self implicit

Aceasta rezolvă convenția de apelare, dar ridică o a doua întrebare: fără niciun Self, cum ajunge callback-ul la fluxul specific din care se presupune că citește? Răspunsul este parametrul opac al utilizatorului. Când adaptorul construiește înregistrarea, își stochează propriul pointer de instanță în m_Param. PDFium predă același pointer înapoi ca prim argument al fiecărui callback. Funcția statică îl convertește înapoi într-un TPdfStreamAdapter și expediază citirea către fluxul acelei instanțe. Acesta este trambulina standard pentru predarea contextului obiectului dincolo de o graniță C care nu are nicio noțiune de obiecte

// 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;

Plafonul de 4 GiB și de ce are nevoie de o protecție

De aici provine granița menționată în deschidere. Câmpul de lungime m_FileLen din FPDF_FILEACCESS este o valoare fără semn pe 32 de biți. Cea mai mare lungime reprezentabilă este cu un octet mai mică de 4 GiB. Un TStream raportează dimensiunea sa ca un Int64, deci un flux poate descrie mult mai mulți octeți decât poate deține câmpul. În momentul în care dimensiunea unui flux depășește acest plafon, nu există o modalitate onestă de a spune PDFium cât de lung este fișierul

Răspunsul greșit este de a atribui dimensiunea și a o lăsa să se înfășoare (wrap). Trunchierea unei lungimi de 5 GiB la un câmp pe 32 de biți produce un număr mic, cu aspect plauzibil, iar PDFium va analiza apoi fișierul crezând că se termină la aproximativ un gigaoctet distanță. Trailerul și tabelul de referințe încrucișate trăiesc la sfârșitul real al fișierului, mult peste lungimea trunchiată, deci analiza eșuează într-un mod care nu are nicio legătură cu cauza reală. Ați depana o eroare de referință încrucișată pe un fișier care este perfect valid, fără niciun indiciu că un număr întreg s-a înfășurat cu două straturi mai sus

Adaptorul refuză intrarea în schimb. Constructorul compară dimensiunea fluxului cu High(FPDF_DWORD) și declanșează EPdfError în momentul în care fluxul este prea mare pentru a fi descris. O eroare explicită, imediată numește problema reală în momentul construirii. O trunchiere tăcută o ascunde în spatele unui simptom înșelător pe care l-ați urmări mult mai târziu. Limita de 4 GiB este o constrângere reală a acestei căi de încărcare, iar lucrul onest este de a o scoate la suprafață puternic în loc de a o masca cu aritmetică care se întâmplă să se compileze. Când o arhivă trece cu adevărat linia, remediile promise în partea de sus trăiesc în afara acestui API: împărțiți scanarea în fișiere pe volume care rămân fiecare sub plafon sau lăsați documentul pe disc și serviți-l printr-un design cu acces direct construit pe offset-uri pe 64 de biți, mai degrabă decât prin FPDF_FILEACCESS

Eșecurile nu trebuie să depășească granița

O citire poate eșua. Fluxul ar putea fi un obiect susținut de rețea care expiră, un handle blob care a fost închis sub dvs. sau un fișier care a fost trunchiat după deschiderea documentului. Contractul PDFium pentru callback-ul de citire este o valoare de returnare: diferită de zero pentru succes, zero pentru eșec. Este un cadru C și nu are niciun mecanism pentru a prinde sau propaga o excepție Pascal

Acesta este motivul pentru care trambulina înfășoară căutarea și citirea într-un try/except care înghite excepția și returnează zero. Dacă unei excepții Delphi i s-ar permite să se propage în afara callback-ului, s-ar derula prin cadrele de stivă cdecl ale PDFium, care nu au fost niciodată construite pentru a fi derulate de mecanismul de excepții Pascal. Rezultatul este un comportament nedefinit în cel mai bun caz și un accident grav în cel mai rău caz, adânc în parserul PDF fără nicio stivă utilizabilă. Returnarea lui zero menține eșecul în cadrul contractului. PDFium vede o citire a blocului eșuată, întrerupe operația în mod curat, iar FPDF_LoadCustomDocument raportează că documentul nu a putut fi încărcat, pe care componenta îl scoate la suprafață ca un EPdfError pe partea Pascal unde îi este locul

Deschiderea unui document în acest mod

Metoda componentei care conduce calea de streaming este LoadCustomDocument, declarată ca o metodă distinctă mai degrabă decât o altă supraîncărcare LoadDocument astfel încât transmiterea unui TMemoryStream să nu ajungă niciodată accidental pe calea cu buffer. Construiește adaptorul, apelează FPDF_LoadCustomDocument și menține adaptorul în viață pe toată durata de viață a documentului încărcat

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;

Același apel funcționează pentru un TMemoryStream, un flux blob dintr-un set de date al bazei de date sau un descendent TStream personalizat. Încărcarea la cerere își câștigă existența atunci când fișierul este mare și doar o parte din el va fi citită: un vizualizator de arhivă, un generator de miniaturi care eșantionează câteva pagini, un index de căutare care extrage o pagină la un moment dat. Când fișierul este mic sau veți citi oricum tot, o încărcare cu buffer este mai simplă și mecanismul de redare în flux nu vă oferă nimic. Factorul decisiv este raportul dintre octeții pe care îi veți atinge efectiv și octeții pe care îi conține fișierul

Odată ce paginile se încarcă în flux la cerere, următoarea preocupare este menținerea paginilor redate receptive pe măsură ce utilizatorul mărește și derulează, care este acoperită în nota noastră despre memoria cache de redare și performanța de mărire. Atunci când documentul redat în flux este unul pe care un vizualizator ar trebui să îl afișeze, dar nu să îi permită utilizatorului să îl exporte sau să îl modifice, tehnicile din ghidul pas cu pas de previzualizare sigură a PDF-urilor se asociază în mod natural cu această cale de încărcare. Ambele se bazează pe încărcarea în flux descrisă aici, care se livrează ca parte a Componentei PDFium pentru Delphi și C++Builder alături de API-urile de redare, extragere a textului și adnotare acoperite în altă parte pe acest blog