Artykuł techniczny

Własna przeglądarka PDF w Delphi z HotPDF: architektura MVC

HotPDF rozdziela swoją przeglądarkę PDF w Delphi na dwa elementy: THPDFViewerModel, zwykłą klasę, która przechowuje stan powiększenia, obrotu, wyszukiwania, wyróżnień i nawigacji bez żadnej zależności od uchwytu okna, oraz THPDFViewer, kontrolkę opartą na TScrollBox, która przekształca ten stan w piksele. Ten podział jest tym, co pozwala logice przeglądarki działać — i być testowaną — bez tworzenia jakiegokolwiek formularza

Większość niestandardowych kontrolek przeglądarki tak nie wygląda. Poziom powiększenia mieszka w prywatnym polu kontrolki, nawigacja po stronach ogranicza swoje granice wewnątrz procedury obsługi OnClick przycisku, a jedynym sposobem sprawdzenia, czy Ctrl+scroll respektuje pułap powiększenia, jest uruchomienie aplikacji, kliknięcie i patrzenie. Kontrolka zbudowana w ten sposób działa dobrze, dopóki nie potrzebuje zestawu testów regresyjnych albo drugiego gospodarza — okna podglądu wydruku, paska miniatur, wsadowego recenzenta bez żadnego widocznego okna — a potrzebny stan okazuje się przyspawany do TWinControl, który upiera się przy prawdziwym uchwycie, zanim cokolwiek zrobi

Dlaczego kontrolka przeglądarki PDF w ogóle potrzebuje podziału MVC?

Przeglądarka PDF potrzebuje tego rodzaju podziału, ponieważ jej stan i jej prezentacja zmieniają się z różnych powodów i w różnym tempie. Indeks strony, powiększenie, obrót widoku, trafienia wyszukiwania i obszary wyróżnień to stan biznesowy: można go obliczyć, zwalidować i zserializować bez ani jednego piksela na ekranie. Malowanie bitmapy, przechwytywanie myszy i rysowanie prostokąta zaznaczenia typu marquee to kwestie prezentacji, które mają sens dopiero wtedy, gdy kontrolka istnieje. HotPDF trzyma pierwszą grupę w THPDFViewerModel, klasie bez żadnego przodka okienkowego VCL, a drugą grupę w THPDFViewer, który posiada instancję modelu i reaguje na nią — bliżej pary Model-Widok niż podręcznikowego trójwarstwowego MVC, ponieważ nie ma osobnej klasy Kontrolera, a sam THPDFViewer zamienia surowe zdarzenia klawiatury i myszy na wywołania modelu. Ważniejszy niż etykieta jest kierunek zależności: nic w THPDFViewerModel nie wymaga Handle, pętli komunikatów ani widocznego pulpitu, co dokładnie pozwala własnemu zestawowi testów HotPDF sterować stronicowaniem, ograniczaniem powiększenia, poleceniami klawiatury i konwersją współrzędnych w obie strony przez DUnitX bez otwierania jakiegokolwiek okna

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;

Co dokładnie posiada THPDFViewerModel

THPDFViewerModel posiada wszystko, czego przeglądarka potrzebuje, aby odpowiedzieć, co obecnie powinno być na ekranie, nie posiadając wiedzy, jak to narysować. PageIndex, PageNumber i PageCount śledzą pozycję; Zoom i ZoomMode (vzmActualSize, vzmFitPage, vzmFitWidth, vzmCustom) śledzą skalę; ViewRotation śledzi nieniszczący obrót ekranowy, który nigdy nie dotyka własnego wpisu /Rotate strony. Metody nawigacji — FirstPage, PriorPage, NextPage, LastPage — i metody powiększenia — ZoomIn, ZoomOut, przechodzące po stałej tabeli dziewiętnastu poziomów wstępnie ustawionych od 5% do 6400% — również mieszkają tutaj, obok FindAll/FindNext/FindPrevious do wyszukiwania tekstu i AddHighlightRegion/RemoveHighlightRegion/ClearHighlightRegions do trwałych adnotacji na stronie, które wywołujący chce zachować między renderowaniami. Model posiada również wyjście, tak jak wejście: CreateCurrentPageSnapshot i CreateCurrentPageMetafile eksportują dokładnie tę stronę, która jest obecnie na ekranie, a PrintCurrentView wysyła ten sam bieżący widok — bieżącą stronę, bieżące DPI wyprowadzone z powiększenia, bieżący obrót — do TPrinter, węższe, ograniczone do widoku zadanie niż potok drukowania całego dokumentu opisany w przewodniku HotPDF po drukowaniu przez TPrinter. Każda istotna mutacja podnosi też odpowiadające jej zdarzenie — OnPageChange, OnZoomChange, OnSearchChange, OnHighlightChange, OnViewRotationChange — więc subskrybent dowiaduje się, co się zmieniło, bez odpytywania

