技術記事

DelphiのPDFiumコンポーネントを使用してPDFファイルをマージする

マージループが毎回挿入位置として1を渡すと、ページ数は正しいのに文書が逆順に並ぶファイルができます。リストの最後のファイルがページ1に座るのは、位置1が「現在の最初のページの前に挿入」を意味し、新しいソースが来るたびに、それまでのものが後ろへ押しやられるからです。失われたものは何もありません。だからこそこの間違いはページ数の簡易チェックを生き延びて、誰かが実際に束を読むときに顔を出します

ImportPagesによるページ単位のループ

PDFium Componentは、2つのTPdfインスタンスからDelphiでマージ済みPDFを組み立てます。1つはCreateDocumentで空のまま作る宛先、もう1つは入力ファイルを順番に開くソースです。TPdf.ImportPages(Pdf, Range, PageNumber)は、Rangeが名指すページをPdfから宛先へコピーし、成功するとTrueを返します。PageNumberは1始まりで既定値は1です。最初にインポートされるページの着地点を宛先内で名指すため、1なら先頭への追加、PageCount + 1なら末尾への追加になります

ファイルをリスト順に保つ最もきれいな方法は、カーソルを追跡するのをやめ、宛先自身に末尾を尋ねることです。InsertAtカウンターを回す方法もありますが、これは実際にインポートされたページ数だけ進めなければならず、範囲指定が絡むとこの数値は間違えやすい。PdfDest.PageCount + 1は常に正しいのです:

procedure MergeFileList(const FileList: TStrings; const OutputPath: string);
var
  PdfDest, PdfSrc: TPdf;
  I: Integer;
begin
  PdfDest := TPdf.Create(nil);
  PdfSrc  := TPdf.Create(nil);
  try
    PdfDest.CreateDocument;
    for I := 0 to FileList.Count - 1 do
    begin
      PdfSrc.FileName := FileList[I];
      PdfSrc.Active   := True;            // 失敗しても例外を投げない
      if not PdfSrc.Active then
        raise Exception.CreateFmt('Cannot open %s', [FileList[I]]);

      // 1始まりの位置:PageCount + 1で最終ページの後に追加
      if not PdfDest.ImportPages(PdfSrc,
        '1-' + IntToStr(PdfSrc.PageCount), PdfDest.PageCount + 1) then
        raise Exception.CreateFmt('Page import failed for %s', [FileList[I]]);

      PdfSrc.Active := False;             // FileNameは非アクティブの間しか変更できない
    end;

    if not PdfDest.SaveAs(OutputPath) then
      raise Exception.Create('Cannot write ' + OutputPath);
  finally
    PdfSrc.Free;
    PdfDest.Free;
  end;
end;

このループの2つの確認には実際の重みがあります。Active := Trueは決して例外を投げません。ファイルが無い、壊れている、パスワードが要る場合、TPdfはロードエラーを内部で受け止め、ActiveをFalseのままにします。コンポーネントは閉じた一貫した状態を保ち、フォームは不良ファイルでクラッシュしません。とはいえ、この確認を飛ばすマージループは、単に短い出力を作るだけです。2つ目はImportPagesのブール結果で、範囲文字列がパースできないときや、位置がPageCount + 1を超えるときにFalseで返ります。さらにFileNameとPasswordはコンポーネントが非アクティブの間しか代入できないため、各パスの末尾でソースを閉じています

PDFium Component による Delphi マージループ。各ソースファイルを開き、InsertAt 位置で ImportPages 経由でコピーし、宛先ドキュメントを SaveAs で 1 回書き出す
ImportPages は各ソースを InsertAt の位置に着地させ、欠けた、あるいは壊れたファイルは、Active を確認しない限り黙って失敗します

範囲文字列:パーサーが受け付けるもの

範囲引数は、単一ページと閉区間のカンマ区切りリストで、ソース文書の1始まりで数えます。'1-3'は最初の3ページ、'2,4,6'は特定の3ページ、'1,3-5'は表紙と3ページの要約です。番号は常にソースを指します。そのページが宛先のどこに着くかは、位置引数だけが決めます

開放区間で人はつまずきます。'7-'のような文字列は「ページ7から末尾まで」に見えますが、PDFiumの範囲パーサーは区間の両端を要求し、文字列全体を拒否します。逆順の区間('5-3')、ページ番号0、ソースのページ数を超えるページも同じ扱いです。悪い項目が1つあると呼び出し全体が失敗します。ImportPagesはFalseを返し、その文字列からのページは1枚もコピーされません。だから上のループは'1-'と書かずに'1-' + IntToStr(PdfSrc.PageCount)を組み立てているのです

ページインデックスを配列で既に持っているなら、ImportPagesByIndex(Source, PageIndices, InsertAt)が文字列の工程を飛ばします。ただし慣習が変わることに注意してください。インデックスもInsertAtもゼロ始まりです。追加はInsertAt = PdfDest.PageCountであり、表紙+要約の選択は[0, 2, 3, 4]になります。空の配列を渡すと全ページをインポートします

ファイルのリストを1呼び出しでマージできるか

