【Kotlin】Apache PDFBoxの使い方 ~複数画像のpdf化、pdfの暗号化など~

皆さんPDFはお好きですか。変な質問かもしれませんが、自分に関して言えばこのPDFというファイルの形式はかなりお気に入りで、というのも自分がそれなりに愛用しているノートアプリのEvernoteがとにかくPDFの保存と整理に長けており*1、学生時代は参考論文をすべてEvernote+PDFで管理していたことから、それ以降書類とか資料とかをとにかくPDFにして管理したがる癖がついてしまっているからです。

PDFの加工に関してはこういうWebサービスだったり、

www.ilovepdf.com

Adobeが提供している何たらを使う方法がありますが、いかんせん主要な機能を使うには無料ではできなかったりとあれなんで、自分で作ってみることにしました。言語はKotlinで、Apache PDFBoxというライブラリを使います。Kotlinはモダンな言語仕様に加えて元がJavaなんで、Javaの豊富な資源が使えるのがとてもいいと思います。

導入方法

gradleかMavenで導入します。

dependencies {
    implementation('org.apache.pdfbox:pdfbox:2.0.4')
}
<dependency>
    <groupId>org.apache.pdfbox</groupId>
    <artifactId>pdfbox</artifactId>
    <version>2.0.4</version>
</dependency>

基本の使い方

以下は新しいpdfを作成する例になりますが、PDDocumentがpdfを表す基本クラスで、PDPageがページに相当するクラスになります。doc.save(filePath)でpdfを保存し、作業が終わったら最後にdoc.close()で閉じる必要があります。

    fun main() {
        val doc = PDDocument()
        
        val page = PDPage(PDRectangle.A4)
        doc.addPage(page)
        
        // ....
        // ページに対する処理など
        // ....

        doc.save("test5.pdf");
        doc.close();
    }

ページに対して文字や画像などを挿入するには、そのページに対するPDPageContentStreamクラスのインスタンスを利用する形になります。

val stream = PDPageContentStream(doc, page)
stream.beginText()
stream.setFont(PDType1Font.HELVETICA, 20f)
stream.moveTextPositionByAmount(100f, 100f)
stream.drawString("test")
stream.endText()
stream.close()

この例では指定したページに対して左下から(100,100)ピクセルの位置に"test"という文字を入れています。

また既存のpdfを読み込む場合は以下のようになります。

val doc = PDDocument.load(File(filePath))

以下ではこれを基本に具体的なサンプルについて説明します。なお実装は以下のサイトを参考にしています。

www.finddevguides.com

サンプル① 複数の画像をまとめてpdfにする

今回特にやりたかったのはこれです。同様のことは最初に上げたiLovePdfでもできるのですが、無料の範囲内では一度に作成できる数に制限があったりします。

これをPDFBoxで実装するとこんな感じになります。各画像に対して、その画像と同じ大きさのページを作って追加していくという感じです。順番に関しては別途ソート処理を入れるとかになると思います。

    /**
     * 指定ディレクトリ以下の画像をまとめて一つのPDFにして保存
     */
    fun createPdfFromImages(resourceDirPath:String, outPutFilePath: String) {
        val doc = PDDocument()
        val resourceDir = File(resourceDirPath)
        resourceDir.listFiles { f -> f.isFile }.forEach {
            println("ページ追加:$it.path")
            var pdImage = PDImageXObject.createFromFile(it.path, doc)
            var page = PDPage(PDRectangle(pdImage.image.width.toFloat(), pdImage.image.height.toFloat()))
            doc.addPage(page)
            var stream = PDPageContentStream(doc, page)
            stream.drawImage(pdImage, 0f, 0f)
            stream.close()
        }
        doc.save(outPutFilePath)
        doc.close()
    }

使い方


createPdfFromImages("./resources", "merged.pdf")

ここではプロジェクト直下のresourcesディレクトリ以下の画像をmerged.pdfに結合しています。

サンプル② PDFの結合

