Article technique

Visionneuse PDFium Delphi : cache de rendu et zoom fluide

Le ticket de support disait : « la visionneuse se fige pendant deux secondes chaque fois que je touche le curseur de zoom ». Le document était un titre foncier scanné de 600 pages, la machine un portable 4K, et le code faisait ce que font la plupart des premières visionneuses : rendre à nouveau la page visible de façon synchrone à chaque événement de changement du curseur. La vitesse de rendu n'avait rien d'anormal ; une page se rastérisait en environ 180 ms. Le problème venait du fait qu'un seul glissement du curseur déclenche des dizaines d'événements de changement, que chacun plaçait un rendu pleine qualité en file, et qu'aucun ne pouvait être annulé. Corriger ce type de problème consiste moins à rendre plus vite qu'à décider quels rendus ne doivent pas aller au bout. PDFium Component donne aux visionneuses Delphi, C++Builder et Lazarus les bons primitifs — bitmaps possédés par l'appelant, rendu progressif avec annulation, modes d'ajustement — et vous laisse la politique de cache, exactement là où elle doit être.

Où partent les millisecondes lors d'un changement de zoom

Soyez concret sur le coût avant de concevoir le cache. Une page A4 à 96 DPI fait environ 794 par 1123 pixels, soit à peu près 3,5 Mo en bitmap 32 bits. À 200 % de zoom, c'est quatre fois plus ; à 400 % sur un affichage haute résolution, vous allouez et remplissez un bitmap de 50 à 60 Mo par page, et une visionneuse en défilement continu garde plusieurs pages en vie à la fois. Le coût de rastérisation suit le nombre de pixels de sortie, donc doubler le zoom quadruple approximativement le temps de rendu en même temps que la mémoire. Deux conséquences suivent directement : un cache qui ignore le niveau de zoom dans sa clé est inutile, et un cache sans borne épuisera un processus 32 bits précisément sur les documents où les utilisateurs zooment le plus fort — scans denses et plans grand format.

Une clé de cache est un contrat avec l'écran

Un bitmap mis en cache ne peut être réutilisé que s'il correspond à tout ce qui a influencé ses pixels : numéro de page, zoom effectif (ou taille de sortie en pixels), rotation, DPI du moniteur et options de rendu en vigueur. Une page rendue avec reAnnotations n'est pas la même image qu'une page sans annotations, et un rendu en niveaux de gris via reGrayscale est encore un artefact différent. Omettez l'un de ces éléments de la clé et vous obtenez les symptômes classiques : surimpressions d'annotations périmées après une action de revue, ou page floue après que l'utilisateur a déplacé la fenêtre vers un autre moniteur.

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;

Le chemin de succès revient en microsecondes. La question intéressante est ce qui arrive aux bitmaps qui perdent leur emplacement — et c'est une question de propriété.

Qui libère le bitmap : la fuite qui apparaît après le déjeuner

La forme fonction de RenderPage renvoie un TBitmap que l'appelant possède. Dans un export ponctuel, c'est évident ; dans un cache, cela devient la fuite la plus courante des visionneuses PDF Delphi. Dès que le bitmap entre dans le dictionnaire, le cache détient la seule référence, et l'éviction doit appeler Free — un simple TDictionary ne le fera pas pour vous. La fuite n'apparaît jamais dans un test de dix minutes ; elle apparaît après qu'un juriste a fait défiler des titres pendant trois heures, raison pour laquelle l'éviction sous pression mémoire appartient à la conception initiale, pas au backlog. Limitez le cache par octets estimés (largeur × hauteur × 4), évincez en LRU les pages hors du viewport et de la fenêtre de prélecture, et libérez ce que vous évincez. Les surcharges qui rendent dans un TBitmap fourni par l'appelant ou directement sur un HDC évitent la question de propriété pour les dessins transitoires — un bon ajustement pour l'aperçu avant impression, où le cache n'a de toute façon aucun intérêt.

Rendu progressif et annulation honnête

Les appels synchrones bloquent jusqu'à la fin ; pour le problème du glissement du curseur, vous voulez RenderPageProgressive, qui prend un IPdfCancellationToken et renvoie prsDone, prsCancelled ou prsFailed. Le détail comportemental crucial : l'annulation est vérifiée aux frontières de blocs à l'intérieur du rendu, pas instantanément. Un jeton signalé au milieu d'un bloc termine d'abord ce bloc courant ; sur une page complexe, attendez-vous donc à une latence d'annulation de quelques dizaines de millisecondes plutôt qu'à zéro. Concevez pour cela : signalez l'ancien jeton dès qu'une nouvelle valeur de zoom arrive, mais ne supposez pas que l'ancien bitmap cesse de changer à l'instant où vous le demandez.

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;

