技術記事

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

シートフィードスキャナの実行が終わると、1 ページ 1 ファイルの JPEG、0001.jpg、0002.jpg、…と名付けられたフォルダが手元に残ります。100 枚の画像サムネイルをクリックして回るようなやり方で案件ファイルをレビューしたい人はいません。アーカイブに必要なのは、ページが順序どおり並んだ、案件ファイルごとの 1 枚の PDF です。番号付きのスキャンの山を順序付けられた単一の PDF へ変換する、その最後の工程がここでの仕事です

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

Delphi のバッチパイプラインが PDFium Component の AddPage、PageNumber、AddImage、SaveAs の呼び出しを使って、番号付きスキャンのフォルダを順序付けられた 1 枚の PDF に変換する様子
各スキャンは AddPage が作成し PageNumber が狙いを定めた 1 ページになり、AddImage がそこへ描画します。SaveAs は完成した文書を 1 度だけ書き出します

変換の形

スキャンごとに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 幅
  PageH = 842.0;   // ポイント単位の A4 高さ
  Margin = 36.0;   // 各スキャンの周囲に半インチの枠
var
  I: Integer;
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.CreateDocument;                       // 新規・空・すでにアクティブ
    for I := 0 to Files.Count - 1 do
    begin
      Pdf.AddPage(I + 1, PageW, PageH);       // 1 基準のページインデックス
      Pdf.PageNumber := I + 1;                // 新しいページを現在ページにする
      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つの比率のうち小さい方でスケーリングし、残った部分を中央に配置することです。原点は左下にあるため、センタリングとは、残りのスペースを均等に分割し、それを X と Y の両方に追加することを意味します

A4 ページの図。PDFium Component の AddImage が Delphi コードと左下原点を使って、拡縮したスキャンを余白の内側へ配置する様子
AddImage は配置矩形の左下隅を受け取るため、フィットとセンタリングは原点からのページポイントで計算します
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);              // VCL グラフィックスユニット経由の BMP, JPG, PNG など

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

    // スキャンを歪ませずに余白内に収める。
    Scale := Min(AvailW / Pic.Width, AvailH / Pic.Height);
    DrawW := Pic.Width * Scale;
    DrawH := Pic.Height * Scale;

    // 中央揃え:残りのスペースを均等分割。Y はページ下端から測定
    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 に読み込むと完全なビットマップへデコードされ、そのビットマップが PDF に入ります。デコードで品質は失われず、PDFium はピクセルをロスレスで保存するため、ページの見た目は変わりません。代償になるのはサイズと時間です。コンパクトな JPEG として届いたスキャンが、ロスレス圧縮の生ピクセルとして書き出されると、通常は元のファイルよりはるかに大きくなります。AddJpegImage は元の圧縮バイトをストリームからページへそのまま埋め込むため、出力はスキャンのサイズに近く保たれ、バッチはデコード作業を丸ごとスキップできます

AddJpegImage が元の JPEG バイトを PDFium Component のページへそのまま埋め込む一方、AddImage はデコードし SaveAs はピクセルを再エンコードする様子(Delphi)
AddJpegImage はスキャナの圧縮バイトをそのまま埋め込み、AddImage と SaveAs が行うデコードと再エンコードの工程を回避します
var
  Stream: TFileStream;
begin
  // ... 現在ページに対する AddPage + PageNumber の後 ...
  Stream := TFileStream.Create(FileName, fmOpenRead);
  try
    // JPEG バイトをそのまま埋め込む。デコード/再エンコードの往復はなし。
    Pdf.AddJpegImage(Stream, X, Y, DrawW, DrawH);
  finally
    Stream.Free;
  end;
end;

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

各ページへのラベル付け

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

// スキャンの下にキャプション:Y はページ下端に向かって減少
Pdf.AddText('File: ' + ExtractFileName(FileName), 'Helvetica', 9,
  X, Y - 14, clGray);

保存について最後にもう 1 点。SaveAs はブール値を返し、False は文書をシリアライズできなかったことを意味します。書き込みが成功したと決めつけず、必ず確認してください。ファイルシステム側の問題は別の経路を通ります。出力フォルダがない、ターゲットファイルがロックされている、といった状況ではファイル層から例外が上がります。したがってバッチジョブには、結果チェックと、どの案件ファイルが失敗したかを記録するハンドラの両方が要ります。ループが終わりファイルが書き上がれば、アーカイブが求めていたものが手元にあります。案件ファイルごとに 1 枚、ページがフィットするよう拡縮された、どのビューアでも開ける順序付けられた PDF です

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

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