Technischer Artikel

Abbrechbares progressives PDF-Rendering in Delphi (PDFium)

Die meisten PDF-Seiten werden in wenigen Millisekunden gerastert, und man denkt nie darüber nach. Dann öffnet ein Benutzer eine technische A1-Zeichnung, eine Seite voller Zehntausender Vektorstriche oder ein Poster mit zahlreichen Transparenzgruppen und weichen Masken, und der einzelne Aufruf, der dies zeichnet, dauert zwei oder drei Sekunden. Wenn dieser Aufruf im UI-Thread ausgeführt wird, hört das Fenster auf, sich neu zu zeichnen, die Titelleiste wird grau und das Betriebssystem bietet an, die Anwendung zu beenden. Die Arbeit ist legitim. Die Seite braucht wirklich so lange. Der Fehler besteht darin, dass das Rendern ein einzelner unteilbarer, blockierender Aufruf ist, ohne die Möglichkeit, Luft zu holen, und ohne Möglichkeit, abzubrechen

Dieser Artikel befasst sich mit genau einem dieser beiden Probleme: dem Abbrechen eines langen Rendervorgangs einer Einzelseite, ohne die Benutzeroberfläche einzufrieren. Der Benutzer hat auf die nächste Seite geklickt, gezoomt oder das Dokument geschlossen, und das laufende Rendern ist nun vergeudete Arbeit, die bei der nächsten Gelegenheit enden sollte, anstatt bis zum Ende durchzulaufen. Das Glätten von Scrollen und Zoomen durch das Zwischenspeichern dessen, was bereits gerastert wurde, ist ein separates Anliegen mit einem eigenen Design, das im am Ende verlinkten Begleitartikel behandelt wird. Hier geht es nur um die Frage, wie ein progressives Rendering auf eine Abbruchanforderung schnell und sauber reagiert

Die progressive Render-API, die PDFium bereits mitliefert

PDFium hat die einfrierende Hälfte des Problems vorhergesehen. Neben dem einmaligen FPDF_RenderPageBitmap bietet es eine progressive Variante, die eine Seite in Arbeitsblöcke aufteilt. Sie rufen FPDF_RenderPageBitmap_Start einmal auf, um das Rendering für eine Ziel-Bitmap einzurichten, und rufen dann wiederholt FPDF_RenderPage_Continue auf. Jedes Continue rastert einen begrenzten Ausschnitt und gibt einen Status zurück. FPDF_RENDER_TOBECONTINUED bedeutet, dass es noch mehr zu tun gibt, FPDF_RENDER_DONE bedeutet, dass die Seite fertig ist, und FPDF_RENDER_FAILED bedeutet, dass ein Fehler aufgetreten ist. Wenn die Schleife endet, rufen Sie FPDF_RenderPage_Close auf, um den progressiven Zustand pro Seite freizugeben. Da die Kontrolle zwischen den Abschnitten an Ihren Code zurückgegeben wird, können Sie Nachrichten verarbeiten, eine Fortschrittsanzeige aktualisieren oder überprüfen, ob die Arbeit noch gewünscht wird

Der Mechanismus, den PDFium bereitstellt, um zu entscheiden, wann nachgegeben werden soll, ist ein Callback-Struct namens IFSDK_PAUSE. Sie übergeben es an Start und an jedes Continue. Nach jedem Block ruft PDFium seinen NeedToPauseNow-Funktionszeiger auf, und wenn dieser einen Wert ungleich null zurückgibt, stoppt das aktuelle Continue vorzeitig und gibt die Kontrolle mit FPDF_RENDER_TOBECONTINUED zurück. Das Struct enthält auch ein version-Feld, das auf 1 gesetzt werden muss, und einen frei wählbaren user-Zeiger, den PDFium nie berührt und unverändert weitergibt. Dieser unberührte Zeiger ist der Dreh- und Angelpunkt für das folgende Design

Umfunktionierung von Pause zu Abbruch

