Excel VBAを活用したPDFファイルの結合手順

Excel VBAから複数のPDFを結合するには、PDFを編集する外部ソフトウェアやライブラリが必要です。この記事では、Windows版ExcelからAdobe AcrobatのOLE Automationを利用し、指定した順序でPDFのページを追加する方法を説明します。Excelや無料のAcrobat Readerだけで動く処理ではありません。

結合処理では「ファイルができた」だけでは不十分です。入力を読み飛ばさず、ページ数と保存結果を確認し、原本や既存の出力を上書きしない構成にします。以下のコードは通常のPDFを対象とする実装例で、Windows版Excel・Acrobatでの実機動作確認は未実施です。導入前に末尾のテスト項目を確認してください。

目次

準備するものと対応範囲

  • Windows版デスクトップExcel:標準モジュールにVBAを記述します。Excel for the webやMac版Excelで、このWindows OLE用コードをそのまま実行することはできません。
  • PDF編集に対応する正規のAdobe Acrobat:例としてAcrobat Proを使用します。ReaderのPDF表示用インターフェイスと、ここで使うAcroExch.PDDocは別です。Readerだけ、またはDLL・タイプライブラリだけを配置した環境は対象にしません。
  • マクロを実行できる業務環境:ブックは.xlsm形式などで保存し、組織のマクロ方針に従います。動かないからといって、ExcelやAcrobatの保護機能を一括で無効にしないでください。
  • 保存済みのPDFとローカルの出力フォルダー:原本をバックアップし、処理中は入力を変更しません。コピーと結合結果を置ける空き容量、読み書き権限を確認します。

AdobeのOLE環境の説明では、Acrobat製品自体のインストールが必要とされています。本例はAs ObjectとCreateObjectを使う遅延バインディングなので、Acrobatの参照設定の手動追加は不要です。早期バインディングに変更する場合は、インストール環境のタイプライブラリを使い、表示名を「Adobe Acrobat 10.0 Type Library」に固定しないでください。

参照設定が不要でも、Acrobatのバージョン、ExcelとAcrobatの32/64ビット構成、COMの登録状態、管理者が設定した制限による違いは残ります。すべての組み合わせでの動作を保証する例ではありません。ログイン済みデスクトップでの実行を前提とし、無人サーバー処理やタイムアウト付きのサービスとしては使用しません。

結合順序と失敗時の扱いを先に決める

InsertPagesの第1引数は「何ページ目の後に挿入するか」で、ページ番号は0から始まります。末尾への追加では、結合先の現在のページ数から1を引いた値を指定します。-1を末尾追加の指定として繰り返し使うと先頭側へ挿入してしまい、入力順になりません。仕様はAdobeのInsertPagesリファレンスを参照してください。

たとえばAが2ページ、Bが1ページ、Cが3ページなら、AにBを追加する位置は1、続いてCを追加する位置は2です。期待する結果はAの1~2ページ、Bの1ページ、Cの1~3ページの順で、合計6ページになります。ファイル名の並びやフォルダー列挙順ではなく、Collection.Addした順序を使用します。

  • 入力の不足を成功扱いしない:どれか1件でもOpen、ページ数取得、InsertPages、Saveに失敗したら、後続処理を中止します。途中までの結合を完成ファイルとして扱いません。
  • 原本をAcrobatで直接開かない:新規の作業フォルダーに全入力をコピーし、そのコピーだけを開きます。原本への保存は行いません。
  • 既存の出力を上書きしない:実行ごとに新しいサブフォルダーを作り、検証用のpending.pdfを保存します。再オープンとページ数の確認後、コピーを削除し、merged.pdfへ移動します。
  • 他の文書を閉じない:自分で開いた作業用PDDocだけを閉じます。App.Exit、CloseAllDocsやプロセスの強制終了は呼び出しません。

原本をコピーしてから開くのには理由があります。AdobeのPDDoc.Closeの説明には、同じファイルに対してPDDocとAVDocを作った場合、PDDoc.Closeがビューアー上の文書も閉じる旨が記載されています。単にアプリケーション終了を削除するだけでなく、原本の表示と作業用文書を分ける構成にしています。

