Technischer Artikel

PDFium Progressive Download und Cancel in Delphi (FPDFAvail)

Die PDFium Component öffnet ein PDF, das noch lädt, über TPdfProgressiveDocument, eine TPdf-Unterklasse, die PDFiums FPDFAvail_*-Availability-API umschließt. BeginProgressiveLoad startet die Session, CheckDocumentAvailability meldet, welche Byte-Bereiche PDFium noch braucht, OpenProgressiveDocument öffnet die Datei, sobald genug Bytes existieren, und CancelProgressiveLoad verwirft einen unterbrochenen Download, ohne native Handles zu leaken. Das Schwierige ist nicht der Happy Path. Ein Viewer auf einer wackligen Verbindung erlebt Nutzer, die bei 25 Prozent den Tab schließen, es sich anders überlegen und denselben Link wieder öffnen – und jede dieser abgebrochenen Sessions hat ein natives Availability-Handle, zwei C-Callback-Records, einen Stream-Adapter und eine Reihe in-flight Range-Requests, die in exakt der richtigen Reihenfolge freigegeben werden müssen

Wie lädt TPdfProgressiveDocument ein PDF, das noch heruntergeladen wird?

TPdfProgressiveDocument hält einen PDFium-Availability-Provider am Leben, während ein Random-Access-Stream gefüllt wird, und fragt diesen Provider vor jedem Parse-Schritt, ob die Bytes, die er will, vorhanden sind. BeginProgressiveLoad(AStream, AFileSize, AOwnsStream, AInitialAvailableByteCount) nimmt den tragenden Stream plus die logische Größe der entfernten Datei, verdrahtet einen IsDataAvail-Callback und einen AddSegment-Callback in zwei Records und ruft FPDFAvail_Create. Fragt PDFium, ob ein Bereich vorhanden ist, antwortet die Komponente mit Ja, wenn der Bereich im kontinuierlichen Präfix liegt, das AvailableByteCount beschreibt, oder in einem Bereich, der bereits über den RangeRequests-Scheduler fertiggestellt wurde, und das OnDataAvailable-Event kann das Urteil für Sparse-Stores überschreiben. Jeder Aufruf von CheckDocumentAvailability liefert einen von drei TPdfDataAvailability-Werten (pdaAvailable, pdaNotAvailable, pdaError) und gibt die Bereiche, nach denen PDFium gefragt hat, als sortiertes, verschmolzenes TPdfDownloadRanges-Array zurück, bereits mit rrpImmediate-Priorität auf dem Scheduler eingereiht

// FetchRange ist Ihr Transport (HTTP-Range-GET, Socket, Blob-Reader):
// er schreibt Size Bytes an Offset in Store und liefert, wie viele ankamen
function FetchRange(Store: TStream; Offset, Size: UInt64): UInt64; forward;

procedure OpenWhileDownloading(Pdf: TPdfProgressiveDocument; Store: TStream;
  RemoteSize: UInt64);
const
  MaxRounds = 64;
var
  Hints: TPdfDownloadRanges;
  State: TPdfDataAvailability;
  Request: TPdfRangeRequest;
  Round: Integer;
begin
  Pdf.BeginProgressiveLoad(Store, RemoteSize, False);
  State := pdaNotAvailable;
  for Round := 1 to MaxRounds do
  begin
    State := Pdf.CheckDocumentAvailability(Hints);
    if State <> pdaNotAvailable then
      Break;
    // Die Hints sind bereits eingereiht; erst die Bytes schreiben, dann abschließen
    while Pdf.RangeRequests.TryDequeue(Request) do
      Pdf.RangeRequests.CompleteRequest(Request,
        FetchRange(Store, Request.Offset, Request.Size));
  end;
  if State <> pdaAvailable then
    raise EPdfError.Create('The document could not be discovered');
  Pdf.OpenProgressiveDocument;
end;

