Technischer Artikel

HotPDF Custom PDF-Viewer in Delphi: die MVC-Architektur

HotPDF trennt seinen Delphi-PDF-Viewer in zwei Teile: THPDFViewerModel, eine einfache Klasse, die Zoom-, Rotations-, Such-, Highlight- und Navigationszustand ohne jede Window-Handle-Abhängigkeit besitzt, und THPDFViewer, ein auf TScrollBox basierendes Steuerelement, das diesen Zustand in Pixel umsetzt. Diese Trennung ist es, was es ermöglicht, die Viewer-Logik laufen zu lassen und zu testen, ohne jemals ein Formular zu erzeugen

Die meisten selbstgebauten Viewer-Steuerelemente sehen nicht so aus. Der Zoomfaktor liegt in einem privaten Feld des Steuerelements, die Seitennavigation begrenzt ihre Grenzen innerhalb des OnClick-Handlers eines Buttons, und die einzige Möglichkeit herauszufinden, ob Strg+Scrollen eine Zoom-Obergrenze respektiert, besteht darin, die Anwendung zu starten, zu klicken und hinzusehen. Ein so gebautes Steuerelement funktioniert gut, bis es eine Regressions-Testsuite benötigt oder einen zweiten Host – einen Druckvorschau-Dialog, eine Miniaturansichtsleiste, einen Batch-Reviewer ganz ohne sichtbares Fenster – und der benötigte Zustand stellt sich als fest verschweißt mit einem TWinControl heraus, das auf einem echten Handle besteht, bevor es überhaupt irgendetwas tut

Warum braucht ein PDF-Viewer-Steuerelement überhaupt eine MVC-Trennung?

Ein PDF-Viewer braucht diese Art von Trennung, weil sich sein Zustand und seine Darstellung aus unterschiedlichen Gründen und mit unterschiedlicher Geschwindigkeit ändern. Seitenindex, Zoom, Ansichtsrotation, Suchtreffer und Highlight-Bereiche sind Geschäftszustand: Sie lassen sich berechnen, validieren und serialisieren, ohne dass ein einziges Pixel auf dem Bildschirm erscheint. Das Malen einer Bitmap, das Erfassen der Maus und das Zeichnen eines Auswahlrechtecks (Marquee) sind Darstellungsbelange, die erst Sinn ergeben, sobald ein Steuerelement existiert. HotPDF hält die erste Gruppe in THPDFViewerModel, einer Klasse ganz ohne VCL-Fensterahnen, und die zweite Gruppe in THPDFViewer, das eine Model-Instanz besitzt und darauf reagiert – näher an einem Model-View-Paar als an einem klassischen dreistufigen MVC im Lehrbuchsinn, da es keine separate Controller-Klasse gibt und THPDFViewer selbst rohe Tastatur- und Mausereignisse in Model-Aufrufe umsetzt. Wichtiger als die Bezeichnung ist die Abhängigkeitsrichtung: Nichts an THPDFViewerModel erfordert ein Handle, eine Nachrichtenschleife oder einen sichtbaren Desktop – genau das ist es, was es HotPDFs eigener Testsuite erlaubt, Seitenwechsel, Zoom-Begrenzung, Tastaturbefehle und Koordinaten-Hin-und-Rück-Übersetzungen über DUnitX zu testen, ohne ein Fenster zu öffnen

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;

Was THPDFViewerModel tatsächlich besitzt

THPDFViewerModel besitzt alles, was ein Viewer braucht, um zu beantworten, was aktuell auf dem Bildschirm stehen sollte, ohne zu besitzen, wie es gezeichnet wird. PageIndex, PageNumber und PageCount verfolgen die Position; Zoom und ZoomMode (vzmActualSize, vzmFitPage, vzmFitWidth, vzmCustom) verfolgen den Maßstab; ViewRotation verfolgt eine nicht-destruktive Bildschirmrotation, die den eigenen /Rotate-Eintrag der Seite nie berührt. Navigationsmethoden – FirstPage, PriorPage, NextPage, LastPage – und Zoom-Methoden – ZoomIn, ZoomOut, die eine feste Tabelle von neunzehn voreingestellten Stufen von 5% bis 6400% durchlaufen – befinden sich ebenfalls hier, zusammen mit FindAll/FindNext/FindPrevious für die Textsuche und AddHighlightRegion/RemoveHighlightRegion/ClearHighlightRegions für dauerhafte Seitenannotationen, die ein Aufrufer zwischen Renderings behalten möchte. Das Model besitzt auch Ausgabe, nicht nur Eingabe: CreateCurrentPageSnapshot und CreateCurrentPageMetafile exportieren genau die Seite, die aktuell auf dem Bildschirm steht, und PrintCurrentView sendet genau diese aktuelle Ansicht – aktuelle Seite, aktuelle aus dem Zoom abgeleitete DPI, aktuelle Rotation – an einen TPrinter, ein engerer, auf die Ansicht beschränkter Auftrag als die dokumentweite Druck-Pipeline, die in HotPDFs TPrinter-Druck-Durchgang behandelt wird. Jede relevante Änderung löst zudem ein passendes Ereignis aus – OnPageChange, OnZoomChange, OnSearchChange, OnHighlightChange, OnViewRotationChange –, sodass ein Abonnent erfährt, was sich geändert hat, ohne pollen zu müssen

