技術文章

Delphi 中的 HotPDF 自訂 PDF 檢視器:MVC 架構

HotPDF 將 Delphi PDF 檢視器分成兩個部分:THPDFViewerModel 是負責縮放、旋轉、搜尋、醒目提示與導覽狀態的純類別,不依賴視窗控制代碼;THPDFViewer 則是以 TScrollBox 為基礎的控制項,將這些狀態轉換為畫面像素。這種拆分讓檢視器邏輯可以執行與測試,而完全不必建立表單

大多數自訂檢視器控制項並不是這樣。縮放層級存在控制項的私有欄位中,頁面導覽在按鈕的 OnClick 處理常式內限制邊界,而要知道 Ctrl+捲動是否遵守縮放上限,唯一方法就是執行應用程式、按一下並觀察。這樣建立的控制項在需要回歸測試套件或第二個主控端之前都能正常運作——例如列印預覽對話方塊、縮圖列,或完全沒有可見視窗的批次檢閱器——但此時需要的狀態往往已焊死在堅持必須先有實際控制代碼才能運作的 TWinControl

PDF 檢視器控制項為什麼需要 MVC 拆分

PDF 檢視器需要這種拆分,是因為它的狀態與呈現會基於不同原因,以不同速率變更。頁面索引、縮放、檢視旋轉、搜尋結果與醒目提示區域都是業務狀態:它們可以在沒有畫面上任何像素的情況下計算、驗證與序列化。繪製點陣圖、擷取滑鼠,以及繪製框選矩形則是呈現層面的工作,只有在控制項存在後才有意義。HotPDF 將前一組放在完全沒有 VCL 視窗祖先的 THPDFViewerModel 類別中,並將後一組放在擁有模型實例且對模型作出反應的 THPDFViewer 中——這更接近模型—檢視配對,而不是教科書式的三層 MVC,因為沒有獨立的控制器類別,且 THPDFViewer 本身會將原始鍵盤與滑鼠事件轉換為模型呼叫。比名稱更重要的是依賴方向:THPDFViewerModel 的任何部分都不需要 Handle、訊息迴圈或可見桌面,正因如此,HotPDF 自己的測試套件才能透過 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 擁有檢視器回答目前畫面上應顯示什麼所需的一切,但不負責擁有繪製方式。PageIndexPageNumberPageCount 追蹤位置;ZoomZoomModevzmActualSizevzmFitPagevzmFitWidthvzmCustom)追蹤縮放比例;ViewRotation 追蹤非破壞性的畫面旋轉,不會觸碰頁面本身的 /Rotate 項目。導覽方法——FirstPagePriorPageNextPageLastPage——以及縮放方法——ZoomInZoomOut,在從 5% 到 6400% 的十九個預設層級固定表格中移動——也都位於此處,與用於文字搜尋的 FindAll/FindNext/FindPrevious,以及用於保存呼叫端希望在各次呈現之間保留的頁面註釋之 AddHighlightRegion/RemoveHighlightRegion/ClearHighlightRegions 並列。模型同時擁有輸出與輸入:CreateCurrentPageSnapshotCreateCurrentPageMetafile 匯出目前畫面上的頁面,而 PrintCurrentView 則將相同的目前檢視——目前頁面、目前縮放衍生的 DPI、目前旋轉——傳送至 TPrinter,這是比 HotPDF 的 TPrinter 列印導覽所涵蓋之整份文件列印流程更窄、以檢視為範圍的工作。每個重要的變更也都會引發相應事件——OnPageChangeOnZoomChangeOnSearchChangeOnHighlightChangeOnViewRotationChange——因此訂閱者不必輪詢就能得知變更內容

THPDFViewer 如何知道何時要重新繪製

