Műszaki cikk

Egyéni PDF-megjelenítő Delphiben a HotPDF-fel: az MVC-architektúra

A HotPDF két részre bontja Delphi PDF-megjelenítőjét: a THPDFViewerModel-re, egy egyszerű osztályra, amely a nagyítási, forgatási, keresési, kiemelési és navigációs állapotot birtokolja ablak-handle-függőség nélkül, és a THPDFViewer-re, egy TScrollBox-alapú vezérlőre, amely ezt az állapotot pixelekké alakítja. Ez a felosztás teszi lehetővé, hogy a megjelenítő logikája fusson és tesztelhető legyen anélkül, hogy valaha is létre kellene hozni egy formot

A legtöbb egyéni megjelenítő-vezérlő nem így néz ki. A nagyítási szint egy privát mezőben él a vezérlőn, a lapozás korlátait egy gomb OnClick kezelőjében szorítják be, és az egyetlen mód, hogy megtudjuk, a Ctrl+görgetés tiszteletben tart-e egy nagyítási plafont, az, hogy futtatjuk az alkalmazást, kattintunk, és megnézzük. Egy így felépített vezérlő jól működik, amíg nincs szüksége regressziós tesztkészletre, vagy egy második gazdaszervezetre — egy nyomtatási előnézet párbeszédpanelre, egy miniatűr-sávra, egy kötegelt felülvizsgálóra, amelynek egyáltalán nincs látható ablaka —, és az állapot, amire szükség van, kiderül, hogy egy TWinControl-hoz van hegesztve, amely ragaszkodik egy valódi handle-höz, mielőtt bármit tenne

Miért van egyáltalán szüksége egy PDF-megjelenítő vezérlőnek MVC-felosztásra?

Egy PDF-megjelenítőnek azért van szüksége erre a felosztásra, mert az állapota és a megjelenítése más okokból és más ütemben változik. Az oldalindex, a nagyítás, a nézetforgatás, a keresési találatok és a kiemelési területek üzleti állapot: kiszámíthatók, érvényesíthetők és szerializálhatók anélkül, hogy egyetlen pixel is megjelenne a képernyőn. Egy bitkép kifestése, az egér elfogása és egy jelölőkeret (marquee-selection) téglalap rajzolása olyan megjelenítési szempontok, amelyeknek csak akkor van értelme, ha egy vezérlő már létezik. A HotPDF az első csoportot a THPDFViewerModel-ben tartja, egy osztályban, amelynek egyáltalán nincs VCL ablakozási őse, a második csoportot pedig a THPDFViewer-ben, amely birtokol egy modellpéldányt, és reagál rá — közelebb áll egy Model-View párhoz, mint egy tankönyvi háromrétegű MVC-hez, mivel nincs külön Controller osztály, és maga a THPDFViewer alakítja a nyers billentyűzet- és egéreseményeket modellhívásokká. Ami jobban számít, mint a címke, az a függőségi irány: a THPDFViewerModel-ben semmi nem igényel Handle-t, üzenethurkot vagy látható asztalt, és pontosan ez teszi lehetővé, hogy a HotPDF saját tesztkészlete végigvigye a lapozást, a nagyítás-korlátozást, a billentyűparancsokat és a koordináta-átváltásokat a DUnitX-en keresztül anélkül, hogy egy ablakot is megnyitna

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;

Mit birtokol valójában a THPDFViewerModel

A THPDFViewerModel mindent birtokol, amire egy megjelenítőnek szüksége van ahhoz, hogy megválaszolja, mi legyen jelenleg a képernyőn, anélkül hogy birtokolná annak rajzolási módját. A PageIndex, PageNumber, és PageCount a pozíciót követi; a Zoom és ZoomMode (vzmActualSize, vzmFitPage, vzmFitWidth, vzmCustom) a méretarányt követi; a ViewRotation egy nem-destruktív, kizárólag képernyőn megjelenő forgatást követ, amely soha nem nyúl az oldal saját /Rotate bejegyzéséhez. A navigációs metódusok — FirstPage, PriorPage, NextPage, LastPage — és a nagyítási metódusok — ZoomIn, ZoomOut, amelyek egy rögzített, tizenkilenc előre beállított szintből álló táblázatot járnak be 5%-tól 6400%-ig — szintén itt élnek, a FindAll/FindNext/FindPrevious mellett szövegkereséshez, és az AddHighlightRegion/RemoveHighlightRegion/ClearHighlightRegions mellett tartós oldal-annotációkhoz, amelyeket a hívó a renderelések között meg akar tartani. A modell a kimenetet is birtokolja, nemcsak a bemenetet: a CreateCurrentPageSnapshot és CreateCurrentPageMetafile pontosan azt az oldalt exportálja, amely éppen a képernyőn van, a PrintCurrentView pedig ugyanezt az aktuális nézetet — az aktuális oldalt, az aktuális nagyításból származtatott DPI-t, az aktuális forgatást — küldi el egy TPrinter-nek, egy szűkebb, nézethez kötött feladatot, mint a HotPDF TPrinter-nyomtatási bemutatójában tárgyalt, dokumentum-szintű nyomtatási csővezeték. Minden mutáció, amely számít, egy megfelelő eseményt is kivált — OnPageChange, OnZoomChange, OnSearchChange, OnHighlightChange, OnViewRotationChange —, így egy feliratkozó lekérdezés nélkül is megtudja, mi változott

