Ein gescanntes Archiv kann in einem einzigen PDF mehrere Gigabyte umfassen. Ein Viewer, der eine solche Datei öffnet, möchte in der Regel eine Seite anzeigen, vielleicht das Inhaltsverzeichnis, vielleicht eine Seite, zu der der Benutzer von einem Lesezeichen aus gesprungen ist. Das Einlesen der gesamten Datei in den Speicher, um zwei Seiten zu rendern, ist in jeder Hinsicht verschwenderisch: Es verbraucht Adressraum, es lässt den Benutzer auf einen langen anfänglichen Lesevorgang warten, und bei einem 32-Bit-Delphi-Prozess kann es komplett fehlschlagen, bevor überhaupt eine einzige Seite erscheint. PDFium wurde mit Blick darauf entwickelt. Es kann ein Dokument über einen Callback laden, der genau die benötigten Bytebereiche anfordert, wenn sie gebraucht werden, und es verlangt niemals die gesamte Datei auf einmal. Eine Grenze gehört ganz nach vorne: Dieser Streaming-Kanal beschreibt die Datei mit einer 32-Bit-Länge, bedient also eine einzelne Datei bis zu 4 GiB, was in der Praxis fast jedes gescannte Archiv abdeckt. Eine Datei jenseits dieser Grenze ist nicht das Thema dieses Artikels; sie sollte stattdessen beim Scannen in Volumes aufgeteilt oder über eine Direktzugriffsstrategie geöffnet werden, und die Absicherung, die diese Obergrenze erzwingt, erhält unten ehrlicherweise einen eigenen Abschnitt
Die Komponente macht diesen Pfad über einen Stream-Adapter zugänglich. Sie übergeben ihm einen beliebigen TStream, und PDFium zieht (pulls) bei Bedarf Blöcke aus diesem Stream. Die Datei kann sich auf der Festplatte, in einem Datenbank-Blob-Feld oder hinter einem anderen TStream-Nachkommen befinden, und nichts davon wird im Voraus 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 hat hier drei wichtige Teile: ein Längenfeld, einen Lese-Callback (Read Callback) und einen opaken (opaque) Benutzerparameter. Der Einstiegspunkt, der sie konsumiert, ist FPDF_LoadCustomDocument. Sobald PDFium diese Struktur besitzt, parst es den Trailer, lokalisiert die Querverweistabelle (Cross-Reference Table) und liest von da an nur noch das, was eine bestimmte Operation erfordert. Das Öffnen des Dokuments berührt das Ende der Datei und eine Handvoll Katalogobjekte. Das Rendern der Seite 400 liest die Inhaltsströme und Ressourcen für diese Seite und sonst nichts
Dies ist der Unterschied zwischen einem gepufferten Laden (Buffered Load) und einem streamenden Laden. Ein gepuffertes Laden liest die Datei von Anfang bis Ende, bevor PDFium überhaupt Byte null sieht. Ein streamendes Laden kehrt die Beziehung um: PDFium steuert die Lesevorgänge, und die Bytes, die nie berührt werden, werden auch nie gelesen. Bei einer Multi-Gigabyte-Datei, die seitenweise betrachtet wird, ist das die Lücke zwischen einem unbrauchbaren und einem sofortigen Ladevorgang
Der Stream-Adapter
Der Adapter, der eine Brücke zwischen einem Delphi-TStream und FPDF_FILEACCESS schlägt, ist TPdfStreamAdapter. Sein Konstruktor nimmt den Stream und ein Eigentums-Flag (Ownership Flag) entgegen, erfasst die Streamlänge einmal, füllt den FPDF_FILEACCESS-Record aus und verknüpft den Lese-Callback. Wenn PDFium später mit einem Offset und einer Größe zurückruft, positioniert (seeks) der Adapter den Stream auf diesen Offset und kopiert genau diesen Bereich in den Puffer, den PDFium bereitgestellt hat
// 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 Eigentums-Flag entscheidet, wer den Stream freigibt. Übergeben Sie False, so behält der Aufrufer den Stream und muss ihn für die gesamte Lebensdauer des Dokuments am Leben erhalten. Übergeben Sie True, so ü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-Pointer hält und zu jedem Zeitpunkt zurückrufen wird, solange das Dokument geöffnet ist, nicht nur während des anfänglichen Ladens
Warum der Callback eine statische Funktion ist
Der Lese-Callback, den PDFium in m_GetBlock speichert, ist ein einfacher C-Funktionszeiger mit der Aufrufkonvention cdecl. Eine Delphi-Methode kann nicht direkt verwendet werden, da eine Methode ein verstecktes Self-Argument mit sich führt, von dem ein C-Aufrufer nichts weiß und das er niemals liefern wird. Der Adapter deklariert den Callback daher als eine class function, die mit cdecl; static markiert ist, was zu einer freistehenden Funktion mit dem C-Frame-Layout kompiliert wird, das PDFium erwartet, und ohne implizites Self
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 aufbaut, speichert er seinen eigenen Instanzzeiger in m_Param. PDFium gibt denselben 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 den Objektkontext über eine C-Grenze hinweg zu übergeben, die keine Vorstellung 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-Obergrenze und warum sie eine Absicherung benötigt
Hierher rührt die in der Einleitung erwähnte Grenze. Das Längenfeld m_FileLen in FPDF_FILEACCESS ist ein vorzeichenloser (unsigned) 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 fassen kann. In dem Moment, in dem die Größe eines Streams diese Obergrenze überschreitet, gibt es keine ehrliche Möglichkeit, PDFium mitzuteilen, wie lang die Datei ist
Die falsche Reaktion ist es, die Größe zuzuweisen und sie überlaufen (wrap) zu lassen. Das Abschneiden einer 5-GiB-Länge auf ein 32-Bit-Feld erzeugt eine kleine, plausibel aussehende Zahl, und PDFium wird die Datei dann in dem Glauben parsen, dass sie etwa nach einem Gigabyte endet. Der Trailer und die Querverweistabelle befinden sich am tatsächlichen Ende der Datei, weit hinter der abgeschnittenen Länge, sodass der Parse-Vorgang auf eine Weise fehlschlägt, die nichts mit der eigentlichen Ursache zu tun hat. Sie würden einen Querverweisfehler in einer Datei debuggen, die völlig gültig ist, ohne jeden Hinweis darauf, dass ein Integer zwei Ebenen höher übergelaufen ist
Stattdessen weist der Adapter die Eingabe ab. Der Konstruktor vergleicht die Streamgröße mit High(FPDF_DWORD) und löst EPdfError aus, sobald der Stream zu groß ist, um beschrieben zu werden. Ein expliziter, sofortiger Fehler benennt das eigentliche Problem zum Zeitpunkt der Konstruktion. Ein stillschweigendes Abschneiden verbirgt es hinter einem irreführenden Symptom, dem Sie viel später nachjagen würden. Das 4-GiB-Limit ist eine echte Einschränkung dieses Ladepfads, und das Ehrlichste ist, es lautstark offenzulegen, anstatt es mit einer Arithmetik zu vertuschen, die zufällig kompiliert. Wenn ein Archiv die Grenze tatsächlich überschreitet, liegen die oben versprochenen Abhilfen außerhalb dieser API: Teilen Sie den Scan in Dateien pro Volume auf, die jeweils unter der Obergrenze bleiben, oder belassen Sie das Dokument auf der Festplatte und stellen Sie es über ein Direktzugriffsdesign bereit, das auf 64-Bit-Offsets basiert, anstatt über FPDF_FILEACCESS
Fehler dürfen die Grenze nicht überschreiten
Ein Lesevorgang kann fehlschlagen. Der Stream könnte ein netzwerkgestütztes Objekt sein, das in ein Timeout läuft, 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 verfügt über keine Mechanismen, um eine Pascal-Ausnahme abzufangen oder weiterzuleiten (propagate)
Deshalb wickelt das Trampolin den Such- (seek) und den Lesevorgang in ein try/except, das die Ausnahme schluckt und null zurückgibt. Wenn sich eine Delphi-Ausnahme aus dem Callback heraus ausbreiten (propagate) dürfte, würde sie durch die cdecl-Stack-Frames von PDFium abgewickelt (unwind), die niemals dafür gebaut wurden, von der Pascal-Ausnahmemaschinerie abgewickelt zu werden. Das Ergebnis ist bestenfalls undefiniertes Verhalten und schlimmstenfalls ein harter Absturz, tief im Inneren des PDF-Parsers 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 offenlegt, wo es hingehört
Ein Dokument auf diese Weise öffnen
Die Komponentenmethode, die den Streaming-Pfad steuert, ist LoadCustomDocument, die als eigenständige Methode anstelle einer weiteren LoadDocument-Überladung deklariert ist, sodass die Übergabe eines TMemoryStream niemals 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-Nachkommen. Das On-Demand-Laden macht sich bezahlt, wenn die Datei groß ist und nur ein Teil davon gelesen wird: ein Archiv-Viewer, ein Thumbnail-Generator, der einige Seiten auswertet (samples), ein Suchindex, der Seite für Seite zieht. Wenn die Datei klein ist oder Sie ohnehin alles lesen werden, 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
Sobald Seiten bei Bedarf (on demand) gestreamt werden, besteht das nächste Anliegen darin, gerenderte Seiten reaktionsschnell zu halten, während der Benutzer zoomt und scrollt, was in unserer Notiz zu Render-Caching und Zoom-Performance behandelt wird. Wenn das gestreamte Dokument ein Dokument ist, das ein Viewer anzeigen, aber den Benutzer nicht exportieren oder ändern lassen soll, passen die Techniken in der Anleitung zur sicheren PDF-Vorschau natürlich zu diesem Ladepfad. Beide bauen auf dem hier beschriebenen streamenden Laden auf, das als Teil der PDFium-Komponente für Delphi und C++Builder zusammen mit den APIs für Rendering, Textextraktion und Annotationen ausgeliefert wird, die an anderer Stelle in diesem Blog behandelt werden