Technischer Artikel

PDFium-Viewer in Delphi: Render-Cache und flüssiger Zoom

Das Supportticket lautete: „Der Viewer friert zwei Sekunden ein, jedes Mal wenn ich den Zoom-Schieberegler berühre.“ Das Dokument war eine 600-seitige gescannte Eigentumsurkunde, der Rechner ein 4K-Laptop, und der Code tat, was die meisten ersten Viewer tun: Er renderte die sichtbare Seite bei jedem Change-Event des Schiebereglers synchron neu. An der Rendering-Geschwindigkeit war nichts falsch; eine Seite war in ungefähr 180 ms gerastert. Das Problem war, dass ein einziger Reglerzug Dutzende Change-Events auslöst, jedes davon ein Rendering in voller Qualität einreiht und keines abgebrochen werden kann. Diese Problemklasse behebt man weniger dadurch, Renderings schneller zu machen, sondern dadurch, zu entscheiden, welche Renderings nicht fertig werden sollen. PDFium Component gibt Viewern für Delphi, C++Builder und Lazarus die passenden Primitiven, also vom Aufrufer besessene Bitmaps, einen progressiven Renderer mit Abbruch und Fit-Modi, und überlässt Ihnen die Cache-Richtlinie. Genau dort gehört sie hin.

Wo die Millisekunden bei einer Zoomänderung hingehen

Wer einen Cache entwirft, sollte zuerst die Kosten konkret machen. Eine A4-Seite bei 96 DPI hat ungefähr 794 mal 1123 Pixel, also etwa 3,5 MB als 32-Bit-Bitmap. Bei 200 % Zoom ist es das Vierfache; bei 400 % auf einem High-DPI-Display allokieren und füllen Sie pro Seite ein Bitmap von 50 bis 60 MB, und ein Continuous-Scroll-Viewer hält mehrere Seiten gleichzeitig am Leben. Die Rasterisierungskosten skalieren mit den Ausgabepixeln, daher vervierfacht eine Verdopplung des Zooms ungefähr Renderzeit und Speicher. Zwei Konsequenzen folgen direkt: Ein Cache, der die Zoomstufe nicht in seinem Schlüssel berücksichtigt, ist nutzlos, und ein unbegrenzter Cache erschöpft einen 32-Bit-Prozess genau bei den Dokumenten, bei denen Anwender am stärksten zoomen: dichte Scans und großformatige Zeichnungen.

Ein Cache-Schlüssel ist ein Vertrag mit dem Bildschirm

Ein gecachtes Bitmap darf nur wiederverwendet werden, wenn es zu allem passt, was seine Pixel beeinflusst hat: Seitenzahl, effektiver Zoom (oder Ausgabepixelgröße), Rotation, Monitor-DPI und die aktiven Renderoptionen. Eine Seite, die mit reAnnotations gerendert wurde, ist nicht dasselbe Bild wie eine ohne, und ein Graustufenrendering über reGrayscale ist wieder ein anderes Artefakt. Lassen Sie eines davon aus dem Schlüssel heraus, bekommen Sie die klassischen Symptome: veraltete Annotation-Overlays nach einer Review-Aktion oder eine unscharfe Seite, nachdem der Nutzer das Fenster auf einen anderen Monitor gezogen hat.

function TPageCache.Acquire(Pdf: TPdf; PageNo: Integer; ZoomPct: Single;
  Rotation: TRotation; Opts: TRenderOptions): TBitmap;
var
  Key: string;
begin
  Key := Format('%d|%.0f|%d|%d|%d',
    [PageNo, ZoomPct, Ord(Rotation), Screen.PixelsPerInch, OptionsMask(Opts)]);
  if FBitmaps.TryGetValue(Key, Result) then
    Exit;

  Pdf.PageNumber := PageNo;
  Result := Pdf.RenderPage(0, 0, OutputWidth(PageNo, ZoomPct),
    OutputHeight(PageNo, ZoomPct), Rotation, Opts);
  FBitmaps.Add(Key, Result);   // the cache now owns this bitmap
end;

Der Trefferpfad kehrt in Mikrosekunden zurück. Die interessante Frage ist, was mit Bitmaps passiert, die ihren Slot verlieren. Das ist eine Frage des Besitzes.

Wer gibt das Bitmap frei: das Leck, das nach dem Mittagessen auffällt

