Műszaki cikk

Óriási PDF-ek igény szerinti (on-demand) streamelése PDFium-mal Delphiben

Egy szkennelt archívum egyetlen PDF-ben több gigabájtos is lehet. Egy olyan megjelenítő (viewer), amely megnyit egy ilyen fájlt, általában egy oldalt akar mutatni, esetleg a tartalomjegyzéket, vagy egy olyan oldalt, amelyre a felhasználó egy könyvjelzőből (bookmark) ugrott. A teljes fájl memóriába olvasása két oldal rendereléséhez minden szempontból pazarlás: elégeti a címtartományt (address space), a felhasználót egy hosszú kezdeti olvasás mögött várakoztatja, és egy 32 bites Delphi folyamat (process) esetén egyenesen el is bukhat, mielőtt egyetlen oldal is megjelenne. A PDFiumot ezt szem előtt tartva építették. Képes egy dokumentumot egy visszahíváson (callback) keresztül betölteni, amely az általa igényelt konkrét bájttartományokat kéri, akkor, amikor szüksége van rájuk, és soha nem követeli meg a teljes fájlt egyszerre. Egy határvonalat már az elején le kell szögezni: ez a streamelő csatorna a fájlt egy 32 bites hosszúsággal írja le, így egyetlen fájlt legfeljebb 4 GiB-ig szolgál ki, ami a gyakorlatban szinte minden szkennelt archívumot lefed. Egy ezen a határon túli fájl nem ennek a cikknek a területe; az ilyeneket a szkenneléskor kötetekre (volumes) kell osztani, vagy egy közvetlen hozzáférésű (direct-access) stratégia révén kell megnyitni, és az a védelem, amely ezt a plafont kikényszeríti, jogosan kap egy saját szakaszt alább

A komponens ezt az útvonalat egy stream adapteren keresztül teszi elérhetővé. Átad neki bármilyen TStream-et, és a PDFium igény szerint blokkokat húz ki abból a streamből. A fájl lehet a lemezen, egy adatbázis blob mezőjében, vagy bármilyen más TStream leszármazott mögött, és az egészből semmi sem másolódik a memóriába előre

Hogyan kér a PDFium bájtokat

A PDFium C API-ja a dokumentumot egy hívó által biztosított, az FPDF_FILEACCESS struktúrával leírt objektumból tölti be. A struktúrának három része van, amely itt számít: egy hossz mező, egy olvasási visszahívás (read callback) és egy átlátszatlan (opaque) felhasználói paraméter. A belépési pont, amely ezt elfogyasztja, az FPDF_LoadCustomDocument. Amint a PDFium birtokolja ezt a struktúrát, értelmezi a trailert, megkeresi a kereszthivatkozási táblát (cross-reference table), és onnantól kezdve csak azt olvassa, amit egy adott művelet megkövetel. A dokumentum megnyitása érinti a fájl végét és egy maréknyi katalógus objektumot. A 400. oldal megjelenítése kiolvassa az oldal tartalomfolyamait (content streams) és erőforrásait, és semmi mást

Ez a különbség egy pufferelt betöltés (buffered load) és egy streamelő betöltés (streaming load) között. Egy pufferelt betöltés elejétől a végéig beolvassa a fájlt, mielőtt a PDFium egyáltalán meglátná a nulladik bájtot. A streamelő betöltés megfordítja a kapcsolatot: a PDFium irányítja az olvasásokat, és azok a bájtok, amelyeket soha nem érint meg, soha nem kerülnek beolvasásra. Egy oldalanként megtekintett, több gigabájtos fájl esetében ez a különbség egy használhatatlan és egy azonnali betöltés között

A stream adapter

Az az adapter, amely egy Delphi TStream-et az FPDF_FILEACCESS-hez hidal át, a TPdfStreamAdapter. Konstruktora felveszi a streamet és egy tulajdonjogi jelzőt (ownership flag), egyszer rögzíti a stream hosszát, kitölti az FPDF_FILEACCESS rekordot, és beköti az olvasási visszahívást. Amikor a PDFium később visszahív egy eltolással (offset) és egy mérettel, az adapter ahhoz az eltoláshoz pozícionálja (seeks) a streamet, és pontosan ezt a tartományt másolja a PDFium által biztosított pufferbe

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

