Tehnički članak

Strujanje ogromnih PDF-ova na zahtjev pomoću PDFiuma u Delphiju

Skenirana arhiva može narasti na nekoliko gigabajta unutar jednog PDF-a. Preglednik koji otvara takvu datoteku obično želi prikazati jednu stranicu, možda sadržaj, možda stranicu na koju je korisnik skočio iz zabilješke. Učitavanje cijele datoteke u memoriju radi iscrtavanja dviju stranica rasipno je u svakom pogledu: troši adresni prostor, zadržava korisnika iza dugog početnog čitanja, a na 32-bitnom Delphi procesu može potpuno zakazati prije nego što se pojavi ijedna stranica. PDFium je izgrađen s tim na umu. Može učitati dokument kroz povratni poziv koji traži određene raspone bajtova koje treba, kad ih treba, i nikad ne zahtijeva cijelu datoteku odjednom. Jedna granica se ističe unaprijed: ovaj protočni kanal opisuje datoteku 32-bitnom duljinom, pa poslužuje jednu datoteku do 4 GiB, što u praksi pokriva gotovo svaku skeniranu arhivu. Datoteka preko te granice nije područje ovog članka; nju treba podijeliti u sveske u vrijeme skeniranja ili je umjesto toga otvoriti kroz strategiju izravnog pristupa, a čuvar koji provodi taj strop iskreno dobiva vlastiti odjeljak niže

Komponenta izlaže taj put kroz adapter toka. Predate mu bilo koji TStream, a PDFium povlači blokove iz tog toka na zahtjev. Datoteka može sjediti na disku, u blob polju baze podataka, ili iza bilo kojeg drugog potomka TStream, i ništa se od toga unaprijed ne kopira u memoriju

Kako PDFium traži bajtove

PDFium-ov C API učitava dokument iz objekta koji predaje pozivatelj, opisanog strukturom FPDF_FILEACCESS. Struktura ima tri dijela koja su ovdje bitna: polje duljine, povratni poziv za čitanje, i neproziran korisnički parametar. Ulazna točka koja je koristi je FPDF_LoadCustomDocument. Kad PDFium drži tu strukturu, raščlanjuje trailer, pronalazi tablicu unakrsnih referenci, i od tada nadalje čita samo ono što dana operacija zahtijeva. Otvaranje dokumenta dotiče kraj datoteke i pregršt objekata kataloga. Iscrtavanje stranice 400 čita tokove sadržaja i resurse za tu stranicu i ništa drugo

To je razlika između učitavanja s međuspremnikom i protočnog učitavanja. Učitavanje s međuspremnikom čita datoteku od početka do kraja prije nego što PDFium vidi bajt nula. Protočno učitavanje obrće taj odnos: PDFium pokreće čitanja, a bajtovi koji se nikad ne dotaknu nikad se ne čitaju. Za datoteku od nekoliko gigabajta koju se pregledava stranicu po stranicu, to je razlika između neupotrebljivog učitavanja i trenutnog

Arhitektonski dijagram koji suprotstavlja međuspremničko učitavanje koje kopira PDF od više gigabajta u memoriju prije raščlanjivanja sa strujanjem gdje PDFium zatraži bajtovske raspone od Delphi TStream-a kroz FPDF_FILEACCESS
Otvaranje samo trailer i katalog košta; prikazivanje stranice 400 bajtove stranice 400 i ništa drugo kroz callback povlači

Adapter toka

Adapter koji premošćuje Delphi TStream prema FPDF_FILEACCESS je TPdfStreamAdapter. Njegov konstruktor uzima tok i zastavicu vlasništva, jednom zabilježi duljinu toka, popuni zapis FPDF_FILEACCESS, i poveže povratni poziv za čitanje. Kad ga PDFium kasnije pozove s pomakom i veličinom, adapter pozicionira tok na taj pomak i kopira upravo taj raspon u međuspremnik koji je PDFium osigurao

// Doslovno iz komponente: most stream-to-FPDF_FILEACCESS
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 je 32-bitni unsigned long. Odbij tok
  // koji bi se tiho skratio preko 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 tko oslobađa tok. Predate li False, pozivatelj zadržava tok i mora ga održati živim tijekom cijelog trajanja dokumenta. Predate li True, adapter preuzima vlasništvo i oslobađa tok kad se dokument zatvori. U oba slučaja tok mora nadživjeti svako čitanje koje će PDFium obaviti, jer PDFium drži pokazivač FPDF_FILEACCESS i pozivat će natrag u bilo kojem trenutku dok je dokument otvoren, ne samo tijekom početnog učitavanja

