Technical Article

Streamovanie obrovských PDF na vyžiadanie s PDFium v Delphi

Naskenovaný archív môže mať v jedinom PDF veľkosť niekoľkých gigabajtov. Prehliadač, ktorý takýto súbor otvorí, zvyčajne chce zobraziť jednu stránku, možno obsah, možno stránku, na ktorú používateľ preskočil zo záložky. Čítanie celého súboru do pamäte na vykreslenie dvoch stránok je plytvaním na každej osi: spaľuje to adresný priestor, zdržiava to používateľa za dlhým úvodným čítaním a v 32-bitovom procese v Delphi to môže úplne zlyhať ešte pred zobrazením jedinej stránky. PDFium bolo vytvorené s ohľadom na toto. Dokáže načítať dokument prostredníctvom spätného volania, ktoré si pýta špecifické rozsahy bajtov, ktoré potrebuje, vtedy, keď ich potrebuje, a nikdy nepožaduje celý súbor naraz.

Komponent odhaľuje túto cestu cez stream adaptér. Odovzdáte mu akýkoľvek TStream a PDFium si z tohto streamu vyťahuje bloky na vyžiadanie. Súbor môže svedomito sedieť na disku, v databázovom poli blob alebo za akýmkoľvek iným potomkom TStream, pričom nič z neho sa vopred nekopíruje do pamäte.

Ako si PDFium pýta bajty

C API knižnice PDFium načítava dokument z objektu dodaného volajúcim, ktorý popisuje štruktúra FPDF_FILEACCESS. Štruktúra má tri časti, na ktorých tu záleží: pole dĺžky, spätné volanie na čítanie a nepriehľadný (opaque) parameter používateľa. Vstupným bodom, ktorý ju spotrebováva, je FPDF_LoadCustomDocument. Akonáhle PDFium drží túto štruktúru, analyzuje trailer, lokalizuje krížovú tabuľku odkazov a od tej chvíle číta len to, čo daná operácia vyžaduje. Otvorenie dokumentu sa dotkne konca súboru a niekoľkých objektov katalógu. Vykreslenie stránky 400 prečíta iba obsahy streamov a zdrojov pre túto stránku a nič iné.

Toto je rozdiel medzi načítaním s vyrovnávacou pamäťou (buffered load) a streamovaným načítaním. Načítanie s vyrovnávacou pamäťou prečíta súbor od začiatku do konca predtým, ako PDFium vôbec uvidí bajt nula. Streamované načítanie obracia tento vzťah: PDFium riadi čítanie a bajty, ktorých sa nikdy nedotkne, sa nikdy neprečítajú. Pre viac-gigabajtový súbor prezeraný po jednej stránke to predstavuje rozdiel medzi nepoužiteľným načítaním a okamžitým spustením.

Praktický kontext

Adaptér, ktorý premosťuje Delphi TStream na FPDF_FILEACCESS, je TPdfStreamAdapter. Jeho konštruktor prijíma stream a príznak vlastníctva, raz zachytí dĺžku streamu, vyplní záznam FPDF_FILEACCESS a prepojí spätné volanie na čítanie. Keď PDFium neskôr zavolá späť s offsetom a veľkosťou, adaptér posunie (seek) stream na tento offset a skopíruje presne tento rozsah do vyrovnávacej pamäte, 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 rozhoduje o tom, kto stream uvoľní. Odovzdajte False a volajúci si stream ponechá a musí ho udržiavať nažive po celú dobu životnosti dokumentu. Odovzdajte True a adaptér ho prevezme a uvoľní stream pri zatvorení dokumentu. V oboch prípadoch musí stream prežiť každé čítanie, ktoré PDFium vykoná, pretože PDFium drží ukazovateľ FPDF_FILEACCESS a zavolá späť v ktoromkoľvek okamihu, keď je dokument otvorený, nielen počas úvodného načítavania.

Prečo je spätné volanie statickou funkciou

Spätné volanie na čítanie, ktoré PDFium ukladá v m_GetBlock, je obyčajný ukazovateľ na funkciu v C s volacou konvenciou cdecl. Metóda Delphi sa nedá použiť priamo, pretože metóda nesie skrytý argument Self, o ktorom volajúci v C nič nevie a nikdy ho neposkytne. Adaptér preto deklaruje spätné volanie ako class function označenú ako cdecl; static, čo sa kompiluje ako voľne stojaca funkcia s rozložením rámca C, ktoré PDFium očakáva, a bez implicitného Self.

To síce rieši volaciu konvenciu, ale vyvoláva to druhú otázku: bez Self, ako spätné volanie dosiahne konkrétny stream, z ktorého má čítať? Odpoveďou je nepriehľadný (opaque) parameter používateľa. Keď adaptér zostavuje záznam, uloží svoj vlastný ukazovateľ inštancie v m_Param. PDFium odovzdá tento rovnaký ukazovateľ späť ako prvý argument každého spätného volania. Statická funkcia ho pretypuje späť na TPdfStreamAdapter a odošle čítanie voči streamu tejto inštancie. Toto je štandardná trampolína na odovzdávanie kontextu objektu cez hranicu C, ktorá nemá žiadnu predstavu 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;

Strop 4 GiB a prečo potrebuje ochranu

