技術記事

Delphiで非破壊のPDFハイライト:HotPDFレビューレイヤー

レビュー中に段落の周りに描かれた矩形は、必ずしもPDF内部のマークになる必要はない。HotPDFのTHPDFViewerModelAddHighlightRegionを公開しており、このメソッドはすべてのハイライトを読み込み済み文書への変更としてではなくメモリ上の記録として保持する。そのためレビュー担当者は、ディスク上のファイルがバイト単位で元のままである間に、何十ページにもわたって書き込みを行うことができる。6400%までズームし、ページを90度回転させ、幅に合わせるからページに合わせるへ切り替えても、同じ矩形は同じ段落の上に留まる。なぜなら座標の計算は、マークが描かれた瞬間の実際のレンダリングジオメトリを通じて実行されるからだ

PDFビューアを中心に構築されたレビューツールは、この問題に絶えず直面する。赤入れ画面、生成された請求書に対するQAパス、社内の承認ワークフロー——これらはすべて、下書きのマークがファイルへの恒久的な変更になってしまうことなく、また誰かがそのマークが妥当かどうかをまだ判断している間に色付きの箱を表示するためだけに本格的な注釈サブシステムに手を伸ばすことなく、誰かがページの一領域に注意を向けられるようにする必要がある。HotPDFはこれに対し、DelphiでMVCアーキテクチャによるカスタムPDFビューアを構築するで説明されている分割のModel側に完全に存在する専用のハイライトレイヤーで答える。これはまた、同じハイライトリストがウィンドウハンドルを一切必要とせず単体テストから駆動できる理由でもある

HotPDFのAddHighlightRegionは実際に何を保存するのか

AddHighlightRegionは各マークにつき正確に3つのものを保存する:0始まりのページインデックス、PDFユーザー空間座標でのTHPDFRectangle、そしてTColor。これらはすべてTHPDFViewerModel内部のTHPDFViewerHighlightレコードとしてパッケージ化される。Viewer.HighlightRegion(PageIndex, PageRect, clYellow)、あるいは同等のModel.AddHighlightRegionを呼び出すと、これらのレコードの1つがプライベートな配列に追加され、そのインデックスが返される。呼び出し元が受け取るハンドルはそのインデックスだけである:別個のオブジェクトも、参照カウント方式のインターフェースも、解放すべきものも一切ない。この記事にある他のすべての機能——マークを描画すること、ズーム変更後にそれを再マッピングすること、削除すること——は、その一つの小さなレコードの上に構築されている

すべての矩形は受け入れられる前に正規化・クリップされる。レビュー担当者が右から左にドラッグするとAddHighlightRegionは左端と右端を入れ替え、上方向へのドラッグでは上端と下端を入れ替え、その結果をGetLoadedPageBox経由で取得したページのMediaBoxに対してクリップする。幅ゼロ、高さゼロ、あるいはページの外に完全に出てしまった矩形はきっぱりと拒否される:メソッドは-1を返し、リストには何も追加されない。この戻り値は飾りではない:外部のレビューファイルから再構築された一連のハイライト、あるいはページが差し替えられた後の古い座標は、呼び出し元がそれをチェックしなければ静かにエントリを失うことがある

ハイライトはズームや回転の後どうやって位置を保つのか

ハイライトが位置を保つのは、HotPDFがそれをPDFページ空間に保存し、ズームレベルが変わった瞬間に古くなってしまう画面矩形として保存するのではなく、再描画のたびに画面空間へ再投影するからである。THPDFViewerModel.PagePointToViewとその逆であるViewPointToPageは、この投影を2段階で行う:まずページ自身の/Rotateエントリ、次にビューアの独立したViewRotationである。後者はPDFに書き戻されることは決してなく、ビューアが表示するものにのみ影響する。マウスを離した際の変換の巻き戻しは同じ2段階を逆順で実行する。これにより、270度回転したページ上で高いズームレベルで描かれたハイライトが、レビュー担当者がビューをページに合わせるへリセットした後も正確に正しい位置に着地することが可能になる

