iT邦幫忙

0

[Day 12]Python 桌面小工具:PDF 合併 / 分割器(Tkinter + PyPDF2)

  • 分享至 

  • xImage
  •  

延續 Day 11 的桌面工具系列,今天做一個離線可用的 PDF 小幫手:
合併:多個 PDF 一鍵合併、支援上下移動、移除、清空!
分割:輸入頁碼範圍(1-3,5,8-9)或「每頁一檔」
支援中文檔名與資料夾
有進度條、背景執行,不會卡 GUI
遇到加密檔會提示輸入密碼(可略過)

安裝pip install PyPDF2

程式碼(存成 pdf_tool.py)

# pdf_tool.py — Day 12:PDF 合併 / 分割器(Tkinter + PyPDF2)
from __future__ import annotations
import threading
from pathlib import Path
from typing import List, Tuple

import PyPDF2
from tkinter import Tk, StringVar, BooleanVar, filedialog, messagebox, simpledialog
from tkinter import ttk, Listbox, MULTIPLE, END


def human_path(p: Path) -> str:
    try:
        return str(p)
    except Exception:
        return p.as_posix()


def parse_ranges(expr: str, max_page: int) -> List[Tuple[int, int]]:
    """
    將 '1-3,5,8-9' 解析成 [(1,3),(5,5),(8,9)](1-based, inclusive)
    """
    expr = (expr or "").strip()
    if not expr:
        return []
    out = []
    for part in expr.split(","):
        part = part.strip()
        if not part:
            continue
        if "-" in part:
            a, b = part.split("-", 1)
            a = int(a); b = int(b)
            if a < 1 or b < 1 or a > max_page or b > max_page:
                raise ValueError(f"頁碼超出範圍:{part}(1~{max_page})")
            if a > b:
                a, b = b, a
            out.append((a, b))
        else:
            x = int(part)
            if x < 1 or x > max_page:
                raise ValueError(f"頁碼超出範圍:{part}(1~{max_page})")
            out.append((x, x))
    return out


