プログラムでおかえしできるかな

定年を過ぎて何かの役に立てないかなと始めた元SEのブログです

PDFからテキストを抽出(プログラム)【Python】

このエントリーをはてなブックマークに追加

PDFからテキストを抽出するアプリをPythonで作成しました。その内容を紹介します。

2段組み構成のPDFも抽出可能です。ヘッダーやフッターの除外、ページの指定なども可能です。
ただし、文書によっては100%とはいきません。その点はご容赦ください。

記事「PDFからテキストを抽出(コマンド)【Python】」でpdf2txt コマンドでの抽出を紹介しました。

そこで上手くいかなかった「段組みの構成が途中で変わるもの」、「文中に図や表などのイメージがある場合」や「ヘッダーやフッターがある場合」でも抽出できます。

Pythonのパッケージは pdfminer.six を使用します。
PythonPythonパッケージのインストールなど基本的な使い方は諸先輩に譲ります。

目次

◆できること

PDFから抽出したテキストをテキストファイルに出力します。

加えて次のような特徴があります。

  • ページのヘッダーやフッターを抽出の対象から除けます。
  • ページを指定して抽出できます。
  • 2段組みの文書でも抽出できます。

今回、表についての考慮はしていません。

◆考え方

pdfminer.six パッケージの pdf2txt コマンドで抽出する場合、次のような文書が苦手のようでした。1

苦手な文書
  • 段組みの構成が途中で変わるもの
  • 文中に図や表などのイメージがあるもの
  • ヘッダーやフッターがあるもの
  • 表(今回、考慮していません)

これらを自動で対応するのは難しいですが、パラメータを与えてあげれば対応できそうです。
pdfminer.six は、PDFを解析して、段落が占める領域(段落の境界ボックス)をプロパティとして持ちます。
これを利用すれば段落の位置が分かり、出力制御を行えます。

具体的には次のような感じです。

方針
  • pdfminer.six はPDFを解析し、グルーピング(段の区別なし)し、範囲(境界)情報を保持するので利用する
  • ヘッダーやフッターはその境界を指定し、そこから外れるテキストを出力しない
  • グルーピングされたテキスト(段落)を境界情報で左の段、右の段に分ける
  • 段落を左、右の順に出力
二つのアプローチ

pdfminer.six にはPDFを解析するメソッドが複数あります。

  • extract_text()extract_text_to_fp() メソッドを使う方法
    APIを使用する方法ですが、コマンドラインを用いる方法とあまり変わりません。
    コマンドラインではなく、プログラムを作成してオプションの設定もプログラム内で終わらせるような使い方に向いていると思います。
  • extract_pages() メソッドを使う方法(本記事では、こちらの方法を用います)
    LTPageオブジェクトが生成されるのでそれを使って制御しながらテキストを抽出します。
    LTPageオブジェクトについてはこちら⤵で説明します。

◆【pdfminer.six】の extract_pages() メソッドを使った抽出方法

extract_pages() メソッドを使用した抽出方法を説明します。
extract_pages() メソッドを使用し、解析方法のオプションを指定するには、LAParams クラスを使用します。

◇考え方

  1. LAParamsクラスのインスタンスを作成し、解析条件を設定
  2. extract_pages() メソッドを使って解析
    • ページ単位、段落単位に文字列を取得
    • 境界情報を参照してページヘッダーとフッターにあたる場合は未処理
    • 境界情報を参照して左段か右段か判断しデータ保持
    • ページが変わったら左段組み、右段組みの順に出力

◇LAParamsクラスのインスタンス作成