Dĺžkové pole m_FileLen v FPDF_FILEACCESS je 32-bitová bezznamienková hodnota. Jeho najväčšia reprezentovateľná dĺžka je o jeden bajt menej ako 4 GiB. Typ TStream hlási svoju veľkosť ako Int64, so stream môže popísať oveľa viac bajtov, než pole dokáže pojať. Vo chvíli, keď veľkosť streamu prekročí tento strop, neexistuje žiaden poctivý spôsob, ako oznámiť PDFium, aký dlhý je súbor.

Nesprávnou reakciou je priradiť veľkosť a nechať ju pretiecť. Orezanie dĺžky 5 GiB na 32-bitové pole vyprodukuje malé, vierohodne vyzerajúce číslo a PDFium potom bude analyzovať súbor s vierou, že končí zhruba na jednom gigabajte. Trailer a krížová tabuľka odkazov žijú na skutočnom konci súboru, ďaleko za orezanou dĺžkou, takže analýza zlyhá spôsobom, ktorý nemá nič spoločné so skutočnou príčinou. Ladením by ste hľadali chybu v krížových odkazoch na súbore, ktorý je dokonale platný, bez náznaku, že celé číslo pretieklo o dve vrstvy vyššie.

Adaptér namiesto toho vstup odmietne. Konštruktor porovná veľkosť streamu s High(FPDF_DWORD) a vyvolá EPdfError vo chvíli, keď je stream príliš veľký na to, aby sa dal opísať. Explicitná, okamžitá chyba pomenuje skutočný problém v bode konštrukcie. Tiché orezanie ho skryje za zavádzajúci príznak, ktorý by ste naháňali oveľa neskôr. Limit 4 GiB je skutočným obmedzením tejto cesty načítavania a čestné je ukázať to nahlas, namiesto lepenia problému aritmetikou, ktorej sa zhodou okolností podarí skompilovať.

Zlyhania nesmú prekročiť hranicu

Čítanie môže zlyhať. Stream môže byť sieťový objekt, ktorého čas vyprší, handle blob, ktorý sa pod vami zatvoril, alebo súbor, ktorý bol po otvorení dokumentu skrátený. Kontrakt PDFium pre spätné volanie čítania je návratová hodnota: nenulová pre úspech, nula pre zlyhanie. Ide o rámec C a nemá žiadny mechanizmus na zachytenie alebo propagáciu výnimky Pascalu.

To je dôvod, prečo trampolína obaluje posun (seek) a čítanie do bloku try/except, ktorý prehltne výnimku a vráti nulu. Ak by sa výnimka Delphi mohla šíriť zo spätného volania, odvinula by sa cez cdecl stack rámce PDFium, ktoré nikdy neboli postavené tak, aby ich stroj výnimiek Pascalu odvíjal. Výsledkom je v lepšom prípade nedefinované správanie a v najhoršom tvrdý pád hlboko vnútri parsera PDF bez použiteľného zásobníka. Návrat nuly udržiava zlyhanie v rámci kontraktu. PDFium uvidí neúspešné čítanie bloku, čisto preruší operáciu a FPDF_LoadCustomDocument nahlási, že dokument nebolo možné načítať, čo komponent zobrazí ako EPdfError na strane Pascalu, kam patrí.

Otvorenie dokumentu týmto spôsobom

Metóda komponentu, ktorá riadi streamovanú cestu, je LoadCustomDocument, deklarovaná ako odlišná metóda, a nie ako ďalšie preťaženie LoadDocument, aby odovzdanie TMemoryStream nikdy omylom nepristálo na buffered ceste. Zostaví adaptér, zavolá FPDF_LoadCustomDocument a udržiava adaptér nažive 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;

Rovnaké volanie funguje pre TMemoryStream, blob stream z databázového datasetu alebo vlastného potomka TStream. Načítavanie na vyžiadanie si obháji svoje miesto vtedy, keď je súbor veľký a prečíta sa len jeho časť: prehliadač archívov, generátor náhľadov, ktorý vzorkuje niekoľko stránok, vyhľadávací index, ktorý vyťahuje jednu stránku po druhej. Keď je súbor malý alebo sa chystáte prečítať ho celý, načítanie s vyrovnávacou pamäťou je jednoduchšie a streamovací mechanizmus vám nič neprinesie. Rozhodujúcim faktorom je pomer bajtov, ktorých sa skutočne dotknete, k bajtom, ktoré súbor obsahuje.

Akonáhle sa stránky streamujú na vyžiadanie, ďalšou obavou je udržanie odozvy vykreslených stránok pri priblížení a posúvaní používateľom, čo je pokryté v našej poznámke o kešovaní vykresľovania a výkone zoomu. Keď je streamovaný dokument taký, ktorý by mal prehliadač zobraziť, ale nedovoliť používateľovi ho exportovať alebo meniť, techniky v návode na zabezpečený náhľad PDF sa prirodzene spájajú s touto cestou načítavania. Obe témy stavajú na streamovanom načítaní popísanom tu, ktoré sa dodáva ako súčasť PDFium Component pre Delphi a C++Builder spolu s API na vykresľovanie, extrakciu textu a anotácie, ktoré sú popísané inde na tomto blogu.