class PDFToolApp:
    def __init__(self) -> None:
        self.root = Tk()
        self.root.title("PDF 合併 / 分割器 (Day 12)")
        self.root.geometry("720x480")

        nb = ttk.Notebook(self.root)
        self.tab_merge = ttk.Frame(nb)
        self.tab_split = ttk.Frame(nb)
        nb.add(self.tab_merge, text="PDF 合併")
        nb.add(self.tab_split, text="PDF 分割")
        nb.pack(fill="both", expand=True)

        self.build_merge_tab()
        self.build_split_tab()

    # ---------------- 合併 ----------------
    def build_merge_tab(self):
        pad = {"padx": 8, "pady": 6}

        self.lb: Listbox = Listbox(self.tab_merge, selectmode=MULTIPLE, width=70, height=14)
        self.lb.grid(row=0, column=0, rowspan=6, **pad, sticky="nsw")

        btns = ttk.Frame(self.tab_merge)
        btns.grid(row=0, column=1, sticky="nw", **pad)

        ttk.Button(btns, text="加入 PDF…", command=self.add_pdfs).grid(row=0, column=0, sticky="ew", pady=3)
        ttk.Button(btns, text="移除選取", command=self.remove_selected).grid(row=1, column=0, sticky="ew", pady=3)
        ttk.Button(btns, text="上移 ▲", command=lambda: self.move_selected(-1)).grid(row=2, column=0, sticky="ew", pady=3)
        ttk.Button(btns, text="下移 ▼", command=lambda: self.move_selected(+1)).grid(row=3, column=0, sticky="ew", pady=3)
        ttk.Button(btns, text="清空列表", command=self.lb.delete(0, END)).grid(row=4, column=0, sticky="ew", pady=3)

        self.out_merge = StringVar()
        ttk.Label(self.tab_merge, text="輸出檔名:").grid(row=6, column=0, sticky="w", **pad)
        out_row = ttk.Frame(self.tab_merge); out_row.grid(row=7, column=0, columnspan=2, sticky="we", **pad)
        ttk.Entry(out_row, textvariable=self.out_merge, width=62).pack(side="left", padx=(0, 6))
        ttk.Button(out_row, text="選擇…", command=self.pick_merge_out).pack(side="left")

        self.merge_status = ttk.Label(self.tab_merge, text="等待操作…")
        self.merge_status.grid(row=8, column=0, columnspan=2, sticky="w", **pad)
        self.merge_prog = ttk.Progressbar(self.tab_merge, length=660)
        self.merge_prog.grid(row=9, column=0, columnspan=2, sticky="we", padx=8, pady=(0, 10))

        ttk.Button(self.tab_merge, text="開始合併", command=self.start_merge).grid(row=10, column=0, columnspan=2, pady=6)

    def add_pdfs(self):
        files = filedialog.askopenfilenames(title="選擇 PDF", filetypes=[("PDF", "*.pdf")])
        for f in files:
            self.lb.insert(END, f)

    def remove_selected(self):
        sel = list(self.lb.curselection())[::-1]
        for i in sel:
            self.lb.delete(i)

    def move_selected(self, delta: int):
        sel = list(self.lb.curselection())
        if not sel:
            return
        items = [self.lb.get(i) for i in range(self.lb.size())]
        for i in sel:
            j = i + delta
            if 0 <= j < len(items):
                items[i], items[j] = items[j], items[i]
        self.lb.delete(0, END)
        for it in items:
            self.lb.insert(END, it)
        # 重新選取移動後的位置
        self.lb.selection_clear(0, END)
        for i in [min(max(0, s + delta), len(items) - 1) for s in sel]:
            self.lb.selection_set(i)

    def pick_merge_out(self):
        f = filedialog.asksaveasfilename(title="輸出合併檔", defaultextension=".pdf",
                                         filetypes=[("PDF", "*.pdf")], initialfile="merged.pdf")
        if f:
            self.out_merge.set(f)

    def start_merge(self):
        files = [Path(self.lb.get(i)) for i in range(self.lb.size())]
        if not files:
            messagebox.showwarning("提醒", "請先加入要合併的 PDF")
            return
        out = Path(self.out_merge.get().strip()) if self.out_merge.get().strip() else None
        if not out:
            self.pick_merge_out()
            out = Path(self.out_merge.get().strip()) if self.out_merge.get().strip() else None
        if not out:
            return

        self.merge_prog["value"] = 0
        self.merge_prog["maximum"] = len(files)
        self.merge_status.config(text="開始合併…")

        t = threading.Thread(target=self.do_merge, args=(files, out), daemon=True)
        t.start()

    def _open_reader(self, path: Path) -> PyPDF2.PdfReader | None:
        try:
            r = PyPDF2.PdfReader(human_path(path))
            if r.is_encrypted:
                pwd = simpledialog.askstring("受保護的 PDF", f"此檔案受保護:\n{path.name}\n請輸入密碼(留空則略過)", show="*")
                if not pwd:
                    return None
                try:
                    r.decrypt(pwd)  # PyPDF2 v3: 回傳 1/0
                except Exception:
                    messagebox.showerror("錯誤", f"密碼錯誤:{path.name}")
                    return None
                # 某些檔需重新打開
                r = PyPDF2.PdfReader(human_path(path))
            return r
        except Exception as e:
            messagebox.showwarning("略過", f"無法讀取:{path.name}\n{e}")
            return None

    def do_merge(self, files: List[Path], out: Path):
        writer = PyPDF2.PdfWriter()
        done = 0
        for fp in files:
            r = self._open_reader(fp)
            if not r:
                done += 1
                self.merge_prog["value"] = done
                self.merge_status.config(text=f"處理中(略過受保護/錯誤):{done}/{len(files)}")
                continue
            for p in r.pages:
                writer.add_page(p)
            done += 1
            self.merge_prog["value"] = done
            self.merge_status.config(text=f"處理中:{done}/{len(files)}")
        try:
            with open(human_path(out), "wb") as f:
                writer.write(f)
            self.merge_status.config(text=f"完成!已輸出:{out}")
            messagebox.showinfo("完成", f"合併成功:\n{out}")
        except Exception as e:
            messagebox.showerror("錯誤", f"寫檔失敗:\n{e}")

    # ---------------- 分割 ----------------
    def build_split_tab(self):
        pad = {"padx": 8, "pady": 6}

        self.split_src = StringVar()
        self.split_outdir = StringVar()
        self.split_each = BooleanVar(value=False)
        self.split_ranges = StringVar()

        ttk.Label(self.tab_split, text="來源 PDF").grid(row=0, column=0, sticky="e", **pad)
        ttk.Entry(self.tab_split, textvariable=self.split_src, width=60).grid(row=0, column=1, **pad)
        ttk.Button(self.tab_split, text="選擇…", command=self.pick_split_src).grid(row=0, column=2, **pad)

        ttk.Label(self.tab_split, text="輸出資料夾").grid(row=1, column=0, sticky="e", **pad)
        ttk.Entry(self.tab_split, textvariable=self.split_outdir, width=60).grid(row=1, column=1, **pad)
        ttk.Button(self.tab_split, text="選擇…", command=self.pick_split_outdir).grid(row=1, column=2, **pad)

        ttk.Checkbutton(self.tab_split, text="每頁一檔", variable=self.split_each
                       ).grid(row=2, column=1, sticky="w", **pad)

        ttk.Label(self.tab_split, text="頁碼範圍(例:1-3,5,8-9)").grid(row=3, column=0, sticky="e", **pad)
        ttk.Entry(self.tab_split, textvariable=self.split_ranges, width=40).grid(row=3, column=1, sticky="w", **pad)

        self.split_status = ttk.Label(self.tab_split, text="等待操作…")
        self.split_status.grid(row=4, column=0, columnspan=3, sticky="w", **pad)
        self.split_prog = ttk.Progressbar(self.tab_split, length=660)
        self.split_prog.grid(row=5, column=0, columnspan=3, sticky="we", padx=8, pady=(0, 10))

        ttk.Button(self.tab_split, text="開始分割", command=self.start_split
                  ).grid(row=6, column=0, columnspan=3, pady=6)

    def pick_split_src(self):
        f = filedialog.askopenfilename(title="選擇來源 PDF", filetypes=[("PDF", "*.pdf")])
        if f:
            self.split_src.set(f)

    def pick_split_outdir(self):
        d = filedialog.askdirectory(title="選擇輸出資料夾")
        if d:
            self.split_outdir.set(d)

    def start_split(self):
        src = Path(self.split_src.get().strip())
        outdir = Path(self.split_outdir.get().strip())
        if not src.exists():
            messagebox.showwarning("提醒", "請選擇來源 PDF")
            return
        if not outdir.exists():
            try:
                outdir.mkdir(parents=True, exist_ok=True)
            except Exception as e:
                messagebox.showerror("錯誤", f"無法建立輸出資料夾:{e}")
                return

        self.split_prog["value"] = 0
        self.split_status.config(text="分析來源…")
        t = threading.Thread(target=self.do_split, args=(src, outdir, self.split_each.get(), self.split_ranges.get()),
                             daemon=True)
        t.start()

    def do_split(self, src: Path, outdir: Path, each: bool, ranges_expr: str):
        reader = self._open_reader(src)
        if not reader:
            self.split_status.config(text="來源檔無法開啟或已略過")
            return

        total_pages = len(reader.pages)

        jobs: List[Tuple[int, int]] = []
        if each:
            jobs = [(i, i) for i in range(1, total_pages + 1)]
        else:
            try:
                jobs = parse_ranges(ranges_expr, total_pages)
            except Exception as e:
                messagebox.showerror("頁碼格式錯誤", str(e))
                return
            if not jobs:
                messagebox.showwarning("提醒", "請勾選『每頁一檔』或填入頁碼範圍")
                return

        self.split_prog["maximum"] = len(jobs)
        done = 0

        base = src.stem
        for (a, b) in jobs:
            writer = PyPDF2.PdfWriter()
            for i in range(a - 1, b):  # reader.pages 是 0-based
                writer.add_page(reader.pages[i])
            out = outdir / f"{base}_{a}-{b}.pdf"
            with open(human_path(out), "wb") as f:
                writer.write(f)
            done += 1
            self.split_prog["value"] = done
            self.split_status.config(text=f"輸出:{done}/{len(jobs)}")

        self.split_status.config(text=f"完成!共輸出 {done} 檔,位置:{outdir}")
        messagebox.showinfo("完成", f"分割成功,輸出於:\n{outdir}")

    # ----------------
    def run(self):
        self.root.mainloop()


