Teknisk artikkel

Egendefinert PDF-fremviser i Delphi med HotPDF: MVC-arkitekturen

HotPDF deler sin Delphi-PDF-fremviser i to deler: THPDFViewerModel, en enkel klasse som eier zoom-, rotasjons-, søke-, highlight- og navigasjonstilstand uten noen avhengighet til et vindushåndtak, og THPDFViewer, en TScrollBox-basert kontroll som gjør den tilstanden om til piksler. Denne oppdelingen er det som lar fremviserlogikken kjøre, og testes, uten noensinne å opprette et skjema

De fleste egendefinerte fremviserkontroller ser ikke slik ut. Zoom-nivået ligger i et privat felt på kontrollen, sidenavigasjon klemmer sine grenser inne i en knapps OnClick-håndterer, og den eneste måten å vite om Ctrl+rull respekterer et zoom-tak, er å kjøre appen, klikke, og se. En kontroll bygget på den måten fungerer greit inntil den trenger en regresjonstestsuite, eller en andre vert — en utskriftsforhåndsvisningsdialog, en miniatyrbilde-stripe, en batch-gjennomgang uten noe synlig vindu i det hele tatt — og tilstanden man trenger, viser seg å være sveiset fast til en TWinControl som insisterer på et ekte håndtak før den gjør noe som helst

Hvorfor trenger en PDF-fremviserkontroll i det hele tatt en MVC-oppdeling?

En PDF-fremviser trenger denne typen oppdeling fordi tilstanden og presentasjonen dens endrer seg av forskjellige grunner og i forskjellig tempo. Sideindeks, zoom, visningsrotasjon, søketreff og highlight-regioner er forretningstilstand: de kan beregnes, valideres og serialiseres uten en eneste piksel på skjermen. Å male et bitkart, fange musen, og tegne et markeringsrektangel er presentasjonsanliggender som først gir mening når en kontroll eksisterer. HotPDF holder den første gruppen i THPDFViewerModel, en klasse uten noen VCL-vindusforfar i det hele tatt, og den andre gruppen i THPDFViewer, som eier en modellinstans og reagerer på den — nærmere et Modell-Visning-par enn en lærebok-tre-lags-MVC, siden det ikke finnes noen separat Controller-klasse, og THPDFViewer selv gjør rå tastatur- og musehendelser om til modellkall. Det som betyr mer enn merkelappen, er avhengighetsretningen: ingenting ved THPDFViewerModel krever et Handle, en meldingsløkke, eller et synlig skrivebord, noe som er nøyaktig det som lar HotPDFs egen testsuite drive sideblaing, zoom-klemming, tastaturkommandoer og koordinat-rundturer gjennom DUnitX uten å åpne et vindu

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;

Hva THPDFViewerModel faktisk eier

THPDFViewerModel eier alt en fremviser trenger for å svare på hva som for øyeblikket skal være på skjermen, uten å eie hvordan det tegnes. PageIndex, PageNumber, og PageCount sporer posisjon; Zoom og ZoomMode (vzmActualSize, vzmFitPage, vzmFitWidth, vzmCustom) sporer skala; ViewRotation sporer en ikke-destruktiv skjermrotasjon som aldri rører sidens egen /Rotate-oppføring. Navigasjonsmetoder — FirstPage, PriorPage, NextPage, LastPage — og zoom-metoder — ZoomIn, ZoomOut, som går gjennom en fast tabell med nitten forhåndsdefinerte nivåer fra 5 % til 6400 % — bor også her, sammen med FindAll/FindNext/FindPrevious for tekstsøk og AddHighlightRegion/RemoveHighlightRegion/ClearHighlightRegions for vedvarende side-annoteringer en kaller ønsker å beholde mellom gjengivelser. Modellen eier utdata så vel som inndata: CreateCurrentPageSnapshot og CreateCurrentPageMetafile eksporterer nøyaktig siden som for øyeblikket er på skjermen, og PrintCurrentView sender den samme gjeldende visningen — gjeldende side, gjeldende zoom-avledet DPI, gjeldende rotasjon — til en TPrinter, en snevrere, visningsavgrenset jobb enn den dokumentomfattende utskriftspipelinen dekket i HotPDFs gjennomgang av TPrinter-utskrift. Hver mutasjon som betyr noe, utløser også en tilhørende hendelse — OnPageChange, OnZoomChange, OnSearchChange, OnHighlightChange, OnViewRotationChange — slik at en abonnent finner ut hva som endret seg uten å polle

