Technical Article

Didelių PDF failų srautinis įkėlimas pagal poreikį naudojant „PDFium“ programoje „Delphi“

Nuskenuotas archyvas gali užimti kelis gigabaitus viename PDF faile. Peržiūros programa, atidaranti tokį failą, dažniausiai nori parodyti tik vieną puslapį – galbūt turinį, o gal puslapį, į kurį naudotojas peršoko iš žymelės. Viso failo nuskaitymas į atmintį norint parodyti du puslapius yra neefektyvus visais atžvilgiais: eikvojama adresų erdvė, naudotojas turi laukti ilgo pradinio nuskaitymo, o 32 bitų Delphi procese tai gali išvis nepavykti dar prieš pasirodant pirmam puslapiui. PDFium buvo sukurtas atsižvelgiant į tai. Ji gali įkelti dokumentą per atgalinį iškvietimą (callback), kuris prašo konkrečių baitų diapazonų tik tada, kai jų prireikia, ir niekada nereikalauja viso failo iš karto.

Komponentas atveria šį kelią per srauto adapterį. Perduodate jam bet kurį TStream objektą, ir PDFium traukia blokus iš šio srauto pagal poreikį. Failas gali būti diske, duomenų bazės blob lauke arba kitame TStream palikuonyje, ir niekas iš to iš anksto nekopijuojama į atmintį.

Kaip „PDFium“ prašo baitų

PDFium C API įkelia dokumentą iš iškvietėjo pateikto objekto, kurį aprašo FPDF_FILEACCESS struktūra. Struktūra turi tris svarbias dalis: ilgio lauką, skaitymo atgalinį iškvietimą ir neskaidrų naudotojo parametrą. Įėjimo taškas, kuris jį naudoja, yra FPDF_LoadCustomDocument. Kai PDFium gauna šią struktūrą, ji išanalizuoja failo pabaigą (trailer), suranda kryžminių nuorodų lentelę ir nuo to laiko skaito tik tai, ko reikalauja atliekama operacija. Dokumento atidarymas paliečia failo pabaigą ir kelis katalogo objektus. 400 puslapio vaizdavimas nuskaito tik to puslapio turinio srautus bei išteklius ir nieko daugiau.

Tai yra skirtumas tarp buferinio įkėlimo (buffered load) ir srautinio įkėlimo (streaming load). Buferinis įkėlimas nuskaito failą nuo pradžios iki galo dar prieš PDFium pamatant nulinį baitą. Srautinis įkėlimas apverčia šį santykį: PDFium valdo skaitymus, o baitai, kurie niekada neliečiami, niekada nėra nuskaitomi. Kelių gigabaitų failui, peržiūrimam po vieną puslapį, tai yra skirtumas tarp visiškai nenaudotino įkėlimo ir momentinio atidarymo.

Srauto adapteris

Adapteris, kuris sujungia Delphi TStream su FPDF_FILEACCESS, yra TPdfStreamAdapter. Jo konstruktorius priima srautą ir nuosavybės žymę (ownership flag), vieną kartą užfiksuoja srauto ilgį, užpildo FPDF_FILEACCESS įrašą ir susieja skaitymo atgalinį iškvietimą. Kai PDFium vėliau kreipiasi su poslinkiu ir dydžiu, adapteris nukreipia srautą į tą poslinkį ir nukopijuoja tiksliai tą diapazoną į PDFium pateiktą buferį.

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

Nuosavybės žymė nustato, kas atlaisvina srautą. Perduokite False, ir iškvietėjas pasilieka srautą bei privalo išlaikyti jį gyvą visą dokumento gyvavimo laikotarpį. Perduokite True, ir adapteris perima valdymą, atlaisvindamas srautą, kai dokumentas uždaromas. Bet kuriuo atveju srautas turi išgyventi kiekvieną PDFium atliekamą nuskaitymą, nes PDFium saugo FPDF_FILEACCESS rodiklį ir kreipsis bet kuriuo metu, kol dokumentas yra atidarytas, o ne tik pirminio įkėlimo metu.

Kodėl atgalinis iškvietimas yra statinė funkcija

Skaitymo atgalinis iškvietimas, kurį PDFium saugo m_GetBlock lauke, yra paprastas C funkcijos rodiklis su cdecl iškvietimo konvencija. Delphi metodo negalima naudoti tiesiogiai, nes metodas turi paslėptą Self argumentą, apie kurį C iškvietėjas nieko nežino ir niekada nepateiks. Todėl adapteris deklaruoja atgalinį iškvietimą kaip class function su žyme cdecl; static, kas sukompiliuojama į savarankišką funkciją su C rėmo išdėstymu, kurio tikisi PDFium, be jokio paslėpto Self kintamojo.

That solves the calling convention but raises a second question: with no Self, how does the callback reach the specific stream it is supposed to read from? The answer is the opaque user parameter. When the adapter builds the record it stores its own instance pointer in m_Param. PDFium hands that same pointer back as the first argument of every callback. The static function casts it back to a TPdfStreamAdapter and dispatches the read against that instance's stream. This is the standard trampoline for handing object context across a C boundary that has no notion of objects.

// 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 riba ir kodėl jai reikalinga apsauga

Ilgio laukas m_FileLen struktūroje FPDF_FILEACCESS yra 32 bitų reikšmė be ženklo. Didžiausias jos atvaizduojamas ilgis yra vienu baitu mažesnis nei 4 GiB. TStream praneša apie savo dydį kaip Int64, todėl srautas gali aprašyti kur kas daugiau baitų, nei gali talpinti šis laukas. Kai tik srauto dydis viršija šią ribą, nebėra teisingo būdo nurodyti PDFium, kokio ilgio yra failas.