できます。TPdf.MergeFiles(Sources, Options, Report)はTPdfBatchMergeSourceレコードの配列を受け取ります。各レコードはTPdfBatchMergeSource.FromFile(FileName, PageRange, Password)で組み立て、1つずつ開き、TPdfBatchMergeReportに何が起きたかを記入します。空のPageRangeは全ページの意味です。範囲構文は同じカンマと区間の形式で、不正な範囲は黙って飛ばされるのではなく、レポート内のファイルごとのエラーになります

TPdfBatchMergeOptions.Defaultの既定値は、いじる前に知っておく価値があります。CreateDestinationはTrueで、非アクティブな宛先は自動生成されます。InsertAtは-1で追加の意味。他の値はゼロ始まりのインデックスであり、ImportPagesByIndexと一致し、ImportPagesとは一致しません。ErrorPolicyはbmepStop、RollbackOnCancelOrFailureはTrueのため、読めないファイルが1つあるだけで走行は止まり、それまでに追加されたページがすべて取り除かれます。CopyViewerPreferencesは、正常にインポートできた最初のソースのビューアー設定をコピーします

procedure BuildBundle(const OutputPath: string; Log: TStrings);
var
  Dest: TPdf;
  Sources: TPdfBatchMergeSources;
  Options: TPdfBatchMergeOptions;
  Report: TPdfBatchMergeReport;
  I: Integer;
begin
  SetLength(Sources, 3);
  Sources[0] := TPdfBatchMergeSource.FromFile('cover.pdf');
  Sources[1] := TPdfBatchMergeSource.FromFile('annual-report.pdf', '1,3-5');
  Sources[2] := TPdfBatchMergeSource.FromFile('appendix.pdf', '', 'user-password');

  Options := TPdfBatchMergeOptions.Default;
  Options.ErrorPolicy := bmepSkip;        // 読めないファイルを飛ばして続行する

  Dest := TPdf.Create(nil);               // 非アクティブ:MergeFilesが生成する
  try
    if not Dest.MergeFiles(Sources, Options, Report) then
      raise Exception.Create('Merge failed: ' + Report.ErrorMessage);

    for I := 0 to High(Report.Issues) do
      Log.Add(Format('%s: %s',
        [Report.Issues[I].FileName, Report.Issues[I].MessageText]));
    Log.Add(Format('%d pages from %d of %d files',
      [Report.ImportedPageCount, Report.ImportedSourceCount, Report.SourceCount]));

    Dest.SaveAs(OutputPath);
  finally
    Dest.Free;
  end;
end;

どのエラーポリシーを選ぶかは、欠けたファイルが出力にとって何を意味するか次第です。契約書の束や請求書の走行では、部品の1つを欠いた束は誤った文書です。既定のbmepStopとロールバックを保ちます。bmepSkipに切り替えるのは、週次のレポートダイジェストのようなベストエフォートの収集だけにし、そのうえでReport.StatusのbmrsCompletedWithErrorsを、人が見るべきものとして扱ってください。どちらを選んでもReport.Issuesを読みましょう。各項目はソースインデックス、ファイル名、メッセージを運び、手書きループが独自の例外で答えていた「犯人はどのファイルか」に答えます

長い走行にはさらに2つのオプションが効きます。OnProgressとCancellationTokenはUIに現在のファイルを示させ、走行を止めさせます。キャンセルされた走行は、失敗した走行と同じようにロールバックされます。ResourcePolicyの既定はbmrpSourceScopedで、各ソースの選択ページを1回のネイティブ呼び出しでインポートするため、それらのページが共有するフォントと画像は1回だけコピーされます。bmrpChunkedはPageChunkSizeページ単位のバッチでインポートし、キャンセル地点は細かくなりますが、共有リソースはバッチごとに再コピーされます

インポートされたページが運ぶもの、ソースに残るもの

インポートされたページは、ページ辞書から到達できるすべてを運んできます。コンテンツストリーム、リソース内のフォント、画像、フォームXObject、そしてコメント、ハイライト、インクを含む注釈です。ソース内で文書レベルに住んでいるものは付いてきません。ISO 32000-1がそれをページツリーの外に置いているからです

  • 文書情報辞書(ISO 32000-1 §14.3.3)は残されるため、マージ済みファイルにはソースのTitle、Author、Keywordsがありません。TPdfのTitleとAuthorプロパティは読み取り専用で、ソースを閉じる前に各ソースが宣言していたものをログするのに便利です
  • アウトライン(§12.3.3)は既定のOutlinePolicyであるbmopDiscardの下で落とされます。bmopRejectはアウトライン付きソースをファイルごとのエラーに変えます。ブックマークを保つには、FPdfMergeユニットのMergeFilesWithNavigationが使えます。アウトラインを再構築し、衝突する名前付き宛先に既定ではImported_プレフィックスを付け、ページ間のリンクを付け替えます。宛先の書き換え方法はアウトライン編集とページ再マッピングの記事が扱います
  • 暗号化(§7.6)はソースファイルの所有物です。保護されたソースはPasswordで開きますが、CreateDocumentで作られた宛先は非暗号化であり、そこへコピーされたページもそうです。マージ済みファイルを保護する必要があるなら、SaveAsの代わりにSaveAsEncryptedで書いてください
