Τεχνικό Άρθρο

Προσαρμοσμένος PDF Viewer στο HotPDF για Delphi: η αρχιτεκτονική MVC

Το HotPDF διαχωρίζει τον PDF viewer του για Delphi σε δύο κομμάτια: το THPDFViewerModel, μια απλή κλάση που κατέχει την κατάσταση zoom, περιστροφής, αναζήτησης, επισήμανσης, και πλοήγησης χωρίς καμία εξάρτηση από window handle, και το THPDFViewer, ένα στοιχείο ελέγχου βασισμένο σε TScrollBox που μετατρέπει αυτή την κατάσταση σε pixel. Ο διαχωρισμός είναι αυτό που επιτρέπει στη λογική του viewer να εκτελείται, και να δοκιμάζεται, χωρίς ποτέ να δημιουργηθεί μια φόρμα

Τα περισσότερα προσαρμοσμένα στοιχεία ελέγχου viewer δεν μοιάζουν έτσι. Το επίπεδο zoom ζει σε ένα ιδιωτικό πεδίο του στοιχείου ελέγχου, η πλοήγηση σελίδων περιορίζει τα όριά της μέσα στον χειριστή OnClick ενός κουμπιού, και ο μόνος τρόπος να γνωρίζετε αν το Ctrl+scroll σέβεται ένα ανώτατο όριο zoom είναι να τρέξετε την εφαρμογή, να κάνετε κλικ, και να κοιτάξετε. Ένα στοιχείο ελέγχου χτισμένο έτσι λειτουργεί μια χαρά μέχρι να χρειαστεί μια σουίτα παλινδρόμησης, ή έναν δεύτερο host — έναν διάλογο προεπισκόπησης εκτύπωσης, μια ράγα μικρογραφιών, έναν ελεγκτή παρτίδας χωρίς κανένα ορατό παράθυρο — και η κατάσταση που χρειάζεστε αποδεικνύεται συγκολλημένη σε ένα TWinControl που επιμένει σε πραγματικό handle προτού κάνει οτιδήποτε

Γιατί ένα στοιχείο ελέγχου PDF viewer χρειάζεται καθόλου διαχωρισμό MVC;

Ένας PDF viewer χρειάζεται αυτό το είδος διαχωρισμού επειδή η κατάστασή του και η παρουσίασή του αλλάζουν για διαφορετικούς λόγους και με διαφορετικούς ρυθμούς. Ο δείκτης σελίδας, το zoom, η περιστροφή όψης, τα ευρήματα αναζήτησης, και οι περιοχές επισήμανσης είναι κατάσταση επιχειρησιακής λογικής: μπορούν να υπολογιστούν, να επικυρωθούν, και να σειριοποιηθούν χωρίς ούτε ένα pixel στην οθόνη. Η ζωγραφική ενός bitmap, η σύλληψη του ποντικιού, και η σχεδίαση ενός ορθογωνίου επιλογής μαρκίζας είναι ζητήματα παρουσίασης που έχουν νόημα μόνο όταν υπάρχει ένα στοιχείο ελέγχου. Το HotPDF κρατά την πρώτη ομάδα στο THPDFViewerModel, μια κλάση χωρίς κανέναν πρόγονο παραθύρωσης VCL, και τη δεύτερη ομάδα στο THPDFViewer, το οποίο κατέχει ένα στιγμιότυπο μοντέλου και αντιδρά σε αυτό — πιο κοντά σε ζεύγος Model-View παρά σε ένα σχολικό τριεπίπεδο MVC, αφού δεν υπάρχει ξεχωριστή κλάση Controller και το ίδιο το THPDFViewer μετατρέπει τα ακατέργαστα συμβάντα πληκτρολογίου και ποντικιού σε κλήσεις μοντέλου. Αυτό που έχει μεγαλύτερη σημασία από την ετικέτα είναι η κατεύθυνση της εξάρτησης: τίποτα στο THPDFViewerModel δεν απαιτεί Handle, βρόχο μηνυμάτων, ή ορατή επιφάνεια εργασίας, κάτι που ακριβώς επιτρέπει στη δική του σουίτα δοκιμών του HotPDF να οδηγεί σελιδοποίηση, περιορισμό zoom, εντολές πληκτρολογίου, και μετατροπές συντεταγμένων μέσω DUnitX χωρίς να ανοίξει παράθυρο

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;

Τι κατέχει πραγματικά το THPDFViewerModel

