Tehnički članak

Prilagođeni PDF preglednik u Delphiju: MVC arhitektura

HotPDF dijeli svoj Delphi PDF preglednik na dva dijela: THPDFViewerModel, običnu klasu koja bez ovisnosti o prozorskoj ručki upravlja stanjem zumiranja, rotacije, pretraživanja, isticanja i navigacije, te THPDFViewer, kontrolu temeljenu na TScrollBoxu koja to stanje pretvara u piksele. Upravo to razdvajanje omogućuje pokretanje i testiranje logike preglednika bez stvaranja obrasca

Većina prilagođenih kontrola preglednika ne izgleda ovako. Razina zumiranja živi u privatnom polju kontrole, navigacija stranica ograničava svoje granice unutar rukovatelja gumba OnClick, a jedini način da saznate poštuje li Ctrl+pomicanje gornju granicu zumiranja jest pokrenuti aplikaciju, kliknuti i pogledati. Takva kontrola dobro radi dok joj ne zatreba regresijski paket testova ili drugi domaćin — dijaloški okvir za pretpregled ispisa, traka minijatura ili paketni preglednik bez vidljivog prozora — a tada se potrebno stanje pokaže zavarenim za TWinControl koji inzistira na stvarnoj ručki prije nego što išta učini

Zašto PDF preglednik uopće treba MVC razdvajanje

PDF pregledniku treba ovakvo razdvajanje jer se njegovo stanje i prezentacija mijenjaju iz različitih razloga i različitom brzinom. Indeks stranice, zumiranje, rotacija prikaza, rezultati pretraživanja i područja isticanja poslovno su stanje: mogu se izračunati, provjeriti i serijalizirati bez ijednog piksela na zaslonu. Crtanje bitmape, hvatanje miša i crtanje pravokutnika za odabir imaju smisla tek kada kontrola postoji. HotPDF prvu skupinu drži u THPDFViewerModel, klasi bez ikakvog VCL pretka za prozore, a drugu u THPDFViewer, koji posjeduje instancu modela i reagira na nju — bliže paru Model-View nego školskom troslojnom MVC-u jer ne postoji zasebna klasa Controller, a sam THPDFViewer pretvara sirove događaje tipkovnice i miša u pozive modela. Važniji od naziva je smjer ovisnosti: ništa u THPDFViewerModelu ne zahtijeva Handle, petlju poruka ni vidljivu radnu površinu, što upravo omogućuje da HotPDF-ov vlastiti paket testova kroz DUnitX upravlja listanjem stranica, ograničavanjem zumiranja, naredbama tipkovnice i povratnim pretvorbama koordinata bez otvaranja prozora

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;

Čime zapravo upravlja THPDFViewerModel

THPDFViewerModel upravlja svime što pregledniku treba da odgovori na pitanje što se trenutno treba nalaziti na zaslonu, a ne upravlja načinom crtanja. PageIndex, PageNumber i PageCount prate položaj; Zoom i ZoomMode (vzmActualSize, vzmFitPage, vzmFitWidth, vzmCustom) prate mjerilo; ViewRotation prati nedestruktivnu rotaciju na zaslonu koja nikada ne dira vlastiti unos stranice /Rotate. Ovdje žive i metode navigacije — FirstPage, PriorPage, NextPage, LastPage — te metode zumiranja — ZoomIn, ZoomOut, koje prolaze kroz fiksnu tablicu od devetnaest unaprijed postavljenih razina od 5% do 6400% — zajedno s metodama FindAll/FindNext/FindPrevious za pretraživanje teksta i AddHighlightRegion/RemoveHighlightRegion/ClearHighlightRegions za trajne oznake stranice koje pozivatelj želi zadržati između prikaza. Model upravlja izlazom jednako kao i ulazom: CreateCurrentPageSnapshot i CreateCurrentPageMetafile izvoze upravo stranicu koja je trenutačno na zaslonu, a PrintCurrentView šalje isti trenutačni prikaz — trenutačnu stranicu, DPI izveden iz trenutačnog zumiranja i trenutačnu rotaciju — u TPrinter, kao uži posao vezan uz prikaz u odnosu na cjevovod ispisa cijelog dokumenta opisan u vodiču za ispis HotPDF-a pomoću TPrintera. Svaka važna izmjena podiže i odgovarajući događaj — OnPageChange, OnZoomChange, OnSearchChange, OnHighlightChange, OnViewRotationChange — pa pretplatnik saznaje što se promijenilo bez anketiranja

