Article technique

Visualiseur PDFium Delphi : cache de rendu et zoom fluide

Maintenez le bouton de zoom enfoncé dans un visualiseur PDF naïf et regardez la courbe du processeur. Une seule pression sur une commande de zoom à répétition automatique déclenche une douzaine de pas de zoom par seconde, et si chaque pas lance un nouveau rendu pleine qualité de la page visible, les rendus s'empilent plus vite qu'ils ne s'achèvent. La page se rastérise très bien isolément, peut-être 180 ms pour un scan A4, mais vous faites désormais tourner une douzaine de rendus de 180 ms sur un travail que l'utilisateur a déjà dépassé. Le visualiseur se fige, un cœur reste collé à 100 %, et le temps que l'écran rattrape son retard, l'utilisateur s'est arrêté à un niveau de zoom vieux de quatre rendus. Le remède n'est pas un rastériseur plus rapide. C'est un cache qui renvoie instantanément des pages finies et une boucle de rendu prête à abandonner un travail dès qu'il devient périmé

PDFium Component vous tend les pièces des deux et reste en dehors de la politique. Vous obtenez des bitmaps possédés par l'appelant, un moteur de rendu progressif qui prend un jeton d'annulation, des modes d'ajustement qui recalculent le zoom au redimensionnement, et un appel de tuilage pour les pages trop grandes à rastériser d'un bloc. Ce qu'il ne fournit délibérément pas, c'est le cache lui-même, parce que la bonne politique d'éviction dépend de votre zone d'affichage, du plafond mémoire de votre plateforme et de la manière dont vos utilisateurs font défiler. Cette décision vous revient, et les conséquences d'une erreur sont exactement le gel et la fuite

Où passent les millisecondes et les mégaoctets

Chiffrez le coût avant de concevoir quoi que ce soit. Une page A4 à 96 DPI fait environ 794 sur 1123 pixels, à peu près 3,5 Mo en bitmap 32 bits. Zoomez à 200 % et cela quadruple. À 400 % sur un écran haute densité, vous allouez et remplissez un seul bitmap de page de 50 à 60 Mo, et un visualiseur à défilement continu garde plusieurs pages vivantes à la fois. Le coût de rastérisation suit les pixels de sortie : chaque doublement du zoom quadruple donc en gros à la fois le temps de rendu et la mémoire

Deux conséquences tombent directement de cette arithmétique. Un cache dont la clé ignore le niveau de zoom ne vaut rien, car le geste même qu'il doit accélérer, le zoom, produit un nouveau bitmap à chaque fois. Et un cache sans borne épuisera l'espace d'adressage d'un processus 32 bits précisément sur les documents où les gens zooment le plus fort : scans denses de titres de propriété, plans techniques, cartes grand format. Le cache doit être correctement clé et fermement plafonné, et aucun des deux n'est facultatif

Ce qui doit figurer dans la clé de cache

Un bitmap mis en cache n'est réutilisable sans risque que si toutes les entrées qui ont façonné ses pixels correspondent encore. Cela veut dire le numéro de page, le zoom effectif (ou, ce qui revient au même, les dimensions de sortie en pixels), la rotation, la résolution du moniteur et les options de rendu en vigueur au moment de sa production. Une page rendue avec reAnnotations est une image différente de la même page sans elles, et une passe en niveaux de gris via reGrayscale en est encore une autre. Retirez l'un de ces éléments de la clé et les bugs sont prévisibles : une surcouche d'annotation qui traîne après qu'un relecteur a supprimé le commentaire, ou une page qui devient floue à l'instant où un utilisateur fait glisser la fenêtre de la dalle du portable vers un écran 4K externe et où la résolution change sous un bitmap périmé

Consultation du cache de rendu PDFium dans un visualiseur Delphi où la clé de cache combine page, zoom, rotation, résolution du moniteur et options de rendu, où un succès renvoie le bitmap en microsecondes, et où l'éviction libère chaque bitmap qu'elle abandonne
La clé de cache couvre chaque entrée qui façonne les pixels, et l'éviction libère les bitmaps qu'elle abandonne
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);   // le cache possède désormais ce bitmap
end;

En cas de succès, cela revient en microsecondes, et c'est tout l'intérêt. La question plus difficile est de savoir ce qu'il advient des bitmaps qui sortent du cache, et cela se révèle être une question de propriété

Qui libère le bitmap

