【詰め合わせ】コマンドラインで PDF 結合

6 min read読了の目安(約5800字

環境:

> $PSVersionTable

Name                           Value
----                           -----
PSVersion                      7.1.3
PSEdition                      Core
GitCommitId                    7.1.3
OS                             Microsoft Windows 10.0.18363
Platform                       Win32NT
PSCompatibleVersions           {1.0, 2.0, 3.0, 4.0…}
PSRemotingProtocolVersion      2.3
SerializationVersion           1.1.0.1
WSManStackVersion              3.0

「結合」のほかに「連結」「つなげる」などと書かれるように、英語でも mergeconcatenate(省略形として catconc )・join など表記が揺れているようです。今回はとりあえず merge で統一してみました。

pdftk

pdftk *.pdf cat output out.pdf

手軽さはダントツですね。内部的には iText を使用しているとか。
Windows では Scoop で入手できます( Scoop install pdftk )。

Python

PyPDF

「Python PDF 結合」で検索すると多くヒットするのがこちらの PyPDF 。
有名な PyPDF2 のほかに PyPDF4 も見つかりましたが、どちらも同じ方法で結合できます。
(無印から2と4が派生した経緯は こちら も参照)

merge_pypdf2.py
import glob
import os
import PyPDF2

pdfs = glob.glob("*.pdf")
merger = PyPDF2.PdfFileMerger()

for p in pdfs:
    merger.append(p)

merger.write("out.pdf")
merger.close()
merge_pypdf4.py
import glob
import os
import PyPDF4

pdfs = glob.glob("*.pdf")
merger = PyPDF4.PdfFileMerger()

for p in pdfs:
    merger.append(p)

merger.write("out.pdf")
merger.close()

後に紹介する iText 系に比べて PDF を厳密に扱うらしく、 Word や Excel から変換した PDF だと時々「形式がおかしいです」的なエラーを出して止まってしまいます。

pdfrw

merge_pdfrw.py
import glob
import os
from pdfrw import PdfReader, PdfWriter

pdfs = glob.glob("*.pdf")
writer = PdfWriter()
for p in pdfs:
    writer.addpages(PdfReader(p).pages)

writer.write("out.pdf")

rw と冠していますがどちらかというと read のほうが得意のようで、PDF 文書をイチから生成するには reportlab と組み合わせるような使い方が想定されているそうです。
公式では結合のほかにも豊富な サンプル が紹介されています。

上記の PyPDF 同様に PDF の型式が厳密に正確でないときに警告を出しますが、警告メッセージを表示するだけで動作上は問題なく動くようです。

iText

世間には C# としての記事が多いようです。が、個人的には普段づかいの PowerShell のコマンドレットに組み込んでターミナルから呼び出すのが気軽に使えて便利だと感じているので、ここではその方法で。

以下、 Get-Childitem したファイルオブジェクト([System.IO.FileInfo] 型)をパイプで渡すフィルタ的な使い方を想定しています。
事前に Where-Object でフィルタリングしたり Sort-Object でソートしてから渡せるので自由度は高めです。

iTextSharp

上記の NuGet ページから dll をダウンロードして、スクリプトと同じ階層に作った lib フォルダ内に itextsharp.dll を置いて使います。

merge_itextsharp.ps1
Add-Type -Path ($PSScriptRoot | Join-Path -ChildPath "lib\itextsharp.dll")

function Invoke-PdfConc {
    <#
        .EXAMPLE
        ls | Invoke-PdfConc -outname hogehoge
    #>
    param(
        [string]$outName = "out"
    )

    $fullpath = Join-Path -Path $PWD.Path -ChildPath "$($outName).pdf"
    if (Test-Path $fullpath) {
        "'{0}.pdf' はもう存在しているファイルです!" -f $outName | Write-Error
        return
    }

    $pdfs = @($input | Where-Object Extension -eq ".pdf")
    if ($pdfs.Count -le 1) {
        return
    }

    $filestream = New-Object System.IO.FileStream($fullpath, [System.IO.FileMode]::Create)
    $document = New-Object iTextSharp.text.Document
    $pdfCopy = New-Object iTextSharp.text.pdf.PdfSmartCopy($document, $fileStream)
    $document.Open()

    "'{0}.pdf' として結合中:" -f  $outName | Write-Host -ForegroundColor Cyan
    $pdfs | ForEach-Object {
        " + {0}" -f $_.Name | Write-Host -ForegroundColor Cyan
        $reader = New-Object iTextSharp.text.pdf.PdfReader($_.Fullname)
        $pdfCopy.AddDocument($reader)
        $reader.Close()
    }

    $pdfCopy.Close()
    $document.Close()
    $filestream.Close()
}

. .\merge_itextsharp.ps1 としてドットソースで読み込んでおくとそのセッションでコマンドレットが有効化されます。 $Profile で読み込んでおくのがオススメです。

iText7

iText7 も NuGet からダウンロードできます。

もともと機能別に複数の dll に分割されているようですが、さらに下記のライブラリも必要だそうです( 参考 )。

ライブラリをダウンロードして解凍すると、中の lib フォルダ内に net●●netstandard●● がありますが、どちらの中に入っている dll でも特に問題なく動作しました。
一連の dll を lib フォルダ内に放り込んで function 宣言の前の Add-Type 部分を下記のようにして一通りロードしておきます。他は iTextSharp と同じ内容で動作します。

Get-ChildItem ($PSScriptRoot | Join-Path -ChildPath "lib") -Recurse -Filter "*.dll" | ForEach-Object {
    Add-Type -Path $_.FullName > $null
}

PdfSharp

老舗のライブラリ。 NuGet からダウンロードできます(現在の最新は 1.51.5185-beta の様子)。

こちらも同じく、スクリプトと同じ階層に lib フォルダを作ってその中に PdfSharp.dll を配置して読み込みます。

merge_pdfsharp.ps1
Add-Type -Path ($PSScriptRoot | Join-Path -ChildPath "lib\PdfSharp.dll")

function Invoke-PdfSharpConc {
    param(
        [string]$outName = "out"
    )

    $outPath = Join-Path -Path $PWD.Path -ChildPath "$($outName).pdf"
    if (Test-Path $outPath) {
        "'{0}.pdf' はもう存在しているファイルです!" -f $outName | Write-Error
        return
    }

    $pdfs = @($input | Where-Object Extension -eq ".pdf")
    if ($pdfs.Count -le 1) {
        return
    }

    "'{0}.pdf' として結合中:" -f  $outName | Write-Host -ForegroundColor Cyan
    $outputFile = New-Object PdfSharp.Pdf.PdfDocument
    $pdfs | ForEach-Object {
        " + {0}" -f $_.Name | Write-Host -ForegroundColor Blue
        $pdf = [PdfSharp.Pdf.IO.PdfReader]::Open($_.FullName, [PdfSharp.Pdf.IO.PdfDocumentOpenMode]::Import)
        for ($i = 0; $i -lt $pdf.PageCount; $i++) {
            $outputFile.AddPage($pdf.Pages[$i]) > $null
        }
    }
    $outputFile.Save($outPath)

}

.Close() を書かなくて言いぶん iText よりも少し記述が少なくて済みます。