Artikel Teknis

PDF Viewer Kustom HotPDF di Delphi: Arsitektur MVC

HotPDF memisahkan PDF viewer Delphi-nya menjadi dua bagian: THPDFViewerModel, sebuah kelas polos yang memegang state zoom, rotasi, pencarian, highlight, dan navigasi tanpa ketergantungan pada window-handle, serta THPDFViewer, sebuah kontrol berbasis TScrollBox yang mengubah state tersebut menjadi piksel. Pemisahan inilah yang memungkinkan logika viewer berjalan, dan diuji, tanpa pernah membuat sebuah form

Kebanyakan kontrol viewer kustom tidak terlihat seperti ini. Level zoom hidup di sebuah field privat pada kontrol, navigasi halaman membatasi batasnya di dalam handler OnClick sebuah tombol, dan satu-satunya cara mengetahui apakah Ctrl+scroll menghormati batas atas zoom adalah dengan menjalankan aplikasi, mengklik, dan melihat. Kontrol yang dibangun dengan cara itu bekerja baik-baik saja sampai ia membutuhkan suite regresi, atau host kedua — dialog print-preview, rel thumbnail, reviewer batch tanpa window yang terlihat sama sekali — dan state yang dibutuhkan ternyata sudah dilas ke sebuah TWinControl yang bersikeras membutuhkan handle asli sebelum mau melakukan apa pun

Mengapa kontrol PDF viewer membutuhkan pemisahan MVC sama sekali?

PDF viewer membutuhkan pemisahan semacam ini karena state dan presentasinya berubah karena alasan dan pada laju yang berbeda. Indeks halaman, zoom, rotasi tampilan, hasil pencarian, dan region highlight adalah state bisnis: semuanya bisa dihitung, divalidasi, dan diserialisasi tanpa satu piksel pun di layar. Menggambar sebuah bitmap, menangkap mouse, dan menggambar rectangle seleksi marquee adalah persoalan presentasi yang hanya masuk akal begitu sebuah kontrol sudah ada. HotPDF menyimpan kelompok pertama di THPDFViewerModel, sebuah kelas yang sama sekali tidak memiliki leluhur windowing VCL, dan kelompok kedua di THPDFViewer, yang memiliki sebuah instance model dan bereaksi terhadapnya — lebih dekat ke pasangan Model-View daripada MVC tiga-tingkat versi buku teks, karena tidak ada kelas Controller terpisah dan THPDFViewer sendiri yang mengubah event keyboard dan mouse mentah menjadi pemanggilan model. Yang lebih penting daripada labelnya adalah arah ketergantungannya: tidak ada apa pun pada THPDFViewerModel yang membutuhkan Handle, message loop, atau desktop yang terlihat, dan itulah persisnya yang memungkinkan test suite milik HotPDF sendiri menjalankan paging, pembatasan zoom, perintah keyboard, dan round-trip koordinat lewat DUnitX tanpa membuka sebuah window

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;

Apa sebenarnya yang dimiliki THPDFViewerModel

THPDFViewerModel memiliki segala sesuatu yang dibutuhkan sebuah viewer untuk menjawab apa yang seharusnya sedang tampil di layar tanpa memiliki cara menggambarnya. PageIndex, PageNumber, dan PageCount melacak posisi; Zoom dan ZoomMode (vzmActualSize, vzmFitPage, vzmFitWidth, vzmCustom) melacak skala; ViewRotation melacak rotasi non-destruktif di layar yang tidak pernah menyentuh entry /Rotate milik halaman itu sendiri. Metode navigasi — FirstPage, PriorPage, NextPage, LastPage — dan metode zoom — ZoomIn, ZoomOut, menelusuri tabel tetap berisi sembilan belas level preset dari 5% hingga 6400% — juga hidup di sini, berdampingan dengan FindAll/FindNext/FindPrevious untuk pencarian teks dan AddHighlightRegion/RemoveHighlightRegion/ClearHighlightRegions untuk anotasi halaman persisten yang ingin dipertahankan pemanggil di antara render. Model ini juga memiliki output selain input: CreateCurrentPageSnapshot dan CreateCurrentPageMetafile mengekspor persis halaman yang sedang tampil di layar, dan PrintCurrentView mengirim tampilan saat ini yang sama — halaman saat ini, DPI turunan zoom saat ini, rotasi saat ini — ke sebuah TPrinter, sebuah job yang lebih sempit dan terikat-view dibanding pipeline cetak seluruh dokumen yang dibahas di panduan pencetakan TPrinter milik HotPDF. Setiap mutasi yang penting juga memicu event yang sesuai — OnPageChange, OnZoomChange, OnSearchChange, OnHighlightChange, OnViewRotationChange — sehingga sebuah subscriber tahu apa yang berubah tanpa perlu polling

