Articolo tecnico

Visualizzatore PDF Personalizzato in Delphi con HotPDF: l'Architettura MVC

HotPDF separa il proprio visualizzatore PDF Delphi in due parti: THPDFViewerModel, una classe semplice che possiede lo stato di zoom, rotazione, ricerca, evidenziazione e navigazione senza alcuna dipendenza da un handle di finestra, e THPDFViewer, un controllo basato su TScrollBox che trasforma quello stato in pixel. Questa suddivisione è ciò che permette alla logica del visualizzatore di essere eseguita, e testata, senza mai creare una form

La maggior parte dei controlli viewer personalizzati non è strutturata così. Il livello di zoom vive in un campo privato del controllo, la navigazione tra pagine limita i propri confini dentro il gestore OnClick di un pulsante, e l'unico modo per sapere se Ctrl+scroll rispetta un tetto di zoom è avviare l'app, cliccare e guardare. Un controllo costruito in questo modo funziona bene finché non serve una suite di regressione, oppure un secondo host — una finestra di anteprima di stampa, una barra di miniature, un revisore batch senza alcuna finestra visibile — e lo stato di cui c'è bisogno risulta saldato a un TWinControl che pretende un handle reale prima di fare qualsiasi cosa

Perché un controllo viewer PDF ha bisogno di una suddivisione MVC?

Un visualizzatore PDF ha bisogno di questo tipo di suddivisione perché il suo stato e la sua presentazione cambiano per ragioni diverse e a ritmi diversi. Indice di pagina, zoom, rotazione di visualizzazione, risultati di ricerca e regioni evidenziate sono stato di business: possono essere calcolati, validati e serializzati senza un solo pixel sullo schermo. Disegnare un bitmap, catturare il mouse e tracciare un rettangolo di selezione a lazo sono invece questioni di presentazione che hanno senso solo una volta che un controllo esiste. HotPDF mantiene il primo gruppo in THPDFViewerModel, una classe senza alcun antenato di windowing VCL, e il secondo gruppo in THPDFViewer, che possiede un'istanza del modello e reagisce ad essa — più vicino a una coppia Model-View che a un classico MVC a tre livelli da manuale, poiché non esiste una classe Controller separata e THPDFViewer stesso trasforma gli eventi grezzi di tastiera e mouse in chiamate al modello. Ciò che conta più dell'etichetta è la direzione della dipendenza: nulla in THPDFViewerModel richiede un Handle, un message loop o un desktop visibile, ed è esattamente questo che permette alla suite di test propria di HotPDF di guidare paginazione, blocco dello zoom, comandi da tastiera e conversioni di coordinate tramite DUnitX senza aprire alcuna finestra

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;

Cosa possiede realmente THPDFViewerModel

THPDFViewerModel possiede tutto ciò di cui un visualizzatore ha bisogno per rispondere a cosa dovrebbe attualmente comparire sullo schermo, senza possedere il come disegnarlo. PageIndex, PageNumber e PageCount tracciano la posizione; Zoom e ZoomMode (vzmActualSize, vzmFitPage, vzmFitWidth, vzmCustom) tracciano la scala; ViewRotation traccia una rotazione a schermo non distruttiva che non tocca mai la voce /Rotate propria della pagina. Anche i metodi di navigazione — FirstPage, PriorPage, NextPage, LastPage — e i metodi di zoom — ZoomIn, ZoomOut, che percorrono una tabella fissa di diciannove livelli preimpostati dal 5% al 6400% — vivono qui, insieme a FindAll/FindNext/FindPrevious per la ricerca testuale e a AddHighlightRegion/RemoveHighlightRegion/ClearHighlightRegions per le annotazioni di pagina persistenti che un chiamante vuole mantenere tra un rendering e l'altro. Il modello possiede anche l'output oltre all'input: CreateCurrentPageSnapshot e CreateCurrentPageMetafile esportano esattamente la pagina attualmente a schermo, e PrintCurrentView invia quella stessa vista corrente — pagina corrente, DPI derivato dallo zoom corrente, rotazione corrente — a un TPrinter, un lavoro più circoscritto, limitato alla vista, rispetto alla pipeline di stampa a livello di documento trattata nella guida di HotPDF alla stampa con TPrinter. Ogni mutazione rilevante genera anche un evento corrispondente — OnPageChange, OnZoomChange, OnSearchChange, OnHighlightChange, OnViewRotationChange — cosicché un sottoscrittore scopra cosa è cambiato senza dover effettuare polling