Skąd THPDFViewer wie, kiedy przemalować?

THPDFViewer wie, kiedy przemalować, ponieważ subskrybuje model zamiast zgadywać. Konstruktor THPDFViewer tworzy prywatny THPDFViewerModel, a następnie podłącza każde z jego zdarzeń powiadamiających — OnBeginUpdate, OnEndUpdate, OnHighlightChange, OnPageChange, OnSearchChange, OnViewRotationChange, OnZoomChange — do odpowiadającego prywatnego programu obsługi. Zadanie każdego programu obsługi jest niewielkie: wywołać RefreshDocument, metodę, która faktycznie rasteryzuje bieżącą stronę przez ten sam buforowany renderer stron opisany w wewnętrznych mechanizmach renderowania strony PDF do bitmapy w HotPDF, a następnie komponuje na wierzchu ramki wyróżnień i trafienia wyszukiwania oraz stosuje bieżący obrót widoku. Publikowane właściwości, takie jak PageIndex, Zoom, ZoomMode i ViewRotation, są cienkimi przekaźnikami — getter odczytuje FModel.PageIndex, setter zapisuje FModel.PageIndex — więc z Object Inspectora albo z kodu kontrolka wygląda, jakby sama trzymała stan bezpośrednio, mimo że THPDFViewerModel jest jedynym miejscem, gdzie ten stan faktycznie mieszka. Wywołujący nie są też ograniczeni do przekazywanego podzbioru: THPDFViewer udostępnia sam model przez właściwość tylko do odczytu Model: THPDFViewerModel, więc kod, który chce FindFormFieldAt albo PrefetchCurrentPageSnapshots — których kontrolka nie udostępnia ponownie — może sięgnąć poza opakowanie i wywołać model bezpośrednio

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 i EndUpdate: powstrzymywanie burz przemalowań

BeginUpdate i EndUpdate istnieją, ponieważ jedna logiczna zmiana często dotyka kilku fragmentów stanu naraz, a przemalowywanie po każdym z nich byłoby marnotrawne i wizualnie hałaśliwe. Podmiana wczytanego dokumentu jest najjaśniejszym przykładem: przypisanie THPDFViewerModel.Document resetuje obrót widoku, czyści trafienia wyszukiwania, czyści obszary wyróżnień i przeskakuje do strony pierwszej, a każdy z tych kroków normalnie wywołuje własne zdarzenie zmiany. THPDFViewerModel opakowuje tę sekwencję w parę BeginUpdate/EndUpdate, liczoną referencyjnie parę, w której zagnieżdżone wywołania uruchamiają OnBeginUpdate tylko przy przejściu do najbardziej zewnętrznego wywołania, a OnEndUpdate przy przejściu z powrotem na zewnątrz. THPDFViewer śledzi tę samą głębokość po swojej stronie i pomija RefreshDocument dla każdego drobnego zdarzenia, dopóki licznik jest powyżej zera, a następnie przemalowuje dokładnie raz, gdy partia się zamyka. Drobne zdarzenia nadal są zgłaszane podczas partii, więc subskrybent, którego interesuje wyłącznie OnSearchChange, nadal o tym usłyszy; to tylko przemalowanie samej kontrolki zostaje zredukowane do jednego wywołania zamiast czterech

Jak wyróżnianie typu marquee mapuje przeciągnięcie myszy z powrotem na współrzędne PDF?