Honnan tudja a THPDFViewer, mikor kell újrarajzolnia?

A THPDFViewer azért tudja, mikor kell újrarajzolnia, mert feliratkozik a modellre, ahelyett hogy találgatna. A THPDFViewer konstruktora létrehoz egy privát THPDFViewerModel-t, majd bekapcsolja annak minden egyes értesítési eseményét — OnBeginUpdate, OnEndUpdate, OnHighlightChange, OnPageChange, OnSearchChange, OnViewRotationChange, OnZoomChange — egy megfelelő privát kezelőhöz. Minden kezelő feladata kicsi: meghívja a RefreshDocument-et, azt a metódust, amely ténylegesen raszterizálja az aktuális oldalt ugyanazon a gyorsítótárazott oldal-renderelőn keresztül, amelyet a HotPDF oldal-bitkép renderelési belső működése ír le, majd a tetejére komponálja a kiemelési dobozokat és a keresési találatokat, és alkalmazza az aktuális nézetforgatást. A publikált tulajdonságok, mint a PageIndex, Zoom, ZoomMode, és ViewRotation, vékony továbbítók — a getter az FModel.PageIndex-et olvassa, a setter az FModel.PageIndex-et írja —, így az Object Inspectorból vagy kódból nézve a vezérlő úgy tűnik, mintha közvetlenül tartaná az állapotot, pedig a THPDFViewerModel az egyetlen hely, ahol az az állapot ténylegesen él. A hívók sincsenek a továbbított részhalmazra korlátozva: a THPDFViewer magát a modellt is elérhetővé teszi egy csak olvasható Model: THPDFViewerModel tulajdonságon keresztül, így az a kód, amely a FindFormFieldAt-ot vagy a PrefetchCurrentPageSnapshots-ot akarja használni — egyiket sem teszi újra elérhetővé a vezérlő —, elérheti a burkot megkerülve, és közvetlenül meghívhatja a modellt

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 és EndUpdate: az újrarajzolási viharok megállítása

A BeginUpdate és EndUpdate azért létezik, mert egyetlen logikai változás gyakran több állapotdarabot érint egyszerre, és minden darab utáni újrarajzolás pazarló és vizuálisan zavaró lenne. A betöltött dokumentum cseréje a legvilágosabb példa: a THPDFViewerModel.Document hozzárendelése visszaállítja a nézetforgatást, törli a keresési találatokat, törli a kiemelési területeket, és az első oldalra ugrik, és ezen lépések mindegyike normál esetben kiváltja a saját változási eseményét. A THPDFViewerModel ezt a sorozatot a BeginUpdate/EndUpdate-be csomagolja, egy referenciaszámlált párba, ahol az egymásba ágyazott hívások csak a legkülső hívásba történő átmenetkor váltják ki az OnBeginUpdate-et, és a visszafelé történő átmenetkor az OnEndUpdate-et. A THPDFViewer a saját oldalán ugyanezt a mélységet követi nyomon, és kihagyja a RefreshDocument-et minden egyes részletes eseménynél, amíg a számláló nulla fölött van, majd pontosan egyszer rajzol újra, amikor a köteg lezárul. A részletes események még mindig kiváltódnak a köteg alatt, így egy feliratkozó, akit csak az OnSearchChange érdekel, még mindig értesül róla; csak a vezérlő saját újrarajzolása omlik össze egyetlen hívásra négy helyett

Hogyan képezi le a jelölőkeretes kiemelés az egérhúzást vissza PDF-koordinátákra?

