見出し画像

PDF からテキスト・データを取り出してみました

PDFを読み込む Python のライブラリ

Python でpdfminer や PyMuPDF(fitz) があります。
PDFファイルではパスワードを要求するファイルも存在します。pdfminer はパスワードを要求するファイルには対応できません。①座標を指定してテキストを呼び込む方法と ②テキストデータを読み込み、その後で、読み込んだテキストデータを処理する方法を使いました。
①は、アスクルの《ご注文内容(控え)》のPDFファイルを処理の対象にします。
②は、”みずほ銀行”の《** みずほ入出金履歴.pdf》を処理の対象にしています。
③は、アスクルの同じPDFファイルから座標を使わずにテキストデータを抽出してみました。同じ結果が得られました。

① 座標を指定してテキストデータを抽出

座標を抽出するプログラム

処理対象のPDFファイルのテキスト文字と対応する座標を取得する必要があります。座標の基準は左下の角が(0,0)になっています。PDFの座標の特徴は下から上に座標が増えていく構造です。また対応した文字の矩形は (x1,y1,x2,y2) の形式で、左下が(x1,y1)、右上が(x2,y2)で指定した矩形の中に入る文字を取得する方式になります。

文字に対応した座標を取得するプログラム

import pdfminer
from pdfminer.high_level import extract_pages


def extract_text_and_coordinates(pdf_path):
    data_with_coordinates = []
    for page_layout in extract_pages(pdf_path):
        for element in page_layout:
            if isinstance(element, pdfminer.layout.LTTextBox):
                for text_line in element:
                    x1, y1, x2, y2 = text_line.bbox
                    text = text_line.get_text().strip()
                    if text:
                        data_with_coordinates.append({
                            "text": text,
                            "coordinates": (x1, y1, x2, y2)
                        })
    return data_with_coordinates


if __name__ == "__main__":
    pdf_file_path = 'アスクル.pdf'  # ここにファイル名を指定します
    extracted_data = extract_text_and_coordinates(pdf_file_path)
    #print(extracted_data)

    txt_file = "アスクル PDF 座標.csv"

    # テキストファイルを書き込みモードで開き、データを書き込みます
    with open(txt_file, mode='w', encoding="cp932") as file:

        for entry in extracted_data:

            moji = str(entry["text"])
            zahyou = str(entry["coordinates"])
            data_str = '"' + moji + '",' + zahyou.replace('(','').replace(')','')
            # with open(txt_file, mode='w', encoding='utf-8') as file:

            #print(entry)
            print('文字:', moji, '\n','座標:',zahyou)

            file.write(data_str + '\n')

print分では以下のような表示出力になります。
文字: ご注文内容(控え)
座標: (219.43, 794.73, 375.57, 814.73)
処理対象のPDFファイル名を任意に変えれば動くはずです。
出力ファイルをCSVにしたのはエクセルで読み込み、行の幅などを計算するためです。

座標を指定して文字を抽出するプログラム

会計ソフトJDLに取り込むデータを作る目的でプログラムを作りました。
座標がズレるとテキストデータを取得できない事態が発生します。y1  y2 の幅を微調整する必要があり、かなりめんどくさい状況でした。
以下のプログラムで読めました。JDLの振替伝票のCSVの部分を削除してプログラムを載せます。
項目名と出力結果の一部です。
 日付 , 適用 , 数量 , 単価(税抜), 単価(税込), 小計 , 税率 
[20231027, '10%:ティッシュペーパー【180組5箱】エリエールティシュー 大王製紙', 3, 559, 614, 1842, '10%']
[20231027, '8%:大井川茶園 香り茎入り静岡特上煎茶ティーバッグ 1袋(50バッグ入)', 1, 1630, 1760, 1760, '8%']

import pdfminer
from pdfminer.high_level import extract_pages

def extract_text_from_position(page_layout, x1, y1, x2, y2):
    extracted_text = ""
    for element in page_layout:
        if isinstance(element, pdfminer.layout.LTTextBox):
            for text_line in element:
                if x1 <= text_line.bbox[0] <= x2 and y1 <= text_line.bbox[1] <= y2:
                    extracted_text += text_line.get_text()
    return extracted_text