Woher weiß THPDFViewer, wann es neu zeichnen muss?

THPDFViewer weiß, wann es neu zeichnen muss, weil es das Model abonniert, statt zu raten. Der Konstruktor von THPDFViewer erzeugt ein privates THPDFViewerModel und verdrahtet dann jedes seiner Benachrichtigungsereignisse – OnBeginUpdate, OnEndUpdate, OnHighlightChange, OnPageChange, OnSearchChange, OnViewRotationChange, OnZoomChange – mit einem passenden privaten Handler. Die Aufgabe jedes Handlers ist klein: RefreshDocument aufrufen, die Methode, die die aktuelle Seite tatsächlich über denselben zwischengespeicherten Seiten-Renderer rastert, der in HotPDFs internem Seite-zu-Bitmap-Rendering beschrieben wird, dann Highlight-Boxen und Suchtreffer darüber kompositieren und die aktuelle Ansichtsrotation anwenden. Veröffentlichte Eigenschaften wie PageIndex, Zoom, ZoomMode und ViewRotation sind dünne Weiterleiter – der Getter liest FModel.PageIndex, der Setter schreibt FModel.PageIndex –, sodass es im Object Inspector oder im Code so aussieht, als würde das Steuerelement den Zustand direkt halten, obwohl THPDFViewerModel der einzige Ort ist, an dem dieser Zustand tatsächlich lebt. Aufrufer sind auch nicht auf die weitergeleitete Teilmenge beschränkt: THPDFViewer stellt das Model selbst über eine schreibgeschützte Model: THPDFViewerModel-Eigenschaft bereit, sodass Code, der FindFormFieldAt oder PrefetchCurrentPageSnapshots benötigt – von denen keines das Steuerelement erneut bereitstellt – am Wrapper vorbei direkt das Model aufrufen kann

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 und EndUpdate: Neuzeichnungs-Stürme stoppen

BeginUpdate und EndUpdate existieren, weil eine einzelne logische Änderung oft mehrere Zustandsteile gleichzeitig berührt, und nach jedem Teil neu zu zeichnen wäre verschwenderisch und visuell unruhig. Das Austauschen des geladenen Dokuments ist das klarste Beispiel: Das Zuweisen von THPDFViewerModel.Document setzt die Ansichtsrotation zurück, löscht Suchtreffer, löscht Highlight-Bereiche und springt zur ersten Seite, und jeder dieser Schritte löst normalerweise sein eigenes Änderungsereignis aus. THPDFViewerModel umschließt diese Sequenz mit BeginUpdate/EndUpdate, einem referenzgezählten Paar, bei dem verschachtelte Aufrufe OnBeginUpdate nur beim Übergang in den äußersten Aufruf und OnEndUpdate nur beim Übergang zurück heraus auslösen. THPDFViewer verfolgt dieselbe Tiefe auf seiner Seite und überspringt RefreshDocument für jedes granulare Ereignis, solange der Zähler über null liegt, und zeichnet dann genau einmal neu, wenn der Batch schließt. Die granularen Ereignisse feuern während des Batches weiterhin, sodass ein Abonnent, der sich nur für OnSearchChange interessiert, trotzdem davon erfährt; nur das eigene Neuzeichnen des Steuerelements wird auf einen einzigen Aufruf statt vier zusammengefasst

Wie bildet Marquee-Highlighting einen Mausziehvorgang auf PDF-Koordinaten zurück ab?