実践例:全件成功時だけ新しいPDFを作る

使用前の条件

この例は2~50ファイル、入力の合計500 MiB以下、合計2000ページ以下に制限しています。これらはサンプルの運用上限であり、Acrobatの公式上限や処理時間・メモリ使用量の保証ではありません。ドライブ文字付きのフルパスだけを受け付け、UNC、URL、相対パス、長いパスは対象外にしています。ネットワークドライブや同期フォルダーではなく、自分だけが操作するローカルフォルダーを指定してください。

パスワード付き、編集制限付き、電子署名・証明済み、PDFポートフォリオ、入力フォーム付きのPDFは、この例の対象外です。コードはこれらを網羅的に自動判定しません。実行前にAcrobatで内容と文書のプロパティを確認し、対象外の文書を混ぜないでください。ページ結合は、署名、フォーム動作、添付ファイル、アクセシビリティ情報などを含む文書全体の完全な移行ではありません。

標準モジュールに貼り付けるコード

次のコードは、呼び出し用マクロ、結合関数、後始末、入力パス検証をまとめたものです。ExecuteCombinePDFs内の入力パスと出力フォルダーを変更してください。出力先の親フォルダーは、あらかじめ作成しておきます。

Option Explicit

Private Const PDSaveFull As Long = 1
Private Const MAX_FILES As Long = 50
Private Const MAX_PAGES As Long = 2000
Private Const MAX_BYTES As Double = 524288000# ' 500 MiB

Public Sub ExecuteCombinePDFs()
    Dim pdfs As New Collection
    Dim resultPath As String
    On Error GoTo Failed

    ' Addした順に結合する。実在するローカルパスに変更する。
    pdfs.Add "C:\PDFwork\01_report.pdf"
    pdfs.Add "C:\PDFwork\02_appendix.pdf"
    resultPath = MergePdfFiles(pdfs, "C:\PDFwork\output")

    MsgBox "保存とページ数の確認が完了しました。" & vbCrLf & _
           resultPath & vbCrLf & "配布前にページ順と内容を確認してください。", vbInformation
    Exit Sub
Failed:
    MsgBox "処理を中止しました。" & vbCrLf & Err.Description, vbExclamation
End Sub