【処理】

  1. インスタンスを作成
  2. 行マージンを指定( laparams.line_margin = 0 )
    行マージンに 0 を指定(段落を1行単位に)
    試行錯誤の結果… 試行錯誤の結果、pdfminer.six にうまく段落を認識してもらえなかったので、段落は1行単位にしてプログラムで制御することにしました。こちらを参照⤵
  3. ボックスフローを無しに指定(laparams.boxes_flow = None)
    (良く理解できていませんが、左下隅の位置に基づいてテキストを返す設定に)
    オプションの説明…
    • line_overlap(デフォルト:0.5)
      • 2つの文字がこれよりもオーバーラップしている場合、 それらは同じ行にあると見なされます。 オーバーラップは、両方の文字の最小の高さを基準にして指定される。
    • char_margin(デフォルト:2.0)
      • 2つの文字がこのマージンよりも接近している場合、 それらは同じ行の一部と見なされる。 マージンは、文字の幅を基準にして指定されます。 大きくすると接近したものがまとめられ、小さくすると分けられる。
    • word_margin(デフォルト:0.1)
      • 同じ行の2つの文字がこのマージンよりも離れている場合、 それらは2つの別個の単語と見なされ、 読みやすくするために中間スペースが追加される。 マージンは、文字の幅を基準にして指定される。 ⇒英語の単語を判別するための設定。日本語の場合、セルの分割には有効。 PDFには空白文字の概念がないため、この値分空いたところに空白を補う。 word_margin < char_margin である必要がある。 そうでないと、スペースで区切られない。 ⇒日本語にはこの方が良い場合もある。
    • line_margin(デフォルト:0.5)
      • 2つの線が接近している場合、それらは同じ段落の一部であると見なされる。 マージンは、線の高さを基準にして指定されます。
    • box_flow(デフォルト:0.5)
      • テキストボックスの順序を決定するときにテキストの 水平方向と垂直方向の位置がどの程度重要かを指定 値は、-1.0(水平位置のみが重要)から+1.0(垂直位置のみが重要)の範囲
        None:高度なレイアウト分析を無効にし、代わりにテキストボックスの 左下隅の位置に基づいてテキストを返す
    • detect_vertical(デフォルト:False)
      • レイアウト分析中に垂直テキストを考慮する必要がある場合
    • all_texts(デフォルト:False)
      • 図のテキストに対してレイアウト分析を実行する必要がある場合

【この部分のソース】

    laparams = LAParams()               # パラメータインスタンス
    laparams.boxes_flow = None          # -1.0(水平位置のみが重要)から+1.0(垂直位置のみが重要)default 0.5
    laparams.word_margin = 0.2          # default 0.1
    laparams.char_margin = 2.0          # default 2.0
    laparams.line_margin = 0            # default 0.5

◇extract_pages() メソッドを使って解析

【処理】

  1. extract_pages() メソッドの実行

    for page_layout in extract_pages( self.input_path, maxpages=0, laparams=laparams):

    • 入力ファイル( self.input_path )、最大ページ数( maxpages=0 )、オプション( laparams=laparams )を指定
    • maxpages=0 は全てのページ
  2. ページ範囲指定されたページ以外のページを処理対象から外す

    if page_layout.pageid < self.start_page: continue
    if self.last_page and self.last_page < page_layout.pageid: break

    • extract_pages() メソッドはLTPageオブジェクト(イテラブル)を返す
      LTPageオブジェクトは1ページ分の解析した情報を含む
    • LTPageオブジェクトの pageid プロパティでページ番号を取得し、コマンドライン引数として指定した開始ページより小さい場合、処理を飛ばす
    • 同様にコマンドライン引数として指定した終了ページより大きい場合、処理を中断
  3. 段組みの境界位置がコマンドライン引数で 0 であれば、ページ幅( width プロパティ)の半分を境界位置とする

    if self.border == 0: self.border = int( page_layout.width / 2)

  4. LTPageオブジェクトに含まれる子要素を取得する

    for element in sorted( self.flatten_lttext( page_layout, LTTextBox), key=lambda x: (-x.y1, x.x0)):

    • 子要素には、LTTextBox, LTFigure, LTImage, LTRect, LTCurve and LTLine等がある
    • 子要素にはさらに子要素が存在するものがある
      LTPageオブジェクトについての図を参照
    • ツリー状になっている要素の子孫を平坦化して返す2
      こちらを参照
      裏話 後になってみると子要素の型が LTTextBox かどうか if 文で判断すればよいだけだったのですが、作成途中では LTTextBox 型ではなく、 LTTextLine 型を取得していて、更にそれは孫要素も存在していたので平坦化を採用しました
    • y1の降順、x0の昇順にソート
      キーを複数指定するためにlambda式を使用してタプルに
      y1を昇順降順にするために「-」を付加
  5. 子要素に対し、

    1. ページのヘッダー部分とフッター部分を処理対象から外す

      if element.y1 < self.footer: continue # フッター位置の文字は抽出しない
      if element.y0 > self.header: continue # ヘッダー位置の文字は抽出しない

    2. 文字列の取得

      _text =element.get_text()

    3. 段落の位置情報を確認し左右の段用の変数にテキストを保存

      • この時、段落が段をまたいでいる場合は、右側の段用に既にテキストがあれば、左右の段の内容をファイルに出力
      • さらに左の段にテキストを保存
  6. 1ページ分の処理が終わったら左右の段の内容をファイルに出力

