Technical Article

Streaming riesiger PDFs bei Bedarf mit PDFium in Delphi

Ein gescanntes Archiv kann in einem einzigen PDF mehrere Gigabyte groß sein. Ein Viewer, der eine solche Datei öffnet, möchte normalerweise eine Seite anzeigen, vielleicht das Inhaltsverzeichnis oder eine Seite, zu der der Benutzer über ein Lesezeichen gesprungen ist. Das Einlesen der gesamten Datei in den Speicher zum Rendern zweier Seiten ist in jeder Hinsicht verschwenderisch: Es verbraucht Adressraum, blockiert den Benutzer durch einen langen anfänglichen Lesevorgang und kann bei einem 32-Bit-Delphi-Prozess fehlschlagen, bevor überhaupt eine einzige Seite angezeigt wird. PDFium wurde genau dafür entwickelt. Es kann ein Dokument über einen Callback laden, der die spezifischen Bytebereiche abfragt, die es benötigt, wenn es sie benötigt, und verlangt nie die gesamte Datei auf einmal

Die Komponente macht diesen Pfad über einen Stream-Adapter zugänglich. Sie übergeben ihr einen beliebigen TStream, und PDFium ruft bei Bedarf Blöcke aus diesem Stream ab. Die Datei kann sich auf der Festplatte, in einem Blob-Feld einer Datenbank oder hinter jedem anderen TStream-Nachfahren befinden, und nichts davon wird vorab in den Speicher kopiert

Wie PDFium nach Bytes fragt

Die C-API von PDFium lädt ein Dokument aus einem vom Aufrufer bereitgestellten Objekt, das durch die Struktur FPDF_FILEACCESS beschrieben wird. Die Struktur besteht aus drei Teilen, die hier von Bedeutung sind: einem Längenfeld, einem Lese-Callback und einem opaken Benutzerparameter. Der Einstiegspunkt, der sie verbraucht, ist FPDF_LoadCustomDocument. Sobald PDFium diese Struktur besitzt, parst es den Trailer, lokalisiert die Kreuzreferenztabelle und liest fortan nur noch das, was eine bestimmte Operation erfordert. Das Öffnen des Dokuments berührt das Ende der Datei und eine Handvoll Katalogobjekte. Das Rendern von Seite 400 liest die Content-Streams und Ressourcen für diese Seite und nichts anderes

Dies ist der Unterschied zwischen einem gepufferten und einem Streaming-Ladevorgang. Ein gepufferter Ladevorgang liest die Datei von Ende zu Ende, bevor PDFium das Byte Null sieht. Ein Streaming-Ladevorgang kehrt diese Beziehung um: PDFium steuert die Lesevorgänge, und Bytes, die nie berührt werden, werden auch nie gelesen. Bei einer mehrere Gigabyte großen Datei, die Seite für Seite betrachtet wird, ist dies der Unterschied zwischen einem unbrauchbaren Ladevorgang und einem sofortigen

Der Stream-Adapter

Der Adapter, der einen Delphi-TStream mit FPDF_FILEACCESS verbindet, ist TPdfStreamAdapter. Sein Konstruktor nimmt den Stream und ein Besitzflag entgegen, erfasst die Stream-Länge einmal, füllt den FPDF_FILEACCESS-Record aus und verdrahtet den Lese-Callback. Wenn PDFium später mit einem Offset und einer Größe zurückruft, der Adapter den Stream auf diesen Offset positioniert und kopiert genau diesen Bereich in den von PDFium bereitgestellten Puffer

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

Das Besitzflag entscheidet, wer den Stream freigibt. Übergeben Sie False, behält der Aufrufer den Stream und muss ihn für die gesamte Lebensdauer des Dokuments am Leben erhalten. Übergeben Sie True, übernimmt der Adapter und gibt den Stream frei, wenn das Dokument geschlossen wird. In jedem Fall muss der Stream jeden Lesevorgang überdauern, den PDFium ausführt, da PDFium den FPDF_FILEACCESS-Zeiger hält und während der gesamten Öffnungszeit des Dokuments zurückrufen kann, nicht nur beim ersten Laden

Warum der Callback eine statische Funktion ist

Der in m_GetBlock gespeicherte Lese-Callback ist ein einfacher C-Funktionszeiger mit der Aufrufkonvention cdecl. Eine Delphi-Methode kann nicht direkt verwendet werden, da eine Methode ein verstecktes Self-Argument trägt, von dem ein C-Aufrufer nichts weiß und das er niemals bereitstellen wird. Der Adapter deklariert den Callback daher als eine als cdecl; static gekennzeichnete class function, was zu einer freistehenden Funktion mit dem von PDFium erwarteten C-Frame-Layout und ohne implizites Self kompiliert wird

Das löst die Aufrufkonvention, wirft aber eine zweite Frage auf: Wie erreicht der Callback ohne Self den spezifischen Stream, aus dem er lesen soll? Die Antwort ist der opake Benutzerparameter. Wenn der Adapter den Record erstellt, speichert er seinen eigenen Instanzzeiger in m_Param. PDFium übergibt genau diesen Zeiger als erstes Argument jedes Callbacks zurück. Die statische Funktion wandelt ihn wieder in einen TPdfStreamAdapter um und leitet den Lesevorgang an den Stream dieser Instanz weiter. Dies ist das Standard-Trampolin, um Objektkontext über eine C-Grenze hinweg zu übergeben, die kein Konzept von Objekten hat

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

Die 4-GiB-Grenze und warum sie einen Schutz benötigt