Traitez prsCancelled comme le résultat normal et fréquent pendant l'interaction, pas comme une erreur. Une file de rendu qui journalise chaque annulation en avertissement enterrera la seule ligne de log importante. Associez le chemin progressif à un placeholder peu coûteux : mettre à l'échelle le précédent bitmap mis en cache vers le nouveau zoom paraît un peu doux pendant 100 à 200 ms et donne une sensation instantanée, ce qui laisse au rendu pleine qualité le temps de finir ou d'être remplacé.

Zoom et FitMode : la réinitialisation silencieuse

La propriété FitMode de la visionneuse (pfmFitPage, pfmFitWidth) recalcule le zoom à chaque redimensionnement. Le piège : affecter directement Zoom réinitialise FitMode à pfmNone. C'est un défaut raisonnable — un utilisateur qui a choisi 150 % ne veut pas qu'un redimensionnement de fenêtre l'annule — mais il mord les barres d'outils qui implémentent les boutons de zoom comme Zoom := Zoom * 1.25 puis se demandent pourquoi l'ajustement à la largeur a cessé de fonctionner. Si votre UI offre les deux, persistez vous-même le dernier choix d'ajustement de l'utilisateur et restaurez-le explicitement lorsqu'il appuie de nouveau sur le bouton d'ajustement ; n'attendez pas du composant qu'il se souvienne d'un mode que l'affectation de zoom vient d'écarter.

Un budget mémoire défendable

Les chiffres rendent la politique discutable. Supposons qu'un défilement continu garde la page visible plus une page préchargée dans chaque sens, ainsi qu'une bande de vignettes. À 100 % sur un affichage 96 DPI, cela fait trois bitmaps d'environ 3,5 Mo chacun — rien. À 300 % sur un affichage 4K, cela fait trois bitmaps d'environ 30 Mo chacun avant que le cache conserve le moindre historique. Un défaut défendable pour un processus Delphi 32 bits est un budget bitmap de 256 Mo avec éviction LRU ; en 64 bits, dimensionnez selon la RAM physique mais gardez une limite dure, car le mode de panne n'est pas la mort de votre processus, c'est toute la machine qui part en pagination pendant que votre visionneuse « fonctionne ». Les vignettes doivent être rendues une fois à leur petite taille propre et conservées dans un pool séparé jamais évincé : régénérer une vignette de 120 pixels en réduisant un bitmap de page de 60 Mo est la manière la plus coûteuse imaginable de dessiner un timbre-poste.

Pour les très grandes pages isolées — plans d'ingénierie, cartes — rendre toute la page à fort zoom cesse d'être viable, aussi généreux soit le budget, car une seule feuille au format E à 400 % devient une allocation de plusieurs centaines de mégaoctets. L'issue de secours est le tuilage : RenderTile rastérise seulement la région à l'offset pixel (Left, Top) d'une page mise à l'échelle vers PageWidth × PageHeight, donc rendez uniquement la région visible plus une bordure d'une tuile autour d'elle, et incluez ces offsets de tuile dans la clé de cache avec le zoom. Gardez des dimensions de tuile fixes afin qu'un changement de DPI invalide proprement au lieu de produire des joints.

Le travail de filtrage couleur multiplie lui aussi la pression sur le cache : les opérations post-rendu comme les niveaux de gris ou l'inversion produisent des bitmaps pleine taille supplémentaires, un coût examiné dans le filtrage couleur basse vision pour les visionneuses PDF Delphi. Et si votre visionneuse met les mots en surbrillance pendant la synthèse vocale, la surimpression de surbrillance invalide la vue à chaque mot prononcé ; son interaction avec le débit de parole est traitée dans la surbrillance TTS mot à mot.

Questions fréquentes

Pourquoi ma visionneuse PDF Delphi fuit-elle de la mémoire pendant le zoom ?

Presque toujours parce que le TBitmap renvoyé par RenderPage est mis en cache ou abandonné sans Free. L'appelant possède ce bitmap ; un cache qui le stocke doit le libérer à l'éviction et à la destruction du cache.

Pourquoi l'annulation d'un rendu ne l'arrête-t-elle pas immédiatement ?

RenderPageProgressive sonde le jeton d'annulation aux frontières internes de blocs. Sur des pages complexes, un jeton signalé termine encore son bloc courant ; concevez donc l'UI pour tolérer quelques dizaines de millisecondes de latence d'annulation.

Pourquoi l'ajustement à la largeur a-t-il cessé de fonctionner après avoir défini Zoom ?

Affecter Zoom réinitialise FitMode à pfmNone par conception. Restaurez explicitement le mode d'ajustement lorsque l'utilisateur le redemande.

Les surcharges de rendu, les codes de statut progressif et le composant de visionneuse sont documentés sur la page produit : PDFium Component.