pdfの結合にはPDFMergerUtilityというクラスを使います。

    /**
     * 読み込んだ複数のpdfファイルを結合
     */
    fun mergePdfFiles(files: List<File>, outPutFilePath: String) {
        val merger = PDFMergerUtility()
        merger.destinationFileName = outPutFilePath
        files.forEach() {
            merger.addSource(it)
        }
        merger.mergeDocuments()
    }

使い方


val file1 = File("test1.pdf")
val file2 = File("test2.pdf")
mergePdfFiles(listOf(file1, file2), "merged.pdf")

サンプル③ PDFの分割

pdfの分割はちょっとややこしいです。というのも、SplitterというクラスのsplitメソッドはPDDocumentをページごとに分割して返すというものなんですが、これの戻り値がPDPageのリストではなくPDDocumentのリストになっているため、返ってきたリスト内のpdfを再度結合するには一工夫いります(PDFMergerUtilityはソースにFileクラスを取り、PDDocumentをそのままでは結合できないため)。

なので、例えばあるpdfを2つに分割したいという場合は以下のようにします。もっと効率のいいやり方があるかもしれません。

    /**
     * 複数のPDDocumentを結合
     */
    fun mergePDDocuments(docs: List<PDDocument>): PDDocument {
        val merged = PDDocument()
        docs.forEach { doc ->
            doc.pages.forEach { page -> merged.addPage(page) }
        }
        return merged
    }

    /**
     * PDDocumentを指定されたページ以下から分割して保存する
     */
    fun splitIntoTwo(doc: PDDocument, pageNumber: Int) {
        val splitter = Splitter()
        val list = splitter.split(doc)
        val docs1 = list.subList(0, pageNumber - 1)
        val docs2 = list.subList(pageNumber, list.lastIndex)
        val doc1 = mergePDDocuments(docs1)
        val doc2 = mergePDDocuments(docs2)
        doc1.save("split_0.pdf")
        doc2.save("split_1.pdf")
        doc1.close()
        doc2.close()
    }

使い方


val doc = PDDocument.load(File("test.pdf"))
// 2ページ以下から分割
splitIntoTwo(doc, 2)

サンプル④ pdfの暗号化

windowsを使う大体の人がpdfリーダーとして使っている(?)adobeのpdf Readerには暗号化機能がなく、冒頭で述べた通り有料版のAdobe Acrobatを使う必要があるため、無料でできる暗号化には意外と需要がありそうな気がします。

PDFBoxでの暗号化は以下のようにします。

    /**
     * PDFを暗号化
     */
    fun encryptPdf(doc: PDDocument) {
        val accessPermission = AccessPermission()
        val spp = StandardProtectionPolicy("1234", "1234", accessPermission)
        spp.setEncryptionKeyLength(128);
        spp.setPermissions(accessPermission);
        doc.protect(spp);
    }

使い方


val doc = PDDocument.load(File("test.pdf"))
encryptPdf(doc, "password", "password")

ここでは所有者パスpassword、ユーザーパスpasswordでtest.pdfを暗号化しています。なお前者は編集権限のパス、後者はドキュメントを開く際のパスっぽいです。

なお、そのままだとdoc.protect()を実行した際にエラーが出ます。

Exception in thread "main" java.lang.NoClassDefFoundError: org/bouncycastle/jce/provider/BouncyCastleProvider

以下に解決策が載っていました。

github.com

その他できること

自分の用途では上の例くらいで間に合うのですが、他にもPDFからの画像の取得、またテキストの読み取りなんかもできたりするみたいです。気になった方は公式サイトか上に上げたチュートリアルのサイトなど見ていただければと思います。

おわりに

サンプルコードは以下にまとめてあります。

github.com

追記

書き終わった後に気づいたんですが、複数画像をまとめてpdf化するならImagemagickでいける上、そっちのほうが出力されるpdfの品質が高いです‥‥。まぁこれも勉強ということで。 higuma.github.io

*1:pdfをドラッグ&ドロップでノート化でき、サムネイル表示もできるため