THPDFViewer 之所以知道何時要重新繪製,是因為它訂閱模型,而不是自行猜測。THPDFViewer 的建構函式會建立私有的 THPDFViewerModel,接著將其中每一個通知事件——OnBeginUpdateOnEndUpdateOnHighlightChangeOnPageChangeOnSearchChangeOnViewRotationChangeOnZoomChange——連接到相應的私有處理常式。每個處理常式的工作都很小:呼叫 RefreshDocument,這個方法會透過 HotPDF 頁面轉點陣圖的內部實作中所描述的相同快取頁面轉譯器,實際將目前頁面點陣化,然後在上方合成醒目提示框與搜尋結果,並套用目前的檢視旋轉。PageIndexZoomZoomModeViewRotation 等已發佈屬性都是薄型轉送器——取值器讀取 FModel.PageIndex,設定器寫入 FModel.PageIndex——因此從物件檢查器或程式碼來看,控制項像是直接持有狀態,儘管實際持有狀態的唯一位置是 THPDFViewerModel。呼叫端也不受限於轉送的子集:THPDFViewer 透過唯讀的 Model: THPDFViewerModel 屬性公開模型本身,因此需要 FindFormFieldAtPrefetchCurrentPageSnapshots 的程式碼——這兩者都沒有由控制項重新公開——可以越過包裝器直接呼叫模型

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 座標:PagePointToViewViewPointToPage。兩者都接受頁面索引、DPI 與點,並分兩個階段解析轉換——先處理頁面自身的 /Rotate 項目與其左下角 PDF 原點,再處理檢視各自獨立且非破壞性的 ViewRotation 與檢視器左上角的裝置原點——特別是為了讓反向能以嚴格相反的順序取消這兩個階段,並在頁面旋轉與檢視旋轉的全部十六種組合中正確往返。當使用者在 vimHighlight 互動模式中拖曳矩形後放開滑鼠,THPDFViewer 會呼叫 ViewPointToPage,將兩個裝置點轉換為頁面空間中的 THPDFRectangle,再交給 Model.AddHighlightRegion。如果你要建立類似功能,有一個細節值得知道:滑鼠擷取屬於繼承自 TScrollBox 的檢視器,而不是繪製點陣圖的子 TImage,因為 TControl.MouseCapture 受到保護,只有父控制項可以取得它——因此即使拖曳在按鈕放開前離開影像邊界,仍會透過檢視器自己覆寫的 MouseMove/MouseUp 解析,而不會被子控制項靜默丟棄

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 得以加入第三個使用者——THPDFViewerActionTHPDFZoomInActionTHPDFFindNextAction 等具體子類別——將導覽、縮放、搜尋與旋轉接入標準 Delphi TActionList,讓工具列按鈕或功能表項目可以宣告式地驅動檢視器,並根據目前是否已解析出檢視器作為動作目標,自動啟用自身。這一層完全不必知道點陣圖或 GDI;它呼叫 Viewer.NextPageViewer.Model.FindNext,而既有的事件鏈會負責重新繪製。由於 THPDFViewerModel 沒有參考 TScrollBoxTImage 或視窗控制代碼,底層狀態機也沒有焊死在該控制項上——相同模型可以置於不同的轉譯表面之後,而不必修改任何導覽、縮放或搜尋邏輯

轉譯快取在哪裡有幫助,在哪裡沒有

THPDFViewerModel 的轉譯快取在已載入文件內有幫助,但不會改變首次載入該文件的成本。CreatePageSnapshotCreateCurrentPageSnapshot 以及 PrefetchPageSnapshots/PrefetchCurrentPageSnapshots 預先擷取方法,都會透過以頁面與 DPI 為索引鍵的相同快取轉譯器,因此以相同縮放層級返回已檢視過的頁面時,會命中快取而非重新轉譯,而預先擷取鄰近頁面的小範圍也能讓讀者逐頁向前翻閱的常見情境更順暢。不過,這些功能都不會影響初始 LoadFromFile 呼叫的成本,而任何為了開啟使用者拖曳進來的檔案所建立的檢視器,最終都會遇到大到讓該呼叫成為實際瓶頸的檔案。若要瞭解完整載入的分層、控制代碼式替代方案——在那一天到來前先知道這點很有價值——請參閱Direct File API 大型 PDF 工作流程的配套文章

本文所描述的模型與檢視類別,是 Delphi 與 C++Builder 適用的 HotPDF 元件整個已載入文件介面的另外兩個部分,可從表單、TActionList,或完全不依賴上述兩者來驅動