PDFファイル中のテキストをKotlinでなんとなく置換してみるmmj blog

エンジニア 伊藤
2021年10月02日


プログラムから帳票を出力する場合、PDFファイルを使用することは多いと思います。
そこで、あらかじめデザインだけ作成したPDFファイルのテキストをKotlinのプログラムで置換し帳票を出力できないかと思い調べてみました。

Office365ならExcelファイルをPDFファイルとしてエクスポートできるようなので、これでPDFファイルのデザインをあらかじめ作成します。
次にプログラムでPDFファイルを読み込みテキストを置換すればよい、と思ったのですが、PDFファイルを直接編集するのはいろいろと課題があり難しそうです。
というのも、PDFファイルにはどの環境でも表示できるようフォントを埋め込む機能があるのですが、サイズ削減のためフォントの一部(サブセット)を埋め込んでいると全てのフォントが使用できない場合があるようです。例えば、元のPDFファイルに「あいうえお」とだけ書き込まれている場合に「かきくけこ」と追記しようとしてもフォントが無いため追記できないことになります。

そこで、PDFファイルを読み込みながらその内容を新しいPDFファイルに出力することで同じようなデザインで出力できないか試してみました。この方法だと厳密に同じファイルを出力するのは難しいですが、出力する際に自由に編集することが可能になります。

準備

PDFの読み書きにはPDFBoxというライブラリを使用します。

https://pdfbox.apache.org/

まずbuild.gradle.ktsにライブラリの情報を追加し利用できるようにします。

dependencies {
    implementation("org.apache.pdfbox:pdfbox:2.0.24")
}

PDFファイルの読み込みにはPDDocument.loadメソッドを使用します。

//import org.apache.pdfbox.pdmodel.PDDocument

  PDDocument.load(file).use { document ->
  }

次に、フォントを利用できるようにします。
一般的なフォントだと日本語に対応していないものが多いため、IPAの公開しているフォントを使用します。
以下のページからダウンロードしたフォントのファイル(ipaexg.ttf)を保存し、プログラムで読み込んで使用します。
https://moji.or.jp/ipafont/ipaex00401/

※ .ttf形式のフォントは使用できますが.ttc形式のフォントは使用できないようです。

ファイルからのフォントの読み込みは以下のようなコードになります。

  val font = PDType0Font.load(doc, File("ipaexg.ttf"))

PDFの内部構造

PDFファイルの内部構造はページ(PDPage)があり、その中にトークンがあるという構造になっています。
トークンには処理(Operator)とパラメータがあります。例えば、線を引く場合、開始位置を指定、終了位置を指定、線を引くという3つの処理により線の情報が記録されています。

トークンはPDFStreamParserで読み込むことができます。

  val parser = PDFStreamParser(page)
  parser.parse()

  val tokens = parser.tokens

  for (tokenIndex in tokens.indices) {
    val token = tokens[tokenIndex]
    if (token is Operator) {
      println("  operator=" + token.name)
    }
  }

具体的な処理の内容はOperator.nameで判定できます。処理の一覧はOperatorNameクラスに定義されています。
テキストを出力する処理は、

  1. 1.テキスト出力開始
  2. 2.テキストの座標を指定
  3. 3.フォントとサイズを指定
  4. 4.テキスト書き込み
  5. 5.テキスト出力終了

と5回の処理で記録されています。ややこしいですね!

例えば、座標の情報を読み込む場合は以下のような処理になります。

    when (token.name) {
      OperatorName.MOVE_TEXT -> {
        val x = (tokens[tokenIndex - 2] as COSNumber).floatValue()
        val y = (tokens[tokenIndex - 1] as COSNumber).floatValue()
      }
    }

注意点として、その処理に対するパラメータは処理の前のトークンにあります。このため、『テキストの座標を指定』する処理があったらその前の2つのトークンを取得するとそれぞれX座標、Y座標になります。ややこしくてちゃんと説明できてるか自信がなくなってきました…