【この部分のソース】

    # 対象ページを読み、テキスト抽出する。(maxpages:0は全ページ)
    for page_layout in extract_pages(self.input_path, maxpages=0, laparams=laparams):    # 
        # 抽出するページの選別。extract_pagesの引数では、開始ページだけの指定に対応できないため
        if page_layout.pageid < self.start_page: continue                   # 指定開始ページより前は飛ばす
        if self.last_page and self.last_page < page_layout.pageid: break    # 指定終了ページ以降は中断
        # ページの幅から段組みの境界を計算(用紙幅の半分とする)
        if self.border == 0:
            self.border = int(page_layout.width / 2)
        
        # 要素のイテレータをたどり入れ子の要素を1次元に取り出す。戻るイテレータはLTTextBox型のみ
        # 要素の行の上側y1で降順、行の左側x0で昇順にソートする。
        for element in sorted(self.flatten_lttext(page_layout, LTTextBox), key=lambda x: (-x.y1, x.x0)):
            if element.y1 < self.footer: continue  # フッター位置の文字は抽出しない
            if element.y0 > self.header: continue  # ヘッダー位置の文字は抽出しない
            _text =element.get_text()

            if element.x1 < self.border:
                # 文字列全体が左側
                self.text_l += _text
            else:
                if element.x0 >= self.border:
                    # 文字列全体が右側
                    self.text_r += _text
                else:
                    # 文字列が境界をまたいでいる場合
                    # 右側に既に文章があれば先に出力する
                    if self.text_r:
                        self.write2text(f)
                    self.text_l += _text

        # 1ページ分処理したら書き込む
        self.write2text(f)

◇ツリー状のイテレータを平坦化

次のサイトを参考にツリー状のイテレータを平坦化して呼び出せるようにします。
その際に、返す値の型を指定できるようにします。
Python解説:Pythonでflatten(多次元リストを一次元に平坦化) | note.nkmk.me

【この部分のソース】

    def flatten_lttext(self, l, _type):
        """
        ツリー状になっているイテレータをフラットに返すイテレータ
        返る要素の型を指定
        pdfminerのextract_pagesで使用するのを想定
        要素の型が引数で指定した型を継承したもののみを返す

        Args:
            l:      pdfminerのextract_pages()の戻り値
            _type:  戻したい値の型
        """
        for el in l:
            if isinstance(el, (_type)):
                yield el
            else:
                if isinstance(el, collections.abc.Iterable) and not isinstance(el, (str, bytes)):
                    yield from self.flatten_lttext(el, _type)
                else:
                    continue

◆全体のソース

全体のソースはこちらから取得できます。
リンク:GitHub juu7g/Python-PDF2text

◆結果の違い

サンプルにした文書(抜粋)

文書

本記事の成果(抜粋)

3.具体的な処理
3.1 導入
2019 年にノーベル化学賞を受賞した吉野彰氏が発明者
となっている(日本)特許出願群を題材に,出願データの
前処理と集計,可視化を行う。
具体的には,出願件数,出願件数推移,特許分類ランキ
ング,共同発明者集計,クロス集計,請求項中の重要語の
集計を行い,結果を可視化する。
以下,Google colaboratory 上で pandas を使って特許
データの処理を行う。上記の Python のインストール済
PC や,ブラウザ上の Python 実行環境であれば,同じコー
ドで動く。
下記の URL を開き,ソースコードをコピペするか,手
打ちで入力して実行しながら処理を体験してもらうと,理
解が進むのでお勧めする。

pdf2txtコマンドでの結果の一部

3.具体的な処理

3.1 導入

2019 年にノーベル化学賞を受賞した吉野彰氏が発明者
となっている(日本)特許出願群を題材に,出願データの

前処理と集計,可視化を行う。

される。

具体的には,出願件数,出願件数推移,特許分類ランキ

次に,以下のコードをコピペ&実行する。

ング,共同発明者集計,クロス集計,請求項中の重要語の

##############################################

集計を行い,結果を可視化する。

import pandas as pd

ここでは処理対象のデータを入力する。といっても簡単
で,まずは下記のコードを実行してほしい。以下,「####
…(略)」で囲われた部分がコード部分である。

##############################################

!curl  -o  yoshino.csv  https://storage.googleapis.com/

yoshino/yoshino.csv

##############################################
これを実行すると,ネット上から「yoshino.csv」とい
うデータをダウンロードされ,ネット上のフォルダに保存

df_yoshino = pd.read_csv("yoshino.csv",encoding='cp932')

##############################################
これを実行すると,df_yoshino という「入れ物」に,

今回の分析対象のデータが格納される。

なお,自分の持っているデータで処理を行いたい場合
は,図 2 のように,画面左側の「ファイル」⇒「アップロー
ド」で csv ファイルやエクセルファイルをアップロード
し,読み込めば良い 6)。