Zašto je povratni poziv statička funkcija

Povratni poziv za čitanje koji PDFium pohranjuje u m_GetBlock obični je C pokazivač na funkciju s pozivnom konvencijom cdecl. Delphi metoda ne može se koristiti izravno, jer metoda nosi skriveni argument Self o kojem C pozivatelj ništa ne zna i koji nikad neće predati. Adapter zato deklarira povratni poziv kao class function označenu s cdecl; static, koja se kompilira u samostalnu funkciju s C rasporedom okvira kakav PDFium očekuje i bez implicitnog Self

Time se rješava pozivna konvencija, ali se otvara drugo pitanje: bez Self, kako povratni poziv dopre do određenog toka iz kojeg treba čitati? Odgovor je neproziran korisnički parametar. Kad adapter gradi zapis, pohranjuje vlastiti pokazivač na instancu u m_Param. PDFium taj isti pokazivač vraća kao prvi argument svakog povratnog poziva. Statička funkcija ga pretvara natrag u TPdfStreamAdapter i usmjerava čitanje prema toku te instance. To je standardni trampolin za predaju konteksta objekta preko C granice koja nema pojma o objektima

Dijagram cdecl trampolina koji prenosi PDFium zahtjeve blokova s C granice u Delphi TPdfStreamAdapter instancu i sažima iznimke u povratnu vrijednost nula
Statični cdecl callback implicitni Self ne skriva, pa m_Param instancu adaptera svakom pozivu vraća i bilo koja Pascal iznimka u povratak nule savija se
// Doslovno iz komponente: cdecl trampoline natrag do 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);   // dohvati instancu iz 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;  // prijavi neuspjeh povratnom vrijednošću, nikad iznimkom
  end;
end;

Strop od 4 GiB i zašto mu treba čuvar

Odavde dolazi granica spomenuta na početku. Polje duljine m_FileLen u FPDF_FILEACCESS je 32-bitna vrijednost bez predznaka. Njegova najveća prikaziva duljina je jedan bajt manje od 4 GiB. TStream prijavljuje svoju veličinu kao Int64, pa tok može opisati mnogo više bajtova nego što polje može sadržati. Čim veličina toka premaši taj strop, ne postoji pošten način da se PDFium-u kaže koliko je datoteka duga

Pogrešan odgovor je dodijeliti veličinu i pustiti da se prelije. Skraćivanje duljine od 5 GiB na 32-bitno polje proizvodi malen, uvjerljivo izgledajući broj, pa će PDFium tada raščlaniti datoteku vjerujući da ona završava otprilike gigabajt unutra. Trailer i tablica unakrsnih referenci nalaze se na stvarnom kraju datoteke, daleko iza skraćene duljine, pa raščlamba zakaže na način koji nema nikakve veze sa stvarnim uzrokom. Otklanjali biste pogrešku unakrsne reference na datoteci koja je posve valjana, bez ikakvog nagovještaja da se cijeli broj prelio dva sloja iznad

Adapter umjesto toga odbija ulaz. Konstruktor uspoređuje veličinu toka s High(FPDF_DWORD) i diže EPdfError u trenutku kad je tok prevelik da bi se mogao opisati. Izričita, trenutna greška imenuje stvarni problem u točki konstrukcije. Tiho skraćivanje ga skriva iza zavaravajućeg simptoma koji biste jurili mnogo kasnije. Ograničenje od 4 GiB stvarno je ograničenje ovog puta učitavanja, a pošteno je to glasno istaknuti umjesto prekriti aritmetikom koja slučajno prolazi kompilaciju. Kad arhiva stvarno prijeđe tu granicu, rješenja obećana na početku žive izvan ovog API-ja: podijelite skeniranje u datoteke po svesku od kojih svaka ostaje ispod stropa, ili ostavite dokument na disku i poslužujte ga dizajnom izravnog pristupa izgrađenim na 64-bitnim pomacima umjesto kroz FPDF_FILEACCESS