その投影に使われるDPIは、回転と同じくらい重要である。HotPDFのビューアは各レンダリングの直後に、現在画面上にあるビットマップの正確なDPIをFRenderedDPIに取り込み、ImageMouseUpはその同じ値をViewPointToPageに渡す。これにより、マウス座標は現在のズームプロパティから再計算された解像度ではなく、実際に描画された解像度を使って常に変換される。CreatePageSnapshotとその関連メソッドはDPIを12から2400の範囲に制限するが、インタラクティブな描画経路にはそのような上限はない:標準のズームラダーは6400%を上限としており、これは既定の96 DPIを基準にすると2400 DPIをゆうに超える計算になる。そのため座標マッピングにスナップショット式の上限を流用すると、ズーム範囲の上限付近ですべてのハイライトが数ピクセルずれてしまう。もう2つの小さな既定値がこのインタラクションを補完する:どちらの軸でも2ピクセルより短いドラッグはクリックとして扱われハイライトを生成しない、そしてFRenderedDPIはゼロから始まるため少なくとも1ページが実際にレンダリングされるまではハイライトを開始できない

インタラクティブなハイライトをレビュー画面に組み込む

インタラクティブなハイライトを有効にすることは、THPDFViewerコントロール自体における3プロパティの作業である:既定のvimBrowseではなくInteractionModevimHighlightに設定し、既定でclYellowであるHighlightColorを選び、レビュー担当者がちょうど描いたものを知るためにOnMarqueeSelectを処理する。それ以外のすべて——マウスをキャプチャすること、レビュー担当者がドラッグしている間に破線の選択矩形を描くこと、離した位置をページ空間に変換すること、AddHighlightRegionを呼び出すこと——は、そのイベントが発火する前にコントロール内部で行われる

type
  TReviewForm = class(TForm)
    Viewer: THPDFViewer;
    ReviewLog: TMemo;
    procedure FormCreate(Sender: TObject);
  private
    procedure ViewerMarqueeSelect(Sender: TObject; Shift: TShiftState;
      PageIndex: Integer; const PageRect: THPDFRectangle;
      HighlightIndex: Integer);
  end;

// PdfDoc is a THotPDF already loaded elsewhere on the form
procedure TReviewForm.FormCreate(Sender: TObject);
begin
  Viewer.PDFDocument := PdfDoc;
  Viewer.InteractionMode := vimHighlight;
  Viewer.HighlightColor := clLime;
  Viewer.OnMarqueeSelect := ViewerMarqueeSelect;
end;

procedure TReviewForm.ViewerMarqueeSelect(Sender: TObject; Shift: TShiftState;
  PageIndex: Integer; const PageRect: THPDFRectangle; HighlightIndex: Integer);
begin
  ReviewLog.Lines.Add(Format('page %d, mark #%d at (%.1f, %.1f)-(%.1f, %.1f)',
    [PageIndex + 1, HighlightIndex, PageRect.Left, PageRect.Bottom,
     PageRect.Right, PageRect.Top]));
end;

OnMarqueeSelectは実際にハイライトを生成したドラッグに対してのみ発火する:ドラッグとして数えるには小さすぎるクリックは選択オーバーレイを即座にクリアし、ページの外側に完全に着地したドラッグはAddHighlightRegionには届くがプログラム的な呼び出しと同じ方法でそこで拒否される。したがってどちらの場合もイベントは沈黙したままである。ハイライトがコントロールの端で反応しなくなったように見えたときに知っておく価値のある実装上の詳細が一つある:マウスキャプチャは、ページのビットマップを表示する内部のTImageではなく、TScrollBoxの子孫であるTHPDFViewer自身に属している。これにより、レビュー担当者がレンダリングされたページの端を越えてドラッグしても、きれいなリリースが得られる

コードからハイライトを追加・削除・再読み取りする

ハイライトは何もマウスドラッグから来る必要はない。Viewer.HighlightRegion(PageIndex, PageRect, Color)は、内部でインタラクティブなドラッグが呼び出すのと同じModel.AddHighlightRegionに流れ込むが、これが公開されているのはまさに、レビュー画面がすでに持っているデータ——データベースから読み込んだコメント、テキスト検索の結果、あるいは前回のセッションから復元されたマーク——からハイライトを再構築できるようにするためである。座標は単純なPDFユーザー空間の数値であるため、この経路はページが先にレンダリングされていることに一切依存しない。これは、実際の値をすでに保持しているFRenderedDPIを必要とするインタラクティブなドラッグとは異なる点である

var
  I: Integer;
  Item: TPriorComment;    // your own record: PageIndex + PageRect
  NewIndex: Integer;
begin
  for I := 0 to PriorComments.Count - 1 do
  begin
    Item := TPriorComment(PriorComments[I]);
    NewIndex := Viewer.HighlightRegion(Item.PageIndex, Item.PageRect, clAqua);
    if NewIndex < 0 then
      LogWarning('comment %d fell outside the page and was dropped', [I]);
  end;
