技術記事

DelphiのPDFium Componentにおける連続スクロールPDFビューアの作成

快適な閲覧倍率でレンダリングされた1枚のA4ページは、32ビットのビットマップで数メガバイトのオーダーになります。これを400ページの契約書に掛け算すると、計算は抽象的なものではなくなります。すべてのページをあらかじめレンダリングしてしまうと、Windowsに対して1ギガバイトを大幅に超えるビットマップを要求することになりますが、ユーザーが一度に閲覧するのは画面1つ分にすぎません。アプリケーションは、32ビットビルドではアドレス空間が不足するか、あるいはGPUとページパーサーが誰もスクロールしていないページを処理する間、最初の数秒間フリーズした状態になります。連続スクロールリーダーは、1本の長いリボンのようにページが繋がっているように見せる必要がありますが、実際にはそのすべてを一度にメモリに保持することはできません

この葛藤こそがここでの最大の問題です。PDFium ComponentはこれをTPdfViewの内部で解決しているため、主な作業は適切な表示モードを選択し、コンポーネントが何を行っているかを理解することになります。コンポーネントが行ってくれない部分、つまり閲覧フローに合わせたページのサイズ設定や、高速スクロールに対する応答性の維持などにおいて、少量のコードが威力を発揮します。周辺ツール(ツールバー、サムネイル、検索ボックスなど)をまだ構築していない場合は、多機能ビューア構築のチュートリアルでカバーされています。ここではスクロール自体に焦点を当てます

レイアウトはビットマップのパネルではなく、表示モードである

VCLフォームの作成では、直感的にスクロールボックスを使用して、その中にページごとにイメージコントロールを積み重ねようとしてしまいがちです。その衝動を抑えてください。その設計では、ページの配置、スクロールの計算、およびメモリ管理の問題をすべて自分で制御する必要があり、最終的にはそれらすべてを不完全に再発明することになります。TPdfViewは、すでにドキュメントを連続した一連 of ページとしてモデル化しており、DisplayModeプロパティを介してそのレイアウトを公開しています

Pdf := TPdf.Create(Self);
PdfView := TPdfView.Create(Self);
PdfView.Parent := Self;
PdfView.Align := alClient;
PdfView.Pdf := Pdf;

PdfView.DisplayMode := dmSingleContinuous;   // one page wide, scrolls vertically

Pdf.FileName := 'contract.pdf';
Pdf.Active := True;
if not Pdf.Active then
  ShowMessage('Could not open the document');

これで連続スクロールのセットアップはすべて完了です。dmSingleContinuousは、ページ間の隙間を内部で処理しつつ、単一の垂直カラムとしてページを配置し、ビューはそのカラム全体を1つの面としてスクロールします。ページごとのコントロールを接続する必要はなく、通常のナビゲーション用のスクロールハンドラーを作成する必要もありません。代入後のPdf.Activeのチェックに注意してください。ドキュメントを開く処理は例外を発生させないため、破損したファイルやパスワード保護されたファイルでは例外をキャッチできず、ActiveFalseのままになります。このチェックを怠ったビューアは、空白のパネルを表示し、自身のコードを疑うことになります

同じプロパティでスプレッド(見開き)モードも管理します。dmTwoPageContinuousは、見開き形式の閲覧に適した2ページ横並びのレイアウトを提供します。dmTwoPageContinuousWithCoverも同様ですが、1ページ目をカバー(表紙)として単独で表示し、残りの見開きが奇数・偶数の正しい境界に収まるようにします。これら3つのモードはすべて連続スクロールに対応しています。モードの切り替えは1つのプロパティへの代入だけで済むため、後から表示モード切り替えのコンボボックスを容易に追加できます

表示されているページのみがラスタライズされる

これが400ページのファイルにもスケールする理由は、カラムが仮想化されているからです。TPdfViewはドキュメントのページツリーからすべてのページの高さを把握しているため、何もラスタライズせずに全体のスクロール範囲と各ページの配置を計算できます。ページのコンテンツストリームをピクセルに変換する負荷の高いステップであるラスタライズは、現在ビューポートと交差しているページと、それがスクロールして表示されるまでのバッファとなるわずかなマージンに対してのみ実行されます。下にスクロールすると、ビューポートに入るページがレンダリングされ、ビューポートから出るページはビットマップが解放されます。メモリ使用量は、ドキュメント全体の長さではなく、画面に表示されている領域にのみ比例します

この点は、処理コストについて考える上で重要です。400ページのドキュメントを開く処理は低コストです。解析するのは構造であり、中身ではないからです。コストはページごとに発生し、ページがスクロールで近づいた瞬間に遅延処理されます。開いた瞬間は即座に表示され、スクロールがスムーズであるビューアは、全体としての処理量を減らしているのではなく、ユーザーの実際の読書経路に処理を分散し、背後に隠れた処理を破棄しているのです。実用的な結論として、ユーザーのスクロールを先回りしてページを強制レンダリングする必要はほとんどありません。何を表示すべきかはビューに任せましょう

ページを幅に合わせ、ズームはそのままにする

閲覧用のカラムは、絶対的なズーム値に固定するのではなく、パネルの幅に合わせてページサイズを調整するのが理想的です。FitModeはこれを実現し、ウィンドウのサイズが変更されても自動追従します

PdfView.FitMode := pfmFitWidth;   // each page fills the column width; height follows