以下,Google colaboratory 上で pandas を使って特許
データの処理を行う。上記の Python のインストール済
PC や,ブラウザ上の Python 実行環境であれば,同じコー
ドで動く。

pdf2txt コマンドを使用した場合に左右の「段組み」のなかにある『段落』が入り混じっていたものが、本記事の成果では正しく出力されているのが分かります。

◆LTPageオブジェクトについて

pdfminer.sixextract_pages() メソッドを使ってPDFを解析するとLTPageオブジェクトという要素を抽出します。
要素には、さらにLTTextBox, LTFigure, LTLine, LTRect, LTImageが存在します。(次の図を参照)

graph TD LTPage-->LTTextBox LTPage-->LTFigure LTPage-->LTLine LTPage-->LTRect LTPage-->LTImage LTTextBox-->LTTextLine[LTTextLine...] LTTextBox-->1[ ] LTTextLine--->LTChar1[LTChar] LTTextLine--->LTChar2[LTChar] LTTextLine--->LTText[LTText...] LTTextLine--->2[ ] LTFigure--->LTCure[LTCure...] LTFigure--->3[ ]

これらのクラスは、LTComponentクラスを継承し、x0, y0, x1, y1, width, height, bboxというプロパティを持ちます。
これらのプロパティは、要素の境界ボックスの座標を表します。
座標は、ページの左下を(0, 0)として右上に増加します。

LTComponentクラスのプロパティ(抜粋)

  • x0:左下のx座標
  • y0:左下のy座標
  • x1:右上のx座標
  • y1:右上のy座標
  • width:幅
  • height:高さ
  • bbox:x0, y0, x1, y1のタプル


◆試行錯誤

途中、なかなかうまくテキストを抽出できなくて試行錯誤しました。
参考までにその経過を紹介します。

  • 段組みごとに出力するようにする
    2段組みをターゲットに左の段と右の段を別々にまとめて左、右の順に出力
    【問題】文末にある段組みされていない段落が途中に入る
    【原因】段組みをまたがる幅の段落を左の段組みとして処理したため
    左の段を出力した後に右の段を出力した結果
graph TD subgraph 求める形 A3[A段組み] --> C3[B段組み] C3 --> B3[C段落] end subgraph 抽出結果 A2[A段組み] --> C2[C段落] C2 --> B2[B段組み] end subgraph 文章の構成 B1["A段組み │ B段組み"] --> C1["C段落(段組みがない)"] end
  • 段組みをまたがる幅の段落の制御
    段組みをまたがる幅の段落を出力する際に、
    既に右の段にデータがあれば右の段に出力
    【問題】表紙の段組みされていない段落がページの最後に出る
    【原因】文書の見出しに段組みされていないけど短くて右の段と判断される段落がある
