Technický článek

Streamování obrovských PDF na vyžádání s PDFium v Delphi

Naskenovaný archiv může v jednom PDF zabírat několik gigabajtů. Prohlížeč, který takový soubor otevírá, obvykle chce zobrazit jednu stránku, možná obsah, nebo stránku, na kterou uživatel přeskočil ze záložky. Načtení celého souboru do paměti pro vykreslení dvou stránek je plýtváním ve všech směrech: spaluje adresní prostor, zdržuje uživatele za dlouhým počátečním čtením a ve 32bitovém procesu Delphi může přímo selhat, než se objeví jediná stránka. PDFium bylo vytvořeno s ohledem na toto. Může načíst dokument přes callback, který si vyžádá konkrétní rozsahy bajtů, které potřebuje, v době, kdy je potřebuje, a nikdy nepožaduje celý soubor najednou. Jedna hranice ale patří na začátek: tento streamovací kanál popisuje soubor pomocí 32bitové délky, takže obslouží jeden soubor do velikosti 4 GiB, což v praxi pokrývá téměř každý naskenovaný archiv. Soubor za touto hranicí není územím tohoto článku; měl by být v době skenování rozdělen do svazků, nebo otevřen pomocí strategie přímého přístupu (direct-access), a ochrana, která tento strop vynucuje, si poctivě vyslouží svou vlastní sekci níže

Jak si PDFium žádá o byty

C API knihovny PDFium načítá dokument z volajícím dodaného objektu popsaného strukturou FPDF_FILEACCESS. Struktura má tři části, na kterých zde záleží: pole délky, callback pro čtení a neprůhledný (opaque) uživatelský parametr. Vstupní bod, který ji konzumuje, je FPDF_LoadCustomDocument. Jakmile PDFium drží tuto strukturu, analyzuje patičku (trailer), najde tabulku křížových odkazů (cross-reference table) a od té chvíle čte pouze to, co daná operace vyžaduje. Otevření dokumentu se dotkne konce souboru a hrstky katalogových objektů. Vykreslení stránky 400 načte toky obsahu a prostředky pro tuto stránku a nic jiného

To je rozdíl mezi načítáním s vyrovnávací pamětí (buffered) a streamovaným načítáním. Buffered načítání přečte soubor od začátku do konce, než PDFium vůbec uvidí nultý bajt. Streamované načítání tento vztah obrací: PDFium řídí čtení a byty, kterých se nikdy nedotkne, nebudou nikdy přečteny. U vícegigabajtového souboru prohlíženého po jedné stránce je to propast mezi nepoužitelným načítáním a okamžitým načítáním

Adaptér streamu (Stream adapter)

Adaptér, který přemosťuje Delphi TStream a FPDF_FILEACCESS, je TPdfStreamAdapter. Jeho konstruktor přebírá stream a příznak vlastnictví (ownership flag), jednou zachytí délku streamu, vyplní záznam FPDF_FILEACCESS a zapojí callback pro čtení. Když později PDFium zavolá zpět s offsetem a velikostí, adaptér posune stream (seek) na tento offset a zkopíruje přesně tento rozsah do bufferu, který PDFium poskytlo

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

Příznak vlastnictví určuje, kdo stream uvolní. Předejte False a volající si stream ponechá a musí jej udržet naživu po celou dobu života dokumentu. Předejte True a adaptér jej převezme a uvolní stream, když se dokument zavře. Tak či onak, stream musí přežít každé čtení, které PDFium provede, protože PDFium drží ukazatel na FPDF_FILEACCESS a zavolá zpět (callback) v jakémkoliv bodě, dokud je dokument otevřený, nejen během počátečního načítání

Proč je callback statickou funkcí

Callback pro čtení, který PDFium ukládá do m_GetBlock, je obyčejný ukazatel na funkci C s volací konvencí cdecl. Metodu Delphi nelze použít přímo, protože metoda nese skrytý argument Self, o kterém volající z C nic neví a nikdy jej neposkytne. Adaptér proto deklaruje callback jako class function označenou cdecl; static, která se zkompiluje do volně stojící funkce s uspořádáním C rámce, které PDFium očekává, a bez implicitního Self

To řeší volací konvenci, ale vyvolává druhou otázku: když chybí Self, jak se callback dostane ke konkrétnímu streamu, ze kterého má číst? Odpovědí je neprůhledný uživatelský parametr. Když adaptér vytváří záznam, uloží ukazatel na svou vlastní instanci do m_Param. PDFium pak tentýž ukazatel vrací zpět jako první argument každého callbacku. Statická funkce jej přetypuje zpět na TPdfStreamAdapter a odešle požadavek na čtení na stream této instance. Jedná se o standardní trampolínu (trampoline) pro předávání kontextu objektu přes hranici C, která nemá tušení o objektech

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

Strop 4 GiB a proč potřebuje ochranu

Odtud pochází hranice uvedená v úvodu. Pole délky m_FileLen v FPDF_FILEACCESS je 32bitová hodnota bez znaménka (unsigned). Její největší reprezentovatelná délka je jeden bajt pod 4 GiB. TStream hlásí svou velikost jako Int64, takže stream může popsat mnohem více bajtů, než kolik může pole pojmout. Ve chvíli, kdy velikost streamu překročí tento strop, neexistuje žádný poctivý způsob, jak sdělit PDFiu, jak dlouhý soubor je