processed_texts = []

pdf_file_path = 'アスクル.pdf'



# ページ指定
#page_number = 0  # 最初のページを指定

data_list = []
iii = 0

with (open(pdf_file_path, 'rb') as pdf_file):
    for ii in extract_pages(pdf_file):
        #print(ii)
        y_spacing = -71
        iii += 1
        page_layout = ii
        #x1, y1, x2, y2 = coords
        #print('coords :    ', coords)
        page_number = iii
        #print('page No ', page_number)
        page_row = []
        page_row_1 = []
        page_row_2 = []

        if page_number == 1:
            target_coordinates_1 = [
                (237, 521, 405.41, 531.62),  # お届け日
                # (237, 521.62, 405.41, 531.62),  # お届け日
                (142, 539.66, 326.976, 574.03),  # 品名
                (365.82, 557.12, 372, 567.12),  # 数
                (428.73, 562.93, 462, 572.93),  # 税抜
                (428.73, 539.66, 462, 549.66),  # 税込
                (538.73, 551.3, 572, 561.3)  # 税抜合計
            ]

            for i in range(1, 8):
                text_len = []
                if i > 1:
                    target_coordinates_1 = [(x1, y1 + y_spacing, x2, y2 + y_spacing) for x1, y1, x2, y2 in target_coordinates_1]

                for coords in target_coordinates_1:
                    #print(x1, y1, x2, y2)
                    x1, y1, x2, y2 = coords
                    #print(coords)
                    extracted_text = extract_text_from_position(page_layout, x1, y1, x2, y2)
                    #print(i , '元 : ' , extracted_text)
                    extr_text = extracted_text.replace('\\', '').replace(',','').replace('\n',"").replace('\u3000'," ")
                    #print(extr_text)
                    text_len.append(extr_text)
                #print(i, text_len)
                data_list.append(text_len)


        else:
            target_coordinates_2 = [
                (237, 710.62, 405.41, 720.62),  # お届け日
                # (237, 521.62, 405.41, 531.62),  # お届け日
                (142, 752.03, 326.976, 763.03),  # 品名
                (365.82, 746.12, 372, 756.12),  # 数
                (428.73, 751.93, 462, 761.93),  # 税抜
                (428.73, 728.66, 462, 738.66),  # 税込
                (538.73, 740.3, 572, 740.3)  # 税抜合計
            ]
            for i in range(1, 8):
                f_1 = 0
                text_len = []
                if i > 1:
                    target_coordinates_2 = [(x1, y1 + y_spacing, x2, y2 + y_spacing) for x1, y1, x2, y2 in
                                            target_coordinates_2]

                for coords in target_coordinates_2:
                    # print(x1, y1, x2, y2)
                    x1, y1, x2, y2 = coords
                    # print(coords)
                    extracted_text = extract_text_from_position(page_layout, x1, y1, x2, y2)
                    # print(i , '元 : ' , extracted_text)
                    extr_text = extracted_text.replace('\\', '').replace(',', '').replace('\n', "").replace('\u3000', " ")
                    # print(extr_text)
                    text_len.append(extr_text)
                #print(i, text_len)
                data_list.append(text_len)

            #print(i, text_len)

data_list = [row for row in data_list if row[1] != '']
for row in data_list:
    row[0] = row[0].replace('ご注文時のお届け日:','')[:10].replace('/','')
    row[0] = int(row[0])
    tax = round((int(row[4]) - int(row[3]))/int(row[3]) * 100)
    row.append(str(tax) + '%')
    row[1] = (str(tax) + '%:') + row[1]
    row[2] = int(row[2])
    row[3] = int(row[3])
    row[4] = int(row[4])
    row[5] = int(row[5])
    #row[6] = row[6].replace('"','')
    print(row)

② みずほ銀行の普通預金のデータを合計残高を計算してエクセルに展開する

何を目指すか