graph TD subgraph 求める形 S1[A段落] --> S2[B段落] S2 --> S3[C段落] S3 --> S4[D段組み] S4 --> S5[E段組み] end subgraph 抽出結果 R1[A段落] --> R2[C段落] R2 --> R3[D段組み] R3 --> R4[B段落] R4 --> R5[E段組み] end subgraph 文章の構成 A1["A段落(幅いっぱいの文書段組み)
B段落(右に寄った文書)
C段落(幅いっぱいの文書)"] --> B1["D段組み │ E段組み"] end
  • 表紙を優先していったん戻す
    【別の問題】参考文献の出力順が正しくない
    【原因】字下げされた部分とそうでない部分が別段落になる
graph TD subgraph 求める形 S1["1行目(インデントされてない)__
__2行目(インデントされている)
3行目(インデントされてない)__"] end subgraph 抽出結果 R1["1行目(インデントされてない)__
3行目(インデントされてない)__
__2行目(インデントされている)"] end subgraph 文章の構成 A1["1行目(インデントされてない)__
__2行目(インデントされている)
3行目(インデントされてない)__"] end
  • 行マージンを調整
    字下げされても同じ段落となるように行マージンを調整
    1.0から0.5まで段階的に下げても改善されない

  • 行マージンを0にする
    段落として1行だけにする(行の順に出力しているので出力順に問題はない)
    【解決】参考文献の出力順が正しくなる

  • 段組みをまたがる幅の段落の制御 段組みをまたがる幅の段落を処理する時に
    右側のテキストが既に存在していたらそれまで貯めた文字を出力する
    【解決】表紙も、文末も出力順が正しくなる

◆バイナリ

pyinstaller でバイナリ( exe ) ファイルを作成しました。

  • 作成コマンド: pyinstaller -F pyファイル名

◇バイナリ取得先

バイナリも公開します。
こちらから取得してください。ダウンロード

◇バイナリの使い方(ヘルプ画面)

usage: pdf_PDF2text.exe [-h] [-b n] [-f n] [-t n] [-s n] [-e n]
                        input_path [output_path]

positional arguments:
  input_path        入力ファイル名
  output_path       出力ファイル名(default:月日_時分_秒.txt)

optional arguments:
  -h, --help        show this help message and exit
  -b n, --border n  段組みの切れ目 0の場合、用紙幅の半分(default:1)
  -f n, --footer n  フッター位置(default:30)
  -t n, --top n     ヘッダー位置(default:1000)
  -s n, --s_page n  開始ページ(default:1)
  -e n, --e_page n  終了ページ(0:最終)(default:0)

ダウンロードしたzipファイルを解凍してできたexeファイルを実行します。
簡単な情報が出るので、コマンドプロンプトから起動するのがよいと思います。

段組みのない文書の場合、-b 1 等を指定すれば抽出できます。
フッター位置、ヘッダー位置は、出力するページの情報を見て調整してください。

◇ヘルプの作成

ヘルプを作るため argparse モジュールを使用します。
argparse モジュールは少しの手間でユーザーフレンドリーなヘルプを提供してくれます。
手順としては、ArgumentParserクラスのインスタンスを作成し、add_argument() メソッドで引数を定義します。

【処理】

  1. argparse を使用するためインポートする

    import argparse

  2. ArgumentParserクラスのインスタンスを作成

    parser = argparse.ArgumentParser()

  3. add_argument() メソッドで引数を定義する3
    一部だけ解説します。

    • input_path という文字型の変数に格納
      「入力ファイル名」というガイドを出す

      parser.add_argument( 'input_path', type=str, help="入力ファイル名")

    • output_pathという文字型の変数に格納
      「出力ファイル名」というガイドを出す
      デフォルト値は self.output_path
      指定がない場合はデフォルト値を使用

      parser.add_argument( 'output_path', nargs="?", default=self.output_path, type=str, help= "出力ファイル名(default:月日_時分_秒.txt)")

    • footerという数値型変数に格納
      「フッター位置」というガイドを出す
      合わせてデフォルト値の値を表示(%(default)sで値を参照)
      指定がない場合はデフォルト値を使用
      ヘルプ表示でパラメータを「n」とする

      parser.add_argument( "-f", '--footer', type=int, metavar="n", default=30, help= "フッター位置(default:%(default)s)")

  4. 引数を解析

    args = parser.parse_args(argv)

【この部分のソース】

    # コマンドライン引数の解析
    parser = argparse.ArgumentParser()      # インスタンス作成
    parser.add_argument('input_path', type=str, help="入力ファイル名")  # 引数定義
    parser.add_argument('output_path', nargs="?", default=self.output_path, type=str, help="出力ファイル名(default:月日_時分_秒.txt)")  # 引数定義
    parser.add_argument("-b", '--border', type=int, metavar="n", default=1, help="段組みの切れ目  0の場合、用紙幅の半分(default:%(default)s)") # 引数定義
    parser.add_argument("-f", '--footer', type=int, metavar="n", default=30, help="フッター位置(default:%(default)s)")    # 引数定義
    parser.add_argument("-t", '--top', type=int, metavar="n", default=1000, help="ヘッダー位置(default:%(default)s)") # 引数定義
    parser.add_argument("-s",'--s_page', type=int, metavar="n", default=1, help="開始ページ(default:%(default)s)") # 引数定義
    parser.add_argument("-e",'--e_page', type=int, metavar="n", default=0, help="終了ページ(0:最終)(default:%(default)s)")   # 引数定義
                
    args = parser.parse_args(argv)              # 引数の解析

◆必要なパッケージ

  • pdfminer.six
    • インストール:pip install pdfminer.six
    • インポート: import pdfminer
    • 本ソースでのインポート
      • from pdfminer.high_level import extract_pages
      • from pdfminer.layout import LAParams, LTTextBox 追加:2022-08-02

◆さいごに

PDF文書のテキスト抽出に関しては、かなりうまくいくと考えています。
しかし、文中に表がある場合はうまく抽出できません。
他にもうまくいかないパターンが見つかるかもしれません。
それでも、自分で目標を立てて、まずは動かすのが大切かなと思っています。

ヘルプの作り方に関して、説明が雑になった感があります。
別記事を作成するか検討してみます。

◇ご注意

本記事は次のバージョンの下で動作した内容を基に記述しています。

  • Python 3.8.5
  • pdfminer.six 20201018

ご利用に際しては、『免責事項』をご確認ください。
お気づきの点がございましたら『お問い合わせ』からお問い合わせください。
追加:2022-08-02

◆参考

投稿: 、更新: