Technischer Artikel

PDFium-Byte-Range-Laden für eingebettete PDFs in Delphi

Die PDFium-Komponente kann eine PDF-Datei, die innerhalb eines größeren Puffers liegt, direkt aus einem Byte-Bereich öffnen. Die Überladung LoadDocument(const Data: TBytes; Index, Count: Integer; Buffered: Boolean) adressiert ein Fenster an Ort und Stelle, sodass kein vorbereitendes Copy nötig ist. Im Gegenzug verlangt sie, eine Regel zu verstehen: Ist Buffered False, wird das zugrunde liegende Array geliehen, nicht kopiert

Das ist ein anderer Mechanismus als der callback-gesteuerte Ansatz, beschrieben in Streaming großer PDF-Dateien bei Bedarf mit PDFium VCL, der PDFium einen FPDF_FILEACCESS-Reader übergibt und ihn Blöcke von der Platte ziehen lässt, wenn sie gebraucht werden. Jener eignet sich für Dokumente, die zu groß sind, um im RAM gehalten zu werden. Dieser hier eignet sich für Dokumente, die bereits im RAM liegen, an einem bekannten Offset innerhalb von etwas anderem. Die beiden ergänzen sich, und der letzte Abschnitt erklärt, welche Situation zu welchem gehört

Die 40-MB-Kopie, um die niemand gebeten hat

Das Szenario taucht überall dort auf, wo PDF-Dateien innerhalb anderer Formate reisen. Ein Mail-Speicher hält Nachrichtentexte und Anhänge in einem Datensatz. Ein Archiv-Container reiht ein Manifest, ein paar Bilder und eine PDF-Datei aneinander. Ein eigenes Wire-Protokoll rahmt ein Dokument hinter einem längenpräfixierten Header ein. In jedem Fall hält man am Ende ein großes TBytes und weiß, dass die PDF-Datei bei Byte 1.182.336 beginnt und 312 Kilobyte lang ist

Bevor die Byte-Range-Überladung existierte, war die naheliegende Antwort Copy(Data, Index, Count), was ein zweites Array alloziert und das Fenster hineinkopiert. Dieses Segment übergibt man dann mit Buffered = True an LoadDocument, was es erneut in den privaten Puffer der Komponente kopiert. Zwei Kopien derselben Bytes, eine davon reine Formsache, und bei einem großen Postfach-Scan für jede Nachricht wiederholt. Die Byte-Range-Überladung entfernt die erste Kopie bedingungslos und die zweite optional

Was die Byte-Range-Überladung tatsächlich tut

Die Überladung ist bewusst schlank: Sie validiert, berechnet einen Zeiger und delegiert an die Zeiger-Form von LoadDocument, durch die die gesamte Familie ohnehin schon läuft. Index ist nullbasiert, Count ist eine Byte-Länge, und Buffered ist standardmäßig True, genau wie bei den anderen Überladungen. Die einargumentige LoadDocument(const Data: TBytes; Buffered: Boolean) ist selbst nun nur noch ein Aufruf dieser einen mit Index = 0 und Count = Length(Data), sodass es einen Validierungspfad gibt statt zwei

Der Aufruf sieht aus wie der Code, den man ohnehin schon geschrieben hat, minus das Slicing

var
  Frame: TBytes;          // whole container record, tens of megabytes
  Offset, Size: Integer;
begin
  Frame := LoadContainerRecord('mailbox.dat');
  LocateEmbeddedPdf(Frame, Offset, Size);   // your container parser

  // No Copy(Frame, Offset, Size) here - the window is addressed in place
  Pdf.LoadDocument(Frame, Offset, Size, True);
  try
    RenderPreview(Pdf);
  finally
    Pdf.UnloadDocument;
  end;
end;

Warum lässt Index plus Count die Grenzprüfung überlaufen?

Weil Index und Count beide Integer sind, und die Summe zweier großer positiver Integer-Werte nicht notwendigerweise ein großer positiver Integer ist. Das ist der technische Kern der Überladung, und es ist die eine Stelle, an der eine natürlich wirkende Prüfung ein Loch in der Speichersicherheit ist. Die naheliegende Formulierung ist falsch

