技術記事

DelphiのPDFium Componentを使用してスキャンした画像を1つのPDFに結合する

ある保険金請求処理チームは、30年分の紙のファイルをシートフィードスキャナーで処理していました。スキャナーは、ページごとに1つのJPEGをフォルダーに吐き出し、0001.jpg0002.jpgなどの名前を付けました。アーカイブが実際に必要としていたのは、ケースファイルごとに1つのPDFであり、ページが順番に並んでいることでした。これにより、レビュー担当者は何百もの画像のサムネイルをクリックする代わりに、単一のドキュメントを開くことができるからです。その最後のステップ、番号が付けられたスキャンの山を1つの順序付けられたPDFに変換することが、ここでの仕事です

PDFium Componentはそれを直接処理します。レンダリングやテキスト抽出に加えて、このコンポーネントはPDFをゼロから構築できます。空のドキュメントを作成し、好きなサイズの空白ページを追加し、ユーザー空間の座標でそのページに画像をドロップして、保存します。パイプライン全体が TPdf コンポーネント上に存在するため、バッチコンバーターはファイル名に対するループと一握りの呼び出しで済みます

変換の形

スキャンごとに3つのことを行う必要があります。ページサイズを決定し、余白を残してページ内に画像を配置し、次のページに進みます。PDFium Componentは、それぞれに1つのメソッドを提供します。AddPage は指定されたサイズで空白のページを作成し、AddImage(すでに TPicture を保持している場合は AddPicture)はビットマップを現在のページに描画し、PageNumber は後続の描画呼び出しがどのページをターゲットにするかをコンポーネントに伝えます

人々がつまずく詳細の1つは座標系です。PDFのユーザー空間はページの左下隅に原点を置き、Yは上に向かって増加します。これはDelphi開発者が反射的に手を伸ばす画面座標とは逆です。AddImage に渡す X, Y は画像の四角形の左下隅であり、Width, Height はソースファイルのピクセルサイズではなく、ポイント単位の配置サイズです。これを逆にすると、スキャンがページからはみ出したり、予想していた場所に対して逆さまになったりします

ドキュメントとスキャンごとのページの作成

空のドキュメントから始めます。CreateDocument は新しいPDFを割り当て、コンポーネントをアクティブなままにするため、個別のオープンステップはありません。そこからスキャンされたファイルのリストを調べ、各ファイルに対してページを追加し、それを現在のページにして、画像を配置します。ここでのページの寸法は、アーカイブされた通信の標準シートサイズであるポイント単位のA4(595 × 842 ポートレート)です

procedure TArchiveForm.ScansToPdf(const Files: TStrings; const OutputPath: string);
const
  PageW = 595.0;   // A4 width in points
  PageH = 842.0;   // A4 height in points
  Margin = 36.0;   // half-inch border around each scan
var
  I: Integer;
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.CreateDocument;                       // new, empty, already active
    for I := 0 to Files.Count - 1 do
    begin
      Pdf.AddPage(I + 1, PageW, PageH);       // 1-based page index
      Pdf.PageNumber := I + 1;                // make the new page current
      PlaceScan(Pdf, Files[I], PageW, PageH, Margin);
    end;
    Pdf.SaveAs(OutputPath);
  finally
    Pdf.Free;
  end;
end;

各反復でページを作成し、すぐに PageNumber をそこに設定します。この2行目が重要です。AddPage はページを挿入しますが、描画メソッドは現在のページがどれであれ作用するため、PageNumber を設定することで AddImage のターゲットをいま作成したページに向けます。これをスキップすると、画像は以前にロードされていたページに積み重なってしまいます

そのループには、Files の順序という1つの仮定が隠されています。スキャナーはページに 0001.jpg から 0100.jpg までの名前を付けますが、ディレクトリの列挙では必ずしもソートされて返されるとは限りません。page10.jpg の横にある page9.jpg にぶつかった瞬間、単純な文字列のソートではページ9の前にページ10が配置されます。ループの前にリストを明示的にソートし、スキャン時にゼロ埋めされた名前を優先して、辞書順がページ順と一致するようにします。ページシーケンスはレビュー担当者がすぐに気付く1つのことであり、防ぐのが最も簡単な間違いです

スキャンを配置し、そのアスペクト比を維持する

スキャンがページと同じ形であることはめったにありません。シート全体を満たすように引き伸ばすとテキストが歪み、フルピクセルサイズで配置するとオーバーフローします。修正方法は、幅合わせまたは高さ合わせの2つの比率のうち小さい方でスケーリングし、残った部分を中央に配置することです。原点は左下にあるため、センタリングとは、残りのスペースを均等に分割し、それを XY の両方に追加することを意味します

