技術記事

PDFium Componentを使用してDelphiでPDFページをJPEG画像にレンダリングする

PDFページをJPEGにレンダリングすることは、人々が一緒に実行し、その後別々にデバッグする傾向がある2つの操作です。まず、選択した解像度でページをピクセルビットマップにラスタライズします。次に、そのビットマップをJPEGエンコーダーに渡し、品質を選択します。PDFium Componentは RenderPage を通じて前半を所有します。後半は純粋なVCLであり、Vcl.Imaging.jpegTJPEGImage です。これら2つの間の継ぎ目に興味深い決定が存在します。レンダリング側で選択する解像度とエンコード側で選択する品質は、ファイルサイズに対して互いにトレードオフの関係にあり、間違えやすい方法だからです

コードを書く前に内面化しておくべきこと:PDFページにはピクセルがありません。1ポイントが1/72インチであるポイント単位で記述されており、ページはそれらのポイントで測定されたベクター描画です。PDFiumにレンダリングを依頼するとき、その描画をいくつのピクセルに投影するかを選択しており、その選択がDPIです。計算を間違えると、プリントマスターが必要だったのにぼやけたサムネイルをレンダリングしたり、120ピクセルのプレビューになる予定のものに2億ピクセルのビットマップを割り当てたりすることになります

DPIからピクセル寸法へ

RenderPage はDPIではなく、整数のピクセルの WidthHeight を必要とします。したがって、最初の仕事は変換です。ページは PageWidthPageHeight(どちらも Double)を通じてそのサイズをポイント単位で報告します。変換はすべてのラスタライザーが使用するものと同じです。ピクセルは、ポイントにターゲットDPIを掛けて72で割ったものに等しくなります。USレターサイズのページは612×792ポイントです。150 DPIでは、これは1275×1650ピクセルになります。72 DPIでは、612×792のままです。1ポイントにつき1ピクセルであり、人々が単なる恒等変換であることを忘れてしまうケースです

// Pdf.PageNumber must already point at the page you want.
PixelW := Round(Pdf.PageWidth  * Dpi / 72);
PixelH := Round(Pdf.PageHeight * Dpi / 72);
Bitmap := Pdf.RenderPage(0, 0, PixelW, PixelH, ro0, [], clWhite);
// ... use Bitmap ...
Bitmap.Free;   // the function-form RenderPage hands you ownership

これら4行の2つの詳細が、コードが正しいかどうかを決定します。1つ目は、関数形式の RenderPage が「あなたが」所有する TBitmap を返すことです。PDFiumはそれを割り当てて立ち去ります。反復ごとにそれを Free しない場合、数百ページのバッチは数百のビットマップをリークし、何かが倒れるまでプロセスが肥大化します。2つ目は Color 引数で、ここでは clWhite です。PDFページは通常、不透明な白い下地を想定して描画されます。透明度のあるページを間違った背景色にレンダリングすると、エッジが濁ったり、暗いハローが迷い込んだりします。白はほぼすべてのドキュメントにとって正しいデフォルトです。このパラメータは、そうでないまれなケースのために存在します

0, 0 は、スケーリングされた座標空間におけるページへの LeftTop のオフセットであり、トリミングする場合を除き、ゼロのままにしておきます。ro0 は回転です。ゼロのままにしておくと、PDFiumはページが /Rotate エントリですでに宣言している回転を優先するため、横向きで作成されたページは何も設定しなくても横向きで出力されます

ビットマップをJPEGとしてエンコードする

ビットマップが存在すれば、JPEGの部分は簡単であり、純粋なDelphiです。TJPEGImage.Assign はビットマップをコピーし、CompressionQuality は品質を1から100のスケールで設定し、SaveToFile はファイルを書き込みます。唯一の順序付けのルールは、品質を保存する前に設定しなければならないということです。これは、品質が SaveToFile によってトリガーされるエンコードを制御するためです

uses
  Vcl.Graphics, Vcl.Imaging.jpeg, PDFium;

