以下是一个功能完整、界面友好的PDF转图片工具,使用tkinter构建界面,pdf2image和PIL处理PDF转换,支持单页/多页导出、分辨率设置、格式选择等。
import os
import sys
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, scrolledtext
from tkinter.scrolledtext import ScrolledText
from threading import Thread
import time
# 尝试导入依赖(带错误处理)
try:
from pdf2image import convert_from_path
from PIL import Image
import fitz # PyMuPDF(备用引擎)
except ImportError as e:
missing = str(e).split("'")[1] if "'" in str(e) else "unknown"
messagebox.showerror("缺少依赖", f"请先安装必要库:\n\npip install pdf2image PyMuPDF Pillow\n\n错误:{e}")
sys.exit(1)
# ================== 全局配置 ==================
POPPLER_PATH_WINDOWS = None # Windows下Poppler路径(自动探测或用户指定)
DEFAULT_DPI = 200
SUPPORTED_FORMATS = ["PNG", "JPEG", "PDF (长图)"]
DEFAULT_FORMAT = "PNG"
# ================== 主应用类 ==================
class PDFToImageGUI:
def __init__(self, root):
self.root = root
self.root.title("📄 PDF转图片工具 v1.2")
self.root.geometry("800x650")
self.root.minsize(700, 600)
self.root.configure(bg="#f8f9fa")
# 当前任务状态
self.is_converting = False
self.cancel_requested = False
self._setup_ui()
self._check_poppler()
def _setup_ui(self):
# 🎯 顶部标题区
title_frame = ttk.Frame(self.root, padding="15 10")
title_frame.pack(fill=tk.X, pady=(0, 10))
ttk.Label(
title_frame,
text="PDF转图片工具",
font=("Microsoft YaHei", 14, "bold"),
foreground="#2c3e50"
).pack()
ttk.Label(
title_frame,
text="支持PNG/JPEG/长图PDF|高精度|批量导出",
font=("Microsoft YaHei", 9),
foreground="#7f8c8d"
).pack()
# 📁 文件选择区
input_frame = ttk.LabelFrame(self.root, text="📁 输入文件", padding="10")
input_frame.pack(fill=tk.X, padx=20, pady=(0, 10))
self.pdf_path_var = tk.StringVar()
ttk.Entry(input_frame, textvariable=self.pdf_path_var, width=60, state="readonly").pack(side=tk.LEFT, padx=(0, 10), fill=tk.X, expand=True)
ttk.Button(input_frame, text="📂 选择PDF", command=self._browse_pdf, width=12).pack(side=tk.RIGHT)
# ⚙️ 参数设置区
options_frame = ttk.LabelFrame(self.root, text="⚙️ 转换设置", padding="10")
options_frame.pack(fill=tk.X, padx=20, pady=(0, 10))
# DPI 设置
dpi_frame = ttk.Frame(options_frame)
dpi_frame.pack(fill=tk.X, pady=(0, 8))
ttk.Label(dpi_frame, text="DPI(清晰度):", width=12, anchor="w").pack(side=tk.LEFT)
self.dpi_var = tk.StringVar(value=str(DEFAULT_DPI))
ttk.Spinbox(dpi_frame, from_=72, to=600, increment=10, textvariable=self.dpi_var, width=8).pack(side=tk.LEFT, padx=(5, 15))
ttk.Label(dpi_frame, text="(推荐150-300)", font=("Arial", 8), foreground="#95a5a6").pack(side=tk.LEFT)
# 格式选择
fmt_frame = ttk.Frame(options_frame)
fmt_frame.pack(fill=tk.X, pady=(0, 8))
ttk.Label(fmt_frame, text="输出格式:", width=12, anchor="w").pack(side=tk.LEFT)
self.format_var = tk.StringVar(value=DEFAULT_FORMAT)
for fmt in SUPPORTED_FORMATS:
ttk.Radiobutton(fmt_frame, text=fmt, variable=self.format_var, value=fmt).pack(side=tk.LEFT, padx=10)
# 页面范围(可选)
page_frame = ttk.Frame(options_frame)
page_frame.pack(fill=tk.X, pady=(0, 8))
ttk.Label(page_frame, text="页码范围:", width=12, anchor="w").pack(side=tk.LEFT)
self.page_range_var = tk.StringVar(value="")
ttk.Entry(page_frame, textvariable=self.page_range_var, width=20).pack(side=tk.LEFT, padx=(5, 15))
ttk.Label(page_frame, text="(留空=全部;例:1-3 或 1,3,5)", font=("Arial", 8), foreground="#95a5a6").pack(side=tk.LEFT)
# 🖼️ 输出选项
output_frame = ttk.LabelFrame(self.root, text="🖼️ 输出设置", padding="10")
output_frame.pack(fill=tk.X, padx=20, pady=(0, 10))
self.output_dir_var = tk.StringVar(value=os.getcwd())
ttk.Entry(output_frame, textvariable=self.output_dir_var, width=60, state="readonly").pack(side=tk.LEFT, padx=(0, 10), fill=tk.X, expand=True)
ttk.Button(output_frame, text="📁 选择目录", command=self._browse_output_dir, width=12).pack(side=tk.RIGHT)
# 🚀 控制按钮区
btn_frame = ttk.Frame(self.root, padding="10")
btn_frame.pack(fill=tk.X, padx=20, pady=(0, 10))
self.btn_convert = ttk.Button(btn_frame, text="🚀 开始转换", command=self._start_conversion, width=15)
self.btn_convert.pack(side=tk.LEFT, padx=(0, 10))
self.btn_cancel = ttk.Button(btn_frame, text="⛔ 取消", command=self._cancel_conversion, width=10, state=tk.DISABLED)
self.btn_cancel.pack(side=tk.LEFT, padx=(0, 10))
self.btn_clear = ttk.Button(btn_frame, text="🧹 清空日志", command=self._clear_log, width=10)
self.btn_clear.pack(side=tk.RIGHT)
# 📊 进度条
self.progress_var = tk.DoubleVar()
self.progress_bar = ttk.Progressbar(
self.root, variable=self.progress_var, mode="determinate", length=700
)
self.progress_bar.pack(pady=(5, 15))
# 📝 日志区域
log_frame = ttk.LabelFrame(self.root, text="📝 转换日志", padding="10")
log_frame.pack(fill=tk.BOTH, expand=True, padx=20, pady=(0, 10))
self.log_text = ScrolledText(log_frame, height=12, wrap=tk.WORD, font=("Consolas", 9))
self.log_text.pack(fill=tk.BOTH, expand=True)
self.log_text.config(state=tk.DISABLED)
# 📋 状态栏
self.status_var = tk.StringVar()
self.status_var.set("就绪|请先选择PDF文件")
status_bar = ttk.Label(self.root, textvariable=self.status_var, relief=tk.SUNKEN, anchor=tk.W, padding=5)
status_bar.pack(side=tk.BOTTOM, fill=tk.X)
# 🔔 绑定关闭事件
self.root.protocol("WM_DELETE_WINDOW", self._on_closing)
# ================== 功能方法 ==================
def _browse_pdf(self):
file_path = filedialog.askopenfilename(
title="选择PDF文件",
filetypes=[("PDF文件", "*.pdf"), ("所有文件", "*.*")]
)
if file_path:
self.pdf_path_var.set(file_path)
self.status_var.set(f"✅ 已选择:{os.path.basename(file_path)}")
def _browse_output_dir(self):
dir_path = filedialog.askdirectory(title="选择输出目录", initialdir=self.output_dir_var.get())
if dir_path:
self.output_dir_var.set(dir_path)
self.status_var.set(f"📁 输出目录已设为:{os.path.basename(dir_path)}")
def _check_poppler(self):
"""检查Poppler是否可用(仅Windows需显式路径)"""
try:
if sys.platform == "win32":
# 尝试自动探测常用Poppler路径(如Chocolatey、手动安装)
common_paths = [
r"C:\poppler\Library\bin",
r"C:\poppler\bin",
r"C:\Program Files\poppler\Library\bin",
r"C:\Program Files\poppler\bin",
r"C:\Plugins\poppler-25.12.0\Library\bin",
]
for p in common_paths:
if os.path.exists(os.path.join(p, "pdftoppm.exe")):
global POPPLER_PATH_WINDOWS
POPPLER_PATH_WINDOWS = p
self._log("💡 自动找到Poppler路径:" + p)
return
# 提示用户手动指定
self._log("⚠️ 未找到Poppler!请下载并安装Poppler(Windows版)→ https://github.com/oschwartz10612/poppler-windows/releases/")
self._log("默认检查目录:C:\poppler\Library\\bin;C:\poppler\\bin;C:\Program Files\poppler\Library\\bin;C:\Program Files\poppler\\bin;C:\Plugins\poppler-25.12.0\Library\\bin")
self._log(" 下载后解压,将 'bin' 目录路径填入下方弹窗。")
self._prompt_poppler_path()
except Exception as e:
self._log(f"🔍 Poppler检查异常:{e}")
def _prompt_poppler_path(self):
"""弹窗提示用户输入Poppler路径"""
top = tk.Toplevel(self.root)
top.title("设置Poppler路径")
top.geometry("500x150")
top.transient(self.root)
top.grab_set()
ttk.Label(top, text="请输入Poppler的 bin 目录路径(含 pdftoppm.exe):", wraplength=450).pack(pady=(10, 5))
path_var = tk.StringVar()
entry = ttk.Entry(top, textvariable=path_var, width=60)
entry.pack(padx=10, pady=5, fill=tk.X)
def save_path():
p = path_var.get().strip()
if os.path.exists(os.path.join(p, "pdftoppm.exe")):
global POPPLER_PATH_WINDOWS
POPPLER_PATH_WINDOWS = p
self._log(f"✅ 已设置Poppler路径:{p}")
top.destroy()
else:
messagebox.showerror("错误", "路径无效:未找到 pdftoppm.exe")
ttk.Button(top, text="确认", command=save_path).pack(pady=10)
def _parse_page_range(self, text: str) -> list:
"""解析页码范围字符串,返回页码列表(从0开始)"""
if not text.strip():
return None
try:
pages = []
for part in text.strip().split(","):
part = part.strip()
if "-" in part:
start, end = map(int, part.split("-"))
pages.extend(range(start - 1, end)) # 转为0索引
else:
pages.append(int(part) - 1)
return sorted(set(pages)) # 去重并排序
except:
self._log("❌ 页码格式错误,请输入如:1,3,5 或 1-3")
return None
def _log(self, msg: str):
"""安全写入日志(线程安全)"""
self.log_text.config(state=tk.NORMAL)
self.log_text.insert(tk.END, f"[{time.strftime('%H:%M:%S')}] {msg}\n")
self.log_text.see(tk.END)
self.log_text.config(state=tk.DISABLED)
def _clear_log(self):
self.log_text.config(state=tk.NORMAL)
self.log_text.delete(1.0, tk.END)
self.log_text.config(state=tk.DISABLED)
def _start_conversion(self):
if self.is_converting:
return
if not self.pdf_path_var.get():
messagebox.showwarning("警告", "请先选择PDF文件!")
return
if not os.path.exists(self.pdf_path_var.get()):
messagebox.showerror("错误", "PDF文件不存在!")
return
# 启动后台线程
self.is_converting = True
self.cancel_requested = False
self.btn_convert.config(state=tk.DISABLED)
self.btn_cancel.config(state=tk.NORMAL)
self.progress_var.set(0)
self._log("▶️ 开始转换任务...")
self.status_var.set("🔄 正在转换中...")
# 在新线程中执行(避免GUI冻结)
Thread(target=self._do_conversion, daemon=True).start()
def _cancel_conversion(self):
self.cancel_requested = True
self._log("⏸️ 用户请求取消...")
self.status_var.set("🛑 正在取消中...")
def _do_conversion(self):
try:
pdf_path = self.pdf_path_var.get()
output_dir = self.output_dir_var.get()
dpi = int(self.dpi_var.get())
fmt = self.format_var.get()
page_range_str = self.page_range_var.get()
# 解析页码
page_list = self._parse_page_range(page_range_str)
if page_list is not None and len(page_list) == 0:
self._log("❌ 页码范围为空,退出转换")
self._finish_conversion()
return
# 创建输出目录
os.makedirs(output_dir, exist_ok=True)
# 获取总页数(用于进度计算)
doc = fitz.open(pdf_path)
total_pages = doc.page_count
doc.close()
# 限制实际转换页数
if page_list:
pages_to_convert = [p for p in page_list if 0 <= p < total_pages]
total_pages = len(pages_to_convert)
self._log(f"📄 将转换 {len(pages_to_convert)} 页(共{total_pages}页)")
else:
pages_to_convert = None
self._log(f"📄 将转换全部 {total_pages} 页")
# 执行转换
if fmt == "PDF (长图)":
self._convert_to_long_pdf(pdf_path, output_dir, dpi, pages_to_convert)
else:
self._convert_to_images(pdf_path, output_dir, dpi, fmt, pages_to_convert)
except Exception as e:
self._log(f"❌ 转换失败:{e}")
finally:
self._finish_conversion()
def _convert_to_images(self, pdf_path, output_dir, dpi, fmt, page_list):
"""转换为PNG/JPEG图像"""
try:
kwargs = {"dpi": dpi}
if sys.platform == "win32" and POPPLER_PATH_WINDOWS:
kwargs["poppler_path"] = POPPLER_PATH_WINDOWS
self._log("🔧 使用pdf2image转换中...")
images = convert_from_path(pdf_path, **kwargs, first_page=None if not page_list else min(page_list)+1,
last_page=None if not page_list else max(page_list)+1)
if not images:
self._log("❌ 未生成任何图像")
return
# 如果指定了页码列表,则只取对应页
if page_list:
# pdf2image的page_list是连续的,需要映射
# 我们用更稳妥的方式:逐页转换
self._log("🔄 按指定页码逐页转换(精确匹配)...")
images = []
for i, p in enumerate(page_list):
img = convert_from_path(pdf_path, dpi=dpi, first_page=p+1, last_page=p+1, **kwargs)
if img:
images.append(img[0])
if self.cancel_requested:
break
# 保存图像
base_name = os.path.splitext(os.path.basename(pdf_path))[0]
ext = "png" if fmt == "PNG" else "jpg"
for i, image in enumerate(images):
if self.cancel_requested:
self._log("⏹️ 转换已取消")
break
idx = page_list[i] + 1 if page_list else i + 1
filename = f"{base_name}_page_{idx}.{ext}"
out_path = os.path.join(output_dir, filename)
image.save(out_path, fmt)
self._log(f"✅ 已保存:{filename}")
self.progress_var.set((i + 1) / len(images) * 100)
self.root.update_idletasks()
except Exception as e:
raise e
def _convert_to_long_pdf(self, pdf_path, output_dir, dpi, page_list):
"""转换为长图PDF(一页PDF)"""
try:
self._log("🔧 使用PyMuPDF生成长图PDF...")
doc = fitz.open(pdf_path)
pages_to_use = range(doc.page_count) if not page_list else page_list
# 创建新PDF文档
long_doc = fitz.open()
long_page = None
for i, pno in enumerate(pages_to_use):
if self.cancel_requested:
break
page = doc[pno]
# 缩放页面以保持比例
mat = fitz.Matrix(dpi / 72, dpi / 72)
pix = page.get_pixmap(matrix=mat, alpha=False)
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
# 第一页:创建新页面
if i == 0:
# 创建与第一页等宽、高度累加的页面
w, h = img.size
long_page = long_doc.new_page(-1, width=w, height=h)
long_page.insert_image(fitz.Rect(0, 0, w, h), pixmap=fitz.Pixmap(fitz.csRGB, img.tobytes(), w, h))
else:
# 后续页:追加到长页底部
_, _, _, curr_h = long_page.bound()
new_h = curr_h + img.height
# 重新设置页面高度
long_page = long_doc[-1]
long_page.set_mediabox(fitz.Rect(0, 0, img.width, new_h))
# 插入图像
long_page.insert_image(fitz.Rect(0, curr_h, img.width, new_h), pixmap=fitz.Pixmap(fitz.csRGB, img.tobytes(), img.width, img.height))
self.progress_var.set((i + 1) / len(pages_to_use) * 100)
self.root.update_idletasks()
if not self.cancel_requested:
out_path = os.path.join(output_dir, f"{os.path.splitext(os.path.basename(pdf_path))[0]}_long.pdf")
long_doc.save(out_path)
self._log(f"✅ 长图PDF已保存:{os.path.basename(out_path)}")
doc.close()
long_doc.close()
except Exception as e:
raise e
def _finish_conversion(self):
self.is_converting = False
self.btn_convert.config(state=tk.NORMAL)
self.btn_cancel.config(state=tk.DISABLED)
self.progress_var.set(0)
self.status_var.set("✅ 转换完成!可在输出目录查看结果")
self._log("🎉 转换任务结束")
def _on_closing(self):
if self.is_converting:
if messagebox.askokcancel("确认退出", "转换正在进行中,确定要退出吗?"):
self._cancel_conversion()
self.root.after(500, self.root.destroy)
else:
self.root.destroy()
# ================== 启动入口 ==================
if __name__ == "__main__":
root = tk.Tk()
app = PDFToImageGUI(root)
root.mainloop()
使用说明
1、安装依赖
首次运行前需安装依赖组件:
pip install pdf2image PyMuPDF Pillow
2、Windows用户额外安装Poppler
- 下载 Poppler for Windows(推荐 poppler-XX.XX.XX_x64.7z,无法访问github时,可从其他地址下载,或留言邮箱)
- 解压后,将 poppler-XX.XX.XX\Library\bin 添加到系统环境变量 PATH
3.运行方式
python pdf_to_image_gui.py
也可以打包成exe可执行软件,打包方式可参考博主上一篇文章。也可以留言,博主通过邮箱可以发送打包后的软件。