Das Längenfeld m_FileLen in FPDF_FILEACCESS ist ein vorzeichenloser 32-Bit-Wert. Seine größte darstellbare Länge ist ein Byte weniger als 4 GiB. Ein TStream meldet seine Größe als Int64, sodass ein Stream weit mehr Bytes beschreiben kann, als das Feld aufnehmen kann. In dem Moment, in dem die Größe eines Streams diese Obergrenze überschreitet, gibt es keinen ehrlichen Weg mehr, PDFium mitzuteilen, wie lang die Datei ist

Die falsche Reaktion besteht darin, die Größe zuzuweisen und sie überlaufen zu lassen. Das Abschneiden einer Länge von 5 GiB auf ein 32-Bit-Feld erzeugt eine kleine, plausibel aussehende Zahl, und PDFium parst die Datei dann in dem Glauben, sie ende etwa bei einem Gigabyte. Der Trailer und die Kreuzreferenztabelle befinden sich jedoch am tatsächlichen Ende der Datei, weit hinter der abgeschnittenen Länge, sodass der Parse-Vorgang aus einem Grund fehlschlägt, der nichts mit der tatsächlichen Ursache zu tun hat. Sie würden einen Kreuzreferenzfehler in einer Datei debuggen, die vollkommen gültig ist, ohne jeden Hinweis darauf, dass ein Integer zwei Ebenen höher übergelaufen ist

Der Adapter lehnt stattdessen die Eingabe ab. Der Konstruktor vergleicht die Stream-Größe mit High(FPDF_DWORD) und löst im selben Moment einen EPdfError aus, in dem der Stream zu groß zur Beschreibung ist. Ein expliziter, sofortiger Fehler benennt das tatsächliche Problem zum Zeitpunkt der Konstruktion. Ein stillschweigendes Abschneiden verbirgt es hinter einem irreführenden Symptom, dem Sie erst viel später nachjagen würden. Die 4-GiB-Grenze ist eine echte Einschränkung dieses Ladepfads, und es ist ehrlich, sie lautstark anzuzeigen, anstatt sie mit Arithmetik zu übertünchen, die zufällig kompiliert wird

Fehler dürfen die Grenze nicht überschreiten

Ein Lesevorgang kann fehlschlagen. Der Stream könnte ein netzwerkgestütztes Objekt sein, das ein Timeout auslöst, ein Blob-Handle, das unter Ihnen geschlossen wurde, oder eine Datei, die nach dem Öffnen des Dokuments abgeschnitten wurde. Der Vertrag von PDFium für den Lese-Callback ist ein Rückgabewert: ungleich Null für Erfolg, Null für Fehler. Es ist ein C-Frame, und er verfügt über keinerlei Mechanismen, um eine Pascal-Exception abzufangen oder weiterzuleiten

Deshalb umschließt das Trampolin das Positionieren und Lesen in einem try/except, das die Ausnahme schluckt und Null zurückgibt. Wenn sich eine Delphi-Ausnahme aus dem Callback heraus fortpflanzen dürfte, würde sie sich durch die cdecl-Stackframes von PDFium abwickeln, die nie dafür gebaut wurden, von der Pascal-Ausnahmemaschine abgewickelt zu werden. Das Ergebnis ist im besten Fall undefiniertes Verhalten und im schlimmsten Fall ein harter Absturz tief im PDF-Parser ohne brauchbaren Stack. Die Rückgabe von Null hält den Fehler innerhalb des Vertrags. PDFium sieht einen fehlgeschlagenen Blocklesevorgang, bricht die Operation sauber ab, und FPDF_LoadCustomDocument meldet, dass das Dokument nicht geladen werden konnte, was die Komponente als EPdfError auf der Pascal-Seite an die Oberfläche bringt, wo es hingehört

Öffnen eines Dokuments auf diesem Weg

Die Komponentenmethode, die den Streaming-Pfad steuert, ist LoadCustomDocument, die als eigenständige Methode deklariert ist und nicht als weitere Überladung von LoadDocument, damit die Übergabe eines TMemoryStream nie versehentlich auf dem gepufferten Pfad landet. Sie baut den Adapter auf, ruft FPDF_LoadCustomDocument auf und hält den Adapter für die Lebensdauer des geladenen Dokuments am Leben

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;

Derselbe Aufruf funktioniert für einen TMemoryStream, einen Blob-Stream aus einem Datenbank-Dataset oder einen benutzerdefinierten TStream-Nachfahren. Das Laden bei Bedarf lohnt sich, wenn die Datei groß ist und nur ein Teil davon gelesen wird: ein Archiv-Viewer, ein Thumbnail-Generator, der einige Seiten stichprobenartig erfasst, oder ein Suchindex, der jeweils eine Seite abruft. Wenn die Datei klein ist oder Sie sowieso alles lesen, ist ein gepuffertes Laden einfacher und die Streaming-Maschinerie bringt Ihnen keinen Vorteil. Der entscheidende Faktor ist das Verhältnis der Bytes, die Sie tatsächlich berühren werden, zu den Bytes, die die Datei enthält

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. Wenn das gestreamte Dokument ein Dokument ist, das ein Viewer anzeigen, aber den Benutzer nicht exportieren oder ändern lassen sollte, lassen sich die Techniken in the secure PDF preview walkthrough hervorragend mit diesem Ladepfad kombinieren. Beide bauen auf dem hier beschriebenen Streaming-Ladevorgang auf, der als Teil der PDFium Component für Delphi und C++Builder zusammen mit den Rendering-, Textextraktions- und Anmerkungs-APIs ausgeliefert wird, die an anderer Stelle auf diesem Blog behandelt werden