Bagaimana THPDFViewer tahu kapan harus menggambar ulang?

THPDFViewer tahu kapan harus menggambar ulang karena ia berlangganan ke model alih-alih menebak-nebak. Constructor THPDFViewer membuat sebuah THPDFViewerModel privat, lalu menghubungkan setiap event notifikasinya — OnBeginUpdate, OnEndUpdate, OnHighlightChange, OnPageChange, OnSearchChange, OnViewRotationChange, OnZoomChange — ke sebuah handler privat yang sesuai. Tugas masing-masing handler kecil: memanggil RefreshDocument, metode yang benar-benar merasterisasi halaman saat ini lewat page renderer ter-cache yang sama yang dijelaskan di internal rendering page-to-bitmap milik HotPDF, lalu mengompositkan kotak highlight dan hasil pencarian di atasnya serta menerapkan rotasi tampilan saat ini. Properti yang dipublikasikan seperti PageIndex, Zoom, ZoomMode, dan ViewRotation adalah forwarder tipis — getter membaca FModel.PageIndex, setter menulis FModel.PageIndex — sehingga dari Object Inspector atau dari kode, kontrol tersebut terlihat seolah memegang state secara langsung, padahal THPDFViewerModel adalah satu-satunya tempat state itu benar-benar hidup. Pemanggil juga tidak dibatasi hanya pada subset yang diteruskan itu: THPDFViewer mengekspos model itu sendiri lewat properti read-only Model: THPDFViewerModel, sehingga kode yang menginginkan FindFormFieldAt atau PrefetchCurrentPageSnapshots — yang tidak diekspos ulang oleh kontrol — bisa menjangkau melewati wrapper dan memanggil model secara langsung

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 dan EndUpdate: menghentikan badai redraw

BeginUpdate dan EndUpdate ada karena satu perubahan logis seringkali menyentuh beberapa bagian state sekaligus, dan menggambar ulang setelah tiap bagian akan boros dan mengganggu secara visual. Mengganti dokumen yang dimuat adalah contoh paling jelas: menetapkan THPDFViewerModel.Document mereset rotasi tampilan, menghapus hasil pencarian, menghapus region highlight, dan melompat ke halaman satu, dan setiap langkah tersebut biasanya memicu event perubahannya sendiri. THPDFViewerModel membungkus urutan itu dalam BeginUpdate/EndUpdate, sepasang metode dengan reference-count di mana pemanggilan bersarang hanya memicu OnBeginUpdate pada transisi masuk ke pemanggilan terluar dan OnEndUpdate pada transisi keluar kembali. THPDFViewer melacak kedalaman yang sama itu di sisinya dan melewatkan RefreshDocument untuk setiap event granular selama hitungan itu di atas nol, lalu menggambar ulang tepat satu kali ketika batch ditutup. Event granular tetap terpicu selama batch berlangsung, sehingga sebuah subscriber yang hanya peduli pada OnSearchChange tetap mendengarnya; hanya penggambaran ulang milik kontrol itu sendiri yang diciutkan menjadi satu pemanggilan alih-alih empat

Bagaimana highlight marquee memetakan drag mouse kembali ke koordinat PDF?