La forme fonction de RenderPage renvoie un TBitmap dont l'appelant est propriétaire. Dans un export ponctuel, cette propriété est évidente et facile à honorer. À l'intérieur d'un cache, elle devient la fuite la plus courante des visualiseurs PDF en Delphi, car le dictionnaire détient maintenant l'unique référence de chaque bitmap, et un simple TDictionary ne libère clés et valeurs pour vous que s'il s'agit de types gérés. Un TBitmap n'en est pas un. Évincez une entrée sans appeler Free et les pixels restent alloués sans que rien ne pointe dessus

La raison pour laquelle cela passe à travers les mailles est temporelle. Un test de dix minutes ne zoome jamais assez de pages distinctes pour s'en apercevoir ; la fuite ne se montre qu'après que quelqu'un a fait défiler et zoomé un long document pendant deux heures, moment où le processus détient des centaines de bitmaps de page orphelins et où la machine se met à paginer. C'est pourquoi l'éviction a sa place dans la première version du cache, pas dans une version ultérieure. Plafonnez le cache par octets estimés, calculés comme largeur fois hauteur fois quatre, évincez selon le moins récemment utilisé les pages hors de la zone d'affichage et de la fenêtre de préchargement, et libérez chaque bitmap au moment où vous le retirez. Pour les dessins réellement passagers, les surcharges qui rendent dans un TBitmap fourni par l'appelant ou directement sur un HDC vous permettent de sauter entièrement la danse de la propriété. L'aperçu avant impression est le cas évident, puisque vous rendez chaque feuille une fois et que la mettre en cache n'apporte rien

Rendu progressif et annulation honnête

Les surcharges simples de RenderPage bloquent jusqu'à ce que la page soit finie, ce qui est exactement le comportement dont vous ne voulez pas pendant que l'utilisateur bouge encore la commande de zoom. Pour cela, vous vous tournez vers RenderPageProgressive. Il prend un IPdfCancellationToken et renvoie l'un de prsDone, prsCancelled ou prsFailed. Le détail de comportement qui piège est que l'annulation n'est pas instantanée. Le jeton est interrogé aux frontières de fragments à l'intérieur du rendu : un jeton que vous signalez au milieu d'un fragment ne prend donc effet qu'une fois ce fragment terminé. Sur une page complexe, la latence entre la demande et l'arrêt se compte en dizaines de millisecondes. Concevez autour de cet écart plutôt que d'espérer le voir disparaître : annulez le jeton précédent dès qu'une nouvelle valeur de zoom arrive, mais ne supposez pas que l'ancien rendu s'arrête à l'instant où vous le lui demandez

Chronologie du rendu progressif PDFium en Delphi où chaque nouvelle demande de zoom annule le jeton précédent, où l'annulation prend effet à une frontière de fragment, où les rendus supplantés renvoient prsCancelled et où la dernière tentative renvoie prsDone
Chaque nouvelle demande de zoom annule le jeton de rendu précédent, et l'annulation prend effet à une frontière de fragment
procedure TViewerForm.RequestRender(TargetZoom: Single);
var
  Status: TPdfProgressiveStatus;
begin
  if FTokenSource <> nil then
    FTokenSource.Cancel;           // abandonne le rendu précédent encore en vol
  FTokenSource := TPdfCancellationTokenSource.New;  // unité FPdfAsync

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

  case Status of
    prsDone:      PresentBackBuffer;
    prsCancelled: ;                // supplanté par une demande plus récente : ignorer
    prsFailed:    ShowRenderFailure;
  end;
end;

Pendant l'interaction, prsCancelled est le résultat normal, pas l'exception. La plupart des rendus qu'un geste de zoom démarre seront supplantés avant de finir : traitez donc l'annulation comme une routine et abandonnez le résultat en silence. Une file de rendu qui consigne chaque annulation comme un avertissement enterrera l'unique échec qui compte vraiment sous des milliers de lignes de bruit. Pour que l'écran n'ait pas l'air mort pendant que le vrai rendu tourne, associez au chemin progressif un remplaçant bon marché : mettez à l'échelle le bitmap précédemment mis en cache au nouveau zoom et présentez-le immédiatement. Cela paraît flou pendant une centaine ou deux de millisecondes, mais cela se lit comme instantané, et cela achète au rendu pleine qualité le temps dont il a besoin, soit pour finir, soit pour être annulé par le geste suivant

Le mode d'ajustement que le zoom coupe discrètement

