技術記事

DelphiのPDFiumコンポーネントを使用してPDFドキュメントを分割する

PDFiumコンポーネントには、PDF分割のための1つのメソッド、ImportPagesが用意されています。単一のページを分離する場合でも、任意の境界で切り取る場合でも、ドキュメント独自のブックマーク構造に従う場合でも、それ以外のすべては、どのページ番号を各出力ファイルに入れるかを決定する異なる方法にすぎません。メカニズムは同じままです。それを早く理解することで、多くの間違った方向へ進むのを防ぐことができます

分割ループの仕組み

ソースドキュメントをどのように分割するかに関係なく、パターンは同じです。新しいTPdfインスタンスを作成し、それに対してCreateDocumentを呼び出してメモリ内に空のPDFを初期化し、ImportPagesを使用して目的のページをインポートし、結果を保存してから、次の反復の前にActiveFalseにリセットします。この最後のステップが、人々が見落とすステップです:CreateDocumentはメモリ内に残っているドキュメントを暗黙的に閉じないため、出力を保存し、再度呼び出す前にActive := Falseを明示的にリセットする必要があります。最初にリセットすることで、状態がクリーンで明確に定義されたままになります。外側のTPdfインスタンスはすべての反復で再利用されるため、大規模なジョブでの割り当てのプレッシャーを低く抑えることができます

ページごとの分割を本質的な部分まで削ぎ落とすと、次のようになります:

procedure SplitIntoPages(Source: TPdf; const OutputDir: string);
var
  I: Integer;
  PdfOut: TPdf;
  OutFile: string;
begin
  PdfOut := TPdf.Create(nil);
  try
    for I := 1 to Source.PageCount do
    begin
      PdfOut.CreateDocument;

      // Range is a 1-based page number string; insertion point 1 = first position
      if not PdfOut.ImportPages(Source, IntToStr(I), 1) then
        raise Exception.CreateFmt('Failed to import page %d', [I]);

      OutFile := OutputDir + '\page_' + Format('%.4d', [I]) + '.pdf';
      if not PdfOut.SaveAs(OutFile) then
        raise Exception.Create('Failed to save ' + OutFile);

      PdfOut.Active := False;   // reset before next CreateDocument
    end;
  finally
    PdfOut.Free;
  end;
end;

ImportPagesへのRangeパラメーターは、PDFiumが内部で使用するのと同じ文字列形式です:カンマ区切りのページ番号のリスト、またはハイフン区切りの範囲であり、すべて1ベースです。'3'はページ3をインポートします。'1-5'はページ1から5を順番にインポートします。'2,5,8'はそれらの3ページをインポートします。第3パラメーターは、宛先ドキュメントへの1ベースの挿入位置です;1を渡すと、インポートされたページは常にそれ以外は空のファイルの先頭に配置されますが、これはここで必要とされていることです

ページ範囲による分割

呼び出し元が1-12,13-24,25-36のようなリストを提供した場合、それを開始/終了のペアに解析し、同じループを実行して、各ペアから範囲文字列を構築します:

procedure SplitByRanges(Source: TPdf; const RangeList: array of string;
  const OutputDir: string);
var
  I: Integer;
  PdfOut: TPdf;
  OutFile: string;
begin
  PdfOut := TPdf.Create(nil);
  try
    for I := 0 to High(RangeList) do
    begin
      PdfOut.CreateDocument;
      if not PdfOut.ImportPages(Source, RangeList[I], 1) then
        raise Exception.Create('Invalid page range: ' + RangeList[I]);
      OutFile := Format('%s\section_%d.pdf', [OutputDir, I + 1]);
      if not PdfOut.SaveAs(OutFile) then
        raise Exception.Create('Failed to save ' + OutFile);
      PdfOut.Active := False;
    end;
  finally
    PdfOut.Free;
  end;
end;

ここでは、ImportPagesに到達する前の検証が重要になります。ImportPagesは、範囲文字列内のページ番号がSource.PageCountを超えるとFalseを返しますが、例外は発生せず、名前だけで検出できる部分的な出力ファイルも生成されません。SaveAsの戻り値を確認し、失敗を個別にログに記録します。空の出力ファイルを生成する範囲は、誰かがそれを開くまで、明らかに間違っているとはわかりません

ブックマーク境界での分割

3番目のアプローチでは、外部から提供されたリストではなく、ドキュメント独自の構造を使用します。各トップレベルのブックマークにはターゲットのページ番号が含まれています。それが定義するセクションは、そのページから次のブックマークのページの前のページまで、または最後のエントリの場合はドキュメントの最後まで実行されます