Το THPDFViewerModel κατέχει ό,τι χρειάζεται ένας viewer για να απαντήσει τι θα έπρεπε να είναι αυτή τη στιγμή στην οθόνη χωρίς να κατέχει το πώς να το σχεδιάσει. Τα PageIndex, PageNumber, και PageCount παρακολουθούν τη θέση· τα Zoom και ZoomMode (vzmActualSize, vzmFitPage, vzmFitWidth, vzmCustom) παρακολουθούν την κλίμακα· το ViewRotation παρακολουθεί μια μη καταστροφική περιστροφή επί της οθόνης που δεν αγγίζει ποτέ τη δική της εγγραφή /Rotate της σελίδας. Οι μέθοδοι πλοήγησης — FirstPage, PriorPage, NextPage, LastPage — και οι μέθοδοι zoom — ZoomIn, ZoomOut, που διατρέχουν έναν σταθερό πίνακα δεκαεννέα προκαθορισμένων επιπέδων από 5% έως 6400% — ζουν και εδώ, μαζί με τα FindAll/FindNext/FindPrevious για αναζήτηση κειμένου και τα AddHighlightRegion/RemoveHighlightRegion/ClearHighlightRegions για επίμονες σημειώσεις σελίδας που ένας καλών θέλει να διατηρήσει μεταξύ των αποδόσεων. Το μοντέλο κατέχει επίσης την έξοδο όσο και την είσοδο: τα CreateCurrentPageSnapshot και CreateCurrentPageMetafile εξάγουν ακριβώς τη σελίδα που βρίσκεται αυτή τη στιγμή στην οθόνη, και το PrintCurrentView στέλνει αυτή την ίδια τρέχουσα όψη — τρέχουσα σελίδα, τρέχον DPI προερχόμενο από το zoom, τρέχουσα περιστροφή — σε έναν TPrinter, μια πιο στενή, εξαρτημένη από την όψη εργασία σε σύγκριση με τη διοχέτευση εκτύπωσης σε επίπεδο εγγράφου που καλύπτεται στην περιήγηση εκτύπωσης TPrinter του HotPDF. Κάθε μετάλλαξη που έχει σημασία εγείρει επίσης ένα αντίστοιχο συμβάν — OnPageChange, OnZoomChange, OnSearchChange, OnHighlightChange, OnViewRotationChange — ώστε ένας συνδρομητής να μαθαίνει τι άλλαξε χωρίς polling

Πώς ξέρει το THPDFViewer πότε να επανασχεδιάσει;

Το THPDFViewer ξέρει πότε να επανασχεδιάσει επειδή εγγράφεται στο μοντέλο αντί να μαντεύει. Ο κατασκευαστής του THPDFViewer δημιουργεί ένα ιδιωτικό THPDFViewerModel, έπειτα καλωδιώνει το καθένα από τα συμβάντα ειδοποίησής του — OnBeginUpdate, OnEndUpdate, OnHighlightChange, OnPageChange, OnSearchChange, OnViewRotationChange, OnZoomChange — σε έναν αντίστοιχο ιδιωτικό χειριστή. Η δουλειά κάθε χειριστή είναι μικρή: καλεί το RefreshDocument, τη μέθοδο που πραγματικά ραστεροποιεί την τρέχουσα σελίδα μέσω του ίδιου αποθηκευμένου σε cache αποδότη σελίδας που περιγράφεται στα εσωτερικά της απόδοσης σελίδας-σε-bitmap του HotPDF, έπειτα συνθέτει κουτιά επισήμανσης και ευρήματα αναζήτησης από πάνω και εφαρμόζει την τρέχουσα περιστροφή όψης. Δημοσιευμένες ιδιότητες όπως τα PageIndex, Zoom, ZoomMode, και ViewRotation είναι λεπτοί προωθητές — ο getter διαβάζει το FModel.PageIndex, ο setter γράφει το FModel.PageIndex — έτσι από τον Object Inspector ή από κώδικα, το στοιχείο ελέγχου φαίνεται να κρατά την κατάσταση απευθείας, παρότι το THPDFViewerModel είναι το μόνο μέρος όπου πράγματι ζει αυτή η κατάσταση. Οι καλούντες δεν περιορίζονται ούτε στο προωθημένο υποσύνολο: το THPDFViewer εκθέτει το ίδιο το μοντέλο μέσω μιας ιδιότητας μόνο για ανάγνωση Model: THPDFViewerModel, ώστε κώδικας που θέλει το FindFormFieldAt ή το PrefetchCurrentPageSnapshots — κανένα από τα δύο δεν επανεκτίθεται από το στοιχείο ελέγχου — να μπορεί να φτάσει πέρα από το wrapper και να καλέσει το μοντέλο απευθείας

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 και EndUpdate: σταματώντας τις καταιγίδες επανασχεδίασης

