Article technique

Visionneuse PDF à défilement continu dans Delphi avec le composant PDFium

Une seule page A4 affichée à un zoom de lecture confortable représente quelques mégaoctets de bitmap 32 bits. Multipliez cela par un contrat de 400 pages et le calcul n'a plus rien d'abstrait : faites le rendu de chaque page à l'avance et vous demanderez à Windows plus d'un gigaoctet de bitmaps que l'utilisateur ne consultera qu'un écran à la fois. L'application risque alors de manquer d'espace d'adressage sur une build 32 bits, ou de rester figée pendant ses premières secondes d'exécution pendant que le GPU et l'analyseur parcourent des pages sur lesquelles personne n'a encore défilé. Un lecteur à défilement continu doit donner l'illusion d'un long ruban de pages, mais il ne peut pas toutes les conserver en mémoire simultanément

Cette tension résume tout le problème. Le composant PDFium résout cela au sein de TPdfView, de sorte qu'est essentiel du travail consiste à choisir le bon mode d'affichage et à comprendre ce que le composant réalise pour vous. Les aspects qu'il ne prend pas en charge, à savoir le dimensionnement des pages pour le flux de lecture et le maintien de la réactivité lors d'un défilement rapide, nécessitent un peu de code. Si vous assemblez encore l'interface périphérique (barre d'outils, vignettes, zone de recherche), le guide de création d'une visionneuse complète couvre ce sujet ; ici, l'accent est mis sur le défilement lui-même

La mise en page est un mode d'affichage, pas un panneau de bitmaps

L'instinct issu du développement VCL pousse à utiliser un TScrollBox et à empiler des contrôles d'image (TImage), un par page. Évitez cela. Cette architecture vous obligerait à gérer le positionnement des pages, les calculs de défilement et la gestion mémoire de front, ce que vous finirez par réimplémenter maladroitement. TPdfView modélise déjà le document comme une suite continue de pages et expose sa disposition via la propriété DisplayMode

Pdf := TPdf.Create(Self);
PdfView := TPdfView.Create(Self);
PdfView.Parent := Self;
PdfView.Align := alClient;
PdfView.Pdf := Pdf;

PdfView.DisplayMode := dmSingleContinuous;   // one page wide, scrolls vertically

Pdf.FileName := 'contract.pdf';
Pdf.Active := True;
if not Pdf.Active then
  ShowMessage('Could not open the document');

C'est l'intégralité de la configuration pour le défilement continu. dmSingleContinuous organise les pages en une unique colonne verticale, les espaces inter-pages étant gérés en interne, et la vue défile sur cette colonne comme sur une seule surface. Il n'y a pas de contrôle par page à connecter, ni de gestionnaire de défilement à écrire pour la navigation courante. Notez la vérification de Pdf.Active après l'affectation : l'ouverture d'un document ne lève jamais d'exception, de sorte qu'un fichier endommagé ou protégé par mot de passe laisse Active à False sans exception à intercepter, et une visionneuse qui omet ce contrôle affichera un panneau vide et semblera en défaut

Cette même propriété prend en charge les modes double page. dmTwoPageContinuous place les pages côte à côte, deux par rangée, pour une lecture de type livre appréciée pour certains documents ; dmTwoPageContinuousWithCover fait de même mais laisse la première page isolée en guise de couverture pour que les doubles pages suivantes s'alignent sur la limite pair-impair. Ces trois modes défilent en continu. Passer de l'un à l'autre se résume à une simple affectation, ce qui rend l'ajout d'une liste déroulante de modes d'affichage très simple par la suite

Seules les pages visibles font l'objet d'un rendu raster

La raison pour laquelle cette méthode s'adapte à un fichier de 400 pages est que la colonne est virtuelle. TPdfView connaît la hauteur de chaque page grâce à l'arborescence des pages du document, ce qui lui permet de calculer l'étendue totale du défilement et la position de chaque page sans générer le moindre rendu. La rasterisation, l'étape coûteuse qui convertit le flux de contenu d'une page en pixels, se produit uniquement pour les pages qui intersectent actuellement la zone d'affichage (viewport), avec une marge réduite pour qu'une page soit prête dès qu'elle apparaît à l'écran. Lorsque vous défilez vers le bas, les pages entrant dans la zone d'affichage sont dessinées et celles qui la quittent libèrent leurs bitmaps. La consommation mémoire reste proportionnelle à ce qui s'affiche à l'écran, et non à la longueur du document

C'est un point important à assimiler car il modifie votre perception des ressources. L'ouverture d'un document de 400 pages est peu coûteuse car elle analyse la structure et non le contenu. La charge s'applique par page et de manière différée (lazy loading), à l'instant où le défilement s'en approche. Une visionneuse qui semble instantanée à l'ouverture et fluide au défilement ne fait pas moins de travail au global, elle répartit le traitement tout au long du parcours de lecture réel de l'utilisateur et abandonne ce qui redevient invisible. En pratique, vous ne devez presque jamais forcer le rendu des pages en amont. Laissez la vue décider de ce qui doit être visible

Ajuster la taille des pages à la largeur, puis laisser le zoom de côté

Un flux de lecture nécessite des pages ajustées à la largeur du panneau, et non figées sur un facteur de zoom absolu. Le mode FitMode s'en charge et maintient ce comportement lors du redimensionnement de la fenêtre

PdfView.FitMode := pfmFitWidth;   // each page fills the column width; height follows

