帳票pdfから入力項目を抽出する

#はじめに
本記事は [Develop fun!を体現する Works Human Intelligence Advent Calendar 2020](https://qiita.com/advent-calendar/2020/whi) の14日目の記事です。

# 前提条件  
* Java14
* Apache PDFBox 2.0.21  

# 動機
法改正により帳票様式が変更されると、製品対応が必要になることがあります。
給与所得者になじみのある源泉徴収票を例にとると、令和2年には以下のような変更がありました。

[平成30年版 給与所得の源泉徴収票](https://www.nta.go.jp/taxes/tetsuzuki/shinsei/annai/hotei/pdf/h30/23100051-01.pdf)
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/857948/452aae36-81eb-4a29-23e3-c7af2bac6ee6.jpeg" width=80%>
[令和2年版 給与所得の源泉徴収票](https://www.nta.go.jp/taxes/tetsuzuki/shinsei/annai/hotei/pdf/r02/23100051-01.pdf)
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/857948/1b32d0e8-20e2-4deb-b389-490e20b04228.jpeg" width=80%>
出典：国税庁ホームページ（https://www.nta.go.jp/taxes/tetsuzuki/shinsei/annai/hotei/23100051.htm）  
制度変更に応じた帳票の変更点は以下の通り

1. 「給与所得控除後の金額」 -> 「給与所得控除後の金額（調整控除後）」
1. 「基礎控除の額」項目追加
1. 「所得金額調整控除額」項目追加
1. 「寡婦（一般・特別）」「寡夫」 -> 「寡婦」「ひとり親」  
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/857948/8f53b113-5881-4845-13b0-e5327394f795.jpeg" width=80%>  

ただ、令和2年版の方が全体的に拡大されていて余白が小さいという違いもあり、単純に平成30年版の印字位置を引き継ぐことができず手間がかかった模様。  
こういったことは今後も発生しうるので、pdf解析による法改正時製品対応の自動化を模索してみます。

# ゴール
1. 入力項目の位置サイズを把握（本稿）
1. 位置サイズを把握した入力項目の名称を特定（次稿予定） 

上記が実現すれば、従前の項目名称マッピングを引き継ぎつつ新しい帳票に応じた印字位置の割り当てまで自動化できるはず。

# pdfをラスタライズ
pdfの記述は自由過ぎて表示される矩形を把握するのが難しそうなので、一旦ラスタ形式に変換します。

```java:RasterizePDF.java

package pdf;

import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer;

import java.awt.image.BufferedImage;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;

import javax.imageio.ImageIO;

public class RasterizePDF {

	public static void main(String[] args) {
		File inPdfFile = new File("hoge.pdf");
		File outImageFile = new File("huga.jpeg");
		int pageIndex = 0;// 1ページ目
		float dpi = 720;// dpi 720
		ImageType imageType = ImageType.RGB;
		String formatName = "jpeg";

		try (InputStream is = new FileInputStream(inPdfFile);
				OutputStream os = new FileOutputStream(outImageFile);
				PDDocument doc = PDDocument.load(is)) {
			PDFRenderer pdfRenderer = new PDFRenderer(doc);
			BufferedImage bim = pdfRenderer.renderImageWithDPI(pageIndex, dpi, imageType);
			ImageIO.write(bim, formatName, os);
			os.flush();
		} catch (IOException e) {
			throw new RuntimeException(e);
		}
	}
}
```

# 閉領域の塗り潰し
背景色が連続している閉領域を塗り潰しつつ、領域の位置とサイズを計測していきます。
抽象クラスのみですが、塗り潰し処理ロジックはこんな感じ。

```java:AbstractImageProcessor.java

package pdf;

import java.awt.image.BufferedImage;
import java.util.Stack;

public abstract class AbstractImageProcessor {

	protected final BufferedImage image;

	public AbstractImageProcessor(BufferedImage image) {
		super();
		this.image = image;
	}

	abstract void processPixel(int x, int y);

	abstract boolean isProcessTarget(int x, int y);

	public boolean processOneArea(int x, int y) {
		if (!isProcessTarget(x, y)) {
			return false;
		}
		int wkX = x;
		int wkY = y;
		//起点から左方向に背景色が途切れるまで進む
		int leftX = getLeftEnd(wkX, wkY);
		//起点から右方向に背景色が途切れるまで進む
		int rightX = getRightEnd(wkX, wkY);
		//起点を含む背景色の連続線を取得（線の上下とも未検査）
		Segment firstSegment = new Segment(leftX, rightX, wkY, false, false);
		Stack<Segment> stack = new Stack<AbstractImageProcessor.Segment>();
		stack.push(firstSegment);
		while (!stack.isEmpty()) {
			//処理する連続線を取得
			Segment segment = stack.pop();
			//当該連続線の直上で塗り潰すべき連続線の有無をチェック
			if (!segment.upSideChecked) {
				checkUpSide(segment, stack);
			}
			//当該連続線の直下で塗り潰すべき連続線の有無をチェック
			if (!segment.downSideChecked) {
				checkDownSide(segment, stack);
			}
			//当該連続線を塗り潰す
			processSegment(segment);
		}
		return true;
	}

	//連続線塗り潰し処理
	private void processSegment(Segment segment) {
		for (int i = segment.leftX; i <= segment.rightX; i++) {
			processPixel(i, segment.y);
		}

	}

	//連続線の直上のチェック
	private void checkUpSide(Segment segment, Stack<Segment> stack) {
		checkEitherSide(segment, stack, true);
	}

	//連続線の直下のチェック
	private void checkDownSide(Segment segment, Stack<Segment> stack) {
		checkEitherSide(segment, stack, false);
	}

	//連続線の直上・直下のチェック
	private void checkEitherSide(Segment segment, Stack<Segment> stack, boolean isUpSide) {
		int targetY = segment.y + (isUpSide ? -1 : 1);
		if (targetY < 0 || targetY >= image.getHeight()) {
			return;
		}
		int targetLeftX = segment.leftX;
		int targetRightX = segment.rightX;
		//左端から左方向に背景色が続いている場合は上下とも未検査の検査対象として追加
		if (isProcessTarget(targetLeftX, targetY) && hasTargetOnLeft(targetLeftX, targetY)) {
			stack.push(new Segment(getLeftEnd(targetLeftX - 1, targetY), targetLeftX - 1, targetY, false, false));
		}
		//右端から右方向に背景色が続いている場合は上下とも未検査の検査対象として追加
		if (isProcessTarget(targetRightX, targetY) && hasTargetOnRight(targetRightX, targetY)) {
			stack.push(new Segment(targetRightX + 1, getRightEnd(targetRightX + 1, targetY), targetY, false, false));
		}

		boolean isInNewSegment = false;
		int newSegmentLeftX = Integer.MIN_VALUE;
		//検査元の連続線に隣接している部分のチェック。連続する背景色の数だけ検査対象を分けて、検査元の側のみ検査済として追加
		for (int i = targetLeftX; i <= targetRightX; i++) {
			if (isProcessTarget(i, targetY)) {
				if (!isInNewSegment) {
					isInNewSegment = true;
					newSegmentLeftX = i;
				}
			} else {
				if (isInNewSegment) {
					isInNewSegment = false;
					stack.push(new Segment(newSegmentLeftX, i - 1, targetY, !isUpSide, isUpSide));
				}
			}
		}
		if (isInNewSegment) {
			stack.push(new Segment(newSegmentLeftX, targetRightX, targetY, !isUpSide, isUpSide));
		}
	}

	//左隣が背景色
	private boolean hasTargetOnLeft(int x, int y) {
		if (x == 0) {
			return false;
		}
		return isProcessTarget(--x, y);
	}

	//右隣が背景色
	private boolean hasTargetOnRight(int x, int y) {
		if (x == image.getWidth() - 1) {
			return false;
		}
		return isProcessTarget(++x, y);
	}

	private static class Segment {
		private int leftX;
		private int rightX;
		private int y;
		private boolean upSideChecked;
		private boolean downSideChecked;

		public Segment(int leftX, int rightX, int y, boolean upSideChecked, boolean downSideChecked) {
			super();
			this.leftX = leftX;
			this.rightX = rightX;
			this.y = y;
			this.upSideChecked = upSideChecked;
			this.downSideChecked = downSideChecked;
		}
	}


	//背景色が途切れるまで左に進み、途切れた個所のインデックスを返す
	private int getLeftEnd(int x, int y) {
		int result = x;
		while (result >= 0 && isProcessTarget(result, y)) {
			result--;
		}
		return ++result;
	}

	//背景色が途切れるまで右に進み、途切れた個所のインデックスを返す
	private int getRightEnd(int x, int y) {
		int result = x;
		while (result < image.getWidth() && isProcessTarget(result, y)) {
			result++;
		}
		return --result;
	}

}

```

# 抽出結果１
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/857948/40af7d71-cc0b-1d56-7578-1d01bb4f93ac.jpeg" width=80%> 
帳票タイトルの「令和」の「和」といった文字や、項目名称が表記された矩形も抽出されています。また、一見何もなさそうな部分も何故か抽出されています。

# 追加対応１
文字関係はpdfから情報を抽出して色々できそうですが、一旦現状のラスタ形式を前提に精度向上を検討します。
まずは以下の追加対応。

* 余りに小さい閉領域は入力項目の候補にしない
* 閉領域の上半分・下半分・左半分・右半分すべてに非背景色が含まれる矩形は入力項目の候補にしない

# 抽出結果２
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/857948/74466497-fcf8-a18f-d341-c00761f69930.jpeg" width=80%> 
不可解な抽出はなくなりましたが、本来入力項目として抽出されそうな項目が抽出されなくなりました。
原因としては、一見滑らかに見える矩形の辺に細かい凹凸がある模様。（ラスタライズの問題？）

# 追加対応２

細かい凹凸に対応するため以下の追加対応。

* 閉領域の外周ギリギリに非背景色が含まれていても、入力項目の候補から外さない

# 抽出結果３
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/857948/a916eba5-f23e-c2bf-c32f-a3927829a985.jpeg" width=80%> 
かなり正解に近づいたようですが、老人（内・人）特別（内・人）や氏名・フリガナ等、破線で区別された矩形の認識ができていません。

# 追加対応３

破線で区別された矩形に対応するため以下の追加対応。

* 細い背景色部分で隔てられた非背景色部分を繋げる（≒破線部分を実線に変換）

# 抽出結果４
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/857948/155003e7-ab1f-aa2e-763c-369a74326fd8.jpeg" width=80%> 
概ねOK。

# そういえば
下部の「支払者・氏名又は名称」が「（電話）」で区切られるべきといった各項目特有の事情も可能な限り吸収しますが、いずれにせよpdf文字列解析の結果が必要なので次稿で・・・

# 次稿予定
* 位置サイズを把握した入力項目の名称を特定