Articolo tecnico

Viewer PDFium Delphi: tattiche di render cache e zoom fluido

Il ticket di supporto diceva: "il viewer si blocca per due secondi ogni volta che tocco lo slider dello zoom". Il documento era un atto immobiliare scansionato da 600 pagine, la macchina un laptop 4K, e il codice faceva ciò che fanno molti primi viewer: renderizzava di nuovo la pagina visibile in modo sincrono a ogni evento change dello slider. La velocità di rendering non aveva nulla che non andasse; una pagina veniva rasterizzata in circa 180 ms. Il problema era che un solo trascinamento dello slider genera decine di eventi change, ognuno accodava un render a qualità piena, e nessuno poteva essere cancellato. Correggere questa classe di problemi riguarda meno il rendere più veloci i render e più il decidere quali render non finire. PDFium Component dà ai viewer Delphi, C++Builder e Lazarus i primitivi giusti, bitmap posseduti dal chiamante, renderer progressivo con cancellazione e fit mode, e lascia a te la policy di caching, che è esattamente il posto giusto.

Dove vanno i millisecondi durante un cambio zoom

Sii concreto sui costi prima di progettare la cache. Una pagina A4 a 96 DPI è circa 794 per 1123 pixel, circa 3,5 MB come bitmap a 32 bit. Al 200% di zoom è quattro volte tanto; al 400% su display high-DPI stai allocando e riempiendo un bitmap da 50–60 MB per pagina, e un viewer a scroll continuo tiene vive più pagine contemporaneamente. Il costo di rasterizzazione scala con i pixel di output, quindi raddoppiare lo zoom quadruplica grossomodo tempo di render e memoria. Ne seguono direttamente due conseguenze: una cache che ignora il livello di zoom nella chiave è inutile, e una cache senza limite esaurirà un processo a 32 bit proprio sui documenti dove gli utenti zoomano di più, scansioni dense e disegni in grande formato.

Una chiave cache è un contratto con lo schermo

Un bitmap in cache può essere riusato solo quando combacia con tutto ciò che ha influenzato i suoi pixel: numero pagina, zoom effettivo o dimensione pixel di output, rotazione, DPI del monitor e opzioni di rendering in vigore. Una pagina renderizzata con reAnnotations non è la stessa immagine di una senza, e un render in scala di grigi tramite reGrayscale è un artefatto diverso ancora. Lascia fuori uno di questi elementi e ottieni i sintomi classici: overlay di annotazioni stantii dopo un'azione di review, oppure pagina sfocata quando l'utente trascina la finestra su un monitor diverso.

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;

Il percorso hit ritorna in microsecondi. La domanda interessante è cosa succede ai bitmap che perdono il proprio slot, che è una domanda di ownership.

Chi libera il bitmap: il leak che compare dopo pranzo

La forma funzione di RenderPage restituisce un TBitmap posseduto dal chiamante. In un'esportazione one-shot è ovvio; dentro una cache diventa il leak più comune nei viewer PDF Delphi. Nel momento in cui il bitmap entra nel dizionario, la cache detiene l'unico riferimento, e l'eviction deve chiamare Free: un semplice TDictionary non lo farà per te. Il leak non compare in un test di dieci minuti; compare dopo che un paralegale ha scrollato atti per tre ore, ed è per questo che l'eviction sotto pressione di memoria appartiene al primo design, non al backlog. Limita la cache per byte stimati, width × height × 4, espelli con LRU le pagine fuori viewport e finestra di prefetch, e libera ciò che espelli. Gli overload che renderizzano in un TBitmap fornito dal chiamante o direttamente su un HDC aggirano l'ownership per disegni transitori, una buona scelta per la print preview, dove il caching è comunque inutile.

Rendering progressivo e cancellazione onesta

Le chiamate sincrone bloccano fino alla fine; per il problema del trascinamento dello slider vuoi RenderPageProgressive, che prende un IPdfCancellationToken e restituisce prsDone, prsCancelled o prsFailed. Il dettaglio comportamentale cruciale: la cancellazione viene controllata ai confini dei chunk interni al render, non istantaneamente. Un token segnalato a metà chunk finisce prima il chunk corrente, quindi su una pagina complessa aspettati latenza di cancellazione nell'ordine di decine di millisecondi invece che zero. Progetta per questo: segnala il vecchio token appena arriva un nuovo valore di zoom, ma non presumere che il vecchio bitmap smetta di cambiare nell'istante in cui lo chiedi.

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;

