| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322 |
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- """
- PDF 条码合成工具(单文件输入,每页独立输出)
- """
- import tkinter as tk
- from tkinter import ttk, filedialog, scrolledtext
- import os
- import json
- import re
- import threading
- import fitz # PyMuPDF
- from PIL import Image
- CONFIG_FILE = "pdf_merge_config.json"
- class PDFMergeApp:
- def __init__(self, root):
- self.root = root
- self.root.title("PDF 条码合成工具")
- self.root.geometry("780x600")
- self.root.minsize(700, 500)
- # 路径变量
- self.input_var = tk.StringVar()
- self.template_var = tk.StringVar()
- self.output_var = tk.StringVar()
- # 缩放与偏移变量
- self.scale_var = tk.StringVar(value="100")
- self.offset_x_var = tk.StringVar(value="0")
- self.offset_y_var = tk.StringVar(value="10")
- self.build_ui()
- self.load_config()
- def build_ui(self):
- main_frame = ttk.Frame(self.root, padding="12")
- main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
- self.root.columnconfigure(0, weight=1)
- self.root.rowconfigure(0, weight=1)
- main_frame.columnconfigure(1, weight=1)
- # 输入文件
- ttk.Label(main_frame, text="输入文件:").grid(row=0, column=0, sticky=tk.W, pady=6)
- ttk.Entry(main_frame, textvariable=self.input_var).grid(row=0, column=1, sticky=(tk.W, tk.E), padx=6)
- ttk.Button(main_frame, text="浏览…", width=10, command=self.select_input).grid(row=0, column=2, padx=4)
- # 模板文件
- ttk.Label(main_frame, text="模板文件:").grid(row=1, column=0, sticky=tk.W, pady=6)
- ttk.Entry(main_frame, textvariable=self.template_var).grid(row=1, column=1, sticky=(tk.W, tk.E), padx=6)
- ttk.Button(main_frame, text="浏览…", width=10, command=self.select_template).grid(row=1, column=2, padx=4)
- # 输出文件夹
- ttk.Label(main_frame, text="输出文件夹:").grid(row=2, column=0, sticky=tk.W, pady=6)
- ttk.Entry(main_frame, textvariable=self.output_var).grid(row=2, column=1, sticky=(tk.W, tk.E), padx=6)
- ttk.Button(main_frame, text="浏览…", width=10, command=self.select_output).grid(row=2, column=2, padx=4)
- # 参数配置区域
- param_frame = ttk.LabelFrame(main_frame, text="合成参数(自动保存)", padding="10")
- param_frame.grid(row=3, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=10)
- ttk.Label(param_frame, text="缩放比例(% 模板宽):").grid(row=0, column=0, sticky=tk.W, padx=4)
- ttk.Entry(param_frame, textvariable=self.scale_var, width=10).grid(row=0, column=1, sticky=tk.W, padx=4)
- ttk.Label(param_frame, text="例:100 = 宽度填满模板").grid(row=0, column=2, sticky=tk.W, padx=4)
- ttk.Label(param_frame, text="X 偏移(点):").grid(row=1, column=0, sticky=tk.W, padx=4, pady=6)
- ttk.Entry(param_frame, textvariable=self.offset_x_var, width=10).grid(row=1, column=1, sticky=tk.W, padx=4, pady=6)
- ttk.Label(param_frame, text="左上角水平偏移,0 表示贴左边").grid(row=1, column=2, sticky=tk.W, padx=4, pady=6)
- ttk.Label(param_frame, text="Y 偏移(点):").grid(row=2, column=0, sticky=tk.W, padx=4)
- ttk.Entry(param_frame, textvariable=self.offset_y_var, width=10).grid(row=2, column=1, sticky=tk.W, padx=4)
- ttk.Label(param_frame, text="左上角垂直偏移,0 表示贴顶部").grid(row=2, column=2, sticky=tk.W, padx=4)
- # 开始按钮
- btn_frame = ttk.Frame(main_frame)
- btn_frame.grid(row=4, column=0, columnspan=3, pady=10)
- self.start_btn = ttk.Button(btn_frame, text="▶ 开始处理", command=self.start_process)
- self.start_btn.pack()
- # 日志区域
- ttk.Label(main_frame, text="处理日志:").grid(row=5, column=0, sticky=tk.W, pady=(8, 0))
- self.log_text = scrolledtext.ScrolledText(
- main_frame, height=18, wrap=tk.WORD, state=tk.NORMAL, font=("Consolas", 10)
- )
- self.log_text.grid(row=6, column=0, columnspan=3, sticky=(tk.W, tk.E, tk.N, tk.S), pady=4)
- main_frame.rowconfigure(6, weight=1)
- # 参数变更自动保存
- for var in (self.scale_var, self.offset_x_var, self.offset_y_var):
- var.trace_add("write", lambda *args: self.save_config())
- # ---------- 路径选择 ----------
- def select_input(self):
- file_path = filedialog.askopenfilename(filetypes=[("PDF 文件", "*.pdf")])
- if file_path:
- self.input_var.set(file_path)
- self.save_config()
- def select_template(self):
- file_path = filedialog.askopenfilename(filetypes=[("PDF 文件", "*.pdf")])
- if file_path:
- self.template_var.set(file_path)
- self.save_config()
- def select_output(self):
- folder = filedialog.askdirectory()
- if folder:
- self.output_var.set(folder)
- self.save_config()
- # ---------- 配置持久化 ----------
- def save_config(self):
- config = {
- "input_file": self.input_var.get(),
- "template": self.template_var.get(),
- "output_dir": self.output_var.get(),
- "scale_percent": self.scale_var.get(),
- "offset_x": self.offset_x_var.get(),
- "offset_y": self.offset_y_var.get(),
- }
- try:
- with open(CONFIG_FILE, "w", encoding="utf-8") as f:
- json.dump(config, f, ensure_ascii=False, indent=2)
- except Exception:
- pass
- def load_config(self):
- if not os.path.exists(CONFIG_FILE):
- return
- try:
- with open(CONFIG_FILE, "r", encoding="utf-8") as f:
- config = json.load(f)
- self.input_var.set(config.get("input_file", ""))
- self.template_var.set(config.get("template", ""))
- self.output_var.set(config.get("output_dir", ""))
- self.scale_var.set(config.get("scale_percent", "100"))
- self.offset_x_var.set(config.get("offset_x", "0"))
- self.offset_y_var.set(config.get("offset_y", "0"))
- except Exception:
- pass
- # ---------- 日志 ----------
- def log(self, msg):
- self.log_text.insert(tk.END, msg + "\n")
- self.log_text.see(tk.END)
- self.root.update_idletasks()
- # ---------- 文字提取 ----------
- def extract_text(self, page):
- text = page.get_text().strip()
- if len(text) >= 2:
- return text
- try:
- import pytesseract
- pix = page.get_pixmap(dpi=300)
- mode = "RGBA" if pix.alpha else "RGB"
- img = Image.frombytes(mode, [pix.width, pix.height], pix.samples)
- if mode == "RGBA":
- img = img.convert("RGB")
- text = pytesseract.image_to_string(img, lang="chi_sim+eng").strip()
- return text
- except Exception:
- return ""
- def get_filename_from_text(self, text):
- """
- 取第一段 + 第四段文字作为文件名
- 如果第四段不存在,只用第一段
- 如果第一段也不存在,返回空字符串
- """
- if not text:
- return ""
- lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
- if not lines:
- return ""
- first = lines[0]
- fourth = lines[4] if len(lines) >= 5 else ""
- fourth = fourth.replace("¨", "0")
- fourth = fourth.replace("©", "1")
- fourth = fourth.replace("ª", "2")
- fourth = fourth.replace("«", "3")
- fourth = fourth.replace("°2", "4")
- fourth = fourth.replace("°2", "5")
- fourth = fourth.replace("®", "6")
- fourth = fourth.replace("°2", "7")
- fourth = fourth.replace("°", "8")
- fourth = fourth.replace("°2", "9")
- name = f"{first}_{fourth}" if fourth else first
- return self.sanitize_filename(name)
- def sanitize_filename(self, text):
- if not text:
- return ""
- name = text[:80] # 限制长度
- name = re.sub(r'[\\/:*?"<>|\s]+', "_", name)
- return name.strip("._")
- # ---------- 核心处理 ----------
- def start_process(self):
- t = threading.Thread(target=self.process, daemon=True)
- t.start()
- def process(self):
- self.start_btn.config(state=tk.DISABLED)
- self.log_text.delete(1.0, tk.END)
- input_file = self.input_var.get()
- template_file = self.template_var.get()
- output_dir = self.output_var.get()
- # 解析参数
- try:
- scale_percent = float(self.scale_var.get().strip() or "100")
- offset_x = float(self.offset_x_var.get().strip() or "0")
- offset_y = float(self.offset_y_var.get().strip() or "0")
- except ValueError:
- self.log("❌ 错误:缩放比例、X 偏移、Y 偏移必须是有效数字")
- self.start_btn.config(state=tk.NORMAL)
- return
- if not all([input_file, template_file, output_dir]):
- self.log("❌ 错误:请完整填写输入文件、模板文件和输出文件夹")
- self.start_btn.config(state=tk.NORMAL)
- return
- if not os.path.isfile(input_file):
- self.log(f"❌ 错误:输入文件不存在:{input_file}")
- self.start_btn.config(state=tk.NORMAL)
- return
- if not os.path.isfile(template_file):
- self.log(f"❌ 错误:模板文件不存在:{template_file}")
- self.start_btn.config(state=tk.NORMAL)
- return
- if not os.path.exists(output_dir):
- os.makedirs(output_dir, exist_ok=True)
- self.log(f"📄 输入文件:{os.path.basename(input_file)}")
- self.log(f"⚙ 参数:缩放 {scale_percent}% 宽,偏移 ({offset_x}, {offset_y})\n")
- try:
- template_doc = fitz.open(template_file)
- if len(template_doc) == 0:
- self.log("❌ 错误:模板文件为空")
- self.start_btn.config(state=tk.NORMAL)
- return
- template_page = template_doc[0]
- template_rect = template_page.rect
- template_w = template_rect.width
- template_h = template_rect.height
- doc = fitz.open(input_file)
- odd_indices = list(range(0, len(doc), 2))
- if not odd_indices:
- self.log("⚠ 该文件没有奇数页,已跳过")
- doc.close()
- template_doc.close()
- self.start_btn.config(state=tk.NORMAL)
- return
- self.log(f"📑 共 {len(doc)} 页,提取奇数页 {len(odd_indices)} 页\n")
- for idx in odd_indices:
- page = doc[idx]
- page_num = idx + 1
- # 从该页提取文字作为文件名
- page_text = self.extract_text(page)
- safe_name = self.get_filename_from_text(page_text)
- if not safe_name:
- base = os.path.splitext(os.path.basename(input_file))[0]
- safe_name = f"{base}_page{page_num}"
- # 创建单页新 PDF
- new_doc = fitz.open()
- new_page = new_doc.new_page(width=template_w, height=template_h)
- # 1) 模板背景
- new_page.show_pdf_page(template_rect, template_doc, 0, overlay=False)
- # 2) 计算输入页目标矩形
- input_rect = page.rect
- target_w = template_w * (scale_percent / 100.0)
- scale = target_w / input_rect.width
- target_h = input_rect.height * scale
- target_rect = fitz.Rect(offset_x, offset_y, offset_x + target_w, offset_y + target_h)
- # 3) 叠加输入页(前景)
- new_page.show_pdf_page(target_rect, doc, idx, overlay=True)
- # 保存(自动处理重名)
- out_name = f"{safe_name}.pdf"
- out_path = os.path.join(output_dir, out_name)
- counter = 1
- while os.path.exists(out_path):
- stem, ext = os.path.splitext(out_name)
- out_path = os.path.join(output_dir, f"{stem}_{counter}{ext}")
- counter += 1
- new_doc.save(out_path)
- new_doc.close()
- self.log(f"✔ 第 {page_num} 页 → {os.path.basename(out_path)}")
- doc.close()
- template_doc.close()
- self.log(f"\n🎉 全部完成!共导出 {len(odd_indices)} 个 PDF")
- except Exception as e:
- self.log(f"\n❌ 发生错误:{str(e)}")
- self.start_btn.config(state=tk.NORMAL)
- if __name__ == "__main__":
- root = tk.Tk()
- app = PDFMergeApp(root)
- root.mainloop()
|