📄

PdfBox-Android を使って PDF を編集する

2023/09/21に公開

PdfBox-Android は PDF を編集するためのライブラリです。
Java に PDFBox という同様のライブラリが存在しますが、PdfBox-Android はこの PDFBox を Android でも利用できるようにしたものです。

最近、このライブラリを使用する機会がありましたので、その使い方や気をつけるべきポイントについて紹介します。
サンプルプロジェクトを公開していますので、こちらも参考にしてください。

セットアップ

まずは、build.gradleに以下の依存関係を追加します。

dependencies {
    implementation 'com.tom-roush:pdfbox-android:2.0.27.0'
}

さらに、PdfBox の機能を使用する前に、一度だけ初期化処理を行う必要があります。
この処理は、Application クラスの onCreate メソッド内や、PDF編集を開始する前の任意のタイミングで呼び出すことが推奨されます。

PDFBoxResourceLoader.init(applicationContext)

PDF編集の基本フロー

編集処理は次のように進めていきます。各ページに対して個別に編集が可能です。

val srcFile: File = ... // 編集対象のPDFファイル

// PDFファイルを読み込んで編集する
PDDocument.load(srcFile).use { document ->
    val pdPage0: PDPage = document.getPage(0) // PDFファイルの最初のページを取得
    PDPageContentStream(
        document,
        pdPage0,
        PDPageContentStream.AppendMode.APPEND, // コンテンツをPDFに上書きする場合は、APPENDモードを選択
        true,  // ページのコンテンツを圧縮する設定
        true // グラフィックコンテキストをリセット(変換行列が不正な動作をする場合があるため、true推奨)
    ).use { contentStream: PDPageContentStream ->
        // ここで contentStream を使用して PDF を編集する
    }

    // 他のページも編集する場合は、同様に行う
    val pdPage1: PDPage = document.getPage(1)
    val content = PDPageContentStream(...).use { content ->
        ...
    }

    val destFile: File = ... // 編集後のPDFファイルの保存先
    document.save(destFile)
}

注意点としては PDPageContentStream のインスタンスを作成する際の引数です。
第三引数に PDPageContentStream.AppendMode.APPEND を指定していますが、線やテキストなどのコンテンツを PDF に上書きする際にはこちらを設定する必要があります。

また、第五引数の true ですがこちらはグラフィックコンテキストをリセットするかどうかを指定します。true にしないと描画したコンテンツの座標が意図しない結果になることがあったため、true にすることを推奨します。

PDFに線を描画する

PDFに線を追加する場合、以下のようにコードを記述します。

// 線の色を設定
contentStream.setStrokingColor(1.0f, 0.0f, 0.0f)
// 線の太さを設定
contentStream.setLineWidth(2.0f)

val width = pdPage.mediaBox.width
val height = pdPage.mediaBox.height
contentStream.moveTo(0f, 0f)
contentStream.lineTo(width / 2, height)
contentStream.lineTo(width, 0f)

contentStream.stroke()

// 2本目の線の色を設定
contentStream.setStrokingColor(0.0f, 0.0f, 1.0f)

contentStream.moveTo(0f, height)
contentStream.lineTo(width / 2, 0f)
contentStream.lineTo(width, height)

contentStream.stroke()

PDPageContentStream クラスのインスタンスを使用して、線を描画します。
setStrokingColor メソッドで線の色を、setLineWidth メソッドで線の太さを指定します。
moveTo メソッドで始点の座標を指定し、lineTo メソッドで線を引きます。最後に stroke メソッドを呼び出すことで、線が描画されます。

また、PDF の各ページの幅と高さは、PDPage クラスの mediaBox.widthmediaBox.height で取得できます。
注意点として、PDF の座標系は原点が左下、x軸が右方向、y軸が上方向に設定されています。

このようにしてPDFを保存すると、以下のような結果が得られます。

座標変換を適用して矩形を描画する

// 線の色を設定
contentStream.setStrokingColor(0.0f, 0.0f, 1.0f)
// 塗りつぶしの色を設定
contentStream.setNonStrokingColor(1.0f, 0.0f, 0.0f)
// 線の太さを設定
contentStream.setLineWidth(4.0f)

val w = 100f
val h = 150f
contentStream.addRect(0f, 0f, w, h)

val m = Matrix()
// 矩形の中心を原点に移動
m.postTranslate(-w / 2, -h / 2) 
// 拡大
m.postScale(2f, 2f)
// 回転
m.postRotate(30f)
// PDFの中心に矩形の中心を移動
m.postTranslate(pdPage.mediaBox.width / 2, pdPage.mediaBox.height / 2)
// 変換行列を適用
contentStream.transform(m.toPdfMatrix())

