[Python] PDFをOCR処理して、テキスト埋め込みPDFを作成する

# 1. はじめに

英語文献PDFで文字埋め込みされていないため、翻訳ツールを使うのに支障がある状態だったので、PDFをOCR処理して文字埋め込みしたPDFを作成するソフトウェアを作成しました。

PDFをOCR処理して文字埋め込みする処理の流れ自体はほぼ以下の記事を参考にしています。

https://qiita.com/Ryo-F/items/9cc168bc5e531e223f27

そのためPythonでの処理を載せてもあまり意味がないので、本記事ではWindowsでのインストールおよびGUIアプリ化して以下のようなプログラムを作成する内容を主としています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3079481/df71b3a4-c6da-dd18-53ab-473ce3d9f964.png)

## 1.1. 動作確認環境

* Windows 10 Pro 64bit
* Python 3.11.5
* pdf2image 1.16.3
* Poppler 23.05
* QPDF 11.4.0
* Tesseract OCR 5.3.1.20230401

# 2. PDFをOCR処理して文字を埋め込む

## 2.1. 全体の処理の流れ

処理の流れとしては、以下のとおりです。

1. pdf2imageでPDFを画像化（内部処理でpopplerを使用）
1. Tesseract OCRでテキストオンリーPDFを作成
1. QPDFで元PDFにテキストオンリーPDFをオーバーレイ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3079481/d7b0fe7c-8ebd-b984-766a-35831d9b38c5.png)

最初に紹介した記事では`pyocr`を使って、OCRした文章をテキスト出力していますが、OCR済みのPDFを作るだけであればテキスト出力は不要なので、`pyocr`は不要です。

そのためpythonのモジュールとして必要なのは`pdf2image`です。

```Python:
pip install pdf2image
```

## 2.2. インストールが必要な外部ソフトウェア

Python単独ではPDFをOCR処理して文字を埋め込むことはできないため、コマンドラインで動く外部ソフトウェアをインストールする必要があります。

|ソフトウェア|概要|
|-|-|
|poppler|PDFのビューワや操作を行えるコマンドラインツール<br>|
|Tesseract OCR|OCRエンジン|
|QPDF|PDFの加工や調査に使えるライブラリおよびコマンドラインツール|

### 2.2.1. poppler

<a href="https://poppler.freedesktop.org/">popplerの公式サイト</a>にはソースしかないため、Windowsにインストールできないので、<a href="https://github.com/oschwartz10612/poppler-windows/releases">Windows用にコンパイルしてくれているサイト</a>からダウンロードします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3079481/8be972e7-ae85-9282-5521-d55760afd6bc.png)


zipフォルダを解凍すると以下のようなフォルダ構成ができるので、以下の手順を配置します。
1. [share]フォルダ内の[poppler]フォルダを、[Library]フォルダ内の[share]フォルダ内に移動
1. [Library]フォルダを[poppler]に変更して、任意の場所に移動させる（Cドライブ直下やProgram Filesなど）
1. （必要に応じて）poppler\binフォルダへパスを通す
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3079481/d6488378-54a3-87f5-ea02-8d5949af84cc.png)

### 2.2.2. Tesseract OCR

<a href="https://github.com/tesseract-ocr/tessdoc#5xx">Tesseract OCRの公式サイト</a>からリンクも貼られている<a href="https://github.com/UB-Mannheim/tesseract/wiki">Mannheim UniversityのGitHub</a>からWindows版をダウンロードします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3079481/c8c59f9d-02b2-c1bf-de13-5cb47a109eeb.png)

Tesseractのインストールでの注意点は、Select components to installで、[Additional script data (download)]と[Additional language data (download)]において、[Japanese script]と[Japanese vertical script]を選択しないと日本語認識してくれません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3079481/610dee36-2ba2-8ec9-462b-5316cf510e8d.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3079481/3ef6dd4e-f710-58e5-2b5a-7073f090dc4d.png)

また標準でインストールされる言語データは速度重視のファイルらしく、精度はあまりよくありません。多少時間がかかっても精度を上げたい場合には、以下のサイトにある jpn.traineddata とjpn_vert.traineddataと入れ替える必要があります（英語も対象の場合はeng.traineddataも入れ替えます）

Tesseract-OCRの言語データはインストールしたTesseract-OCRフォルダ内のtessdataフォルダ内にあります。