end;

単一のハイライトを削除する場面で、配列に基づく保存方式の本質が表面化する。RemoveHighlightRegionは1つのレコードを削除し、後続のすべてのレコードを1つ前にずらして隙間を埋める。つまり、以前にOnMarqueeSelectイベントから、あるいは以前の列挙から取得したインデックスは、リスト内でそれより前にある何かが削除された時点でもはや信頼できなくなる。OnHighlightChangeは追加・削除・ClearHighlightRegions呼び出しのたびに発火するが、何が変わったかについての情報は一切運ばない。したがって安全なパターンは、キャッシュされたインデックスをその場でパッチするのではなく、それをレビューパネルが表示しているリストをHighlightCountTryGetHighlightRegionから再構築するための合図として扱うことである

procedure TReviewForm.ViewerHighlightChange(Sender: TObject);
var
  I: Integer;
  Mark: THPDFViewerHighlight;
begin
  MarkList.Items.Clear;
  for I := 0 to Viewer.Model.HighlightCount - 1 do
    if Viewer.Model.TryGetHighlightRegion(I, Mark) then
      MarkList.Items.AddObject(Format('page %d', [Mark.PageIndex + 1]),
        TObject(I));
end;

マークをいつ本物のハイライト注釈に昇格させるべきか

ハイライト領域は、それがその一つのTHPDFViewerインスタンスの外で生き延びる必要が出た瞬間に本物の注釈にすべきである。HotPDFは新規ページ用のAddHighlightAnnotationと、すでに読み込み済みの文書用のAddLoadedHighlightAnnotationも公開している。ほぼ同一の名前にもかかわらず、これは完全に別の仕組みである:どちらも本物のISO 32000-1 §12.5.6.10テキストマークアップ注釈、すなわちPDFの/Subtype /Highlightを、正確なグリフ範囲を示す/QuadPointsとともにページの/Annots配列に書き込む。ファイルが保存されれば、準拠するあらゆるPDFビューアがそれをレンダリングする、HotPDF自身のビューアに限らずだ。同じ仕組みの境界が、マークがXFDFを経由して往復するかどうかも決める:AddLoadedHighlightAnnotationで作成された注釈は通常のPDFオブジェクトであり、ExportLoadedAnnotationsToXFDFがそれを拾い上げ、DelphiでPDF注釈をXFDFとしてインポート・エクスポートするで扱われているISO 19444-1マークアップとしてAcrobatや他のレビューツールに渡す。一方、AddHighlightRegionで追加された領域はそもそもオブジェクトグラフに一切書き込まれていないため、このエクスポートには見えない:それはそれを作成したTHPDFViewerModelが存在する間だけ存在する。ページで利用可能なマークアップおよび図形注釈タイプの全体像と、矩形がそれぞれをどう配置するかについては、DelphiのHotPDFコンポーネントによるPDF注釈の記事で扱われている。実践的なルールはシンプルだ:文書がまだ議論されている間はマークを使い捨てのままにしておき、決定が確定した時点で注釈として確定する

ハイライトレイヤーが対応しない範囲

ハイライトレイヤーは、その側では半透明のハイライトペンのように見せようとする試みを一切行わない:RefreshDocumentは、検索ヒットを描くのと同じ方法で、下にあるテキストの上に色を塗った塗りつぶしをブレンドするのではなく、キャッシュされたページビットマップの上に各領域を2ピクセル幅の輪郭矩形としてその色で描く。そのため、古典的な黄色い水彩風の見た目が欲しければアプリケーションコード側で描くか、昇格させた注釈自身の外観ストリームに委ねる必要がある。領域が存在するようになった後で再利用する価値のある機能が一つある:CreateCurrentPageRegionSnapshotは、ハイライトがすでに保持しているのと同じTHPDFRectangleを受け取り、その領域だけをビットマップにレンダリングする。これはページ全体をエクスポートすることなく、レビューコメントに小さなプレビュー画像を添付するのに便利である。レビュー用のビルドはこの2つの仕組みを最初から選ぶ必要はない:コメントスレッドが開いている間は新しいマークをすべて既定で使い捨てのTHPDFViewerHighlight領域とし、レビュー担当者がそれを解決した時点でのみAddLoadedHighlightAnnotationを呼び出す。これにより、最も変動が激しいやり取りの間、読み込んだPDFは触れられないままになる。ここで説明したビューアコントロールは、上記で参照した注釈・フォームAPIの残りとともに、DelphiおよびC++Builder向け標準HotPDFコンポーネントの一部である