みずほ銀行の「CSVデータ」やPDFの「みずほビジネスWEB」では入出金ごとの残高は表示してくれません。通帳と同じように表示するためには計算する必要があります。記帳した通帳と整合を確認するために残高でチェックすれば正誤を簡単に見つけることも可能になります。ということで、一目で分かるように「記帳と同じ出力をエクセルで行う」ことを目標にしました。さらにこのデータを元にしてJDL会計システムに取り込む振替伝票のCSVデータも生成することにしました。

課題と対策

CSVから集計するソフトを作りましたが、会社の口座の場合には1日単位でしかデータが落とせず(個人口座の場合には3ヶ月以内なら期間指定で落とせます)、「とても使えない」と社員が愚痴をこぼしました。
PDFの場合には期間指定でデータを取得できますが、パスワードの入力が必要でモジュールの pdfminer が使用できないことが判明しました。 PyMuPDF(fitz)ではパスワード付きのPDFのデータは取得できますが座標データは取得できません(もしかしたらできるのかも?)。ということで ・・・
⒈ データを正規化する。
⒉ パターンを見つけ関連付けを探す。

この方式で対処してみました。
「データの正規化」と「パターンを見つける」ことにAIのChatGPTが使えれば大変面白いと考えています。実験をしてみたいと思っています。
複数のPDFデータで検証しました。予想以上に上手くいきました。ただし、「本当に合っているのか?」かなり不安になりました。みずほ銀行からメール添付で送られてくるPDFファイルがありましたのでこのファイルを複数入れて動かしてみました。正しく動きました。これなら社員も喜ぶかと思います。
座標で指定のデータを抽出する方法より各段に楽です。
今後PDFで仕入伝票も送られてくる時代になると考えています。簡単にデータが取得できればEDIも可能になります。

'pdf_data'のフォルダー内のデータ

詳細はこちらのページで解説いたします

③ 同じアスクルのPDFのデータを座標を利用しない方法で読んでみました。

考えざるを得なかった問題

商品名に対応するデータの要素が、表示が1行の記載の場合には1、表示が2行の場合には2、表示が3行の場合のは3と可変でした。可変のデータをどう取得するかで悩みました。
商品名に対応するデータの範囲を特定する(要素の数を計算する)ことにしました。もう一つの問題は、ページの明細の1行目の要素を取得する方法をどうするかでした。'オーダー管理番号' が 'コメント欄:' と同じ関係にあると判断しこの言葉を行のスタートの要素の判断に利用しました。

if row[0] == 'オーダー管理番号':
    gyou_1 = index

プログラム

import fitz
# pip install PyMuPDF で fitz が使えます。
import os
import datetime
import openpyxl

mizuho = []
mizuho_m = []
def extract_text_from_password_protected_pdf(pdf_path, password):
    extracted_text = ""

    # PDFファイルを開く
    pdf_document = fitz.open(pdf_path)

    if password != "":
        # パスワードが必要な場合は
        pdf_document.authenticate(password)

    # 全ページのテキストを抽出
    for page_num in range(pdf_document.page_count):
        page = pdf_document.load_page(page_num)
        extracted_text += page.get_text()
        pdf_txt.append(page.get_text())
        #print(pdf_txt)

    # PDFを閉じる
    pdf_document.close()

    return extracted_text

folder_path = 'pdf_data'
# PDF データ
# フォルダ内のすべてのファイルを取得します
# 重複するデータがあっても良いように行う。
file_list = os.listdir(folder_path)
# import os の機能を利用して file_listを作ります。

#print(file_list)
file_list = [filename for filename in file_list if not (filename.startswith('.') or '~$' in filename)]
# 隠しファイルなどを取り除く

# フォルダ内の各エクセルファイルに対して処理を行います
pdf_1D_list = []
pdf_moto_txt = ''
pdf_txt = []  # 取得の生データを見るため
 #pdf_txt = []