Τα BeginUpdate και EndUpdate υπάρχουν επειδή μια μεμονωμένη λογική αλλαγή συχνά αγγίζει πολλά κομμάτια κατάστασης ταυτόχρονα, και η επανασχεδίαση μετά από κάθε κομμάτι θα ήταν σπάταλη και οπτικά θορυβώδης. Η αντικατάσταση του φορτωμένου εγγράφου είναι το πιο καθαρό παράδειγμα: η ανάθεση στο THPDFViewerModel.Document επαναφέρει την περιστροφή όψης, καθαρίζει τα ευρήματα αναζήτησης, καθαρίζει τις περιοχές επισήμανσης, και μεταπηδά στη σελίδα ένα, και καθένα από αυτά τα βήματα κανονικά πυροδοτεί το δικό του συμβάν αλλαγής. Το THPDFViewerModel τυλίγει αυτή την ακολουθία σε BeginUpdate/EndUpdate, ένα ζεύγος μετρημένο με αναφορές όπου εμφωλευμένες κλήσεις πυροδοτούν το OnBeginUpdate μόνο στη μετάβαση προς την εξωτερικότερη κλήση και το OnEndUpdate στη μετάβαση προς τα έξω. Το THPDFViewer παρακολουθεί το ίδιο βάθος στη δική του πλευρά και παραλείπει το RefreshDocument για κάθε λεπτομερές συμβάν όσο ο μετρητής είναι πάνω από το μηδέν, έπειτα επανασχεδιάζει ακριβώς μία φορά όταν κλείνει η παρτίδα. Τα λεπτομερή συμβάντα εξακολουθούν να πυροδοτούνται κατά τη διάρκεια της παρτίδας, οπότε ένας συνδρομητής που ενδιαφέρεται μόνο για το OnSearchChange εξακολουθεί να το μαθαίνει· είναι μόνο η δική του επανασχεδίαση του στοιχείου ελέγχου που συμπτύσσεται σε μία κλήση αντί για τέσσερις

Πώς αντιστοιχίζει η επισήμανση μαρκίζας ένα σύρσιμο ποντικιού πίσω σε συντεταγμένες PDF;

Η επισήμανση μαρκίζας αντιστοιχίζει ένα σύρσιμο ποντικιού πίσω σε συντεταγμένες PDF μέσω ενός ζεύγους μεθόδων μοντέλου χτισμένων ακριβώς για αυτό το πήγαινε-έλα: PagePointToView και ViewPointToPage. Και οι δύο δέχονται δείκτη σελίδας, DPI, και ένα σημείο, και και οι δύο επιλύουν τον μετασχηματισμό σε δύο στάδια — πρώτα τη δική της εγγραφή /Rotate της σελίδας και την κάτω-αριστερή αρχή PDF της, έπειτα την ξεχωριστή, μη καταστροφική ViewRotation της όψης και την πάνω-αριστερή αρχή συσκευής του viewer — ειδικά ώστε η αντίστροφη κατεύθυνση να μπορεί να αναιρέσει τα δύο στάδια σε αυστηρά αντίστροφη σειρά και να κάνει σωστό πήγαινε-έλα σε όλους τους δεκαέξι συνδυασμούς περιστροφής σελίδας και περιστροφής όψης. Το THPDFViewer καλεί το ViewPointToPage όταν ο χρήστης αφήνει το ποντίκι μετά το σύρσιμο ενός ορθογωνίου σε λειτουργία αλληλεπίδρασης vimHighlight, μετατρέπει τα δύο σημεία συσκευής σε ένα THPDFRectangle σε χώρο σελίδας, και το παραδίδει στο Model.AddHighlightRegion. Μια λεπτομέρεια που αξίζει να γνωρίζετε αν χτίζετε κάτι παρόμοιο: η σύλληψη ποντικιού ανήκει στον απόγονο TScrollBox viewer, όχι στο θυγατρικό TImage στο οποίο ζωγραφίζεται το bitmap, επειδή το TControl.MouseCapture είναι protected και μόνο το γονικό στοιχείο ελέγχου μπορεί να το διεκδικήσει — έτσι ένα σύρσιμο που αφήνει τα όρια της εικόνας πριν σηκωθεί το κουμπί εξακολουθεί να επιλύεται μέσω των δικών του παρακαμπτόμενων MouseMove/MouseUp του viewer αντί να απορρίπτεται σιωπηλά από το θυγατρικό στοιχείο ελέγχου

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;

