B-Teck!

お仕事からゲームまで幅広く

【Kotlin/PDFBox】Kotlinを使ってPDFBoxでPDFを編集する

前置き

Apache PDFBox は、JavaでPDFを操作するためのライブラリです。
PDFBoxは低レベルなAPI群で構成されているため、段落の処理や文字の折返しなどの処理が組み込まれていなかったりします。
今回、KotlinでPDFの編集を行う必要があったのですが、Javaで実装している記事が殆どで、Kotlinの記事はほぼ見当たりませんでした。
また、調べるにあたって様々なサイト参照する必要があり、情報があまり集約されていませんでした。
日本語の記事も多くはないため、備忘も含めて調べた内容をサンプルコードとともに列挙したいと思います。
線の描画は今回取り扱わなかったため含まれていません。もしいずれ実装する機会があったら追加します。
末尾に、自分なりに実装を整理した拡張関数群をリンクします。
場合によっては最適な実装ではないかもしれませんが…

ライブラリ読み込み

今回はMavenを利用します。
Gradleを利用する場合は適宜読み替えてください。
実装を書いた時点では2.0.22が最新だったので参照していますが、記事執筆時点では2.0.23が最新みたいです。

  • pom.xmlに下記の依存を追加
<dependency>
    <groupId>org.apache.pdfbox</groupId>
    <artifactId>pdfbox</artifactId>
    <version>2.0.22</version>
</dependency>

PDFを操作する

既存のPDFを読み込む

// resourcesから既存のPDFをInputStreamで読み出す
val template = this.javaClass
    .classLoader
    .getResourceAsStream("sample.pdf")

// InputStreamから編集用のオブジェクトを生成する
val doc = PDDocument.load(template)

新しいPDFを生成する

// 空の編集用オブジェクトを生成する
val doc = PDDocument()
// 追加したいサイズのページを生成して追加する
// (今回はA4サイズのページ)
doc.addPage(PDPage(PDRectangle.A4))

保存する

java - PDFBox document to InputStream - Stack Overflow

// 新規のPDFを作成、A4ページを追加してInputStreamに変換
val result = PDDocument().let { doc ->
    // A4のページを追加
    val page = PDPage(PDRectangle.A4)
    doc.addPage(page)

    // ByteArrayInputStreamに変換
    val out = ByteArrayOutputStream()
    doc.save(out)
    doc.close()
    ByteArrayInputStream(out.toByteArray())
}

// InputStreamを保存
File("output.pdf").writeBytes(result.readBytes())

ページを複製する

java - Can duplicating a pdf with PDFBox be small like with iText? - Stack Overflow

// PDFから1ページ目を取得する
val origin = COSDictionary(doc.documentCatalog.pages[0].cosObject).also{ it.removeItem(COSName.ANNOTS)}

// 必要なページ数分Documentに1ページ目のコピーを挿入する
repeat(pageList.size - 1){doc.importPage(PDPage(origin))}

PDFを編集する

文字を書く

フォントを指定する

Apache PDFBox で折り返しのある文章を表示する - A Memorandum
日本語用フォントは自前で読み込まないとならないため、上記記事を参考にIPAゴシックをDLして、resourcesに放り込んで読み込みます。

 // 組み込みフォントの指定(英数字のみ描画可)
val font = PDType1Font.HELVETICA_BOLD
// 日本語描画用のフォント読み込み(IPA Pゴシック)
val jpFont = PDType0Font.load(
    doc,
    object : Any() {}.javaClass.classLoader.getResourceAsStream("pdfbox/ipag.ttf")
)

文字列の高さ・幅を取得する

java - Get the font height of a character in PDFBox - Stack Overflow

val fontHeight = font.fontDescriptor.fontBoundingBox.height / 1000 * fontSize
val fontWidth = font.getStringWidth(text) / 1000 * fontSize

左寄せで文字を書く

val result = PDDocument().let { doc ->
    // A4のページを追加
    val page = PDPage(PDRectangle.A4)
    doc.addPage(page)

    // フォントの指定
    val font : PDFont = PDType1Font.HELVETICA_BOLD

    // 指定のページオブジェクトに文字の印字
    PDPageContentStream(doc, page).use { cs ->
        cs.beginText()
        cs.setFont(font, 12f)
        cs.newLineAtOffset(200f, 500f)
        cs.showText("Hello World")
        cs.endText()
    }

    // ByteArrayInputStreamに変換
    val out = ByteArrayOutputStream()
    doc.save(out)
    doc.close()
    ByteArrayInputStream(out.toByteArray())
}

折返しのある文字を書く

Apache PDFBox で折り返しのある文章を表示する - A Memorandum
PDFBoxでは領域を指定して自動で折り返すような処理ができないようなので、自分で文字幅を計算して折り返す必要があります。
PDType0Font::getStringWidth({文字列}, {フォントサイズ}) / 1000 * {フォントサイズ} で取得できるようです。

