Technischer Artikel

Hintergrund-Rendering-Warteschlange für PDF in Delphi mit HotPDF

HotPDFs Klasse THPDFBackgroundRenderer ist ein TThread-Abkömmling, der geladene PDF-Seiten auf einem Worker-Thread in Bitmaps rendert, sodass ein Delphi-Viewer weiter scrollen und neu zeichnen kann, während eine Seite im Hintergrund noch gerastert wird. THPDFBackgroundRenderer.RequestPage reiht einen Seitenindex für diesen Worker-Thread ein, CancelAll verwirft, was noch wartet, und GetCachedBitmap liefert eine fertige Bitmap zurück, die der Aufrufer besitzt und freigeben muss. Wird ein zweihundertseitiger gescannter Vertrag in Druckauflösung allein auf dem UI-Thread gescrollt, pausiert jeder Seitenwechsel das Fenster, bis GDI mit dem Zeichnen fertig ist – genau das Stottern, das THPDFBackgroundRenderer beseitigen soll

Warum PDF-Seiten überhaupt auf einem Hintergrund-Thread rendern?

Ein Hintergrund-Thread rechtfertigt seine Komplexität, weil HotPDFs Seiten-Renderer ein echter Content-Stream-Interpreter ist, keine billige Bitmap-Kopie, die zurückkehrt, bevor es jemand bemerkt: Er durchläuft PDF-Operatoren, führt einen Grafikzustand-Stack und rastert Pfade, Bilder und Glyphen über GDI, dieselbe Engine, die in dem Rendern geladener PDF-Seiten in eine TBitmap behandelt wird. Diese Arbeit synchron innerhalb eines Scroll- oder Paint-Handlers auszuführen, stoppt die Nachrichtenschleife, bis der Aufruf zurückkehrt – genau das ist es, was ein eingefrorenes Fenster tatsächlich bedeutet. Application.ProcessMessages innerhalb des Render-Aufrufs einzustreuen behebt das nicht: Es lässt die Nachrichtenwarteschlange abfließen, aber das Rendering selbst besitzt weiterhin den aufrufenden Thread, sodass das Fenster veraltete Inhalte schneller neu zeichnet, während die eigentliche Arbeit sich nirgendwohin bewegt hat. Der einzige Weg, einen Viewer während eines wirklich langsamen Renderings reaktionsfähig zu halten, besteht darin, dieses Rendering woanders auszuführen – weshalb THPDFBackgroundRenderer als TThread-Unterklasse existiert statt als Callback oder Timer

Eine Anfrage-Warteschlange für einen scrollenden Viewer einrichten

THPDFBackgroundRenderer.Create nimmt die geladene THotPDF-Instanz und eine DPI entgegen, die für die gesamte Lebensdauer dieses Renderers fest bleibt, sodass jede über eine Instanz eingereihte Seite mit einer Auflösung gerendert wird; ein Viewer, der Zoom unterstützt, braucht bei jeder Änderung der Zoomstufe einen neuen Renderer, keine neue DPI-Eigenschaft. RequestPage hängt einen Seitenindex an eine interne Warteschlange an und kehrt sofort zurück: Es rendert selbst nichts und berührt nie den UI-Thread. Execute, der geerbte TThread-Einstiegspunkt, den HotPDF ausführt, sobald Start aufgerufen wird, zieht jeweils einen Index vom Anfang dieser Warteschlange, rendert ihn über den Seiten-Cache des Dokuments und speichert eine Kopie, indiziert nach Seite, sodass GetCachedBitmap sie später zurückgeben kann

type
  TViewerForm = class(TForm)
    RenderPollTimer: TTimer;
    procedure RenderPollTimerTimer(Sender: TObject);
  private
    FDoc: THotPDF;
    FRenderer: THPDFBackgroundRenderer;
    FPendingPage: Integer;
    procedure RequestPageWindow(CenterPage: Integer);
  end;

procedure TViewerForm.RequestPageWindow(CenterPage: Integer);
var
  I: Integer;
begin
  if FRenderer <> nil then
  begin
    FRenderer.CancelAll;
    FRenderer.Free;
  end;
  FRenderer := THPDFBackgroundRenderer.Create(FDoc, 150);
  for I := CenterPage - 1 to CenterPage + 1 do
    if (I >= 0) and (I < FDoc.LoadedPageCount) then
      FRenderer.RequestPage(I);
  FPendingPage := CenterPage;
  FRenderer.Start;
end;

procedure TViewerForm.RenderPollTimerTimer(Sender: TObject);
var
  Bmp: TBitmap;
begin
  if FRenderer = nil then Exit;
  Bmp := FRenderer.GetCachedBitmap(FPendingPage);
  if Bmp <> nil then
  begin
    PageImage.Picture.Bitmap.Assign(Bmp);
    Bmp.Free;
  end;
end;

