Java

Javaを使ってPDFからテキストを抽出する(Apache PDFBox 編)

こんな人におすすめ

  • PDFからテキストを抽出してデータとして利用したい
  • PDFBox の使い方を知りたい

先日、経済産業省 がすすめている「キャッシュレス消費者還元事業」から登録加盟店一覧が公開されましたが、3600ページを超えるPDFをダウンロードさせる姿勢が問題視されました。

会計簿サービスを展開する株式会社Zaimがこの問題の解決にいち早く取り組み、地図化・市町村別の一覧にしてキャッシュレス還元マップとしてサービス公開しました。この取り組みの早さには尊敬の念を覚えます。

経産省の「3600ページPDF」、たった1日で民間が地図化 Zaim「キャッシュレス還元マップ」公開

実際にPDFからテキストを読み取るにはどういう手順を踏んだらよいか、本稿ではJavaでの実装例を解説します。

準備

Java

JDK8検証しています。JDK9以降を使用する際は、モジュール関連の設定を適宜行ってください。

環境

java version "1.8.0_211"
Java(TM) SE Runtime Environment (build 1.8.0_211-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.211-b12, mixed mode) 

ライブラリ

JavaでPDFを扱うライブラリとして、Apache PDFBox や iText 、JasperReports Library などいくつか知られていますが、本稿ではPDFBoxを使用した方法を解説します。

Mavenを使用する場合は依存関係を追加してください。プロジェクト管理ツールを使用しない場合は、こちらからjarをダウンロードしてクラスパスに追加してください。

pom.xml

<!-- https://mvnrepository.com/artifact/org.apache.pdfbox/pdfbox -->
<dependency>
    <groupId>org.apache.pdfbox</groupId>
    <artifactId>pdfbox</artifactId>
    <version>2.0.16</version>
</dependency>

version 1 はマルチバイト文字に対応していません。日本語を取り扱い際は、必ず version 2 以降を使用してください。

検証に使用するPDF

こちらからダウンロードができます。

