Odborný článok

Streamovanie obrovských súborov PDF na požiadanie pomocou PDFium v Delphi

Naskenovaný archív môže mať v jedinom PDF súbore aj niekoľko gigabajtov. Prehliadač, ktorý takýto súbor otvára, zvyčajne potrebuje zobraziť len jednu stranu, možno obsah alebo stranu zložky, na ktorú používateľ prešiel cez záložku. Načítať celý súbor do pamäte len pre vykreslenie dvoch strán je plytvaním vo všetkých smeroch: míňa to adresný priestor, zdržiava používateľa kvôli dlhému úvodnému načítavaniu a v 32-bitovom Delphi procese to môže úplne zlyhať skôr, než sa vôbec nejaká strana zobrazí. Knižnica PDFium bola navrhnutá práve s ohľadom na tento problém. Dokument dokáže načítať cez spätné volanie (callback), ktoré si pýta iba tie bajtové rozsahy, ktoré práve potrebuje, presne v momente, keď ich potrebuje, a nikdy nevyžaduje celý súbor naraz. Jedno obmedzenie však existuje už na začiatku: tento streamovací kanál popisuje dĺžku súboru pomocou 32-bitovej hodnoty, čo znamená, že dokáže obslúžiť jediný súbor do veľkosti 4 GiB, čím v praxi pokrýva takmer každý naskenovaný archív. Súbor presahujúci túto hranicu nespadá do oblasti tohto článku; v ideálnom prípade by mal byť už pri skenovaní rozdelený na viacero zväzkov, alebo by mal byť otvorený pomocou stratégie s priamym prístupom, pričom poistke, ktorá vynucuje tento strop, venujeme pre jej dôležitosť vlastnú sekciu nižšie

Komponent túto cestu sprístupňuje cez adaptér streamu. Odovzdáte mu akýkoľvek TStream a PDFium si z tohto streamu na požiadanie ťahá bloky. Súbor môže byť uložený na disku, v blob poli databázy alebo za akýmkoľvek iným potomkom TStream, pričom nič z neho sa na začiatku nekopíruje do pamäte

Ako PDFium požaduje bajty

C API knižnice PDFium načítava dokument z objektu poskytnutého volajúcim, ktorý je opísaný štruktúrou FPDF_FILEACCESS. Táto štruktúra má tri dôležité časti: pole pre dĺžku, spätné volanie (callback) pre čítanie a nepriehľadný používateľský parameter (opaque user parameter). Vstupným bodom, ktorý ju využíva, je FPDF_LoadCustomDocument. Akonáhle má PDFium túto štruktúru k dispozícii, spracuje chvost (trailer), nájde tabuľku krížových odkazov a od tohto momentu číta len to, čo si daná operácia vyžaduje. Otvorenie dokumentu sa dotkne len chvosta súboru a niekoľkých katalógových objektov. Vykreslenie strany 400 načíta obsahy tokov (content streams) a zdroje pre túto konkrétnu stranu a nič iné

V tom spočíva rozdiel medzi načítaním do vyrovnávacej pamäte (buffered load) a načítaním pomocou streamovania (streaming load). Bufferované načítanie prečíta súbor od začiatku do konca ešte predtým, než PDFium vôbec uvidí nultý bajt. Streamované načítanie tento vzťah prevracia: čítanie riadi samotné PDFium a bajty, ktorých sa nikdy nedotkne, sa nikdy nenačítajú. Pre viacgigabajtový súbor, ktorý si prezeráte stranu po strane, je toto presne ten rozdiel medzi nepoužiteľným a bleskovým načítaním

Streamovací adaptér

Adaptér, ktorý premosťuje TStream v Delphi s FPDF_FILEACCESS, sa nazýva TPdfStreamAdapter. Jeho konštruktor prijíma stream a príznak vlastníctva (ownership flag), jednorazovo zachytí dĺžku streamu, vyplní záznam FPDF_FILEACCESS a pripojí spätné volanie pre čítanie. Keď neskôr PDFium zavolá späť s posunutím (offset) a veľkosťou, adaptér presunie prístup v streame na túto pozíciu a skopíruje presne tento rozsah do buffera, ktorý PDFium poskytlo

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