Public Function MergePdfFiles(ByVal pdfs As Collection, _
                              ByVal outputFolder As String) As String
    Dim fso As Object, seen As Object
    Dim dst As Object, src As Object, checkDoc As Object
    Dim dstOpen As Boolean, srcOpen As Boolean, checkOpen As Boolean
    Dim inputs() As String, copies() As String
    Dim ownedFiles As New Collection
    Dim jobFolder As String, pendingPath As String, finalPath As String
    Dim jobOwned As Boolean, saveAttempted As Boolean
    Dim p As String, item As Variant
    Dim i As Long, n As Long, totalPages As Long, countNow As Long
    Dim totalBytes As Double, errorNumber As Long, errorText As String

    On Error GoTo Failed
    If pdfs Is Nothing Then RaiseMergeError "入力リストがNothingです。"
    If pdfs.Count < 2 Or pdfs.Count > MAX_FILES Then
        RaiseMergeError "入力は2~50ファイルにしてください。"
    End If
    Set fso = CreateObject("Scripting.FileSystemObject")
    Set seen = CreateObject("Scripting.Dictionary")
    seen.CompareMode = vbTextCompare
    outputFolder = LocalFullPath(outputFolder, fso)
    If Not fso.FolderExists(outputFolder) Then
        RaiseMergeError "出力先フォルダーがありません: " & outputFolder
    End If
    If Len(outputFolder) > 160 Then RaiseMergeError "出力先のパスが長すぎます。"
    ReDim inputs(1 To pdfs.Count)
    ReDim copies(1 To pdfs.Count)

    ' 全入力の基本条件を確認してから、コピーやAcrobat操作を始める。
    For i = 1 To pdfs.Count
        If VarType(pdfs(i)) <> vbString Then RaiseMergeError "入力は文字列で指定してください。"
        p = LocalFullPath(CStr(pdfs(i)), fso)
        If LCase$(fso.GetExtensionName(p)) <> "pdf" Then
            RaiseMergeError "PDF以外の拡張子です: " & p
        End If
        If Not fso.FileExists(p) Then RaiseMergeError "入力がありません: " & p
        If fso.GetFile(p).Size <= 0 Then RaiseMergeError "空ファイルです: " & p
        If seen.Exists(p) Then RaiseMergeError "入力パスが重複しています: " & p
        seen.Add p, True
        totalBytes = totalBytes + CDbl(fso.GetFile(p).Size)
        If totalBytes > MAX_BYTES Then RaiseMergeError "入力の合計が500 MiBを超えています。"
        inputs(i) = p
    Next i

    ' GetTempNameは名前を作るだけ。CreateFolderの成功後だけ所有を記録する。
    jobFolder = fso.BuildPath(outputFolder, "merge_" & fso.GetBaseName(fso.GetTempName))
    fso.CreateFolder jobFolder ' 既存ならエラー。既存フォルダーは使わない。
    jobOwned = True
    pendingPath = fso.BuildPath(jobFolder, "pending.pdf")
    finalPath = fso.BuildPath(jobFolder, "merged.pdf")

    ' 原本や、利用者がAcrobatで開いている原本のPDDocには触れない。
    For i = 1 To pdfs.Count
        copies(i) = fso.BuildPath(jobFolder, "input_" & Format$(i, "000") & ".pdf")
        ownedFiles.Add copies(i) ' コピー途中で失敗したファイルも後始末の対象。
        fso.CopyFile inputs(i), copies(i), False
    Next i

    Set dst = CreateObject("AcroExch.PDDoc")
    If dst.Open(copies(1)) = 0 Then RaiseMergeError "先頭のPDFを開けません: " & inputs(1)
    dstOpen = True
    totalPages = CLng(dst.GetNumPages())
    If totalPages < 1 Or totalPages > MAX_PAGES Then
        RaiseMergeError "先頭のPDFのページ数が対象範囲外です。"
    End If

    For i = 2 To pdfs.Count
        Set src = CreateObject("AcroExch.PDDoc")
        If src.Open(copies(i)) = 0 Then RaiseMergeError "PDFを開けません: " & inputs(i)
        srcOpen = True
        n = CLng(src.GetNumPages())
        If n < 1 Then RaiseMergeError "ページ数を取得できません: " & inputs(i)
        If n > MAX_PAGES - totalPages Then RaiseMergeError "合計ページ数が2000を超えます。"
        countNow = CLng(dst.GetNumPages())
        If countNow <> totalPages Then RaiseMergeError "結合先のページ数が不一致です。"

        ' 末尾の0始まり番号を指定。0は追加元のしおりをコピーしない指定。
        If dst.InsertPages(countNow - 1, src, 0, n, 0) = 0 Then
            RaiseMergeError "ページ追加に失敗しました: " & inputs(i)
        End If
        totalPages = totalPages + n
        If CLng(dst.GetNumPages()) <> totalPages Then
            RaiseMergeError "追加後のページ数が不一致です。"
        End If
        CloseOwnedDoc src, srcOpen
    Next i

    ' 全入力の追加成功後だけ保存。完成名ではなく検証用の名前に保存する。
    If fso.FileExists(pendingPath) Or fso.FolderExists(pendingPath) Or _
       fso.FileExists(finalPath) Or fso.FolderExists(finalPath) Then
        RaiseMergeError "作業フォルダー内に予期しない出力があります。"
    End If
    saveAttempted = True
    If dst.Save(PDSaveFull, pendingPath) = 0 Then RaiseMergeError "PDFの保存に失敗しました。"
    CloseOwnedDoc dst, dstOpen
    If Not fso.FileExists(pendingPath) Then RaiseMergeError "保存ファイルがありません。"
    If fso.GetFile(pendingPath).Size <= 0 Then RaiseMergeError "保存ファイルが空です。"

    Set checkDoc = CreateObject("AcroExch.PDDoc")
    If checkDoc.Open(pendingPath) = 0 Then RaiseMergeError "保存したPDFを再度開けません。"
    checkOpen = True
    If CLng(checkDoc.GetNumPages()) <> totalPages Then
        RaiseMergeError "保存後のページ数が不一致です。"
    End If
    CloseOwnedDoc checkDoc, checkOpen

    ' コピーを片付けてから完成名へ移動。MoveFileは既存ファイルを上書きしない。
    For Each item In ownedFiles
        fso.DeleteFile CStr(item), True
    Next item
    fso.MoveFile pendingPath, finalPath
    MergePdfFiles = finalPath
    Exit Function