https://github.com/tesseract-ocr/tessdata_best/blob/main/jpn.traineddata

https://github.com/tesseract-ocr/tessdata_best/blob/main/jpn_vert.traineddata

### 2.2.3. QPDF

<a href="https://qpdf.sourceforge.io/">QPDFの公式サイト</a>のLatest Releaseから、`qpdf-xx.x.x-msvc64.zip`をダウンロード

popplerと同じく任意のフォルダに保存して、（必要に応じて）binフォルダへパスを通します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3079481/0424bb01-c74e-350e-bd7c-38bf7be13de0.png)

### 2.2.4. パスの通し方

[スタート]->[設定]->[システム]->[詳細情報]->[システムの詳細設定]->[環境変数(N)...]->[Path]の編集から[新規]でインストールしたフォルダを追加

インストールが完了して、パスが通っていれば、それぞれ以下のコマンドでヘルプが参照できます。

|ソフトウェア|コマンド|備考|
|-|-|-|
|poppler|pdftocairo --help|popplerは12のexeから構成されるライブラリです。<br>`pdftocairo`がPDFを画像形式に変換するexeになります。|
|Tesseract OCR|tesseract --help||
|QPDF|qpdf --help||

# 3. PythonでのGUIへの実装例

## 3.1. 概要とソースコード

* OCRするPDFファイル選択と言語選択を選択して、開始ボタンを押せば実行されます。
* 進捗状況は下部の`scrolledText`へ出力するようにしています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3079481/df71b3a4-c6da-dd18-53ab-473ce3d9f964.png)

<details><summary><b>[実装例のソースコード]</b></summary><div>