実装自体はかなり泥臭くて、

  1. 指定の文字幅を超えるまで文字を結合する
  2. 超えたら超える前の文字までを切り出す(繰り返し)
  3. 末尾になったら残りの文字を切り出す

といった手順で分割した文字列のリストを作ったあと、

  1. 文字の原点を決める
  2. 文字を1行分描画する
  3. フォントサイズ分字下げをする

の手順を繰り返しています。

// 指定のページオブジェクトに出力
PDPageContentStream(doc, page, PDPageContentStream.AppendMode.APPEND, true, true).use { cs ->
    // リソース内に配置したfontファイルを読み込む
    val font = PDType0Font.load(doc, this.javaClass.classLoader?.getResourceAsStream("pdfbox/ipag.ttf"))

    // 出力する文字列
    val text = "あのイーハトーヴォのすきとおった風、夏でも底に冷たさをもつ青いそら、うつくしい森で飾られたモリーオ市、郊外のぎらぎらひかる草の波。"
    // 出力するフォントサイズ
    val fontSize = 50f
          // 折返しの幅
    val width = 1000

          // 指定の文字幅に収まる文字数を計算してテキストを分割する
    var tempIndex = 0
    val lines = text.indices.mapNotNull {
        if (tempIndex > it) return@mapNotNull null
        if (font.getStringWidth(text.substring(tempIndex..it)) / 1000 * fontSize > width) {
            val result = text.substring(tempIndex until it)
            tempIndex = it
            return@mapNotNull result
        }

        // 末尾のテキストはすべて出力する
        if (it == text.length - 1) text.substring(tempIndex) else null
    }

    cs.beginText()
    // テキストの原点を指定
    cs.newLineAtOffset(0f, page.mediaBox.height - fontSize)
    // フォントの設定
    cs.setFont(font, fontSize)

 // 1行ずつ字下げしながら描画
    lines.forEach { s ->
        cs.showText(s)
        cs.setLeading(fontSize)
        cs.newLine()
    }
    cs.endText()
}

右寄せで文字を書く

右寄せでテキストを描画する場合にも自前で計算する必要があります。
先に終端のx, y座標を決めた上で、 font.getStringWidth(text) / 1000 * fontSize で配置する文字の文字幅を取得し、終点から文字幅を引いた座標を設定することで、右寄せのテキストを表現できます。

// 指定のページオブジェクトに文字列の出力
PDPageContentStream(doc, page, PDPageContentStream.AppendMode.APPEND, true, true).use { cs ->
    // リソース内に配置したfontファイルを読み込む
    val font = PDType0Font.load(doc, this.javaClass.classLoader?.getResourceAsStream("pdfbox/ipag.ttf"))

    // 出力する文字列
    val text = "右寄せ用テキスト"
    // 出力するフォントサイズ
    val fontSize = 50f
    val x = 1000f
    val y = 0f

    cs.beginText()
    // 印字する文字の幅を取得
    val stringWidth = font.getStringWidth(text) / 1000 * fontSize
    // 指定座標から文字幅を引いたx座標から文字を書く
    cs.newLineAtOffset(x - stringWidth, y)
    cs.setFont(font, fontSize)
    cs.showText(text)
    cs.endText()
}

中央寄せで文字を書く

中央寄せもまた、自前で計算する必要があります。処理自体はほぼ右寄せと同様です。 この実装では幅、高さの両方を指定座標を中心に設定しています。

// 指定のページオブジェクトに文字列の出力
PDPageContentStream(doc, page, PDPageContentStream.AppendMode.APPEND, true, true).use { cs ->
    // リソース内に配置したfontファイルを読み込む
    val font = PDType0Font.load(doc, this.javaClass.classLoader?.getResourceAsStream("ipag.ttf"))

    // 出力する文字列
    val text = "中央寄せ用テキスト"
    // 出力するフォントサイズ
    val fontSize = 50f

    val x = 1000f
    val y = 0f

    cs.beginText()
    // 印字する文字の幅・高さの半分を取得
    val halfWidth = font.getStringWidth(text) / 1000 * fontSize / 2
    val halfHeight = (font.fontDescriptor.fontBoundingBox.height / 1000 * fontSize / 2)

    // 指定座標から半分の文字幅・高さを引いた座標を起点に文字を書く
    cs.newLineAtOffset(x - halfWidth, y - halfHeight)
    cs.setFont(font, fontSize)
    cs.showText(text)
    cs.endText()
}

文字色を変更する

Apache PDFBoxでのテキスト装飾(ななめ・斜体・アンダーライン・文字間隔・白抜き文字・TTCフォントファイルの読み込み・ハイパーリンク) - Qiita
文字色は PDPageContentStream.setNonStrokingColorにColorを渡してやることで設定できます。
カラーコードから色を取るときはColor.decode("#6b7a8f")から取得すると良いようです。
線の色はstrokingColor、文字の色はnonStrokingColorで指定できるようですが、線の描画は試していません。

