Pythonを使えばテキストを含むPDFの解析は簡単だ・・・そんなふうに考えていた時期が俺にもありました

# まえがき
Pythonを使えばテキストを含むPDFの解析は簡単だ・・・

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47856/aeea18ce-78e8-ba18-27d2-96df2373365d.png)

文字情報が含まれていればPDFから文字やテーブルの情報を抽出して、そのデータを利用してWebサービスなんて簡単につくれるぜ、ひゃっほーいっという安易な思考の結果が以下になります。

**新型コロナウイルス感染症の感染拡大を踏まえたオンライン診療のPDFデータを利用してみた**
https://qiita.com/mima_ita/items/c0f28323f330c5f59ed8

ここで得た最も重要な知見は<font color="red">**「PDFデータをコンピュータで読むのはやめとけ、あれは人間が読むものだ」**</font>ということと、わずかなPythonを使用したPDFの取り扱いの方法です。

今回はそのわずかなPythonを使用したPDFの取り扱い方法について説明します。
なお、実験環境はWindow10のPython 3.7.5 64bitになります。

# PDFの解析
## operandsとoperator
PDFの文字やグラフィックはすべてoperandsとoperatorで構成されており、その仕様は以下に記載されています。
https://www.adobe.com/content/dam/acom/en/devnet/acrobat/pdfs/PDF32000_2008.pdf

