技術記事

DelphiでカスタムPDFビューアを作る:HotPDFのMVCアーキテクチャ

HotPDFは、そのDelphi PDFビューアを2つの部品に分離している:ズーム・回転・検索・ハイライト・ナビゲーション状態を保持しウィンドウハンドルへの依存を一切持たない単純なクラスであるTHPDFViewerModelと、その状態をピクセルへ変換するTScrollBoxベースのコントロールであるTHPDFViewerである。この分離こそが、フォームを一切作成せずにビューアのロジックを実行し、テストすることを可能にしている

ほとんどのカスタムビューアコントロールはこのようにはなっていない。ズームレベルはコントロールのプライベートフィールドに存在し、ページナビゲーションはボタンのOnClickハンドラの内部でその範囲をクランプし、Ctrl+スクロールがズームの上限を尊重しているかどうかを知る唯一の方法はアプリを実行し、クリックし、目で見ることである。そのように作られたコントロールは、回帰テストスイートが必要になるまで、あるいは2つ目のホスト——印刷プレビューダイアログ、サムネイルレール、目に見えるウィンドウを一切持たないバッチレビューアなど——が必要になるまでは問題なく動作する。そのとき必要な状態が、何かをする前に本物のハンドルを要求するTWinControlに溶接されていることが判明する

なぜPDFビューアコントロールにそもそもMVC分割が必要なのか

PDFビューアにこの種の分割が必要なのは、その状態とその表示が異なる理由・異なる頻度で変化するからである。ページインデックス、ズーム、表示回転、検索ヒット、ハイライト領域はビジネス状態であり、画面上のピクセルを1つも必要とせずに計算・検証・シリアライズできる。ビットマップを描画すること、マウスをキャプチャすること、マーキー選択矩形を描くことは、コントロールが存在して初めて意味を持つ表示上の関心事である。HotPDFは前者のグループをVCLのウィンドウ祖先を一切持たないクラスTHPDFViewerModelに、後者のグループをモデルのインスタンスを保持しそれに反応するTHPDFViewerに保持する——教科書的な3層MVCというよりはモデル・ビューのペアに近い、独立したControllerクラスがなく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%までの19段階のプリセットレベルの固定テーブルを歩く——もここに存在し、テキスト検索用のFindAll/FindNext/FindPrevious、そして呼び出し元が描画の合間に保持しておきたい永続的なページ注釈用のAddHighlightRegion/RemoveHighlightRegion/ClearHighlightRegionsも同様である。モデルは入力だけでなく出力も保有する:CreateCurrentPageSnapshotCreateCurrentPageMetafileは現在画面に表示されているページそのものをエクスポートし、PrintCurrentViewはその同じ現在のビュー——現在のページ、現在のズームから導かれるDPI、現在の回転——をTPrinterに送信する。これはHotPDFのTPrinter印刷の解説で扱われている文書全体の印刷パイプラインよりも狭い、ビュースコープのジョブである。重要な変更はすべて対応するイベントも発生させる——OnPageChangeOnZoomChangeOnSearchChangeOnHighlightChangeOnViewRotationChange——そのため購読者はポーリングなしで何が変わったかを知ることができる

THPDFViewerはいつ再描画すべきかをどう知るのか

THPDFViewerは推測ではなくモデルを購読することでいつ再描画すべきかを知る。THPDFViewerのコンストラクタはプライベートなTHPDFViewerModelを作成し、その通知イベント——OnBeginUpdateOnEndUpdateOnHighlightChangeOnPageChangeOnSearchChangeOnViewRotationChangeOnZoomChange——のすべてを対応するプライベートハンドラに配線する。各ハンドラの仕事は小さい:HotPDFのページ・ツー・ビットマップレンダリングの内部実装で説明されているのと同じキャッシュ済みページレンダラーを通じて現在のページを実際にラスタライズするメソッドであるRefreshDocumentを呼び、その上にハイライトボックスと検索ヒットを合成し、現在の表示回転を適用する。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への代入は表示回転をリセットし、検索ヒットをクリアし、ハイライト領域をクリアし、1ページ目にジャンプする。そしてこれらの各ステップは通常それぞれ自身の変更イベントを発生させる。THPDFViewerModelはその一連の処理をBeginUpdate/EndUpdateという参照カウント方式のペアでラップし、ネストした呼び出しは最も外側の呼び出しに入る遷移でのみOnBeginUpdateを発生させ、外に戻る遷移でのみOnEndUpdateを発生させる。THPDFViewerはこちら側でも同じ深さを追跡し、カウントがゼロより大きい間はすべての細かいイベントに対してRefreshDocumentをスキップし、バッチが閉じたときに正確に一度だけ再描画する。細かいイベント自体はバッチ中も引き続き発生するため、OnSearchChangeだけを気にする購読者はそれでも通知を受け取る。潰されるのはコントロール自身の再描画だけであり、4回ではなく1回の呼び出しになる

