Python で PDF から下付き文字を抽出する

翻訳会社の PM さんがある作業をするための人手が足りなくて難儀しているので、少しでも作業を楽にできないか考えてみました。
1. やりたいこと
英語版の PDF と日本語訳した PDF を見比べて、英語版の下付き文字が日本語版でもちゃんと下付き文字になっているかどうか確認したい。なお、英語版 PDF の InDesign ファイルは手元にないという状況。
この作業の何が辛いって、非常に小さな下付き文字をすべて目 Grep で探さなければいけないのです。はっきり言って目が死にます。せめて目 Grep だけはしないで済むようにと、下記の方法を考えました。
- 英語版の PDF から下付き文字のテキストを抽出する。
- 「高度な検索」機能を使って日本語訳した PDF 内を検索し、抽出した下付き文字のテキストを探す。
- 下付き文字になっているか確認する。
Python のライブラリを上手く活用すれば出来そうな気がするので、ひとまず ChatGPT に聞いてみました。
2. ChatGPT の回答
PDFファイルから下付き文字になっているテキストを検索または抽出するには、PythonのPDF解析ライブラリを使用する方法があります。具体的には、以下の手順を実行します:
必要なライブラリ
- PyPDF2: PDFファイルを読み取るためのライブラリ。
- pdfplumber: PDF内のテキストやスタイル情報を詳細に解析可能。
-
PyMuPDF (別名
fitz
): フォントサイズや位置情報を取得するために便利。
方法
-
下付き文字の特定:
- PDF内のテキストのスタイルを解析し、フォントサイズやY座標(縦方向位置)を比較します。
- 下付き文字は通常、標準文字より小さいフォントサイズまたは相対的に低いY座標を持つことが多いです。
-
コード例
以下はPyMuPDF
を使った例です:
import fitz # PyMuPDF
def extract_subscripts(pdf_path):
subscripts = []
# PDFを開く
with fitz.open(pdf_path) as doc:
for page_num, page in enumerate(doc, start=1):
blocks = page.get_text("dict")["blocks"] # テキストブロックを取得
for block in blocks:
for line in block.get("lines", []):
for span in line.get("spans", []):
# フォントサイズとY座標を取得
font_size = span["size"]
origin_y = span["origin"][1] # Y座標
# 下付き文字を特定 (例: フォントサイズが小さい and Y座標が低い)
if span["size"] < font_size * 0.8 and span["origin"][1] > origin_y + 2:
subscripts.append({
"text": span["text"],
"page": page_num,
"size": span["size"],
"position": span["origin"]
})
return subscripts
# 使用例
pdf_path = "example.pdf" # PDFファイルのパス
subscripts = extract_subscripts(pdf_path)
# 結果表示
for item in subscripts:
print(f"Page {item['page']}: {item['text']} (Font Size: {item['size']}, Position: {item['position']})")
説明
-
get_text("dict")
: 各ページのテキスト、フォントサイズ、位置情報などを辞書形式で取得。 - 条件チェック: フォントサイズが基準より小さい、またはY座標が他の文字より低いテキストを下付き文字として抽出。
- 出力フォーマット: 下付き文字の内容、ページ番号、フォントサイズ、位置情報を出力。
その他のポイント
- 精度調整: 下付き文字の基準となる閾値(フォントサイズ比率やY座標差)を調整できます。
-
複雑なPDFの場合: 特殊なエンコーディングやレイアウトの場合、
pdfplumber
を組み合わせて解析することも検討してください。
これでPDF内の下付き文字を効率的に抽出できます!
以上が ChatGPT の回答でした。
3. Python の環境構築
上記の ChatGPT のコードで実際に下付き文字を抽出できるかどうか早速試して…みたいところですが、その前に、忘備録として Python の環境構築について書き留めておこうと思います。そんなんええから、早くコードを試してみたい!! という方は読み飛ばしてください。
Python をアップグレード
Python やバージョン管理ツールの pyenv は既にインストールしてあるのですが、Python のバージョンが若干古くなっていたので最新バージョンをインストールします。なお、私の Mac の現在の環境では、その前に pyenv のアップグレードが必要でした。
brew update && brew upgrade pyenv
pyenv install 3.13.1
毎度のことですが brew update
が完了するのにめちゃくちゃ時間がかかりました😢
3.13.1 のインストールに成功したら次のコマンドで切り替えてからバージョンを表示します。
pyenv global 3.13.1
python3 --version
「Python 3.13.1」と表示されれば OK です。
仮想環境を作成
次のコマンドでプロジェクト直下に「myvenv」という仮想環境を作成してアクティベートします(仮想環境の名前は「myvenv」じゃなくても OK)。
python -m venv myvenv
. myvenv/bin/activate
もし「permission denied: myvenv/bin/activate」と表示された場合、次のコマンドで「myvenv」ディレクトリに実行権限があるかどうか確認します。
ls -l myvenv/bin/activate
次のように表示された場合は実行権限がありません。
-rw-r--r-- 1 user group ... myvenv/bin/activate
なので、実行権限(x)を付与します。
chmod +x myvenv/bin/activate
再度権限を確認して、次のように表示されたら OK です。
-rwxr-xr-x 1 user group ... myvenv/bin/activate
この後、もう一度 . myvenv/bin/activate
を実行すれば開発環境がアクティベートされ、プロンプトの最初に「(myvenv)」と表示されます。
なお、仮想環境から抜けたいときは下記のコマンドを実行します。
deactivate
PyMuPDF のインストール
PyMuPDF 公式ドキュメント:
インストールのコマンド:
pip install --upgrade pymupdf
インストールは成功しましたが、なぜかコードの「import pymupdf 」のところに「インポート"pymupdf"を解決できませんでした」というエラーが出てしまいます。
そこで、ChatGPT のアドバイスに従って次のコマンドを実行し、python と pip が正しい仮想環境を指しているか確認してみました。
which python
which pip
すると、pip の方は仮想環境のパスが表示されましたが、python の方は pyenv によって管理されている Python バージョンを指していることが判明しました。これはどうやらエイリアスのせいと思われるので、次のコマンドを使用して、仮想環境内でエイリアスを一時的に無効化してみました。
unalias python
その後 which python
を実行したところ、ちゃんと仮想環境のパスが表示されるようになりました。
ところが、それでもまだ VS Code 上ではインポートエラーが出ています。そこで、次のコマンドを試してみました。
python -c "import pymupdf; print(pymupdf.__version__)"
すると、ちゃんと PyMuPDF のバージョンが表示されるではありませんか!
Pylance のバグかもしれないので、ひとまず無視して先に進めることにしました。
4. コードの調整と実行
いよいよコードを実行していきます。
テストに使用した PDF:
実行しながらコードを修正していったのですが、最終的には下記の形になりました。
import pymupdf # PyMuPDF
def extract_subscripts(pdf_path):
subscripts = []
# PDFを開く
with pymupdf.open(pdf_path) as doc:
for page_num, page in enumerate(doc, start=1):
blocks = page.get_text("dict")["blocks"] # テキストブロックを取得
for block in blocks:
for line in block.get("lines", []):
for span in line.get("spans", []):
# フォントサイズとY座標を取得
font_size = span["size"]
origin_y = span["origin"][1] # Y座標
# 文字が小さいかつ、Y座標が基準よりも低い場合を下付き文字として特定
if font_size < 7 and origin_y - font_size / 2 > 0:
subscripts.append({
"text": span["text"],
"page": page_num,
"size": font_size,
"position": span["origin"]
})
return subscripts
# 使用例
pdf_path = "ADG5298.pdf" # PDFファイルのパス
subscripts = extract_subscripts(pdf_path)
# 結果をファイルに書き出し
output_file = "subscripts.txt" # 出力ファイルのパス
with open(output_file, "w") as f:
if subscripts:
for item in subscripts:
f.write(f"Page {item['page']}: {item['text']} (Font Size: {item['size']}, Position: {item['position']})\n")
else:
f.write("下付き文字は見つかりませんでした。\n")
print(f"結果は {output_file} に書き出されました。")
まず、公式ドキュメントに従って次のように変更しました。
- import fitz
+ import pymupdf
確かに import fitz
でも動くことは動くのですが、公式によると、これはバージョン 1.24.3 以前のレガシーなモジュール名とのことです。ところが、ChatGPT は頑なに import fitz
を勧めてきました。やっぱり AI の言うことを鵜呑みにしたら駄目ですね…
次に、下記の条件を変更しました。
- if span["size"] < font_size * 0.8 and span["origin"][1] > origin_y + 2:
+ if font_size < 7 and origin_y - font_size / 2 > 0:
最初は font_size < 5
で試したのですが、それだと値が小さすぎて全く抽出されなくなってしまうので、色々試した結果上記の条件に落ち着きました。
さらに、抽出されるテキストが多すぎるため、「subscripts.txt」というファイルに書き出すことにしました。
抽出した結果の一部:
Page 1: SS (Font Size: 5.519999980926514, Position: (59.70000076293945, 325.260009765625))
Page 1: DD (Font Size: 5.519999980926514, Position: (83.0999984741211, 325.260009765625))
Page 1: S1 (Font Size: 6.050809383392334, Position: (400.8599853515625, 176.4000244140625))
Page 1: S8 (Font Size: 6.050809383392334, Position: (400.8599853515625, 245.69879150390625))
Page 1: D (Font Size: 6.050809383392334, Position: (492.95855712890625, 210.7811279296875))
Page 1: 1-OF-8 (Font Size: 6.050809383392334, Position: (442.3757019042969, 265.80413818359375))
Page 1: DECODER (Font Size: 6.050809383392334, Position: (436.7945251464844, 271.86285400390625))
Page 1: A0 A1 A2 EN (Font Size: 6.050809383392334, Position: (433.496826171875, 297.4844055175781))
Page 1: 14872-001 (Font Size: 4.033872604370117, Position: (507.659912109375, 298.44000244140625))
Page 1: INH (Font Size: 5.519999980926514, Position: (357.6600036621094, 630.179931640625))
Page 1: INL (Font Size: 5.519999980926514, Position: (407.3999938964844, 630.179931640625))
Page 1: L (Font Size: 5.519999980926514, Position: (451.8599853515625, 642.2999877929688))
Page 1: (Font Size: 1.0199999809265137, Position: (54.0, 704.4600219726562))
誤検出もあるけど、比較的いい感じで抽出できました!!
さて、エンジニアではない PM さん達とどうやってプログラムを共有するか…それはまた後日考えることにします。