Hvordan vet THPDFViewer når den skal tegne på nytt?

THPDFViewer vet når den skal tegne på nytt fordi den abonnerer på modellen i stedet for å gjette. THPDFViewers konstruktør oppretter en privat THPDFViewerModel, og kobler deretter hver eneste av dens varslingshendelser — OnBeginUpdate, OnEndUpdate, OnHighlightChange, OnPageChange, OnSearchChange, OnViewRotationChange, OnZoomChange — til en tilhørende privat håndterer. Hver håndterers jobb er liten: kall RefreshDocument, metoden som faktisk rasteriserer den gjeldende siden gjennom den samme bufrede sidefremviseren beskrevet i HotPDFs interne detaljer for side-til-bitkart-gjengivelse, og deretter komponerer highlight-bokser og søketreff på toppen og anvender gjeldende visningsrotasjon. Publiserte egenskaper som PageIndex, Zoom, ZoomMode, og ViewRotation er tynne videresendere — getteren leser FModel.PageIndex, setteren skriver FModel.PageIndex — slik at kontrollen, sett fra Object Inspector eller fra kode, ser ut som om den holder tilstanden direkte, selv om THPDFViewerModel er det eneste stedet den tilstanden faktisk bor. Kallere er heller ikke begrenset til det videresendte delsettet: THPDFViewer eksponerer selve modellen gjennom en skrivebeskyttet Model: THPDFViewerModel-egenskap, slik at kode som ønsker FindFormFieldAt eller PrefetchCurrentPageSnapshots — som kontrollen ikke re-eksponerer noen av — kan nå forbi wrapperen og kalle modellen direkte

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 og EndUpdate: å stoppe stormer av omtegning

BeginUpdate og EndUpdate finnes fordi én enkelt logisk endring ofte berører flere tilstandsbiter samtidig, og å tegne på nytt etter hver bit ville vært sløsing og visuelt urolig. Å bytte det innlastede dokumentet er det klareste eksempelet: å tilordne THPDFViewerModel.Document tilbakestiller visningsrotasjon, fjerner søketreff, fjerner highlight-regioner, og hopper til side én, og hvert av disse trinnene utløser normalt sin egen endringshendelse. THPDFViewerModel pakker inn den sekvensen i BeginUpdate/EndUpdate, et referansetellet par der nestede kall bare utløser OnBeginUpdate ved overgangen inn i det ytterste kallet og OnEndUpdate ved overgangen tilbake ut. THPDFViewer sporer den samme dybden på sin side og hopper over RefreshDocument for hver granulær hendelse mens telleren er over null, og tegner deretter på nytt nøyaktig én gang når batchen lukkes. De granulære hendelsene utløses fortsatt under batchen, slik at en abonnent som bare bryr seg om OnSearchChange, fortsatt hører om det; det er bare kontrollens egen omtegning som slås sammen til ett kall i stedet for fire

Hvordan kartlegger markerings-highlighting en musedra tilbake til PDF-koordinater?