Failed:
    errorNumber = Err.Number
    errorText = Err.Description
    ' エラー処理内のみ継続し、自分で開いた文書と作ったファイルだけを片付ける。
    On Error Resume Next
    CloseOwnedDoc checkDoc, checkOpen
    CloseOwnedDoc src, srcOpen
    CloseOwnedDoc dst, dstOpen
    If jobOwned Then
        For Each item In ownedFiles
            If fso.FileExists(CStr(item)) Then fso.DeleteFile CStr(item), True
        Next item
        If saveAttempted Then
            If fso.FileExists(pendingPath) Then fso.DeleteFile pendingPath, True
        End If
        RmDir jobFolder ' 空の場合だけ削除。フォルダーの再帰削除はしない。
        If fso.FolderExists(jobFolder) Then
            errorText = errorText & vbCrLf & "残存ファイルを確認してください: " & jobFolder
        End If
    End If
    Set checkDoc = Nothing
    Set src = Nothing
    Set dst = Nothing
    On Error GoTo 0
    If errorNumber = 0 Then errorNumber = vbObjectError + 2357
    Err.Raise errorNumber, "MergePdfFiles", errorText
End Function

Private Sub CloseOwnedDoc(ByRef doc As Object, ByRef isOpen As Boolean)
    If isOpen Then
        If doc.Close() = 0 Then RaiseMergeError "作業用PDFを閉じられませんでした。"
        isOpen = False
    End If
    Set doc = Nothing
End Sub

Private Sub RaiseMergeError(ByVal message As String)
    Err.Raise vbObjectError + 2357, "MergePdfFiles", message
End Sub

Private Function LocalFullPath(ByVal value As String, ByVal fso As Object) As String
    Dim i As Long, ch As String, result As String
    ' この例はドライブ文字付きのパスのみ。UNC、URL、相対パスは対象外。
    If Len(value) < 3 Then RaiseMergeError "絶対パスを指定してください。"
    If Not (UCase$(Left$(value, 1)) Like "[A-Z]") Then
        RaiseMergeError "ドライブ文字付きのパスを指定してください。"
    End If
    If Mid$(value, 2, 2) <> ":\" Then RaiseMergeError "絶対パスを指定してください。"
    For i = 3 To Len(value)
        ch = Mid$(value, i, 1)
        If AscW(ch) >= 0 And AscW(ch) < 32 Then RaiseMergeError "パスに制御文字があります。"
        If InStr(1, "*?""<>|:/", ch, vbBinaryCompare) > 0 Then
            RaiseMergeError "この例では扱えないパスです: " & value
        End If
    Next i
    result = fso.GetAbsolutePathName(value)
    If Len(result) > 240 Then RaiseMergeError "この例では240文字を超えるパスは扱いません。"
    LocalFullPath = result
End Function