Avec pfmFitWidth, le composant recalcule le zoom à chaque redimensionnement de la vue, de sorte que la colonne occupe toujours la largeur disponible et que la hauteur des pages, et donc l'étendue du défilement, s'adaptent en conséquence. Il existe un piège classique : l'attribution directe de Zoom réinitialise FitMode à pfmNone. C'est un comportement délibéré, un zoom manuel et un ajustement automatique étant contradictoires, mais cela implique qu'un appel PdfView.Zoom := 1.0 isolé dans votre code désactive silencieusement l'ajustement à la largeur et bloque le redimensionnement suivant. Si vous proposez à la fois un contrôle de zoom et un bouton d'ajustement, gérez-les comme un commutateur de mode : l'activation de l'un désactive l'autre, et vous déterminez lequel est prioritaire

Pour des contrôles de zoom absolu lisibles, la vue expose les valeurs d'ajustement sous forme de propriétés applicables ou affichables : PageWidthZoom[PageNumber] renvoie le zoom qui adapterait cette page à la largeur, et le PageZoom correspondant ajuste la page entière. La lecture de ces propriétés vous permet de remplir un menu "Ajuster à la largeur" / "Ajuster à la page" sans coder en dur des pourcentages arbitraires qui échoueraient sur des pages au format paysage ou hors normes

Maintenir la réactivité du défilement rapide grâce au rendu progressif

Le processus de rendu par défaut dessine une page jusqu'à son achèvement avant de rendre la main. Pour une page unique, cela convient. Lors d'un défilement rapide à travers un document dense, ce n'est plus le cas : chaque page qui défile déclenche une rasterisation complète, et si l'utilisateur défile plus vite que le rendu ne s'effectue, ces tâches s'accumulent et l'affichage saccade car du travail est fourni pour des pages déjà sorties de l'écran lorsqu'il se termine. La solution consiste à rendre le rendu annulable et à l'abandonner dès que l'utilisateur continue de défiler

RenderPageProgressive effectue le rendu par blocs et vérifie un jeton d'annulation (cancellation token) à chaque limite de bloc, de sorte qu'un rendu en cours pour une page qui vient de disparaître de l'écran puisse être abandonné plutôt que mené à son terme

type
  TFormMain = class(TForm)
    // ...
  private
    FRenderCancel: IPdfCancellationTokenSource;
    procedure RenderPageToBitmap(PageNo: Integer; Bmp: TBitmap);
  end;

procedure TFormMain.RenderPageToBitmap(PageNo: Integer; Bmp: TBitmap);
var
  Status: TPdfProgressiveStatus;
begin
  // Cancel whatever was rendering; the old token is now signaled.
  if Assigned(FRenderCancel) then
    FRenderCancel.Cancel;
  FRenderCancel := TPdfCancellationTokenSource.New;

  Pdf.PageNumber := PageNo;
  Status := Pdf.RenderPageProgressive(Bmp, 0, 0, Bmp.Width, Bmp.Height,
    FRenderCancel.Token);

  case Status of
    prsDone:      ;                    // bitmap is complete, paint it
    prsCancelled: Exit;                // superseded, discard this result
    prsFailed:    ShowMessage('Render failed for page ' + IntToStr(PageNo));
  end;
end;

L'élément clé est la valeur de retour. prsDone indique que le bitmap est entièrement dessiné et prêt à être affiché à l'écran ; prsCancelled signifie qu'une nouvelle position de défilement a rendu cette page obsolète, vous rejetez donc le résultat partiel plutôt que de l'afficher ; prsFailed signale une erreur réelle sur cette page. L'annulation étant vérifiée aux limites de blocs plutôt que de manière préemptive, prévoyez quelques dizaines de millisecondes de latence entre l'appel à Cancel et l'arrêt effectif du rendu. Cela reste bien moins coûteux que de laisser un rendu complet de page obsolète bloquer la file d'attente. Passer nil comme jeton effectue le rendu d'une seule traite, ce qui est le bon choix pour un rendu ponctuel comme un aperçu avant impression où il n'y a rien à annuler

Lorsque vous appelez la fonction RenderPage (celle qui renvoie un nouveau TBitmap), n'oubliez pas que l'appelant en est propriétaire et doit appeler Free pour le libérer. Dans une boucle de défilement qui alloue un bitmap par page, cet oubli crée une fuite mémoire qui croît à chaque page lue, ce qui correspond exactement au dépassement de mémoire que l'architecture continue cherchait à éviter. Effectuez le rendu dans un bitmap réutilisé dans la mesure du possible

Bilan

La réalisation d'un lecteur à défilement continu repose principalement sur le composant lui-même. Vous choisissez le mode dmSingleContinuous pour la mise en page, définissez pfmFitWidth pour que la colonne s'adapte à la fenêtre, et vérifiez Pdf.Active pour qu'un fichier défectueux génère un avertissement clair. La seule partie qu'il convient d'écrire soi-même concerne le rendu annulable, car la qualité d'un lecteur s'évalue à sa réactivité lorsque l'utilisateur glisse la barre de défilement vers le bas d'un long document. Tout le reste (sélection de texte sur plusieurs pages, mise en surbrillance de recherche, arborescence de signets) relève du travail d'interface qui s'ajoute au-dessus de cette surface de défilement plutôt qu'au sein de celle-ci

Les API TPdfView, DisplayMode et RenderPageProgressive présentées ici font partie du composant PDFium pour Delphi et Lazarus