#!/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="110") 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=[ ("图片文件", "*.png *.jpg *.jpeg *.bmp *.gif *.tiff *.webp"), ("PNG 图片", "*.png"), ("JPEG 图片", "*.jpg *.jpeg"), ("所有文件", "*.*") ] ) 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"🖼 模板图片:{os.path.basename(template_file)}") self.log(f"⚙ 参数:缩放 {scale_percent}% 宽,偏移 ({offset_x}, {offset_y})\n") try: # 获取模板图片尺寸 with Image.open(template_file) as template_img: template_w, template_h = template_img.size doc = fitz.open(input_file) total_pages = len(doc) if total_pages == 0: self.log("⚠ 该文件没有页面,已跳过") doc.close() self.start_btn.config(state=tk.NORMAL) return self.log(f"📑 共 {total_pages} 页,全部处理\n") for idx in range(total_pages): 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() page_rect = page.rect new_page = new_doc.new_page(width=page_rect.width, height=page_rect.height) # 1) 输入页作为背景 new_page.show_pdf_page(page_rect, doc, idx, overlay=False) # 2) 计算模板目标矩形(缩放模板) target_w = page_rect.width * (scale_percent / 100.0) scale = target_w / template_w target_h = template_h * scale target_rect = fitz.Rect(offset_x, offset_y, offset_x + target_w, offset_y + target_h) # 3) 叠加模板图片(前景) new_page.insert_image(target_rect, filename=template_file) # 保存(自动处理重名) 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() self.log(f"\n🎉 全部完成!共导出 {total_pages} 个 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()