contentStream.fillAndStroke() // 枠線と塗りつぶし
// contentStream.fill() // 塗りつぶしのみ
// contentStream.stroke() // 枠線のみ

矩形を描画するには addRect メソッドを使用します。
fillAndStroke は枠線と内部の塗りつぶしを行い、fill は内部の塗りつぶしのみ、stroke は枠線のみを描画します。

座標変換は、transform メソッドに行列を指定することで実行できます。
このメソッドには com.tom_roush.pdfbox.util.Matrix 型の行列を渡しますが、この行列の操作は android.graphics.Matrix に比べて扱いにくいと感じました。
具体的には、両方の行列とも3x3の行列を表現する型ですが、行列の要素の配置が異なっています。

そのため、行列の計算は android.graphics.Matrix で行い、計算が終わった後で com.tom_roush.pdfbox.util.Matrix に変換しています。
以下はその変換処理で、行列を転置しています。

typealias PdfMatrix = com.tom_roush.pdfbox.util.Matrix
typealias GraphicMatrix = android.graphics.Matrix

/**
 * [android.graphics.Matrix] を [com.tom_roush.pdfbox.util.Matrix] に変換する.
 */
fun GraphicMatrix.toPdfMatrix(): PdfMatrix {
    val tmp = FloatArray(size = 9)
    // android.graphics.Matrix は一次元配列を使って以下のインデックスの値で3x3行列を表現している
    // 0 1 2
    // 3 4 5
    // 6 7 8
    getValues(tmp)

    // com.tom_roush.pdfbox.util.Matrix は一次元配列を使って以下のインデックスの値で3x3行列を表現している
    // 0 3 6
    // 1 4 7
    // 2 5 8
    return PdfMatrix(tmp[0], tmp[3], tmp[1], tmp[4], tmp[2], tmp[5])
}

PDFにテキストを描画する

// テキスト描画の処理を開始
contentStream.beginText()

// フォントとそのサイズを設定
contentStream.setFont(PDType1Font.HELVETICA_BOLD, 32f)
contentStream.setNonStrokingColor(1.0f, 0.0f, 0.0f)
val fontMetrics1 = FontMetrics(PDType1Font.HELVETICA_BOLD, 32f)
// テキストの描画位置を調整(フォントのdescentの分だけ下げる)
contentStream.newLineAtOffset(0f, -fontMetrics1.descent)
contentStream.showText("Hello, PdfBox-Android!!!")

val texts = "AAAAA\nBBBBB"
contentStream.setFont(PDType1Font.TIMES_BOLD, 64f)
contentStream.setNonStrokingColor(0.0f, 1.0f, 0.0f)
contentStream.newLineAtOffset(pdPage.mediaBox.width / 2, pdPage.mediaBox.height / 2)
val fontMetrics2 = FontMetrics(PDType1Font.TIMES_BOLD, 64f)
// 改行の高さを設定(この場合、フォントの高さと同じ)
contentStream.setLeading(fontMetrics2.height)

// 改行文字を含む場合、テキストを分割して一行ずつ描画
texts.split("\n").forEach { text ->
    contentStream.showText(text)
    // 改行する
    contentStream.newLine()
}

// テキスト描画処理を終了
contentStream.endText()
private data class FontMetrics(
    val ascent: Float,
    val descent: Float
) {
    constructor(font: PDFont, fontSize: Float) : this(
        font.fontDescriptor.ascent * fontSize / 1000,
        font.fontDescriptor.descent * fontSize / 1000,
    )

    val height: Float = abs(descent - ascent)
}

テキストを描画するには、beginText メソッドで描画処理を開始します。
次に、setFont メソッドを使用してフォントとそのサイズを設定します。
この例では、PdfBox-Android に組み込まれている PDType1Font.HELVETICA_BOLDPDType1Font.TIMES_BOLD などのフォントを使用しています。

テキストの描画位置は newLineAtOffset メソッドで設定し、showText メソッドで描画するテキストを指定します。
showText で改行文字が含まれていると、例外が発生するので注意が必要です。
改行文字が含まれる場合は、テキストを改行で分割して一行ずつ描画します。
その際、setLeading メソッドで改行の高さを設定し、newLine メソッドで改行します。
setLeading に設定する値は一般的にフォントの高さです。
newLine が呼び出された後、次の showText での描画位置は setLeading で設定した高さ分下がります。

この手順に従ってPDFを保存すると、以下のような出力になります。

座標変換を適用してテキストを描画する

val text = "Hello, PdfBox-Android!!!"
val fontSize = 32f
// テキスト描画処理の開始
contentStream.beginText()

