1. はじめに
この記事では、約3000ページあるPDFリファレンスガイドを「必要な箇所にすぐ移動できる」ようにした業務効率化ツールを作成しましたので、紹介します。
対象としているリファレンスガイドはこちらです。
- リファレンスガイド:Nastran Quick Reference Guide
PythonやGUIはまだ勉強中ですが、「業務で本当に困っていること」をテーマにツール化してみたところ、日々のPDF参照にかかる時間をかなり減らすことができました。
- 分厚いPDFリファレンスを扱っている
- 目次と検索を行ったり来たりしている
- Pythonで業務改善ツールを作ってみたい
そんな方の参考になれば幸いです。
2. 抱えていた課題
リファレンスガイド(PDF)で調べものをする際、関連するキーワードが1つだけであればまだ何とかなります。しかし、実際の業務では、複数の関連キーワードを横断的に調べたい場合があります。
- 目次に行く
- キーワード検索する
- 該当ページに飛んで読む
- (関連情報を探すために)また目次に戻る
- ...(以下ループ)
この「PDF内の往復」、地味ですが、意外と業務時間が削られていました。
※ 補足
Web上にもHTML版のリファレンスガイドは用意されていますが、製造業の業務環境ではセキュリティ上の理由から社内ネットワークが制限され、容易にインターネットに接続できないケースも少なくありません。そのため本記事では、リファレンスガイド(PDF)をあらかじめダウンロードし、ローカル環境で参照する運用を前提にしています。
3. 解決策: キーワードごとのタブ分割表示
「PDFを行ったり来たりする時間を減らしたい!」
その一心で、指定した複数のキーワードに関連する該当箇所をChromeの別タブでまとめて開くツール を作成してみました。
4. システム構成
本ツールは、次の2つのPythonプログラムで構成されています。
① リファレンスガイド(PDF)目次のリンク情報を取得するツール
② 抽出した移動先データを使って、リファレンスガイド(PDF)を開くツール
5. フォルダ構成
① リファレンスガイド (PDF) 目次のリンク情報を取得するツール
read_Contents_from_QRG/
├─ read_Contents_from_QRG_251220.py
├─ read_Contents_from_QRG_setting.txt (← PDFのパスを記入)
└─ Nastran_Quick_Reference_Guide.pdf
② 抽出した移動先データを使って、リファレンスガイド (PDF) を開くツール
jump_to_Nast_QRG/
├─ jump_to_Nast_QRG_251220.py
├─ jump_to_Nast_QRG_setting.txt (← PDFのパス / 移動先データCSVファイルのパスを記入)
├─ Nastran_Quick_Reference_Guide.pdf
└─ output_Contents_Nastran_Quick_Reference_Guide.csv (← 移動先データファイル)
6. ツール概要
① リファレンスガイド(PDF)目次のリンク情報を取得するツール
- PDFのパスが記入してあるテキストファイルの読み込み
- PDFの「Contents」が設定されているページを指定する
- 指定したページ内のリンク情報から、該当箇所へ移動するために必要な情報を抽出する
- 抽出した
移動先データをCSVに保存する
chapter,section_name,"""key_word""",link_page,named_dest
2,NASTRAN Statement,"""NASTRAN""",49,G6.509052
2,NASTRAN Statement,"""BUFFSIZE (1)""",50,G6.885786
2,NASTRAN Statement,"""F06 (2)""",50,G6.885794
2,NASTRAN Statement,"""NLINES (9)""",50,G6.885802
2,NASTRAN Statement,"""MAXLINES (14)""",50,G6.885813
2,NASTRAN Statement,"""METIME (20)""",50,G6.885824
2,NASTRAN Statement,"""APP (21)""",50,G6.885835
2,NASTRAN Statement,"""MACHTYPE (22)""",50,G6.885846
2,NASTRAN Statement,"""DIAGA (25)""",50,G6.885854
...
② 抽出したデータを使って、リファレンスガイド(PDF)を開くツール
- PDFのパス / 移動先データCSVファイルのパスが記入してあるテキストファイルの読み込み
- 検索・フィルタ機能で、目的のキーワードを探す
- GUI上で、開きたいキーワードを選択する
- 選択したキーワードに対応するページのPDFを開く
7. 実装コード
① リファレンスガイド(PDF)目次のリンク情報を取得するツール
import csv
from pathlib import Path
import fitz
import tkinter as tk
from tkinter import font, messagebox
# -----------------------------
# 関数
# -----------------------------
def load_pdf_path(list_file):
"""設定ファイルを読込み、PDFのパスを抽出して返す"""
with open(list_file, "r", encoding="utf-8") as f:
pdf_path = None
for line in f:
# 改行・先頭・末尾("と’)を削除
line = line.strip().strip('\'"')
if not line or line.startswith("#"):
continue
file_path = Path(line)
# 拡張子で判定(大文字小文字は無視)
suffix = file_path.suffix.lower()
if suffix == ".pdf":
pdf_path = file_path
else:
print(f"---> 拡張子が .pdf/.csv ではありません: {line}")
return pdf_path
def read_contents():
# PDF抽出ページ
start_text = start_pg_entry.get().strip()
last_text = last_pg_entry.get().strip()
# 空欄チェック
if not start_text or not last_text:
messagebox.showwarning("入力エラー","開始ページと終了ページを入力してください。")
return
# 数値チェック
if not start_text.isdigit() or not last_text.isdigit():
messagebox.showwarning("入力エラー", "ページ番号は数字で入力してください。")
return
start_pg = int(start_text)
last_pg = int(last_text)
# 0チェック(1以上に限定)
if start_pg <= 0 or last_pg <= 0:
messagebox.showwarning("入力エラー", "ページ番号は1以上で入力してください。")
return
# 範囲チェック
if start_pg > last_pg:
messagebox.showwarning("入力エラー", "開始ページは終了ページ以下にしてください。")
return
# 章記号、セクション名の初期値
pending_chapter = None
chapter = ""
section_name = ""
# データ格納用リスト
data_list = []
with fitz.open(pdf_path) as doc:
for page_idx in range(start_pg - 1, last_pg):
# PDFドキュメント doc から、page_idx 番目のページを読み込み、page オブジェクトとして取得
page = doc.load_page(page_idx)
# page に設定されている、すべてのリンク情報をリストとして取得
links = page.get_links()
for link in links:
# -----------------------------
# PDFのリンク範囲の矩形の座標値
# -----------------------------
rect = fitz.Rect(link["from"])
# 内側に少しリンク範囲を縮める
shrink_x = 0
shrink_y = 2
clip_rect = fitz.Rect(
rect.x0 + shrink_x,
rect.y0 + shrink_y,
rect.x1 - shrink_x,
rect.y1 - shrink_y,
)
# -----------------------------
# リンク範囲内の「文字列」を取得
# -----------------------------
link_text = page.get_text("text", clip=clip_rect).strip()
# 「空」なら、次に行く
if not link_text:
continue
# 「章記号」の番号か、A~Cだけなら、次に行く
if link_text.isdigit():
pending_chapter = int(link_text)
continue
elif link_text.upper() in ("A", "B", "C"):
pending_chapter = link_text
continue
# 先頭に「■」記号があるなら、次に行く(例:■ The NASTRAN Statement, 16)
if link_text.lstrip().startswith(""):
continue
# 先頭に「- 」がある場合 (例:- CBAR, 1341)
link_text = link_text.removeprefix("- ").strip()
# 右側に「カンマ + 数字」がある場合 (例:- CBAR, 1341)
link_text = link_text.rsplit(",", 1)[0]
# 右側に「スペース + 数字」がある場合 (例:CWELD 992)
link_text = link_text.rsplit(" ", 1)
if link_text[-1].isdigit():
link_text = link_text[0]
else:
link_text = " ".join(link_text)
# 「改行記号」が含まれる場合
link_text = link_text.replace("\n", "")
# -----------------------------
# 「page」「nameddest」の情報を取得
# -----------------------------
link_page = link.get("page", 0) + 1
named_dest = link.get("nameddest", "")
# -----------------------------
# 章記号とセクション名を取得
# -----------------------------
if pending_chapter is not None:
chapter = pending_chapter
section_name = link_text
pending_chapter = None
continue
# -----------------------------
# 辞書形式でPDFリンク情報を格納
# -----------------------------
# (Excelだと(100)が-100となるため、「key_word」は"'"で括る)
if chapter:
data_list.append({
"chapter": chapter,
"section_name": section_name,
'"key_word"': f'"{link_text}"',
"link_page": link_page,
"named_dest": named_dest,
})
# -----------------------------
# CSV 書き込み
# -----------------------------
with csv_path.open("w", encoding="utf-8-sig", newline="") as f:
fieldnames = ["chapter", "section_name", '"key_word"', "link_page", "named_dest"]
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(data_list)
# ウィンドウを閉じる
window.destroy()
messagebox.showinfo("完了", "PDFからデータを抽出しました。")
def close_window():
"""ウィンドウを閉じる"""
window.destroy()
# -----------------------------
# 参照するPDF、保存するCSV名
# -----------------------------
setting_file = "read_Contents_from_QRG_setting.txt"
# 参照するPDF
pdf_path = load_pdf_path(setting_file)
# 保存するCSVファイル名
csv_path = Path(f"output_Contents_{pdf_path.stem}.csv")
# -----------------------------
# 「Window」
# -----------------------------
window = tk.Tk()
window.title('Read Contents from PDF')
window.geometry('410x150+800+300')
window.resizable(0,0)
window.attributes("-topmost",True)
# -----------------------------
# PDF名を表示ラベル
# -----------------------------
pdf_path_label1 = tk.Label(
window,
text="PDF File",
font=font.Font(family="Meiryo", size=9, weight="bold", underline=1)
)
pdf_path_label1.place(x=10, y=0)
pdf_path_label2=tk.Label(window,text=pdf_path.name, font=("Meiryo", 10))
pdf_path_label2.place(x=10, y=20)
# -----------------------------
# 指示文ラベル
# -----------------------------
label=tk.Label(window,text="上記のPDFから抽出するページを指定してください。", font=("Meiryo", 10))
label.place(x=10, y=60)
# -----------------------------
# PDF抽出ページ指定ボックス
# -----------------------------
start_pg_entry = tk.Entry(window, width=10, font=("Meiryo", 10), justify="right")
start_pg_entry.place(x=20, y=100)
last_pg_entry = tk.Entry(window, width=10, font=("Meiryo", 10), justify="right")
last_pg_entry.place(x=130, y=100)
mark_path_label=tk.Label(window,text="~", font=("Meiryo", 10))
mark_path_label.place(x=108, y=100)
# -----------------------------
# 実行/キャンセル ボタン
# -----------------------------
exe_btn=tk.Button(window, text="OK", width=9, command=read_contents)
exe_btn.place(x=230, y=100)
cancel_btn=tk.Button(window, text="Cancel", width=9, command=close_window)
cancel_btn.place(x=315, y=100)
window.mainloop()
② 抽出したデータを使って、リファレンスガイド(PDF)を開くツール
import sys
import webbrowser
from pathlib import Path
import pandas as pd
import tkinter as tk
from tkinter import messagebox, ttk
# -----------------------------
# 関数
# -----------------------------
def load_pdf_csv_path(list_file):
""" 設定ファイルを読込み、PDFとCSV のパスを抽出して返す """
list_file = Path(list_file)
# 設定ファイル自体が存在しない
if not list_file.exists():
messagebox.showerror("エラー", f"設定ファイルが見つかりません!\n\n場所: {list_file}")
sys.exit()
pdf_path = None
csv_path = None
with open(list_file, "r", encoding="utf-8") as f:
for line in f:
# 改行・先頭・末尾("と’)を削除
line = line.strip().strip('\'"')
if not line or line.startswith("#"):
continue
file_path = Path(line)
# 拡張子で判定(大文字小文字は無視)
suffix = file_path.suffix.lower()
if suffix == ".pdf":
pdf_path = file_path
elif suffix == ".csv":
csv_path = file_path
else:
print(f"---> 拡張子が .pdf/.csv ではありません: {line}")
# PDF / CSV の指定がない
if pdf_path is None:
messagebox.showerror("エラー", f"PDFファイルが設定ファイル内に見つかりません!\n\n場所: {pdf_path}")
sys.exit()
if csv_path is None:
messagebox.showerror("エラー", f"CSVファイルが設定ファイル内に見つかりません!\n\n場所: {csv_path}")
sys.exit()
# PDF / CSV の指定されているが、実ファイルが存在しない
if not pdf_path.exists():
messagebox.showerror("エラー", f"PDFファイルが存在しません!\n\n場所: {pdf_path}")
sys.exit()
if not csv_path.exists():
messagebox.showerror("エラー", f"CSVファイルが存在しません!\n\n場所: {csv_path}")
sys.exit()
return pdf_path, csv_path
def refresh_treeview(view_df):
""" Treeviewの表示内容をview_dfで全更新 """
trv.delete(*trv.get_children())
for row in view_df.values.tolist():
trv.insert("", "end", values=row)
# スクロールを一番上へ
trv.yview_moveto(0)
def filter_data(event=None):
""" Comboboxの選択で、dfを絞り込み、検索欄の条件も反映して表示を更新する """
global current_df
# Tkintrerの画面で選択されているフィルタ名を取得
selected_section = section_name_var.get()
if selected_section == "All":
view_df = df
else:
view_df = df[df["section_name"] == selected_section]
# フィルタ後のデータフレームの範囲を current_df に保存
current_df = view_df
# 検索欄に文字があれば、その条件も反映
if search_entry.get().strip():
# current_df に対して検索して表示
search_data()
else:
# 検索なしならフィルタ結果だけ表示
refresh_treeview(view_df)
def search_data():
"""current_dfのkey_word列に対して、複数キーワード検索し、表示を更新する"""
global current_df
# 検索ワードのテキスト
search_text = search_entry.get().strip()
# 空なら、いまのフィルタ結果(current_df)をそのまま表示
if not search_text:
refresh_treeview(current_df)
return
# 全角スペースがある場合、半角にし、キーワードをリスト化する
search_text = search_text.replace(" ", " ").strip()
search_keywords = search_text.split()
# current_dfのkey_word列をSeriesにする
key_word_series = current_df["key_word"].astype(str)
# 一旦に全部 False にする
mask = pd.Series(False, index=current_df.index)
# OR検索 (大文字・小文字区別なく)
for kw in search_keywords:
hit = key_word_series.str.contains(kw, case=False, na=False)
mask = mask | hit
# 再描画
view_df = current_df[mask]
refresh_treeview(view_df)
def clear_search():
"""検索欄をクリアして、現在のフィルタ名で一覧表示に戻す"""
search_entry.delete(0, tk.END)
filter_data()
def jump_to_pdf():
"""選択行からPDFのURLを作成し、Chromeで該当箇所を開く"""
sel = trv.selection()
selected_rows_values = [trv.item(item_id, "values") for item_id in sel]
for value in selected_rows_values:
link_page = value[3]
named_dest = value[4]
if str(named_dest) == "-":
url = pdf_path.as_uri() + f"#page={link_page}"
else:
url = pdf_path.as_uri() + f"#nameddest={named_dest}"
# Chrome を登録して開く
browser_controller = webbrowser.get(chrome_path)
browser_controller.open_new_tab(url)
def close_window():
"""ウィンドウを閉じる"""
window.destroy()
# -----------------------------
# CSV読み込み、データフレーム作成
# -----------------------------
setting_file = "jump_to_Nast_QRG_setting.txt"
# 参照するPDF&CSV
pdf_path, csv_path = load_pdf_csv_path(setting_file)
# 目次情報のデータフレーム
df = pd.read_csv(csv_path, encoding="utf-8-sig")
# key_word 列の前後のダブルクォーテーションを削除
df.columns = df.columns.str.strip().str.strip('"')
df["key_word"] = df["key_word"].astype(str).str.replace('"', '')
# 欠損値がある場合、穴埋め
df["chapter"] = df["chapter"].fillna("-").astype(str)
df["section_name"] = df["section_name"].fillna("-").astype(str)
df["link_page"] = df["link_page"].fillna(0).astype(int)
df["named_dest"] = df["named_dest"].fillna("-").astype(str)
# 並び替え
df = df.sort_values( by=["chapter", "key_word"], ascending=[True, True])
# 現在のデータフレームに入れる
current_df = df
# Chrome の実行用ファイル ( URLの置換文字列 %s が必要)
chrome_path = "C:/Program Files/Google/Chrome/Application/chrome.exe %s"
# -----------------------------
# 「Window」
# -----------------------------
window = tk.Tk()
window.title("Jump to Nastran Quick Reference Guide")
window.geometry("800x550+300+300")
window.resizable(0, 0)
# window.attributes("-topmost", True)
style = ttk.Style(window)
# -----------------------------
# 「Section Filter」
# -----------------------------
section_name_lbl = tk.Label(window, text="Filter:", font=("Meiryo", 10))
section_name_lbl.place(x=10, y=10)
section_name_lst = ['All'] + df['section_name'].dropna().unique().tolist()
section_name_var = tk.StringVar(value='All')
section_name_cb = ttk.Combobox(
window,
values=section_name_lst,
textvariable=section_name_var,
state='readonly',
width=24,
font=("Meiryo", 10)
)
section_name_cb.place(x=60, y=10)
section_name_cb.bind("<<ComboboxSelected>>", filter_data)
# -----------------------------
# 「Search」
# -----------------------------
search_lbl = tk.Label(window, text="Keyword Search:", font=("Meiryo", 10))
search_lbl.place(x=280, y=10)
search_entry = tk.Entry(window, width=29, font=("Meiryo", 10))
search_entry.place(x=400, y=10)
search_btn = tk.Button(window, text='Search', width=8, font=("Meiryo", 10), command=search_data)
search_btn.place(x=650, y=5)
# -----------------------------
# 「Clear」
# -----------------------------
clear_btn = tk.Button(window, text='Clear', width=7, font=("Meiryo", 10), command=clear_search)
clear_btn.place(x=725, y=5)
# -----------------------------
# 「Frame + Treeview + Scrollbar + Column」
# -----------------------------
# Frame
frm = tk.Frame(window, bd=1, relief="solid")
frm.place(x=10, y=50, width=780, height=430)
# Treeview のフォント設定
style.configure("My.Treeview", font=("Meiryo", 10), rowheight=22)
style.configure("My.Treeview.Heading", font=("Meiryo", 10))
# Treeview
trv = ttk.Treeview(
frm,
columns=list(df.columns),
show="headings",
selectmode="extended",
style="My.Treeview"
)
trv.place(x=0, y=0, width=760, height=430)
# Scrollbar
vsb = ttk.Scrollbar(frm, orient="vertical", command=trv.yview)
vsb.place(x=760, y=0, width=20, height=430)
trv.configure(yscrollcommand=vsb.set)
# Cloumun
trv.heading("chapter", text="Chapter")
trv.column("chapter", width=20, anchor="c")
trv.heading("section_name", text="Section Name")
trv.column("section_name", width=180, anchor="w")
trv.heading("key_word", text="Keyword")
trv.column("key_word", width=150, anchor="w")
trv.heading("link_page", text="Link Page")
trv.column("link_page", width=50, anchor="c")
trv.heading("named_dest", text="Named Destination")
trv.column("named_dest", width=100, anchor="c")
# -----------------------------
# 起動時、Treeviewに全データ表示
# -----------------------------
refresh_treeview(df)
# -----------------------------
# 「Jump to PDF」/「Cancel」
# -----------------------------
jump_pdf_btn = tk.Button(window, text='Jump to PDF', width=15, font=("Meiryo", 10), command=jump_to_pdf)
jump_pdf_btn.place(x=250, y=500)
cancel_btn=tk.Button(window, text="Cancel", width=15, font=("Meiryo", 10), command=close_window)
cancel_btn.place(x=400, y=500)
window.mainloop()
8. データ仕様
リファレンスガイド(PDF)のリンク情報
リファレンスガイド(PDF)に設定されているリンクから取得できる情報の例を示します。
PDF内のリンク情報のサンプル
[
{
'from': Rect(34.0791015625, 135.59698486328125, 142.0760040283203, 146.39599609375),
'id': '',
'kind': 4,
'nameddest': 'G3.1014612',
'page': 4,
'to': Point(157.0, 538.0),
'xref': 38492,
'zoom': 0.0
},
...
]
PDF内のリンク情報の各項目
・from(Rect(x0, y0, x1, y1)):ページ上のリンクの矩形範囲(座標値)
・id:リンクのID
・kind:リンクの目的の種類(例:4 は 名前付きの場所(Named Destination)を指す)
・nameddest:PDF内の 名前付きの場所(Named Destination)
・page:移動先のページ番号
・to:(Point(x, y)):移動先ページ内の座標(x, y)
・xref:PDF内オブジェクトに対する一意の識別子
・zoom:移動先ページの拡大率
本ツールで使用する移動先データ
本ツールでは、PDF内のリンク情報(Rect(...) で示されるリンクの矩形範囲にある文字列)から、以下の情報を取得します。
- 章記号
- セクション名
- キーワード (※)
※ ExcelでCSVを開くと、キーワードの表記が変わってしまうケースがある。そのため、本ツールでは キーワードを取得後ダブルクォーテーションで括ってCSVに保存する。
(例):(100) → -100 のように解釈されてしまうことがあるため
また、PDFの移動先を特定するために、リンク情報から次の2種類のデータを取得します。
PDFの移動先は、名前付きの場所があれば名前付きの場所に、なければ、ページ番号に移動します。
- ページ番号(
page) - 名前付きの場所(
nameddest)
9. 苦労した点
-
PDFライブラリ選定に時間がかかった
PythonにはPDF関連ライブラリが多数あり、「自分のやりたいこと(PDFからの目次情報の抽出)」に最も適したものがどれかを見つけるのに想像以上に時間がかかった。
今回は PDFのリンクデータから抽出しやすいこと を最優先にして、最終的に PyMuPDF(fitz) を採用しました。 -
Tkinterでの検索・フィルタ機能の作り方がすぐに見つからなかった
TkinterでGUIを作るにあたり、扱うキーワードが約2000個あるため、表示を絞り込む検索・フィルタ機能が必須でした。
ただ、TreeviewとDataFrameを組み合わせた検索・フィルタの実装例が、自分の想定に合うケースがすぐに見つからず、技術調査に少し苦労した。
最終的には、以下の記事を参考にして実装の方法を検討した。
10. ツールの配布について: PyInstallerを使用
作成したツールは Pythonがインストールされていない環境でも実行できるようにするため、今回は PyInstaller を使って exe ファイル化した。
業務で使用することを想定すると、
- 配布先のPCに Python が入っているとは限らない
- 仮想環境の構築やライブラリインストールを依頼するのは現実的でない
といった理由から、exe単体で配布できるようにする。
PyInstallerを選んだ理由
Pythonのexe化ツールはいくつかありますが、今回は以下の理由で PyInstaller を選択しました。
- 導入が簡単(pipでインストール可能)
- Tkinter を使ったGUIアプリの実績が多い
- 情報量が多く、トラブル時に調べやすい
exe化の方法
以下のコマンドで exe ファイルを作成しました。
pyinstaller main.py --onefile --noconfirm --clean --windowed
主なオプションの意味は次の通りです。
・--onefile:実行ファイルを 1つのexeにまとめる
・--noconfirm:既存のビルド成果物があっても確認なしで上書き
・--clean:一時ファイルを削除して、クリーンな状態でビルド
・--windowed:コンソール画面を表示しない(GUIアプリ向け)
11. 今後の発展アイデア
- リファレンスガイド (PDF) 目次のリンク情報を取得するツールで、複数のPDFに対し、一括処理できるようにする
- 抽出した移動先データを使って、リファレンスガイド(PDF)を開くツールで、検索履歴機能をつけ、過去検索したPDFページを開けるようにする
12. まとめ
今回の開発を通して、Pythonを使えば 日々の 「繰り返し作業」 をツール化できることを実感しました。特に、分厚いPDFリファレンスを行ったり来たりする時間は、積み重なると想像以上にコストがかかります。
「必要な箇所にすぐ移動できる」ようにできただけでも、業務のストレスと検索時間をかなり減らせました。
13. 学習を通して得たこと
今回あらためて感じたのは、「業務で本当に困っていること」をテーマにすると学習の質が変わる ということです。目的が明確になるため、学習する姿勢も受け身ではなくなり、「とにかく完成させる」「現場で使える形にする」という意識で取り組めました。
結果として、Pythonの勉強に対する集中度や、調べ物をするスピード、及び、検討/検証レベルが、これまでと比べて明らかに上がったと思います。



