Technical Article

Enorme PDF's on-demand streamen met PDFium in Delphi

Een gescand archief kan oplopen tot meerdere gigabytes in een enkele PDF. Een viewer die zo'n bestand opent, wil meestal slechts één pagina tonen, misschien de inhoudsopgave of een pagina waarnaar de gebruiker via een bladwijzer is gesprongen. Het hele bestand in het geheugen laden om twee pagina's te renderen is op elk vlak inefficiënt: het verbruikt adresruimte, het vertraagt de gebruiker door een lange initiële leesactie en op een 32-bits Delphi-proces kan het volledig falen voordat er ook maar één pagina verschijnt. PDFium is met dit in gedachten gebouwd. Het kan een document laden via een callback die vraagt om de specifieke bytebereiken die het nodig heeft op het moment dat het ze nodig heeft, en het vereist nooit het hele bestand in één keer.

Het component ontsluit dat pad via een stream-adapter. U overhandigt een willekeurige TStream en PDFium haalt on-demand blokken uit die stream. Het bestand kan zich op de schijf bevinden, in een database-blobveld, of achter een andere TStream-afstammeling, en niets ervan wordt vooraf naar het geheugen gekopieerd.

Hoe PDFium om bytes vraagt

De C-API van PDFium laadt een document vanuit een door de aanroeper aangeleverd object dat wordt beschreven door de FPDF_FILEACCESS-structuur. De structuur heeft drie onderdelen die hier van belang zijn: een lengteveld, een lees-callback en een ondoorzichtige (opaque) gebruikersparameter. Het ingangspunt dat dit consumeert is FPDF_LoadCustomDocument. Zodra PDFium die structuur heeft, parseert het de trailer, lokaliseert het de kruisverwijzingstabel (cross-reference table) en leest vanaf dat moment alleen wat een specifieke bewerking vereist. Het openen van het document raakt de staart van het bestand en een handvol catalogusobjecten. Het renderen van pagina 400 leest de inhoudsstreams en bronnen voor die pagina, en niets anders.

Dit is het verschil tussen een gebufferde en een streamende laadactie. Een gebufferde laadactie leest het bestand van begin tot eind voordat PDFium byte nul ziet. Een streamende laadactie draait die relatie om: PDFium stuurt de leesacties aan, en de bytes die nooit worden geraakt, worden ook nooit gelezen. Voor een bestand van meerdere gigabytes dat pagina voor pagina wordt bekeken, is dat het verschil tussen een onbruikbaar trage laadactie en een onmiddellijke weergave.

De stream-adapter

De adapter die een Delphi TStream overbrugt naar FPDF_FILEACCESS is TPdfStreamAdapter. De constructor accepteert de stream en een eigendomsflag, legt de streamlengte eenmalig vast, vult het FPDF_FILEACCESS-record in en koppelt de lees-callback. Wanneer PDFium later terugbelt met een offset en een grootte, de adapter zoekt de stream op die offset en kopieert exact dat bereik naar de buffer die PDFium heeft geleverd.

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

De eigendomsflag bepaalt wie de stream vrijgeeft. Geeft u False door, dan behoudt de aanroeper de stream en moet deze gedurende de hele levensduur van het document in leven houden. Geeft u True door, dan neemt de adapter het beheer over en geeft de stream vrij wanneer het document sluit. Hoe dan ook moet de stream elke leesactie overleven die PDFium uitvoert, omdat PDFium de FPDF_FILEACCESS-pointer vasthoudt en op elk moment dat het document geopend is een callback kan uitvoeren, dus niet alleen tijdens het initiële laden.

Waarom de callback een statische functie is

De lees-callback die PDFium opslaat in m_GetBlock is een gewone C-functiepointer met de cdecl calling convention. Een Delphi-methode kan niet rechtstreeks worden gebruikt, omdat een methode een verborgen Self-argument bevat waarvan een C-aanroeper niets weet en dat hij dus nooit zal aanleveren. De adapter declareert de callback daarom als een class function met de markering cdecl; static, wat compileert tot een zelfstandige functie met de C-frame-indeling die PDFium verwacht en zonder impliciete Self.

Dat lost de calling convention op, maar roept een tweede vraag op: hoe bereikt de callback zonder Self de specifieke stream waaruit hij moet lezen? Het antwoord is de ondoorzichtige gebruikersparameter. Wanneer de adapter het record opbouwt, slaat hij zijn eigen instantiepointer op in m_Param. PDFium geeft diezelfde pointer terug als het eerste argument van elke callback. De statische functie cast deze terug naar een TPdfStreamAdapter en voert de leesactie uit tegen de stream van die instantie. Dit is the standard trampoline om objectcontext over te dragen over een C-grens die geen concept van objecten kent.

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