Zwei Details in dieser Schleife sind tragend. Die Rundengrenze zählt, weil ein toter Link CheckDocumentAvailability für immer nach denselben Bereichen fragen lässt und eine unbegrenzte Schleife aus einem Netzwerkfehler eine hängende UI macht. Die Reihenfolge zählt, weil der Scheduler seinen eigenen Zustand mit einer Critical Section serialisiert, aber nichts für TStream.Position auf dem tragenden Store tut: Ein Transport-Thread muss die Antwort-Bytes in den Stream schreiben, bevor er CompleteRequest aufruft, denn in dem Moment, in dem eine Fertigstellung veröffentlicht wird, darf PDFium diesen Bereich lesen, und nebenläufige Writer brauchen positioniertes I/O oder ein eigenes Lock

Die Availability-Schleife von TPdfProgressiveDocument in PDFium Component: BeginProgressiveLoad erzeugt den FPDFAvail-Provider, CheckDocumentAvailability gibt sortierte, verschmolzene Download-Hints mit rrpImmediate-Priorität zurück, der Transport schreibt Bytes in den Store, bevor CompleteRequest jeden Bereich an PDFium veröffentlicht, und die Schleife ist bei 64 Runden gedeckelt, weil ein toter Link immer wieder nach denselben Bereichen fragt
Erst die Bytes schreiben, dann die Anfrage abschließen: In dem Moment, in dem eine Fertigstellung veröffentlicht wird, darf PDFium diesen Bereich lesen, und nichts schützt die Stream-Position für Sie

Warum weigert sich AvailableByteCount, rückwärts zu gehen?

AvailableByteCount wächst nur, und der Setter wirft ein EPdfError mit „Available byte count cannot move backwards“, wenn Sie ihn schrumpfen lassen wollen. Sobald der IsDataAvail-Callback PDFium gesagt hat, dass ein Bereich existiert, hat der Parser daraus möglicherweise bereits Objekte gelesen und gecacht, also würde das spätere Zurückziehen dieser Bytes die Availability-Antworten mit dem inkonsistent machen, was PDFium schon konsumiert hat. Derselbe Setter weist Werte über LogicalFileSize hinaus zurück und wirft außerhalb einer Session „No progressive load is active“ – deshalb gehören Bytes, die Sie schon vor dem Start des Ladens halten, in das AInitialAvailableByteCount-Argument von BeginProgressiveLoad statt in eine zu früh gemachte Property-Zuweisung. Füllt sich Ihr Download-Store ungeordnet, versuchen Sie das gar nicht erst über das Präfix auszudrücken: Schließen Sie die Bereiche über den Scheduler ab oder antworten Sie über OnDataAvailable

Wann kann ein teilweise heruntergeladenes PDF tatsächlich öffnen?

Nur ein linearisiertes PDF (ISO 32000-1 Anhang F, das „Fast Web View“-Layout) öffnet, bevor die ganze Datei angekommen ist; ein nicht linearisiertes PDF braucht weiterhin jedes Byte. OpenProgressiveDocument prüft die Linearization-Property (plnUnknown, plnNotLinearized, plnLinearized) und routet entsprechend: Eine linearisierte Datei öffnet über FPDFAvail_GetDocument, sobald der First-Page-Abschnitt und die Hint-Tabellen vorhanden sind, während eine nicht linearisierte Datei über FPDF_LoadCustomDocument auf demselben File-Access-Record geöffnet und nur als Ganzes als lesbar behandelt wird. Das Routing existiert aus einem konkreten Grund. FPDFAvail_GetDocument auf einer nicht linearisierten Datei kann ein nicht-null Handle liefern, dessen Seitenzahl null ist – ein Dokument, das aussieht wie geöffnet und leer ist. In der eigenen Testsuite der Komponente erreicht ein 51-seitiges linearisiertes Fixture pdaAvailable und öffnet mit seinem vollen Seitenbaum, während der Sparse-Download-Store die Datei noch immer nicht abdeckt