```Python:PDFOCR.pyw
import os
import sys
import time
import threading
import subprocess
from pdf2image import convert_from_path
import tkinter as tk
from tkinter import ttk
from tkinter import messagebox
from tkinter import filedialog
from tkinter import scrolledtext


class Application(tk.Frame):
    def __init__(self, master=None):
        super().__init__(master)
        self.master.geometry('500x330')
        self.master.title('PDF OCR')
        self.master.resizable(width=False, height=True)

        self.master.grid_rowconfigure(3, weight=1)
        self.master.grid_columnconfigure(2, weight=1)

        self.DirLabel = ttk.Label(self.master, text='PDF選択')
        self.DirLabel.grid(row=0, column=0, padx=(10, 0), pady=(10, 5), sticky='ew')
        self.entry = tk.StringVar()
        self.DirEntry = ttk.Entry(self.master, textvariable=self.entry, width=50)
        self.DirEntry.grid(row=0, column=1, columnspan=2, padx=10, pady=(10, 5), sticky='ew')
        self.DirButton = ttk.Button(self.master, text='参照', command=self.dialog_open)
        self.DirButton.grid(row=0, column=3, padx=(0, 10), pady=(10, 5), sticky='ew')
        self.radio = tk.StringVar(value='jpn')
        self.LangLabel = ttk.Label(self.master, text='言語選択')
        self.LangLabel.grid(row=1, column=0, padx=(10, 0), pady=(0, 10), sticky='ew')
        self.radio_jpn = tk.Radiobutton(self.master, text='Japanese', value='jpn', variable=self.radio)
        self.radio_jpn.grid(row=1, column=1, padx=(10, 0), pady=(0, 10), sticky='w')
        self.radio_eng = tk.Radiobutton(self.master, text='English', value='eng', variable=self.radio)
        self.radio_eng.grid(row=1, column=2, padx=(10, 0), pady=(0, 10), sticky='w')
        self.button = ttk.Button(self.master, text='PDF OCR 開始', command=self.callback, width=40)
        self.button.grid(row=2, column=0, columnspan=4, padx=10, pady=(0, 10), sticky='ew')
        self.scrolledText = scrolledtext.ScrolledText(self.master)
        self.scrolledText.grid(row=3, column=0, columnspan=4, padx=10, pady=(0, 10), sticky='nsew')
        self.scrolledText.tag_config('error', foreground="red")

        # printなどの標準出力をscrolledTextに表示させる
        sys.stdout.write = self.output_print
        sys.stderr.write = self.output_error

        # subprocessで呼び出す外部ソフトがインストールされているかの確認
        self.is_external_software_installed()

    # 標準出力の表示
    def output_print(self, msg):
        self.scrolledText.insert(tk.END, msg)
        self.scrolledText.see("end")

    # 標準エラー出力の表示（赤字で表示させる）
    def output_error(self, msg):
        self.scrolledText.insert(tk.END, msg, 'error')
        self.scrolledText.see("end")

    # subprocessで呼び出す外部ソフトがインストールされているかの確認
    def is_external_software_installed(self):
        check_lists = [('qpdf', 'QPDF'), ('tesseract', 'Tesseract'), ('pdftocairo', 'poppler')]
        not_installed = []
        for cmd, software in check_lists:
            try:
                subprocess.Popen(f'{cmd} --help', stdout=subprocess.PIPE, stderr=subprocess.PIPE)
            except FileNotFoundError:
                not_installed.append(software)
        if not_installed:
            messagebox.showerror('Error', f'{", ".join(not_installed)}がインストールされていないか、パスが通っていません')
            self.widgets_disabled()

    # ファイル選択ダイアログボックス（__file__はexe化すると使えないのでsys.argv[0]）
    def dialog_open(self):
        current_dir = os.path.dirname(sys.argv[0])
        current_path = filedialog.askopenfilename(title="PDFファイルを開く", filetypes=[("PDF", ".pdf")], initialdir=current_dir)
        self.entry.set(current_path)

    # 処理経過を表示するための関数
    def progress_print(self, *args, before=0):
        now = time.time()
        if len(args) == 1:
            print(args[0])
        else:
            print(f"{args[0]}：{now - before:.3f}sec\n{args[1]}")
        return now

    # ウィジェットの無効化
    def widgets_disabled(self):
        self.button.state(["disabled"])
        self.DirButton.state(["disabled"])
        self.DirEntry.state(["disabled"])
        self.radio_jpn.configure(state=tk.DISABLED)
        self.radio_eng.configure(state=tk.DISABLED)

    # ウィジェットの有効化
    def widgets_enabled(self):
        self.button.state(["!disabled"])
        self.DirButton.state(["!disabled"])
        self.DirEntry.state(["!disabled"])
        self.radio_jpn.configure(state=tk.NORMAL)
        self.radio_eng.configure(state=tk.NORMAL)

    # PDF-OCRのメインの処理
    def pdfocr_process(self, pdf_path):
        # 0.ディレクトリの設定・作成・削除
        current_dir = os.path.dirname(sys.argv[0])
        pdf_file = os.path.splitext(os.path.basename(pdf_path))[0]
        tmp_path = f'{current_dir}/tmp_file/{pdf_file}'
        ocr_file = f'{current_dir}/ocr_file/{pdf_file}_ocr.pdf'
        for folder_name in ['tmp_file', 'ocr_file']:
            if not os.path.exists(f'{current_dir}/{folder_name}'):
                os.mkdir(f'{current_dir}/{folder_name}')
        for file in [f'{tmp_path}.tif', f'{tmp_path}_txtonly.pdf', ocr_file]:
            if os.path.exists(file):
                os.remove(file)

        # 1.PDFから画像に変換
        print(f'-----------\n{pdf_file}.pdfのOCR処理を開始します')
        start = self.progress_print('1.PDFから画像変換中')
        images = convert_from_path(pdf_path, 300)
        elapse1 = self.progress_print('  -- 完了 --', '2.TIFFファイルとして保存中', before=start)

        # 2.マルチページのTIFFとして保存する
        images[0].save(f'{tmp_path}.tif', "TIFF", compression="tiff_deflate", save_all=True, append_images=images[1:])
        elapse2 = self.progress_print(' -- 完了 --', '3.テキストオンリーPDF生成中', before=elapse1)

        # 3.テキストオンリーpdfの生成
        lang = self.radio.get()
        cmd = f'tesseract -c page_separator="[PAGE SEPRATOR]" -c textonly_pdf=1 "{tmp_path}.tif" "{tmp_path}_txtonly" -l {lang} pdf'
        returncode = subprocess.Popen(cmd, shell=True)
        returncode.wait()
        elapse3 = self.progress_print(' -- 完了 --', '4.透過文字埋め込みPDF生成中', before=elapse2)

        # 4.元のPDFにテキストオンリーPDFをオーバーレイ
        cmd = f'qpdf --overlay "{tmp_path}_txtonly.pdf" -- "{pdf_path}" "{ocr_file}"'
        returncode = subprocess.Popen(cmd, shell=True)
        returncode.wait()
        self.progress_print(' -- 完了 --', f'■■  PDFからのOCR完了  ■■\n■■ 処理時間：{(time.time() - start):.3f}sec ■■\n', before=elapse3)

    # マルチスレッドで呼び出される実行関数
    def main(self):
        pdf_path = self.entry.get()
        try:
            if not pdf_path:
                raise UnboundLocalError
            if not os.path.exists(pdf_path):
                raise FileNotFoundError
            self.pdfocr_process(pdf_path)
        except FileNotFoundError as e:
            messagebox.showerror('Error', f'指定のファイルが存在しません\n{e}')
        except UnboundLocalError:
            messagebox.showerror('Error', 'ファイルパスが入力されていません')
        except Exception as e:
            print(e, file=sys.stderr)
        finally:
            self.widgets_enabled()

    # 実行ボタン押下時の実行関数(ウィジェット無効化＋マルチスレッド呼び出し)
    def callback(self):
        self.widgets_disabled()
        th = threading.Thread(target=self.main)
        th.start()


if __name__ == '__main__':
    root = tk.Tk()
    app = Application(master=root)
    app.mainloop()
```

