Pythonで実装するKindleのPDF化ー②トリミング編
はじめに
Kindleの電子書籍をPDF化したいと思ったことはありませんか。
私は教科書など、文字を書き込みながら読みたい本は今回紹介する方法を使ってPDF化し、タブレットに読み込んで閲覧しています。
KindleのPDF化は大きく分けて以下の3つの作業に分かれます。
- すべてのページのスクリーンショットを撮る。
- 適切に余白を削除して整える。
- 整えた画像ファイルを一つのPDFに変換する。
以下のページでそれぞれのやり方を説明しているので、ご興味のある方は参考にしてみてください。(当たり前ですが、PDF化したものの著作権にはお気をつけください。)
Pythonで実装するKindleのPDF化ー①スクリーンショット編
Pythonで実装するKindleのPDF化ー②トリミング編
Pythonで実装するKindleのPDF化ー③PDF変換編
なお、今回紹介するコードは以下の記事でご紹介いただいたものに、自分なりにアレンジを加えたものになります。
Kindle for PCのスクショを撮る #Python - Qiita
本家様の記事のコードの方が完成度は高いので、ぜひそちらもご覧ください。
作成者様ありがとうございました。
トリミング編
前回の記事では、Kindle PCアプリケーションから自動的にスクリーンショットを撮影する方法をご紹介しました。今回は、撮影した画像から不要な余白を削除するためのGUIツールの実装方法について解説します。
このツールは以下のような機能を持っています。
- フォルダ内の画像一括処理
- プレビュー機能による確認
- 余白のカスタマイズ設定
- 進捗状況の表示
- マルチスレッドによる処理
なお、このツールはほとんど全てClaudeに作ってもらいました。
なので解説もClaudeにお任せしています。
私もちゃんと理解できていないところはたくさんありますが、とりあえず動くので気にしていません。
プログラムの実装解説
1. 必要なライブラリのインポート
まず、必要なライブラリをインポートします。
import os
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
from PIL import Image, ImageTk
import threading
各ライブラリの役割を詳しく説明します。
-
os
: ファイルやディレクトリの操作に使用します -
tkinter
: GUIを作成するための標準ライブラリです -
PIL (Python Imaging Library)
: 画像処理を行うためのライブラリです -
threading
: 並行処理を実現するためのライブラリです
2. 画像処理の核となる関数
画像処理の中心となるtrim_margins
関数を実装します:
def trim_margins(im, left_margin, right_margin, top_margin, bottom_margin):
width, height = im.size
return im.crop((left_margin, top_margin, width - right_margin, height - bottom_margin))
この関数は以下のような処理を行います。
- 画像のサイズ(幅と高さ)を取得
- PIL(Python Imaging Library)の
crop
関数を使用して余白を削除 - トリミングされた新しい画像を返す
crop
関数は4つの値を受け取ります。
- 左端からの距離
- 上端からの距離
- 右端からの距離(画像の幅から右余白を引いた値)
- 下端からの距離(画像の高さから下余白を引いた値)
3. フォルダ選択機能
フォルダ選択のための関数を実装します。
def select_folder():
return filedialog.askdirectory()
この関数は非常にシンプルですが、重要な役割を果たします。
- tkinterの
filedialog
モジュールを使用してフォルダ選択ダイアログを表示 - ユーザーが選択したフォルダのパスを返す
4. 画像処理の本体
画像の一括処理を行うprocess_images
関数を実装します。
def process_images(input_folder, output_folder, left_margin, right_margin,
top_margin, bottom_margin, progress_var, status_var):
image_files = [f for f in os.listdir(input_folder)
if f.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.gif'))]
total_files = len(image_files)
if not image_files:
messagebox.showerror("エラー", "指定されたフォルダに画像ファイルが見つかりません。")
return False
for i, filename in enumerate(image_files, 1):
input_path = os.path.join(input_folder, filename)
output_path = os.path.join(output_folder, filename)
try:
with Image.open(input_path) as img:
trimmed_img = trim_margins(img, left_margin, right_margin,
top_margin, bottom_margin)
trimmed_img.save(output_path)
except Exception as e:
messagebox.showerror("エラー",
f"ファイル {filename} の処理中にエラーが発生しました: {str(e)}")
return False
progress = (i / total_files) * 100
progress_var.set(progress)
status_var.set(f"処理中... {i}/{total_files} ファイル")
root.update_idletasks()
status_var.set("完了")
return True
この関数の処理を詳しく見ていきましょう。
-
画像ファイルの検索
- 入力フォルダ内のすべての画像ファイルを取得
- 対応する拡張子(.png, .jpg, .jpeg, .bmp, .gif)を持つファイルのみを選択
-
エラーチェック
- 画像ファイルが見つからない場合はエラーメッセージを表示して処理を中断
-
画像処理ループ
- 各画像ファイルに対して以下の処理を実行
- 入力パスと出力パスの生成
- 画像を開いてトリミング処理
- トリミング済み画像の保存
- 各画像ファイルに対して以下の処理を実行
-
進捗状況の更新
- 処理の進行状況をプログレスバーとステータスメッセージで表示
-
update_idletasks()
でGUIの更新を確実に行う
-
エラーハンドリング
- 処理中に発生する可能性のある例外を適切に捕捉
- エラーメッセージをユーザーに表示
5. トリミング実行機能
トリミング処理を実行するrun_trimming
関数を実装します
def run_trimming():
input_folder = input_folder_var.get()
output_folder = output_folder_var.get()
try:
left_margin = int(left_margin_var.get())
right_margin = int(right_margin_var.get())
top_margin = int(top_margin_var.get())
bottom_margin = int(bottom_margin_var.get())
except ValueError:
messagebox.showerror("エラー", "余白の値は整数で入力してください。")
return
if not input_folder or not output_folder:
messagebox.showerror("エラー", "入力フォルダと出力フォルダをすべて指定してください。")
return
progress_var.set(0)
status_var.set("開始中...")
trim_button.config(state=tk.DISABLED)
preview_button.config(state=tk.DISABLED)
def trimming_thread():
success = process_images(input_folder, output_folder, left_margin, right_margin,
top_margin, bottom_margin, progress_var, status_var)
if success:
messagebox.showinfo("完了", f"画像のトリミングが完了しました。\n出力フォルダ: {output_folder}")
trim_button.config(text="終了", command=root.quit)
else:
trim_button.config(text="トリミング開始")
trim_button.config(state=tk.NORMAL)
preview_button.config(state=tk.NORMAL)
thread = threading.Thread(target=trimming_thread)
thread.start()
この関数は以下のような重要な処理を行います
-
入力値の取得と検証
- フォルダパスと余白値を取得
- 余白値が整数であることを確認
- 必要な情報がすべて入力されていることを確認
-
UI状態の初期化
- プログレスバーをリセット
- ステータス表示を更新
- 操作ボタンを無効化
-
マルチスレッド処理
- 別スレッドで画像処理を実行
- UIのフリーズを防止
- 処理完了後の適切なUI更新
-
完了処理
- 成功時は完了メッセージを表示
- 失敗時は適切なエラーメッセージを表示
- ボタンの状態を元に戻す
6. プレビュー機能の実装
プレビュー機能を提供するshow_preview
関数を実装します
def show_preview():
input_folder = input_folder_var.get()
if not input_folder:
messagebox.showerror("エラー", "入力フォルダを選択してください。")
return
image_files = [f for f in os.listdir(input_folder)
if f.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.gif'))]
if not image_files:
messagebox.showerror("エラー", "指定されたフォルダに画像ファイルが見つかりません。")
return
preview_window = tk.Toplevel(root)
preview_window.title("プレビュー")
current_image_index = 0
def load_image(index):
nonlocal current_image_index
current_image_index = index
filename = image_files[index]
filepath = os.path.join(input_folder, filename)
try:
left_margin = int(left_margin_var.get())
right_margin = int(right_margin_var.get())
top_margin = int(top_margin_var.get())
bottom_margin = int(bottom_margin_var.get())
except ValueError:
messagebox.showerror("エラー", "余白の値は整数で入力してください。")
preview_window.destroy()
return
with Image.open(filepath) as img:
# オリジナル画像の表示
original_img = img.copy()
original_img.thumbnail((400, 400))
original_photo = ImageTk.PhotoImage(original_img)
original_label.config(image=original_photo)
original_label.image = original_photo
# トリミング後の画像表示
trimmed_img = trim_margins(img, left_margin, right_margin,
top_margin, bottom_margin)
trimmed_img.thumbnail((400, 400))
trimmed_photo = ImageTk.PhotoImage(trimmed_img)
trimmed_label.config(image=trimmed_photo)
trimmed_label.image = trimmed_photo
filename_label.config(text=f"ファイル: {filename} ({index + 1}/{len(image_files)})")
プレビュー機能の実装では、以下のような処理を行っています:
-
入力チェック
- フォルダの選択確認
- 画像ファイルの存在確認
-
プレビューウィンドウの作成
- 新しいウィンドウを作成
- 画像表示用のラベルを配置
-
画像の読み込みと表示
- オリジナル画像とトリミング後の画像を並べて表示
- サムネイルサイズへの調整
- メモリリークを防ぐための適切な参照管理
-
ナビゲーション機能
- 前後の画像への移動機能
- 現在の画像番号の表示
7. GUIレイアウトの実装
最後に、メインウィンドウのGUIレイアウトを実装します
# GUIの設定
root = tk.Tk()
root.title("画像トリミングツール")
input_folder_var = tk.StringVar()
output_folder_var = tk.StringVar()
left_margin_var = tk.StringVar(value="0")
right_margin_var = tk.StringVar(value="0")
top_margin_var = tk.StringVar(value="0")
bottom_margin_var = tk.StringVar(value="0")
progress_var = tk.DoubleVar()
status_var = tk.StringVar()
# フォルダ選択部分
tk.Label(root, text="入力フォルダ:").grid(row=0, column=0, sticky="e", padx=5, pady=5)
tk.Entry(root, textvariable=input_folder_var, width=50).grid(row=0, column=1, padx=5, pady=5)
tk.Button(root, text="参照", command=lambda: input_folder_var.set(select_folder())).grid(
row=0, column=2, padx=5, pady=5
)
tk.Label(root, text="出力フォルダ:").grid(row=1, column=0, sticky="e", padx=5, pady=5)
tk.Entry(root, textvariable=output_folder_var, width=50).grid(row=1, column=1, padx=5, pady=5)
tk.Button(root, text="参照", command=lambda: output_folder_var.set(select_folder())).grid(
row=1, column=2, padx=5, pady=5
)
# マージン設定部分
margin_frame = ttk.LabelFrame(root, text="余白設定(ピクセル)")
margin_frame.grid(row=2, column=0, columnspan=3, padx=5, pady=5, sticky="ew")
tk.Label(margin_frame, text="左:").grid(row=0, column=0, padx=5, pady=5)
tk.Entry(margin_frame, textvariable=top_margin_var, width=10).grid(
row=1, column=1, padx=5, pady=5
)
tk.Label(margin_frame, text="下:").grid(row=1, column=2, padx=5, pady=5)
tk.Entry(margin_frame, textvariable=bottom_margin_var, width=10).grid(
row=1, column=3, padx=5, pady=5
)
# ボタン類
preview_button = tk.Button(root, text="プレビュー", command=show_preview)
preview_button.grid(row=3, column=0, pady=10)
trim_button = tk.Button(root, text="トリミング開始", command=run_trimming)
trim_button.grid(row=3, column=1, pady=10)
# プログレスバーとステータス
progress_bar = ttk.Progressbar(root, variable=progress_var, maximum=100)
progress_bar.grid(row=4, column=0, columnspan=3, sticky="ew", padx=5, pady=5)
status_label = tk.Label(root, textvariable=status_var)
status_label.grid(row=5, column=0, columnspan=3, pady=5)
root.mainloop()
GUIのレイアウトは以下のような構成になっています。
- 変数の初期化
- 入力・出力フォルダのパスを保持する変数
- 各余白の値を保持する変数
- 進捗状況とステータス表示用の変数
- フォルダ選択部分
- 入力フォルダと出力フォルダの選択UI
- パス表示用のテキストボックス
- フォルダ選択ダイアログを開くボタン
- 余白設定部分
- 上下左右の余白を個別に設定可能
- 整数値での入力を想定
- わかりやすいラベル付きフレーム内に配置
- 操作ボタン
- プレビュー表示用のボタン
- トリミング処理開始用のボタン
- 進捗表示
- 処理の進行状況を示すプログレスバー
- 現在の状態を表示するステータスラベル
使い方
このツールの使い方を説明します。
- 入力フォルダの選択
- 「参照」ボタンをクリックして、トリミングしたい画像が入っているフォルダを選択します。
- このフォルダ内の全ての画像ファイルが処理対象となります。
- 出力フォルダの選択
- 同様に「参照」ボタンで、トリミング後の画像を保存するフォルダを選択します。
- 入力フォルダと同じフォルダを指定することもできます。
- 余白設定
- 上下左右それぞれの余白をピクセル単位で指定します。
- 初期値は全て0になっています。
- 数値は整数で入力する必要があります。
- プレビュー確認
- 「プレビュー」ボタンをクリックすると、設定した余白でトリミングした結果を確認できます。
- プレビューウィンドウでは、元の画像とトリミング後の画像を並べて表示します。
- 「前へ」「次へ」ボタンで他の画像も確認できます。
- 処理実行
- 「トリミング開始」ボタンをクリックすると、一括処理が開始されます。
- プログレスバーで進行状況を確認できます。
- 処理中もプログラムは応答を維持します。
- 完了確認
- 処理が完了すると、完了メッセージが表示されます。
- 出力フォルダに処理済みの画像が保存されています。
実行例
それでは、前回の記事で撮影したスクリーンショットで実行してみます。
このフォルダの中に、
このような形で画像が保存されています。
それではコードを実行します。
実行すると次の画面が表示されます。
入力フォルダには先ほどの画像が保存されているフォルダを選択。
出力フォルダには適当に作った「トリミング後」というフォルダを選択します。
ここからが本番です。
まずは上下に適当な数字(今回は上下に100)を入れて「プレビューボタン」を押してみましょう。
そうするとこのように、トリミング前後を比較することができます。
「前へ」「次へ」を押すと別のページも確認することができます。
こんな感じで、数字を変えては「プレビュー」を押すということを繰り返して、ちょうどいい数字を見つけます。
この時に、表紙だけで判断せずに別のページもちゃんと確認するように注意してください。
表紙がうまくいっても、他のページではやりすぎているということもよくあります。
完璧にならないことも多々あるので、うまく妥協点を探しましょう。
今回はこの設定が一番よかったです。
表紙には黒い余白が少し残ってしまいますが、本文が一番きれいに表示できるのがこれくらいでした。(この辺は好みですね)
いい感じの設定を見つけたら「トリミング開始」をクリックします。
このようにプログレスバーが進捗しますので、完了を待ちます。
PCの性能に問題が無ければ数百枚でも1,2分あれば完了すると思います。
完了するとこのようなポップアップが表示されます。
「トリミング後」フォルダを見てみると、画像がきれいにトリミングされています。
これで、画像のトリミング処理は完了です。
注意点とカスタマイズ
- 対応画像形式
- PNG, JPEG, BMP, GIF形式の画像ファイルを処理できます。
- 他の形式を追加する場合は、
process_images
関数内の拡張子リストを修正します。
- エラーハンドリング
- 画像ファイルが壊れている場合や読み込めない場合は、エラーメッセージが表示されます。
- 処理は中断されますが、既に処理済みの画像は保存されています。
- プレビューサイズ
- プレビュー画像は最大400x400ピクセルにリサイズされます。
- この値を変更する場合は、
show_preview
関数内のthumbnail
メソッドの引数を修正します。
まとめ
このスクリプトを実行することで、kindleのスクリーンショットから余白を削除してきれいにトリミングすることができます。
次回は最後の処理として、このトリミング済みの画像ファイルをPDFにまとめる方法について解説します。
Pythonで実装するKindleのPDF化ー①スクリーンショット編
Pythonで実装するKindleのPDF化ー②トリミング編
Pythonで実装するKindleのPDF化ー③PDF変換編
コード全文
import os
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
from PIL import Image, ImageTk
import threading
def trim_margins(im, left_margin, right_margin, top_margin, bottom_margin):
width, height = im.size
return im.crop((left_margin, top_margin, width - right_margin, height - bottom_margin))
def select_folder():
return filedialog.askdirectory()
def process_images(input_folder, output_folder, left_margin, right_margin, top_margin, bottom_margin, progress_var, status_var):
image_files = [f for f in os.listdir(input_folder) if f.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.gif'))]
total_files = len(image_files)
if not image_files:
messagebox.showerror("エラー", "指定されたフォルダに画像ファイルが見つかりません。")
return False
for i, filename in enumerate(image_files, 1):
input_path = os.path.join(input_folder, filename)
output_path = os.path.join(output_folder, filename)
try:
with Image.open(input_path) as img:
trimmed_img = trim_margins(img, left_margin, right_margin, top_margin, bottom_margin)
trimmed_img.save(output_path)
except Exception as e:
messagebox.showerror("エラー", f"ファイル {filename} の処理中にエラーが発生しました: {str(e)}")
return False
progress = (i / total_files) * 100
progress_var.set(progress)
status_var.set(f"処理中... {i}/{total_files} ファイル")
root.update_idletasks()
status_var.set("完了")
return True
def run_trimming():
input_folder = input_folder_var.get()
output_folder = output_folder_var.get()
try:
left_margin = int(left_margin_var.get())
right_margin = int(right_margin_var.get())
top_margin = int(top_margin_var.get())
bottom_margin = int(bottom_margin_var.get())
except ValueError:
messagebox.showerror("エラー", "余白の値は整数で入力してください。")
return
if not input_folder or not output_folder:
messagebox.showerror("エラー", "入力フォルダと出力フォルダをすべて指定してください。")
return
progress_var.set(0)
status_var.set("開始中...")
trim_button.config(state=tk.DISABLED)
preview_button.config(state=tk.DISABLED)
def trimming_thread():
success = process_images(input_folder, output_folder, left_margin, right_margin,
top_margin, bottom_margin, progress_var, status_var)
if success:
messagebox.showinfo("完了", f"画像のトリミングが完了しました。\n出力フォルダ: {output_folder}")
trim_button.config(text="終了", command=root.quit)
else:
trim_button.config(text="トリミング開始")
trim_button.config(state=tk.NORMAL)
preview_button.config(state=tk.NORMAL)
thread = threading.Thread(target=trimming_thread)
thread.start()
def show_preview():
input_folder = input_folder_var.get()
if not input_folder:
messagebox.showerror("エラー", "入力フォルダを選択してください。")
return
image_files = [f for f in os.listdir(input_folder) if f.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.gif'))]
if not image_files:
messagebox.showerror("エラー", "指定されたフォルダに画像ファイルが見つかりません。")
return
preview_window = tk.Toplevel(root)
preview_window.title("プレビュー")
current_image_index = 0
def load_image(index):
nonlocal current_image_index
current_image_index = index
filename = image_files[index]
filepath = os.path.join(input_folder, filename)
try:
left_margin = int(left_margin_var.get())
right_margin = int(right_margin_var.get())
top_margin = int(top_margin_var.get())
bottom_margin = int(bottom_margin_var.get())
except ValueError:
messagebox.showerror("エラー", "余白の値は整数で入力してください。")
preview_window.destroy()
return
with Image.open(filepath) as img:
# オリジナル画像
original_img = img.copy()
original_img.thumbnail((400, 400))
original_photo = ImageTk.PhotoImage(original_img)
original_label.config(image=original_photo)
original_label.image = original_photo
# トリミング後の画像
trimmed_img = trim_margins(img, left_margin, right_margin, top_margin, bottom_margin)
trimmed_img.thumbnail((400, 400))
trimmed_photo = ImageTk.PhotoImage(trimmed_img)
trimmed_label.config(image=trimmed_photo)
trimmed_label.image = trimmed_photo
filename_label.config(text=f"ファイル: {filename} ({index + 1}/{len(image_files)})")
def next_image():
if current_image_index < len(image_files) - 1:
load_image(current_image_index + 1)
def prev_image():
if current_image_index > 0:
load_image(current_image_index - 1)
original_label = tk.Label(preview_window)
original_label.grid(row=0, column=0, padx=10, pady=10)
trimmed_label = tk.Label(preview_window)
trimmed_label.grid(row=0, column=1, padx=10, pady=10)
filename_label = tk.Label(preview_window, text="")
filename_label.grid(row=1, column=0, columnspan=2)
prev_button = tk.Button(preview_window, text="前へ", command=prev_image)
prev_button.grid(row=2, column=0, pady=10)
next_button = tk.Button(preview_window, text="次へ", command=next_image)
next_button.grid(row=2, column=1, pady=10)
load_image(0)
# GUIの設定
root = tk.Tk()
root.title("画像トリミングツール")
input_folder_var = tk.StringVar()
output_folder_var = tk.StringVar()
left_margin_var = tk.StringVar(value="0")
right_margin_var = tk.StringVar(value="0")
top_margin_var = tk.StringVar(value="0")
bottom_margin_var = tk.StringVar(value="0")
progress_var = tk.DoubleVar()
status_var = tk.StringVar()
# フォルダ選択部分
tk.Label(root, text="入力フォルダ:").grid(row=0, column=0, sticky="e", padx=5, pady=5)
tk.Entry(root, textvariable=input_folder_var, width=50).grid(row=0, column=1, padx=5, pady=5)
tk.Button(root, text="参照", command=lambda: input_folder_var.set(select_folder())).grid(row=0, column=2, padx=5, pady=5)
tk.Label(root, text="出力フォルダ:").grid(row=1, column=0, sticky="e", padx=5, pady=5)
tk.Entry(root, textvariable=output_folder_var, width=50).grid(row=1, column=1, padx=5, pady=5)
tk.Button(root, text="参照", command=lambda: output_folder_var.set(select_folder())).grid(row=1, column=2, padx=5, pady=5)
# マージン設定部分
margin_frame = ttk.LabelFrame(root, text="余白設定(ピクセル)")
margin_frame.grid(row=2, column=0, columnspan=3, padx=5, pady=5, sticky="ew")
tk.Label(margin_frame, text="左:").grid(row=0, column=0, padx=5, pady=5)
tk.Entry(margin_frame, textvariable=left_margin_var, width=10).grid(row=0, column=1, padx=5, pady=5)
tk.Label(margin_frame, text="右:").grid(row=0, column=2, padx=5, pady=5)
tk.Entry(margin_frame, textvariable=right_margin_var, width=10).grid(row=0, column=3, padx=5, pady=5)
tk.Label(margin_frame, text="上:").grid(row=1, column=0, padx=5, pady=5)
tk.Entry(margin_frame, textvariable=top_margin_var, width=10).grid(row=1, column=1, padx=5, pady=5)
tk.Label(margin_frame, text="下:").grid(row=1, column=2, padx=5, pady=5)
tk.Entry(margin_frame, textvariable=bottom_margin_var, width=10).grid(row=1, column=3, padx=5, pady=5)
# ボタン類
preview_button = tk.Button(root, text="プレビュー", command=show_preview)
preview_button.grid(row=3, column=0, pady=10)
trim_button = tk.Button(root, text="トリミング開始", command=run_trimming)
trim_button.grid(row=3, column=1, pady=10)
# プログレスバーとステータス
progress_bar = ttk.Progressbar(root, variable=progress_var, maximum=100)
progress_bar.grid(row=4, column=0, columnspan=3, sticky="ew", padx=5, pady=5)
status_label = tk.Label(root, textvariable=status_var)
status_label.grid(row=5, column=0, columnspan=3, pady=5)
root.mainloop()
Discussion