Špatnou reakcí je přiřadit velikost a nechat ji přetéct (wrap). Oříznutí délky 5 GiB na 32bitové pole vytvoří malé, věrohodně vypadající číslo a PDFium pak bude soubor analyzovat v domnění, že končí zhruba na jednom gigabajtu. Patička a tabulka křížových odkazů však leží na skutečném konci souboru, hluboko za oříznutou délkou, takže analýza selže způsobem, který nemá nic společného se skutečnou příčinou. Ladili byste chybu křížových odkazů v souboru, který je naprosto platný, aniž by existoval náznak, že celé číslo (integer) přeteklo o dvě vrstvy výše

Adaptér vstup raději odmítne. Konstruktor porovná velikost streamu s High(FPDF_DWORD) a vyvolá EPdfError v okamžiku, kdy je stream příliš velký, než aby jej bylo možné popsat. Výslovná, okamžitá chyba pojmenuje skutečný problém v místě konstrukce. Tiché oříznutí by jej schovalo za zavádějící symptom, který byste honili mnohem později. Limit 4 GiB je skutečným omezením této cesty načítání a tím poctivým přístupem je vytáhnout jej hlasitě na povrch, než jej maskovat aritmetikou, která se jen náhodou zkompiluje. Když archiv skutečně tuto hranici překročí, nápravná opatření slíbená výše spočívají mimo toto API: rozdělte sken na soubory po jednotlivých svazcích tak, aby se každý z nich vešel pod stanovený strop, nebo nechte dokument na disku a obsluhujte jej prostřednictvím návrhu s přímým přístupem postaveného na 64bitových offsetech namísto použití FPDF_FILEACCESS

Selhání nesmí překročit hranici

Čtení může selhat. Stream může být síťově podporovaný (network-backed) objekt s vypršením času (timeout), handle blobu, které pod vámi někdo zavřel, nebo soubor, který byl zkrácen poté, co se dokument otevřel. Kontrakt PDFia pro callback pro čtení je návratová hodnota: nenulová pro úspěch, nula pro selhání. Je to C rámec a nemá žádné mechanismy pro zachycení nebo šíření (propagate) Pascal výjimky

Proto trampolína zabalí seek a čtení do try/except, což polkne výjimku a vrátí nulu. Pokud by se výjimce Delphi dovolilo šířit se ven z callbacku, rozvinula by se přes cdecl rámce zásobníku (stack frames) v PDFiu, které nikdy nebyly navrženy tak, aby byly rozvinovány mechanismem výjimek Pascalu. Výsledkem by v lepším případě bylo nedefinované chování, v tom horším pak tvrdý pád hluboko v PDF parseru bez jakéhokoli použitelného zásobníku. Vrácení nuly udržuje selhání uvnitř kontraktu. PDFium vidí, že čtení bloku selhalo, čistě přeruší operaci a FPDF_LoadCustomDocument ohlásí, že dokument nešlo načíst. Tuto chybu pak komponenta vyvede na povrch jako EPdfError na straně Pascalu, kam patří

Otevření dokumentu tímto způsobem

Metodou komponenty, která řídí streamovací cestu, je LoadCustomDocument. Je deklarována jako samostatná metoda spíše než další přetížení (overload) pro LoadDocument, aby se předešlo tomu, že by předání TMemoryStream náhodně skončilo na cestě s vyrovnávací pamětí (buffered). Sestaví adaptér, zavolá FPDF_LoadCustomDocument a udrží adaptér naživu po celou dobu životnosti načteného dokumentu

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;

Stejné volání funguje pro TMemoryStream, blob stream z databázové sady, nebo uživatelského potomka TStream. Načítání na vyžádání (on-demand) se vyplatí ve chvíli, kdy je soubor velký a bude přečtena jen jeho část: u prohlížeče archivů, generátoru miniatur, který vzorkuje pár stránek, nebo u vyhledávacího indexu, který tahá po jedné stránce. Když je soubor malý nebo se jej stejně chystáte přečíst celý, je načítání s vyrovnávací pamětí (buffered) jednodušší a streamovací mechanismus vám nepřinese nic navíc. Rozhodujícím faktorem je poměr bajtů, kterých se skutečně dotknete, k bajtům, které soubor obsahuje

Jakmile stránky začnou proudit na vyžádání, další starostí je udržet vykreslené stránky svižné (responsive), když uživatel přibližuje a roluje, což pokrývá naše poznámka o ukládání renderu do mezipaměti a výkonu zoomu. Když je streamovaný dokument takový, který by měl prohlížeč pouze zobrazit, ale neměl by dovolit uživateli jej exportovat nebo upravovat, techniky popsané v průvodci bezpečným náhledem PDF se s touto cestou načítání přirozeně doplňují. Obojí staví na streamovaném načítání popsaném zde, které je dodáváno jako součást komponenty PDFium pro Delphi a C++Builder spolu s API pro vykreslování, extrakci textu a anotace, kterými se zabýváme jinde na tomto blogu