Technisch artikel

Enorme PDF's on demand streamen met PDFium in Delphi

Een gescand archief kan oplopen tot meerdere gigabytes in één PDF. Een viewer die zo'n bestand opent, wil meestal één pagina laten zien, misschien de inhoudsopgave, of misschien een pagina waar de gebruiker naartoe is gesprongen via een bladwijzer. Het hele bestand in het geheugen inlezen om twee pagina's te renderen, is verspillend op elk vlak: het verbruikt adresruimte, het laat de gebruiker wachten op een lange initiële inlezing, en bij een 32-bits Delphi-proces kan het ronduit mislukken voordat er éé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, wanneer het deze nodig heeft, en het eist nooit het hele bestand tegelijk op. Eén grens hoort echter voorop te staan: dit streamingkanaal beschrijft het bestand met een 32-bits lengte, dus het bedient een enkel bestand tot maximaal 4 GiB, wat in de praktijk vrijwel elk gescand archief dekt. Een bestand voorbij die grens is niet het terrein van dit artikel; dat wil tijdens het scannen in delen worden gesplitst of via een strategie voor directe toegang worden geopend, en de bewaker die dit plafond afdwingt, krijgt hieronder eerlijk gezegd zijn eigen sectie

De component stelt dat pad bloot via een stream-adapter. U geeft hem een willekeurige TStream, en PDFium haalt on-demand blokken uit die stream. Het bestand kan op de schijf staan, in een blob-veld in een database, of achter elke andere TStream-afstammeling, en niets ervan wordt vooraf in het geheugen gekopieerd

Hoe PDFium om bytes vraagt

De C-API van PDFium laadt een document vanaf een door de aanroeper geleverd object dat wordt beschreven door de structuur FPDF_FILEACCESS. De structuur heeft drie onderdelen die hier van belang zijn: een lengteveld, een read-callback en een ondoorzichtige (opaque) gebruikersparameter. Het startpunt dat dit verbruikt is FPDF_LoadCustomDocument. Zodra PDFium die structuur in handen heeft, ontleedt het de trailer, lokaliseert het de kruisverwijzingstabel (cross-reference table), en leest vanaf dan alleen wat een bepaalde bewerking vereist. Het openen van het document raakt de staart van het bestand aan en een handvol catalogusobjecten. Het renderen van pagina 400 leest de inhoudsstromen en bronnen voor die pagina en verder niets

Dit is het verschil tussen een gebufferde lading en een streaming lading. Een gebufferde lading leest het bestand van begin tot eind in voordat PDFium byte nul ziet. Een streaming lading keert de verhouding om: PDFium stuurt de inleesbewerkingen aan, en de bytes die nooit worden aangeraakt, worden nooit ingelezen. Voor een bestand van meerdere gigabytes dat pagina voor pagina wordt bekeken, is dat de kloof tussen een onbruikbare lading en een onmiddellijke lading

De stream-adapter

De adapter die de brug slaat tussen een Delphi TStream en FPDF_FILEACCESS is TPdfStreamAdapter. De constructor neemt de stream en een eigendomsvlag aan, legt eenmalig de stroomlengte vast, vult het FPDF_FILEACCESS-record in, en koppelt de read-callback. Wanneer PDFium later terugbelt met een offset en een grootte, zoekt de adapter (seek) de stream naar die offset en kopieert exact dat bereik naar de buffer die PDFium heeft opgegeven

// 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 eigendomsvlag bepaalt wie de stream vrijmaakt. Geef False door en de aanroeper behoudt de stream en moet deze in leven houden gedurende de gehele levensduur van het document. Geef True door en de adapter neemt het over, waardoor de stream wordt vrijgegeven wanneer het document wordt gesloten. In beide gevallen moet de stream elke inleesbewerking die PDFium zal uitvoeren overleven, omdat PDFium de pointer FPDF_FILEACCESS vasthoudt en op elk willekeurig moment zal terugbellen terwijl het document open is, niet alleen tijdens de initiële lading

Waarom de callback een statische functie is

De read-callback die PDFium opslaat in m_GetBlock is een gewone C-functiepointer met de aanroepconventie cdecl. Een Delphi-methode kan niet direct worden gebruikt, omdat een methode een verborgen Self-argument meedraagt waar een C-aanroeper niets van weet en nooit zal opgeven. De adapter declareert de callback daarom als een class function, gemarkeerd met cdecl; static, die compileert naar een vrijstaande functie met de C-frame lay-out die PDFium verwacht en geen impliciete Self

Dat lost de aanroepconventie op, maar roept een tweede vraag op: zonder Self, hoe bereikt de callback de specifieke stream waaruit hij verondersteld wordt te lezen? Het antwoord is de ondoorzichtige gebruikersparameter. Wanneer de adapter het record bouwt, 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 stuurt de inleesbewerking naar de stream van die instantie. Dit is de standaard trampoline voor het overdragen van objectcontext over een C-grens die geen besef heeft van objecten

// 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 plafond van 4 GiB en waarom het een bewaker nodig heeft