PDFの出力はPDPageContentStreamを使用します。PDFの内容を読み取り・解析しながら同じ内容をPDPageContentStreamに出力すれば(なんとなく)同じPDFが出力できるというわけです。

テキストの出力は以下のようなコードで実現できます。

        val page2Stream = PDPageContentStream(doc2, page2)
        page2Stream.beginText()
        page2Stream.newLineAtOffset(x, y)
        page2Stream.setFont(fontDefault, fontSize)
        page2Stream.showText(buffer2)
        page2Stream.endText()

実装

最終的に、PDFの内容を読み取りながら出力するのは以下のコードで実現できました。
動作確認のため、replacePdfTextで適当にテキストの置換をおこなっています。

    when (token.name) {
      //テキスト
      OperatorName.BEGIN_TEXT -> {
        page2Stream.beginText()
      }
      OperatorName.MOVE_TEXT -> {
        val x = (tokens[tokenIndex - 2] as COSNumber).floatValue()
        val y = (tokens[tokenIndex - 1] as COSNumber).floatValue()
        page2Stream.newLineAtOffset(x, y)
      }
      OperatorName.SET_MATRIX -> {
        val ary = COSArray()
        ary.addAll((6 downTo 1).map {
          tokens[tokenIndex - it] as COSNumber
        })
        page2Stream.setTextMatrix(Matrix(ary))
      }
      OperatorName.SET_FONT_AND_SIZE -> {
        val fontNameObj = tokens[tokenIndex - 2] as COSName
        val fontSizeObj = tokens[tokenIndex - 1] as COSNumber
        font = page.resources.getFont(fontNameObj)
        val fontSize = fontSizeObj.floatValue()
        page2Stream.setFont(fontDefault, fontSize)
      }
      OperatorName.SHOW_TEXT -> {
        val previous = tokens[tokenIndex - 1] as COSString
        val buffer = parsePdfString(font, previous)
        val buffer2 = replacePdfText(buffer)
        page2Stream.showText(buffer2)
      }
      OperatorName.SHOW_TEXT_ADJUSTED -> {
        val previous = tokens[tokenIndex - 1] as COSArray
        val buffer = previous.filterIsInstance().joinToString("") { obj ->
          parsePdfString(font, obj)
        }

        val buffer2 = replacePdfText(buffer)
        page2Stream.showText(buffer2)
      }
      OperatorName.END_TEXT -> {
        page2Stream.endText()
      }
    }

同様に、罫線の出力は以下のようになりました

    when (token.name) {
      //罫線
      OperatorName.MOVE_TO -> {
        val x = (tokens[tokenIndex - 2] as COSNumber).floatValue()
        val y = (tokens[tokenIndex - 1] as COSNumber).floatValue()
        page2Stream.moveTo(x, y)
      }
      OperatorName.LINE_TO -> {
        val x = (tokens[tokenIndex - 2] as COSNumber).floatValue()
        val y = (tokens[tokenIndex - 1] as COSNumber).floatValue()
        page2Stream.lineTo(x, y)
      }
      OperatorName.STROKE_PATH -> {
        page2Stream.stroke()
      }
    }

最後に、作成したPDFの内容をファイルに保存します。

    PDDocument().use { document2 ->
      val page2 = PDPage()
      doc2.addPage(page2)

      PDPageContentStream(doc2, page2).use { page2Stream ->
        // ...
      }
      document2.save("sample_dest.pdf")
    }

サンプルコードはこちらにあります。

実際の出力例です。

デザイン

出力

上部余白の設定が消えているような気がしなくもないですがほぼ見た目どおりにテキストを置換したPDFファイルがなんとなく出力できました。
画像や細かな書式の対応など課題は多々ありますが、ある程度機能を制限すれば利用できそうです。

2021年10月02日

エンジニア 伊藤 |

«
このページのトップへ