procedure SavePageAsJpeg(Pdf: TPdf; PageNumber, Dpi, Quality: Integer;
  const FileName: string);
var
  Bitmap: TBitmap;
  Jpeg: TJPEGImage;
begin
  Pdf.PageNumber := PageNumber;
  Bitmap := Pdf.RenderPage(0, 0,
    Round(Pdf.PageWidth  * Dpi / 72),
    Round(Pdf.PageHeight * Dpi / 72),
    ro0, [], clWhite);
  try
    Jpeg := TJPEGImage.Create;
    try
      Jpeg.Assign(Bitmap);
      Jpeg.CompressionQuality := Quality;   // 1..100
      Jpeg.SaveToFile(FileName);
    finally
      Jpeg.Free;
    end;
  finally
    Bitmap.Free;
  end;
end;

そのネストされた try/finally は、1ページのヘルパーとしては面倒に見えますが、バッチ処理にはまさに適切です。内側のブロックはエンコーダーを解放し、外側のブロックはビットマップを解放します。どちらかが例外で発火した場合でも、所有しているものを解放します。これらを1つにまとめると、エンコード中の例外によってビットマップが取り残される可能性があります。長期間の実行において、それは正常に終了するコンバーターと、破損したファイルとメモリ不足のダイアログを表示して300ページ目で終了するコンバーターの違いとなります

DPIと品質を一緒に選択する

これら2つのノブは出力の目的と無関係ではなく、よくある間違いは、念のために両方を上げてしまうことです。300 DPIでレンダリングされ、品質95で保存されたWebサムネイルは、120ピクセルの画像のふりをした数百キロバイトのファイルです。ブラウザーは縮小時にそのほとんどを捨ててしまいます。出力が実際に必要とするピクセル数に解像度を合わせ、視覚的なアーティファクトなしにJPEGの非可逆圧縮に耐えられる品質を選択してください

出力DPIJPEG品質
リストサムネイル7260-70
オンスクリーンプレビュー96-15080-85
高精細ビューイング200-30085-95
プリントマスター300-60090-100

JPEG品質は、それ自体が注意に値します。それは線形なダイヤルではありません。70から85へのジャンプは、控えめなファイルサイズの増加で実質的な視覚的改善をもたらします。95から100へのジャンプは、ファイルをほぼ2倍にしますが、ほとんど誰もその違いを見ることができません。品質100は依然としてロスレス(可逆)ではなく、単に多くを破棄しなくなるだけだからです。テキストを多用するページの場合、JPEGのブロックベースの圧縮は、グリフの鋭いエッジをかすかなリンギング(モアレ)ににじませます。これが、品質が約80を下回ると、鮮明な出力になるはずのテキストがスキャンされたように見える理由です。ページが主にテキストであり、フォーマットを変更できる場合、PNGはそのようなリンギングなしでテキストをレンダリングします。JPEGは、その圧縮が純粋に小さくなる写真や混合コンテンツにおいて、その地位を獲得します

より高速で、より小さなサムネイル

忠実な再現ではなくサムネイルがターゲットである場合、レンダラーに処理を減らすように指示できます。Options パラメータは TRenderOption フラグのセットを受け入れ、そのうちのいくつかは、小さなプレビューがまさに必要とする方法で、忠実度を速度とトレードオフします。reGrayscale は色を落とし、これによりレンダリングが速くなり、エンコードするためのより小さなビットマップが生成されます。reNoSmoothImagereNoSmoothPath は、サムネイルのスケールではいずれにせよ目に見えないアンチエイリアシングをスキップします

function RenderThumbnail(Pdf: TPdf; PageNumber, MaxW, MaxH: Integer): TBitmap;
var
  Scale: Double;
begin
  Pdf.PageNumber := PageNumber;
  // Fit the page inside MaxW x MaxH while preserving aspect ratio.
  Scale := Min(MaxW / Pdf.PageWidth, MaxH / Pdf.PageHeight);
  Result := Pdf.RenderPage(0, 0,
    Round(Pdf.PageWidth  * Scale),
    Round(Pdf.PageHeight * Scale),
    ro0, [reGrayscale, reNoSmoothImage], clWhite);
