Teknisk artikel

HotPDF-brugerdefineret PDF-fremviser i Delphi: MVC-arkitekturen

HotPDF opdeler sin Delphi-PDF-fremviser i to dele: THPDFViewerModel, en almindelig klasse der ejer zoom-, rotations-, søge-, highlight- og navigationstilstand uden afhængighed af et vindueshandle, og THPDFViewer, en TScrollBox-baseret kontrol der omsætter den tilstand til pixels. Opdelingen er det, der lader fremviserlogikken køre — og blive testet — uden nogensinde at oprette en formular

De fleste brugerdefinerede fremviserkontroller ser ikke sådan ud. Zoomniveauet ligger i et privat felt på kontrollen, sidenavigation begrænser sine grænser inde i en knaps OnClick-handler, og den eneste måde at vide, om Ctrl+scroll respekterer et zoom-loft, er at køre appen, klikke og se efter. En kontrol bygget på den måde fungerer fint, indtil den skal bruge en regressionssuite, eller en anden vært — en print-forhåndsvisningsdialog, en miniaturebillede-skinne, en batch-gennemgang uden noget synligt vindue overhovedet — og den tilstand, man har brug for, viser sig at være svejset fast til et TWinControl, der insisterer på et rigtigt handle, før det vil gøre noget som helst

Hvorfor har en PDF-fremviser-kontrol overhovedet brug for en MVC-opdeling?

En PDF-fremviser har brug for denne slags opdeling, fordi dens tilstand og dens præsentation ændrer sig af forskellige årsager og i forskellige tempi. Sideindeks, zoom, visningsrotation, søgetræffere og highlight-regioner er forretningstilstand: de kan beregnes, valideres og serialiseres uden en eneste pixel på skærmen. At male en bitmap, indfange musen og tegne et markering-rektangel er præsentationshensyn, der først giver mening, når en kontrol findes. HotPDF holder den første gruppe i THPDFViewerModel, en klasse uden nogen VCL-vinduesforfader overhovedet, og den anden gruppe i THPDFViewer, som ejer en model-instans og reagerer på den — nærmere et Model-View-par end en lærebogs-tre-lags-MVC, da der ikke er nogen separat Controller-klasse, og THPDFViewer selv omsætter rå tastatur- og musehændelser til model-kald. Det, der betyder mere end etiketten, er afhængighedsretningen: intet ved THPDFViewerModel kræver et Handle, en beskedsløjfe eller et synligt skrivebord, hvilket er præcis det, der lader HotPDFs egen testsuite drive paging, zoom-begrænsning, tastaturkommandoer og koordinat-tur-retur gennem DUnitX uden at åbne et vindue

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;

Hvad THPDFViewerModel egentlig ejer

THPDFViewerModel ejer alt, en fremviser har brug for for at kunne svare på, hvad der aktuelt bør være på skærmen, uden at eje, hvordan det tegnes. PageIndex, PageNumber og PageCount sporer position; Zoom og ZoomMode (vzmActualSize, vzmFitPage, vzmFitWidth, vzmCustom) sporer skala; ViewRotation sporer en ikke-destruktiv skærmrotation, der aldrig rører sidens egen /Rotate-post. Navigationsmetoder — FirstPage, PriorPage, NextPage, LastPage — og zoom-metoder — ZoomIn, ZoomOut, som gennemgår en fast tabel med nitten forudindstillede niveauer fra 5% til 6400% — bor også her, sammen med FindAll/FindNext/FindPrevious til tekstsøgning og AddHighlightRegion/RemoveHighlightRegion/ClearHighlightRegions til vedvarende side-annotationer, en kalder ønsker at beholde mellem gengivelser. Modellen ejer output lige så vel som input: CreateCurrentPageSnapshot og CreateCurrentPageMetafile eksporterer netop den side, der aktuelt er på skærmen, og PrintCurrentView sender den samme aktuelle visning — aktuel side, aktuel zoom-afledt DPI, aktuel rotation — til en TPrinter, et snævrere, visningsafgrænset job end den dokument-omfattende udskriftspipeline dækket i HotPDFs TPrinter-udskrivningsgennemgang. Enhver mutation, der betyder noget, udløser også en tilsvarende hændelse — OnPageChange, OnZoomChange, OnSearchChange, OnHighlightChange, OnViewRotationChange — så en abonnent finder ud af, hvad der ændrede sig, uden at polle

Hvordan ved THPDFViewer, hvornår den skal gentegne?

THPDFViewer ved, hvornår den skal gentegne, fordi den abonnerer på modellen i stedet for at gætte. THPDFViewers konstruktør opretter en privat THPDFViewerModel, og forbinder derefter hver eneste af dens meddelelseshændelser — OnBeginUpdate, OnEndUpdate, OnHighlightChange, OnPageChange, OnSearchChange, OnViewRotationChange, OnZoomChange — til en tilsvarende privat handler. Hver handlers opgave er lille: kald RefreshDocument, metoden der rent faktisk rasteriserer den aktuelle side gennem den samme cachede siderenderer beskrevet i HotPDFs interne side-til-bitmap-gengivelse, og sammensætter derefter highlight-bokse og søgetræffere oven på og anvender den aktuelle visningsrotation. Publicerede egenskaber som PageIndex, Zoom, ZoomMode og ViewRotation er tynde videresendere — getteren læser FModel.PageIndex, setteren skriver FModel.PageIndex — så fra Object Inspector eller fra kode ser kontrollen ud, som om den holder tilstanden direkte, selvom THPDFViewerModel er det eneste sted, den tilstand rent faktisk bor. Kaldere er heller ikke begrænset til den videresendte delmængde: THPDFViewer eksponerer selve modellen gennem en skrivebeskyttet Model: THPDFViewerModel-egenskab, så kode der vil bruge FindFormFieldAt eller PrefetchCurrentPageSnapshots — som ingen af dem kontrollen genudstiller — kan række forbi wrapperen og kalde 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: stopper gentegningsstorme