Highlight marquee memetakan drag mouse kembali ke koordinat PDF lewat sepasang metode model yang dibangun khusus untuk round trip itu: PagePointToView dan ViewPointToPage. Keduanya menerima indeks halaman, sebuah DPI, dan sebuah titik, dan keduanya menyelesaikan transformasi dalam dua tahap — pertama entry /Rotate milik halaman itu sendiri dan origin PDF kiri-bawahnya, lalu ViewRotation terpisah dan non-destruktif milik tampilan serta origin device kiri-atas milik viewer — secara khusus agar arah kebalikannya bisa membatalkan kedua tahap dalam urutan terbalik yang ketat dan melakukan round-trip dengan benar di seluruh enam belas kombinasi rotasi halaman dan rotasi tampilan. THPDFViewer memanggil ViewPointToPage ketika pengguna melepaskan mouse setelah menyeret sebuah rectangle dalam mode interaksi vimHighlight, mengubah kedua titik device menjadi sebuah THPDFRectangle dalam ruang halaman, dan menyerahkannya ke Model.AddHighlightRegion. Satu detail yang layak diketahui jika Anda membangun sesuatu yang serupa: penangkapan mouse dimiliki oleh viewer turunan TScrollBox, bukan oleh TImage anak tempat bitmap digambar, karena TControl.MouseCapture bersifat protected dan hanya kontrol induk yang bisa mengklaimnya — sehingga sebuah drag yang keluar dari batas gambar sebelum tombol dilepas tetap terselesaikan lewat MouseMove/MouseUp milik viewer itu sendiri yang di-override, alih-alih terjatuh diam-diam oleh kontrol anak

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;

Apa yang didapat dari pemisahan ini di luar test suite yang hijau

Hasilnya tidak terbatas pada test yang lolos di job CI tanpa sesi desktop. Karena THPDFViewer meneruskan ke THPDFViewerModel alih-alih menduplikasi logikanya, HotPDF berhasil menambahkan konsumer ketiga — THPDFViewerAction dan subclass konkret seperti THPDFZoomInAction dan THPDFFindNextAction — yang menghubungkan navigasi, zoom, pencarian, dan rotasi ke sebuah TActionList Delphi standar, sehingga sebuah tombol toolbar atau item menu bisa mengendalikan viewer secara deklaratif, mengaktifkan dirinya sendiri secara otomatis berdasarkan apakah sebuah viewer saat ini terselesaikan sebagai target action tersebut. Tidak satu pun dari lapisan itu perlu tahu apa pun tentang bitmap atau GDI; ia memanggil Viewer.NextPage atau Viewer.Model.FindNext, dan rantai event yang sudah ada mengurus penggambaran ulangnya. Dan karena tidak ada apa pun di THPDFViewerModel yang mereferensikan TScrollBox, TImage, atau window handle, mesin state di baliknya juga tidak dilas ke satu kontrol itu saja — model yang sama bisa berada di balik permukaan rendering yang berbeda tanpa menyentuh satu baris pun logika navigasi, zoom, atau pencarian

Di mana cache render membantu, dan di mana tidak

Cache render milik THPDFViewerModel membantu di dalam sebuah dokumen yang sudah dimuat, tetapi tidak mengubah biaya memuat dokumen itu sejak awal. CreatePageSnapshot, CreateCurrentPageSnapshot, dan metode prefetch PrefetchPageSnapshots/PrefetchCurrentPageSnapshots semuanya melalui renderer ter-cache yang sama, dikunci berdasarkan halaman dan DPI, sehingga kembali ke halaman yang sudah pernah dilihat pada level zoom yang sama adalah cache hit alih-alih render ulang, dan melakukan prefetch pada radius kecil halaman-halaman tetangga memperhalus kasus umum seorang pembaca yang membalik halaman maju satu per satu. Tak satu pun dari itu menyentuh biaya pemanggilan LoadFromFile awal, dan sebuah viewer yang dibangun untuk membuka apa pun yang diseret pengguna ke dalamnya pada akhirnya akan bertemu file yang cukup besar untuk membuat pemanggilan itu menjadi bottleneck sesungguhnya. Untuk alternatif berjenjang berbasis handle dibanding pemuatan penuh — layak diketahui sebelum hari itu tiba — lihat artikel pendamping tentang Direct File API untuk PDF besar

Kelas Model dan View yang dijelaskan di sini adalah dua bagian lagi dari permukaan dokumen-termuat yang sama yang digunakan di seluruh HotPDF Component untuk Delphi dan C++Builder, dibangun agar bisa dikendalikan dari sebuah form, dari sebuah TActionList, atau dari tidak keduanya sama sekali