技術記事

Delphi PDFビューアでのディスクキャッシュとモニターごとのDPI対応

組み込みPDFビューアにおいて、常に付きまとう不満が2つあります。1つは、大容量ドキュメントを起動するたびに最初から再レンダリングされること、もう1つは、ウィンドウを高DPIモニターに移動した瞬間にテキストがぼやけることです。losLab PDF Library は、TPDFlibViewer コントロールにおいてこれら両方に対応しています。DiskCacheFolder プロパティは、ドキュメントのフィンガープリントをキーとするセッション間のディスクページキャッシュを有効にし、ScreenDPI プロパティは、GetDpiForWindow および WM_DPICHANGED メッセージハンドラと連携して、モニターごとの真のDPI対応(Per-Monitor DPI Awareness)を実現します。

どちらの問題も、ユーザーが即座に気づくため深刻に捉える必要があります。昨日スクロール可能になるまで8秒かかったスキャン済みの400ページの契約書は、ファイルに変更がないにもかかわらず、今日もまた8秒かかります。また、異なるDPIが混在するデスクトップ環境では、プライマリモニターの 96 DPI で綺麗にレンダリングされているビューアを、隣の4Kパネルに移動した途端にぼやけて見えてしまいます。この記事では、TPDFlibViewer がこれらをどのように解決しているか、またなぜそのような設計になったのかを詳しく解説します。コントロール自体の基本的な使い方については、まずインタラクティブPDFビューアコントロールの概要をお読みになり、その後でこちらのパフォーマンス層の解説に戻ってください。

Delphiでセッションをまたいでレンダリング済みPDFページをキャッシュする方法

TPDFlibViewer.DiskCacheFolder にフォルダのパスを割り当てるだけで、コントロールはレンダリングされたページのビットマップをディスクに保存し、再読込時に再レンダリングすることなく瞬時にドキュメントを開くことができます。ディスク上の構造は <Folder>\PDFlibPas-PageCache\<document fingerprint>\p<page>_z<zoomkey>.bin となり、ドキュメントごとに1つのサブフォルダ、ページとズームの組み合わせごとに1つのファイルが作成されます。これ以外のすべての処理は自動化されており、呼び出すべきキャッシュ用のAPIや手動でのフラッシュ処理などは不要です。

uses
  PDFlibViewer;

procedure TMainForm.FormCreate(Sender: TObject);
begin
  FViewer := TPDFlibViewer.Create(Self);
  FViewer.Parent := Self;
  FViewer.Align := alClient;
  // One line turns on the cross-session page cache
  FViewer.DiskCacheFolder := 'C:\ProgramData\MyApp\PageCache';
  FViewer.LoadFromFile('C:\Contracts\master-agreement.pdf', '');
end;

DiskCacheHits カウンタは、現在のセッションにおいてディスクキャッシュによってどれだけのレンダリング処理が削減されたかを報告するため、単なる感覚的な効果ではなく数値として効果を測定できます。ドキュメントを閉じて再度開いた場合、レンダリング処理を経ずにスクロール表示されたすべてのページでこのカウンタがインクリメントされます。

キャッシュキーがバージョン番号ではなくフィンガープリントである理由

ドキュメントのフィンガープリントは、path|size|last write time(パス、ファイルサイズ、最終更新日時)の文字列から生成される 64ビットの FNV-1a ハッシュ値であり、16進数の16文字で表現されます。この3つを組み合わせることでキャッシュの無効化処理を完結させています。ファイルが編集されてサイズや更新日時が変更されるとハッシュ値が変化するため、ビューアは単に異なるキャッシュサブフォルダを使用するようになります。古いフォルダが参照されることはなくなり、最終的には LRU 破棄アルゴリズムによって削除されます。管理すべきバージョンタグのプロトコルも、ズレの生じやすいタイムスタンプの比較ロジックも不要であり、変更されたドキュメントに対して古いビットマップが誤って適用される心配もありません。