Die ursprüngliche Absicht von NeedToPauseNow ist Time-Slicing. Geben Sie einen Wert ungleich null zurück, wenn Ihr Frame-Budget aufgebraucht ist, geben Sie null zurück, um weiter zu rendern, und PDFium pausiert, sodass Sie etwas anderes tun können, bevor Sie dasselbe Rendering fortsetzen. Die PDFium-Komponente verwendet dasselbe Signal für ein anderes Verb wieder. Anstatt die Frage "Soll ich pausieren und dich fortsetzen lassen?" zu beantworten, beantwortet der Callback die Frage "Wurde diese Arbeit abgebrochen?". Beide lassen sich sauber aufeinander abbilden, aufgrund dessen, was die Schleife tut, wenn sie das Flag sieht. Eine echte Pause erwartet ein späteres Continue; ein Abbruch nicht. Sobald die aufrufende Schleife feststellt, dass das Token abgebrochen wurde, schließt sie den Renderkontext und ruft Continue nie wieder auf, sodass derselbe Rückgabewert ungleich null, den PDFium als "Stoppe diesen Block" liest, im Endeffekt zu einem "Stoppe endgültig" wird

Der Abbruch wird über ein Interface, IPdfCancellationToken, ausgedrückt, dessen IsCancelled-Eigenschaft von falsch auf wahr wechselt, wenn ein anderer Teil des Programms das Anhalten des Rendervorgangs anfordert. Die Brücke zwischen diesem Pascal-Interface und PDFiums C-Callback ist ein einzelner Zeiger. Die Interface-Referenz des Tokens wird in IFSDK_PAUSE.user geschrieben, und ein statischer cdecl-Callback liest sie wieder aus und fragt sie ab. Dies ist das klassische Problem, wenn man eine C-Bibliothek nach Pascal zurückrufen lässt: Der Callback muss eine einfache Funktion mit C-Aufrufkonvention sein, keine Methode, da PDFium einen bloßen Funktionszeiger speichert und aufruft, der nichts über Pascal-Objekte oder Self weiß

type
  TPdfProgressivePause = record
    Pause: IFSDK_PAUSE;            // PDFium reads this; .user holds the token
    Token: IPdfCancellationToken; // strong ref keeps the token alive
  end;

function ProgressivePauseCallback(pThis: PIFSDK_PAUSE): FPDF_BOOL; cdecl;
var
  Token: IPdfCancellationToken;
begin
  Result := 0;
  if (pThis = nil) or (pThis^.user = nil) then
    Exit;
  Token := IPdfCancellationToken(pThis^.user);
  if Token.IsCancelled then
    Result := 1; // non-zero: PDFium stops this chunk
end;

Der Callback stellt das Token wieder her, indem er pThis^.user auf den Interface-Typ zurückcastet und IsCancelled liest. Nichts darin weist Speicher zu, sperrt oder blockiert, was wichtig ist, da PDFium es im Rendering-Thread nach jedem Block aufruft und jede hier geleistete Arbeit zu den Kosten des Renderings selbst hinzukommt. Die Absicherung gegen ein nil-Struct oder ein nil-user-Feld bedeutet, dass dieselbe Funktion auch bei einem Rendering, dem nie ein echtes Token übergeben wurde, sicher installiert werden kann

Das Token über die Schleife hinweg am Leben erhalten

Beim Casten eines Interface-Zeigers über einen rohen Pointer und zurück entstehen Fehler in der Lebensdauer. Ein IInterface in Delphi ist referenzgezählt, und der Zähler ändert sich nur, wenn der Compiler sieht, dass einer Variablen vom Typ Interface ein Wert zugewiesen wird. Die Speicherung des Tokens ausschließlich als bloßer Zeiger innerhalb von IFSDK_PAUSE.user würde es dem Referenzzähler vollständig verbergen. Wenn die einzige andere Referenz auf dieses Token ihren Gültigkeitsbereich (Scope) verlässt, während die Continue-Schleife noch läuft, würde das Objekt unterhalb des Callbacks freigegeben, und der nächste Block würde einen hängenden Zeiger (Dangling Pointer) dereferenzieren

Das ist der Grund, warum der Deskriptor ein Record ist, das zwei Dinge enthält, nicht eines. Das Feld Pause ist das Struct, das PDFium liest. Das Feld Token ist eine echte typsichere Interface-Referenz, die der Compiler mitzählt, und sie existiert aus keinem anderen Grund, als das Token im Speicher zu fixieren, solange der Record existiert. Der Record ist eine lokale Variable auf dem Stack der Renderroutine, bleibt also für die gesamte Dauer der Schleife gültig und wird erst beim Beenden der Routine abgebaut. Der bloße Zeiger in user und die gezählte Referenz in Token benennen dasselbe Objekt; das eine ist das, was PDFium lesen kann, das andere ist das, was verhindert, dass das Objekt von der Garbage Collection eingesammelt wird

var
  Pause: TPdfProgressivePause;
  EffectiveToken: IPdfCancellationToken;