GetCachedBitmap gibt nil zurück, bis die Kopie dieser Seite fertig ist, sodass ein Poll-per-Timer-Muster wie das obige ausreicht; es gibt kein separates Ready-Ereignis zu verdrahten, HotPDF löst das mit einer einfachen nil-Prüfung statt einer größeren Benachrichtigungs-API. Der nächste Abschnitt behandelt, was CancelAll und dieser Free-Aufruf tatsächlich tun, denn beides zählt, sobald Seiten außer der Reihe gerendert werden oder ein Scroll schneller passiert, als die Warteschlange abfließen kann

Die Ein-Aufruf-Abkürzung für eine einzelne Seite

THotPDF.RenderLoadedPageToBitmapAsync existiert für den häufigen Fall, genau eine Seite loszuschicken, ohne THPDFBackgroundRenderer direkt anzufassen: Es baut den Renderer intern auf, ruft RequestPage einmal auf, startet den Thread und gibt die TThread-Referenz an den Aufrufer zurück, der sie besitzt und für ihre Freigabe verantwortlich ist. Das Abrufen des Ergebnisses läuft über THotPDF.GetLoadedCachedRenderedBitmap statt über das eigene GetCachedBitmap des Renderers, weil GetLoadedCachedRenderedBitmap den gemeinsamen Cache des Dokuments liest, indiziert nach Seitenindex und DPI – denselben Cache, den RenderLoadedPageToBitmapCached und der eingebaute Prefetcher bereits füllen –, sodass eine Seite, die ein anderer Teil des Viewers bereits mit dieser DPI gerendert hat, sofort zurückkommen kann, noch bevor der gerade erst gestartete Hintergrund-Thread vom Betriebssystem überhaupt eingeplant wurde

// A simpler alternative to the queue above, for one page at a time.
procedure TViewerForm.RequestSinglePage(PageIndex: Integer);
begin
  if FAsyncWorker <> nil then
    FAsyncWorker.Free; // waits if a prior page is still rendering
  FAsyncWorker := Pdf.RenderLoadedPageToBitmapAsync(PageIndex, 150);
  FPendingPage := PageIndex;
end;

procedure TViewerForm.AsyncPollTimerTimer(Sender: TObject);
var
  Bmp: TBitmap;
begin
  Bmp := Pdf.GetLoadedCachedRenderedBitmap(FPendingPage, 150);
  if Bmp <> nil then
  begin
    PageImage.Picture.Bitmap.Assign(Bmp);
    Bmp.Free;
  end;
end;

Lässt sich eine bereits eingereihte Seite abbrechen?

CancelAll entfernt nur Aufträge, die noch in der Warteschlange sitzen; eine Seite, die HotPDF bereits vom Anfang gezogen und an seinen Render-Aufruf übergeben hat, läuft bis zum Abschluss weiter, weil THPDFBackgroundRenderer keinen Mechanismus hat, um bereits laufende Arbeit zu unterbrechen. Das ist in der Praxis ein vernünftiger Kompromiss – das Rendern einer einzelnen Seite dauert selten lange genug, um Präemption die zusätzliche Komplexität wert zu machen –, aber ein schnelles Scrollen, das bei jedem Scroll-Ereignis CancelAll auslöst, zahlt trotzdem für die eine Seite, die im Moment jedes Abbruchs gerade mitten im Rendering war. Die offizielle Referenz ist hier direkt: bereits laufendes Rendering kann abschließen, bevor der Thread beendet wird

Execute hat ein zweites, leicht zu übersehendes Verhalten: Die Schleife beendet sich, sobald sie die Warteschlange leer vorfindet, sie leerläuft nicht und wartet nicht auf weitere Arbeit. Eine THPDFBackgroundRenderer-Instanz ist daher ein Einmal-Batch-Worker, kein dauerhafter Hintergrunddienst – ein paar Seiten einreihen, Start aufrufen, und sobald die letzte eingereihte Seite gerendert wurde, endet der zugrunde liegende Betriebssystem-Thread von selbst. RequestPage erneut auf derselben Instanz aufzurufen, nachdem Execute die Warteschlange bereits geleert hat, startet sie nicht neu – genau deshalb ersetzt RequestPageWindow oben bei jedem Aufruf die Renderer-Instanz, statt zu versuchen, ein einzelnes langlebiges Objekt weiter zu füttern

Ist es sicher, eine TBitmap in Delphi aus einem Hintergrund-Thread heraus anzufassen?

Das Anfassen einer TBitmap aus einem Hintergrund-Thread ist in HotPDFs Design sicher, solange immer nur ein Thread gleichzeitig mit einer gegebenen Bitmap-Instanz arbeitet, und THPDFBackgroundRenderer erzwingt diese Grenze, statt sie dem Aufrufer zu überlassen. Execute rendert jede Seite innerhalb der eigenen Render-Sperre des Dokuments, derselben kritischen Sektion, die sich bereits jeder RenderLoadedPageToBitmapCached-Aufruf und der eingebaute PrefetchLoadedPages-Prefetcher teilen, sodass das eigentliche GDI-Zeichnen für eine gegebene Seite immer auf genau einem Thread stattfindet und sich nie mit einem anderen Rendering desselben Dokuments überschneidet. Die entstehende Bitmap ist ein vom Worker-Thread besessenes Objekt, das THPDFBackgroundRenderer nie direkt an einen Aufrufer veröffentlicht

