Technical Article

Strimovanje ogromnih PDF dokumenata na zahtev sa PDFium-om u Delphi-ju

Skenirana arhiva može dostići nekoliko gigabajta u jednom PDF-u. Pregledač koji otvara takvu datoteku obično želi da prikaže jednu stranicu, možda sadržaj, možda stranicu na koju je korisnik skočio sa obeleživača. Čitanje cele datoteke u memoriju radi renderovanja dve stranice je rasipanje na svakom polju: troši adresni prostor, usporava korisnika iza dugog početnog čitanja, a na 32-bitnom Delphi procesu može potpuno propasti pre nego što se pojavi ijedna stranica. PDFium je napravljen sa ovim na umu. On može učitati dokument kroz povratni poziv koji traži konkretne opsege bajtova koji su mu potrebni, kada su mu potrebni, i nikada ne zahteva celu datoteku odjednom.

Komponenta izlaže tu putanju kroz adapter toka (stream adapter). Predajete joj bilo koji TStream, a PDFium povlači blokove iz tog toka na zahtev. Datoteka može biti na disku, u blob polju baze podataka ili iza bilo kog drugog potomka klase TStream, i ništa od toga se ne kopira unapred u memoriju.

Kako PDFium traži bajtove

PDFium-ov C API učitava dokument iz objekta koji obezbeđuje pozivalac, a koji je opisan strukturom FPDF_FILEACCESS. Struktura ima tri dela koja su ovde važna: polje dužine, povratni poziv za čitanje i neprozirni (opaque) korisnički parametar. Ulazna tačka koja ga troši je FPDF_LoadCustomDocument. Kada PDFium dobije tu strukturu, on analizira trailer, locira tabelu unakrsnih referenci, i od tog trenutka čita samo ono što data operacija zahteva. Otvaranje dokumenta dodiruje kraj datoteke i nekoliko objekata kataloga. Renderovanje stranice 400 čita tokove sadržaja i resurse za tu stranicu i ništa više.

To je razlika između baferovanog i strimovanog učitavanja. Baferovano učitavanje čita datoteku sa kraja na kraj pre nego što PDFium vidi nulti bajt. Strimovano učitavanje obrće taj odnos: PDFium upravlja čitanjem, a bajtovi koji se nikada ne dotaknu nikada se i ne čitaju. Za datoteku od više gigabajta koja se gleda stranicu po stranicu, to čini razliku između neupotrebljivog učitavanja i trenutnog.

Adapter toka

Adapter koji premošćuje Delphi TStream i FPDF_FILEACCESS je TPdfStreamAdapter. Njegov konstruktor uzima tok i zastavicu vlasništva, snima dužinu toka jednom, popunjava FPDF_FILEACCESS zapis i povezuje povratni poziv za čitanje. Kada PDFium kasnije uputi povratni poziv sa ofsetom i veličinom, adapter pozicionira tok na taj ofset i kopira tačno taj opseg u bafer koji je PDFium obezbedio.

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

Zastavica vlasništva odlučuje o tome ko oslobađa tok. Prenesite False i pozivalac zadržava tok i mora ga držati aktivnim tokom celog životnog veka dokumenta. Prenesite True i adapter preuzima kontrolu, oslobađajući tok kada se dokument zatvori. U oba slučaja tok mora da nadživi svako čitanje koje će PDFium izvršiti, jer PDFium drži pokazivač FPDF_FILEACCESS i uputiće povratni poziv u bilo kom trenutku dok je dokument otvoren, a ne samo tokom početnog učitavanja.

Zašto je povratni poziv statička funkcija

Povratni poziv za čitanje koji PDFium čuva u m_GetBlock je običan pokazivač na C funkciju sa konvencijom pozivanja cdecl. Delphi metod se ne može koristiti direktno, jer metod nosi skriveni argument Self o kom C pozivalac ne zna ništa i nikada ga neće obezbediti. Adapter stoga deklariše povratni poziv kao class function sa oznakom cdecl; static, što se kompajlira u samostalnu funkciju sa C rasporedom okvira koji PDFium očekuje i bez implicitnog Self argumenta.

Zašto povratni poziv nema implicitni Self argument

To rešava konvenciju pozivanja, ali postavlja drugo pitanje: bez Self argumenta, kako povratni poziv dolazi do određenog toka iz kog treba da čita? Odgovor je neprozirni korisnički parametar. Kada adapter gradi zapis, on čuva pokazivač na sopstvenu instancu u m_Param. PDFium vraća taj isti pokazivač kao prvi argument svakog povratnog poziva. Statička funkcija ga kastuje nazad u TPdfStreamAdapter i šalje čitanje toku te instance. Ovo je standardni trambolin (trampoline) za prenos konteksta objekta preko C granice koja nema predstavu o objektima.

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

Granica od 4 GiB i zašto joj je potrebna zaštita