begin
  // ... choose EffectiveToken ...

  // Strong ref first, then publish the same object to PDFium via .user.
  Pause.Token := EffectiveToken;
  Pause.Pause.version := 1;
  Pause.Pause.NeedToPauseNow := ProgressivePauseCallback;
  Pause.Pause.user := Pointer(EffectiveToken);

Schließen des Renderkontexts, unabhängig davon, wie die Schleife endet

Jeder Aufruf von FPDF_RenderPageBitmap_Start weist einen progressiven Zustand zu, den PDFium mit der Seite verknüpft, und dieser Zustand wird nur durch FPDF_RenderPage_Close freigegeben. Es gibt drei Wege aus der Antriebsschleife heraus. Die Seite wird fertiggestellt und der letzte Status ist FPDF_RENDER_DONE. Das Token löst aus und die Schleife wird vorzeitig mit der Meldung eines Abbruchs beendet. Etwas schlägt fehl und der Status ist FPDF_RENDER_FAILED. Alle drei müssen Close aufrufen, und beim Abbruchpfad passieren am ehesten Fehler, da die natürliche Form von "Abbruch erkennen, ausbrechen" dazu neigt, die Bereinigung auf dem Weg zum Ausgang zu überspringen. Wenn Close nicht erreicht wird, verbleibt der Zustand pro Seite als Leck im Speicher, und ein Viewer, der den Benutzer Rendervorgang um Rendervorgang abbrechen lässt, würde dieses Leck bei jeder abgebrochenen Seite anhäufen

Die robuste Form packt die Schleife und die Ergebnisklassifizierung in einen try-Block und FPDF_RenderPage_Close in das passende finally. Die Ziel-Bitmap wird im selben Block zerstört. Ein Abbruch kann die Schleife durch ein vorzeitiges Exit verlassen, und das finally wird dennoch ausgeführt. Es gibt also genau eine Stelle, die den progressiven Zustand freigibt, und sie kann nicht umgangen werden

Status := FPDF_RenderPageBitmap_Start(PdfBmp, FPage, Left, Top,
  Width, Height, Ord(Rotation), EncodeRenderOptions(Options), Pause.Pause);
try
  while Status = FPDF_RENDER_TOBECONTINUED do
  begin
    if EffectiveToken.IsCancelled then
    begin
      Result := prsCancelled;
      Exit;
    end;
    Status := FPDF_RenderPage_Continue(FPage, Pause.Pause);
  end;

  if EffectiveToken.IsCancelled then
    Result := prsCancelled
  else if Status = FPDF_RENDER_DONE then
    Result := prsDone
  else
    Result := prsFailed;
finally
  // Frees the progressive state Start allocated; mandatory on every path.
  FPDF_RenderPage_Close(FPage);
  FPDFBitmap_Destroy(PdfBmp);
end;

Die Schleife überprüft das Token vor jedem Continue und verlässt sich zusätzlich auf den darin enthaltenen Callback. Der Callback verkürzt den aktuellen Block; die Schleifenprüfung verhindert, dass der nächste startet. Zusammen begrenzen sie die Zeit, die ein Abbruch benötigt, um wirksam zu werden, auf ungefähr die Dauer eines Blocks

Drei Ergebnisse und was die Bitmap nach einem Abbruch enthält

Der öffentliche Einstiegspunkt ist TPdf.RenderPageProgressive, und er gibt einen TPdfProgressiveStatus zurück, der entweder prsDone, prsCancelled oder prsFailed ist. Die Werte spiegeln die FPDF_RENDER_*-Konstanten von PDFium im Pascal-Idiom wider, behandeln den Abbruchfall jedoch als erstklassiges Ergebnis und nicht als Fehler

Der Punkt, an dem viele scheitern, ist das, was die Ziel-Bitmap nach prsCancelled enthält. Sie ist nicht leer. PDFium rendert progressiv Block für Block in dieselbe Bitmap. Wenn also ein Abbruch die Schleife stoppt, enthält die Bitmap das, was bis zu diesem Moment gezeichnet wurde, was ein unvollständiges Bild ist: Einige Streifen sind fertig, der Rest zeigt noch die Füllfarbe. Ob dieses Teilergebnis nützlich ist, hängt vom Aufrufer ab. Ein Viewer, der die Bitmap gleich verwirft, weil der Benutzer woandershin navigiert ist, kann sie einfach ignorieren. Ein Viewer, der eine kostengünstige Vorschau anzeigen möchte, kann sie behalten. Was Sie nicht tun dürfen, ist davon auszugehen, dass prsCancelled eine leere oder undefinierte Bitmap impliziert; es bedeutet eine wahrheitsgetreue Momentaufnahme eines unvollendeten Renderings

