前置き
Apache PDFBox は、JavaでPDFを操作するためのライブラリです。
PDFBoxは低レベルなAPI群で構成されているため、段落の処理や文字の折返しなどの処理が組み込まれていなかったりします。
今回、KotlinでPDFの編集を行う必要があったのですが、Javaで実装している記事が殆どで、Kotlinの記事はほぼ見当たりませんでした。
また、調べるにあたって様々なサイト参照する必要があり、情報があまり集約されていませんでした。
日本語の記事も多くはないため、備忘も含めて調べた内容をサンプルコードとともに列挙したいと思います。
線の描画は今回取り扱わなかったため含まれていません。もしいずれ実装する機会があったら追加します。
末尾に、自分なりに実装を整理した拡張関数群をリンクします。
場合によっては最適な実装ではないかもしれませんが…
ライブラリ読み込み
今回はMavenを利用します。
Gradleを利用する場合は適宜読み替えてください。
実装を書いた時点では2.0.22が最新だったので参照していますが、記事執筆時点では2.0.23が最新みたいです。
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>2.0.22</version>
</dependency>
PDFを操作する
既存のPDFを読み込む
val template = this.javaClass
.classLoader
.getResourceAsStream("sample.pdf")
val doc = PDDocument.load(template)
新しいPDFを生成する
val doc = PDDocument()
doc.addPage(PDPage(PDRectangle.A4))
保存する
java - PDFBox document to InputStream - Stack Overflow
val result = PDDocument().let { doc ->
val page = PDPage(PDRectangle.A4)
doc.addPage(page)
val out = ByteArrayOutputStream()
doc.save(out)
doc.close()
ByteArrayInputStream(out.toByteArray())
}
File("output.pdf").writeBytes(result.readBytes())
ページを複製する
java - Can duplicating a pdf with PDFBox be small like with iText? - Stack Overflow
val origin = COSDictionary(doc.documentCatalog.pages[0].cosObject).also{ it.removeItem(COSName.ANNOTS)}
repeat(pageList.size - 1){doc.importPage(PDPage(origin))}
PDFを編集する
文字を書く
フォントを指定する
Apache PDFBox で折り返しのある文章を表示する - A Memorandum
日本語用フォントは自前で読み込まないとならないため、上記記事を参考にIPAゴシックをDLして、resourcesに放り込んで読み込みます。
val font = PDType1Font.HELVETICA_BOLD
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 ->
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()
}
val out = ByteArrayOutputStream()
doc.save(out)
doc.close()
ByteArrayInputStream(out.toByteArray())
}
折返しのある文字を書く
Apache PDFBox で折り返しのある文章を表示する - A Memorandum
PDFBoxでは領域を指定して自動で折り返すような処理ができないようなので、自分で文字幅を計算して折り返す必要があります。
PDType0Font::getStringWidth({文字列}, {フォントサイズ}) / 1000 * {フォントサイズ}
で取得できるようです。
実装自体はかなり泥臭くて、
- 指定の文字幅を超えるまで文字を結合する
- 超えたら超える前の文字までを切り出す(繰り返し)
- 末尾になったら残りの文字を切り出す
といった手順で分割した文字列のリストを作ったあと、
- 文字の原点を決める
- 文字を1行分描画する
- フォントサイズ分字下げをする
の手順を繰り返しています。
PDPageContentStream(doc, page, PDPageContentStream.AppendMode.APPEND, true, true).use { cs ->
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)
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 ->
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
cs.newLineAtOffset(x - stringWidth, y)
cs.setFont(font, fontSize)
cs.showText(text)
cs.endText()
}
中央寄せで文字を書く
中央寄せもまた、自前で計算する必要があります。処理自体はほぼ右寄せと同様です。
この実装では幅、高さの両方を指定座標を中心に設定しています。
PDPageContentStream(doc, page, PDPageContentStream.AppendMode.APPEND, true, true).use { cs ->
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 =
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 ->
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はページの中身を圧縮描画するかのフラグのようで、出力したい内容に応じて変えてください。
val cs1 = PDPageContentStream(doc, page)
val cs2 = PDPageContentStream(doc, page, PDPageContentStream.AppendMode.APPEND, false)
定数にないレイアウトのページを追加する
PDFBoxサンプル(Hishidama's Apache PDFBox Example)
定数にないA3横やA4横などのページは、自分でサイズを指定して生成してあげる必要があります。
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) {
false
}
拡張関数
Sandbox-Kotlin/Sandbox/src/main/kotlin/pdfbox at master · beatdjam/Sandbox-Kotlin · GitHub
Sample.ktが利用例、PDFBoxExt.ktが拡張関数の入っているファイルになります。
ご自由にご利用ください。役に立ったらコメントとかもらえたら喜びます。