最新の情報を利用する場合は、キャッシュレス・消費者還元事業(https://cashless.go.jp/)のページより入手してください。

処理実装

今回読み取りに使用するPDFは、以下のように店舗が一覧化されています。この一覧から、「No.」「都道府県」「市区町村」「事業所名(屋号)」「業種」「業種(サブカテゴリ)」「還元率」の7種類の情報を個別の文字列として取得しましょう。

ちなみにいろいろひっかかるこの一覧。「伊達の牛タン本舗」の各店でスペース有り無しが混在しているのが細かいけどすごく気になるし、No.10001にはおそらく間違いが2つ存在してます。まず気になる文字化けはハイフン。その上で「だし廊」と「だし廊 -NIBO-」は別店舗。この一覧の作者は詰めが甘いように思う。。

PDFドキュメントを読み込む

PDDocument.load(file)でPDFのメタ情報が読み込まれます。まずはじめにドキュメントを読み込み、確認のためにページ数を表示してみましょう。

ソース

package net.develman.pdf;

import org.apache.pdfbox.pdmodel.PDDocument;
import java.io.File;
import java.io.IOException;

public class PDFBoxSample {

    public static final void main(String[] args) throws IOException {

        File file = new File("kameiten_touroku_list.pdf");

        PDDocument document = PDDocument.load(file);
        System.out.println("総ページ数:" + document.getNumberOfPages());
    }
}

実行結果はこちら。PDFの読み込みができました。

実行結果

総ページ数:3608

ページ内すべてのテキストを読み込む

PDFTextStripper を使用すると、指定するページ範囲からテキストを抽出することが簡単にできます。

ソース

        //取得する店舗一覧のページ
        int page = 203;

        PDFTextStripper stripper = new PDFTextStripper();
        stripper.setStartPage(page);
        stripper.setEndPage(page);
        String text = stripper.getText(document);

        System.out.println(text);

上のコードを追加して実行すると以下の結果が得られます(一部抜粋)

実行結果

 キャッシュレス・消費者還元事業 事務局審査を通過した加盟店一覧
 ①固定店舗(EC・通信販売を除く) 令和元年8月21日 現在
 ※9月中に地図上に掲載予定
 No. 都道府県 市区町村 事業所名(屋号) 業種 還元率
 10,001 宮城県 仙台市青葉区 だし廊 だし廊 ?NIBO? サービス 飲食業 5%
 10,002 宮城県 仙台市青葉区 辰巳庵 サービス 飲食業 5%
 10,003 宮城県 仙台市青葉区 伊達路 サービス 飲食業 5%
 10,004 宮城県 仙台市青葉区 伊達の牛たん本舗 エスパル店 サービス 飲食業 5%
 10,005 宮城県 仙台市青葉区 伊達の牛たん本舗定禅寺通り店 サービス 飲食業 5%
 10,006 宮城県 仙台市青葉区 伊達の牛たん本舗 仙台駅売店 サービス 飲食業 5%
 10,007 宮城県 仙台市青葉区 伊達の牛たん本舗 仙台駅店 サービス 飲食業 5%
 10,008 宮城県 仙台市青葉区 伊達の牛たん本舗 仙台ビブレ名店街店 サービス 飲食業 5%
 10,009 宮城県 仙台市青葉区 伊達の牛たん本舗 藤崎名店街店 サービス 飲食業 5%
 10,010 宮城県 仙台市青葉区 伊達の牛たん本舗 本店 サービス 飲食業 5%
 10,011 宮城県 仙台市青葉区 伊達の牛たん本舗 三越名店街店 サービス 飲食業 5%

上から4行はここでは不要なので、5行目から取得するか抽出範囲を絞る必要があります。

ページ内の特定の範囲のテキストを読み込む

PDFTextStripper ではページ内のすべてのテキストを対象とするため、ヘッダやフッタも読み込まなければならず、今回の用途ではやや不向きでした。

PDFTextStripperByArea を使用すると範囲を定義することができるため、一定の表領域からテキストを抽出する用途に対して大変効果的です。

ソース

        //取得する店舗一覧のページ
        int page = 203;

        //左上の座標を基準として読み込む範囲を矩形で定義する
        double x = 37.0;
        double y = 115.05;
        double w = 513.58;
        double h = 671.47;
        Rectangle2D area = new Rectangle2D.Double(x, y, w, h);

        PDFTextStripperByArea stripper = new PDFTextStripperByArea();
        //抽出対象の範囲を指定する(名前は任意)
        stripper.addRegion("list", area);
        //抽出対象のページから範囲ごとにテキストを抽出する(getPageに渡すpageIndexは0〜)
        stripper.extractRegions(document.getPage(page - 1));
        //抽出結果を取得する
        String text = stripper.getTextForRegion("list");

        System.out.println(text);

範囲指定して抽出すると、余計なテキストを省くことができます。

実行結果

 10,001 宮城県 仙台市青葉区 だし廊 だし廊 ?NIBO? サービス 飲食業 5%
 10,002 宮城県 仙台市青葉区 辰巳庵 サービス 飲食業 5%
 10,003 宮城県 仙台市青葉区 伊達路 サービス 飲食業 5%
 10,004 宮城県 仙台市青葉区 伊達の牛たん本舗 エスパル店 サービス 飲食業 5%
 10,005 宮城県 仙台市青葉区 伊達の牛たん本舗定禅寺通り店 サービス 飲食業 5%
 10,006 宮城県 仙台市青葉区 伊達の牛たん本舗 仙台駅売店 サービス 飲食業 5%
 10,007 宮城県 仙台市青葉区 伊達の牛たん本舗 仙台駅店 サービス 飲食業 5%
 10,008 宮城県 仙台市青葉区 伊達の牛たん本舗 仙台ビブレ名店街店 サービス 飲食業 5%
 10,009 宮城県 仙台市青葉区 伊達の牛たん本舗 藤崎名店街店 サービス 飲食業 5%
 10,010 宮城県 仙台市青葉区 伊達の牛たん本舗 本店 サービス 飲食業 5%
 10,011 宮城県 仙台市青葉区 伊達の牛たん本舗 三越名店街店 サービス 飲食業 5%

各要素を区切って個別のデータとして扱う

抽出したデータはページ単位の文字列なので、これを各行・各要素毎に区切り、今回はListに詰めてデータを利用したいと思います。

まず1行のデータを格納するクラスを用意しましょう。getter/setterやtoStringについてはお好みでLombokに任せてもかまいません。

ソース

/**
 * 加盟店情報
 */
class Kameiten {

    /**
     * 都道府県
     */
    private String pref;
    /**
     * 市区町村
     */
    private String city;
    /**
     * 事業所名(屋号)
     */
    private String name;
    /**
     * サービス
     */
    private String category1;
    /**
     * サービス(サブカテゴリ)
     */
    private String category2;
    /**
     * 還元率
     */
    private String rate;

    public Kameiten(String pref, String city, String name, String category1, String category2, String rate) {
        this.pref = pref;
        this.city = city;
        this.name = name;
        this.category1 = category1;
        this.category2 = category2;
        this.rate = rate;
    }

    public boolean isCity(String city) {
        return this.city.equals(city);
    }

    public boolean containsName(String name) {
        return this.name.contains(name);
    }

    public String toString() {
        return String.join(",", pref, city, name, category1, category2, rate);
    }
}

次に抽出処理ですが、対象のPDFは運良く「要素の欠落がない」「半角スペースを含む事業所名がない」理想的のように見えるので、単純に1行は半角スペースで分割できます。

ソース

        //加盟店を管理するListを定義する
        List<Kameiten> kameitenList = new ArrayList<Kameiten>();

        //改行で区切り、行ごとに処理する
        for (String line : text.split("\n")) {

            //半角スペースで区切り、要素ごとに処理する
            String[] words = line.split(" ");

            Kameiten kameiten = new Kameiten(words, words, words, words, words[5], words[6]);
            kameitenList.add(kameiten);
        }

これで読み込んだ内容をListに格納することができました。今回はStreamAPIを利用して簡単にフィルタリングできます。このままCSVファイルに出力してもよいでしょう。

ソース

        //仙台市青葉区の店舗のみ表示したい
        kameitenList.stream()
                .filter(k -> k.isCity("仙台市青葉区"))
                .forEach(System.out::println);

実行結果

 宮城県,仙台市青葉区,だし廊 だし廊 ?NIBO?,サービス,飲食業,5%
 宮城県,仙台市青葉区,辰巳庵,サービス,飲食業,5%
 宮城県,仙台市青葉区,伊達路,サービス,飲食業,5%
 宮城県,仙台市青葉区,伊達の牛たん本舗 エスパル店,サービス,飲食業,5%
 宮城県,仙台市青葉区,伊達の牛たん本舗定禅寺通り店,サービス,飲食業,5%
 宮城県,仙台市青葉区,伊達の牛たん本舗 仙台駅売店,サービス,飲食業,5%
...
        //伊達の牛たんのみ表示したい
        kameitenList.stream()
                .filter(k -> k.containsName("伊達の牛たん"))
                .forEach(System.out::println);

実行結果

 宮城県,仙台市青葉区,伊達の牛たん本舗 エスパル店,サービス,飲食業,5%
 宮城県,仙台市青葉区,伊達の牛たん本舗定禅寺通り店,サービス,飲食業,5%
 宮城県,仙台市青葉区,伊達の牛たん本舗 仙台駅売店,サービス,飲食業,5%
 宮城県,仙台市青葉区,伊達の牛たん本舗 仙台駅店,サービス,飲食業,5%
 宮城県,仙台市青葉区,伊達の牛たん本舗 仙台ビブレ名店街店,サービス,飲食業,5%
 宮城県,仙台市青葉区,伊達の牛たん本舗 藤崎名店街店,サービス,飲食業,5%
 宮城県,仙台市青葉区,伊達の牛たん本舗 本店,サービス,飲食業,5%
 宮城県,仙台市青葉区,伊達の牛たん本舗 三越名店街店,サービス,飲食業,5%

さいごに

今回扱ったPDFについては、一定の表形式で、サービス名のサブカテゴリなどに欠落した要素がない、要素ごとの区切り文字として想定している半角スペースが事業所名などに使われていないという、非常に条件の整った内容でした。

要素に欠落があるような場合(サービス名が空など)や、半角スペースが含まれる場合(splitした結果の要素数が一定でない)は、表の罫線を取得するなど PDFTextStripper 系では対応できない深い情報まで踏み込まないといけないため、PDFの仕様自体への理解度が必要になります。

また、重複を考慮していませんので重複する事業所のチェック等は場合により必要です。残念ながら今回使用したPDFには重複が存在します。

 10,520 宮城県 仙台市宮城野区 トーメスヘアー 仙台店 サービス 理容・美容業 5%
 10,521 宮城県 仙台市宮城野区 トーメスヘアー 仙台店 サービス 理容・美容業 5% 

微妙なミス入力みたいなものも含めると大変ですね。

実行結果

 10,453 宮城県 仙台市宮城野区 ITAGAKI TBCハウジング 小売業 食料品 5%
 10,454 宮城県 仙台市宮城野区 ITAGAKI TBCハウジング店 小売業 食料品 5%
 10,455 宮城県 仙台市宮城野区 ITAGAKI TBCハウジン 小売業 食料品 5% 

半角スペースで区切ればいい..と思いきや

事業所名の区切りは思いっきり半角スペースな加盟店もありました。今回は妥当性チェックを入れていないため気がつきませんでしたが、きっとずれて格納されています(全部Stringなのでエラーになっていない)。

実行結果

 10,016 宮城県 仙台市青葉区 中華 謙太郎 サービス 飲食業 5%
 10,050 宮城県 仙台市青葉区 どんぐり整骨院 八幡本院 サービス その他サービス 5%

今後、PDFTextStripper に頼ることができない場面で使えるような、より複雑な条件のPDFを扱うプログラムについても解説していきたいと思います。

ABOUT ME
sasakiyu
ひ弱で優しい少年だったが、デーモンと合体。 強大な力を得つつも人間の心を失わないデベルマンとなる。