for file_name in file_list:

    if not ('.pdf' in file_name):  # 対象となるPDFファイルを選択
        print(f"対象外 : {file_name}")
        continue

    else:

        pdf_path = './' + folder_path + '/' + file_name
        password = ''  # パスワードが必要の場合には、パスワードを入れます。必要ない場合は '' にします。

        extracted_text = extract_text_from_password_protected_pdf(pdf_path, password)

        pdf_txt.append(extracted_text)
        print(pdf_txt)  # 正規化する対象データを表示
        # このデータをみてパターンを見つけます。

        extracted_text = extracted_text.replace('\u3000',' ')
         #extracted_text = extracted_text.replace('\\,','')
         #extracted_text = extracted_text.replace(' ','')

        # ファイルが複数あった場合の対処策 全てのデータを extracted_text に
        pdf_moto_txt += extracted_text

    pdf_1D_list = [line for line in pdf_moto_txt.split('\n') if line.strip()]

# 文字列を\nで分割し、空白文字を削除して要素が一つの二次元リストを作成 
# 対象データをマッチすることが簡単にできることを目的にしています。 

# 二次元リスト化
pdf_2D_list = []
for i in pdf_1D_list:
    pdf_m = [None]
    pdf_m[0] = str(i)
    pdf_2D_list.append(pdf_m)
    print(pdf_m)

# さらに正規化
for row in pdf_2D_list:
    txt_0 = row[0]
    txt_0 = txt_0.replace('\\', '').replace(',', '')
    # 金額は ['\\1,670'] のデータになる。 
    row[0] = txt_0
    #print(row)

# 同じ構造・データを作り、index番号をキーにしてコピーしたデータから目的のデータを取得するため。
pdf_2D_list_copy = pdf_2D_list

data_lsit = []
gyou_suu = 0
gyou_1 = 0
hinmei = ''

 #gyou_0 = 13

for index, row in enumerate(pdf_2D_list):  # index を取得するため
    # ページの最初の行には'コメント欄:' が存在せす、一行目のスタート時点がわからないための対処策。
    if row[0] == 'オーダー管理番号':
        gyou_1 = index
        print('gyou_1 = ', gyou_1, '*********')

    # 'コメント欄:' が 明細行のデータの最後になり、必ず存在するので、この要素の index を基準に各種データを取得する
    if row[0] == 'コメント欄:':  #:
        gyou_suu = index - gyou_1  # 一つの明細行に何個の要素数があるかを計算する。
        gyou_1 = index  # 次の行の先頭になるので現在のindex番号を入れる。
        print(index,'gyou_suu = ', gyou_suu ,'gyou_1 =', gyou_1 )


        data_mei = [None, None, 0, 0, 0, 0, None]
        # 品名は可変なので、可変に対応するように明細行の要素数を計算し、対応
        if gyou_suu == 12:  # 1行
            hinmei = pdf_2D_list_copy[index - 9][0]
            # print(hinmei)

        if gyou_suu == 13:  # 2行
            hinmei = pdf_2D_list_copy[index - 10][0] + pdf_2D_list_copy[index - 9][0]
            # print(hinmei)

        if gyou_suu == 14:  # 3行
            hinmei = pdf_2D_list_copy[index - 11][0] + pdf_2D_list_copy[index - 10][0] + pdf_2D_list_copy[index - 9][0]
            # print(hinmei)

        data_mei[0] = pdf_2D_list_copy[index - 1][0].replace('ご注文時のお届け日:', '')[:10].replace('/', '')
        data_mei[1] = hinmei
        data_mei[2] = int(pdf_2D_list_copy[index - 8][0])  # 数量は 8 行 上のデータ
        data_mei[3] = int(pdf_2D_list_copy[index - 5][0])  # 税抜き単価 5 行 上のデータ
        data_mei[4] = int(pdf_2D_list_copy[index - 4][0])  # 税込単価 4 行 上のデータ
        data_mei[5] = int(pdf_2D_list_copy[index - 2][0])  # 税込小計 2 行 上のデータ

        tax = round((data_mei[4] - data_mei[3]) / data_mei[3] * 100)
        #print(str(tax) + '%')
        data_mei[6] = str(tax) + '%'
        #print(data_mei)
        data_lsit.append(data_mei)


この記事が気に入ったらサポートをしてみませんか?