procedure TArchiveForm.PlaceScan(Pdf: TPdf; const FileName: string;
  PageW, PageH, Margin: Double);
var
  Pic: TPicture;
  AvailW, AvailH, Scale, DrawW, DrawH, X, Y: Double;
begin
  Pic := TPicture.Create;
  try
    Pic.LoadFromFile(FileName);              // BMP, JPG, PNG, etc. via the VCL graphics units

    AvailW := PageW - 2 * Margin;
    AvailH := PageH - 2 * Margin;

    // Fit inside the margins without distorting the scan.
    Scale := Min(AvailW / Pic.Width, AvailH / Pic.Height);
    DrawW := Pic.Width * Scale;
    DrawH := Pic.Height * Scale;

    // Center: leftover space split evenly. Y measured from the page bottom.
    X := (PageW - DrawW) / 2;
    Y := (PageH - DrawH) / 2;

    Pdf.AddImage(FileName, X, Y, DrawW, DrawH);
  finally
    Pic.Free;
  end;
end;

これにより、ファイルが1回ロードされてピクセルの寸法が読み取られ、単一の均一なスケールが計算され、配置の四角形が AddImage に渡されます。AddImage はファイルパスを直接受け入れ、AddPicture と同じ画像パイプラインを介してルーティングするため、VCLグラフィックスユニットが認識するフォーマットであれば、特殊な処理なしで機能します。プレビューペインから TPicture に画像をすでにデコードしている場合は、同じ四角形で AddPicture(Pic, X, Y, DrawW, DrawH) を呼び出し、2回目のファイル読み取りをスキップします

JPEGスキャンのデコードをスキップする

スキャナーはほとんどの場合JPEGを出力します。JPEGを TPicture にロードするとビットマップにデコードされ、PDFiumは保存時にそれを再エンコードします。これは不要な2回の非可逆な往復です。AddJpegImage は、元の圧縮されたバイトをストリームから直接ページに埋め込みます。これは、大量のバッチ処理において高速であり、視覚的にもクリーンです

var
  Stream: TFileStream;
begin
  // ... after AddPage + PageNumber for the current page ...
  Stream := TFileStream.Create(FileName, fmOpenRead);
  try
    // Embeds the JPEG bytes as-is; no decode/re-encode cycle.
    Pdf.AddJpegImage(Stream, X, Y, DrawW, DrawH);
  finally
    Stream.Free;
  end;
end;

スケーリングにはピクセルの寸法が必要なため、依然として同じように XYDrawW、および DrawH を計算します。それらをファイルまたは簡単なヘッダー解析から読み取り、生のストリームを AddJpegImage に渡します。PNGまたはTIFFスキャンの場合、適切なのは AddImage パスです。JPEGのショートカットは、それが実際に適用されるフォーマットのために予約しておいてください

各ページへのラベル付け

アーカイブされたスキャンは、各ページにソースファイル名が含まれていると、監査が容易になります。AddText はユーザー空間の座標に文字列を描画するため、キャプションは画像のすぐ下に配置されます。反転したY軸を覚えておいてください。スキャンの下にラベルを配置するには、画像の下端に追加するのではなく、そこから差し引きます

// Caption below the scan: Y decreases toward the page bottom.
Pdf.AddText('File: ' + ExtractFileName(FileName), 'Helvetica', 9,
  X, Y - 14, clGray);

保存についての最後のポイント。SaveAs はブール値を返す関数であるため、本番コードでは、書き込みが成功したと思い込むのではなく、その結果を確認してください。そうしないと、ディスクがいっぱいだったり出力パスがロックされていたりした場合に、エラーを出さずに失敗します。ループが終了してファイルが書き込まれると、アーカイブが必要としていたもの、つまりケースファイルごとに1つの順序付けられたPDF、サイズに合わせてスケーリングされたページ、および任意のビューアで読み取れる準備が整ったドキュメントが完成します

同じビルディングブロックが関連するジョブをカバーします。ページごとのサイズ設定ルールを入れ替えると、シートごとに1つの画像があるフォトブックが得られます。ループを維持しながらTIFFマルチページソースから読み取ると、FAXアーカイブコンバーターになります。プログラムによるPDF構築の全体像を把握したい場合は、PDFium Componentを使用してPDFドキュメントをゼロから作成するを参照してください。後で結果を画面にレンダリングして戻すには、PDFium Componentを使用してPDFページをJPEG画像に変換するを参照してください

loslab.comの PDFium Component には、このシリーズ全体で使用されるドキュメント作成、レンダリング、およびテキストAPIがバンドルされています