PDFiumコンポーネントのFPDF_RenderPageBitmap関数は回転引数を受け取るが、PDFiumはページがすでに自身の/Rotateエントリに持っている回転の上に、常にそれを加算する。そのため、ページの保存された回転を読み取り、その同じ値をレンダー呼び出しに渡すと、ページを2回回転させてしまう。全く同じ間違いがフィットズームの計算にも現れる:ページの未回転の幅と高さからサムネイルのサイズを決めると、/Rotateが90度または270度のときにアスペクト比が間違ったものになる。なぜならレンダリングされたビットマップは幅と高さが入れ替わった状態で出てくるからだ
この失敗は、何を探すべきか知っていれば見つけやすく、知るまでは見逃しやすい。ポートレートとランドスケープの原本が混在するスキャン済み請求書のバッチが到着し、誰かがアーカイブする前にAcrobatでその半分を90度回転させてまっすぐにし、PDFiumの上に構築されたDelphiビューアのサムネイルストリップは、それらの特定のページを横向き、上下逆さま、あるいは間違った向きの形をした箱に押し込められた状態でレンダリングする。何も例外を発生させない。何もエラーをログに残さない。ピクセルは単純に間違っており、それも誰かが事後に回転させたページのサブセットについてだけである——まさに未回転のテストPDFに対する完全なQAパスを生き延び、その後実際のファイルの47ページ目で本番に現れる類のバグである
なぜPDFiumはページを2回回転させるのか
PDFiumは、レンダラーに何が渡されようとも、ビットマップをレンダリングするたびにページ自身の/Rotate値を自動的に適用する。FPDF_RenderPageBitmapの回転パラメータは、PDFiumPasではTPdf.RenderPage、TPdf.RenderTile、TPdf.RenderPageThumbnail上のTRotation値ro0、ro90、ro180、ro270として公開されているが、これはページが最終的にどの角度になるべきかを設定するものではない;この回転パラメータは、ページ辞書がすでに指定しているものの上に、どれだけ追加の回転を重ねるかを設定する。これがまさに、これらのメソッドすべてが既定でそれをro0にする理由である
TPdf.PageRotationはFPDFPage_GetRotationを通じてその同じ/Rotate値を読み取り、アプリケーションのコードは、注釈をページ空間でどうレイアウトするか決めるといった、レンダリングとは何の関係もない理由でしばしばそれを必要とする。この罠はたった1行である:PageRotationをRenderPageのRotation引数に渡し、その呼び出しがページを正立するよう正規化してくれると期待することだ。すでに/Rotate 90で保存されているページは、PDFiumを含む準拠したあらゆるビューアで正しく回転して表示される;その上にさらにro90を加えると、ページは意図した90度ではなく180度に振れてしまい、一方全く回転のないページは理由もなく望まない4分の1回転をさせられてしまう
// Wrong: PageRotation already reflects /Rotate, and PDFium applies
// it automatically on every render -- passing it again as Rotation
// doubles the angle
Bitmap := Pdf.RenderPage(0, 0, TargetW, TargetH, Pdf.PageRotation, []);
// Right: leave Rotation at its ro0 default and let PDFium apply the
// page's own /Rotate exactly once
Bitmap := Pdf.RenderPage(0, 0, TargetW, TargetH, ro0, []);
Rotationパラメータが本当に何のためにあるのか
Rotationパラメータは、本質的に異なる仕事のためにAPI内に自身の居場所を得ている:ページの保存された向きとは何の関係もない、表示のみの回転を追加することであり、これは回転ビューのツールバーボタンが基礎となるファイルに触れずに適用する類のものだ。TPdfViewはまさにこの理由から、2つの概念を2つの別個のプロパティとして保つ。TPdfView.PageRotationはページ自身の/Rotateを反映し、FPDFPage_SetRotationを通じて新しい値を文書に書き戻すことができる;TPdfView.Rotationは一時的な、表示のみのプロパティであり、既定でro0であり、ファイルには決して触れない。最初のプロパティを読んで2番目に書き込むことが、このバグのすべてを一文で表している
// View-only: rotates what the user sees, changes nothing in the file
procedure TViewerForm.RotateViewClick(Sender: TObject);
begin
case PdfView.Rotation of
ro0: PdfView.Rotation := ro90;
ro90: PdfView.Rotation := ro180;
ro180: PdfView.Rotation := ro270;
ro270: PdfView.Rotation := ro0;
end;
end;
// Persistent: rewrites the page's own /Rotate entry in the document
procedure TViewerForm.RotatePageClick(Sender: TObject);
begin
case PdfView.PageRotation of
ro0: PdfView.PageRotation := ro90;
ro90: PdfView.PageRotation := ro180;
ro180: PdfView.PageRotation := ro270;
ro270: PdfView.PageRotation := ro0;
end;
end;
なぜフィットズームのサイズ計算も同じように壊れるのか
フィットズームのサイズ計算は、鏡写しの理由で壊れる:計算は間違った角度ではなく間違った2つの数値から始まる。サムネイルの箱のサイズを決める典型的な方法は、PDFiumにページの幅と高さを尋ね、そのアスペクト比を利用可能な箱と比較し、その中に収まる最大の矩形を計算する——これは未回転のページに対してはきれいに機能する。同じ計算は、幅と高さがページの本質的な、未回転のサイズを報告する呼び出しから来ている場合、/Rotate 90または/Rotate 270のページに対して静かに失敗する:/Rotate 90を持つA4ポートレートページは、回転が効いた後にPDFiumが正しくおよそ842×595ポイントでレンダリングするにもかかわらず、依然としておよそ595×842ポイントを報告する。そして未回転のペアから計算されたフィットボックスは、完全に間違った向きの形になってしまう
FPDF_GetPageSizeByIndexは、設計上その本質的な未回転のサイズを報告する呼び出しの具体例の一つであり、これはすべてのページを読み込まずにページの寸法をスキャンするには便利だが、それを考慮し忘れたフィットズームの計算には危険である。この修正は問題を名指しすることから直接導かれる:フィット計算を行う前にページの回転をチェックし、その回転が90度または270度であればいつでも幅と高さを入れ替え、入れ替えたペアからフィットボックスを計算し、それでも実際のレンダー呼び出しにはro0を渡す。なぜならPDFiumが本物の回転を適用する側であり続けるからだ
フィット計算を再発明せずにサムネイルを正しくする
TPdf.RenderPageThumbnailはすでにこの修正を持っているため、正しいサムネイルへの最短経路は、フィット・回転のロジックを自分で組み立て直すのではなくそれを呼ぶことである。1始まりのページインデックスと最大の幅・高さを与えると、RenderPageThumbnailはフィットボックスを計算し、内部で/Rotateが90度または270度である場合の補正を行い、文書の現在のページを乱すこともOnPageChangeイベントを発生させることもなく、呼び出し元が所有するビットマップを返す——これは、同じTPdfインスタンス上のライブなビューアと並んで構築されるサムネイルストリップにとって重要である
// PageW, PageH are a page's own (unrotated) dimensions in points, for
// example from FPDF_GetPageSizeByIndex, which reports size before
// /Rotate is applied
function FitBox(PageW, PageH: Double; Rotation: TRotation;
MaxW, MaxH: Integer; out FitW, FitH: Integer): Boolean;
var
PgW, PgH, Swap: Integer;
begin
PgW := Round(PageW);
PgH := Round(PageH);
if PgW < 1 then PgW := 1;
if PgH < 1 then PgH := 1;
if Rotation in [ro90, ro270] then
begin
Swap := PgW;
PgW := PgH;
PgH := Swap;
end;
Result := (MaxW > 0) and (MaxH > 0);
if not Result then
Exit;
if PgW * MaxH > PgH * MaxW then
begin
FitW := MaxW;
FitH := (MaxW * PgH) div PgW;
end
else
begin
FitH := MaxH;
FitW := (MaxH * PgW) div PgH;
end;
end;
このFitBoxヘルパーは、それでも手元に置いておく価値がある。なぜならRenderPageThumbnailは単一ビットマップのケースしかカバーしないからだ。カスタムのサムネイルグリッド、印刷プレビューストリップ、あるいは独立した箱に対して複数ページをレイアウトするページピッカーダイアログは、必ずしもタイルごとに新しいビットマップを望むわけではなく、同じ回転を意識したフィット計算を必要とする。TPdfView自身のフィットページ・フィット幅ズームモードも、内部で全く同じ発想に頼っている——利用可能なクライアント領域と比較する前に、ビューの現在の回転に基づいてズーム比率の計算にページの幅と高さのどちらを使うか選んでいる。そのようなビューアにおけるズームとスクロールのパフォーマンスがリストの次の問題であれば、PDFiumベースのDelphiビューアにおけるレンダーキャッシュとスムーズなズームの関連記事が、正しいサイズ計算が終わったまさにその場所から引き継いでくれる
顧客より先に二重回転を見つける
二重回転には一つの信頼できる視覚的な特徴がある:入力段階で90度回転させられたページは、文書の残りに対して90度ではなく180度回転して見える。なぜなら追加のro90がページ自身のro90を置き換えるのではなく、その上に積み重なったからだ。/Rotate 0のページだけから構築されたテストフィクスチャは決してこれを捕まえられない。なぜならro0にro0を加えても依然としてro0であり、バグは見えないままだからだ。サムネイルやフィットズームのコードパスを信頼できるようにするには、フィクスチャに少なくとも/Rotate 90で保存された1ページと/Rotate 270で保存された1ページが必要である
PDFiumコンポーネントによるPDFページのJPEG変換で扱われている基本的なページ・ツー・ビットマップのパイプラインは、特別なケース処理のコードを一切必要とせず、回転したページをすでに正しくレンダリングしている。まさに、Rotationを既定のro0のままにしておき、PDFiumに/Rotateを自分で適用させているからだ。この二重回転のバグは、アプリケーションのコードがPageRotationを読み戻し、それをどこか属さない場所に供給し始めた時にだけ現れる
ここで説明した回転を意識したレンダー呼び出しとサムネイルのサイズ計算は、同じTPdfとTPdfViewのクラスの上に構築されたレンダリング・表示・テキスト抽出APIの残りとともに、DelphiおよびC++Builder向けPDFiumコンポーネントの一部である