BeginUpdate og EndUpdate findes, fordi en enkelt logisk ændring ofte rører flere dele af tilstanden på én gang, og at gentegne efter hver del ville være spild og visuelt støjende. At udskifte det indlæste dokument er det tydeligste eksempel: at tildele THPDFViewerModel.Document nulstiller visningsrotation, rydder søgetræffere, rydder highlight-regioner og hopper til side ét, og hvert af de trin udløser normalt sin egen ændringshændelse. THPDFViewerModel pakker den sekvens ind i BeginUpdate/EndUpdate, et referenceoptalt par, hvor indlejrede kald kun udløser OnBeginUpdate ved overgangen ind i det yderste kald og OnEndUpdate ved overgangen tilbage ud. THPDFViewer sporer den samme dybde på sin side og springer RefreshDocument over for hver granulær hændelse, mens tallet er over nul, og gentegner derefter nøjagtigt én gang, når batchen lukker. De granulære hændelser udløses stadig under batchen, så en abonnent, der kun bekymrer sig om OnSearchChange, hører stadig om det; det er kun kontrollens egen gentegning, der bliver samlet til ét kald i stedet for fire

Hvordan mapper markeringsmarkering et musetræk tilbage til PDF-koordinater?

Markeringsmarkering mapper et musetræk tilbage til PDF-koordinater gennem et par model-metoder bygget netop til den tur-retur: PagePointToView og ViewPointToPage. Begge tager et sideindeks, en DPI og et punkt, og begge løser transformationen i to trin — først sidens egen /Rotate-post og dens nederste venstre PDF-oprindelse, derefter visningens separate, ikke-destruktive ViewRotation og fremviserens øverste venstre enhedsoprindelse — netop så den omvendte retning kan fortryde de to trin i strengt omvendt rækkefølge og gå tur-retur korrekt på tværs af alle seksten kombinationer af siderotation og visningsrotation. THPDFViewer kalder ViewPointToPage, når brugeren slipper musen efter at have trukket et rektangel i vimHighlight-interaktionstilstand, omsætter de to enhedspunkter til et THPDFRectangle i sidens koordinatrum og overdrager det til Model.AddHighlightRegion. Én detalje er værd at kende, hvis man bygger noget lignende: musefangst tilhører den TScrollBox-nedstammede fremviser, ikke det underliggende TImage bitmappen tegnes ind i, fordi TControl.MouseCapture er beskyttet, og kun den overordnede kontrol kan gøre krav på den — så et træk, der forlader billedets grænser, før knappen slippes, løses stadig gennem fremviserens egne overstyrede MouseMove/MouseUp i stedet for stiltiende at blive droppet af den underliggende kontrol

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;

Hvad opdelingen giver dig ud over en grøn testsuite

Gevinsten er ikke begrænset til, at tests består i et CI-job uden nogen skrivebordssession. Fordi THPDFViewer videresender til THPDFViewerModel i stedet for at duplikere dens logik, kunne HotPDF tilføje en tredje forbruger — THPDFViewerAction og konkrete underklasser som THPDFZoomInAction og THPDFFindNextAction — der kobler navigation, zoom, søgning og rotation til en standard Delphi TActionList, så en værktøjslinjeknap eller et menupunkt kan drive fremviseren deklarativt, og selv aktivere sig baseret på, om en fremviser aktuelt er løst som handlingens mål. Ingen af det lag behøvede at vide noget om bitmaps eller GDI; det kalder Viewer.NextPage eller Viewer.Model.FindNext, og den eksisterende hændelseskæde tager sig af gentegningen. Og fordi intet i THPDFViewerModel refererer til TScrollBox, TImage eller et vindueshandle, er tilstandsmaskinen nedenunder heller ikke svejset fast til den ene kontrol — den samme model kunne sidde bag en anden gengivelsesflade uden at røre en eneste linje navigations-, zoom- eller søgelogik

Hvor gengivelses-cachen hjælper, og hvor den ikke gør

THPDFViewerModels gengivelses-cache hjælper inden for et indlæst dokument, men den ændrer ikke, hvad det koster at indlæse det dokument i første omgang. CreatePageSnapshot, CreateCurrentPageSnapshot og prefetch-metoderne PrefetchPageSnapshots/PrefetchCurrentPageSnapshots går alle gennem den samme cachede renderer nøglet efter side og DPI, så at bladre tilbage til en side, man allerede har set på samme zoomniveau, er et cache-hit snarere end en re-gengivelse, og at forudhente en lille radius af nabo-sider udjævner det almindelige tilfælde, hvor en læser blader fremad én side ad gangen. Intet af det rører dog omkostningen ved det indledende LoadFromFile-kald, og en fremviser bygget til at åbne, hvad end en bruger trækker ind på den, møder til sidst en fil stor nok til at gøre det kald til den egentlige flaskehals. For det lagdelte, handle-baserede alternativ til en fuld indlæsning — værd at kende, før den dag ankommer — se følgeartiklen om Direct File API til store PDF'er

Model- og View-klasserne beskrevet her er endnu to dele af den samme indlæste-dokument-flade, der bruges gennem hele HotPDF-komponenten til Delphi og C++Builder, bygget til at blive drevet fra en formular, fra en TActionList, eller fra ingen af delene overhovedet