コードの確認ポイント

  • pdfs Is Nothingと件数を先に確認し、空のリストでpdfs(1)へアクセスしないようにしています。ファイルの存在、拡張子、サイズ、正規化したパスの重複も調べます。ただし、別名のハードリンク等による同一実体の重複までは判定しません。
  • GetNumPagesは0以下を異常として扱い、挿入直後と保存後に合計ページ数を確認します。ページ数が合っていても、内容やページ順の正しさまで証明できるわけではありません。
  • InsertPages(countNow - 1, src, 0, n, 0)の最後の0は、追加元のしおりをコピーしない指定です。しおりを必要とする場合は別途設計・検証してください。先頭PDFに由来するしおりやメタデータまで消去する処理ではありません。
  • PDSaveFull = 1を定義し、完全保存の戻り値を確認します。Open、InsertPages、Save、Closeの失敗を、メッセージだけ表示して無視する構成にはしていません。
  • GetTempNameだけでは名前は予約されません。CreateFolderの成功後に作業フォルダーとして扱います。コピーはoverwrite=Falseで、最後のMoveFileも既存ファイルを上書きしません。
  • 異常時は、自分で作ったコピーとpending.pdfの削除を試みます。削除できなければ残存フォルダーを表示します。停電、Excelの強制終了、Acrobatの応答停止まで巻き戻す仕組みではありません。残ったPDFは完成品として配布せず、確認後に片付けてください。

ファイル操作の挙動はMicrosoftのGetTempName、CopyFile、MoveFileで確認できます。本例は同じ作業フォルダーを他の処理が変更しないことを前提にしています。悪意あるファイル差し替えに対抗する仕組みや、厳密なトランザクションではありません。

よくあるエラーと確認箇所

  • 「ActiveXコンポーネントはオブジェクトを作成できません」:Readerのみの環境ではないか、Acrobatが正しくインストールされているか、初回起動・ライセンス認証が完了しているか、COM連携と組織の制限を確認します。別のDLLを無作為に登録する方法は避けます。
  • PDFを開けない:存在確認だけではPDFの正常性は分かりません。破損、拡張子だけPDFのファイル、パスワード保護、読み取り権限などを確認します。問題のファイルを飛ばして続行しないでください。
  • ページ追加が失敗する:入力PDFの編集制限や特殊な構造を確認します。保護の解除を自動化せず、文書の所有者に利用可能な原稿を確認してください。
  • 保存・移動・削除に失敗する:空き容量、書き込み権限、他アプリによるロックを確認します。出力フォルダーを共有・同期対象にすると、実行中の別処理の影響を受ける場合があります。
  • 処理が戻ってこない:このVBA例はAcrobatの呼び出しに時間制限を設けていません。実行中のダイアログやアプリの状態を確認し、短いテストから切り分けます。利用者の作業中のAcrobatをコードで強制終了しないでください。

Acrobat以外の選択肢

PDFtk Server:コマンドラインで結合する場合

PDFtk Serverの公式マニュアルでは、catを使ってPDFを結合できます。たとえば、確認済みの実行ファイルと固定パスを使うWindowsコマンドプロンプト用の形は次のとおりです。実行ファイルの場所はインストール先に合わせ、出力には未使用のファイル名を指定します。

"C:\Tools\PDFtk\bin\pdftk.exe" "C:\PDFwork\01_report.pdf" "C:\PDFwork\02_appendix.pdf" cat output "C:\PDFwork\new_combined.pdf"

これは固定パスで構文を確認する例であり、VBAの完成版ではありません。空白区切りの入力リストをそのままcmd.exe /Cへ連結すると、空白のあるパスが壊れたり、&等がコマンドとして解釈されたりする問題が生じます。引用符だけで任意の文字列に対して安全になるとも限りません。

さらに、VBAのShell関数は通常、非同期に動作します。呼び出し直後に「完了」と表示したり出力を開いたりしてはいけません。自動化する場合は、検証済みの実行ファイルを直接起動する方式、引数の適切な受け渡し、プロセス終了待ち、タイムアウト、終了コード、出力の再検証、上書き防止を一式で設計してください。

PDFtk ServerにはGPLと再配布に関するライセンス条件があります。無料で使えることと、自作ソフトへの組み込み・再配布の条件は別に確認します。導入前に対象OSへの対応と配布元の更新状況も確認してください。

PDFsam Basic:手作業で確実に結合したい場合