GetCachedBitmap allokiert stattdessen eine brandneue TBitmap und ruft Assign darauf unter der eigenen separaten Sperre des Renderers auf, sodass die Kopie immer stattfindet, während Execute daran gehindert wird, diesen Cache-Slot darunter zu ersetzen – der aufrufende Thread bekommt Pixeldaten, nie das Original-Handle. Diese Trennung ist auch der Grund, davon abzusehen, einen eigenen Rendering-Thread zu bauen, der HotPDFs Render-Funktionen direkt aufruft, ohne über THPDFBackgroundRenderer oder PrefetchLoadedPages zu laufen: Zwei Renderings, die um die gemeinsamen Caches und den Objektgraphen desselben geladenen Dokuments konkurrieren, sind genau das Szenario, das HotPDFs interne Sperrung verhindern soll, und die Background-Renderer-Klasse liefert diese Sperrung kostenlos mit, statt sie neu implementieren zu müssen

Wie unterscheidet sich das vom eingebauten Seiten-Prefetch von HotPDF?

PrefetchLoadedPages und THPDFBackgroundRenderer lösen verwandte, aber unterschiedliche Probleme: PrefetchLoadedPages rendert bei gegebenem Seitenbereich diese ganze Nachbarschaft automatisch auf seinem eigenen Worker-Thread in den gemeinsamen Dokument-Cache, ohne dass der Aufrufer ein Warteschlangenobjekt erstellen oder verwalten muss. THPDFBackgroundRenderer tauscht diese Automatisierung gegen Kontrolle ein – der Aufrufer entscheidet genau, welche Seitenindizes wichtig sind und in welcher Reihenfolge, und kann die noch eingereihten abbrechen, ohne den Bereich zu berühren, den der eingebaute Prefetcher anderswo gerade aufwärmt. Beide laufen über dieselbe Render-Sperre, sodass ein Viewer für den gewöhnlichen Fall der nächsten paar Seiten PrefetchLoadedPages verwenden und nur dann zu THPDFBackgroundRenderer greifen kann, wenn etwas außerhalb dieses Musters auftaucht, etwa eine Miniaturansichtsleiste, die direkt zu einer gerade angeklickten Seite springt

begin
  // PrefetchLoadedPages takes a 1-based "start-end" range string, while
  // RequestPage below stays 0-based like every other loaded-page index.
  Pdf.PrefetchLoadedPages(Format('%d-%d', [CenterPage + 1, CenterPage + 5]), 150);

  // Reach for THPDFBackgroundRenderer only for a page outside that
  // window, such as a thumbnail the user just clicked.
  FRenderer := THPDFBackgroundRenderer.Create(Pdf, 150);
  FRenderer.RequestPage(ClickedThumbnailPage);
  FRenderer.Start;
end;

Zwei Lebenszyklus-Details lohnt es sich, in Produktionscode mitzunehmen. Der dokumentweite Cache hinter RenderLoadedPageToBitmapCached ist durch RenderCacheCapacity begrenzt, standardmäßig acht Seiten, und verdrängt bei Erreichen der Grenze den am längsten unbenutzten Eintrag, aber die eigene Ergebnisliste einer THPDFBackgroundRenderer-Instanz kennt keine solche Grenze – sie behält eine Bitmap pro jemals über diese Instanz angeforderten eindeutigen Seitenindex, bis die Instanz selbst freigegeben wird, sodass ein Renderer, der über eine ganze Scroll-Sitzung bei hoher DPI am Leben gehalten wird, bereitwillig eine volle Auflösungs-Bitmap pro gescrollter Seite anhäuft. HotPDF bricht einen vom Aufrufer erstellten Renderer auch nicht automatisch ab, so wie es seinen eigenen Prefetcher abbricht, bevor ein Dokument lädt oder sich selbst zerstört, da eine THPDFBackgroundRenderer-Instanz nie am THotPDF-Objekt registriert wird, auf das sie zeigt – der aufrufende Code muss also jeden gegen ein Dokument gebauten Renderer abbrechen und freigeben, bevor dieses Dokument neu geladen oder freigegeben wird, dieselbe Reihenfolgen-Disziplin, die HotPDF intern auf PrefetchLoadedPages anwendet

THPDFBackgroundRenderer ist ein Baustein der Fassade für geladene Dokumente hinter HotPDFs MVC-Viewer-Architektur, und er passt natürlich zu den dateibasierten Workflows in der Direct File API für große PDFs, wenn das gescrollte Dokument selbst zu groß ist, um beiläufig geladen zu werden. Hintergrund-Rendering, Anfrage-Warteschlangen und der hier beschriebene Render-Cache sind alle Teil der Standard-HotPDF-Komponente für Delphi und C++Builder