end;

サムネイルのケースは、サイズ設定について考えるためのよりクリーンな方法も示しています。DPIを経由する代わりに、ページを境界ボックス内に収め、アスペクト比を維持する単一のスケールファクターを計算します。これは、2つの比率の Min が行うことです。縦長のページと横長のページはどちらも、歪みなく同じボックス内に収まり、「200×280に合わせる」に対応するDPIについて推論する必要はありません。reGrayscale に関する1つの注意点:それはラスター画像コンテンツをグレーに変換しますが、ベクターの塗りつぶしとテキストはエンジン内でそのカラー値を保持します。したがって、主にベクターアートであるページは、フラグの名前が示唆するほどモノクロにならない場合があります。真の完全なグレースケールの結果を得るには、レンダリングされたビットマップを GrayscalePdfBitmap で変換するのが信頼できるパスです

ドキュメント全体をバッチ処理する

ドキュメント全体をまとめるには、PageCount に対するループであり、PageNumber を1回に1ページずつ移動します。ページは1ベースです。1ページ目は PageNumber := 1 であり、ループは PageCount - 1 ではなく PageCount まで(これを含む)実行されます。バッチが尊重しなければならないもう1つのことは、サイレントロードの契約です。Active := True を設定しても、破損したファイルや間違ったパスワードで例外は発生しません。単に ActiveFalse のままにします。1ページでもレンダリングする前に確認してください。そうしないと、最初の RenderPage が開かれていないドキュメントに対して動作することになります

procedure ExportAllPages(const PdfPath, OutDir: string; Dpi, Quality: Integer);
var
  Pdf: TPdf;
  I, Digits: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := PdfPath;
    Pdf.Active := True;
    if not Pdf.Active then
      raise Exception.Create('Could not open ' + PdfPath);

    Digits := Length(IntToStr(Pdf.PageCount));   // zero-pad so files sort right
    for I := 1 to Pdf.PageCount do
      SavePageAsJpeg(Pdf, I, Dpi, Quality,
        Format('%s\page_%.*d.jpg', [OutDir, Digits, I]));
  finally
    Pdf.Active := False;
    Pdf.Free;
  end;
end;

Digits によるゼロ埋めは小さなことですが、後で午後の時間を節約できます。ファイルに page_1.jpg から page_10.jpg までの名前を付けると、それらを文字列としてソートするツールは、page_1 の直後に page_10 を配置し、順序をスクランブルします。最も高いページ番号の幅にパディングして、300ページのドキュメントが page_001.jpg を生成するようにすることで、下流のすべての場所で辞書順とページ順が同一に保たれます

変換に目立った時間がかかるほど大きなドキュメントの場合、UIスレッドから外して実行するか、ページ間でメッセージをポンプして、アプリケーションの応答性を維持し、ユーザーに停止する方法を提供します。非常に大きなページをレンダリングしていて、ページ間のみではなくページの中間で機能するキャンセルが必要な場合、PDFium Componentにはキャンセルトークンを備えたプログレッシブレンダリングパスがあります。これは、ほとんどのバッチエクスポートが必要とするよりも重いメカニズムですが、600 DPIでの1ページがそれ自体でブロックするほど遅い場合に存在します

知っておく価値のある最後のペアリングが1つあります。ページをラスタライズすると、そのテキストレイヤーは破棄されます。JPEGはピクセルであり、その中の単語はもはや選択したり検索したりできません。画像と基になるテキストの両方が必要な場合は、画像用にレンダリングし、テキストを個別に抽出します。これについては、姉妹記事のPDFium Componentを使用してPDFドキュメントからテキストを抽出するで取り上げています。ここに示されている RenderPage のオーバーロードとレンダリングオプションは、DelphiおよびC++Builder用の PDFium Component の一部です