A tulajdonjogi jelző dönti el, ki szabadítja fel a streamet. Ha False értéket ad át, a hívó megtartja a streamet, és a dokumentum teljes élettartama alatt életben kell tartania. Ha True értéket ad át, az adapter átveszi az irányítást, felszabadítva a streamet a dokumentum bezárásakor. Így vagy úgy, a streamnek túl kell élnie minden olvasást, amelyet a PDFium végrehajt, mert a PDFium megtartja az FPDF_FILEACCESS mutatót, és bármikor visszahívhat, amíg a dokumentum nyitva van, nem csak a kezdeti betöltés során

Miért statikus függvény a visszahívás?

Az olvasási visszahívás (read callback), amelyet a PDFium az m_GetBlock-ban tárol, egy egyszerű C függvénymutató a cdecl hívási konvencióval. Egy Delphi metódust nem lehet közvetlenül használni, mivel egy metódus hordoz egy rejtett Self argumentumot, amelyről egy C hívó semmit sem tud, és soha nem fogja biztosítani. Az adapter ezért a visszahívást egy class function-ként (osztályfüggvényként) deklarálja, amely cdecl; static jelöléssel van ellátva, és egy szabadon álló (free-standing) függvénnyé fordul, azzal a C keretelrendezéssel (frame layout), amelyet a PDFium elvár, és minden implicit Self nélkül

Ez megoldja a hívási konvenciót, de felvet egy második kérdést: Self nélkül hogyan éri el a visszahívás azt a konkrét streamet, amelyből olvasnia kell? A válasz az átlátszatlan (opaque) felhasználói paraméter. Amikor az adapter felépíti a rekordot, eltárolja a saját példánymutatóját (instance pointer) az m_Param mezőben. A PDFium ugyanezt a mutatót adja vissza minden visszahívás első argumentumaként. A statikus függvény visszakasztolja azt egy TPdfStreamAdapter-ré, és továbbítja (dispatches) az olvasást azon példány streamje felé. Ez a szabványos trambulin (trampoline) az objektumkontextus átadására egy olyan C határon keresztül, amelynek nincs fogalma az objektumokról

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

A 4 GiB-os plafon és miért van szüksége védelemre

Itt jön a képbe a bevezetőben említett határvonal. A hossz mező, az m_FileLen az FPDF_FILEACCESS-ben egy 32 bites előjel nélküli (unsigned) érték. Legnagyobb ábrázolható hossza egy bájttal kevesebb, mint 4 GiB. Egy TStream Int64-ként jelenti a méretét, így egy stream sokkal több bájtot is leírhat, mint amennyit a mező elbír. Abban a pillanatban, amikor egy stream mérete meghaladja ezt a plafont, nincs tisztességes módja annak, hogy megmondjuk a PDFiumnak, milyen hosszú a fájl

A helytelen válasz az, hogy hozzárendeljük a méretet, és hagyjuk túlcsordulni (wrap). Egy 5 GiB hosszúság csonkítása (truncating) egy 32 bites mezőbe egy kis, hihetőnek tűnő számot eredményez, és a PDFium ezután úgy fogja értelmezni (parse) a fájlt, mintha az nagyjából egy gigabájt után véget érne. A trailer és a kereszthivatkozási tábla (cross-reference table) a fájl valódi végén élnek, jóval a csonkolt hosszúság után, így az értelmezés olyan módon bukik el, aminek semmi köze a tényleges okhoz. Egy tökéletesen érvényes fájlon hibakeresne (debugging) egy kereszthivatkozási hibát anélkül, hogy bármi is utalna arra, hogy egy egész szám (integer) túlcsordult két réteggel feljebb

Az adapter ehelyett visszautasítja a bemenetet. A konstruktor összehasonlítja a stream méretét a High(FPDF_DWORD) értékkel, és egy EPdfError-t dob (raises) abban a pillanatban, amikor a stream túl nagy a leíráshoz. Egy kifejezett (explicit), azonnali hiba a valódi problémát nevezi meg a konstrukció pillanatában. Egy csendes csonkítás elrejtené azt egy félrevezető tünet mögé, amit sokkal később kergetne. A 4 GiB-os korlát egy valódi (genuine) kényszer ezen a betöltési útvonalon, és az az őszinte dolog, ha ezt hangosan felszínre hozzuk, ahelyett, hogy egy olyan aritmetikával fednénk el (paper over), ami történetesen lefordul. Ha egy archívum valóban átlépi a határt, a fent ígért megoldások ezen az API-n kívül élnek: ossza fel a szkennelést kötetenkénti (per-volume) fájlokra, amelyek mindegyike a plafon alatt marad, vagy hagyja a dokumentumot a lemezen, és szolgálja ki egy 64 bites eltolásokra épülő, közvetlen hozzáférésű kialakításon keresztül, ne pedig az FPDF_FILEACCESS segítségével