var
  Bmp: TBitmap;
  Token: IPdfCancellationToken;
  Status: TPdfProgressiveStatus;
begin
  Bmp := TBitmap.Create;
  try
    // Token starts un-cancelled; flip Token.IsCancelled from elsewhere
    // (a UI action, a navigation event) to abort the render in flight.
    Status := Pdf.RenderPageProgressive(Bmp, 0, 0, PageW, PageH, Token);
    case Status of
      prsDone:      Image1.Picture.Assign(Bmp);  // fully rendered
      prsCancelled: ;                            // partial bitmap, usually discarded
      prsFailed:    ShowMessage('Render failed');
    end;
  finally
    Bmp.Free;
  end;
end;

Das nil-Token und ein verzweigungsfreier Callback-Pfad

Ein Abbruch ist optional (opt-in). Ein Aufrufer, der progressives Rendering nur wegen des Vorteils der Nachrichtenverarbeitung nutzen möchte, ohne die Absicht eines Abbruchs, sollte nil für das Token übergeben können. Der naive Weg, dies zu unterstützen, bestünde darin, Prüfungen wie "wurde ein Token geliefert" über den Callback und die Schleife zu verstreuen, was eine Verzweigung bei jedem Block und einen Callback bedeuten würde, der sowohl ein echtes Token als auch dessen Fehlen handhaben muss

Die Implementierung vermeidet dies, indem sie ein Singleton einsetzt, wenn der Aufrufer nichts übergibt. Ein nil-Token wird gegen PdfNoCancellationToken ausgetauscht, ein Interface, dessen IsCancelled immer falsch ist. Von diesem Punkt an haben der Callback und die Schleife in jedem Fall ein Token, das sie abfragen können, sodass weder eine Prüfung auf nil noch ein spezieller Pfad erforderlich ist. Das Token, das niemals abbricht, antwortet einfach immer mit falsch, der Callback gibt immer null zurück, und das Rendern läuft genau so bis zum Ende durch, wie es ein nicht abbrechbares Rendering tun würde. Optionales Verhalten wird eher als ein Token modelliert, das nie auslöst, denn als das Fehlen eines Tokens, was den Hot Path einheitlich hält

// nil -> never-cancel singleton, so the callback path is identical
// whether or not the caller opted into cancellation.
if AToken <> nil then
  EffectiveToken := AToken
else
  EffectiveToken := PdfNoCancellationToken;

Die sich daraus ergebende Struktur ist klein und wert, noch einmal wiederholt zu werden, denn dies ist der wiederverwendbare Teil. Eine C-Bibliothek, die einen Callback unterstützt, bietet Ihnen genau einen Kanal, um den Status in diesen Callback zu übergeben: den undurchsichtigen (opaque) User-Zeiger. Legen Sie eine referenzgezählte Pascal-Interface-Referenz hinter diesen Zeiger, halten Sie eine zweite echte Referenz neben dem Struct am Leben, damit das Objekt während des Aufrufs nicht durch die Garbage Collection erfasst werden kann, und lesen Sie das Interface innerhalb einer statischen cdecl-Funktion wieder aus. Wickeln Sie die gesamte Antriebsschleife in ein try und geben Sie den nativen Kontext im finally frei. Dasselbe Muster lässt sich auf jede progressive oder Callback-gesteuerte PDFium-Operation übertragen, bei der Pascal-Code die Kontrolle über die Lebensdauer behalten muss, während C einen Zeiger hält

Ein Abbruch ist nur eine Hälfte eines reaktionsschnellen Viewers. Die andere Hälfte besteht darin, Seiten, die Sie bereits gezeichnet haben, nicht neu zu rendern und das Zoomen und Scrollen durch die Bereitstellung zwischengespeicherter Bitmaps flüssig zu halten, was in unserem Artikel über Render-Caching und Zoom-Leistung behandelt wird. Wie sich das abbrechbare Rendern neben Navigation, Auswahl und Suche in einen vollständigen Viewer einfügt, lesen Sie unter Erstellen eines funktionsreichen PDF-Viewers mit der PDFium-Komponente. Das hier beschriebene progressive Rendering wird als Teil der PDFium-Komponente für Delphi und Lazarus geliefert, zusammen mit den Lade-, Rendering- und Formular-APIs, die an anderer Stelle in diesem Blog behandelt werden