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

【Kotlin】画像をリサイズしたり切り抜いたりする

仕事で、URLから画像を取得していい感じにリサイズする要件があったため調べました。
備忘のためメモを書いておきます。

URLから画像の取得

今回はURLから画像を取得して編集することが要件だったので、まずはURLから画像を取得する処理です。
後々の取り回しのためにByteArrayで取得しておきます。

import java.net.HttpURLConnection
import java.net.URL

fun getImageBytes(url: String): ByteArray {
    lateinit var conn: HttpURLConnection
    return try {
        conn = URL(url).openConnection() as HttpURLConnection
        conn.requestMethod = "GET"
        conn.inputStream.readBytes()
    } finally {
        conn.disconnect()
    }
}

画像の加工

BufferedImageへ変換

取得した画像を編集するために、BufferedImageに変換します。

// 画像読み込み
javax.imageio.ImageIO.read(imageBytes.inputStream())

画像のリサイズ

縦横の幅を指定して新たなBufferedImageとして描画し直すことでリサイズできます。

import java.awt.image.BufferedImage

fun resize(image: BufferedImage, width: Int, height: Int): BufferedImage =
    BufferedImage(width, height, BufferedImage.TYPE_INT_RGB).also {
        val g = it.createGraphics()
        g.drawImage(image, 0, 0, width, height, null)
        g.dispose()
    }

画像の切り抜き

BufferdImage.getSubimage() を使うと、画像の一部を切り出すことができます。
x, yで切り抜きの始点を指定し、そこからwidthとheightで指定した分の画像を切り抜いた新しいBufferdImageを生成します。
このとき、指定した座標などが画像の範囲外になるとエラーになるので注意してください。

image.getSubimage(x, y, width, height)

画像の保存

BufferdImageはOutputStreamやFileのようなオブジェクトに書き込むことで保存できます。

// ByteArrayOutputStreamに書き込んでByteArrayに変換する場合
ByteArrayOutputStream().let {
        ImageIO.write(croppedImage, "PNG", it)
        it.toByteArray()
    }

// Fileオブジェクトに直接書き込む場合
ImageIO.write(croppedImage, "PNG", File("ファイル名.png")

(おまけ)画像をいい感じにリサイズして切り抜く

今回求められていた要件は、与えられた画像を指定のサイズに余白なく収まるようにリサイズした上で、中心を基準に画像を切り抜くというものでした。
文字だけだと伝わりづらいと思うので例を示すとこんな感じです。
f:id:beatdjam:20210321050258p:plain
f:id:beatdjam:20210321050313p:plain
f:id:beatdjam:20210321050328p:plain

この処理を実装するためには、

  • 元画像と描画先の比率からリサイズの倍率を求めてリサイズする
  • 描画領域に応じて切る方向を決める
  • 中心から適切に画像を切り抜く

という手順が必要でした。

欲しいサイズに合わせてリサイズ

縦、横それぞれの元画像、編集後の比率を求めて、差の大きい方を基準の倍率としてリサイズします。

import java.awt.image.BufferedImage
import kotlin.math.ceil

fun resize(image: BufferedImage, width: Int, height: Int): BufferedImage {
    // 横幅・縦幅の比率を計算して、大きい方を基準にリサイズする
    val widthScale = width.toDouble() / image.width.toDouble()
    val heightScale = height.toDouble() / image.height.toDouble()
    val scale = if (widthScale > heightScale) widthScale else heightScale

    val resizeWidth = ceil(image.width * scale).toInt()
    val resizeHeight = ceil(image.height * scale).toInt()
    return BufferedImage(resizeWidth, resizeHeight, BufferedImage.TYPE_INT_RGB).also {
        val g = it.createGraphics()
        g.drawImage(image, 0, 0, resizeWidth, resizeHeight, null)
        g.dispose()
    }
}

中心から画像を切り抜く

元画像と出力結果の向きから画像の切り取り方向を決定し、切り抜きます。
切り抜く座標の原点は画像の中心から出力結果の半分を引くことで求めることができます。

import java.awt.image.BufferedImage

fun crop(image: BufferedImage, width: Int, height: Int): BufferedImage? {
    // 元画像が横長か縦長かを判定
    val isHorizontalImage = image.width > image.height
    // 出力結果が縦長か横長かを判定
    val isHorizontalView = width > height
    return when {
        // 横長の画像を縦長のViewに表示する場合
        isHorizontalImage && !isHorizontalView -> {
            val x = (image.width.toDouble() / 2 - width.toDouble() / 2).toInt()
            image.getSubimage(x, 0, width, height)
        }
        // 縦長の画像を縦長のViewに表示する場合
        else -> {
            val y = (image.height.toDouble() / 2 - height.toDouble() / 2).toInt()
            image.getSubimage(0, y, width, height)
        }
    }
}

サンプル実装

おまけで記載している処理については下記のファイルに一通りが記載されています。
手元で実行すれば、冒頭であげた画像のような出力結果が得られます。
sandbox/20210320.kt at 686f532c6dbddda43262df1673e015870a5bf5ad · beatdjam/sandbox · GitHub

おわりに

Java標準のライブラリを使って、思いの外自由に画像の編集が行えることがわかりました。
ググって出てくるのはほとんどJavaの実装だったので、Kotlinで同じようなことをしてみたい方の役に立てばいいなと思っています。
おまけの部分の実装は、思いついた実装でどうにかしたという感じなので、より良い方法をご存知の方はコメントやTwitterなどで教えて下さい。

【Kotlin】範囲同士の重なりの有無を判定する

2つの期間が重なり合うかどうかを判定する。 - こせきの技術日記
によると、 始点2 <= 終点1 && 始点1 <= 終点2 で網羅できるらしい。

雑にいくつかテスト書いてみたけどたしかに大丈夫だった。すごい。

以下余談。
雑に書いたコードをコミットして適当にブログに上げるようにsandboxリポジトリを作ってみた。
日付のファイルを作って適当に書いてここに引用する感じで運用できたらいいなあ。

Gitのファイルをブログに表示するために gist-it.appspot.com - Embed files from a github repository like a gist を使ってみた。
野良サービスなので突然消えたら怖いな…と思うものの、便利なのでしばらく利用していってみる。

IntelliJで新しくプロジェクトを作ったら build.gradle.kts が生成されてびっくりした。
testブロックの書き方がこうなのに気づかず、しばらくハマってしまった…

tasks.withType<Test> {
    useJUnitPlatform {
        includeEngines("junit-jupiter")
    }
}