procedure SplitByBookmarks(Source: TPdf; const OutputDir: string);
var
  Bm: TBookmarks;
  I, StartPage, EndPage: Integer;
  PdfOut: TPdf;
  RangeStr, OutFile, SafeTitle: string;
begin
  Bm := Source.Bookmarks;
  if Length(Bm) = 0 then
    Exit;

  PdfOut := TPdf.Create(nil);
  try
    for I := 0 to High(Bm) do
    begin
      StartPage := Bm[I].PageNumber;
      if I < High(Bm) then
        EndPage := Bm[I + 1].PageNumber - 1
      else
        EndPage := Source.PageCount;

      if (StartPage < 1) or (EndPage < StartPage) then
        Continue;

      RangeStr := Format('%d-%d', [StartPage, EndPage]);

      PdfOut.CreateDocument;
      if not PdfOut.ImportPages(Source, RangeStr, 1) then
      begin
        PdfOut.Active := False;
        Continue;   // skip a malformed section instead of writing an empty file
      end;

      SafeTitle := StringReplace(Bm[I].Title, '/', '_', [rfReplaceAll]);
      SafeTitle := StringReplace(SafeTitle, ':', '_', [rfReplaceAll]);
      OutFile := Format('%s\%02d_%s.pdf', [OutputDir, I + 1, SafeTitle]);
      if not PdfOut.SaveAs(OutFile) then
        raise Exception.Create('Failed to save ' + OutFile);

      PdfOut.Active := False;
    end;
  finally
    PdfOut.Free;
  end;
end;

ブックマークのないドキュメントは、エラー状態としてユーザーに表面化する価値があるものではありません;それは単に、この分割モードに機能するものが何もないことを意味します。Length(Bm) = 0のガードはそれを静かに処理します。表面化する価値があるのは、ブックマークのページ番号がドキュメントの範囲外にある場合です。これは、ページが削除された後にアウトラインが更新されなかった不正なファイルで発生します。StartPageおよびEndPageでの境界チェックは、ガベージ範囲をImportPagesに渡すのではなく、それらのエントリをスキップします

出力ファイルの名前付けとActiveのリセット

ブックマークに由来する名前のファイル名の安全性には、明示的な注意が必要です。ブックマークのタイトルには、PDF文字列では有効でもファイルシステムパスでは有効でない文字を含めることができます。出力パスを構築する前に、少なくともスラッシュ、バックスラッシュ、およびコロンを置き換えてください。Windowsでは、*?"<>、および|も禁止されています;固定セット上の単純なループは、正規表現を取り込むことなくそれらをカバーします

各反復の終了時におけるActive := Falseの行は、パターンにおいて唯一の自明ではない要件であるため、強調する価値があります。CreateDocumentは開いているものを暗黙的に閉じません。CreateDocumentが再び実行されたときにActiveが依然としてTrueである場合、メモリ内に残っているドキュメントは適切に閉じられたり保存されたりしたことがなく、その状態での明確に定義された動作に依存することはできないため、次のドキュメントを開始する前に明示的に保存してリセットしてください。それをtry/finallyのペアと考えてください:finallyブロックは外側のオブジェクトを解放し、Active := Falseはループの反復間で内側のドキュメントの状態をリセットします

このアプローチでは、複数の出力ドキュメントを同時にメモリに保持することがないため、大規模な分割ジョブ全体でのメモリ使用量は一定に保たれます。ソースドキュメントは一貫して開いたままで読み取り専用になります。ImportPagesはソースを変更することなく、ページデータを新しいドキュメントにコピーします。ソースが暗号化されている場合は、ループの前にパスワードで開き、各出力ファイルにコピーされたページは暗号化されないようにします。これは、異なる受信者に配布される分割出力では、通常正しい動作です

SaveAsについてもう1つ:これはBooleanを返します。存在しない出力ディレクトリ、OSが拒否する文字を含むパス、またはディスクフル状態はすべて、例外を発生させることなくSaveAsFalseを返す原因となります。200ページのドキュメントを200の単一ページファイルに分割するバッチジョブでは、ページ147での静かな失敗は見落とされがちです。ループが終了したら、各呼び出しで戻り値を確認し、期待される合計に対して成功数をカウントします

ここで示されているImportPagesおよびCreateDocumentメソッドは、DelphiおよびC++Builder用のPDFiumコンポーネントの一部です