Article technique

Visionneuse PDF personnalisée HotPDF en Delphi : l'architecture MVC

HotPDF sépare sa visionneuse PDF Delphi en deux éléments : THPDFViewerModel, une classe simple qui détient l'état de zoom, de rotation, de recherche, de surlignage et de navigation sans aucune dépendance à un handle de fenêtre, et THPDFViewer, un contrôle basé sur TScrollBox qui transforme cet état en pixels. Cette séparation est ce qui permet à la logique de la visionneuse de s'exécuter, et d'être testée, sans jamais créer de formulaire

La plupart des contrôles de visionneuse personnalisés ne ressemblent pas à cela. Le niveau de zoom vit dans un champ privé du contrôle, la navigation entre pages limite ses bornes à l'intérieur du gestionnaire OnClick d'un bouton, et le seul moyen de savoir si Ctrl+molette respecte un plafond de zoom est de lancer l'application, de cliquer, et de regarder. Un contrôle construit ainsi fonctionne bien jusqu'à ce qu'il ait besoin d'une suite de régression, ou d'un second hôte — une boîte de dialogue d'aperçu avant impression, un rail de vignettes, un réviseur par lot sans aucune fenêtre visible — et l'état dont on a besoin se révèle soudé à un TWinControl qui insiste pour avoir un véritable handle avant de faire quoi que ce soit

Pourquoi un contrôle de visionneuse PDF a-t-il besoin d'une séparation MVC ?

Une visionneuse PDF a besoin de ce type de séparation parce que son état et sa présentation changent pour des raisons différentes et à des rythmes différents. L'index de page, le zoom, la rotation d'affichage, les résultats de recherche et les zones de surlignage sont de l'état métier : ils peuvent être calculés, validés et sérialisés sans qu'un seul pixel n'apparaisse à l'écran. Peindre un bitmap, capturer la souris et dessiner un rectangle de sélection en pointillé sont des préoccupations de présentation qui n'ont de sens qu'une fois qu'un contrôle existe. HotPDF garde le premier groupe dans THPDFViewerModel, une classe sans aucun ancêtre de fenêtrage VCL, et le second groupe dans THPDFViewer, qui détient une instance de modèle et y réagit — plus proche d'une paire Modèle-Vue que d'un MVC à trois niveaux de manuel scolaire, puisqu'il n'y a pas de classe Contrôleur séparée et que THPDFViewer lui-même convertit les événements bruts de clavier et de souris en appels au modèle. Ce qui compte plus que l'étiquette, c'est le sens de la dépendance : rien dans THPDFViewerModel ne requiert un Handle, une boucle de messages, ou un bureau visible, ce qui est précisément ce qui permet à la propre suite de tests de HotPDF de piloter la pagination, le plafonnement du zoom, les commandes clavier et les allers-retours de coordonnées via DUnitX sans ouvrir de fenêtre

uses
  DUnitX.TestFramework,
  HPDFDoc, HPDFViewerModel;

type
  [TestFixture]
  TViewerModelTests = class
  public
    [Test]
    procedure ZoomInStopsAtTheTopPresetLevel;
  end;

procedure TViewerModelTests.ZoomInStopsAtTheTopPresetLevel;
var
  Doc: THotPDF;
  Model: THPDFViewerModel;
begin
  Doc := THotPDF.Create(nil);
  Model := THPDFViewerModel.Create;
  try
    Doc.LoadFromFile('sample.pdf');
    Model.Document := Doc;
    Model.Zoom := 64.0;          // top of the preset table (6400%)
    Model.ZoomIn;                // already at the ceiling
    Assert.AreEqual(64.0, Model.Zoom, 0.0001);
  finally
    Model.Free;
    Doc.Free;
  end;
end;

Ce que THPDFViewerModel détient réellement

THPDFViewerModel détient tout ce dont une visionneuse a besoin pour répondre à la question de ce qui devrait actuellement figurer à l'écran, sans détenir la manière de le dessiner. PageIndex, PageNumber, et PageCount suivent la position ; Zoom et ZoomMode (vzmActualSize, vzmFitPage, vzmFitWidth, vzmCustom) suivent l'échelle ; ViewRotation suit une rotation d'affichage non destructive qui ne touche jamais l'entrée /Rotate propre de la page. Les méthodes de navigation — FirstPage, PriorPage, NextPage, LastPage — et les méthodes de zoom — ZoomIn, ZoomOut, qui parcourent une table fixe de dix-neuf niveaux prédéfinis de 5 % à 6400 % — vivent également ici, aux côtés de FindAll/FindNext/FindPrevious pour la recherche de texte et AddHighlightRegion/RemoveHighlightRegion/ClearHighlightRegions pour les annotations de page persistantes qu'un appelant souhaite conserver entre les rendus. Le modèle détient aussi bien la sortie que l'entrée : CreateCurrentPageSnapshot et CreateCurrentPageMetafile exportent exactement la page actuellement à l'écran, et PrintCurrentView envoie cette même vue actuelle — page actuelle, DPI dérivé du zoom actuel, rotation actuelle — vers un TPrinter, une tâche plus restreinte, limitée à la vue, que le pipeline d'impression à l'échelle du document couvert dans le guide d'impression TPrinter de HotPDF. Chaque mutation qui compte déclenche aussi un événement correspondant — OnPageChange, OnZoomChange, OnSearchChange, OnHighlightChange, OnViewRotationChange — de sorte qu'un abonné apprend ce qui a changé sans avoir à sonder l'état