Die Funktionsform von RenderPage liefert ein TBitmap, das dem Aufrufer gehört. Bei einem einmaligen Export ist das offensichtlich; in einem Cache wird es zum häufigsten Speicherleck in Delphi-PDF-Viewern. Sobald das Bitmap in das Dictionary gelangt, hält der Cache die einzige Referenz, und beim Entfernen muss Free aufgerufen werden. Ein einfacher TDictionary erledigt das nicht für Sie. Das Leck erscheint nie in einem Zehn-Minuten-Test; es erscheint, nachdem eine Rechtsanwaltsgehilfin drei Stunden lang Urkunden gescrollt hat. Deshalb gehört Eviction unter Speicherdruck in den ersten Entwurf, nicht ins Backlog. Begrenzen Sie den Cache nach geschätzten Bytes (Breite × Höhe × 4), entfernen Sie least-recently-used Seiten außerhalb des Viewports und des Prefetch-Fensters, und geben Sie frei, was Sie entfernen. Die Überladungen, die in ein vom Aufrufer bereitgestelltes TBitmap oder direkt auf ein HDC rendern, umgehen Besitzfragen bei flüchtigen Zeichnungen. Das passt gut zur Druckvorschau, wo Caching ohnehin sinnlos ist.

Progressives Rendering und ehrlicher Abbruch

Die synchronen Aufrufe blockieren bis zum Ende. Für das Schiebereglerproblem wollen Sie RenderPageProgressive, das ein IPdfCancellationToken entgegennimmt und prsDone, prsCancelled oder prsFailed zurückgibt. Das entscheidende Verhaltensdetail: Der Abbruch wird an Chunk-Grenzen innerhalb des Renderings geprüft, nicht sofort. Ein mitten im Chunk signalisiertes Token beendet zuerst den aktuellen Chunk. Rechnen Sie auf komplexen Seiten also mit Abbruchlatenzen im zweistelligen Millisekundenbereich statt mit null. Entwerfen Sie dafür: Signalisieren Sie das alte Token in dem Moment, in dem ein neuer Zoomwert eintrifft, aber nehmen Sie nicht an, dass das alte Bitmap im selben Augenblick aufhört, sich zu ändern.

procedure TViewerForm.RequestRender(TargetZoom: Single);
var
  Status: TPdfProgressiveStatus;
begin
  if FTokenSource <> nil then
    FTokenSource.Cancel;           // abandon the previous in-flight render
  FTokenSource := TPdfCancellationTokenSource.New;  // FPdfAsync unit

  Status := Pdf.RenderPageProgressive(FBackBuffer, 0, 0,
    FBackBuffer.Width, FBackBuffer.Height, FTokenSource.Token,
    ro0, [reAnnotations]);

  case Status of
    prsDone:      PresentBackBuffer;
    prsCancelled: ;                // superseded by a newer request: drop silently
    prsFailed:    ShowRenderFailure;
  end;
end;

Behandeln Sie prsCancelled während der Interaktion als normales, häufiges Ergebnis, nicht als Fehler. Eine Renderwarteschlange, die jeden Abbruch als Warnung loggt, begräbt die eine Logzeile, die wirklich zählt. Kombinieren Sie den progressiven Pfad mit einem billigen Platzhalter: Das vorherige gecachte Bitmap auf den neuen Zoom zu skalieren sieht für 100 bis 200 ms weich aus und fühlt sich sofort an. Das verschafft dem Rendering in voller Qualität Zeit, fertig zu werden oder durch eine neuere Anfrage ersetzt zu werden.

Zoom und FitMode: der stille Reset

Die Viewer-Eigenschaft FitMode (pfmFitPage, pfmFitWidth) berechnet den Zoom bei jeder Größenänderung neu. Die Falle: Eine direkte Zuweisung an Zoom setzt FitMode auf pfmNone zurück. Das ist ein vernünftiger Standard, denn ein Nutzer, der 150 % gewählt hat, will nicht, dass eine Fenstergrößenänderung es rückgängig macht. Aber es trifft Toolbars, die Zoom-Schaltflächen als Zoom := Zoom * 1.25 implementieren und sich dann wundern, warum „auf Breite einpassen“ leise aufgehört hat zu funktionieren. Wenn Ihre UI beides anbietet, speichern Sie die letzte Fit-Wahl des Nutzers selbst und stellen Sie sie ausdrücklich wieder her, wenn er erneut die Fit-Schaltfläche drückt. Erwarten Sie nicht, dass die Komponente sich an einen Modus erinnert, den die Zoom-Zuweisung gerade verworfen hat.