Come sa THPDFViewer quando ridisegnare?

THPDFViewer sa quando ridisegnare perché si sottoscrive al modello invece di indovinare. Il costruttore di THPDFViewer crea un THPDFViewerModel privato, quindi collega ognuno dei suoi eventi di notifica — OnBeginUpdate, OnEndUpdate, OnHighlightChange, OnPageChange, OnSearchChange, OnViewRotationChange, OnZoomChange — a un gestore privato corrispondente. Il compito di ciascun gestore è ridotto: chiamare RefreshDocument, il metodo che effettivamente rasterizza la pagina corrente tramite lo stesso renderer di pagina con cache descritto negli interni di rendering pagina-verso-bitmap di HotPDF, poi compone sopra i riquadri di evidenziazione e i risultati di ricerca e applica la rotazione di visualizzazione corrente. Le proprietà pubblicate come PageIndex, Zoom, ZoomMode e ViewRotation sono semplici inoltratori — il getter legge FModel.PageIndex, il setter scrive FModel.PageIndex — cosicché, dall'Object Inspector o dal codice, il controllo sembri detenere direttamente lo stato, anche se THPDFViewerModel è l'unico posto in cui quello stato risiede realmente. I chiamanti non sono nemmeno limitati al sottoinsieme inoltrato: THPDFViewer espone il modello stesso tramite una proprietà di sola lettura Model: THPDFViewerModel, cosicché il codice che desidera FindFormFieldAt o PrefetchCurrentPageSnapshots — nessuno dei quali il controllo riespone — possa superare il wrapper e chiamare direttamente il modello

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 e EndUpdate: fermare le tempeste di ridisegno

BeginUpdate e EndUpdate esistono perché un singolo cambiamento logico spesso tocca più elementi di stato contemporaneamente, e ridisegnare dopo ciascun elemento sarebbe dispendioso e visivamente rumoroso. Sostituire il documento caricato è l'esempio più chiaro: assegnare THPDFViewerModel.Document azzera la rotazione di visualizzazione, cancella i risultati di ricerca, cancella le regioni evidenziate e salta alla prima pagina, e ciascuno di questi passaggi normalmente genera il proprio evento di cambiamento. THPDFViewerModel racchiude quella sequenza in BeginUpdate/EndUpdate, una coppia con conteggio di riferimenti in cui le chiamate annidate generano OnBeginUpdate solo alla transizione verso la chiamata più esterna e OnEndUpdate solo alla transizione di ritorno. THPDFViewer traccia la stessa profondità dal proprio lato e salta RefreshDocument per ogni evento granulare finché il conteggio è superiore a zero, per poi ridisegnare esattamente una volta quando il batch si chiude. Gli eventi granulari continuano comunque a generarsi durante il batch, cosicché un sottoscrittore interessato solo a OnSearchChange ne venga comunque informato; è solo il ridisegno proprio del controllo a essere ridotto a un'unica chiamata invece di quattro

Come mappa l'evidenziazione a lazo un trascinamento del mouse in coordinate PDF?