// フォントとそのサイズを設定する
contentStream.setFont(PDType1Font.HELVETICA_BOLD, fontSize)
contentStream.setNonStrokingColor(1.0f, 0.0f, 0.0f)
val fontMetrics = FontMetrics(PDType1Font.HELVETICA_BOLD, fontSize)

val textWidth = PDType1Font.HELVETICA_BOLD.getStringWidth(text) * fontSize / 1000
val textHeight = fontMetrics.height

val m = Matrix()
// 座標をテキストの中心に移動
m.postTranslate(-textWidth / 2, -textHeight / 2)
// 135度回転
m.postRotate(135f)
// ページの中心に移動
m.postTranslate(pdPage.mediaBox.width / 2, pdPage.mediaBox.height / 2)

// テキストの座標変換行列を設定
contentStream.setTextMatrix(m.toPdfMatrix())
// テキストの描画位置を調整(フォントのdescentの分だけ下げる)
contentStream.newLineAtOffset(0f, -fontMetrics.descent)
contentStream.showText(text)

// テキスト描画処理の終了
contentStream.endText()

矩形に座標変換を適用する際に transform メソッドを使用したのと同様に、テキストの座標変換では setTextMatrix メソッドを使用します。

日本語のテキストを描画する

上記の例では、PdfBox-Android に組み込まれているフォントを使用しましたが、これらのフォントは日本語をサポートしていません。したがって、日本語を表示するには別途日本語フォントをロードする必要があります。

// 日本語のフォントをロード
val font = PDType0Font.load(pdDocument, assetManager.open("NotoSansJP-Regular.ttf"))
val fontSize = 32f
val fontMetrics = FontMetrics(font, fontSize)

contentStream.beginText()
contentStream.setFont(font, fontSize)
contentStream.setNonStrokingColor(1.0f, 0.0f, 0.0f)
contentStream.newLineAtOffset(0f, -fontMetrics.descent)
contentStream.showText("こんにちは, PdfBox-Android!!!")

contentStream.setNonStrokingColor(0.0f, 1.0f, 0.0f)
contentStream.newLineAtOffset(pdPage.mediaBox.width / 2, pdPage.mediaBox.height / 2)
contentStream.showText("あいうえお")
contentStream.setLeading(fontMetrics.height)
contentStream.newLine()

contentStream.setNonStrokingColor(0.0f, 0.0f, 1.0f)
contentStream.showText("かきくけこ")

contentStream.endText()

assets ディレクトリに NotoSansJP-Regular.ttf を配置し、PDType0Font.load メソッドでフォントをロードしています。このようにすると、日本語のテキストをPDFに書き込むことが可能になります。

1行に複数の言語が含まれるテキストを描画する

上記の例では日本語を出力することができましたが、フォントの設定はテキスト1行単位にしか行うことができません。
したがって1行のテキストに日本語とタイ語が含まれている場合、日本語のフォントを設定するとタイ語が出力できません。また、タイ語のフォントを設定すると日本語の出力ができないことになります。

このような事態に対処するために、1行ごとにテキストを出力するのではなく、1文字ごとにテキストを出力させることで解決することができます。

// 日本語のフォントをロード
val jpFont = PDType0Font.load(pdDocument, assetManager.open("NotoSansJP-Regular.ttf"))
// タイ語のフォントをロード(システムのフォントを参照)
val thaiFont = PDType0Font.load(pdDocument, File("/system/fonts/NotoSansThaiUI-Regular.ttf"))
val fonts = listOf(jpFont, thaiFont)
val fontSize = 32f
val jpFontMetrics = FontMetrics(jpFont, fontSize)
// 改行とサロゲートペアの文字を含んだテキスト
val texts = "こんにちは、สวัสดี\nปลาแมคเคอเรลอัตกะ、\uD867\uDE3D" // \uD867\uDE3D で 𩸽

// 改行文字ごとに分割してテキストを出力する
texts.split("\n").forEachIndexed { index, text ->
    contentStream.beginText()
    contentStream.setNonStrokingColor(1.0f, 0.0f, 0.0f)
    contentStream.newLineAtOffset(0f, pdPage.mediaBox.height / 2 - index * jpFontMetrics.height)

    // テキストを見た目の文字(書記素クラスタ)ごとに PDF に出力
    text.divideByGraphemeCluster().forEach { s ->
        // 1文字ごとに日本語とタイ語で出力できるか試す
        for (font in fonts) {
            try {
                contentStream.setFont(font, fontSize)
                contentStream.showText(s)

                // 1文字ごとに文字幅を取得し、次の表示位置を設定
                val width = font.getStringWidth(s) * fontSize / 1000
                contentStream.newLineAtOffset(width, 0f)
                break
            } catch (e: IllegalArgumentException) {
                Log.w("PdfEditor", "テキスト出力失敗: $s")
            }
        }
    }

    contentStream.endText()
}