Polje dužine m_FileLen u FPDF_FILEACCESS je 32-bitna neoznačena vrednost. Njena najveća dužina koju može predstaviti je jedan bajt manje od 4 GiB. TStream prijavljuje svoju veličinu kao Int64, tako da tok može opisati daleko više bajtova nego što polje može da drži. Onog trenutka kada veličina toka pređe tu granicu, ne postoji ispravan način da se PDFium-u saopšti kolika je dužina datoteke.

Pogrešan odgovor je dodeljivanje veličine i dopuštanje da se ona obmota. Odsecanje dužine od 5 GiB na 32-bitno polje daje mali broj koji izgleda uverljivo, a PDFium će potom analizirati datoteku verujući da se ona završava na oko jedan gigabajt. Trailer i tabela unakrsnih referenci nalaze se na stvarnom kraju datoteke, daleko iza odsečene dužine, pa analiza ne uspeva na način koji nema nikakve veze sa stvarnim uzrokom. Analizirali biste grešku unakrsne reference na datoteci koja je savršeno ispravna, bez ikakvog nagoveštaja da se ceo broj obmotao dva nivoa iznad.

Adapter umesto toga odbija ulaz. Konstruktor poredi veličinu toka sa High(FPDF_DWORD) i podiže EPdfError onog trenutka kada je tok prevelik za opisivanje. Eksplicitna, trenutna greška imenuje stvarni problem u trenutku konstrukcije. Tiho odsecanje ga krije iza obmanjujućeg simptoma koji biste tražili mnogo kasnije. Ograničenje od 4 GiB je stvarno ograničenje ove putanje učitavanja, i ispravna stvar je izbaciti ga glasno umesto zataškavanja aritmetikom koja se slučajno kompajlira.

Neuspesi ne smeju preći granicu

Čitanje može da ne uspe. Tok može biti mrežni objekat koji ističe (timeout), blob ručka koja je zatvorena ispod vas, ili datoteka koja je skraćena nakon što je dokument otvoren. PDFium-ov ugovor za povratni poziv za čitanje je povratna vrednost: različito od nule za uspeh, nula za neuspeh. To je C okvir i nema mehanizam za hvatanje ili širenje Pascal izuzetka.

Zbog toga trambolin obmotava pozicioniranje i čitanje u try/except blok koji guta izuzetak i vraća nulu. Ako bi se dozvolilo da se Delphi izuzetak proširi van povratnog poziva, on bi se odmotao kroz PDFium-ove cdecl okvire steka, koji nikada nisu napravljeni da budu odmotani Pascal mehanizmom izuzetaka. Rezultat je nedefinisano ponašanje u najboljem slučaju i teško rušenje u najgorem, duboko unutar PDF parsera bez upotrebljivog steka. Vraćanje nule zadržava neuspeh unutar ugovora. PDFium vidi neuspešno čitanje bloka, čisto prekida operaciju, a FPDF_LoadCustomDocument izveštava da dokument nije mogao biti učitan, što komponenta prikazuje kao EPdfError na Pascal strani gde i pripada.

Otvaranje dokumenta na ovaj način

Metod komponente koji pokreće strimovanu putanju je LoadCustomDocument, deklarisan kao poseban metod umesto još jednog preopterećenja (overload) metode LoadDocument, tako da prenošenje TMemoryStream-a nikada slučajno ne sleti na baferovanu putanju. On gradi adapter, poziva FPDF_LoadCustomDocument i održava adapter aktivnim tokom životnog veka učitanog dokumenta.

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;

Isti poziv radi za TMemoryStream, blob tok iz skupa podataka baze podataka, ili prilagođenog potomka klase TStream. Učitavanje na zahtev opravdava svoje postojanje kada je datoteka velika i čitaće se samo njen deo: pregledač arhive, generator sličica koji uzorkuje nekoliko stranica, indeks pretrage koji povlači jednu po jednu stranicu. Kada je datoteka mala ili ćete ionako pročitati sve, baferovano učitavanje je jednostavnije i mehanizam strimovanja vam ne donosi ništa. Odlučujući faktor je odnos bajtova koje ćete zapravo dodirnuti i bajtova koje datoteka sadrži.

Jednom kada stranice počnu da se strimuju na zahtev, sledeća briga je održavanje odziva renderovanih stranica dok korisnik zumira i skroluje, što je pokriveno u našoj belešci o performansama keša renderovanja i zumiranja. Kada je strimovani dokument onaj koji pregledač treba da prikaže ali ne i da dozvoli korisniku da ga izveze ili promeni, tehnike u vodiču kroz bezbedan PDF pregled prirodno se povezuju sa ovom putanjom učitavanja. Oba se grade na strimovanom učitavanju koje je ovde opisano, a koje se isporučuje kao deo PDFium komponente za Delphi i C++Builder zajedno sa API-jima za renderovanje, ekstrakciju teksta i anotacije koji su pokriveni na drugim mestima na ovom blogu.