Het 4 GiB-plafond en waarom een beveiliging vereist is

Het lengteveld m_FileLen in FPDF_FILEACCESS is een 32-bits unsigned waarde. De grootste representeerbare lengte is één byte minder dan 4 GiB. Een TStream rapporteert zijn grootte als een Int64, waardoor een stream veel meer bytes kan beschrijven dan het veld kan bevatten. Zodra de grootte van een stream dat plafond overschrijdt, is er geen betrouwbare manier om PDFium te vertellen hoe lang het bestand is.

De verkeerde reactie is om de grootte toe te wijzen en deze te laten wrappen. Het afkappen van een lengte van 5 GiB naar een 32-bits veld levert een klein, aannemelijk getal op, waarna PDFium het bestand parseert in de veronderstelling dat het ongeveer na een gigabyte eindigt. De trailer en de kruisverwijzingstabel bevinden zich aan het werkelijke einde van het bestand, ver voorbij de afgekapte lengte. De parse mislukt daardoor op een manier die niets te maken heeft met de echte oorzaak. U zou dan een kruisverwijzingsfout debuggen in een bestand dat volkomen geldig is, zonder enig idee dat een integer twee lagen hoger is gewrapt.

De adapter weigert in plaats daarvan de invoer. De constructor vergelijkt de streamgrootte met High(FPDF_DWORD) and raises EPdfError the instant the stream is too large to describe. An explicit, immediate error names the real problem at the point of construction. A silent truncation hides it behind a misleading symptom you would chase much later. The 4 GiB limit is a genuine constraint of this loading path, and the honest thing is to surface it loudly rather than paper over it with arithmetic that happens to compile.

Fouten mogen de grens niet overschrijden

Een leesactie kan mislukken. De stream kan een netwerkobject zijn dat een time-out veroorzaakt, een blob-handle die onder uw voeten is gesloten, of een bestand dat is afgekapt nadat het document werd geopend. PDFium's contract voor de lees-callback is een retourwaarde: ongelijk aan nul voor succes, nul voor fouten. Het is een C-frame, en het bevat geen mechanisme om een Pascal-uitzondering op te vangen of te propageren.

Dit is de reden waarom de trampoline de seek- en leesbewerkingen verpakt in een try/except die de uitzondering inslikt en nul retourneert. Als een Delphi-uitzondering uit de callback zou mogen ontsnappen, zou deze afwikkelen door PDFium's cdecl-stackframes, die daar nooit voor zijn ontworpen. Dit leidt in het gunstigste geval tot ongedefinieerd gedrag en in het ongunstigste geval tot een harde crash, diep in de PDF-parser zonder bruikbare stack. Het retourneren van nul houdt de fout binnen de afspraken van het contract. PDFium ziet een mislukte blok-leesactie, breekt de bewerking netjes af, en FPDF_LoadCustomDocument rapporteert dat het document niet kon worden geladen. Het component brengt dit vervolgens aan de oppervlakte als een EPdfError aan de Pascal-kant, waar het thuishoort.

Een document op deze manier openen

De componentmethode die het streamende pad aanstuurt is LoadCustomDocument, gedeclareerd als een afzonderlijke methode in plaats van een extra overload van LoadDocument, zodat het doorgeven van een TMemoryStream nooit per ongeluk op het gebufferde pad terechtkomt. Deze bouwt de adapter, roept FPDF_LoadCustomDocument aan en houdt de adapter in leven gedurende de levensduur van het geladen document.

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;

Dezelfde aanroep werkt voor een TMemoryStream, een blob-stream uit een database-dataset of een aangepaste TStream-afstammeling. On-demand laden bewijst zijn nut wanneer het bestand groot is en er slechts een deel van wordt gelezen: een archief-viewer, een thumbnail-generator die een paar pagina's scant of een zoekindex die pagina voor pagina binnenhaalt. Wanneer het bestand klein is of u de volledige inhoud toch gaat lezen, is een gebufferde laadactie eenvoudiger en biedt de stream-functionaliteit geen voordeel. De doorslaggevende factor is de verhouding tussen de bytes die u daadwerkelijk gaan gebruiken en de bytes die het bestand bevat.

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.