A hibáknak nem szabad átlépniük a határt

Egy olvasás meghiúsulhat (fail). A stream lehet egy hálózat által támogatott objektum, amelynél időtúllépés (timeout) történik, egy blob kezelő (handle), amelyet bezártak Ön alatt, vagy egy fájl, amelyet csonkítottak (truncated) a dokumentum megnyitása után. A PDFium szerződése az olvasási visszahívásra egy visszatérési érték: nem nulla a siker, nulla a hiba esetén. Ez egy C keret (frame), és nincs mechanizmusa egy Pascal kivétel (exception) elkapására vagy továbbítására (propagate)

Ezért a trambulin egy try/except blokkba csomagolja a keresést (seek) és az olvasást, amely elnyeli (swallows) a kivételt és nullát ad vissza. Ha egy Delphi kivételt hagynánk kiterjedni (propagate) a visszahívásból, az a PDFium cdecl veremkeretein (stack frames) keresztül tekeredne le (unwind), amelyeket soha nem arra építettek, hogy a Pascal kivétel-mechanizmus lecsévélje őket. Az eredmény a legjobb esetben is meghatározatlan viselkedés (undefined behavior), a legrosszabb esetben pedig egy kemény összeomlás (hard crash) lenne mélyen a PDF-elemzőben, használható verem (stack) nélkül. A nulla visszatérése a hibát a szerződésen belül tartja. A PDFium meghiúsult blokk-olvasást lát, tisztán megszakítja a műveletet, az FPDF_LoadCustomDocument pedig jelenti, hogy a dokumentumot nem lehetett betölteni, amit a komponens EPdfError-ként hoz felszínre a Pascal oldalon, ahová való

Dokumentum megnyitása ezen a módon

A komponens metódus, amely a streamelő útvonalat vezérli, a LoadCustomDocument, amelyet egy különálló metódusként deklaráltak egy újabb LoadDocument túlterhelés (overload) helyett, így a TMemoryStream átadása soha nem landol véletlenül a pufferelt útvonalon. Felépíti az adaptert, meghívja az FPDF_LoadCustomDocument-et, és életben tartja az adaptert a betöltött dokumentum élettartama alatt

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;

Ugyanez a hívás működik egy TMemoryStream, egy adatbázis adatkészletből származó blob stream, vagy egy egyedi TStream leszármazott esetén is. Az igény szerinti (on-demand) betöltés akkor térül meg, ha a fájl nagy, és csak egy részét fogják beolvasni: egy archívum-megjelenítő, egy miniatűr-generátor (thumbnail generator), amely néhány oldalból vesz mintát, vagy egy keresési index, amely egyszerre egy oldalt húz be. Ha a fájl kicsi, vagy úgyis beolvassa az egészet, a pufferelt betöltés (buffered load) egyszerűbb, és a streamelő mechanizmus semmit sem ér. A döntő tényező az a viszonyszám, ami a ténylegesen érintett bájtok és a fájl által tartalmazott bájtok között van

Amint az oldalak igény szerint (on-demand) streamelődnek be, a következő szempont az, hogy a megjelenített oldalak reszponzívak maradjanak, ahogy a felhasználó nagyít és görget, amivel a render gyorsítótárazásról és nagyítási teljesítményről (render caching and zoom performance) szóló jegyzetünkben foglalkozunk. Amikor a streamelt dokumentum olyan, amelyet a megjelenítőnek (viewer) mutatnia kell, de nem hagyhatja, hogy a felhasználó exportálja vagy megváltoztassa, a biztonságos PDF előnézetről szóló útmutatónkban ismertetett technikák természetesen párosulnak ezzel a betöltési útvonallal. Mindkettő az itt leírt streamelő betöltésre (streaming load) épül, amely a Delphihez és C++Builderhez készült PDFium Component csomag részeként érhető el, a blog többi részén bemutatott megjelenítési, szövegkivonási (text extraction) és annotációs API-k mellett