Technical Article

Hatalmas PDF-ek streamelése igény szerint PDFium segítségével Delphiben

Egy szkennelt archívum mérete egyetlen PDF-ben elérheti a több gigabájtot is. A megjelenítő, amely megnyit egy ilyen fájlt, általában egyetlen oldalt szeretne mutatni, esetleg a tartalomjegyzéket vagy egy oldalt, amelyre a felhasználó egy könyvjelzőről ugrott. A teljes fájl beolvasása a memóriába két oldal kirajzolásához minden szempontból pazarló: elégeti a címtartományt, hosszú kezdeti olvasással feltartja a felhasználót, és egy 32 bites Delphi folyamatban teljesen meg is hiúsulhat, még mielőtt egyetlen oldal megjelenne. A PDFiumot ezt szem előtt tartva építették fel. Be tud tölteni egy dokumentumot egy olyan visszahívás segítségével, amely pontosan azokat a bájttartományokat kéri le, amelyekre szüksége van, amikor szüksége van rájuk, és soha nem követeli meg a teljes fájlt egyszerre.

A komponens ezt az utat egy adatfolyam-adapteren (stream adapter) keresztül teszi elérhetővé. Átad neki bármilyen TStream objektumot, és a PDFium igény szerint blokkokat húz le ebből az adatfolyamból. A fájl ülhet a lemezen, egy adatbázis blob mezőjében vagy bármilyen más TStream leszármazott mögött, és semmi sem másolódik át előre a memóriába.

Hogyan kér bájtokat a PDFium

A PDFium C API-ja egy a hívó által biztosított objektumból tölti be a dokumentumot, amelyet az FPDF_FILEACCESS struktúra ír le. A struktúrának három lényeges része van itt: egy hosszúság mező, egy olvasási visszahívás és egy nem átlátszó (opaque) felhasználói paraméter. Az ezt fogyasztó belépési pont a FPDF_LoadCustomDocument. Amint a PDFium kézben tartja ezt a struktúrát, elemzi a trailert, megkeresi a kereszthivatkozási táblát, és ettől kezdve csak azt olvassa be, amit az adott művelet megkövetel. A dokumentum megnyitása érinti a fájl végét és maroknyi katalógusobjektumot. A 400. oldal kirajzolása beolvassa az adott oldal tartalomfolyamait és erőforrásait, és semmi mást.

Ez a különbség a pufferelt betöltés és a streamelt betöltés között. A pufferelt betöltés a fájlt a végétől az elejéig beolvassa, mielőtt a PDFium látná a nulladik bájtot. A streamelt betöltés megfordítja a viszonyt: a PDFium hajtja végre az olvasásokat, és a bájtok, amelyekhez soha nem ér hozzá, soha nem is olvasódnak be. Egy egyszerre egy oldalonként megtekintett, több gigabájtos fájl esetében ez jelenti a szakadékot a használhatatlan betöltés és az azonnali betöltés között.

Az adatfolyam-adapter

Az adapter, amely áthidalja a Delphi TStream-et az FPDF_FILEACCESS felé, a TPdfStreamAdapter. Konstruktora fogadja az adatfolyamot és egy tulajdonosi (ownership) zászlót, egyszer elkapja az adatfolyam hosszát, kitölti a FPDF_FILEACCESS rekordot, és beköti az olvasási visszahívást. Amikor a PDFium később visszahív egy eltolással és mérettel, az adapter a folyamot arra az eltolásra pozicionálja, és pontosan azt a tartományt másolja át 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 tulajdonosi flag dönti el, ki szabadítja fel az adatfolyamot. Adjon át False értéket, és a hívó megtartja a folyamot, és életben kell tartania a dokumentum teljes élettartama alatt. Adjon át True értéket, és az adapter átveszi az irányítást, felszabadítva a folyamot, amikor a dokumentum bezárul. Bárhogy is legyen, a folyamnak túl kell élnie minden olyan olvasást, amelyet a PDFium végez, mert a PDFium kézben tartja 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, amelyet a PDFium a m_GetBlock-ban tárol, egy egyszerű C függvénymutató cdecl hívási konvencióval. Delphi metódust nem lehet közvetlenül használni, mert a metódus egy rejtett Self argumentumot hordoz, amelyről a C hívó semmit sem tud, és soha nem adná át. Az adapter ezért a visszahívást cdecl; static jelölésű class function-ként deklarálja, ami egy független függvénnyé fordul le a PDFium által elvárt C keret-elrendezéssel és 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 folyamot, amelyből olvasnia kellene? A válasz a nem átlátszó felhasználói paraméter. Amikor az adapter felépíti a rekordot, eltárolja a saját példánymutatóját a m_Param-ban. A PDFium ugyanezt a mutatót adja vissza minden visszahívás első argumentumaként. A statikus függvény visszaalakítja (castolja) ezt TPdfStreamAdapter-ré, és az olvasást a példány folyamára irányítja. Ez a standard trambulin az objektum-kontextus á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ég védelemre