PDPageContentStream(doc, page, PDPageContentStream.AppendMode.APPEND, true, true).use { cs ->
    cs.beginText()

    cs.setFont(font, 12f)
    // 定数から指定
    cs.setNonStrokingColor(Color.RED)
    cs.newLineAtOffset(0f, 0f)
    cs.showText("Red")

    // カラーコードから指定
    cs.setNonStrokingColor(Color.decode("#F15B5B"))
    cs.newLineAtOffset(0f, 20f)
    cs.showText("#F15B5B")
    cs.endText()
}

画像を書き込む

ローカルの場合であればresourcesから取り出したInputStreamを使って画像を書き込むことができます。
Webなどの画像の場合はpath指定では貼り付けられないので、ByteArrayなどに変換して書き込みましょう。

PDPageContentStream(doc, page, PDPageContentStream.AppendMode.APPEND, true, true).use { cs ->
    // ローカルの画像を書き込む
    val realPath = object : Any() {}.javaClass.classLoader.getResource("image.png")?.path
    cs.drawImage(PDImageXObject.createFromFile(realPath, doc), 0f, 0f)

    // ローカル以外の画像を読み込むとき
    val imgByteArray = /* Webなどから画像をByteArrayとして取得する処理 */
    val img = PDImageXObject.createFromByteArray(doc, byteArray, null)
    this.drawImage(img, 100f, 100f)
}

描画する向きを回転させる

java - Pdfbox : Draw image in rotated page - Stack Overflow transformを変更することで、原点や描画の向きを変更して描画させることができます。
画像・テキストの描画の向きが変更されます。
変更した向きはそのままになってしまうので、元に戻したい場合は、

  • 事前に PDPageContentStream::saveGraphicsState()で一度状態を保存
  • 書き込みが終わったあとに PDPageContentStream::restoreGraphicsState() で戻す

をすると良いです。

// 画像を埋め込む
PDPageContentStream(doc, page, PDPageContentStream.AppendMode.APPEND, true, true).use { cs ->
    // 180度回転させて原点(translate)を右上に置いた領域情報を作成する
    val matrix = Matrix.getRotateInstance(Math.toRadians(180.0), 0f, 0f)
        .also { it.translate(-page.mediaBox.width, -page.mediaBox.height) }

    cs.saveGraphicsState()
    cs.transform(page.matrix)
    cs.drawImage(PDImageXObject.createFromFile(realPath, doc), 0f, 0f)
    cs.restoreGraphicsState()
}

トラブルシュート

既存のPDFを編集すると真っ白になってしまう

PDFBoxを使って既存のPDF文書に線を引く - Qiita
PDPageContentStreamのデフォルトが PDPageContentStream.AppendMode.OVERWRITE で中身を初期化した上で書き込んでしまうため、 PDPageContentStream.AppendMode.APPENDで上書きモードで書き込むと良いです。
最後のbooleanはページの中身を圧縮描画するかのフラグのようで、出力したい内容に応じて変えてください。

// OVERWRITEになる
val cs1 = PDPageContentStream(doc, page)

val cs2 = PDPageContentStream(doc, page, PDPageContentStream.AppendMode.APPEND, false)

定数にないレイアウトのページを追加する

PDFBoxサンプル(Hishidama's Apache PDFBox Example)
定数にないA3横やA4横などのページは、自分でサイズを指定して生成してあげる必要があります。

// A3の縦横を入れ替えて新規ページを生成
val page = PDPage(PDRectangle(PDRectangle.A3.height, PDRectangle.A3.width))

PDFに文字を書き込んだら鏡文字になってしまった

grails - Text is reverse in generated pdf - Stack Overflow
編集元のPDFに何らかの処理がかかっている場合このような状態になってしまうようです。
PDPageContentStreamに更に追加の引数として resetContext というものがあり、このフラグをtrueにしてあげると通常の向きで印字できるようになります。

val cs = PDPageContentStream(doc, page, PDPageContentStream.AppendMode.APPEND, true, true)

指定のフォントで描画できない文字を判定したい

java - PDFBox hasGlyph() returns true for unsupported unicode control characters - Stack Overflow
読み込んでみてエラーが出るかどうかで判定するしかなさそうでした。
呼び出し元で1文字ずつ判定し、空白などに置換する形で対応しました。

fun isWritableChar(c: Char, font : PDFont): Boolean = try {
    font.encode(c.toString())
    true
} catch (e: IllegalArgumentException) {
    // 未収録の文字列はIllegalArgumentExceptionを吐くのでfalseで返す
    false
}

拡張関数

Sandbox-Kotlin/src/main/kotlin/pdfbox at master · beatdjam/Sandbox-Kotlin · GitHub
Sample.ktが利用例、PDFBoxExt.ktが拡張関数の入っているファイルになります。
ご自由にご利用ください。役に立ったらコメントとかもらえたら喜びます。