PDFium ImportPagesはページコンテンツ、フォント、注釈をマージ済みPDFへ運び、Infoメタデータ、AcroFormフィールドツリー、暗号化は各ソースファイルに残る
ImportPagesはページと一緒に保存されたすべてを運び、文書レベルのメタデータとAcroFormフィールドツリーは置き去りになる

マージ後にフォームフィールドが動かなくなるのはなぜか

フォームフィールドはPDFの2つのレベルに跨っています。テキストボックスを描くウィジェット注釈はページの上にあり、フィールド自体は、名前と値とともに、カタログのインタラクティブフォーム辞書(ISO 32000-1 §12.7.2)の/Fields配列に列挙されます。ネイティブのページインポートはウィジェットを他のページ注釈と一緒にコピーしますが、それを宛先のフィールドツリーに追加するものは何もありません。ボックスは最後の外観を描き続けます。しかし、/AcroFormからフォームモデルを組み立てるビューアーには、ウィジェットを付けるフィールドがないため、値は編集できず、フィールドツリーを歩くフォーム操作はすべてそれを飛ばします

TPdfBatchMergeOptions.FormPolicyは、素のマージに3つの答えを与えます。既定のbmfpPreserveNativeは、上で述べたネイティブインポートの結果を保ちます。bmfpFlattenは、フォームを持つ各ソースの選択ページについて、インポートの前にメモリ内で外観を生成してフラット化するため、値は通常のページコンテンツになります。ディスク上のソースファイルは変更されません。bmfpRejectは、AcroFormかXFAフォームを持つソースをファイルごとのエラーにします

フィールドを記入可能なままにする必要があるなら、FPdfFormMergeユニットのMergeFilesWithFormsを使います。ページコンテンツをインポートし、各ソースのフィールドツリーからウィジェットを組み立て直し、それらのフィールドを宛先フォームへ移植します。難物はフィールド名です。§12.7.3.2の下では、完全修飾名が同じ2つのフィールドは同一フィールドであり、1つの値を共有します。請求書のTotalフィールドと納品書のTotalフィールドは互いに上書きし合います。既定のCollisionPolicyはpagcpRejectで、最初の衝突でマージを止めます:

uses
  PDFium, FPdfCompress, FPdfFormMerge;

procedure MergeFillableForms(Dest: TPdf;
  const Sources: TPdfBatchMergeSources; Log: TStrings);
var
  Options: TPdfFormMergeOptions;
  Report: TPdfFormMergeReport;
begin
  Options := TPdfFormMergeOptions.Default;
  // 衝突する名前にはRenamePrefixが付く(変更しなければ'Imported_')
  Options.GraftOptions.CollisionPolicy := pagcpRename;

  if Dest.MergeFilesWithForms(Sources, Options, Report) then
    Log.Add(Format('%d fields grafted, %d renamed',
      [Report.GraftedFieldCount, Report.RenamedFieldCount]))
  else
    Log.Add('Form merge failed: ' + Report.ErrorMessage);
end;

pagcpNamespaceは代わりに各ソースのフィールドを独自のプレフィックス(Imported+ソース番号)の下に置き、pagcpMergeCompatibleは、型とオプションが一致する場合に限り、同名フィールドを統合します。MergeFilesWithFormsの対象は、署名フィールドを持たないAcroFormのソースと宛先です。XFAフォームと署名済みファイルは徹底的に拒否します。署名済みファイルの書き換えは、ISO 32000-1 §12.8.1が述べる署名バイト範囲を無効化するためです。開かれた宛先も非暗号化でなければなりません。パスワード保護されたファイルへのマージは失敗するため、保護は後からSaveAsEncryptedで加えます。1つのソースで同じページが2回選ばれた場合、たとえば'1,1'には、明示的なRepeatedPagePolicyが要ります

宛先文書が置き換えられるのは、ステージングされた結果を開き直して確認した後です。フィールド名、値、型がページごとに比較され、失敗したフォームマージはDestをそのままにします。衝突処理の詳細はAcroForm移植の記事が深く掘り下げています

マージ済み文書の保存

SaveAsにはファイルのオーバーロードとTStreamのオーバーロードがあり、どちらも省略可能なTSaveOption(既定saNone)とTPdfVersion(既定pvUnknown、ヘッダーをライブラリに任せる)を受け取ります。どちらもブールを返し、バッチジョブではこの結果もActiveと同じ確認に値します。バージョンの固定は、下流システムが特定のヘッダーを頑なに要求するときに、pv17のように行います

逆の作業、1つのファイルを複数へ切り分けるのは、同じ範囲文字列と同じ1始まりの位置を使います。分割の記事がその手順を歩きます

ImportPages、MergeFiles、そしてここで述べたフォーム対応マージは、PDFium Componentに同梱されており、これらのページ操作をDelphiとC++Builderのアプリケーションにもたらします