Ein Speicherbudget, das Sie vertreten können

Zahlen machen die Richtlinie diskutierbar. Angenommen, Continuous Scroll hält die sichtbare Seite plus eine vorab geladene Seite in jede Richtung sowie einen Vorschaustreifen. Bei 100 % auf einem 96-DPI-Display sind das drei Bitmaps von jeweils etwa 3,5 MB: nichts. Bei 300 % auf einem 4K-Display sind es drei Bitmaps von ungefähr 30 MB, bevor der Cache irgendeine Historie hält. Ein vertretbarer Standard für einen 32-Bit-Delphi-Prozess ist ein Bitmap-Budget von 256 MB mit LRU-Eviction. Für 64 Bit skalieren Sie mit dem physischen RAM, behalten aber eine harte Obergrenze, denn der Fehlermodus ist nicht, dass Ihr Prozess stirbt; er besteht darin, dass die ganze Maschine auslagert, während Ihr Viewer „arbeitet“. Thumbnails sollten einmal in ihrer eigenen kleinen Pixelgröße gerendert und in einem separaten, nie entfernten Pool gehalten werden. Ein 120-Pixel-Thumbnail durch Schrumpfen eines 60-MB-Seitenbitmaps neu zu erzeugen, ist die teuerste denkbare Art, eine Briefmarke zu zeichnen.

Bei sehr großen Einzelseiten, etwa technischen Zeichnungen oder Karten, ist das Rendern der ganzen Seite bei hohem Zoom unabhängig vom großzügigen Budget nicht mehr tragfähig, weil ein E-Size-Blatt bei 400 % eine Allokation von mehreren Hundert Megabyte wird. Der Ausweg ist Kachelung: RenderTile rastert nur die Region am Pixeloffset (Left, Top) einer auf PageWidth × PageHeight skalierten Seite. Rendern Sie also nur den sichtbaren Bereich plus einen Kachelrand darum herum, und schlüsseln Sie den Cache neben dem Zoom auch nach diesen Kacheloffsets. Halten Sie Kachelgrößen fest, damit eine DPI-Änderung sauber invalidiert, statt Nähte zu erzeugen.

Farbfilterarbeit vervielfacht den Cachedruck ebenfalls: Nachgelagerte Renderoperationen wie Graustufen oder Invertierung erzeugen zusätzliche Bitmaps in voller Größe, ein Kostenpunkt, der in Farbfiltern für sehschwache Nutzer in Delphi-PDF-Viewern genauer betrachtet wird. Und wenn Ihr Viewer Wörter während Text-to-Speech hervorhebt, invalidiert das Highlight-Overlay die Ansicht bei jedem gesprochenen Wort. Wie das mit der Sprechgeschwindigkeit zusammenspielt, behandelt wortweise TTS-Hervorhebung.

Häufig gestellte Fragen

Warum verliert mein Delphi-PDF-Viewer beim Zoomen Speicher?

Fast immer, weil das TBitmap, das RenderPage zurückgibt, gecacht oder verworfen wird, ohne Free aufzurufen. Dem Aufrufer gehört dieses Bitmap; ein Cache, der es speichert, muss es beim Entfernen und bei der Cache-Zerstörung freigeben.

Warum stoppt ein abgebrochenes Rendering nicht sofort?

RenderPageProgressive fragt das Abbruchtoken an internen Chunk-Grenzen ab. Auf komplexen Seiten beendet ein signalisiertes Token noch seinen aktuellen Chunk. Entwerfen Sie die UI daher so, dass sie Abbruchlatenzen im zweistelligen Millisekundenbereich toleriert.

Warum funktionierte „auf Breite einpassen“ nicht mehr, nachdem ich Zoom gesetzt hatte?

Eine Zuweisung an Zoom setzt FitMode absichtlich auf pfmNone zurück. Stellen Sie den Fit-Modus ausdrücklich wieder her, wenn der Nutzer ihn erneut anfordert.

Rendering-Überladungen, progressive Statuscodes und die Viewer-Komponente sind auf der Produktseite dokumentiert: PDFium Component.