PythonではPDFを読み込む際に便利なライブラリが各種ありますが、ここでは[PyPDF2](https://github.com/mstamy2/PyPDF2)を使用してPDFを読んでみます。
このライブラリの特徴はPythonで全て書かれているということで、PDFがどんな内容かをoperandsとoperatorレベルで確認することが可能です。

では実際に以下の単純なPDFがどのようなoperandsとoperatorで構成されているか確認してみましょう。
http://needtec.sakura.ne.jp/doc/hello.pdf

以下のコードはoperandsとoperatorを列挙するコードになります。

```python
import PyPDF2
from PyPDF2.pdf import ContentStream

with open("hello.pdf", "rb") as fp:
    pdf = PyPDF2.PdfFileReader(fp)
    for page_no in range(pdf.numPages):
        page = pdf.getPage(page_no)
        content = page['/Contents'].getObject()
        if not isinstance(content, ContentStream):
            content = ContentStream(content, pdf)
        for operands, operator in content.operations:
            print(operands, operator)

```

これを先ほどの単純なPDFで実行した結果は以下のようになります。

```
[1, 0, 0, 1, 0, 0] b'cm'
[] b'BT'
['/F1', 12] b'Tf'
[14.4] b'TL'
[] b'ET'
[] b'n'
[10, 10, 200, 200] b're'
[] b'S'
[] b'BT'
[1, 0, 0, 1, 100, 50] b'Tm'
['Hello'] b'Tj'
[] b'T*'
[] b'ET'
```

[仕様書](https://www.adobe.com/content/dam/acom/en/devnet/acrobat/pdfs/PDF32000_2008.pdf)のAnnex A Operator Summaryを読みつつ解析していくと以下のことが分かります。

|Operands|Operator|Description|ページ数|
|:-------|:-------|:----------|:----|
|x y width height|re|左下の隅(x,y)からの四角形のパスを追加する|133p|
|-|S|現在のパスに沿って線を描く|135p|
|a b c d e f|Tm|テキストの位置を決定する行列を指定する。<br>![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47856/9eb2efb4-aa86-bdb6-a6f3-79b25cd4b553.png)|249|
|string|Tj|文字を表示する|250|

つまり以下のような描画になります。
(1)左下を原点として(10,10)から幅：200,高さ：200の四角形を描画する
(2)(100, 50)から文字「Hello」を記述する

今回は単純な例だったので読み解くことができましたが、テキストの描画が非常に厄介で、Text-positioning operators とText-Showing Operatorsの挙動を理解しないとPDFから文字を抽出して、その位置や大きさを知ることはできません。

たとえば以下のようなPDFがあります。
http://needtec.sakura.ne.jp/doc/hello2.pdf

見た目的には多少、日本語とテーブルの行列が増えただけですが、これを同様の方法で読み解くのは困難です。


なお、PyPDF2にはpage.extractText()というページを抽出する関数が用意されていますが、アメリカ語使う人以外は多くの困難を味わうことになります。
https://github.com/mstamy2/PyPDF2/issues

## PDFMinerでPDFの文字を解析する
[PDFMiner](https://github.com/pdfminer/pdfminer.six)を使用するとPDF中の文字を抽出するのが容易になります。

以下はPDF中の文字を抽出するサンプルになります。

```python
from pdfminer.high_level import extract_text
print(extract_text('hello2.pdf'))
```

また、PDFMinerの真価は文字を抽出するだけでなく、文字が描画される座標とその大きさを取得することができます。
以下は特定のPDFの文字とその座標情報を抽出するプログラムのサンプルです。

```python
from pdfminer.pdfparser import PDFParser
from pdfminer.pdfdocument import PDFDocument
from pdfminer.pdfpage import PDFPage
from pdfminer.pdfpage import PDFTextExtractionNotAllowed
from pdfminer.pdfinterp import PDFResourceManager
from pdfminer.pdfinterp import PDFPageInterpreter
from pdfminer.converter import PDFPageAggregator
from pdfminer.layout import (
    LAParams,
    LTContainer,
    LTTextLine,
)

def get_objs(layout, results):
    if not isinstance(layout, LTContainer):
        return
    for obj in layout:
        if isinstance(obj, LTTextLine):
            results.append({'bbox': obj.bbox, 'text' : obj.get_text(), 'type' : type(obj)})
        get_objs(obj, results)

def main(path):
    with open(path, "rb") as f:
        parser = PDFParser(f)
        document = PDFDocument(parser)
        if not document.is_extractable:
            raise PDFTextExtractionNotAllowed
        # https://pdfminersix.readthedocs.io/en/latest/api/composable.html#
        laparams = LAParams(
            all_texts=True,
        )
        rsrcmgr = PDFResourceManager()
        device = PDFPageAggregator(rsrcmgr, laparams=laparams)
        interpreter = PDFPageInterpreter(rsrcmgr, device)
        for page in PDFPage.create_pages(document):
            interpreter.process_page(page)
            layout = device.get_result()
            results = []
            print('objs-------------------------')
            get_objs(layout, results)
            for r in results:
                print(r)


main('hello2.pdf')


```

このプログラムを[先の日本語が混在したPDF](http://needtec.sakura.ne.jp/doc/hello2.pdf)を使用して実行した結果が以下のようになります。

```
objs-------------------------
{'bbox': (90.744, 728.1928, 142.2056, 738.7528), 'text': 'Hello 世界 \n', 'type': <class 'pdfminer.layout.LTTextLineHorizontal'>}
{'bbox': (168.5, 728.1928, 223.8356, 738.7528), 'text': '猫が鳴いた \n', 'type': <class 'pdfminer.layout.LTTextLineHorizontal'>}
{'bbox': (168.5, 709.7128, 202.8356, 720.2728), 'text': '私は神 \n', 'type': <class 'pdfminer.layout.LTTextLineHorizontal'>}
{'bbox': (90.744, 691.1128, 146.0456, 701.6728), 'text': '神は死んだ \n', 'type': <class 'pdfminer.layout.LTTextLineHorizontal'>}
{'bbox': (168.5, 691.1128, 171.2456, 701.6728), 'text': ' \n', 'type': <class 'pdfminer.layout.LTTextLineHorizontal'>}
{'bbox': (168.5, 672.6328, 255.2756, 683.1928), 'text': 'あｗねおｌんｓｆ \n', 'type': <class 'pdfminer.layout.LTTextLineHorizontal'>}
{'bbox': (90.744, 709.7128, 93.4896, 720.2728), 'text': ' \n', 'type': <class 'pdfminer.layout.LTTextLineHorizontal'>}
{'bbox': (90.744, 672.6328, 93.4896, 683.1928), 'text': ' \n', 'type': <class 'pdfminer.layout.LTTextLineHorizontal'>}
{'bbox': (85.104, 654.1528, 87.8496, 664.7128), 'text': ' \n', 'type': <class 'pdfminer.layout.LTTextLineHorizontal'>}
```

PDFの文字の内容だけでなくその座標を取得していることが確認できます。

## PDF中のテーブルを解析する
PDFにはテーブルを現わすoperandsとoperatorは存在しません。
ここまでで説明した四角形の描画やテキストの描画を使用してテーブルを表現しているのにすぎません。
そのため、HTMLのテーブルやExcelを解析するように簡単に、PDFのテーブルの解析は行えません。

Pythonのいくつかのライブラリは、そのPDFのテーブルの解析を行おうとしているものがあります。
今回は全てPythonで実装している[camelot](https://github.com/camelot-dev/camelot)を使用します。

[camelot](https://github.com/camelot-dev/camelot)と他のライブラリの比較については下記を参照してください。
https://github.com/atlanhq/camelot/wiki/Comparison-with-other-PDF-Table-Extraction-libraries-and-tools

私が[tabula-py](https://github.com/chezou/tabula-py)と比較した結果は以下になります。
**[厚生労働省のPDFをCSVやJSONに変換する](https://needtec.sakura.ne.jp/wod07672/2020/04/29/%e5%8e%9a%e7%94%9f%e5%8a%b4%e5%83%8d%e7%9c%81%e3%81%aepdf%e3%82%92csv%e3%82%84json%e3%81%ab%e5%a4%89%e6%8f%9b%e3%81%99%e3%82%8b/)**

### 簡単なcamelotのサンプル
[先ほど使用したPDF](http://needtec.sakura.ne.jp/doc/hello2.pdf)のテーブルをcamelotを使用して抽出してみます。

```python
import camelot
tables = camelot.read_pdf('hello2.pdf')

for ix in tables[0].df.index:
    print(ix, tables[0].df.loc[ix][0], '|', tables[0].df.loc[ix][1])
```

**結果**

```
0 Hello 世界 | 猫が鳴いた
1  | 私は神
2 神は死んだ |
3  | あｗねおｌんｓｆ
```

その他、直接、URLを指定してPDFを開いたり、CSVやJSONにエクスポートすることができます。
いくつかのPDFはこれでテーブルを抽出することができるでしょう。


### テーブルの抽出が期待通りに動作しない場合
多くの場合はデフォルトの設定で上手くいきますが、実際PDFを解析すると予期せぬ動きをするときがあります。
この場合は下記のドキュメントを一度よんでみることをお勧めします。

**Advanced Usage**
https://camelot-py.readthedocs.io/en/master/user/advanced.html

Visual Debbugでcamelotがどのような認識をしているか確認しながら、[read_pdfに渡すパラメータを調整する](https://camelot-py.readthedocs.io/en/master/api.html#main-interface)と旨くいく場合があります。


#### PDFMiner用のパラメータの調整
[Tweak layout generation](https://camelot-py.readthedocs.io/en/master/user/advanced.html#tweak-layout-generation)でサラっとのべられていますが、camelotは内部でPDFMinerを使用しています。ここまでの方法でPDFからテーブルが上手く抽出できない場合はPDFMinerに渡すパラメータを調整することで解決が可能な場合があります。

たとえば以下のPDFを先ほどのコードと同じように解析すると2行目が上手く抽出できません。
http://needtec.sakura.ne.jp/doc/hello4.pdf

**出力結果**

```
0 1 | あああ
1  | 2 いいい
2 3 | ううう
```

これは「2」と「いいい」の距離が近すぎて同じ文字列であると認識された結果です。
これを調整するには以下のようにします。

```python
tables = camelot.read_pdf(
    'hello4.pdf',
    layout_kwargs = {
        'char_margin': 0.25
    }
)
```

layout_kwargsはPDFMinerの[pdfminer.layout](https://github.com/obeattie/pdfminer/wiki/pdfminer.layout)に渡すパラメータのオブジェクトとなっています。
char_marginはこの値より距離が近い2つのテキストチャンクは連続していると見なされます。デフォルトは0.5で、それより短くして同じテキストであるとみなされづらくしています。

このパラメータで実行した結果は以下のようになります。

```
0 1 | あああ
1  | 2 いいい
2 3 | ううう
--------------------------
0 1 | あああ
1 2 | いいい
2 3 | ううう
```

#### 点線を含む場合
camelotで点線を含むテーブルを処理すると点線を認識しません。

**Detect dotted line #370**
https://github.com/atlanhq/camelot/issues/370

たとえば、以下のようなPDFがそれにあたります。
➀縦の点線
https://github.com/atlanhq/camelot/files/3565115/Test.pdf

②横の点線
https://github.com/mima3/yakusyopdf/blob/master/20200502/%E5%85%B5%E5%BA%AB%E7%9C%8C.pdf

この解決方法は下記の記事の方法で対応が可能です。

・**[camelotで点線を実線として処理する](https://needtec.sakura.ne.jp/wod07672/2020/05/03/camelot%e3%81%a7%e7%82%b9%e7%b7%9a%e3%82%92%e5%ae%9f%e7%b7%9a%e3%81%a8%e3%81%97%e3%81%a6%e5%87%a6%e7%90%86%e3%81%99%e3%82%8b/)**


簡単にいうと、camelotが処理している途中の画像データを無理やり加工して点線を実線に置き換えて処理を継続しています。

#### なにやってもダメな場合
PDFの作り上なにをやってもダメなケースがあります。
たとえば[セルをはみ出す長い文字を含むデータの場合](https://qiita.com/mima_ita/items/c0f28323f330c5f59ed8#pdf%E3%81%8B%E3%82%89%E3%83%86%E3%83%BC%E3%83%96%E3%83%AB%E3%82%92%E6%8A%BD%E5%87%BA%E3%81%99%E3%82%8B%E9%9A%9B%E3%81%AE%E5%95%8F%E9%A1%8C%E7%82%B9)などです。

また、そもそも罫線を引き忘れていた場合、旨く動作しません。


# PDFの更新
PDFを読み取る方法は前項までで説明しました。
次にPDFの更新について簡単に考えてみます。

## 新しいPDFを作成する
[reportlab](https://github.com/Distrotech/reportlab)を使用してテキストや図形を描画した内容をPDFに出力が可能です。

```python

from io import BytesIO
from reportlab.pdfgen import canvas

with open('hello.pdf', 'wb') as output_stream:
    buffer = BytesIO()
    c = canvas.Canvas(buffer, pagesize=(300, 300))
    c.rect(10, 10, 200, 200, fill=0)
    c.drawString(100, 50, 'Hello')
    c.showPage()
    c.save()
    buffer.seek(0)
    output_stream.write(buffer.getvalue())

```

この出力結果は最初の方で使用したPDFになります。
http://needtec.sakura.ne.jp/doc/hello.pdf


## 既存のPDFのページを書き換える
既存のPDFを読み込んでページ中の図形情報やテキストを書き換える方法を色々調べましたが正直困難そうでした。
ここで紹介する方法は、PDFのページに新しく図形やテキストを加える方法になります。

・**[PDFの点線を実線におきかえる(PyPDF2 + reportlab)](https://needtec.sakura.ne.jp/wod07672/2020/05/04/pdf%e3%81%ae%e7%82%b9%e7%b7%9a%e3%82%92%e5%ae%9f%e7%b7%9a%e3%81%ab%e3%81%8a%e3%81%8d%e3%81%8b%e3%81%88%e3%82%8b/)**
・**[PDFの点線を実線におきかえる(PyMuPDF)](https://needtec.sakura.ne.jp/wod07672/2020/05/04/pdf%e3%81%ae%e7%82%b9%e7%b7%9a%e3%82%92%e5%ae%9f%e7%b7%9a%e3%81%ab%e3%81%8a%e3%81%8d%e3%81%8b%e3%81%88%e3%82%8bpymupdf/)**

なお、PyPDF2には圧縮機能が存在しないため、ファイルの更新は別の手段を使用した方がいいと思います。当方の環境では3MBのPDFが440MBになってしまいました。

# まとめ
ここまでの説明でPDFのデータを利用するのが簡単だと思われる方もいらっしゃるでしょう。
もし、入力となるPDFを修正する権限がない場合、基本的に<font color="red">**茨の道**</font>と思ってください。
たとえば、Excelとかでは罫線を引き忘れてもセルの区別ができますが、PDFではできないのです。
※PDFの点線を実線に置き換える際に使用した方法で、全部のセルに対して線を引くことはできますが、そのセルの罫線が引き忘れなのか敢えて引いていないかはわからないのです。。。

最初に述べた最も重要なことを繰り返し述べて本稿を終わりたいと思います。

<font color="red">**「PDFデータをコンピュータで読むのはやめとけ、あれは人間が読むものだ」**</font>