Tratta prsCancelled come esito normale e frequente durante l'interazione, non come errore. Una coda di render che registra ogni cancellazione come warning seppellirà l'unica riga di log importante. Abbina il percorso progressivo a un placeholder economico: scalare il bitmap precedente in cache al nuovo zoom appare morbido per 100–200 ms e sembra istantaneo, comprando tempo al render a qualità piena per finire o essere superato.

Zoom e FitMode: il reset silenzioso

La proprietà FitMode del viewer, pfmFitPage o pfmFitWidth, ricalcola lo zoom a ogni resize. La trappola: assegnare direttamente Zoom resetta FitMode a pfmNone. È un default sensato, un utente che ha scelto 150% non vuole che un resize finestra lo annulli, ma morde toolbar che implementano i pulsanti di zoom come Zoom := Zoom * 1.25 e poi si chiedono perché fit-to-width abbia smesso in silenzio di funzionare. Se la UI offre entrambi, persisti tu l'ultima scelta di fit dell'utente e ripristinala esplicitamente quando preme di nuovo il pulsante fit; non aspettarti che il componente ricordi una modalità che l'assegnazione dello zoom ha appena scartato.

Un budget memoria difendibile

I numeri rendono la policy discutibile. Supponi che lo scroll continuo mantenga la pagina visibile più una pagina prefetched in ciascuna direzione, più una striscia di miniature. Al 100% su display a 96 DPI sono tre bitmap da circa 3,5 MB ciascuno: niente. Al 300% su display 4K sono tre bitmap da circa 30 MB ciascuno prima che la cache tenga qualunque storico. Un default difendibile per un processo Delphi a 32 bit è un budget bitmap di 256 MB con eviction LRU; per 64 bit, scala con la RAM fisica ma mantieni un cap rigido, perché il failure mode non è la morte del tuo processo, è l'intera macchina che pagina mentre il viewer "funziona". Le miniature dovrebbero essere renderizzate una volta alla propria piccola dimensione pixel e tenute in un pool separato, mai espulso: rigenerare una miniatura da 120 pixel riducendo un bitmap pagina da 60 MB è il modo più costoso immaginabile per disegnare un francobollo.

Per pagine singole molto grandi, disegni tecnici o mappe, renderizzare l'intera pagina a zoom elevato smette di essere praticabile qualunque sia il budget, perché un foglio E-size al 400% è un'allocazione da centinaia di megabyte. La via di fuga è il tiling: RenderTile rasterizza solo la regione all'offset pixel (Left, Top) di una pagina scalata a PageWidth × PageHeight, quindi renderizza solo la regione visibile più un margine di un tile intorno, e chiave la cache anche per quegli offset tile oltre che per lo zoom. Mantieni fisse le dimensioni dei tile così un cambio DPI invalida in modo pulito invece di produrre giunzioni.

Anche il lavoro sui filtri colore moltiplica la pressione sulla cache: operazioni post-render come scala di grigi o inversione producono bitmap full-size aggiuntivi, un costo esaminato in filtri colore per ipovisione nei viewer PDF Delphi. E se il viewer evidenzia le parole durante text-to-speech, l'overlay di evidenziazione invalida la vista a ogni parola pronunciata: l'interazione con la velocità del parlato è trattata in evidenziazione TTS parola per parola.

Domande frequenti

Perché il mio viewer PDF Delphi perde memoria durante lo zoom?

Quasi sempre perché il TBitmap restituito da RenderPage viene messo in cache o scartato senza Free. Quel bitmap appartiene al chiamante; una cache che lo memorizza deve liberarlo in eviction e alla distruzione della cache.

Perché cancellare un render non lo ferma subito?

RenderPageProgressive interroga il token di cancellazione ai confini dei chunk interni. Su pagine complesse un token segnalato completa comunque il chunk corrente, quindi progetta la UI per tollerare decine di millisecondi di latenza di cancellazione.

Perché fit-to-width ha smesso di funzionare dopo aver impostato Zoom?

Assegnare Zoom resetta FitMode a pfmNone per design. Ripristina esplicitamente la modalità fit quando l'utente la richiede di nuovo.

Overload di rendering, codici di stato progressivo e componente viewer sono documentati nella pagina prodotto: PDFium Component.