速卖通.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. PDF 条码合成工具(单文件输入,每页独立输出)
  5. 模板为图片,嵌入到输入文件每一页的指定位置
  6. """
  7. import tkinter as tk
  8. from tkinter import ttk, filedialog, scrolledtext
  9. import os
  10. import json
  11. import re
  12. import threading
  13. import fitz # PyMuPDF
  14. from PIL import Image
  15. CONFIG_FILE = "pdf_merge_config.json"
  16. class PDFMergeApp:
  17. def __init__(self, root):
  18. self.root = root
  19. self.root.title("PDF 条码合成工具")
  20. self.root.geometry("780x600")
  21. self.root.minsize(700, 500)
  22. # 路径变量
  23. self.input_var = tk.StringVar()
  24. self.template_var = tk.StringVar()
  25. self.output_var = tk.StringVar()
  26. # 缩放与偏移变量
  27. self.scale_var = tk.StringVar(value="100")
  28. self.offset_x_var = tk.StringVar(value="0")
  29. self.offset_y_var = tk.StringVar(value="110")
  30. self.build_ui()
  31. self.load_config()
  32. def build_ui(self):
  33. main_frame = ttk.Frame(self.root, padding="12")
  34. main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
  35. self.root.columnconfigure(0, weight=1)
  36. self.root.rowconfigure(0, weight=1)
  37. main_frame.columnconfigure(1, weight=1)
  38. # 输入文件
  39. ttk.Label(main_frame, text="输入文件:").grid(row=0, column=0, sticky=tk.W, pady=6)
  40. ttk.Entry(main_frame, textvariable=self.input_var).grid(row=0, column=1, sticky=(tk.W, tk.E), padx=6)
  41. ttk.Button(main_frame, text="浏览…", width=10, command=self.select_input).grid(row=0, column=2, padx=4)
  42. # 模板文件(图片)
  43. ttk.Label(main_frame, text="模板图片:").grid(row=1, column=0, sticky=tk.W, pady=6)
  44. ttk.Entry(main_frame, textvariable=self.template_var).grid(row=1, column=1, sticky=(tk.W, tk.E), padx=6)
  45. ttk.Button(main_frame, text="浏览…", width=10, command=self.select_template).grid(row=1, column=2, padx=4)
  46. # 输出文件夹
  47. ttk.Label(main_frame, text="输出文件夹:").grid(row=2, column=0, sticky=tk.W, pady=6)
  48. ttk.Entry(main_frame, textvariable=self.output_var).grid(row=2, column=1, sticky=(tk.W, tk.E), padx=6)
  49. ttk.Button(main_frame, text="浏览…", width=10, command=self.select_output).grid(row=2, column=2, padx=4)
  50. # 参数配置区域
  51. param_frame = ttk.LabelFrame(main_frame, text="合成参数(自动保存)", padding="10")
  52. param_frame.grid(row=3, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=10)
  53. ttk.Label(param_frame, text="缩放比例(% 页面宽):").grid(row=0, column=0, sticky=tk.W, padx=4)
  54. ttk.Entry(param_frame, textvariable=self.scale_var, width=10).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. file_path = filedialog.askopenfilename(filetypes=[("PDF 文件", "*.pdf")])
  80. if file_path:
  81. self.input_var.set(file_path)
  82. self.save_config()
  83. def select_template(self):
  84. file_path = filedialog.askopenfilename(
  85. filetypes=[
  86. ("图片文件", "*.png *.jpg *.jpeg *.bmp *.gif *.tiff *.webp"),
  87. ("PNG 图片", "*.png"),
  88. ("JPEG 图片", "*.jpg *.jpeg"),
  89. ("所有文件", "*.*")
  90. ]
  91. )
  92. if file_path:
  93. self.template_var.set(file_path)
  94. self.save_config()
  95. def select_output(self):
  96. folder = filedialog.askdirectory()
  97. if folder:
  98. self.output_var.set(folder)
  99. self.save_config()
  100. # ---------- 配置持久化 ----------
  101. def save_config(self):
  102. config = {
  103. "input_file": self.input_var.get(),
  104. "template": self.template_var.get(),
  105. "output_dir": self.output_var.get(),
  106. "scale_percent": self.scale_var.get(),
  107. "offset_x": self.offset_x_var.get(),
  108. "offset_y": self.offset_y_var.get(),
  109. }
  110. try:
  111. with open(CONFIG_FILE, "w", encoding="utf-8") as f:
  112. json.dump(config, f, ensure_ascii=False, indent=2)
  113. except Exception:
  114. pass
  115. def load_config(self):
  116. if not os.path.exists(CONFIG_FILE):
  117. return
  118. try:
  119. with open(CONFIG_FILE, "r", encoding="utf-8") as f:
  120. config = json.load(f)
  121. self.input_var.set(config.get("input_file", ""))
  122. self.template_var.set(config.get("template", ""))
  123. self.output_var.set(config.get("output_dir", ""))
  124. self.scale_var.set(config.get("scale_percent", "100"))
  125. self.offset_x_var.set(config.get("offset_x", "0"))
  126. self.offset_y_var.set(config.get("offset_y", "0"))
  127. except Exception:
  128. pass
  129. # ---------- 日志 ----------
  130. def log(self, msg):
  131. self.log_text.insert(tk.END, msg + "\n")
  132. self.log_text.see(tk.END)
  133. self.root.update_idletasks()
  134. # ---------- 文字提取 ----------
  135. def extract_text(self, page):
  136. text = page.get_text().strip()
  137. if len(text) >= 2:
  138. return text
  139. try:
  140. import pytesseract
  141. pix = page.get_pixmap(dpi=300)
  142. mode = "RGBA" if pix.alpha else "RGB"
  143. img = Image.frombytes(mode, [pix.width, pix.height], pix.samples)
  144. if mode == "RGBA":
  145. img = img.convert("RGB")
  146. text = pytesseract.image_to_string(img, lang="chi_sim+eng").strip()
  147. return text
  148. except Exception:
  149. return ""
  150. def get_filename_from_text(self, text):
  151. """
  152. 取第一段 + 第四段文字作为文件名
  153. 如果第四段不存在,只用第一段
  154. 如果第一段也不存在,返回空字符串
  155. """
  156. if not text:
  157. return ""
  158. lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
  159. if not lines:
  160. return ""
  161. first = lines[0]
  162. fourth = lines[4] if len(lines) >= 5 else ""
  163. fourth = fourth.replace("¨", "0")
  164. fourth = fourth.replace("©", "1")
  165. fourth = fourth.replace("ª", "2")
  166. fourth = fourth.replace("«", "3")
  167. fourth = fourth.replace("°2", "4")
  168. fourth = fourth.replace("°2", "5")
  169. fourth = fourth.replace("®", "6")
  170. fourth = fourth.replace("°2", "7")
  171. fourth = fourth.replace("°", "8")
  172. fourth = fourth.replace("°2", "9")
  173. name = f"{first}_{fourth}" if fourth else first
  174. return self.sanitize_filename(name)
  175. def sanitize_filename(self, text):
  176. if not text:
  177. return ""
  178. name = text[:80] # 限制长度
  179. name = re.sub(r'[\\/:*?"<>|\s]+', "_", name)
  180. return name.strip("._")
  181. # ---------- 核心处理 ----------
  182. def start_process(self):
  183. t = threading.Thread(target=self.process, daemon=True)
  184. t.start()
  185. def process(self):
  186. self.start_btn.config(state=tk.DISABLED)
  187. self.log_text.delete(1.0, tk.END)
  188. input_file = self.input_var.get()
  189. template_file = self.template_var.get()
  190. output_dir = self.output_var.get()
  191. # 解析参数
  192. try:
  193. scale_percent = float(self.scale_var.get().strip() or "100")
  194. offset_x = float(self.offset_x_var.get().strip() or "0")
  195. offset_y = float(self.offset_y_var.get().strip() or "0")
  196. except ValueError:
  197. self.log("❌ 错误:缩放比例、X 偏移、Y 偏移必须是有效数字")
  198. self.start_btn.config(state=tk.NORMAL)
  199. return
  200. if not all([input_file, template_file, output_dir]):
  201. self.log("❌ 错误:请完整填写输入文件、模板图片和输出文件夹")
  202. self.start_btn.config(state=tk.NORMAL)
  203. return
  204. if not os.path.isfile(input_file):
  205. self.log(f"❌ 错误:输入文件不存在:{input_file}")
  206. self.start_btn.config(state=tk.NORMAL)
  207. return
  208. if not os.path.isfile(template_file):
  209. self.log(f"❌ 错误:模板图片不存在:{template_file}")
  210. self.start_btn.config(state=tk.NORMAL)
  211. return
  212. if not os.path.exists(output_dir):
  213. os.makedirs(output_dir, exist_ok=True)
  214. self.log(f"📄 输入文件:{os.path.basename(input_file)}")
  215. self.log(f"🖼 模板图片:{os.path.basename(template_file)}")
  216. self.log(f"⚙ 参数:缩放 {scale_percent}% 宽,偏移 ({offset_x}, {offset_y})\n")
  217. try:
  218. # 获取模板图片尺寸
  219. with Image.open(template_file) as template_img:
  220. template_w, template_h = template_img.size
  221. doc = fitz.open(input_file)
  222. total_pages = len(doc)
  223. if total_pages == 0:
  224. self.log("⚠ 该文件没有页面,已跳过")
  225. doc.close()
  226. self.start_btn.config(state=tk.NORMAL)
  227. return
  228. self.log(f"📑 共 {total_pages} 页,全部处理\n")
  229. for idx in range(total_pages):
  230. page = doc[idx]
  231. page_num = idx + 1
  232. # 从该页提取文字作为文件名
  233. page_text = self.extract_text(page)
  234. safe_name = self.get_filename_from_text(page_text)
  235. if not safe_name:
  236. base = os.path.splitext(os.path.basename(input_file))[0]
  237. safe_name = f"{base}_page{page_num}"
  238. # 创建单页新 PDF
  239. new_doc = fitz.open()
  240. page_rect = page.rect
  241. new_page = new_doc.new_page(width=page_rect.width, height=page_rect.height)
  242. # 1) 输入页作为背景
  243. new_page.show_pdf_page(page_rect, doc, idx, overlay=False)
  244. # 2) 计算模板目标矩形(缩放模板)
  245. target_w = page_rect.width * (scale_percent / 100.0)
  246. scale = target_w / template_w
  247. target_h = template_h * scale
  248. target_rect = fitz.Rect(offset_x, offset_y, offset_x + target_w, offset_y + target_h)
  249. # 3) 叠加模板图片(前景)
  250. new_page.insert_image(target_rect, filename=template_file)
  251. # 保存(自动处理重名)
  252. out_name = f"{safe_name}.pdf"
  253. out_path = os.path.join(output_dir, out_name)
  254. counter = 1
  255. while os.path.exists(out_path):
  256. stem, ext = os.path.splitext(out_name)
  257. out_path = os.path.join(output_dir, f"{stem}_{counter}{ext}")
  258. counter += 1
  259. new_doc.save(out_path)
  260. new_doc.close()
  261. self.log(f"✔ 第 {page_num} 页 → {os.path.basename(out_path)}")
  262. doc.close()
  263. self.log(f"\n🎉 全部完成!共导出 {total_pages} 个 PDF")
  264. except Exception as e:
  265. self.log(f"\n❌ 发生错误:{str(e)}")
  266. self.start_btn.config(state=tk.NORMAL)
  267. if __name__ == "__main__":
  268. root = tk.Tk()
  269. app = PDFMergeApp(root)
  270. root.mainloop()