// WRONG: Index + Count is evaluated in Integer and can wrap negative
if Index + Count <= Length(Data) then
  DataPtr := @Data[Index];

// RIGHT: reject signs first, then bound each term separately,
// with the only arithmetic done as a subtraction that cannot wrap
Check(Index >= 0,  'PDF byte range index cannot be negative');
Check(Count >= 0,  'PDF byte range count cannot be negative');
Check(Index <= Length(Data), 'PDF byte range index exceeds data length');
Check(Count <= Length(Data) - Index, 'PDF byte range exceeds data length');

Man rechne den fehlschlagenden Fall durch. Nimmt man Index = 2000000000 und Count = 2000000000, ist ihre wahre Summe vier Milliarden, aber in 32-Bit-Ganzzahlarithmetik mit Vorzeichen läuft das Ergebnis auf genau minus 294.967.296 über. Dieser Wert ist bequem kleiner als Length(Data), sodass die falsche Prüfung besteht, @Data[Index] weit außerhalb des Arrays genommen wird, und PDFium einen wilden Zeiger plus eine Zwei-Gigabyte-Länge übergeben bekommt. Was folgt, ist an einem guten Tag eine Zugriffsverletzung und an einem schlechten Tag stilles Parsen von unbeteiligtem Prozessspeicher

Die korrekte Reihenfolge behebt das, indem sie nie addiert. Negative Werte werden zurückgewiesen, bevor überhaupt indiziert wird, sodass @Data[Index] nie unterhalb des Arrays genommen werden kann. Dann wird Index für sich gegen Length(Data) begrenzt, was garantiert, dass Length(Data) - Index ein nicht-negativer Integer ist. Erst dann wird Count gegen diesen Rest verglichen. Jeder Zwischenwert bleibt innerhalb des darstellbaren Bereichs, sodass keine Build-Konfiguration das Ergebnis ändern kann. Man sollte sich auch nicht verleiten lassen, sich auf die Überlaufprüfung {$Q+} als Sicherheitsnetz zu verlassen: Release-Builds werden routinemäßig ohne sie ausgeliefert, und selbst wenn sie aktiv ist, hat man einen Speichersicherheitsfehler nur in eine EIntOverflow verwandelt, die mitten aus einer Validierungsroutine entweicht. Die PDFium-Komponente behandelt nicht vertrauenswürdige Längenarithmetik genauso wie den Rest der Grenze, eine Disziplin, die umfassender in Härtung der PDFium-VCL-ABI und Speichersicherheit in Delphi behandelt wird

Warum muss ein Fenster der Länge null nil übergeben?

Weil @Data[Index] nicht für jeden von der Validierung akzeptierten Index ein legaler Ausdruck ist. Index = Length(Data) mit Count = 0 ist ein vollkommen wohlgeformtes leeres Fenster am Ende des Puffers, und ein leeres TBytes ergibt Index = 0 bei einem Array, das gar kein Element null hat. Die Adresse in beiden Fällen zu nehmen, indiziert entweder über das Ende hinaus oder dereferenziert ein nil-dynamisches Array. Die Überladung verzweigt daher: Count = 0 ergibt einen nil-Zeiger, jeder andere Count ergibt @Data[Index]. Das nil fließt dann in die Zeiger-Überladung, deren eigene Absicherung einen nil-Zeiger akzeptiert, wenn die Größe null ist, und das Laden endet im gewöhnlichen Fehler "Cannot load PDF document" statt in einer Zugriffsverletzung. Ein Aufrufer, der aus einem fehlerhaften Container ein Null-Byte-Fenster berechnet hat, bekommt eine saubere, abfangbare EPdfError wie jede andere fehlerhafte Eingabe

Geliehen oder kopiert: was Buffered entscheidet

