SHEIN.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  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")
  28. self.offset_y_var = tk.StringVar(value="10")
  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. ttk.Entry(param_frame, textvariable=self.scale_var, width=10).grid(row=0, column=1, sticky=tk.W, padx=4)
  54. ttk.Label(param_frame, text="例:100 = 宽度填满模板").grid(row=0, column=2, sticky=tk.W, padx=4)
  55. ttk.Label(param_frame, text="X 偏移(点):").grid(row=1, column=0, sticky=tk.W, padx=4, pady=6)
  56. ttk.Entry(param_frame, textvariable=self.offset_x_var, width=10).grid(row=1, column=1, sticky=tk.W, padx=4, pady=6)
  57. ttk.Label(param_frame, text="左上角水平偏移,0 表示贴左边").grid(row=1, column=2, sticky=tk.W, padx=4, pady=6)
  58. ttk.Label(param_frame, text="Y 偏移(点):").grid(row=2, column=0, sticky=tk.W, padx=4)
  59. ttk.Entry(param_frame, textvariable=self.offset_y_var, width=10).grid(row=2, column=1, sticky=tk.W, padx=4)
  60. ttk.Label(param_frame, text="左上角垂直偏移,0 表示贴顶部").grid(row=2, column=2, sticky=tk.W, padx=4)
  61. # 开始按钮
  62. btn_frame = ttk.Frame(main_frame)
  63. btn_frame.grid(row=4, column=0, columnspan=3, pady=10)
  64. self.start_btn = ttk.Button(btn_frame, text="▶ 开始处理", command=self.start_process)
  65. self.start_btn.pack()
  66. # 日志区域
  67. ttk.Label(main_frame, text="处理日志:").grid(row=5, column=0, sticky=tk.W, pady=(8, 0))
  68. self.log_text = scrolledtext.ScrolledText(
  69. main_frame, height=18, wrap=tk.WORD, state=tk.NORMAL, font=("Consolas", 10)
  70. )
  71. self.log_text.grid(row=6, column=0, columnspan=3, sticky=(tk.W, tk.E, tk.N, tk.S), pady=4)
  72. main_frame.rowconfigure(6, weight=1)
  73. # 参数变更自动保存
  74. for var in (self.scale_var, self.offset_x_var, self.offset_y_var):
  75. var.trace_add("write", lambda *args: self.save_config())
  76. # ---------- 路径选择 ----------
  77. def select_input(self):
  78. file_path = filedialog.askopenfilename(filetypes=[("PDF 文件", "*.pdf")])
  79. if file_path:
  80. self.input_var.set(file_path)
  81. self.save_config()
  82. def select_template(self):
  83. file_path = filedialog.askopenfilename(filetypes=[("PDF 文件", "*.pdf")])
  84. if file_path:
  85. self.template_var.set(file_path)
  86. self.save_config()
  87. def select_output(self):
  88. folder = filedialog.askdirectory()
  89. if folder:
  90. self.output_var.set(folder)
  91. self.save_config()
  92. # ---------- 配置持久化 ----------
  93. def save_config(self):
  94. config = {
  95. "input_file": self.input_var.get(),
  96. "template": self.template_var.get(),
  97. "output_dir": self.output_var.get(),
  98. "scale_percent": self.scale_var.get(),
  99. "offset_x": self.offset_x_var.get(),
  100. "offset_y": self.offset_y_var.get(),
  101. }
  102. try:
  103. with open(CONFIG_FILE, "w", encoding="utf-8") as f:
  104. json.dump(config, f, ensure_ascii=False, indent=2)
  105. except Exception:
  106. pass
  107. def load_config(self):
  108. if not os.path.exists(CONFIG_FILE):
  109. return
  110. try:
  111. with open(CONFIG_FILE, "r", encoding="utf-8") as f:
  112. config = json.load(f)
  113. self.input_var.set(config.get("input_file", ""))
  114. self.template_var.set(config.get("template", ""))
  115. self.output_var.set(config.get("output_dir", ""))
  116. self.scale_var.set(config.get("scale_percent", "100"))
  117. self.offset_x_var.set(config.get("offset_x", "0"))
  118. self.offset_y_var.set(config.get("offset_y", "0"))
  119. except Exception:
  120. pass
  121. # ---------- 日志 ----------
  122. def log(self, msg):
  123. self.log_text.insert(tk.END, msg + "\n")
  124. self.log_text.see(tk.END)
  125. self.root.update_idletasks()
  126. # ---------- 文字提取 ----------
  127. def extract_text(self, page):
  128. text = page.get_text().strip()
  129. if len(text) >= 2:
  130. return text
  131. try:
  132. import pytesseract
  133. pix = page.get_pixmap(dpi=300)
  134. mode = "RGBA" if pix.alpha else "RGB"
  135. img = Image.frombytes(mode, [pix.width, pix.height], pix.samples)
  136. if mode == "RGBA":
  137. img = img.convert("RGB")
  138. text = pytesseract.image_to_string(img, lang="chi_sim+eng").strip()
  139. return text
  140. except Exception:
  141. return ""
  142. def get_filename_from_text(self, text):
  143. """
  144. 取第一段 + 第四段文字作为文件名
  145. 如果第四段不存在,只用第一段
  146. 如果第一段也不存在,返回空字符串
  147. """
  148. if not text:
  149. return ""
  150. lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
  151. if not lines:
  152. return ""
  153. first = lines[0]
  154. fourth = lines[4] if len(lines) >= 5 else ""
  155. fourth = fourth.replace("¨", "0")
  156. fourth = fourth.replace("©", "1")
  157. fourth = fourth.replace("ª", "2")
  158. fourth = fourth.replace("«", "3")
  159. fourth = fourth.replace("°2", "4")
  160. fourth = fourth.replace("°2", "5")
  161. fourth = fourth.replace("®", "6")
  162. fourth = fourth.replace("°2", "7")
  163. fourth = fourth.replace("°", "8")
  164. fourth = fourth.replace("°2", "9")
  165. name = f"{first}_{fourth}" if fourth else first
  166. return self.sanitize_filename(name)
  167. def sanitize_filename(self, text):
  168. if not text:
  169. return ""
  170. name = text[:80] # 限制长度
  171. name = re.sub(r'[\\/:*?"<>|\s]+', "_", name)
  172. return name.strip("._")
  173. # ---------- 核心处理 ----------
  174. def start_process(self):
  175. t = threading.Thread(target=self.process, daemon=True)
  176. t.start()
  177. def process(self):
  178. self.start_btn.config(state=tk.DISABLED)
  179. self.log_text.delete(1.0, tk.END)
  180. input_file = self.input_var.get()
  181. template_file = self.template_var.get()
  182. output_dir = self.output_var.get()
  183. # 解析参数
  184. try:
  185. scale_percent = float(self.scale_var.get().strip() or "100")
  186. offset_x = float(self.offset_x_var.get().strip() or "0")
  187. offset_y = float(self.offset_y_var.get().strip() or "0")
  188. except ValueError:
  189. self.log("❌ 错误:缩放比例、X 偏移、Y 偏移必须是有效数字")
  190. self.start_btn.config(state=tk.NORMAL)
  191. return
  192. if not all([input_file, template_file, output_dir]):
  193. self.log("❌ 错误:请完整填写输入文件、模板文件和输出文件夹")
  194. self.start_btn.config(state=tk.NORMAL)
  195. return
  196. if not os.path.isfile(input_file):
  197. self.log(f"❌ 错误:输入文件不存在:{input_file}")
  198. self.start_btn.config(state=tk.NORMAL)
  199. return
  200. if not os.path.isfile(template_file):
  201. self.log(f"❌ 错误:模板文件不存在:{template_file}")
  202. self.start_btn.config(state=tk.NORMAL)
  203. return
  204. if not os.path.exists(output_dir):
  205. os.makedirs(output_dir, exist_ok=True)
  206. self.log(f"📄 输入文件:{os.path.basename(input_file)}")
  207. self.log(f"⚙ 参数:缩放 {scale_percent}% 宽,偏移 ({offset_x}, {offset_y})\n")
  208. try:
  209. template_doc = fitz.open(template_file)
  210. if len(template_doc) == 0:
  211. self.log("❌ 错误:模板文件为空")
  212. self.start_btn.config(state=tk.NORMAL)
  213. return
  214. template_page = template_doc[0]
  215. template_rect = template_page.rect
  216. template_w = template_rect.width
  217. template_h = template_rect.height
  218. doc = fitz.open(input_file)
  219. odd_indices = list(range(0, len(doc), 2))
  220. if not odd_indices:
  221. self.log("⚠ 该文件没有奇数页,已跳过")
  222. doc.close()
  223. template_doc.close()
  224. self.start_btn.config(state=tk.NORMAL)
  225. return
  226. self.log(f"📑 共 {len(doc)} 页,提取奇数页 {len(odd_indices)} 页\n")
  227. for idx in odd_indices:
  228. page = doc[idx]
  229. page_num = idx + 1
  230. # 从该页提取文字作为文件名
  231. page_text = self.extract_text(page)
  232. safe_name = self.get_filename_from_text(page_text)
  233. if not safe_name:
  234. base = os.path.splitext(os.path.basename(input_file))[0]
  235. safe_name = f"{base}_page{page_num}"
  236. # 创建单页新 PDF
  237. new_doc = fitz.open()
  238. new_page = new_doc.new_page(width=template_w, height=template_h)
  239. # 1) 模板背景
  240. new_page.show_pdf_page(template_rect, template_doc, 0, overlay=False)
  241. # 2) 计算输入页目标矩形
  242. input_rect = page.rect
  243. target_w = template_w * (scale_percent / 100.0)
  244. scale = target_w / input_rect.width
  245. target_h = input_rect.height * scale
  246. target_rect = fitz.Rect(offset_x, offset_y, offset_x + target_w, offset_y + target_h)
  247. # 3) 叠加输入页(前景)
  248. new_page.show_pdf_page(target_rect, doc, idx, overlay=True)
  249. # 保存(自动处理重名)
  250. out_name = f"{safe_name}.pdf"
  251. out_path = os.path.join(output_dir, out_name)
  252. counter = 1
  253. while os.path.exists(out_path):
  254. stem, ext = os.path.splitext(out_name)
  255. out_path = os.path.join(output_dir, f"{stem}_{counter}{ext}")
  256. counter += 1
  257. new_doc.save(out_path)
  258. new_doc.close()
  259. self.log(f"✔ 第 {page_num} 页 → {os.path.basename(out_path)}")
  260. doc.close()
  261. template_doc.close()
  262. self.log(f"\n🎉 全部完成!共导出 {len(odd_indices)} 个 PDF")
  263. except Exception as e:
  264. self.log(f"\n❌ 发生错误:{str(e)}")
  265. self.start_btn.config(state=tk.NORMAL)
  266. if __name__ == "__main__":
  267. root = tk.Tk()
  268. app = PDFMergeApp(root)
  269. root.mainloop()