Dijagram odluke koji čuva ograničenje od 4 GiB FPDF_FILEACCESS gdje preveliki Delphi TStream odmah podiže EPdfError umjesto da tiho presavije deklarirano polje duljine
Neposredan EPdfError aritmetiku koja se samo kompajlira bolje je od: prelivena m_FileLen otklanjanje pogrešaka niz izmišljeni cross-reference trag šalje

Neuspjesi ne smiju prijeći granicu

Čitanje može zakazati. Tok može biti objekt podržan mrežom kojemu istekne vrijeme, blob rukovatelj koji je zatvoren ispod vas, ili datoteka koja je skraćena nakon što je dokument otvoren. PDFium-ov ugovor za povratni poziv čitanja je povratna vrijednost: različito od nule za uspjeh, nula za neuspjeh. To je C okvir, i nema mehanizam za hvatanje ili propagiranje Pascal iznimke

Zato trampolin omata pozicioniranje i čitanje u try/except koji proguta iznimku i vrati nulu. Kad bi se Delphi iznimci dopustilo da propagira izvan povratnog poziva, odmotavala bi se kroz PDFium-ove cdecl okvire stoga, koji nikad nisu izgrađeni da ih odmotava Pascal mehanizam iznimki. Rezultat je u najboljem slučaju nedefinirano ponašanje, a u najgorem tvrdi pad, duboko unutar PDF raščlanjivača bez upotrebljivog stoga. Vraćanje nule zadržava neuspjeh unutar ugovora. PDFium vidi neuspjelo čitanje bloka, uredno prekida operaciju, a FPDF_LoadCustomDocument prijavljuje da se dokument nije mogao učitati, što komponenta na Pascal strani, gdje i pripada, izlaže kao EPdfError

Otvaranje dokumenta na ovaj način

Metoda komponente koja pokreće protočni put je LoadCustomDocument, deklarirana kao zasebna metoda, a ne kao još jedno preopterećenje metode LoadDocument, tako da predaja TMemoryStream nikad slučajno ne završi na putu s međuspremnikom. Ona gradi adapter, poziva FPDF_LoadCustomDocument, i održava adapter živim tijekom trajanja učitanog dokumenta

var
  Pdf: TPdf;
  FileStream: TFileStream;
begin
  Pdf := TPdf.Create(nil);
  FileStream := TFileStream.Create('Archive_4GB.pdf', fmOpenRead or fmShareDenyWrite);
  try
    // Predaj vlasništvo nad tokom objektu Pdf: on oslobađa FileStream kad se dokument zatvori.
    Pdf.LoadCustomDocument(FileStream, True);
    // PDFium je dosad pročitao samo trailer i katalog.
    // Iscrtavanje stranice povlači samo bajtove te stranice kroz povratni poziv.
    // ... ovdje iscrtajte ili pregledajte stranice ...
  finally
    Pdf.Free;  // zatvara dokument, čime oslobađa adapter i tok
  end;
end;

Isti poziv radi za TMemoryStream, blob tok iz skupa podataka baze, ili prilagođeni potomak TStream. Učitavanje na zahtjev opravdava se kad je datoteka velika, a čitat će se samo njezin dio: preglednik arhiva, generator sličica koji uzorkuje nekoliko stranica, indeks pretraživanja koji povlači po jednu stranicu odjednom. Kad je datoteka mala ili ćete je svejedno pročitati u cijelosti, učitavanje s međuspremnikom je jednostavnije, a protočni mehanizam vam ništa ne donosi. Odlučujući čimbenik je omjer bajtova koje ćete stvarno dotaknuti naspram bajtova koje datoteka sadrži

Kad stranice stižu protočno na zahtjev, sljedeća briga je održati iscrtane stranice odzivnima dok korisnik zumira i pomiče prikaz, što je obrađeno u našoj bilješci o predmemoriranju iscrtavanja i performansama zumiranja. Kad je protočni dokument onaj koji bi preglednik trebao prikazati, ali ne dopustiti korisniku da ga izveze ili izmijeni, tehnike iz prolaska kroz sigurni pregled PDF-a prirodno se uparuju s ovim putem učitavanja. Oboje se nadograđuje na ovdje opisano protočno učitavanje, koje se isporučuje kao dio PDFium komponente za Delphi i C++Builder, uz API-je za iscrtavanje, izdvajanje teksta i napomene obrađene drugdje na ovom blogu