PDFsam Basicの結合機能は、GUIで入力ファイルやページ範囲、順序を指定する選択肢です。単発の作業や、目視確認を挟みたいときに向いています。ここではVBAから使えるCOM APIがあるとは扱わず、Acrobat用コードの差し替え先にはしません。

iText:別のアプリケーションとして実装する場合

iTextはJavaや.NETでPDF処理を実装するためのライブラリです。iTextSharpという名称で知られるiText 5系は、公式サイトではレガシー製品・保守モードとして案内されています。新規開発では現行の製品・APIを確認してください。

ライブラリを参照設定に追加するだけで、VBAからそのままCOMオブジェクトとして使えるわけではありません。必要なら.NET等の別プログラムを設計して連携する開発が必要です。本記事では未検証のCOMブリッジや登録手順は示しません。AGPLまたは商用ライセンスの条件も、用途・配布形態に応じて確認してください。

結合前後のセキュリティと文書の確認

  • 署名付きPDFは原本を保管する:ページを別のPDFへ取り込んでも、原本の電子署名を有効な署名として引き継げるとは限りません。署名の見た目を残すことと検証可能な署名を保持することは異なります。署名済み原本を保管する業務要件は、ページ結合とは分けて検討します。
  • フォームやポートフォリオを通常のページと同一視しない:フォームの値や計算、添付ファイル、しおり、タグ等が必要な場合は、それぞれの保持要件を定めた専用の検証が必要です。
  • 機密情報を四角形で覆うだけにしない:必要な情報削除には正式な墨消しを適用し、非表示データも確認します。結合処理自体は墨消しやサニタイズを行いません。
  • 作業用コピーにも原本と同じ保護を適用する:アクセスが限定されたローカルの出力先を使い、失敗時の残存コピーも確認します。機密PDFを、許可のないオンライン結合サービスにアップロードしないでください。
  • 配布前に結果を開いて確認する:ページ数・順序・向き・文字・画像の欠落を確認し、必要な保護や署名は完成文書に対して適切な手順で行います。ページ結合の成功だけをセキュリティの保証にしないでください。

墨消しと非表示情報の削除については、Adobeの機密コンテンツの削除手順を参照してください。

導入前のテスト項目

以下は実機で行う受け入れ確認です。本記事のコードについて、Excel上のコンパイル、AcrobatのCOM呼び出し、実際のPDF結合・表示は未検証です。少量のコピーを使い、自分の環境で確認してから業務用データへ適用してください。

  1. 各ページにA-1、A-2、B-1、C-1などの目印を付けたPDFを用意し、A→B→Cの順序と合計ページ数を確認する。日本語や空白を含むパスも試す。
  2. 空のCollection、1件のみ、重複、欠損ファイル、0バイト、PDF以外の拡張子、サイズ・件数上限超過で、完成扱いにならないことを確認する。
  3. 途中の入力を、AcrobatのOpenが失敗する読み取り不能なPDFに差し替え、読み飛ばさずに停止することを確認する。破損PDFでもAcrobatが修復して開く場合はあり、本例では修復の有無までは判定しない。編集制限等の対象外PDFは、通常運用では実行前に除外する。
  4. 書き込み不可・容量不足・保存先ロックなどの失敗をテスト用環境で確認し、成功通知が出ないこと、残存ファイルの案内を確認する。
  5. 同じ入力で2回実行し、別のサブフォルダーに出力されること、原本と以前の結果が変わらないことを確認する。原本と無関係なPDFをAcrobatで開いた状態でも、それらが閉じられないことを確認する。
  6. 完成したPDFを別途開き、ページ数に加えて内容・順序・見た目を照合する。必要に応じて原本のハッシュを実行前後で比較する。

まとめ

Excel VBAでPDFを結合する場合も、実際のPDF編集はAcrobat等の外部ソフトウェアが担当します。末尾追加にはGetNumPages() - 1を使い、入力を飛ばさず、保存結果まで確認することが基本です。原本から独立した作業用コピー、新しい出力先、自分が開いた文書だけの後始末を組み合わせ、実機テストと完成文書の目視確認を行ってください。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次