Technical Article

PDFium Delphi Viewer: Render Cache and Smooth Zoom Tactics

The support ticket read: "viewer freezes for two seconds every time I touch the zoom slider." The document was a 600-page scanned title deed, the machine a 4K laptop, and the code did what most first viewers do — re-render the visible page synchronously on every change event of the slider. Nothing was wrong with the rendering speed; a page rasterized in about 180 ms. The problem was that one slider drag fires dozens of change events, each queued a full-quality render, and none of them could be cancelled. Fixing that class of problem is less about making renders faster and more about deciding which renders not to finish. PDFium Component gives Delphi, C++Builder, and Lazarus viewers the right primitives — caller-owned bitmaps, a progressive renderer with cancellation, fit modes — and leaves the caching policy to you, which is exactly where it belongs.

Where the milliseconds go on a zoom change

Be concrete about the cost before designing the cache. An A4 page at 96 DPI is roughly 794 by 1123 pixels — about 3.5 MB as a 32-bit bitmap. At 200% zoom it is four times that; at 400% on a high-DPI display you are allocating and filling a 50–60 MB bitmap per page, and a continuous-scroll viewer keeps several pages alive at once. Rasterization cost scales with output pixels, so doubling zoom roughly quadruples render time along with memory. Two consequences follow directly: a cache that ignores zoom level in its key is useless, and an unbounded cache will exhaust a 32-bit process on exactly the documents where users zoom hardest — dense scans and large-format drawings.

A cache key is a contract with the screen

A cached bitmap may be reused only when it matches everything that influenced its pixels: page number, effective zoom (or output pixel size), rotation, monitor DPI, and the render options that were in force. A page rendered with reAnnotations is not the same image as one without, and a grayscale render via reGrayscale is a different artifact again. Leave any of these out of the key and you get the classic symptoms: stale annotation overlays after a review action, or a blurry page after the user drags the window to a different monitor.

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;

The hit path returns in microseconds. The interesting question is what happens to bitmaps that lose their slot — which is a question about ownership.

Who frees the bitmap: the leak that shows up after lunch

The function form of RenderPage returns a TBitmap the caller owns. In a one-shot export that is obvious; inside a cache it becomes the most common leak in Delphi PDF viewers. The moment the bitmap enters the dictionary, the cache holds the only reference, and eviction must call Free — a plain TDictionary will not do it for you. The leak never appears in a ten-minute test; it appears after a paralegal has scrolled deeds for three hours, which is why memory-pressure eviction belongs in the first design, not the backlog. Cap the cache by estimated bytes (width × height × 4), evict least-recently-used pages outside the viewport and prefetch window, and free what you evict. The overloads that render into a caller-provided TBitmap or directly onto an HDC sidestep ownership for transient draws — a good fit for print preview, where caching is pointless anyway.

Progressive rendering and honest cancellation

The synchronous calls block until done; for the slider-drag problem you want RenderPageProgressive, which takes an IPdfCancellationToken and returns prsDone, prsCancelled, or prsFailed. The crucial behavioral detail: cancellation is checked at chunk boundaries inside the render, not instantaneously. A token signalled mid-chunk finishes its current chunk first, so on a complex page expect cancellation latency in the tens of milliseconds rather than zero. Design for it — signal the old token the moment a new zoom value arrives, but do not assume the old bitmap stops changing the instant you ask.

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;

Treat prsCancelled as the normal, frequent outcome during interaction, not as an error. A render queue that logs every cancellation as a warning will bury the one log line that matters. Pair the progressive path with a cheap placeholder — scaling the previous cached bitmap to the new zoom looks soft for 100–200 ms and feels instant, which buys the full-quality render time to finish or be superseded.

Zoom and FitMode: the silent reset

The viewer's FitMode property (pfmFitPage, pfmFitWidth) recomputes zoom on every resize. The trap: assigning Zoom directly resets FitMode to pfmNone. That is a sane default — a user who chose 150% does not want a window resize to undo it — but it bites toolbars that implement zoom buttons as Zoom := Zoom * 1.25 and then wonder why fit-to-width quietly stopped working. If your UI offers both, persist the user's last fit choice yourself and restore it explicitly when they press the fit button again; do not expect the component to remember a mode that the zoom assignment just discarded.

A memory budget you can defend

Numbers make the policy discussable. Suppose continuous scroll keeps the visible page plus one prefetched page in each direction, plus a thumbnail strip. At 100% on a 96-DPI display that is three bitmaps of about 3.5 MB each — nothing. At 300% on a 4K display it is three bitmaps of roughly 30 MB each before the cache holds anything historical. A defensible default for a 32-bit Delphi process is a 256 MB bitmap budget with LRU eviction; for 64-bit, scale with physical RAM but keep a hard cap, because the failure mode is not your process dying — it is the whole machine paging while your viewer "works." Thumbnails should be rendered once at their own small pixel size and kept in a separate, never-evicted pool: regenerating a 120-pixel thumbnail by shrinking a 60 MB page bitmap is the most expensive way imaginable to draw a postage stamp.

For very large single pages — engineering drawings, maps — rendering the whole page at high zoom stops being viable no matter how generous the budget, because one E-size sheet at 400% is a multi-hundred-megabyte allocation. The escape hatch is tiling: RenderTile rasterizes just the region at pixel offset (Left, Top) of a page scaled to PageWidth × PageHeight, so render only the visible region plus a one-tile apron around it, and key the cache by those tile offsets as well as zoom. Keep tile dimensions fixed so a DPI change invalidates cleanly instead of producing seams.

Color-filter work multiplies cache pressure too: post-render operations such as grayscale or inversion produce additional full-size bitmaps, a cost examined in low-vision color filtering for Delphi PDF viewers. And if your viewer highlights words during text-to-speech, the highlight overlay invalidates the view on every spoken word — how that interacts with speech rate is covered in word-by-word TTS highlighting.

Frequently asked questions

Why does my Delphi PDF viewer leak memory when zooming?

Almost always because the TBitmap returned by RenderPage is cached or discarded without Free. The caller owns that bitmap; a cache that stores it must free it on eviction and on cache destruction.

Why doesn't cancelling a render stop it immediately?

RenderPageProgressive polls the cancellation token at internal chunk boundaries. On complex pages a signalled token still completes its current chunk, so design the UI to tolerate tens of milliseconds of cancellation latency.

Why did fit-to-width stop working after I set Zoom?

Assigning Zoom resets FitMode to pfmNone by design. Restore the fit mode explicitly when the user asks for it again.

Rendering overloads, progressive status codes, and the viewer component are documented on the product page: PDFium Component.