技術記事

Delphi での HotPDF ページ抽出パフォーマンス

40ページの PDF から3ページをコピーするのに2分かかるのは、パフォーマンスチューニングの問題ではありません。これは、間違った API パスが使用されているというシグナルです。私が最初に HotPDF Component のページコピーサンプルでこのタイミングを見たとき、私の直感は、まずドキュメント構造を確認し、次にコードを確認することでした。その順序が重要であることが判明しました

実際に遅かった部分

問題の PDF は、単一のフラットな配列ではなく複数の複数の中間 /Pages ノードを持つ、些細ではないページツリーを含む40ページのリファレンスドキュメントでした。元のサンプルコードでは、LoadFromFile を呼び出した後、BeginDoc で新しいドキュメントを構築し、選択したページ番号をループ処理して、各反復でページを取得するためにソースドキュメントをディスクから再度読み込んでいました。これは、必要なページ数に完全な解析コストを掛けたものになります。12 MB のファイルが、3ページの抽出のためにディスクに6回アクセスした理由は、反復処理をまたいでファイルを開いたままにする必要があるかどうかを誰も確認しなかったためです

2番目の要因はコードからは見えませんでした。HotPDF の LoadFromFile は、ロード時にクロスリファレンステーブル全体を解決し、すべてのオブジェクトストリームを解凍します。これは、これから変更しようとするドキュメントとしては正しい動作ですが、ページ数とページのサブセットのみが必要な場合は過剰な処理になります。構造への読み取り専用アクセスの場合、DAOpenFileReadOnly は完全なオブジェクトツリーのデシリアライズを回避します。これは、大きな画像リソースを持つ圧縮ファイルで重要になります

これらのどちらもライブラリのバグではありません。どちらも呼び出し元が1つの作業用に設計された API を選択し、それを別の用途に使用しているためです

ページ抽出のための InsertPagesFromDocument の使用

ある HotPDF ドキュメントから別のドキュメントへページ範囲をコピーするための正しいパスは、ソースに対する LoadFromFile の後に呼び出される InsertPagesFromDocument です。ソースを一度ロードし、宛先を一度ロードまたは作成して、ページを移動し、保存します。ソースは、すべてのページ挿入にわたってメモリに保持されます

procedure ExtractPages(const SourceFile, DestFile: string;
  const PageRange: string);
var
  Source, Dest: THotPDF;
begin
  Source := THotPDF.Create(nil);
  Dest   := THotPDF.Create(nil);
  try
    // ソースを一度ロードする:完全な解析はここで、ここでのみ行われます
    Source.LoadFromFile(SourceFile);

    // 最小限の宛先ドキュメントを構築する
    Dest.FileName := DestFile;
    Dest.BeginDoc;

    // 要求された範囲をコピーする:'1-3' はページ 1 から 3 を
    // 宛先の位置 1 から挿入します
    Dest.InsertPagesFromDocument(Source, PageRange, 1);

    Dest.EndDoc;
  finally
    Source.Free;
    Dest.Free;
  end;
end;

PageRange パラメーターは、コマンドラインサンプルと同じ形式を受け入れます。つまり、'1-3''1,5,7-9' のようなカンマ区切りのページ番号または範囲のリストです。ページは1から始まります。InsertPagesFromDocument は、コピーされたページから参照されない限り、メタデータ、ブックマーク、または埋め込まれたファイルの添付ファイルに触れることなく、コンテンツストリーム、リソース辞書、およびページのジオメトリをコピーします。40ページのドキュメントからの3ページの抽出の場合、これは小さなワーキングセットです

以前は2分かかっていた同じ 12 MB のファイルでのタイミングは、このパターンでは1.5秒未満です。その時間のほとんどは、1回の LoadFromFile 呼び出しによるものです。オブジェクトテーブルが最初に解決されれば、ドキュメントの構造は無関係になります

LoadFromFile が過剰な場合:Direct File API

ページ数を数えたり、ドキュメント情報を調べたり、コンテンツに触れることなくファイルをコピーしたりするだけでよい場合は、Direct File API を使用することで完全な解析を完全に回避できます。DAOpenFileReadOnly はオブジェクトストリームを解凍せずにクロスリファレンステーブルをマッピングするため、ページ数は O(ファイルサイズ) ではなく O(xref サイズ) になります