Buffered wählt den Eigentumsvertrag, und es ist der einzige Parameter hier mit Konsequenzen über den Aufruf hinaus. Mit Buffered = True kopiert die PDFium-Komponente das ausgewählte Fenster, und nur das Fenster, in ihren internen Puffer, bevor geladen wird. Der 40-MB-Container wird nicht kopiert; die 312-KB-PDF-Datei schon. Sobald LoadDocument zurückkehrt, darf man den Container sofort freigeben, wiederverwenden oder überschreiben, weil die Komponente ihn nicht mehr referenziert. Das ist die Vorgabe und die richtige Wahl für nahezu jeden Code

Buffered = False übergibt @Data[Index] direkt an FPDF_LoadMemDocument64, und PDFium behält diesen Zeiger für die Lebensdauer des Dokuments, statt die Bytes zu kopieren. Das macht das Laden allokationsfrei, und es macht das gesamte zugrunde liegende TBytes zu einer geliehenen Ressource. Es muss lebendig und unverändert bleiben, bis UnloadDocument läuft oder Active auf False geht. Nicht das Fenster, das ganze Array: Ein dynamisches Array ist als Einheit referenzgezählt, und die letzte Referenz irgendwo im eigenen Code loszulassen, gibt den Speicher frei, den PDFium noch liest. Length darauf zu setzen, ist genauso fatal, weil eine Reallokation den Block verschieben kann. Man dokumentiert das in der eigenen API-Dokumentation überall dort, wo man ein solches Laden anbietet, im selben Geist wie jede andere Leih-versus-Besitz-Grenze in Pascal-Code; der Fehlermodus ist identisch mit den Aliasing-Gefahren, beschrieben in dem FillChar- und Ergebnis-String-Leck in Delphi, wo ein Puffer besessen aussieht und es nicht ist

type
  TFrameSession = class
  private
    FFrame: TBytes;   // owns the backing storage for as long as FPdf is loaded
    FPdf: TPdf;
  public
    procedure OpenEmbedded(Offset, Size: Integer);
    destructor Destroy; override;
  end;

procedure TFrameSession.OpenEmbedded(Offset, Size: Integer);
begin
  // Buffered = False: FFrame must outlive the loaded document
  FPdf.LoadDocument(FFrame, Offset, Size, False);
end;

destructor TFrameSession.Destroy;
begin
  FPdf.UnloadDocument;   // release the borrow first
  FFrame := nil;         // only now may the storage go
  inherited;
end;

Wenn das Byte-Range-Fenster das falsche Werkzeug ist

Man sollte ehrlich über die Grenze sein. Die Byte-Range-Überladung setzt voraus, dass der Container bereits vollständig im Speicher liegt, und Count ist ein Integer, sodass ein einzelnes Fenster zwei Gigabyte nicht überschreiten kann. Ist der Container ein 6-GB-Archiv auf der Platte, oder trifft er über einen Socket ein, den man nicht zurückspulen kann, hilft diese Überladung nicht weiter, und das Ganze in TBytes einzulesen, nur um ein Fenster darin zu adressieren, verfehlt den Sinn. Genau dort gehört der FPDF_FILEACCESS-Pfad hin, und der Artikel zum On-Demand-Streaming zeigt, wie man eine offsetverschobene Sicht auf eine Datei als eigene Dokumentquelle offenlegt. Ebenso, wenn die eingebetteten Bytes vor der Übergabe an PDFium transformiert werden müssen, Dekomprimierung, Entschlüsselung, ein Unwrapping-Schritt, dann ist eine echte Kopie unvermeidbar, und Buffered = True auf dem transformierten Array ist die ehrliche Antwort. Das Byte-Range-Fenster zahlt sich in genau einer Form aus: zusammenhängende, unveränderte PDF-Bytes, bereits resident, an einem bekannten Offset

Wer das für einen Viewer, ein Vorschaufenster oder eine Batch-Intake-Pipeline evaluiert: Die Byte-Range-Überladung und der Streaming-Loader sind zwei der Ladestrategien, die die PDFium-Komponente neben Datei-, Stream- und Rohzeiger-Laden bereitstellt. Die vollständige API-Oberfläche, Lizenzierung und Unterstützung für Delphi- und C++Builder-Versionen sind auf der Produktseite der PDFium-Komponente dokumentiert