A m_FileLen hosszmező a FPDF_FILEACCESS-ben egy 32 bites előjel nélküli érték. A legnagyobb ábrázolható hossza egy bájttal kevesebb, mint 4 GiB. A TStream a méretét Int64-ként jelenti, így az adatfolyam sokkal több bájtot írhat le, mint amennyit a mező el tud tartani. Abban a pillanatbon, amikor az adatfolyam mérete átlépi ezt a plafont, nincs korrekt módja annak megmondására a PDFium számára, hogy milyen hosszú a fájl.

A rossz válasz a méret hozzárendelése és a túlcsordulás engedése. Egy 5 GiB-os hossz csonkolása egy 32 bites mezőre egy kis, hihetőnek tűnő számot eredményez, és a PDFium úgy fogja elemezni a fájlt, azt gondolva, hogy az nagyjából egy gigabájt után véget ér. A trailer és a kereszthivatkozási tábla a fájl valódi végén lakik, jóval a csonkolt hossz után, így az elemzés olyan módon hiúsul meg, aminek semmi köze sincs a tényleges okhoz. Kereszthivatkozási hibát keresne egy tökéletesen érvényes fájlban, anélkül, hogy sejtené, hogy egy egész szám két szinttel feljebb túlcsordult.

Az adapter ehelyett elutasítja a bemenetet. A konstruktor összehasonlítja a folyam megadott méretét a High(FPDF_DWORD) értékkel, és azonnal EPdfError kivételt dob, amint a folyam túl nagy a leíráshoz. Egy kifejezett, azonnali hiba a valós problémát nevezi meg a felépítés helyén. A csendes csonkolás elrejtené azt egy félrevezető tünet mögé, amelyet sokkal később keresne. A 4 GiB-os korlát ennek a betöltési útvonalnak a valódi korlátja, és a helyes dolog az, ha ezt hangosan a felszínre hozzuk, ahelyett, hogy elfednénk egy olyan aritmetikával, amely véletlenül lefordul.

A hibák nem léphetik át a határt

Az olvasás meghiúsulhat. A folyam lehet egy hálózati objektum, amelynél időtúllépés történik, egy blob kezelő, amely bezárult Ön alatt, vagy egy fájl, amelyet a dokumentum megnyitása után csonkoltak. 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, és nem rendelkezik semmilyen gépezettel a Pascal kivétel elkapására vagy továbbítására.

Ezért csomagolja be a trambulin a pozicionálást és az olvasást egy try/except blokkba, amely elnyeli a kivételt és nullát ad vissza. Ha megengednénk, hogy egy Delphi kivétel kiszökjön a visszahívásból, az letekeredne a PDFium cdecl veremkeretein keresztül, amelyeket soha nem úgy építettek fel, hogy a Pascal kivételkezelő megsemmisíthesse őket. Az eredmény legjobb esetben definiálatlan viselkedés, legrosszabb esetben pedig hard összeomlás a PDF elemző mélyén, használható verem nélkül. A nulla visszatérése a hibát a szerződésen belül tartja. A PDFium észleli a meghiúsult blokk-olvasást, tisztán megszakítja a műveletet, és a FPDF_LoadCustomDocument jelzi, hogy a dokumentumot nem lehetett betölteni, amelyet a komponens EPdfError-ként hoz a felszijnre a Pascal oldalon, ahová az tartozik.

Dokumentum megnyitása ezen az úton

A streamelési útvonalot meghajtó komponensmetódus a LoadCustomDocument, amelyet különálló metódusként deklaráltak, nem pedig egy újabb LoadDocument túlterhelésként, így egy TMemoryStream átadása soha nem landol véletlenül a pufferelt útvonalon. Felépíti az adaptert, meghívja a 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 TMemoryStream, adatbázis adatbázis-rekordjából származó blob folyam, vagy egyedi TStream leszármazott esetén is. Az igény szerinti betöltés akkor szolgálja meg az árát, ha a fájl nagy, és csak egy részét olvassák be: archívum-megjelenítő, néhány oldalt mintavevő bélyegkép-generátor vagy egyszerre egy oldalt letöltő keresőindex esetén. Ha a fájl kicsi, vagy egyébként is beolvasná a teljes tartalmát, a pufferelt betöltés egyszerűbb, és a streamelő gépezet semmit sem ad hozzá. A döntő tényező a ténylegesen megérintett bájtok aránya a fájl által tartalmazott bájtokhoz képest.

Once pages stream in on demand, the next concern is keeping rendered pages responsive as the user zooms and scrolls, which is covered in our note on render caching and zoom performance. When the streamed document is one a viewer should display but not let the user export or alter, the techniques in the secure PDF preview walkthrough pair naturally with this loading path. Both build on the streaming load described here, which ships as part of the PDFium Component for Delphi and C++Builder alongside the rendering, text extraction, and annotation APIs covered elsewhere on this blog.