procedure InspectPDF(const FileName: string);
var
  Pdf: THotPDF;
  Handle, PageCount: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Handle := Pdf.DAOpenFileReadOnly(FileName, '');
    if Handle <= 0 then
      Exit;
    try
      PageCount := Pdf.DAGetPageCount(Handle);
      Writeln('Pages: ', PageCount);

      // DACopyFile はバイトを保持したコピーであり、再シリアライズは行いません
      Pdf.DACopyFile(FileName, 'archive-copy.pdf');
    finally
      Pdf.DACloseFile(Handle);
    end;
  finally
    Pdf.Free;
  end;
end;

注意点:DAOpenFileReadOnly はパスワードパラメーターを受け入れますが、復号化には暗号化辞書を解決するためのオブジェクトツリーが必要なため、暗号化された入力の場合は完全な解析にフォールバックします。ソースファイルが暗号化されている場合は、まず DecryptFile を使用して復号化し、暗号化されていないコピーを取得してから、Direct File API で開きます。ファイルレベルの DecryptFile 関数は、標準の暗号化に対して直接的な AES-256 書き換えパスを使用し、完全なインメモリのオブジェクトモデルを構築しないため、大きなファイルに対して LoadFromFile に続いて SaveLoadedDocument を呼び出すよりも高速です

大規模バッチ処理時のメモリ

数十のファイルをループ内で処理するバッチジョブには、正しく見えてメモリを蓄積するパターンがあります。それは、ループ内で THotPDF を作成し、LoadFromFile を呼び出し、処理を行い、Free を呼び出すというものです。これは構造的には問題ありません。問題は、内部の処理でスクラッチオブジェクトを割り当て、例外をキャッチし、エラーパスでそれらのスクラッチオブジェクトを有効なままにしておく場合です。Delphi のメモリマネージャーはコンパクションを行わないため、バッチ実行全体で100回のエラーパスによるリークが発生すると、他のすべてのメモリ割り当てが遅くなるほどメモリが高くなる可能性があります

修正は特別なものではありません。PDF 処理に関与するすべての THotPDF と、すべての中間 TStream または TBitmap は、Free を最後のステートメントとする try/finally ブロックに属します。try の前にローカルポインターを nil に設定することで、初期化が途中で失敗した場合に、finally 分岐で if Assigned(x) then x.Free を安全に使用できます。これは標準的な Delphi の所有権の規律であり、このクラスの問題に対する完全な説明です

バッチコンテキストでもう1つ確認すべきことがあります。AddImage は、THotPDF インスタンスの有効期間中持続する内部リストに画像を登録します。LoadFromFile を繰り返し呼び出して、多くのドキュメント間で単一のインスタンスを再利用すると、以前のドキュメントの画像登録がリストに残ります。ドキュメントごとに新しいインスタンスを作成するか、ドキュメントの間に画像リストのクリアパスを呼び出してください

何か変更する前の測定

これらのパターンのいずれかに手を伸ばす前に、測定してください。System.Diagnostics の Delphi の TStopwatchQueryPerformanceCounter をラップしており、ファイル I/O のウォールクロックプロファイリングに十分な精度があります。LoadFromFile だけをラップして、どのくらいの時間がかかるかを確認します。それが合計時間の 90% を占める場合、解決策は Direct File API か、同じファイルの解析回数を減らすことです。20% 未満の場合、ボトルネックは別の場所にあり、間違ったものを追いかけています

この記事の発端となった2分間の抽出は、完全にロードを繰り返すパターンであることが判明しました。ドキュメント構造は何も影響しておらず、フラットなページツリーでも同じように実行されたはずです。単一の LoadFromFile とそれに続く1回の InsertPagesFromDocument 呼び出しに切り替えたことで、他に何も変更せずに、同じハードウェア上で 1.3 秒に短縮されました

ここに示されているページ操作 API は、Delphi および C++Builder 用の HotPDF Component の一部です