pfmFitWidthを使用すると、ビューのサイズが変更されるたびにコンポーネントがズームを再計算するため、カラムは常に利用可能な幅いっぱいに広がり、ページの高さ、ひいてはスクロール範囲もそれに追従します。1つ注意が必要な罠があります。Zoomを直接代入すると、FitModeが自動的にpfmNoneにリセットされます。これは、手動ズームと自動フィットが相反する意図であるため意図的な設計ですが、コードのどこかで意図せずPdfView.Zoom := 1.0などを実行すると、幅に合わせる設定が暗黙的にオフになり、次のリサイズ時に再フローが停止してしまいます。ズームコントロールとフィットボタンの両方を提供する場合、それらをモードの切り替えとして扱い、一方が設定されたら他方をクリアするように制御してください

直感的な絶対ズームコントロールを提供するために、ビューは適用または表示可能なフィット用のズーム値を公開しています。PageWidthZoom[PageNumber]はそのページを幅に合わせるためのズーム値を返し、対応するPageZoomはページ全体を合わせるための値を返します。これらの値を読み取ることで、横向きのページや規格外のサイズのページで崩れてしまうようなマジックナンバー(固定のパーセンテージ)をハードコーディングすることなく、「幅に合わせる」や「ページに合わせる」メニューを構成できます

プログレッシブレンダリングによる高速スクロール時の応答性維持

デフォルトのレンダリング処理は、ページ全体の描画が完了するまで戻り値を返しません。単一のページを表示するだけならこれで問題ありません。しかし、情報密度の高いドキュメントを素早くスクロールする際には問題が生じます。通り過ぎる各ページがフルラスタライズを開始してしまい、レンダリング速度よりも速くスクロールされると、レンダリング処理がスタックしてパネルがカクつきます。これは、処理が完了した時点ですでに画面外に消えているページに対して無駄な処理を行っているためです。解決策は、レンダリング処理をキャンセル可能にし、ユーザーが次のページに進んだ瞬間にその処理を放棄することです

RenderPageProgressiveはチャンク(分割領域)ごとにレンダリングを行い、チャンクの境界でキャンセル トークンを確認します。これにより、スクロールで画面外に消えたページのレンダリング処理を、最後まで実行せずに途中で破棄できます

type
  TFormMain = class(TForm)
    // ...
  private
    FRenderCancel: IPdfCancellationTokenSource;
    procedure RenderPageToBitmap(PageNo: Integer; Bmp: TBitmap);
  end;

procedure TFormMain.RenderPageToBitmap(PageNo: Integer; Bmp: TBitmap);
var
  Status: TPdfProgressiveStatus;
begin
  // Cancel whatever was rendering; the old token is now signaled.
  if Assigned(FRenderCancel) then
    FRenderCancel.Cancel;
  FRenderCancel := TPdfCancellationTokenSource.New;

  Pdf.PageNumber := PageNo;
  Status := Pdf.RenderPageProgressive(Bmp, 0, 0, Bmp.Width, Bmp.Height,
    FRenderCancel.Token);

  case Status of
    prsDone:      ;                    // bitmap is complete, paint it
    prsCancelled: Exit;                // superseded, discard this result
    prsFailed:    ShowMessage('Render failed for page ' + IntToStr(PageNo));
  end;
end;

重要となるのは戻り値です。prsDoneはビットマップが完全に描画され、画面に描画(転送)できる状態を意味します。prsCancelledは新しいスクロール位置によってこのページが不要になったことを意味するため、部分的な描画結果は表示せずに破棄します。prsFailedはページで実際にエラーが発生したことを意味します。キャンセル処理は割り込み的に実行されるのではなく、チャンクの境界でポーリングされるため、Cancelを呼び出してからレンダリングが実際に停止するまでに数十ミリ秒の遅延が発生します。それでも、無効になったページのフルレンダリングがキューをブロックし続けるよりははるかに低コストです。トークンとしてnilを渡すと、途中で停止することなく完了まで描画されるため、キャンセルの必要がない印刷プレビューのような単発のレンダリング処理に適しています

代わりに新しいTBitmapを返す関数形式のRenderPageを呼び出す場合は、呼び出し側がその所有権を持ち、明示的にFreeする必要があることに注意してください。ページごとにビットマップを割り当てるスクロールループにおいて、この解放を忘れると、ユーザーがページをめくるたびにメモリリークが増加します。これは、連続スクロール設計が回避すべきメモリの無限消費バグそのものです。可能な限り、再利用可能なビットマップに対してレンダリングを行ってください

まとめ

連続スクロールリーダーの実装は、その大部分をコンポーネントが処理してくれます。レイアウトにdmSingleContinuousを選択し、カラムがウィンドウサイズに合わせて再フローするようにpfmFitWidthを設定し、破損したファイルで適切に失敗するようにPdf.Activeをチェックします。唯一、自身で実装する価値があるのはキャンセル可能なレンダリング処理です。なぜなら、長いドキュメントでスクロールバーを一気に下までドラッグした際に、ビューが追従できるかどうかでビューアの品質が評価されるからです。それ以外の機能、例えばページをまたぐテキスト選択、検索時のハイライト、ブックマークツリーなどは、スクロール処理の内部ではなく、その上に重ねるインターフェースの設計タスクになります

ここに記載されているTPdfViewDisplayMode、およびRenderPageProgressive APIは、DelphiおよびLazarus用のPDFium Componentに含まれています