Τι σας προσφέρει ο διαχωρισμός πέρα από μια πράσινη σουίτα δοκιμών

Το όφελος δεν περιορίζεται στην επιτυχία δοκιμών σε μια εργασία CI χωρίς συνεδρία επιφάνειας εργασίας. Επειδή το THPDFViewer προωθεί στο THPDFViewerModel αντί να διπλασιάζει τη λογική του, το HotPDF μπόρεσε να προσθέσει έναν τρίτο καταναλωτή — το THPDFViewerAction και συγκεκριμένες υποκλάσεις όπως τα THPDFZoomInAction και THPDFFindNextAction — που συνδέουν πλοήγηση, zoom, αναζήτηση, και περιστροφή σε μια τυπική Delphi TActionList, ώστε ένα κουμπί εργαλειοθήκης ή ένα στοιχείο μενού να μπορεί να οδηγεί τον viewer δηλωτικά, ενεργοποιώντας τον εαυτό του αυτόματα ανάλογα με το αν ένας viewer επιλύεται αυτή τη στιγμή ως ο στόχος της ενέργειας. Τίποτα από αυτό το επίπεδο δεν χρειάστηκε να γνωρίζει τίποτα για bitmap ή GDI· καλεί το Viewer.NextPage ή το Viewer.Model.FindNext, και η υπάρχουσα αλυσίδα συμβάντων αναλαμβάνει την επανασχεδίαση. Και επειδή τίποτα στο THPDFViewerModel δεν αναφέρεται σε TScrollBox, TImage, ή window handle, ούτε η μηχανή κατάστασης από κάτω είναι συγκολλημένη σε αυτό το ένα στοιχείο ελέγχου — το ίδιο μοντέλο θα μπορούσε να στέκεται πίσω από μια διαφορετική επιφάνεια απόδοσης χωρίς να αγγίξει ούτε μία γραμμή λογικής πλοήγησης, zoom, ή αναζήτησης

Πού βοηθά η προσωρινή μνήμη απόδοσης, και πού όχι

Η προσωρινή μνήμη απόδοσης του THPDFViewerModel βοηθά μέσα σε ένα φορτωμένο έγγραφο, αλλά δεν αλλάζει τι κοστίζει η φόρτωση αυτού του εγγράφου εξαρχής. Τα CreatePageSnapshot, CreateCurrentPageSnapshot, και οι μέθοδοι προανάκτησης PrefetchPageSnapshots/PrefetchCurrentPageSnapshots περνούν όλα μέσα από τον ίδιο αποθηκευμένο σε cache αποδότη με κλειδί τη σελίδα και το DPI, οπότε η επιστροφή σε μια σελίδα που έχετε ήδη δει στο ίδιο επίπεδο zoom είναι ένα cache hit αντί για επανα-απόδοση, και η προανάκτηση μιας μικρής ακτίνας γειτονικών σελίδων εξομαλύνει την κοινή περίπτωση ενός αναγνώστη που σελιδοποιεί προς τα εμπρός μία σελίδα τη φορά. Τίποτα από αυτά όμως δεν αγγίζει το κόστος της αρχικής κλήσης LoadFromFile, και ένας viewer χτισμένος για να ανοίγει ό,τι σέρνει ένας χρήστης πάνω του τελικά συναντά ένα αρχείο αρκετά μεγάλο ώστε να κάνει αυτή την κλήση το πραγματικό σημείο συμφόρησης. Για την πολυεπίπεδη, βασισμένη σε handle εναλλακτική σε σχέση με μια πλήρη φόρτωση — αξίζει να τη γνωρίζετε πριν έρθει εκείνη η μέρα — δείτε το συνοδευτικό άρθρο για το Direct File API για μεγάλα PDF

Οι κλάσεις Model και View που περιγράφονται εδώ είναι δύο ακόμη κομμάτια της ίδιας επιφάνειας φορτωμένου εγγράφου που χρησιμοποιείται σε όλο το εξάρτημα HotPDF για Delphi και C++Builder, χτισμένο για να οδηγείται από μια φόρμα, από μια TActionList, ή από κανένα από τα δύο