これは、ビルドシステムなどが使用する「コンテンツの同一性をキャッシュキーとする」設計思想と同じであり、実装上の不具合を排除する点で大いに役立ちます。ドキュメントの元のファイル名をキーとするキャッシュでは、明示的に編集を検知する必要があり、そのような検知処理は常にエッジケース(バックアップから古いタイムスタンプのまま復元されたファイル、ファイルサイズが同じまま保存されたケース、大文字小文字を区別する環境とそうでない環境の違いなど)によるバグを誘発します。これら3つのシグナルをフォルダ名にハッシュ化して落とし込むことで、キャッシュの不整合を「手動での検知処理」ではなく「構造上不可能」な仕組みにしています。

回転角度がディスクのキーにあえて含まれていない理由

キャッシュのファイル名にはページ番号とズーム率のみがエンコードされ、表示回転角度は意図的に除外されています。ディスクキャッシュは常に「回転していない」レンダリングデータを保存し、回転表示中にキャッシュから読み込まれたページは、メモリ上にデコードされた後にピクセルが再配置されます。もし回転角度をディスクのキーに含めてしまうと、ユーザーが4つの回転角度を切り替えるだけで同じページがディスクに4回書き込まれてしまい、キャッシュサイズが4倍に膨れ上がってしまいます。回転処理はフルレンダリングに比べて極めて軽量なピクセル並べ替え処理であるため、ディスクに多重に保存するメリットはありません。

ただし、メモリ上のビットマップキャッシュでは、描画ハンドラが回転後のデータを即座に必要とするため、Round(Zoom * 1000) * 4 + Rotation div 90 という複合キーを使用しています。バックグラウンドの先読みスレッドも同じ仕様に従い、常に回転していないページをレンダリングしてプレーンなズームキーを報告し、メインスレッドが結果を取り出す際に回転を適用します。これは並列ページレンダリングとスレッドセーフで解説されている「メインスレッドがキャッシュを所有する」ルールと同じです。メモリ層の下には回転を含めないことで、高コストなレンダリング結果を一意に保存する設計になっています。

LRU破棄処理によるキャッシュフォルダ容量の制御

キャッシュは最大10個のドキュメントフォルダを保持し、新しいドキュメントが追加されると最も古く使用された(Least Recently Used)フォルダを削除します。使用履歴は、各ドキュメントフォルダ内の last-used.marker ファイルの更新日時によって管理されます。このタイムスタンプはキャッシュの読み書きのたびに更新されるため、再閲覧されただけのドキュメントも書き直されることなく最新の状態に維持されます。現在開いているドキュメントのフォルダは削除対象から除外されるため、表示中のビューの下からキャッシュ画像が消えることはありません。

キャッシュ処理におけるすべてのディスク操作(フィンガープリント生成、フォルダ作成、マーカーの更新、ページの読み書き、LRUの削除など)は、発生した例外を内部で吸収します。これは手抜きではなく、意図的な設計です。キャッシュはパフォーマンスを向上させるためのベストエフォートなアクセラレータであり、ディスクがいっぱいだったり、書き込み禁止フォルダだったり、ウイルス対策ソフトのロックがかかったりした場合でも、ビューアは「以前の速度でレンダリングする」ように動作をフォールバックすべきであり、「ページの表示に失敗する」という最悪の結果を防ぐ必要があります。キャッシュIOのエラーが引き起こす最悪の結果は、単にキャッシュヒットしないことだけです。

procedure TMainForm.FormClose(Sender: TObject; var Action: TCloseAction);
begin
  // How many render passes did the disk cache save this session?
  Log(Format('Disk cache hits: %d', [FViewer.DiskCacheHits]));
end;

DelphiでPDFビューアをDPI対応にする方法