マーキーによるハイライトはマウスドラッグをどうPDF座標に戻すのか

マーキーによるハイライトは、まさにその往復のために作られた2つのモデルメソッド、PagePointToViewViewPointToPageを通じてマウスドラッグをPDF座標に戻す。両方ともページインデックス、DPI、そして点を受け取り、変換を2段階で解決する——まずページ自身の/Rotateエントリとその左下起点のPDF原点、次にビューの独立した非破壊的なViewRotationとビューアの左上起点のデバイス原点である——これはまさに逆方向の変換がこの2段階を厳密に逆順で取り消し、ページ回転と表示回転の全16通りの組み合わせにわたって正しく往復できるようにするためである。THPDFViewerは、ユーザーがvimHighlightインタラクションモードで矩形をドラッグした後にマウスを離した際にViewPointToPageを呼び出し、2つのデバイス座標をページ空間のTHPDFRectangleに変換し、それをModel.AddHighlightRegionに渡す。似たようなものを作る際に知っておく価値のある一つの詳細:マウスキャプチャは、ビットマップが描画される子のTImageではなく、TScrollBoxを継承したビューア自身に属する。なぜならTControl.MouseCaptureはprotectedであり、親コントロールだけがそれを主張できるからだ——そのため、ボタンが上がる前に画像の境界を出てしまったドラッグでも、子コントロールによって静かに落とされることなく、ビューア自身のオーバーライドされた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は3つ目の消費者——ナビゲーション、ズーム、検索、回転を標準のDelphi TActionListに組み込むTHPDFViewerActionと、THPDFZoomInActionTHPDFFindNextActionのような具象サブクラス——を追加できた。これにより、ツールバーのボタンやメニュー項目は宣言的にビューアを駆動でき、ビューアが現在アクションのターゲットとして解決されているかどうかに基づいて自身を自動的に有効化する。この層はビットマップやGDIについて何も知る必要がなかった。それはViewer.NextPageViewer.Model.FindNextを呼ぶだけであり、既存のイベント連鎖が再描画の面倒を見る。そしてTHPDFViewerModel内にはTScrollBoxTImage、あるいはウィンドウハンドルへの参照が一切ないため、その下にある状態機械もその一つのコントロールに溶接されてはいない——同じモデルは、ナビゲーション・ズーム・検索ロジックの1行にも触れることなく、別の描画サーフェスの背後に座ることができる

レンダーキャッシュが助けになるところ、ならないところ

THPDFViewerModelのレンダーキャッシュは読み込み済み文書の内部では役立つが、その文書を読み込むこと自体のコストは変えない。CreatePageSnapshotCreateCurrentPageSnapshot、そしてプリフェッチメソッドであるPrefetchPageSnapshots/PrefetchCurrentPageSnapshotsはすべて、ページとDPIをキーとする同じキャッシュ済みレンダラーを経由する。そのため、同じズームレベルで既に見たページに戻ることは再レンダリングではなくキャッシュヒットになり、隣接ページの小さな半径をプリフェッチしておくことは読者が1ページずつ前へ進んでいくよくあるケースを滑らかにする。しかしそのいずれも最初のLoadFromFile呼び出しのコストには一切触れない。ユーザーがドラッグしたものを何でも開くように作られたビューアは、いずれその呼び出し自体が実際のボトルネックになるほど大きなファイルに出会う。その日が来る前に知っておく価値のある、完全な読み込みに代わる段階的でハンドルベースの代替手段については、大きなPDF向けDirect File APIの関連記事を参照してほしい

ここで説明したModelクラスとViewクラスは、フォームから、TActionListから、あるいはそのどちらからも駆動されるように作られた、DelphiおよびC++Builder向けHotPDFコンポーネント全体で使われている同じ読み込み済み文書のサーフェスを構成する、さらに2つの部品である