基于Python实现PDF转图片工具

以下是一个功能完整、界面友好的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可执行软件,打包方式可参考博主上一篇文章。也可以留言,博主通过邮箱可以发送打包后的软件。

相关推荐
皮肤科大白7 小时前
图像处理的 Python库
图像处理·人工智能·python
asdfg12589637 小时前
小程序开发中的JS和Go的对比及用途
开发语言·javascript·golang
FL16238631298 小时前
基于yolo11实现的车辆实时交通流量进出统计与速度测量系统python源码+演示视频
开发语言·python·音视频
华如锦8 小时前
四:从零搭建一个RAG
java·开发语言·人工智能·python·机器学习·spring cloud·计算机视觉
向阳蒲公英8 小时前
Pycharm2025版本配置Anaconda步骤
python
每天吃饭的羊8 小时前
媒体查询
开发语言·前端·javascript
Darkershadow8 小时前
蓝牙学习之uuid与mac
python·学习·ble
北海有初拥8 小时前
Python基础语法万字详解
java·开发语言·python
阿里嘎多学长8 小时前
2026-01-02 GitHub 热点项目精选
开发语言·程序员·github·代码托管
天远云服8 小时前
Go语言高并发实战:集成天远手机号码归属地核验API打造高性能风控中台
大数据·开发语言·后端·golang