</div></details>

## 3.2. 説明

### 3.2.1. PDFをOCR処理して文字を埋め込む処理

* いろいろと進捗状況表示用やファイルパスを指定したりするのにコードが長くなっていますが、必須の処理は以下の構成になっています。
* popplerのみpdf2imageからの呼び出しですが、TesseractとQPDFは`subprocess.Popen`で呼び出します。

* Tesseractのコマンドは <a href="http://blog.machine-powers.net/2018/08/02/learning-tesseract-command-utility/">tesseractコマンドの使い方（Tesseract OCR 4.x）</a>、QPDFのコマンドは <a href="http://pdf-file.nnn2.com/?p=865">Qpdf : コマンドラインのPDFツール</a>で勉強しましたが、機能的には最初に示した記事をコピペした状態です。

```Python:
        # pdf2imageでPDFを画像形式で保存（マルチページTIFF）
        images = convert_from_path(pdf_path, 300)
        images[0].save(f'{tmp_path}.tif', "TIFF", compression="tiff_deflate", save_all=True, append_images=images[1:])

        # TesseractでテキストオンリーPDFを作成
        cmd = f'tesseract -c page_separator="[PAGE SEPRATOR]" -c textonly_pdf=1 "{tmp_path}.tif" "{tmp_path}_txtonly" -l {lang} pdf'
        returncode = subprocess.Popen(cmd, shell=True)
        returncode.wait()

        # QPDFでテキストオンリーPDFを元のPDFにオーバーレイ
        cmd = f'qpdf --overlay "{tmp_path}_txtonly.pdf" -- "{pdf_path}" "{ocr_file}"'
        returncode = subprocess.Popen(cmd, shell=True)
        returncode.wait()
```

### 3.2.2. 進捗状況表示画面

* `sys.stdout.write`および`sys.stderr.write`に関数を指定することで、通常コマンドプロンプトなどに出力される標準出力を補足して、任意のウィジェットで使うことができます。
* 今回は`scrolledText`へ出力していて、エラー出力については`insert`のタグを付与することで、`self.scrolledText.tag_config('error', foreground="red")`を適用するようにできます。

```Python:
class Application(tk.Frame):
    def __init__(self, master=None):

        ...(中略)...

        self.scrolledText = scrolledtext.ScrolledText(self.master)
        self.scrolledText.grid(row=3, column=0, columnspan=4, padx=10, pady=(0, 10), sticky='nsew')
        self.scrolledText.tag_config('error', foreground="red")

        # printなどの標準出力をscrolledTextに表示させる
        sys.stdout.write = self.output_print
        sys.stderr.write = self.output_error

    # 標準出力の表示
    def output_print(self, msg):
        self.scrolledText.insert(tk.END, msg)
        self.scrolledText.see("end")

    # 標準エラー出力の表示
    def output_error(self, msg):
        self.scrolledText.insert(tk.END, msg, 'error')
        self.scrolledText.see("end")
```

### 3.2.3. 進捗状況表示

* 今回の実装において進捗状況と処理時間を表示したかったので、1行でまとめて書けるように以下の関数を定義しています。
* 処理開始と完了を同じ関数で実行できるように引数を`*args`と`before=0`としています。