Comment THPDFViewer sait-il quand redessiner ?

THPDFViewer sait quand redessiner parce qu'il s'abonne au modèle plutôt que de deviner. Le constructeur de THPDFViewer crée un THPDFViewerModel privé, puis connecte chacun de ses événements de notification — OnBeginUpdate, OnEndUpdate, OnHighlightChange, OnPageChange, OnSearchChange, OnViewRotationChange, OnZoomChange — à un gestionnaire privé correspondant. La tâche de chaque gestionnaire est réduite : appeler RefreshDocument, la méthode qui rastérise réellement la page actuelle via le même moteur de rendu de page mis en cache décrit dans les rouages internes du rendu page-vers-bitmap de HotPDF, puis composite par-dessus les rectangles de surlignage et les résultats de recherche et applique la rotation d'affichage actuelle. Les propriétés publiées comme PageIndex, Zoom, ZoomMode, et ViewRotation sont de simples relais — le getter lit FModel.PageIndex, le setter écrit FModel.PageIndex — de sorte que, depuis l'Inspecteur d'objets ou depuis le code, le contrôle a l'air de détenir l'état directement, alors même que THPDFViewerModel est le seul endroit où cet état vit réellement. Les appelants ne sont pas non plus limités au sous-ensemble relayé : THPDFViewer expose le modèle lui-même via une propriété en lecture seule Model: THPDFViewerModel, si bien que le code qui veut FindFormFieldAt ou PrefetchCurrentPageSnapshots — dont aucun n'est réexposé par le contrôle — peut passer outre l'enveloppe et appeler le modèle directement

procedure THPDFViewer.RefreshDocument;
var
  Bitmap: TBitmap;
  DPI: Integer;
begin
  // simplified: the real method also resolves fit-mode DPI
  // and composites highlight and search-hit rectangles first
  if (FModel.Document = nil) or (FModel.PageIndex < 0) then Exit;
  DPI := Round(96 * FModel.Zoom);
  Bitmap := FModel.Document.RenderLoadedPageToBitmapCached(FModel.PageIndex, DPI);
  try
    FModel.ApplyViewRotation(Bitmap);
    FImage.Picture.Bitmap.Assign(Bitmap);
  finally
    Bitmap.Free;
  end;
end;

BeginUpdate et EndUpdate : arrêter les tempêtes de redessin

BeginUpdate et EndUpdate existent parce qu'un seul changement logique touche souvent plusieurs éléments d'état à la fois, et redessiner après chaque élément serait à la fois coûteux et visuellement bruyant. Remplacer le document chargé en est l'exemple le plus clair : assigner THPDFViewerModel.Document réinitialise la rotation d'affichage, efface les résultats de recherche, efface les zones de surlignage, et saute à la page un, et chacune de ces étapes déclenche normalement son propre événement de changement. THPDFViewerModel enveloppe cette séquence dans une paire BeginUpdate/EndUpdate à comptage de références, où les appels imbriqués ne déclenchent OnBeginUpdate qu'à la transition vers l'appel le plus externe et OnEndUpdate qu'à la transition de sortie correspondante. THPDFViewer suit cette même profondeur de son côté et saute RefreshDocument pour chaque événement granulaire tant que le compteur est supérieur à zéro, puis redessine exactement une fois lorsque le lot se referme. Les événements granulaires se déclenchent toujours pendant le lot, de sorte qu'un abonné qui ne s'intéresse qu'à OnSearchChange en est toujours informé ; c'est seulement le redessin propre du contrôle qui se retrouve regroupé en un seul appel au lieu de quatre

Comment le surlignage en rectangle de sélection reconvertit-il un glisser de souris en coordonnées PDF ?