上記のように1文字ごとに日本語とタイ語のフォントでテキストが出力できるかどうかを試します。
そして1文字ごとに font.getStringWidth メソッドで文字幅を取得し newLineAtOffset メソッドで表示位置を更新するようにしています。

注意点としては1文字ごと出力するにあたって「見た目の文字単位」で文字を出力している点です。
Kotlin では String に対して forEach 拡張関数が使えますが、これは Char 単位で分割されるためサロゲートペアの文字に対応できません。

そのため、以下のような拡張関数を用意して見た目の文字単位で文字を処理できるようにしています。

import com.ibm.icu.text.BreakIterator

/**
 * 見た目上の文字(書記素クラスタ)単位で分割したリストを返す
 */
fun String.divideByGraphemeCluster(): List<String> {
    val iterator = BreakIterator.getCharacterInstance()
    iterator.setText(this)

    val result = mutableListOf<String>()
    var start = iterator.first()
    var end = iterator.next()
    while (end != BreakIterator.DONE) {
        result.add(substring(start, end))
        start = end
        end = iterator.next()
    }

    return result
}

com.ibm.icu.text.BreakIterator を使うためには、build.gradle に icu4j の設定が必要です。

dependencies {
    implementation "com.ibm.icu:icu4j:73.2"
}

絵文字などのテキストを画像として描画する

現状では PDFBox ではカラフルな絵文字のフォントをロードしてテキストとして出力することができないようです。

※ ただし NotoEmoji-Regular.ttf のようなモノクロの絵文字フォントなら出力可能です

この制約を回避するために、絵文字を画像に変換し、その画像をPDFに埋め込む方法を紹介します。

// 日本語のフォントをロードする
val font = PDType0Font.load(pdDocument, assetManager.open("NotoSansJP-Regular.ttf"))
val fontSize = 32f
val fontMetrics = FontMetrics(font, fontSize)
val text = """こんにちは😀안녕하세요🫠あいうえお"""

contentStream.beginText()
contentStream.setFont(font, fontSize)
contentStream.setNonStrokingColor(1.0f, 0.0f, 0.0f)
contentStream.newLineAtOffset(0f, -fontMetrics.descent)

val textImagePainters = mutableListOf<TextImagePainter>()
var totalWidth = 0f

text.divideByGraphemeCluster().forEach { s ->
    val width = try {
        contentStream.showText(s)

        font.getStringWidth(s) * fontSize / 1000
    } catch (e: IllegalArgumentException) {
        Log.w("PdfEditor", "テキスト出力失敗: $s")

        // テキストの出力に失敗した場合、画像として出力
        val textImagePainter =
            TextImagePainter.create(pdDocument, text = s, textSize = fontSize, textColor = Color.RED, x = totalWidth, y = 0f)
        // beginText ~ endText までの間に画像描画処理を呼び出すと例外が発生するため、画像の描画に必要な情報を配列に入れておく
        textImagePainters.add(textImagePainter)
        textImagePainter.imageWidth
    }

    contentStream.newLineAtOffset(width, 0f)
    totalWidth += width
}

contentStream.endText()

// 画像の描画処理
textImagePainters.forEach {
    it.drawImage(contentStream)
}

上記のコード例では、日本語のフォントをロードしていますが、その他の文字や絵文字が含まれている場合には、それらを画像として出力しています。
注意点として、beginText()endText() の間で画像描画処理を呼び出すと例外が発生するため、endText() が呼ばれた後に画像描画を行っています。

TextImagePainter の具体的な実装についてはこちらのソースコードを参照してください。

上記のコード例を PDF に出力した場合、フォントをロードしている日本語はテキストとして出力され、フォントをロードしていない絵文字や韓国語は画像として出力されます。

まとめ

今回は PdfBox-Android を用いて、特に絵文字などの特殊な文字を画像として PDF に出力する方法について詳しく説明しました。この手法は、PDFBox が標準でサポートしていない文字や絵文字を出力する必要がある場合に非常に有用かと思います。

もし問題が発生した場合や、さらに詳しい情報が必要な方は、公式ドキュメントGitHubのリポジトリ を参照してください。

このブログが皆さんの参考になれば幸いです!

もっとアルダグラムエンジニア組織を知りたい人、ぜひ下記の情報をチェックしてみてください!

アルダグラム Tech Blog

Discussion