```Python:
    def progress_print(self, *args, before=0):
        now = time.time()
        if len(args) == 1:
            print(args[0])
        else:
            print(f"{args[0]}：{now - before:.3f}sec\n{args[1]}")
        return now

    start = self.progress_print('1.PDFから画像変換中')
    ...処理実行...
    elapse1 = self.progress_print('  -- 完了 --', '2.TIFFファイルとして保存中', before=start)
```

### 3.2.4. 外部ソフトウェアのインストール確認

* 今回のプログラムでは外部ソフトウェアがインストールされていないと動かないので、起動時にコマンドプロンプトでインストールされているか確認して、インストールされていない場合はウィジェットを無効化して機能しないようにしています。
* 実装例ではパスが通っている前提での書き方になっています。

```Python:
    def is_external_software_installed(self):
        check_lists = [('qpdf', 'QPDF'), ('tesseract', 'Tesseract'), ('pdftocairo', 'poppler')]
        not_installed = []
        for cmd, software in check_lists:
            try:
                subprocess.Popen(f'{cmd} --help', stdout=subprocess.PIPE, stderr=subprocess.PIPE)
            except FileNotFoundError:
                not_installed.append(software)
        if not_installed:
            messagebox.showerror('Error', f'{", ".join(not_installed)}がインストールされていないか、パスが通っていません')
            self.widgets_disabled()
```

### 3.2.5. ウィジェットの無効化/有効化

* tkウィジェットとttkウィジェットで無効化/有効化の方法が異なります。
* tkウィジェットは`.configure(state=tk.DISABLED)`で無効化、`.configure(state=tk.NORMAL)`で有効化です。
* ttkウィジェットは`.state(["disabled"])`で無効化、`.state(["!disabled"])`で有効化です。
* 今回の実装例では、処理中にボタンが押せてしまうと不要な呼び出しがかかるため、処理中にもボタンを無効化するようにしています。

```Python:
    # ウィジェットの無効化
    def widgets_disabled(self):
        self.button.state(["disabled"])
        self.DirButton.state(["disabled"])
        self.DirEntry.state(["disabled"])
        self.radio_jpn.configure(state=tk.DISABLED)
        self.radio_eng.configure(state=tk.DISABLED)

    # ウィジェットの有効化
    def widgets_enabled(self):
        self.button.state(["!disabled"])
        self.DirButton.state(["!disabled"])
        self.DirEntry.state(["!disabled"])
        self.radio_jpn.configure(state=tk.NORMAL)
        self.radio_eng.configure(state=tk.NORMAL)
```

### 3.2.6. マルチスレッディング

* 今回の処理は外部ソフトウェアを呼び出した際に処理時間がかかります。
* tkinterでは`mainloop()`でイベントの待機処理を行っているため、時間のかかる処理を実行すると応答していないものとして認識して、裏で処理は回っていてもtkinterの画面は固まります。
* それを回避するために時間のかかる処理をマルチスレッドで呼び出します。

```Python:
import threading

    def callback(self):
        self.widgets_disabled()
        th = threading.Thread(target=self.main)
        th.start()
```

### 3.2.7. 例外処理

* 例外として処理する必要はないかもしれませんが、ファイルパスが入力されていない場合、ファイルが存在しない場合にメッセージボックスで表示させています。
* その他の例外は`scrolledText`へ表示されるようにしており、例外が発生してもしなくても、最後にウィジェットの有効化処理を実行します。

```Python:
    def main(self):
        pdf_path = self.entry.get()
        try:
            if not pdf_path:
                raise UnboundLocalError
            if not os.path.exists(pdf_path):
                raise FileNotFoundError
            self.pdfocr_process(pdf_path)
        except FileNotFoundError as e:
            messagebox.showerror('Error', f'指定のファイルが存在しません\n{e}')
        except UnboundLocalError:
            messagebox.showerror('Error', 'ファイルパスが入力されていません')
        except Exception as e:
            print(e, file=sys.stderr)
        finally:
            self.widgets_enabled()
```

# 4. 最後に

* 今回メインの処理となる部分は外部ソフトウェアを使うこともあって対して長くもないのに、GUI化しようとしたら思いのほかケアしないといけない点が多く勉強になりました。
* 正直GUI化する必要があるのか？という内容ですけど、趣味なので今後も気になったところは色々とやってみたいと思います。