Príznak vlastníctva (ownership flag) rozhoduje o tom, kto uvoľní stream z pamäte. Ak odošlete False, volajúci si ponechá stream a musí ho udržať pri živote po celú dobu životnosti dokumentu. Ak odošlete True, adaptér prevezme kontrolu a pri zatvorení dokumentu stream uvoľní. V oboch prípadoch musí stream prežiť každé čítanie, ktoré PDFium vykoná, pretože PDFium uchováva ukazovateľ na FPDF_FILEACCESS a môže uskutočniť spätné volanie kedykoľvek počas otvorenia dokumentu, nielen počas počiatočného načítavania

Prečo je spätné volanie statickou funkciou

Spätné volanie pre čítanie (read callback), ktoré PDFium ukladá v m_GetBlock, je obyčajný ukazovateľ na C funkciu s konvenciou volania cdecl. Metódu z Delphi nie je možné použiť priamo, pretože metóda so sebou nesie skrytý argument Self, o ktorom volajúci v C nič nevie a nikdy ho neposkytne. Adaptér preto deklaruje spätné volanie ako metódu triedy (class function) označenú ako cdecl; static. To sa preloží na samostatnú funkciu s rozložením zásobníka pre C, aké PDFium očakáva, bez implicitného Self

Toto rieši konvenciu volania, no vyvoláva druhú otázku: ako sa spätné volanie bez Self dostane ku konkrétnemu streamu, z ktorého má čítať? Odpoveďou je nepriehľadný užívateľský parameter (opaque user parameter). Keď adaptér buduje záznam, uloží svoj vlastný ukazovateľ na inštanciu do m_Param. PDFium vráti ten istý ukazovateľ ako prvý argument pri každom spätnom volaní. Statická funkcia ho pretypuje späť na TPdfStreamAdapter a presmeruje čítanie na stream tejto inštancie. Toto je štandardný "trampoline" (odrazový mostík) pre odovzdávanie objektového kontextu cez rozhranie C, ktoré nemá pojem o objektoch

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

4 GiB strop a prečo potrebuje ochranu

Tu má pôvod limit spomenutý v úvode. Pole dĺžky m_FileLen v štruktúre FPDF_FILEACCESS je 32-bitová hodnota bez znamienka (unsigned). Jej najväčšia reprezentovateľná dĺžka je o jeden bajt menšia ako 4 GiB. TStream hlási svoju veľkosť ako Int64, takže stream dokáže opísať oveľa viac bajtov, než dokáže toto pole pojať. V momente, keď veľkosť streamu prekročí tento strop, neexistuje žiadny úprimný spôsob, ako oznámiť knižnici PDFium, aký dlhý je súbor

Nesprávnou odpoveďou by bolo priradiť veľkosť a nechať hodnotu pretiecť (wrap). Skrátenie 5 GiB dĺžky do 32-bitového poľa vytvorí malé, vierohodne vyzerajúce číslo a PDFium následne analyzuje súbor v domnení, že končí približne po prvom gigabajte. Chvost (trailer) a tabuľka krížových odkazov sa však nachádzajú na skutočnom konci súboru, hlboko za orezanou dĺžkou, takže analýza zlyhá spôsobom, ktorý nemá nič spoločné so skutočnou príčinou. Ladili by ste chybu krížového odkazu na súbore, ktorý je úplne platný, bez akéhokoľvek náznaku, že o dve vrstvy vyššie došlo k pretečeniu celého čísla

Adaptér namiesto toho takýto vstup odmietne. Konštruktor porovná veľkosť streamu s High(FPDF_DWORD) a vyvolá výnimku EPdfError v momente, keď je stream príliš veľký na to, aby sa dal opísať. Výslovná, okamžitá chyba pomenuje skutočný problém priamo v mieste konštrukcie. Tiché skrátenie (truncation) by chybu skrylo za zavádzajúci symptóm, ktorý by ste naháňali oveľa neskôr. 4 GiB limit je skutočným obmedzením tohto spôsobu načítavania a čestným prístupom je vyniesť ho na povrch nahlas, namiesto toho, aby sa zakryl aritmetikou, ktorá sa náhodou dá skompilovať. Keď archív skutočne prekročí túto hranicu, riešenia sľúbené v úvode existujú mimo tohto API: rozdeľte sken na súbory po jednotlivých zväzkoch, z ktorých každý zostane pod stropom, alebo nechajte dokument na disku a podávajte ho prostredníctvom prístupu (direct-access) postaveného na 64-bitových offsetoch, a nie cez FPDF_FILEACCESS

