app.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. PDF 条码合成工具(带缩放与偏移配置)
  5. """
  6. import tkinter as tk
  7. from tkinter import ttk, filedialog, scrolledtext
  8. import os
  9. import json
  10. import re
  11. import threading
  12. import fitz # PyMuPDF
  13. from PIL import Image
  14. CONFIG_FILE = "pdf_merge_config.json"
  15. class PDFMergeApp:
  16. def __init__(self, root):
  17. self.root = root
  18. self.root.title("PDF 条码合成工具")
  19. self.root.geometry("780x600")
  20. self.root.minsize(700, 500)
  21. # 路径变量
  22. self.input_var = tk.StringVar()
  23. self.template_var = tk.StringVar()
  24. self.output_var = tk.StringVar()
  25. # 缩放与偏移变量
  26. self.scale_var = tk.StringVar(value="100") # 相对于模板宽度的百分比
  27. self.offset_x_var = tk.StringVar(value="0") # X 偏移(点)
  28. self.offset_y_var = tk.StringVar(value="10") # Y 偏移(点)
  29. self.build_ui()
  30. self.load_config()
  31. def build_ui(self):
  32. main_frame = ttk.Frame(self.root, padding="12")
  33. main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
  34. self.root.columnconfigure(0, weight=1)
  35. self.root.rowconfigure(0, weight=1)
  36. main_frame.columnconfigure(1, weight=1)
  37. # 输入文件夹
  38. ttk.Label(main_frame, text="输入文件夹:").grid(row=0, column=0, sticky=tk.W, pady=6)
  39. ttk.Entry(main_frame, textvariable=self.input_var).grid(row=0, column=1, sticky=(tk.W, tk.E), padx=6)
  40. ttk.Button(main_frame, text="浏览…", width=10, command=self.select_input).grid(row=0, column=2, padx=4)
  41. # 模板文件
  42. ttk.Label(main_frame, text="模板文件:").grid(row=1, column=0, sticky=tk.W, pady=6)
  43. ttk.Entry(main_frame, textvariable=self.template_var).grid(row=1, column=1, sticky=(tk.W, tk.E), padx=6)
  44. ttk.Button(main_frame, text="浏览…", width=10, command=self.select_template).grid(row=1, column=2, padx=4)
  45. # 输出文件夹
  46. ttk.Label(main_frame, text="输出文件夹:").grid(row=2, column=0, sticky=tk.W, pady=6)
  47. ttk.Entry(main_frame, textvariable=self.output_var).grid(row=2, column=1, sticky=(tk.W, tk.E), padx=6)
  48. ttk.Button(main_frame, text="浏览…", width=10, command=self.select_output).grid(row=2, column=2, padx=4)
  49. # 参数配置区域
  50. param_frame = ttk.LabelFrame(main_frame, text="合成参数(自动保存)", padding="10")
  51. param_frame.grid(row=3, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=10)
  52. ttk.Label(param_frame, text="缩放比例(% 模板宽):").grid(row=0, column=0, sticky=tk.W, padx=4)
  53. scale_entry = ttk.Entry(param_frame, textvariable=self.scale_var, width=10)
  54. scale_entry.grid(row=0, column=1, sticky=tk.W, padx=4)
  55. ttk.Label(param_frame, text="例:100 = 宽度填满模板").grid(row=0, column=2, sticky=tk.W, padx=4)
  56. ttk.Label(param_frame, text="X 偏移(点):").grid(row=1, column=0, sticky=tk.W, padx=4, pady=6)
  57. ttk.Entry(param_frame, textvariable=self.offset_x_var, width=10).grid(row=1, column=1, sticky=tk.W, padx=4, pady=6)
  58. ttk.Label(param_frame, text="左上角水平偏移,0 表示贴左边").grid(row=1, column=2, sticky=tk.W, padx=4, pady=6)
  59. ttk.Label(param_frame, text="Y 偏移(点):").grid(row=2, column=0, sticky=tk.W, padx=4)
  60. ttk.Entry(param_frame, textvariable=self.offset_y_var, width=10).grid(row=2, column=1, sticky=tk.W, padx=4)
  61. ttk.Label(param_frame, text="左上角垂直偏移,0 表示贴顶部").grid(row=2, column=2, sticky=tk.W, padx=4)
  62. # 开始按钮
  63. btn_frame = ttk.Frame(main_frame)
  64. btn_frame.grid(row=4, column=0, columnspan=3, pady=10)
  65. self.start_btn = ttk.Button(btn_frame, text="▶ 开始处理", command=self.start_process)
  66. self.start_btn.pack()
  67. # 日志区域
  68. ttk.Label(main_frame, text="处理日志:").grid(row=5, column=0, sticky=tk.W, pady=(8, 0))
  69. self.log_text = scrolledtext.ScrolledText(
  70. main_frame, height=18, wrap=tk.WORD, state=tk.NORMAL, font=("Consolas", 10)
  71. )
  72. self.log_text.grid(row=6, column=0, columnspan=3, sticky=(tk.W, tk.E, tk.N, tk.S), pady=4)
  73. main_frame.rowconfigure(6, weight=1)
  74. # 绑定参数变更自动保存
  75. for var in (self.scale_var, self.offset_x_var, self.offset_y_var):
  76. var.trace_add("write", lambda *args: self.save_config())
  77. # ---------- 路径选择 ----------
  78. def select_input(self):
  79. folder = filedialog.askdirectory()
  80. if folder:
  81. self.input_var.set(folder)
  82. self.save_config()
  83. def select_template(self):
  84. file_path = filedialog.askopenfilename(filetypes=[("PDF 文件", "*.pdf")])
  85. if file_path:
  86. self.template_var.set(file_path)
  87. self.save_config()
  88. def select_output(self):
  89. folder = filedialog.askdirectory()
  90. if folder:
  91. self.output_var.set(folder)
  92. self.save_config()
  93. # ---------- 配置持久化 ----------
  94. def save_config(self):
  95. config = {
  96. "input_dir": self.input_var.get(),
  97. "template": self.template_var.get(),
  98. "output_dir": self.output_var.get(),
  99. "scale_percent": self.scale_var.get(),
  100. "offset_x": self.offset_x_var.get(),
  101. "offset_y": self.offset_y_var.get(),
  102. }
  103. try:
  104. with open(CONFIG_FILE, "w", encoding="utf-8") as f:
  105. json.dump(config, f, ensure_ascii=False, indent=2)
  106. except Exception:
  107. pass
  108. def load_config(self):
  109. if not os.path.exists(CONFIG_FILE):
  110. return
  111. try:
  112. with open(CONFIG_FILE, "r", encoding="utf-8") as f:
  113. config = json.load(f)
  114. self.input_var.set(config.get("input_dir", ""))
  115. self.template_var.set(config.get("template", ""))
  116. self.output_var.set(config.get("output_dir", ""))
  117. self.scale_var.set(config.get("scale_percent", "100"))
  118. self.offset_x_var.set(config.get("offset_x", "0"))
  119. self.offset_y_var.set(config.get("offset_y", "0"))
  120. except Exception:
  121. pass
  122. # ---------- 日志 ----------
  123. def log(self, msg):
  124. self.log_text.insert(tk.END, msg + "\n")
  125. self.log_text.see(tk.END)
  126. self.root.update_idletasks()
  127. # ---------- 文字提取 ----------
  128. def extract_text(self, page):
  129. text = page.get_text().strip()
  130. print(f"Extracted text: {text}") # Debug print
  131. if len(text) >= 2:
  132. return text
  133. try:
  134. import pytesseract
  135. pix = page.get_pixmap(dpi=300)
  136. mode = "RGBA" if pix.alpha else "RGB"
  137. img = Image.frombytes(mode, [pix.width, pix.height], pix.samples)
  138. if mode == "RGBA":
  139. img = img.convert("RGB")
  140. text = pytesseract.image_to_string(img, lang="chi_sim+eng").strip()
  141. return text
  142. except Exception:
  143. return ""
  144. def sanitize_filename(self, text):
  145. if not text:
  146. return ""
  147. lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
  148. name = lines[0] if lines else text
  149. name = name[:60]
  150. name = re.sub(r'[\\/:*?"<>|\s]+', "_", name)
  151. return name.strip("._")
  152. # ---------- 核心处理 ----------
  153. def start_process(self):
  154. t = threading.Thread(target=self.process, daemon=True)
  155. t.start()
  156. def process(self):
  157. self.start_btn.config(state=tk.DISABLED)
  158. self.log_text.delete(1.0, tk.END)
  159. input_dir = self.input_var.get()
  160. template_file = self.template_var.get()
  161. output_dir = self.output_var.get()
  162. # 解析参数
  163. try:
  164. scale_percent = float(self.scale_var.get().strip() or "100")
  165. offset_x = float(self.offset_x_var.get().strip() or "0")
  166. offset_y = float(self.offset_y_var.get().strip() or "0")
  167. except ValueError:
  168. self.log("❌ 错误:缩放比例、X 偏移、Y 偏移必须是有效数字")
  169. self.start_btn.config(state=tk.NORMAL)
  170. return
  171. if not all([input_dir, template_file, output_dir]):
  172. self.log("❌ 错误:请完整填写输入文件夹、模板文件和输出文件夹")
  173. self.start_btn.config(state=tk.NORMAL)
  174. return
  175. if not os.path.isdir(input_dir):
  176. self.log(f"❌ 错误:输入文件夹不存在:{input_dir}")
  177. self.start_btn.config(state=tk.NORMAL)
  178. return
  179. if not os.path.isfile(template_file):
  180. self.log(f"❌ 错误:模板文件不存在:{template_file}")
  181. self.start_btn.config(state=tk.NORMAL)
  182. return
  183. if not os.path.exists(output_dir):
  184. os.makedirs(output_dir, exist_ok=True)
  185. pdf_files = [f for f in os.listdir(input_dir) if f.lower().endswith(".pdf")]
  186. if not pdf_files:
  187. self.log("⚠ 输入文件夹中没有找到 PDF 文件")
  188. self.start_btn.config(state=tk.NORMAL)
  189. return
  190. self.log(f"📁 共找到 {len(pdf_files)} 个 PDF 文件")
  191. self.log(f"⚙ 参数:缩放 {scale_percent}% 宽,偏移 ({offset_x}, {offset_y})\n")
  192. try:
  193. template_doc = fitz.open(template_file)
  194. if len(template_doc) == 0:
  195. self.log("❌ 错误:模板文件为空")
  196. self.start_btn.config(state=tk.NORMAL)
  197. return
  198. template_page = template_doc[0]
  199. template_rect = template_page.rect
  200. template_w = template_rect.width
  201. template_h = template_rect.height
  202. for pdf_name in pdf_files:
  203. pdf_path = os.path.join(input_dir, pdf_name)
  204. self.log(f"📄 正在处理:{pdf_name}")
  205. try:
  206. doc = fitz.open(pdf_path)
  207. odd_indices = list(range(0, len(doc), 2))
  208. if not odd_indices:
  209. self.log(" ⚠ 该文件没有奇数页,已跳过")
  210. doc.close()
  211. continue
  212. safe_name = self.sanitize_filename(self.extract_text(doc[odd_indices[0]]))
  213. if not safe_name:
  214. safe_name = os.path.splitext(pdf_name)[0]
  215. new_doc = fitz.open()
  216. for idx in odd_indices:
  217. page = doc[idx]
  218. page_num = idx + 1
  219. new_page = new_doc.new_page(width=template_w, height=template_h)
  220. # 1) 模板背景
  221. new_page.show_pdf_page(template_rect, template_doc, 0, overlay=False)
  222. # 2) 计算输入页目标矩形
  223. input_rect = page.rect
  224. # 按模板宽度的百分比计算目标宽度
  225. target_w = template_w * (scale_percent / 100.0)
  226. # 等比例缩放高度
  227. scale = target_w / input_rect.width
  228. target_h = input_rect.height * scale
  229. target_rect = fitz.Rect(offset_x, offset_y, offset_x + target_w, offset_y + target_h)
  230. # 3) 叠加输入页(前景)
  231. new_page.show_pdf_page(target_rect, doc, idx, overlay=True)
  232. self.log(f" ✔ 已合成第 {page_num} 页 → 尺寸 {target_w:.1f}×{target_h:.1f} 位置 ({offset_x}, {offset_y})")
  233. # 保存
  234. out_name = f"{safe_name}.pdf"
  235. out_path = os.path.join(output_dir, out_name)
  236. counter = 1
  237. while os.path.exists(out_path):
  238. stem, ext = os.path.splitext(out_name)
  239. out_path = os.path.join(output_dir, f"{stem}_{counter}{ext}")
  240. counter += 1
  241. new_doc.save(out_path)
  242. new_doc.close()
  243. doc.close()
  244. self.log(f" 💾 已保存:{os.path.basename(out_path)}\n")
  245. except Exception as e:
  246. self.log(f" ❌ 处理失败:{str(e)}\n")
  247. template_doc.close()
  248. self.log("🎉 全部处理完成!")
  249. except Exception as e:
  250. self.log(f"❌ 发生错误:{str(e)}")
  251. self.start_btn.config(state=tk.NORMAL)
  252. if __name__ == "__main__":
  253. root = tk.Tk()
  254. app = PDFMergeApp(root)
  255. root.mainloop()