La propriété FitMode d'un visualiseur, posée à pfmFitPage ou pfmFitWidth, recalcule le zoom à chaque redimensionnement pour que la page continue de tenir quand la fenêtre change. Le piège est qu'affecter Zoom directement remet FitMode à pfmNone. Comme comportement par défaut, c'est correct : un utilisateur qui a délibérément tapé 150 % ne veut pas que le prochain redimensionnement de fenêtre le jette. Mais cela surprend quiconque câble un bouton de zoom avant comme Zoom := Zoom * 1.25 et ne comprend ensuite pas pourquoi l'ajustement à la largeur a cessé de répondre après le premier clic. Si votre barre d'outils propose à la fois le zoom explicite et les modes d'ajustement, vous devez vous-même mémoriser le dernier choix d'ajustement de l'utilisateur et le réaffecter quand il represse le bouton correspondant. Le composant ne restaurera pas un mode qu'une affectation de zoom vient d'effacer, et ce n'est pas son rôle

Un budget mémoire défendable

Un budget que vous pouvez écrire est un budget que vous pouvez défendre en revue de code : partez donc d'un scénario concret. Disons qu'un défilement continu garde la page visible plus une page préchargée au-dessus et au-dessous, à côté d'une bande de vignettes. À 100 % sur un écran à 96 DPI, ces trois bitmaps pleine taille font environ 3,5 Mo chacun, ce qui n'est rien. À 300 % sur un écran 4K, les mêmes trois bitmaps font à peu près 30 Mo chacun, et c'est avant que le cache n'ait retenu la moindre page historique. La croissance est dans le geste, pas dans le document

Arithmétique de la mémoire des bitmaps PDFium pour un visualiseur Delphi où chaque doublement du zoom quadruple la mémoire de page, où le défilement continu garde trois pages vivantes, où un budget LRU plafonné défend le cache et où RenderTile traite les dessins surdimensionnés
Chaque doublement du zoom quadruple la mémoire des bitmaps, si bien que le cache a besoin d'un plafond dur et de tuiles pour les pages surdimensionnées

Un défaut solide pour un processus Delphi 32 bits est un budget de bitmaps de 256 Mo sous éviction LRU. En 64 bits vous pouvez l'échelonner sur la RAM physique, mais gardez un plafond dur quoi qu'il arrive, car l'échec dont vous vous prémunissez n'est pas le plantage de votre processus. C'est toute la machine qui s'épuise sur son fichier d'échange pendant que votre visualiseur continue techniquement de tourner et que l'utilisateur se demande pourquoi tout le reste a ralenti. Un plafond dur échoue de façon prévisible ; un cache sans borne échoue en emportant le bureau avec lui. Les vignettes méritent leur propre traitement : rendez chacune une fois à sa petite taille cible et gardez-la dans un pool séparé auquel la logique LRU ne touche jamais. Régénérer une vignette de 120 pixels en réduisant un bitmap pleine page de 60 Mo est la façon la plus gaspilleuse possible de produire un timbre-poste

Certaines pages seules mettent en échec n'importe quel budget. Un plan technique de format E ou une grande carte rendus entiers à 400 % représentent une allocation de plusieurs centaines de mégaoctets, et aucune politique d'éviction ne rend cela acceptable. La réponse est alors de cesser de rendre des pages entières. RenderTile ne rastérise que la région située au décalage en pixels (Left, Top) à l'intérieur d'une page théoriquement mise à l'échelle sur PageWidth par PageHeight : vous ne rendez donc que le rectangle visible plus une marge d'une tuile autour pour un panoramique fluide, et vous repliez les décalages de tuile dans la clé de cache à côté du zoom. Gardez des dimensions de tuile fixes sur tout le document. Des tuiles fixes signifient qu'un changement de résolution invalide proprement toute la grille, là où des tuiles variables vous laissent courir après des coutures visibles entre des régions rendues à des échelles légèrement différentes

Deux fonctions voisines alourdissent discrètement tout cela. Les passes de filtre couleur comme les niveaux de gris ou l'inversion s'exécutent après le rendu et produisent à chaque fois un second bitmap pleine taille, doublant l'empreinte par page de toute vue qui les utilise ; ce coût fait l'objet du filtrage couleur pour malvoyants dans les visualiseurs PDF Delphi. Et un visualiseur qui surligne les mots pendant la synthèse vocale invalide la vue rendue à chaque mot prononcé, si bien que l'interaction entre les redessins de surlignage et le débit de parole compte plus qu'il n'y paraît, comme le couvre le surlignage TTS mot à mot

Les surcharges de rendu, les codes d'état progressifs et le composant visualiseur lui-même sont documentés sur la page produit de PDFium Component