Wie OpenProgressiveDocument in PDFium Component einen Teil-Download routet: Eine linearisierte Datei öffnet über FPDFAvail_GetDocument, sobald First-Page-Abschnitt und Hint-Tabellen ankommen, eine nicht linearisierte braucht FPDF_LoadCustomDocument und jedes Byte, und LoadAvailablePage prüft die Form-Verfügbarkeit mit FPDFAvail_IsFormAvail vor dem Seitencheck und vermeidet damit die Nicht-null-Null-Seiten-Handle-Falle
Nur linearisierte Dateien bekommen einen Vorsprung; bei allem anderen kann FPDFAvail_GetDocument ein offenaussehendes Dokument mit null Seiten liefern – genau das verhindert das Routing
function WaitForPage(Pdf: TPdfProgressiveDocument; Store: TStream;
  PageNumber: Integer): Boolean;
var
  Hints: TPdfDownloadRanges;
  Request: TPdfRangeRequest;
  Round: Integer;
begin
  Result := False;
  for Round := 1 to 64 do
    case Pdf.LoadAvailablePage(PageNumber, Hints) of
      pdaAvailable:
        Exit(True);   // PageNumber ist jetzt die aktive Seite
      pdaError:
        Exit(False);
      pdaNotAvailable:
        while Pdf.RangeRequests.TryDequeue(Request) do
          Pdf.RangeRequests.CompleteRequest(Request,
            FetchRange(Store, Request.Offset, Request.Size));
    end;
end;

LoadAvailablePage nimmt eine 1-basierte Seitennummer und erzwingt die Reihenfolge, die PDFium erwartet: Vor dem ersten Seitencheck läuft CheckFormAvailability, das FPDFAvail_IsFormAvail umschließt, und erst danach ruft es FPDFAvail_IsPageAvail. Ein Ergebnis von pfaNotPresent ist die normale Antwort für ein Dokument ohne AcroForm und blockiert nichts. Ist die Seite bereit, macht LoadAvailablePage sie zur aktiven Seite, also kann ein Viewer Seite 1 einer linearisierten Broschüre rendern, während die übrigen Seiten noch unterwegs sind; FirstAvailablePageNumber verrät, welche Seite das Linearisierungs-Dictionary als erste ausweist, bereits aus PDFiums nullbasiertem Index umgerechnet

Was gibt CancelProgressiveLoad frei, und in welcher Reihenfolge?

CancelProgressiveLoad baut eine Session in vier Schritten ab, die sich nicht umordnen lassen: den Range-Scheduler abbrechen, das Dokument schließen, das Availability-Handle mit FPDFAvail_Destroy zerstören, dann die Callback-Records entsorgen und den Stream-Adapter freigeben. Den Scheduler zuerst abzubrechen erhöht seinen Generationszähler, wirft jede wartende und in-flight Anfrage weg und feuert OnCancelRequest für jede in-flight, sodass eine später eintreffende Transport-Fertigstellung die alte Generation trägt und CompleteRequest False zurückliefert, ohne etwas anzufassen. Das Dokument muss schließen, bevor das Availability-Handle und der Adapter verschwinden, denn PDFium kann beim Schließen eines Dokuments in den File-Access-Provider zurückrufen, und ist der Adapter schon weg, liest dieser Callback freigegebenen Speicher

Die feste Abbau-Reihenfolge von CancelProgressiveLoad in PDFium Component: Erst den Range-Scheduler abbrechen, damit späte Fertigstellungen auf den erhöhten Generationszähler treffen und False liefern, das Dokument schließen, bevor der File-Access-Adapter verschwindet, das Availability-Handle mit FPDFAvail_Destroy zerstören und erst dann die Callback-Records entsorgen und den Stream-Adapter freigeben
Eine idempotente Methode räumt einen gescheiterten Start, einen Nutzer-Abbruch und den Destruktor gleichermaßen auf; schreibt ein Worker-Thread in den Store, bleibt die Stream-Ownership bei Ihnen
procedure TDownloadForm.FormCreate(Sender: TObject);
begin
  FPdf := TPdfProgressiveDocument.Create(nil);
  // Der Scheduler lebt so lang wie FPdf, also einmal verdrahten
  FPdf.RangeRequests.OnCancelRequest := RangeCancelled;
end;

procedure TDownloadForm.RangeCancelled(Sender: TObject; RequestId: UInt64;
  Attempt: Cardinal);