Le surlignage en rectangle de sélection reconvertit un glisser de souris en coordonnées PDF via une paire de méthodes du modèle construites précisément pour cet aller-retour : PagePointToView et ViewPointToPage. Toutes deux prennent un index de page, un DPI, et un point, et toutes deux résolvent la transformation en deux étapes — d'abord l'entrée /Rotate propre de la page et son origine PDF en bas à gauche, puis le ViewRotation distinct et non destructif de la vue et l'origine de périphérique en haut à gauche de la visionneuse — précisément pour que la direction inverse puisse défaire les deux étapes dans l'ordre strictement inverse et effectuer un aller-retour correct sur les seize combinaisons possibles de rotation de page et de rotation de vue. THPDFViewer appelle ViewPointToPage lorsque l'utilisateur relâche la souris après avoir glissé un rectangle en mode d'interaction vimHighlight, transforme les deux points de périphérique en un THPDFRectangle dans l'espace de la page, et le transmet à Model.AddHighlightRegion. Un détail qui mérite d'être connu si vous construisez quelque chose de similaire : la capture de la souris appartient à la visionneuse descendant de TScrollBox, pas au TImage enfant dans lequel le bitmap est peint, car TControl.MouseCapture est protégé et seul le contrôle parent peut la revendiquer — de sorte qu'un glissement qui sort des limites de l'image avant que le bouton ne soit relâché se résout tout de même via les propres MouseMove/MouseUp surchargés de la visionneuse, au lieu d'être silencieusement perdu par le contrôle enfant

var
  ViewPt, PagePt: THPDFViewerPoint;
  Rect: THPDFRectangle;
begin
  ViewPt.X := 240;   // device pixels inside the rendered image
  ViewPt.Y := 96;
  if Model.ViewPointToPage(Model.PageIndex, ViewPt, PagePt,
     RenderedDPI) then                 // DPI you last rendered at
  begin
    Rect.Left := PagePt.X - 40;  Rect.Bottom := PagePt.Y - 10;
    Rect.Right := PagePt.X + 40; Rect.Top := PagePt.Y + 10;
    Model.AddHighlightRegion(Model.PageIndex, Rect);
  end;
end;

Ce que cette séparation apporte au-delà d'une suite de tests au vert

Le gain ne se limite pas aux tests qui réussissent dans une tâche CI sans session de bureau. Parce que THPDFViewer relaie vers THPDFViewerModel plutôt que de dupliquer sa logique, HotPDF a pu ajouter un troisième consommateur — THPDFViewerAction et des sous-classes concrètes comme THPDFZoomInAction et THPDFFindNextAction — qui branchent la navigation, le zoom, la recherche et la rotation sur une TActionList Delphi standard, de sorte qu'un bouton de barre d'outils ou un élément de menu peut piloter la visionneuse de façon déclarative, en s'activant automatiquement selon qu'une visionneuse est actuellement résolue comme cible de l'action. Rien dans cette couche n'a eu besoin de connaître quoi que ce soit sur les bitmaps ou GDI ; elle appelle Viewer.NextPage ou Viewer.Model.FindNext, et la chaîne d'événements existante se charge du redessin. Et parce que rien dans THPDFViewerModel ne référence TScrollBox, TImage, ou un handle de fenêtre, la machine à états sous-jacente n'est pas non plus soudée à ce seul contrôle — le même modèle pourrait se placer derrière une surface de rendu différente sans toucher une seule ligne de logique de navigation, de zoom, ou de recherche

Où le cache de rendu aide, et où il n'aide pas

Le cache de rendu de THPDFViewerModel aide à l'intérieur d'un document chargé, mais il ne change rien au coût du chargement de ce document en premier lieu. CreatePageSnapshot, CreateCurrentPageSnapshot, et les méthodes de préchargement PrefetchPageSnapshots/PrefetchCurrentPageSnapshots passent toutes par le même moteur de rendu mis en cache, indexé par page et par DPI, de sorte que revenir à une page déjà visualisée au même niveau de zoom est un succès de cache plutôt qu'un nouveau rendu, et précharger un petit rayon de pages voisines fluidifie le cas courant d'un lecteur qui avance page par page. Rien de tout cela ne touche cependant le coût de l'appel initial à LoadFromFile, et une visionneuse conçue pour ouvrir tout ce qu'un utilisateur y glisse finit par rencontrer un fichier assez volumineux pour que cet appel devienne le véritable goulot d'étranglement. Pour l'alternative par paliers, basée sur des handles, à un chargement complet — utile à connaître avant que ce jour n'arrive — voir l'article compagnon sur l'API de fichier direct pour les PDF volumineux

Les classes Model et View décrites ici sont deux éléments supplémentaires de la même surface de document chargé utilisée dans l'ensemble du composant HotPDF pour Delphi et C++Builder, conçues pour être pilotées depuis un formulaire, depuis une TActionList, ou depuis aucun des deux