Kako THPDFViewer zna kada treba ponovno crtati

THPDFViewer zna kada treba ponovno crtati jer se pretplaćuje na model umjesto da nagađa. Konstruktor THPDFViewera stvara privatni THPDFViewerModel, a zatim svaki njegov događaj obavijesti — OnBeginUpdate, OnEndUpdate, OnHighlightChange, OnPageChange, OnSearchChange, OnViewRotationChange, OnZoomChange — povezuje s odgovarajućim privatnim rukovateljem. Posao svakog rukovatelja je malen: pozvati RefreshDocument, metodu koja zapravo rasterizira trenutačnu stranicu kroz isti predmemorirani renderer stranice opisan u unutarnjem postupku renderiranja HotPDF stranice u bitmapu, zatim preko slike složiti okvire isticanja i rezultate pretraživanja te primijeniti trenutačnu rotaciju prikaza. Objavljena svojstva poput PageIndex, Zoom, ZoomMode i ViewRotation tanki su prosljeđivači — dohvat čita FModel.PageIndex, a postavljač zapisuje FModel.PageIndex — pa iz Object Inspectora ili koda kontrola izgleda kao da stanje drži izravno, iako je THPDFViewerModel jedino mjesto na kojem to stanje stvarno postoji. Pozivatelji nisu ograničeni ni na proslijeđeni podskup: THPDFViewer sam model izlaže kroz svojstvo samo za čitanje Model: THPDFViewerModel, pa kod koji želi FindFormFieldAt ili PrefetchCurrentPageSnapshots — nijedno od toga kontrola ponovno ne izlaže — može zaobići omotač i izravno pozvati model

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 zaustavljaju oluje ponovnog crtanja

BeginUpdate i EndUpdate postoje jer jedna logička promjena često istodobno dodiruje nekoliko dijelova stanja, a ponovno crtanje nakon svakog dijela bilo bi rasipno i vizualno bučno. Zamjena učitanog dokumenta najjasniji je primjer: dodjela THPDFViewerModel.Document resetira rotaciju prikaza, briše rezultate pretraživanja, briše područja isticanja i skače na prvu stranicu, a svaki od tih koraka obično pokreće vlastiti događaj promjene. THPDFViewerModel taj slijed omata u BeginUpdate/EndUpdate, par s brojanjem referenci u kojemu ugniježđeni pozivi pokreću OnBeginUpdate samo pri prijelazu u najvanjskiji poziv, a OnEndUpdate pri prijelazu natrag iz njega. THPDFViewer istu dubinu prati na svojoj strani i preskače RefreshDocument za svaki pojedinačni događaj dok je brojač veći od nule, a zatim ponovno crta točno jednom kada se paket zatvori. Pojedinačni događaji i dalje se pokreću tijekom paketa, pa pretplatnik koji prati samo OnSearchChange i dalje prima obavijest; samo se ponovno crtanje same kontrole sažima u jedan poziv umjesto četiri

Kako se označavanje pravokutnikom mišem vraća u PDF koordinate