Markerings-highlighting kartlegger en musedra tilbake til PDF-koordinater gjennom et par modellmetoder bygget nøyaktig for den rundturen: PagePointToView og ViewPointToPage. Begge tar en sideindeks, en DPI, og et punkt, og begge løser transformasjonen i to trinn — først sidens egen /Rotate-oppføring og dens nedre-venstre PDF-origo, deretter visningens separate, ikke-destruktive ViewRotation og fremviserens øvre-venstre enhetsorigo — spesifikt slik at den motsatte retningen kan reversere de to trinnene i streng motsatt rekkefølge og gi korrekt rundtur på tvers av alle seksten kombinasjoner av siderotasjon og visningsrotasjon. THPDFViewer kaller ViewPointToPage når brukeren slipper musen etter å ha dratt et rektangel i vimHighlight-interaksjonsmodus, gjør de to enhetspunktene om til en THPDFRectangle i sideplass, og overleverer det til Model.AddHighlightRegion. Én detalj verdt å kjenne til hvis man bygger noe lignende: musefangst tilhører den TScrollBox-avledede fremviseren, ikke det underordnede TImage-et bitkartet males inn i, fordi TControl.MouseCapture er beskyttet (protected) og bare den overordnede kontrollen kan kreve det — slik at en dra-bevegelse som forlater bildets grenser før knappen slippes opp, likevel løses gjennom fremviserens egen overstyrte MouseMove/MouseUp i stedet for å bli stille forkastet av den underordnede kontrollen

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;

Hva oppdelingen gir deg utover en grønn testsuite

Gevinsten er ikke begrenset til at tester består i en CI-jobb uten skrivebordsøkt. Fordi THPDFViewer videresender til THPDFViewerModel i stedet for å duplisere logikken dens, kunne HotPDF legge til en tredje konsument — THPDFViewerAction og konkrete underklasser som THPDFZoomInAction og THPDFFindNextAction — som kobler navigasjon, zoom, søk og rotasjon inn i en standard Delphi TActionList, slik at en verktøylinjeknapp eller et menyvalg kan drive fremviseren deklarativt, og aktivere seg selv automatisk basert på om en fremviser for øyeblikket er løst som handlingens mål. Ingenting i det laget trengte å vite noe om bitkart eller GDI; det kaller Viewer.NextPage eller Viewer.Model.FindNext, og den eksisterende hendelseskjeden tar seg av omtegningen. Og fordi ingenting i THPDFViewerModel refererer til TScrollBox, TImage, eller et vindushåndtak, er heller ikke tilstandsmaskinen under sveiset fast til den ene kontrollen — den samme modellen kunne sitte bak en annen gjengivelsesoverflate uten å røre en eneste linje navigasjons-, zoom- eller søkelogikk

Hvor gjengivelsesbufferen hjelper, og hvor den ikke gjør det

THPDFViewerModels gjengivelsesbuffer hjelper innenfor et innlastet dokument, men den endrer ikke hva det koster å laste inn det dokumentet i utgangspunktet. CreatePageSnapshot, CreateCurrentPageSnapshot, og prefetch-metodene PrefetchPageSnapshots/PrefetchCurrentPageSnapshots ruter alle gjennom den samme bufrede fremviseren, nøkkelsatt på side og DPI, slik at det å blaie tilbake til en side man allerede har sett på samme zoom-nivå, er et buffertreff snarere enn en ny gjengivelse, og å forhåndslaste en liten radius av naboer jevner ut det vanlige tilfellet med en leser som blar fremover én side om gangen. Ingenting av dette rører derimot kostnaden ved det innledende LoadFromFile-kallet, og en fremviser bygget for å åpne hva enn en bruker drar inn i den, møter til slutt en fil stor nok til å gjøre det kallet til selve flaskehalsen. For det trinndelte, håndtaksbaserte alternativet til en full innlasting — verdt å kjenne til før den dagen kommer — se følgeartikkelen om Direct File API for store PDF-er

Modell- og Visning-klassene beskrevet her er to flere biter av den samme innlastede-dokument-overflaten brukt gjennom hele HotPDF-komponenten for Delphi og C++Builder, bygget for å drives fra et skjema, fra en TActionList, eller fra ingen av delene