PDFビューアー
PDFファイルをページ単位で表示・ナビゲーションできるビューアー。PyMuPDF(fitz)ライブラリの活用を学びます。
1. アプリ概要
PDFファイルをページ単位で表示・ナビゲーションできるビューアー。PyMuPDF(fitz)ライブラリの活用を学びます。
このアプリは中級カテゴリに分類される実践的なGUIアプリです。使用ライブラリは tkinter(標準ライブラリ)・PyMuPDF・Pillow で、難易度は ★★☆ です。
このアプリは「ファイル操作」カテゴリです。ファイル操作は業務スクリプトの心臓部で、I/O のパターンは他のあらゆる Python プログラムで再利用できます。tkinter(標準ライブラリ)・PyMuPDF・Pillow を活かして実装するこの構造は、他のアプリにも応用が効きます。
コピー&実行で動作確認 → コード解説で構造理解 → カスタマイズで応用、の 3 ステップで進めると、短時間で本質を押さえられます。
アプリを完成させた後は、自分の使い方に合わせて改造するのが学びを定着させる近道です。カスタマイズ章のアイデアを足がかりに、独自機能を一つ追加してみてください。
2. 機能一覧
- ループで生成するボタン群を
_go_to()に接続 - 「📂 開く」ボタン(
_open_pdf())・「◀◀ 最初」ボタン(_first_page())・「◀ 前」ボタン(_prev_page())・「次 ▶」ボタン(_next_page())・「最後 ▶▶」ボタン(_last_page())・「+」ボタン(_zoom_in())・「-」ボタン(_zoom_out())・「全幅」ボタン(_fit_width())で操作 - キー
<Return>から_jump_to_page()を実行 - キー
<Left>から_prev_page()を実行 - キー
<Right>から_next_page()を実行 - キー
<Prior>から_prev_page()を実行 - キー
<Next>から_next_page()を実行 <Configure>イベント(ウィンドウ側からの通知)でlambda e: self.thumb_canvas.configure(scrollregion=self.thumb_canvas.bbox('all'))を自動実行- マウス操作
<MouseWheel>から_on_mousewheel()を実行 filedialog.askopenfilename()によるファイル選択ダイアログmessagebox.showerror()・messagebox.showwarning()によるダイアログ通知- ウィンドウはタイトル「PDFビューアー」・サイズ 1044x700 で起動
3. 事前準備・環境
Python 3.10 以上 / Windows 11(実機)で起動・基本操作を確認 / Linux Mint 22.3(仮想マシン)で起動・画面表示を確認(macOS は未検証)
Windows 11(実機)で起動と基本操作を確認しています(全機能の網羅テストではありません)。Linux Mint 22.3(仮想マシン)では起動と画面表示を確認しました。macOS は必要なライブラリ(pip install)を入れれば動作する想定ですが、未検証です。
- Python 3.10 以上
- OS: Windows 11(実機で起動・基本操作を確認)・Linux Mint 22.3(仮想マシンで起動・画面表示を確認)
インストールが必要なライブラリ
pip install pillow
4. 完全なソースコード
右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import os
try:
import fitz # PyMuPDF
FITZ_AVAILABLE = True
except ImportError:
FITZ_AVAILABLE = False
try:
from PIL import Image, ImageTk
PIL_AVAILABLE = True
except ImportError:
PIL_AVAILABLE = False
class App07:
"""PDFビューアー"""
def __init__(self, root):
self.root = root
self.root.title("PDFビューアー")
self.root.geometry("1044x700")
self.root.minsize(1044, 700)
self.root.configure(bg="#404040")
self.doc = None
self.current_page = 0
self.total_pages = 0
self.zoom = 1.5
self.tk_images = []
self._build_ui()
if not FITZ_AVAILABLE:
messagebox.showwarning(
"ライブラリ未インストール",
"PyMuPDF が必要です。\n"
"pip install pymupdf でインストールしてください。")
elif not PIL_AVAILABLE:
messagebox.showwarning(
"ライブラリ未インストール",
"Pillow が必要です。\n"
"pip install Pillow でインストールしてください。")
def _build_ui(self):
# ツールバー
toolbar = tk.Frame(self.root, bg="#3c3f41", pady=6)
toolbar.pack(fill=tk.X)
tk.Label(toolbar, text="📄 PDFビューアー",
font=("Noto Sans JP", 13, "bold"),
bg="#3c3f41", fg="#a9b7c6").pack(side=tk.LEFT, padx=12)
ttk.Button(toolbar, text="📂 開く",
command=self._open_pdf).pack(side=tk.LEFT, padx=4)
# ページナビ
nav_frame = tk.Frame(toolbar, bg="#3c3f41")
nav_frame.pack(side=tk.LEFT, padx=16)
ttk.Button(nav_frame, text="◀◀ 最初",
command=self._first_page).pack(side=tk.LEFT, padx=2)
ttk.Button(nav_frame, text="◀ 前",
command=self._prev_page).pack(side=tk.LEFT, padx=2)
self.page_var = tk.StringVar(value="0")
page_entry = ttk.Entry(nav_frame, textvariable=self.page_var,
width=5, font=("Arial", 11))
page_entry.pack(side=tk.LEFT, padx=4)
page_entry.bind("<Return>", self._jump_to_page)
self.total_label = tk.Label(nav_frame, text="/ 0",
bg="#3c3f41", fg="#a9b7c6",
font=("Arial", 11))
self.total_label.pack(side=tk.LEFT)
ttk.Button(nav_frame, text="次 ▶",
command=self._next_page).pack(side=tk.LEFT, padx=2)
ttk.Button(nav_frame, text="最後 ▶▶",
command=self._last_page).pack(side=tk.LEFT, padx=2)
# ズーム
zoom_frame = tk.Frame(toolbar, bg="#3c3f41")
zoom_frame.pack(side=tk.LEFT, padx=16)
tk.Label(zoom_frame, text="ズーム:", bg="#3c3f41",
fg="#a9b7c6").pack(side=tk.LEFT)
ttk.Button(zoom_frame, text="+",
command=self._zoom_in).pack(side=tk.LEFT, padx=2)
self.zoom_label = tk.Label(zoom_frame, text="150%",
bg="#3c3f41", fg="#a9b7c6",
font=("Arial", 11), width=5)
self.zoom_label.pack(side=tk.LEFT)
ttk.Button(zoom_frame, text="-",
command=self._zoom_out).pack(side=tk.LEFT, padx=2)
ttk.Button(zoom_frame, text="全幅",
command=self._fit_width).pack(side=tk.LEFT, padx=2)
# キーバインド
self.root.bind("<Left>", lambda e: self._prev_page())
self.root.bind("<Right>", lambda e: self._next_page())
self.root.bind("<Prior>", lambda e: self._prev_page())
self.root.bind("<Next>", lambda e: self._next_page())
# メインエリア: サムネイル + ページ表示
paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
paned.pack(fill=tk.BOTH, expand=True)
# 左: サムネイルパネル
thumb_frame = tk.Frame(paned, bg="#2b2b2b", width=120)
thumb_label = tk.Label(thumb_frame, text="ページ一覧",
bg="#3c3f41", fg="#a9b7c6",
font=("Arial", 9))
thumb_label.pack(fill=tk.X)
self.thumb_canvas = tk.Canvas(thumb_frame, bg="#2b2b2b",
width=120, highlightthickness=0)
thumb_sb = ttk.Scrollbar(thumb_frame, command=self.thumb_canvas.yview)
self.thumb_canvas.configure(yscrollcommand=thumb_sb.set)
thumb_sb.pack(side=tk.RIGHT, fill=tk.Y)
self.thumb_canvas.pack(fill=tk.BOTH, expand=True)
self.thumb_inner = tk.Frame(self.thumb_canvas, bg="#2b2b2b")
self.thumb_canvas.create_window((0, 0), window=self.thumb_inner, anchor="nw")
self.thumb_inner.bind("<Configure>",
lambda e: self.thumb_canvas.configure(
scrollregion=self.thumb_canvas.bbox("all")))
paned.add(thumb_frame, weight=0)
# 右: ページキャンバス
canvas_frame = tk.Frame(paned, bg="#525252")
self.canvas = tk.Canvas(canvas_frame, bg="#525252",
highlightthickness=0)
h_sb = ttk.Scrollbar(canvas_frame, orient=tk.HORIZONTAL,
command=self.canvas.xview)
v_sb = ttk.Scrollbar(canvas_frame, orient=tk.VERTICAL,
command=self.canvas.yview)
self.canvas.configure(xscrollcommand=h_sb.set,
yscrollcommand=v_sb.set)
v_sb.pack(side=tk.RIGHT, fill=tk.Y)
h_sb.pack(side=tk.BOTTOM, fill=tk.X)
self.canvas.pack(fill=tk.BOTH, expand=True)
self.canvas.bind("<MouseWheel>", self._on_mousewheel)
paned.add(canvas_frame, weight=1)
self.status_var = tk.StringVar(value="PDFファイルを開いてください")
tk.Label(self.root, textvariable=self.status_var,
bg="#3c3f41", fg="#888", font=("Arial", 9),
anchor="w", padx=8).pack(fill=tk.X, side=tk.BOTTOM)
def _open_pdf(self):
if not FITZ_AVAILABLE or not PIL_AVAILABLE:
messagebox.showwarning("警告", "必要なライブラリをインストールしてください")
return
path = filedialog.askopenfilename(
filetypes=[("PDFファイル", "*.pdf"), ("すべて", "*.*")])
if path:
try:
self.doc = fitz.open(path)
self.total_pages = len(self.doc)
self.current_page = 0
self.total_label.config(text=f"/ {self.total_pages}")
self.root.title(f"PDFビューアー — {os.path.basename(path)}")
self._build_thumbnails()
self._render_page()
self.status_var.set(
f"{os.path.basename(path)} | {self.total_pages} ページ")
except Exception as e:
messagebox.showerror("エラー", str(e))
def _render_page(self):
if not self.doc:
return
page = self.doc[self.current_page]
mat = fitz.Matrix(self.zoom, self.zoom)
pix = page.get_pixmap(matrix=mat)
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
tk_img = ImageTk.PhotoImage(img)
self.tk_images = [tk_img] # 参照保持
self.canvas.delete("all")
pad = 20
self.canvas.create_rectangle(
pad, pad, pix.width + pad + 4, pix.height + pad + 4,
fill="#888", outline="")
self.canvas.create_image(pad, pad, anchor="nw", image=tk_img)
self.canvas.configure(
scrollregion=(0, 0, pix.width + pad*2, pix.height + pad*2))
self.page_var.set(str(self.current_page + 1))
self.status_var.set(
f"ページ {self.current_page+1} / {self.total_pages} "
f"ズーム {int(self.zoom*100)}%")
def _build_thumbnails(self):
for w in self.thumb_inner.winfo_children():
w.destroy()
self.thumb_tk_images = []
for i in range(self.total_pages):
page = self.doc[i]
pix = page.get_pixmap(matrix=fitz.Matrix(0.15, 0.15))
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
tk_img = ImageTk.PhotoImage(img)
self.thumb_tk_images.append(tk_img)
btn = tk.Button(self.thumb_inner, image=tk_img,
text=str(i+1), compound=tk.BOTTOM,
bg="#2b2b2b", fg="#ccc", relief=tk.FLAT,
command=lambda p=i: self._go_to(p))
btn.pack(pady=2)
def _go_to(self, page):
if 0 <= page < self.total_pages:
self.current_page = page
self._render_page()
def _first_page(self):
self._go_to(0)
def _last_page(self):
self._go_to(self.total_pages - 1)
def _prev_page(self):
self._go_to(self.current_page - 1)
def _next_page(self):
self._go_to(self.current_page + 1)
def _jump_to_page(self, event=None):
try:
p = int(self.page_var.get()) - 1
self._go_to(p)
except ValueError:
pass
def _zoom_in(self):
self.zoom = min(4.0, self.zoom + 0.25)
self.zoom_label.config(text=f"{int(self.zoom*100)}%")
self._render_page()
def _zoom_out(self):
self.zoom = max(0.5, self.zoom - 0.25)
self.zoom_label.config(text=f"{int(self.zoom*100)}%")
self._render_page()
def _fit_width(self):
if not self.doc:
return
page = self.doc[self.current_page]
cw = self.canvas.winfo_width() or 600
pw = page.rect.width
self.zoom = (cw - 60) / pw
self.zoom_label.config(text=f"{int(self.zoom*100)}%")
self._render_page()
def _on_mousewheel(self, event):
if event.state & 0x4: # Ctrl
if event.delta > 0:
self._zoom_in()
else:
self._zoom_out()
else:
self.canvas.yview_scroll(-1 * (event.delta // 120), "units")
if __name__ == "__main__":
root = tk.Tk()
app = App07(root)
root.mainloop()
5. コード解説
PDFビューアーのコードを、実際に書かれている実装に沿って解説します。
クラス設計とコンストラクタ
App07 クラスにアプリの全機能をまとめています(メソッド15個・全257行)。__init__ ではタイトル「PDFビューアー」とウィンドウサイズ 1044x700 を設定します。あわせて状態を保持する変数(self.doc・self.current_page・self.total_pages・self.zoom)を初期化し、最後に _build_ui() で画面を組み立てます。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
ウィジェット構成
画面は tk.Frame×6・tk.Label×6・ttk.Button×8・ttk.Entry・ttk.PanedWindow・tk.Canvas×2・ttk.Scrollbar×3・tk.Button(ループで生成) で構成しています。PanedWindow を使っているので、ペインの境界をマウスでドラッグして幅を変えられます。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
イベント処理とボタンの接続
「📂 開く」ボタン(_open_pdf())・「◀◀ 最初」ボタン(_first_page())・「◀ 前」ボタン(_prev_page())・「次 ▶」ボタン(_next_page())・「最後 ▶▶」ボタン(_last_page())・「+」ボタン(_zoom_in())・「-」ボタン(_zoom_out())・「全幅」ボタン(_fit_width())を command= で接続しています。ボタンはループ内で command=lambda p=i: self._go_to(p) の形で生成しています。既定引数でループ変数を固定するのがポイントで、これを書かないと全ボタンが最後の値で動いてしまいます。また bind("<Return>", ...) から _jump_to_page()、bind("<Left>", ...) から _prev_page()、bind("<Right>", ...) から _next_page()、bind("<Prior>", ...) から _prev_page()、bind("<Next>", ...) から _next_page()、bind("<Configure>", ...) から lambda e: self.thumb_canvas.configure(scrollregion=self.thumb_canvas.bbox('all'))、bind("<MouseWheel>", ...) から _on_mousewheel() を呼べるようにイベントも登録しています。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
例外処理
try-except で Exception・ImportError・ValueError を捕捉しています。あわせて messagebox.showerror()・messagebox.showwarning() のダイアログで、ユーザーへの通知・確認を行います。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
6. ステップバイステップガイド
このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。
-
1ファイルを作成する
新しいファイルを作成して app07.py と保存します。外部ライブラリ
PIL・fitzを使います。先にpip install Pillow PyMuPDFを実行してください。(import 名と pip のパッケージ名が異なる点に注意:PILはPillow・fitzはPyMuPDFでインストールします) -
2クラスの骨格を作る
App07クラスを定義し、__init__と、末尾のroot = tk.Tk()~mainloop()の最小構成を書いて、まず空のウィンドウが出ることを確認します。 -
3ウィンドウを設定する
title("PDFビューアー")・geometry("1044x700")・configure(bg="#404040")・minsize(1044, 700)をコンストラクタで設定します。 -
4画面部品を並べる
_build_ui()の中で、tk.Frame×6・tk.Label×6・ttk.Button×8・ttk.Entry・ttk.PanedWindow・tk.Canvas×2・ttk.Scrollbar×3・tk.Button(ループで生成) を作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。 -
5イベントを接続する
「2. 機能一覧」で挙げた各ボタンを
command=で対応するメソッド(_open_pdf()・_first_page()・_prev_page()・_next_page()・_last_page()・_zoom_in()・_zoom_out()・_fit_width())につなぎます。ループ生成のボタンはcommand=lambda p=i: self._go_to(p)の形で接続します。bind("<Return>", ...)・bind("<Left>", ...)・bind("<Right>", ...)・bind("<Prior>", ...)・bind("<Next>", ...)・bind("<Configure>", ...)・bind("<MouseWheel>", ...)の登録も忘れずに。 -
6中心になるメソッドを実装する
アプリの本体である
_open_pdf()(19行)・_fit_width()(9行)・_on_mousewheel()(8行)など を実装します。 -
7動作確認する
python app07.pyで起動し、各ボタンが反応すること・キー操作(<Return>・<Left>・<Right>・<Prior>・<Next>)が効くこと・マウス操作(クリック・ホイールなど)が効くことを確認します。
7. カスタマイズアイデア
基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。少しずつ機能を追加することで、Pythonのスキルが飛躍的に向上します。
💡 ダークモードを追加する
bg色・fg色を辞書で管理し、ボタン1つでダークモード・ライトモードを切り替えられるようにしましょう。
💡 結果をファイルに保存する
このコードに保存処理はないため、表示中の数値や結果はアプリを閉じると消えます。テキストファイルへ書き出して、次回起動時に読み込む機能を追加してみましょう。
💡 入力履歴機能
以前の入力値を覚えておいてComboboxのドロップダウンで再選択できる履歴機能を追加しましょう。
8. よくある問題と解決法
❌ 文字のフォントが崩れる・見た目が違う
原因:このコードは font=("Noto Sans JP", ...) のようにフォント名を直接指定しています。環境にこのフォントが入っていないと、OS が代わりのフォントで表示するため、見本と見た目が変わることがあります。
解決法:動作には支障ありません。気になる場合は font 引数の名前を自分の OS に入っているフォントへ書き換えるか、font 引数を省略してください。
❌ 写経したら全部のボタンが同じ動きになる
原因:ループでボタンを作るとき、このコードは command=lambda p=i: self._go_to(p) のように既定引数でループ変数を固定しています。これを省略してループ変数を直接書くと、ループ終了後の最後の値が全ボタンで共有されてしまいます(Python のクロージャの有名な罠です)。
解決法:掲載コードのとおり、lambda の既定引数でその時点の値を渡してください。
❌ <Return> キーを押しても反応しない
原因:bind("<Return>", ...) はウィジェット単位で登録されるため、ウィンドウ(またはバインド先)にキーボードフォーカスが無いとイベントが届きません。
解決法:一度ウィンドウ内をクリックしてフォーカスを与えてから、キーを押してください。
❌ ウィンドウの大きさが画面に合わない
原因:geometry("1044x700") の固定サイズで起動するためです。
解決法:geometry() と minsize() の両方の数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。ただし minsize(1044, 700) を指定しているため、起動時の大きさより小さくは縮められません(縮めると画面部品が表示されなくなるのを防ぐためです)。
9. 練習問題
アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。課題を試すには PyMuPDF と Pillow が要ります(pip install pymupdf Pillow)。サムネイルは開いた時点で全ページ分を作るため、練習は数ページの PDF で試してください。なお PyMuPDF は AGPL と商用の二重ライセンスで配布されています(ページ下部の MIT 表記は本サイトが書いたコードについてのものです)。学習や個人利用は AGPL のままで問題ありませんが、ソースを公開しない製品に組み込む場合は提供元の商用ライセンスを確認してください。
-
課題1:ページ番号の入力ミスに気づけるようにする
_jump_to_page()は入力欄の数字を_go_to()に渡すだけです。範囲の外だと何も起きず、入力欄には打ち込んだ数字が残ります。範囲外や数字以外を入れたときは、帯に案内を出して入力欄を今のページに戻してください。期待結果:全5ページの PDF で
999と入れて Enter を押すと、帯に「1〜5 の範囲で入力してください」と出て、入力欄が表示中のページ番号に戻る。合格条件
abcのような数字以外を入れても落ちず、同じ案内が出る- 範囲内の数字ならこれまでどおり移動し、案内は出ない
- PDF を開く前に Enter を押しても、案内が出るだけで落ちない
ヒント①(どこを触るか): 触るのは
_jump_to_page()の1か所です。except ValueError:のpassを案内に差し替え、self._go_to(p)を呼ぶ前に範囲を確かめます。
ヒント②(使うもの): 総ページ数はself.total_pagesに入っています。入力欄を戻すのはself.page_var.set(str(self.current_page + 1))で、_render_page()の末尾と同じ書き方です。案内の表示にはself.status_var.set(...)を使います。
つまずきやすい点: 判定を_go_to()に足すと、先頭ページで「◀ 前」を押したときにも案内が出ます(_prev_page()が-1を渡すためです)。PDF を開く前はself.total_pagesが0なので、その場合は別の文言にすると分かりやすくなります。 -
課題2:いま見ているページのサムネイルに色を付ける
左のサムネイルは押すとそのページへ飛べますが、どれが表示中かは見た目で分かりません。今のページのボタンだけ背景色を変え、ページを移るたびに色も移るようにしてください。
期待結果:PDF を開くと1ページ目のサムネイルだけ色が変わり、「次 ▶」を押すと色が2ページ目に移る。
合格条件
- サムネイルを直接クリックして移動しても、色が今のページに付いてくる
- ズームの「+」「-」を押しても、色は今のページのまま残る
- 別の PDF を開き直したとき、前のファイルの色が残らない
ヒント①(どこを触るか): 触るのは2か所です。
_build_thumbnails()で作ったボタンをリストに貯め、_render_page()の末尾でそのリストを回して色を塗り分けます。
ヒント②(使うもの): サムネイルはtk.Buttonなのでbtn.config(bg="#4a6fa5")で背景色を変えられます。既定色はコードにあるbg="#2b2b2b"です。リストはself.thumb_tk_images = []の隣にself.thumb_buttons = []を足すと分かりやすいです。
つまずきやすい点: ツールバーのttk.Buttonはbgを受け付けず、TclError: unknown option "-bg"になります。色を変えるのはサムネイルのtk.Buttonだけです。_build_thumbnails()は先頭で古いボタンをdestroy()するため、リストも同じ場所で空に戻さないと消えたボタンを触ってTclErrorが出ます。 -
課題3:保存機能の追加
入力値や計算結果をファイルに保存する機能を追加しましょう。jsonやcsvモジュールを使います。
写経中に赤いエラー文が出たら、Pythonエラー一覧&解決法(英語メッセージ逆引き)で原因と直し方をすぐ確認できます。
このレベルのアプリが作れたら、入門書の次の「作るための本」へ進む時期です。実践におすすめのPython本(当サイトの参考書ランキング総合3〜4位)で自動化とコードの書き方の2冊を、その直後の用途別専門書の節でデータ分析・Web・AIの本を比較しています。