if __name__ == "__main__":
    PDFToolApp().run()

怎麼用
合併: 加入多個 PDF → 可上/下移、移除 → 選擇輸出檔名 →「開始合併」。
分割: 選來源 PDF、輸出資料夾 →
勾「每頁一檔」;或
在「頁碼範圍」輸入 1-3,5,8-9(1 為第一頁)
→ 點「開始分割」。

實作:
合併
https://ithelp.ithome.com.tw/upload/images/20250926/2016936820CGKRyT4r.png
https://ithelp.ithome.com.tw/upload/images/20250926/20169368WZ6CNR06mE.pnghttps://ithelp.ithome.com.tw/upload/images/20250926/2016936851MpCE0pL0.png
分割
https://ithelp.ithome.com.tw/upload/images/20250926/20169368Sg6QYtY5eD.png
https://ithelp.ithome.com.tw/upload/images/20250926/20169368z2oMgjN9Wt.png
小提醒 / 除錯
ModuleNotFoundError: PyPDF2:先 pip install PyPDF2。
檔案受保護:程式會跳出密碼輸入框;若不知道密碼,可略過。
頁碼錯誤:請確認頁碼介於 1 ~ 總頁數,用逗號分隔,多段可混用。
合併/分割後開啟失敗:檔案可能壞掉或 PDF 特殊(稀有)。

今日小結
完成 PDF 合併/分割 的桌面 GUI 工具
學到 PyPDF2 的 PdfReader/PdfWriter 用法、Tkinter Notebook 分頁、進度條與背景執行


圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言