A jelölőkeretes kiemelés két modellmetóduson keresztül képezi le az egérhúzást vissza PDF-koordinátákra, amelyeket pontosan erre az oda-vissza útra építettek: a PagePointToView és a ViewPointToPage. Mindkettő egy oldalindexet, egy DPI-t és egy pontot fogad, és mindkettő két szakaszban oldja fel a transzformációt — először az oldal saját /Rotate bejegyzését és bal alsó PDF-eredetpontját, majd a nézet külön, nem-destruktív ViewRotation-jét és a megjelenítő bal felső eszköz-eredetpontját — kifejezetten azért, hogy a fordított irány szigorúan visszafelé sorrendben tudja visszavonni a két szakaszt, és helyesen oda-vissza váltson az oldalforgatás és a nézetforgatás mind a tizenhat kombinációja között. A THPDFViewer meghívja a ViewPointToPage-et, amikor a felhasználó elengedi az egeret egy téglalap húzása után vimHighlight interakciós módban, a két eszközpontot THPDFRectangle-lé alakítja oldaltérben, és átadja a Model.AddHighlightRegion-nak. Egy részlet, amit érdemes tudni, ha valaki hasonlót épít: az egérfogás a TScrollBox-leszármazott megjelenítőé, nem a gyerek TImage-é, amelybe a bitkép kifestődik, mert a TControl.MouseCapture védett, és csak a szülő vezérlő igényelheti — így egy húzás, amely elhagyja a kép határait, mielőtt a gomb felengedésre kerülne, még mindig a megjelenítő saját felülírt MouseMove/MouseUp-ján keresztül oldódik fel, ahelyett hogy csendben eldobná a gyerek vezérlő

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;

Mit ad a felosztás egy zöld tesztkészleten túl

A megtérülés nem korlátozódik a tesztekre, amelyek egy asztali munkamenet nélküli CI-feladatban lefutnak. Mivel a THPDFViewer a THPDFViewerModel-hez továbbít ahelyett, hogy megkettőzné annak logikáját, a HotPDF képes volt hozzáadni egy harmadik fogyasztót — a THPDFViewerAction-t és konkrét leszármazottakat, mint a THPDFZoomInAction és THPDFFindNextAction —, amelyek a navigációt, nagyítást, keresést és forgatást egy szabványos Delphi TActionList-be dugják be, így egy eszköztárgomb vagy egy menüelem deklaratívan vezérelheti a megjelenítőt, automatikusan engedélyezve önmagát aszerint, hogy jelenleg feloldódik-e egy megjelenítő az akció céljaként. Ennek a rétegnek semmit nem kellett tudnia a bitképekről vagy a GDI-ról; meghívja a Viewer.NextPage-et vagy a Viewer.Model.FindNext-et, és a meglévő eseménylánc gondoskodik az újrarajzolásról. És mivel a THPDFViewerModelben semmi nem hivatkozik a TScrollBox-ra, a TImage-re vagy egy ablak-handle-re, az alatta lévő állapotgép sincs egyetlen vezérlőhöz hegesztve — ugyanaz a modell egy másik rajzolási felület mögött is állhatna anélkül, hogy egyetlen sort is hozzá kellene nyúlni a navigációs, nagyítási vagy keresési logikához

Hol segít a renderelési gyorsítótár, és hol nem

A THPDFViewerModel renderelési gyorsítótára egy betöltött dokumentumon belül segít, de nem változtat azon, mibe kerül eleve annak a dokumentumnak a betöltése. A CreatePageSnapshot, CreateCurrentPageSnapshot, és az előtöltő metódusok, a PrefetchPageSnapshots/PrefetchCurrentPageSnapshots, mind ugyanazon az oldal és DPI szerint kulcsolt, gyorsítótárazott renderelőn keresztül haladnak, így egy már ugyanazon a nagyítási szinten megtekintett oldalra visszalapozás gyorsítótár-találat, nem újrarenderelés, és a szomszédos oldalak egy kis sugarú előtöltése simává teszi a gyakori esetet, amikor egy olvasó egyszerre egy oldalt lapoz előre. Mindez viszont nem érinti a kezdeti LoadFromFile hívás költségét, és egy megjelenítő, amelyet arra építettek, hogy megnyisson bármit, amit egy felhasználó ráhúz, előbb-utóbb találkozik egy elég nagy fájllal ahhoz, hogy az a hívás legyen a tényleges szűk keresztmetszet. A teljes betöltés rétegzett, handle-alapú alternatívájáról — érdemes tudni róla, mielőtt eljön az a nap — lásd a nagy PDF-ekhez készült Direct File API-ról szóló kísérőcikket

Az itt leírt Model és View osztályok két további darabja ugyanannak a betöltött dokumentum-felületnek, amelyet a Delphihez és C++Builderhez készült HotPDF komponensben mindenütt használnak, arra tervezve, hogy egy formból, egy TActionList-ből, vagy egyikből sem legyen vezérelhető