L'evidenziazione a lazo mappa un trascinamento del mouse in coordinate PDF tramite una coppia di metodi del modello costruiti esattamente per quel percorso di andata e ritorno: PagePointToView e ViewPointToPage. Entrambi accettano un indice di pagina, un DPI e un punto, ed entrambi risolvono la trasformazione in due fasi — prima la voce /Rotate propria della pagina e la sua origine PDF in basso a sinistra, poi la ViewRotation separata e non distruttiva della vista e l'origine device del visualizzatore in alto a sinistra — proprio affinché la direzione inversa possa annullare le due fasi in ordine strettamente contrario e completare correttamente il percorso di andata e ritorno su tutte le sedici combinazioni di rotazione di pagina e rotazione di vista. THPDFViewer chiama ViewPointToPage quando l'utente rilascia il mouse dopo aver trascinato un rettangolo in modalità di interazione vimHighlight, trasforma i due punti device in un THPDFRectangle nello spazio pagina, e lo passa a Model.AddHighlightRegion. Un dettaglio che vale la pena conoscere se si costruisce qualcosa di simile: la cattura del mouse appartiene al visualizzatore discendente da TScrollBox, non al TImage figlio in cui viene disegnato il bitmap, perché TControl.MouseCapture è protetto e solo il controllo genitore può rivendicarlo — quindi un trascinamento che esce dai confini dell'immagine prima che il pulsante venga rilasciato viene comunque risolto tramite i propri MouseMove/MouseUp sovrascritti del visualizzatore, invece di essere silenziosamente perso dal controllo figlio

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;

Cosa offre la suddivisione oltre a una suite di test verde

Il vantaggio non si limita a test superati in un job CI senza alcuna sessione desktop. Poiché THPDFViewer inoltra a THPDFViewerModel invece di duplicarne la logica, HotPDF ha potuto aggiungere un terzo consumatore — THPDFViewerAction e sottoclassi concrete come THPDFZoomInAction e THPDFFindNextAction — che collegano navigazione, zoom, ricerca e rotazione a una TActionList Delphi standard, cosicché un pulsante della toolbar o una voce di menu possano guidare il visualizzatore in modo dichiarativo, abilitandosi automaticamente in base al fatto che un visualizzatore sia attualmente risolto come target dell'azione. Nessuno di quel livello ha dovuto sapere nulla riguardo a bitmap o GDI; chiama Viewer.NextPage oppure Viewer.Model.FindNext, e la catena di eventi esistente si occupa del ridisegno. E poiché nulla in THPDFViewerModel fa riferimento a TScrollBox, TImage o a un handle di finestra, anche la macchina a stati sottostante non è saldata a quell'unico controllo — lo stesso modello potrebbe trovarsi dietro una superficie di rendering diversa senza toccare una sola riga di logica di navigazione, zoom o ricerca

Dove aiuta la cache di rendering, e dove no

La cache di rendering di THPDFViewerModel aiuta all'interno di un documento caricato, ma non cambia quanto costi in primo luogo caricare quel documento. CreatePageSnapshot, CreateCurrentPageSnapshot e i metodi di prefetch PrefetchPageSnapshots/PrefetchCurrentPageSnapshots passano tutti attraverso lo stesso renderer con cache indicizzato per pagina e DPI, cosicché tornare a una pagina già visualizzata allo stesso livello di zoom sia un hit di cache anziché un nuovo rendering, e il prefetch di un piccolo raggio di pagine vicine ammorbidisce il caso comune di un lettore che avanza una pagina alla volta. Nulla di tutto ciò tocca però il costo della chiamata iniziale LoadFromFile, e un visualizzatore costruito per aprire qualsiasi cosa un utente vi trascini sopra prima o poi incontra un file abbastanza grande da rendere quella chiamata il vero collo di bottiglia. Per l'alternativa a livelli, basata su handle, a un caricamento completo — utile da conoscere prima che arrivi quel giorno — si veda l'articolo di approfondimento sulla Direct File API per PDF di grandi dimensioni

Le classi Model e View descritte qui sono altre due componenti della stessa superficie del documento caricato usata in tutto il componente HotPDF per Delphi e C++Builder, costruito per essere guidato da una form, da una TActionList, oppure da nessuna delle due