5. コードを改善
どうやって PM さん達と共有するか考えた結果、Windows 仮想マシンに持っていって .exe 化するのが最も手っ取り早いと判断しました。しかしそうするとソースコードを直接いじってもらうことができないため、PDF ファイル名や抽出したい下付き文字の大きさを指定できるようにしました。
import pymupdf # PyMuPDF
import sys
import os
def extract_subscripts(pdf_path, threshold):
subscripts = []
# PDFを開く
with pymupdf.open(pdf_path) as doc:
for page_num, page in enumerate(doc, start=1):
blocks = page.get_text("dict")["blocks"] # テキストブロックを取得
for block in blocks:
for line in block.get("lines", []):
for span in line.get("spans", []):
# フォントサイズとY座標を取得
font_size = span["size"]
origin_y = span["origin"][1] # Y座標
# 文字が小さいかつ、Y座標が基準よりも低い場合を下付き文字として特定
if font_size <= threshold and origin_y - font_size / 2 > 0:
subscripts.append({
"text": span["text"],
"page": page_num,
"size": font_size,
"position": span["origin"]
})
return subscripts
input_file_name = ""
while input_file_name == "":
print('下付き文字のテキストを抽出したいPDFのファイル名を「XXX.pdf」の形式で入力してください: ')
sys.stdout.flush()
input_file_name = str(sys.stdin.readline()).strip()
if not os.path.exists(input_file_name):
print(f"ファイル {input_file_name} が存在しません。ファイル名を確認してください。")
input_file_name = ""
print(f"{input_file_name} から下付き文字のテキストを抽出します。")
print('抽出する下付き文字の大きさを数値で入力してください。デフォルトは「8.5」です: ')
sys.stdout.flush()
threshold_input = sys.stdin.readline().strip()
if threshold_input == "":
threshold = 8.5
else:
threshold = int(threshold_input)
print('結果を出力するテキストファイルの名前を「XXX.txt」の形式で入力してください。デフォルトのファイル名は「subscripts.txt」です: ')
sys.stdout.flush()
output_file_name = str(sys.stdin.readline()).strip()
if output_file_name == "":
output_file_name = "subscripts.txt"
subscripts = extract_subscripts(input_file_name, threshold)
# 結果をファイルに書き出し
with open(output_file_name, "w") as f:
if subscripts:
for item in subscripts:
f.write(f"Page {item['page']}: {item['text']} (Font Size: {item['size']}, Position: {item['position']})\n")
else:
f.write("下付き文字は見つかりませんでした。\n")
print(f"結果は {output_file_name} に書き出されました。")

6. Windows の実行形式ファイルを生成
こちらの記事を参考にして Windows の実行形式(.exe )ファイルを生成しました。
実行形式ファイル化の際は、仮想環境で作業しないと余計なモジュールを読み込んでしまい、生成されるファイルが肥大してしまうそうなので、必ず仮想環境で作業しましょう‼
完成したコードはこちらで公開しています。良かったらご自由にお使いください。
いずれ上付き文字も抽出できるようにして、より汎用的なプログラムにしていきたいと考えています。