Hier komt de grens vandaan die in de inleiding is genoemd. Het lengteveld m_FileLen in FPDF_FILEACCESS is een 32-bits (unsigned) getal zonder teken. De grootste lengte die hiermee kan worden weergegeven, is één byte korter dan 4 GiB. Een TStream rapporteert zijn grootte als een Int64, dus een stream kan veel meer bytes beschrijven dan het veld kan bevatten. Op het moment dat de grootte van een stream dat plafond overschrijdt, is er geen eerlijke manier om PDFium te vertellen hoe lang het bestand is

De verkeerde reactie is om de grootte toe te wijzen en deze te laten overlopen (wrap). Het afkappen van een 5 GiB lengte naar een 32-bits veld levert een klein, plausibel klinkend getal op, en PDFium zal vervolgens het bestand ontleden in de overtuiging dat het na ongeveer een gigabyte eindigt. De trailer en kruisverwijzingstabel bevinden zich aan het werkelijke einde van het bestand, ver voorbij de afgekorte lengte, dus de ontleding mislukt op een manier die niets te maken heeft met de werkelijke oorzaak. U zou bezig zijn met het debuggen van een kruisverwijzingsfout op een bestand dat perfect geldig is, zonder enige aanwijzing dat een integer twee lagen hoger is overgelopen

De adapter weigert in plaats daarvan de invoer. De constructor vergelijkt de grootte van de stream met High(FPDF_DWORD) en werpt EPdfError op het moment dat de stream te groot is om te beschrijven. Een expliciete, onmiddellijke fout benoemt het daadwerkelijke probleem op het moment van constructie. Een stille inkorting verbergt het achter een misleidend symptoom waar u veel later achteraan zou jagen. De limiet van 4 GiB is een oprechte beperking van dit inleespad, en het eerlijkste is om dit luidkeels naar boven te halen in plaats van het te verdoezelen met rekenkunde die toevallig compileert. Wanneer een archief daadwerkelijk de grens overschrijdt, bevinden de in het begin beloofde oplossingen zich buiten deze API: splits de scan in per-volume bestanden die elk onder het plafond blijven, of laat het document op de schijf staan en serveer het via een ontwerp voor directe toegang gebouwd op 64-bits offsets in plaats van via FPDF_FILEACCESS

Mislukkingen mogen de grens niet overschrijden

Een inleesbewerking kan mislukken. De stream kan een door een netwerk ondersteund object zijn waarvan de tijdslimiet overschreden is, een blob-handle die onder u is gesloten, of een bestand dat werd afgekapt nadat het document was geopend. Het contract van PDFium voor de read-callback is een retourwaarde: niet-nul voor succes, nul voor falen. Het is een C-frame en het beschikt niet over de machinerie om een Pascal-uitzondering af te vangen of voort te planten

Dit is waarom de trampoline de seek en de inleesbewerking inpakt in een try/except-blok dat de uitzondering inslikt en nul retourneert. Als een Delphi-uitzondering zich vanuit de callback naar buiten zou mogen voortplanten, zou deze afrollen (unwind) door de cdecl-stackframes van PDFium, die nooit gebouwd zijn om te worden afgerold door het Pascal-uitzonderingsmechanisme. Het resultaat is in het beste geval ongedefinieerd gedrag en in het slechtste geval een harde crash, diep binnen de PDF-parser, met een onbruikbare stack. Het retourneren van nul houdt de mislukking binnen het contract. PDFium ziet een mislukte blok-inleesbewerking, breekt de operatie netjes af, en FPDF_LoadCustomDocument rapporteert dat het document niet kon worden geladen, wat de component naar boven haalt als een EPdfError aan de Pascal-zijde, waar het thuishoort

Een document op deze manier openen

De componentmethode die het streamingpad aanstuurt is LoadCustomDocument, gedeclareerd als een afzonderlijke methode in plaats van een andere LoadDocument overload, zodat het doorgeven van een TMemoryStream nooit per ongeluk op het gebufferde pad terechtkomt. Het 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 van een database-dataset, of een aangepaste TStream-afstammeling. On-demand inlezen verdient zichzelf terug wanneer het bestand groot is en slechts een deel ervan gelezen zal worden: een archiefviewer, een miniatuurgenerator die een paar pagina's bemonstert, of een zoekindex die pagina voor pagina binnenhaalt. Wanneer het bestand klein is, of als u toch alles gaat inlezen, is een gebufferde lading eenvoudiger en levert de streamingmachinerie u niets op. De beslissende factor is de verhouding tussen de bytes die u daadwerkelijk gaat aanraken en de bytes die het bestand bevat

Zodra pagina's on demand binnenstromen, is de volgende zorg het responsief houden van gerenderde pagina's terwijl de gebruiker inzoomt en scrolt, wat wordt behandeld in onze notitie over render-caching en zoomprestaties. Wanneer het gestreamde document er een is dat een viewer moet weergeven, maar de gebruiker niet mag laten exporteren of wijzigen, passen de technieken in de walkthrough voor veilige PDF-voorbeeldweergave op natuurlijke wijze bij dit laadpad. Beide bouwen voort op de hier beschreven streaming lading, die wordt geleverd als onderdeel van de PDFium-component voor Delphi en C++Builder, naast de API's voor rendering, tekstextractie en annotatie die elders op deze blog worden behandeld