begin
  FTransport.Abort(RequestId);   // Ihr Code: diesen Socket oder Request schließen
end;

procedure TDownloadForm.CancelButtonClick(Sender: TObject);
begin
  FPdf.CancelProgressiveLoad;
  // ProgressiveLoading = False, Active = False, AvailableByteCount = 0
end;

Die Methode ist idempotent und der einzige Aufräumweg für drei Situationen: ein BeginProgressiveLoad, das mitten im Aufbau scheitert, ein expliziter Nutzer-Abbruch und der Destruktor. BeginProgressiveLoad ruft sie auch vor dem Start auf, also ist ein Neustart desselben Objekts auf einer neuen URL sicher, ohne expliziten Cancel. Eine Ownership-Entscheidung müssen Sie richtig treffen: Schreibt ein Worker-Thread in den tragenden Stream, übergeben Sie AOwnsStream = False und geben Sie den Stream selbst frei, nachdem der Worker gestoppt hat, denn mit übergebener Ownership gibt der Cancel den Stream frei, während eine späte Schreiboperation möglicherweise noch unterwegs ist. Exceptions aus OnCancelRequest werden pro Anfrage geschluckt, damit ein scheiternder Transport die übrigen Abbrüche nicht blockieren kann

Wie beweist die Lifecycle-Suite, dass der Cancel-Pfad nicht leakt?

Die Lifecycle-Stress-Suite der PDFium Component übt einen unterbrochenen netzwerkartigen Download in jedem gemischten Zyklus aus. Jeder Zyklus startet einen progressiven Load, dessen Store nur ein Viertel der Fixture-Bytes hält, verlangt pdaNotAvailable mit nicht-leerer Hint-Liste, ruft CancelProgressiveLoad und behauptet, dass das Objekt weder ProgressiveLoading noch Active meldet; danach läuft derselbe Streaming-Pfad mit voller Verfügbarkeit zu Ende, OpenProgressiveDocument, ein Render und ein Close. Der gemischte Standardlauf deckt 100 gemessene Zyklen mit 600 Opens, 2300 Renders und 100 progressiven Abbrüchen ab, und der bei Stichproben gemessene private Speicher wuchs um 8,21 MiB gegen ein 32-MiB-Budget. Die Suite zählt progressive Abbrüche getrennt von Render-Callback-Abbrüchen, denn ein abgebrochener Download und eine Render-Schleife, die früh aufhört, sind verschiedene Events mit verschiedenen Akzeptanzkriterien

Wo der progressive Pfad aufhört zu helfen

Ein paar Grenzen sind gut zu wissen, bevor Sie einen Viewer darauf bauen. Features, die die Original-Datei-Bytes brauchen, weisen eine unvollständige progressive Quelle zurück, statt zu raten: ReadXmpPacket scheitert explizit, und die Signaturvalidierung meldet Indeterminate, bis die ganze Datei vorhanden ist. Der Standard-Verfügbarkeitstest nimmt ein kontinuierliches Präfix an, also muss ein Transport, der Bereiche ungeordnet lädt, sie über RangeRequests abschließen oder über OnDataAvailable antworten, sonst fragt PDFium ewig nach Bytes, die Sie bereits halten. Eine nicht linearisierte Datei gewinnt nichts bei der Zeit bis zur ersten Seite, also linearisieren Sie die Datei serverseitig, wenn ein schneller First Paint zählt. Und CancelProgressiveLoad schließt Ihre Sockets nicht von selbst; OnCancelRequest ist der Hook, an dem das passiert

Für den schlichten Stream-Adapter-Pfad, der eine komplette lokale Datei bei Bedarf lädt, siehe große PDFs bei Bedarf mit PDFium streamen; zum Öffnen eines PDFs, das in einem größeren Buffer sitzt, siehe Byte-Range-Laden für eingebettete PDFs. Das Abbrechen eines langsamen Renders einer bereits geladenen Seite ist ein separater Mechanismus, beschrieben in abbruchfähigem progressivem Seiten-Rendering. TPdfProgressiveDocument und sein Range-Scheduler kommen mit der PDFium Component for Delphi and C++Builder