Wyróżnianie typu marquee mapuje przeciągnięcie myszy z powrotem na współrzędne PDF przez parę metod modelu zbudowanych dokładnie do tej podróży w obie strony: PagePointToView i ViewPointToPage. Obie przyjmują indeks strony, DPI i punkt, i obie rozwiązują transformację w dwóch etapach — najpierw własny wpis /Rotate strony i jej lewy dolny początek PDF, potem osobny, nieniszczący ViewRotation widoku i lewy górny początek urządzenia przeglądarki — specjalnie po to, żeby kierunek odwrotny mógł cofnąć oba etapy w ściśle odwrotnej kolejności i poprawnie odwzorować się w obie strony przez wszystkie szesnaście kombinacji obrotu strony i obrotu widoku. THPDFViewer wywołuje ViewPointToPage, gdy użytkownik zwalnia przycisk myszy po przeciągnięciu prostokąta w trybie interakcji vimHighlight, zamienia dwa punkty urządzenia na THPDFRectangle w przestrzeni strony i przekazuje go do Model.AddHighlightRegion. Jeden szczegół wart poznania, jeśli budujesz coś podobnego: przechwytywanie myszy należy do przeglądarki wywodzącej się z TScrollBox, a nie do potomnego TImage, do którego malowana jest bitmapa, ponieważ TControl.MouseCapture jest chronione (protected) i tylko kontrolka nadrzędna może je przejąć — więc przeciągnięcie, które opuszcza granice obrazu, zanim przycisk zostanie zwolniony, nadal rozwiązuje się przez własne nadpisane MouseMove/MouseUp przeglądarki, zamiast zostać po cichu porzucone przez kontrolkę potomną

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;

Co ten podział daje ponad zieloną kontrolkę testów

Korzyść nie ogranicza się do zielonych testów przechodzących w zadaniu CI bez sesji pulpitu. Ponieważ THPDFViewer przekazuje do THPDFViewerModel zamiast powielać jego logikę, HotPDF mógł dodać trzeciego odbiorcę — THPDFViewerAction i konkretne podklasy jak THPDFZoomInAction i THPDFFindNextAction — które podłączają nawigację, powiększenie, wyszukiwanie i obrót do standardowej TActionList Delphi, więc przycisk paska narzędzi albo pozycja menu może sterować przeglądarką deklaratywnie, samodzielnie się włączając na podstawie tego, czy jakaś przeglądarka jest obecnie rozwiązana jako cel akcji. Żadna z tych warstw nie musiała nic wiedzieć o bitmapach ani GDI; wywołuje Viewer.NextPage albo Viewer.Model.FindNext, a istniejący łańcuch zdarzeń zajmuje się przemalowaniem. A ponieważ nic w THPDFViewerModel nie odwołuje się do TScrollBox, TImage ani uchwytu okna, maszyna stanów pod spodem też nie jest przyspawana do tej jednej kontrolki — ten sam model mógłby siedzieć za inną powierzchnią renderowania bez dotykania ani jednej linii logiki nawigacji, powiększenia czy wyszukiwania

Gdzie pomaga bufor renderowania, a gdzie nie

Bufor renderowania THPDFViewerModel pomaga w obrębie wczytanego dokumentu, ale nie zmienia tego, ile kosztuje wczytanie tego dokumentu w pierwszej kolejności. CreatePageSnapshot, CreateCurrentPageSnapshot i metody wstępnego pobierania PrefetchPageSnapshots/PrefetchCurrentPageSnapshots wszystkie przechodzą przez ten sam buforowany renderer indeksowany przez stronę i DPI, więc powrót do strony już obejrzanej przy tym samym poziomie powiększenia to trafienie w bufor, a nie ponowne renderowanie, a wstępne pobieranie niewielkiego promienia sąsiednich stron wygładza typowy przypadek czytelnika przewracającego strony po jednej do przodu. Nic z tego jednak nie dotyka kosztu początkowego wywołania LoadFromFile, a przeglądarka zbudowana do otwierania czegokolwiek, co użytkownik na nią przeciągnie, w końcu spotyka plik na tyle duży, że to wywołanie staje się faktycznym wąskim gardłem. Poznaj warstwową, opartą na uchwytach alternatywę dla pełnego wczytania — warto ją znać, zanim ten dzień nadejdzie — w towarzyszącym artykule o Direct File API dla dużych plików PDF

Opisane tutaj klasy Model i Widok to kolejne dwa elementy tej samej powierzchni wczytanego dokumentu, wykorzystywanej w całym komponencie HotPDF dla Delphi i C++Buildera, zbudowane tak, by dało się je sterować z formularza, z TActionList, albo z żadnego z nich