Marquee-Highlighting bildet einen Mausziehvorgang über ein Paar von Model-Methoden, die genau für diese Hin-und-Rück-Übersetzung gebaut sind, auf PDF-Koordinaten zurück ab: PagePointToView und ViewPointToPage. Beide nehmen einen Seitenindex, eine DPI und einen Punkt entgegen, und beide lösen die Transformation in zwei Stufen auf – zuerst den eigenen /Rotate-Eintrag der Seite und ihren unten-links liegenden PDF-Ursprung, dann die separate, nicht-destruktive ViewRotation der Ansicht und den oben-links liegenden Geräteursprung des Viewers – und zwar genau deshalb, damit die umgekehrte Richtung die beiden Stufen in strikt umgekehrter Reihenfolge rückgängig machen und über alle sechzehn Kombinationen aus Seitenrotation und Ansichtsrotation korrekt hin- und zurückrechnen kann. THPDFViewer ruft ViewPointToPage auf, wenn der Benutzer die Maus nach dem Ziehen eines Rechtecks im Interaktionsmodus vimHighlight loslässt, verwandelt die beiden Gerätepunkte in ein THPDFRectangle im Seitenraum und übergibt es an Model.AddHighlightRegion. Ein Detail, das es sich zu kennen lohnt, wenn Sie etwas Ähnliches bauen: Die Maus-Erfassung gehört dem von TScrollBox abgeleiteten Viewer, nicht dem Kind-TImage, in das die Bitmap gemalt wird, weil TControl.MouseCapture geschützt ist und nur das übergeordnete Steuerelement es beanspruchen kann – sodass ein Ziehvorgang, der die Grenzen des Bildes verlässt, bevor die Taste losgelassen wird, trotzdem über die eigene überschriebene MouseMove/MouseUp-Methode des Viewers aufgelöst wird, statt vom Kind-Steuerelement stillschweigend verworfen zu werden

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;

Was die Trennung über eine grüne Testsuite hinaus bringt

Der Nutzen beschränkt sich nicht darauf, dass Tests in einem CI-Job ohne Desktop-Sitzung bestehen. Weil THPDFViewer an THPDFViewerModel weiterleitet, statt dessen Logik zu duplizieren, konnte HotPDF einen dritten Konsumenten hinzufügen – THPDFViewerAction und konkrete Unterklassen wie THPDFZoomInAction und THPDFFindNextAction –, die Navigation, Zoom, Suche und Rotation in eine Standard-Delphi-TActionList einklinken, sodass ein Symbolleisten-Button oder ein Menüpunkt den Viewer deklarativ steuern kann und sich automatisch aktiviert, je nachdem, ob aktuell ein Viewer als Ziel der Aktion aufgelöst wird. Keine dieser Schicht musste irgendetwas über Bitmaps oder GDI wissen; sie ruft Viewer.NextPage oder Viewer.Model.FindNext auf, und die bestehende Ereigniskette kümmert sich um das Neuzeichnen. Und weil nichts in THPDFViewerModel auf TScrollBox, TImage oder ein Fenster-Handle verweist, ist die darunterliegende Zustandsmaschine auch nicht an dieses eine Steuerelement geschweißt – dasselbe Model könnte hinter einer anderen Render-Oberfläche stehen, ohne eine einzige Zeile Navigations-, Zoom- oder Suchlogik anzufassen

Wo der Render-Cache hilft und wo nicht

THPDFViewerModels Render-Cache hilft innerhalb eines geladenen Dokuments, ändert aber nichts daran, was das Laden dieses Dokuments überhaupt kostet. CreatePageSnapshot, CreateCurrentPageSnapshot und die Prefetch-Methoden PrefetchPageSnapshots/PrefetchCurrentPageSnapshots laufen alle über denselben zwischengespeicherten Renderer, der nach Seite und DPI indiziert ist, sodass das Zurückblättern zu einer bereits bei derselben Zoomstufe betrachteten Seite ein Cache-Treffer statt eines erneuten Renderings ist, und das Vorab-Laden eines kleinen Radius benachbarter Seiten glättet den üblichen Fall eines Lesers, der Seite für Seite vorwärts blättert. Nichts davon berührt jedoch die Kosten des anfänglichen LoadFromFile-Aufrufs, und ein Viewer, der gebaut wurde, um zu öffnen, was ein Benutzer auch immer hineinzieht, trifft irgendwann auf eine Datei, die groß genug ist, um genau diesen Aufruf zum eigentlichen Engpass zu machen. Für die gestufte, handle-basierte Alternative zu einem vollständigen Laden – gut zu kennen, bevor dieser Tag kommt – siehe den begleitenden Artikel zur Direct File API für große PDFs

Die hier beschriebenen Model- und View-Klassen sind zwei weitere Bausteine derselben Oberfläche für geladene Dokumente, die durchgängig in der HotPDF-Komponente für Delphi und C++Builder verwendet wird, gebaut, um von einem Formular, von einer TActionList oder von gar keinem von beiden gesteuert zu werden