Zlyhania nesmú prekročiť hranicu

Čítanie môže zlyhať. Stream môže byť objektom podloženým sieťou, pri ktorom dôjde k vypršaniu času (timeout), môže to byť blob handle, ktorý bol uzatvorený pod vami, alebo súbor, ktorý bol po otvorení dokumentu skrátený. Zmluva (contract) knižnice PDFium pre spätné volanie čítania spočíva v návratovej hodnote: nenulová znamená úspech, nulová zlyhanie. Je to C rámec a nemá mechanizmus na zachytenie alebo šírenie Pascal výnimky

Z tohto dôvodu odrazový mostík (trampoline) obaľuje posun (seek) a čítanie (read) do bloku try/except, ktorý výnimku pohltí a vráti nulu. Ak by sa výnimke z Delphi dovolilo preniknúť von zo spätného volania, odvíjala by sa (unwind) cez cdecl zásobníkové rámce knižnice PDFium, ktoré nikdy neboli stavané na to, aby ich odvíjal mechanizmus výnimiek Pascalu. Výsledkom by bolo prinajlepšom nedefinované správanie a prinajhoršom tvrdý pád, hlboko v útrobách syntaktického analyzátora (parsera) PDF bez akéhokoľvek použiteľného zásobníka. Vrátenie nuly udržuje zlyhanie v rámci zmluvy. PDFium uvidí zlyhané čítanie bloku, čisto preruší operáciu a FPDF_LoadCustomDocument ohlási, že dokument nebolo možné načítať. Toto komponent vynesie na povrch ako EPdfError na strane Pascalu, kam to aj patrí

Otvorenie dokumentu týmto spôsobom

Metóda komponentu, ktorá riadi cestu streamovania, je LoadCustomDocument, deklarovaná skôr ako samostatná metóda než ako ďalšie preťaženie (overload) metódy LoadDocument, aby sa predišlo tomu, že by odovzdanie TMemoryStream náhodou skončilo na ceste s vyrovnávacou pamäťou (buffered path). Táto metóda vytvorí adaptér, zavolá FPDF_LoadCustomDocument a udrží adaptér pri živote po celú dobu životnosti načítaného dokumentu

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;

To isté volanie funguje pre TMemoryStream, stream blobov z databázovej dátovej sady (dataset), alebo prispôsobeného potomka TStream. Načítavanie na požiadanie (on-demand) má svoje opodstatnenie vtedy, keď je súbor veľký a bude sa z neho čítať len časť: prehliadač archívov, generátor miniatúr, ktorý vzorkuje zopár strán, alebo vyhľadávací index, ktorý vyťahuje jednu stranu za druhou. Ak je súbor malý, alebo ho aj tak budete čítať celý, načítanie do vyrovnávacej pamäte (buffered load) je jednoduchšie a streamovacia mašinéria vám neprinesie žiadnu výhodu. Rozhodujúcim faktorom je pomer medzi bajtmi, ktorých sa reálne dotknete, a bajtmi, ktoré súbor obsahuje

Akonáhle sú stránky streamované na požiadanie, ďalšou starosťou je udržať vykreslené stránky svižné (responsive), keď používateľ približuje a posúva (zoom and scroll), čomu sa venuje naša poznámka o vyrovnávacej pamäti pre vykresľovanie a výkone približovania. Keď je streamovaným dokumentom taký dokument, ktorý má prehliadač zobraziť, ale nemá používateľovi dovoliť exportovať ho alebo upravovať, techniky opísané v návode na bezpečný náhľad PDF sa prirodzene spárujú s touto cestou načítania. Obe tieto riešenia stavajú na tu opísanom načítavaní prostredníctvom streamovania, ktoré sa dodáva ako súčasť PDFium Component pre Delphi a C++Builder spolu s API pre vykresľovanie, extrakciu textu a anotácie, ktoré sú pokryté inde na tomto blogu