Neteisingas sprendimas būtų tiesiog priskirti dydį ir leisti jam persipildyti. Sutrumpinus 5 GiB ilgį iki 32 bitų lauko, gaunamas nedidelis, tikroviškai atrodantis skaičius, ir PDFium analizuos failą tikėdama, kad jis baigiasi maždaug ties pirmuoju gigabaitu. Failo pabaigos blokas (trailer) ir kryžminių nuorodų lentelė yra tikrojoje failo pabaigoje, toli už sutrumpinto ilgio, todėl analizė nepavyksta dėl priežasties, kuri neturi nieko bendra su tikrąja problema. Jūs analizuotumėte kryžminių nuorodų klaidą faile, kuris yra visiškai teisingas, be jokios užuominos, kad sveikasis skaičius persipildė dviem lygiais aukščiau.

Vietoj to adapteris atmeta tokią įvestį. Konstruktorius palygina srauto dydį su High(FPDF_DWORD) ir sukelia EPdfError tą pačią sekundę, kai srautas yra per didelis aprašyti. Aiški, neatidėliotina klaida nurodo tikrąją problemą dar konstravimo metu. Tylus sutrumpinimas paslepia ją už klaidinančio simptomo, kurį analizuotumėte daug vėliau. 4 GiB riba yra tikras šio įkėlimo kelio apribojimas, ir teisingiausia yra parodyti jį aiškiai, užuot bandžius paslėpti po aritmetika, kuri tiesiog susikompiliuoja.

Nesėkmės neturi kirsti ribos

Skaitymas gali nepavykti. Srautas gali būti tinklo objektas, kuriam baigėsi laikas (timeout), blob lauko deskriptorius, uždarytas po jumis, arba failas, kuris buvo apkarpytas jau po to, kai atsidarė dokumentas. PDFium sutartis skaitymo atgaliniam iškvietimui yra grąžinama reikšmė: ne nulis sėkmės atveju, nulis nesėkmės atveju. Tai yra C rėmas, ir jis neturi priemonių Pascal išimčiai pagauti ar perduoti toliau.

Štai kodėl peršokimo funkcija (trampoline) apgaubia nukreipimą (seek) ir skaitymą try/except bloku, kuris praryja išimtį ir grąžina nulį. Jei Delphi išimčiai būtų leista plisti iš atgalinio iškvietimo, ji būtų išvyniota per PDFium cdecl dėklo rėmus, kurie niekada nebuvo tam pritaikyti. Rezultatas geriausiu atveju būtų neapibrėžtas elgesys, o blogiausiu – visiškas programos lūžimas giliai PDF parseryje be jokio naudingo dėklo. Nulio grąžinimas išlaiko klaidą sutarties rėmuose. PDFium mato nepavykusį bloko nuskaitymą, tvarkingai nutraukia operaciją, o FPDF_LoadCustomDocument praneša, kad dokumento nepavyko įkelti, ką komponentas pateikia kaip EPdfError Pascal pusėje, kur jai ir vieta.

Dokumento atidarymas šiuo būdu

Komponento metodas, kuris valdo srautinį kelią, yra LoadCustomDocument, deklaruotas kaip atskiras metodas, o ne dar viena LoadDocument perkrova, kad perduodant TMemoryStream netyčia neatsidurtumėte buferiniame kelyje. Jis sukuria adapterį, iškviečia FPDF_LoadCustomDocument ir išlaiko adapterį gyvą visą įkelto dokumento gyvavimo laiką.

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;

Tas pats iškvietimas veikia su TMemoryStream, blob srautu iš duomenų bazės rinkinio arba pasirinktiniu TStream palikuoniu. Įkėlimas pagal poreikį atperka pastangas, kai failas yra didelis ir bus skaitoma tik jo dalis: archyvų peržiūros programa, miniatiūrų generatorius, paimantis kelis puslapius, arba paieškos indeksatorius, nuskaitantis po vieną puslapį vienu metu. Kai failas yra mažas arba ketinate skaityti jį visą, buferinis įkėlimas yra paprastesnis, o srautinis mechanizmas neduoda jokios naudos. Lemiamas veiksnys yra santykis tarp baitų, kuriuos iš tikrųjų paliesite, ir baitų, kuriuos turi pats failas.

Kai puslapiai pradedami įkelti pagal poreikį, kitas svarbus uždavinys yra užtikrinti atvaizduotų puslapių reaktyvumą naudotojui keičiant mastelį ir slenkant puslapį, kas aprašyta mūsų pastaboje apie vaizdavimo talpyklą ir mastelio keitimo našumą. Kai srautiniu būdu įkeliamas dokumentas yra toks, kurį peržiūros programa turėtų rodyti, bet neleisti naudotojui eksportuoti ar keisti, apsaugoto PDF peržiūros metodai, aprašyti apsaugotos PDF peržiūros apžvalgoje, puikiai dera su šiuo įkėlimo keliu. Abu sprendimai remiasi čia aprašytu srautiniu įkėlimu, kuris platinamas kaip PDFium Component, skirto Delphi ir C++Builder, dalis kartu su vaizdavimo, teksto išgavimo ir anotacijų API, aprašytais kitose šio tinklaraščio dalyse.