Označavanje pravokutnikom vraća povlačenje miša u PDF koordinate kroz par metoda modela napravljenih upravo za taj povratni put: PagePointToView i ViewPointToPage. Obje primaju indeks stranice, DPI i točku, a transformaciju rješavaju u dvije faze — najprije vlastiti unos stranice /Rotate i ishodište PDF-a u donjem lijevom kutu, zatim zasebnu nedestruktivnu vrijednost ViewRotation i ishodište uređaja preglednika u gornjem lijevom kutu — upravo kako bi inverzni smjer mogao poništiti te dvije faze strogim obrnutim redoslijedom i ispravno vratiti izvornu točku kroz svih šesnaest kombinacija rotacije stranice i rotacije prikaza. THPDFViewer poziva ViewPointToPage kada korisnik nakon povlačenja pravokutnika otpusti miš u načinu interakcije vimHighlight, pretvara dvije točke uređaja u THPDFRectangle u prostoru stranice i predaje ga metodi Model.AddHighlightRegion. Ako izrađujete nešto slično, vrijedi znati jedan detalj: hvatanje miša pripada pregledniku izvedenom iz TScrollBoxa, a ne podređenom TImageu u koji se crta bitmapa, jer je TControl.MouseCapture zaštićen i samo ga nadređena kontrola može preuzeti — zato se povlačenje koje napusti granice slike prije otpuštanja gumba i dalje rješava kroz vlastiti nadjačani MouseMove/MouseUp preglednika, umjesto da ga podređena kontrola tiho odbaci

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;

Što vam ovo razdvajanje donosi osim zelene testne zbirke

Dobit nije ograničena na prolazak testova u CI poslu bez radne površine. Budući da THPDFViewer prosljeđuje pozive THPDFViewerModelu umjesto da duplicira njegovu logiku, HotPDF je mogao dodati trećeg korisnika — THPDFViewerAction i konkretne podklase poput THPDFZoomInAction i THPDFFindNextAction — koje navigaciju, zumiranje, pretraživanje i rotaciju priključuju na standardni Delphi TActionList, pa gumb alatne trake ili stavka izbornika mogu deklarativno upravljati preglednikom i automatski se omogućiti ovisno o tome je li preglednik trenutačno razriješen kao cilj akcije. Taj sloj nije morao znati ništa o bitmapama ni GDI-ju; poziva Viewer.NextPage ili Viewer.Model.FindNext, a postojeći lanac događaja brine o ponovnom crtanju. A budući da ništa u THPDFViewerModelu ne upućuje na TScrollBox, TImage ni prozorsku ručku, ni donji stroj stanja nije zavaren za tu jednu kontrolu — isti bi model mogao stajati iza druge površine za prikaz bez izmjene ijednog retka logike navigacije, zumiranja ili pretraživanja

Gdje predmemorija renderiranja pomaže, a gdje ne

Predmemorija renderiranja THPDFViewerModela pomaže unutar učitanog dokumenta, ali ne mijenja cijenu njegova početnog učitavanja. CreatePageSnapshot, CreateCurrentPageSnapshot i metode prethodnog dohvaćanja PrefetchPageSnapshots/PrefetchCurrentPageSnapshots sve prolaze kroz isti predmemorirani renderer, čiji je ključ stranica i DPI, pa je povratak na stranicu koju ste već gledali pri istoj razini zumiranja pogodak u predmemoriji umjesto novog renderiranja, a prethodno dohvaćanje malog raspona susjednih stranica ublažava uobičajeni slučaj čitatelja koji napreduje jednu po jednu stranicu. Ništa od toga ne dira cijenu početnog poziva LoadFromFile, a preglednik napravljen da otvori sve što korisnik povuče na njega prije ili poslije naiđe na dovoljno veliku datoteku da taj poziv postane stvarno usko grlo. O alternativi punom učitavanju koja se temelji na razinama i ručki — o njoj vrijedi znati prije nego što dođe taj dan — pročitajte u pratećem članku o Direct File API-ju za velike PDF-ove

Klase Model i View opisane ovdje još su dva dijela iste površine učitanog dokumenta koja se koristi u cijeloj HotPDF komponenti za Delphi i C++Builder, napravljene za upravljanje iz obrasca, iz TActionLista ili ni iz jednog od njih