TPDFlibViewer は、DPIに依存するすべての計算を一元管理しています。この単一の設計構造により、マルチモニターへの対応が容易になっています。レイアウト、先読み、およびレンダリングコードはすべて、単一 of FScreenDPI 値を参照します。100%ズーム時、1 PDFポイントは ScreenDPI / 72 ピクセルにマッピングされます。公開されている ScreenDPI プロパティを設定すると、自動的にビットマップキャッシュがクリアされ、レイアウトが再構築され、スクロール範囲が更新され、ウィンドウが再描画されます。これにより、DPIの変更は単一の代入で完了し、すべての依存処理が自動的に追従します。同じピクセル計算は、モニターではなくプリンタにレンダリングする際にも重要となります。これについては、プリントプレビューとデバイスコンテキスト描画で詳しく説明されています。

DPIの検出はコンストラクタではなく、CreateWnd のオーバーライド内で行われます。なぜなら GetDpiForWindow は有効なウィンドウハンドルを必要とするのに対し、コンストラクタ実行時にはまだハンドルが存在しないからです。CreateWnd が実行されるまで、コントロールはデフォルト値である 96 を保持して計算の整合性を維持します。GetDpiForWindow 自体は Windows 10 1607以降のAPIであるため、コントロールは GetProcAddress を使用して動的にバインドし、古いシステムでは GetDeviceCaps(LOGPIXELSY) にフォールバックします。これにより、ユニットはすべての環境でロード可能であり、古いWindowsシステムでは単純にシステムDPIが報告されます。

ウィンドウを4Kモニターに移動したときの動作

モニターごとのDPIに対応したウィンドウが異なるスケールのモニターをまたいだ際、Windowsは WM_DPICHANGED メッセージを送信し、TPDFlibViewer はこれを直接処理します。メッセージハンドラは、wParam の下位ワードから新しいDPIを読み取り、それを SetScreenDPI を介して適用します。これにより、以前のビットマップキャッシュが破棄されてページレイアウトが更新され、さらに lParam で渡された推奨ウィンドウ矩形を SetWindowPos を介して適用することで、表示の移動前後で論理的なクライアントサイズを維持します。ユーザーの画面には、OSによって引き伸ばされたビットマップではなく、新しいスケーリング率でくっきりと再レンダリングされたドキュメントが表示されます。

// Normally you never touch ScreenDPI: CreateWnd detects the host monitor
// and WM_DPICHANGED tracks moves. Override it only for special targets,
// e.g. rendering the layout as if for a 150% display:
FViewer.ScreenDPI := 144;  // clears the bitmap cache and rebuilds layout

リリース前に知っておくべき制限事項

設計時に考慮すべき3つの実質的な制限があります。第一に、WM_DPICHANGED メッセージはプロセスがPer-Monitor DPIに対応している場合にのみ送信されます。アプリケーションマニフェストで「モニターごとのDPI対応」(現在のWindowsでは Per-Monitor V2)を宣言する必要があり、Delphi IDEではアプリケーションオプションの「DPI対応」の設定に該当します。システムDPI対応(System DPI Aware)のみのプロセスはメッセージを受け取らず、ウィンドウ作成時に検出されたDPIのまま描画されます。第二に、ディスクキャッシュは速度のためにディスク容量を消費します。複数のズーム率にわたる最大10個のドキュメントのページ画像は無視できない容量になるため、DiskCacheFolder には移動ユーザープロファイル(Roaming Profile)などを指すのではなく、十分な容量のあるローカルフォルダを設定してください。第三に、キャッシュフォルダは実行中のユーザーに対する書き込み権限が必要です。権限がない場合でもビューア自体は動作しますが、キャッシュヒットは0になります。テスト時にはキャッシュが機能しているか DiskCacheHits を確認してください。

これら2つの機能は、単なる描画の最適化以上に、ドキュメントを多用するアプリケーションの使い心地を向上させます。再訪したドキュメントは瞬時に開き、ウィンドウをどのモニターに移動させても表示はクリアに保たれます。どちらの機能も TPDFlibViewer の標準的なプロパティとして提供されているため、高コストな描画エンジンの開発に時間をかけることなく、半日程度の記述でアプリケーションに導入できます。