速卖通.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. PDF 条码合成工具(单文件输入,每页独立输出)
  5. 模板为图片,嵌入到输入文件每一页的指定位置
  6. 输出页面固定裁剪为 60mm × 50mm
  7. 文件名取“第几段”可配置(从 1 开始计数)
  8. """
  9. import tkinter as tk
  10. from tkinter import ttk, filedialog, scrolledtext
  11. import os
  12. import json
  13. import re
  14. import threading
  15. import fitz # PyMuPDF
  16. from PIL import Image
  17. CONFIG_FILE = "pdf_merge_config.json"
  18. # 输出页面固定尺寸:60mm × 50mm → 点(1pt = 25.4mm / 72)
  19. MM_TO_PT = 72.0 / 25.4
  20. TARGET_W = 60 * MM_TO_PT # ≈ 170.08 pt
  21. TARGET_H = 50 * MM_TO_PT # ≈ 141.73 pt
  22. class PDFMergeApp:
  23. def __init__(self, root):
  24. self.root = root
  25. self.root.title("PDF 条码合成工具")
  26. self.root.geometry("820x680")
  27. self.root.minsize(720, 560)
  28. # 路径变量
  29. self.input_var = tk.StringVar()
  30. self.template_var = tk.StringVar()
  31. self.output_var = tk.StringVar()
  32. # 缩放与偏移变量
  33. self.scale_var = tk.StringVar(value="100")
  34. self.offset_x_var = tk.StringVar(value="0")
  35. self.offset_y_var = tk.StringVar(value="120")
  36. # 文件名取段配置(段号从 1 开始)
  37. self.seg1_var = tk.StringVar(value="1") # 主段
  38. self.seg2_var = tk.StringVar(value="4") # 拼接段
  39. self.seg2_enable_var = tk.BooleanVar(value=True) # 是否启用拼接段
  40. self.build_ui()
  41. self.load_config()
  42. def build_ui(self):
  43. main_frame = ttk.Frame(self.root, padding="12")
  44. main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
  45. self.root.columnconfigure(0, weight=1)
  46. self.root.rowconfigure(0, weight=1)
  47. main_frame.columnconfigure(1, weight=1)
  48. # 输入文件
  49. ttk.Label(main_frame, text="输入文件:").grid(row=0, column=0, sticky=tk.W, pady=6)
  50. ttk.Entry(main_frame, textvariable=self.input_var).grid(row=0, column=1, sticky=(tk.W, tk.E), padx=6)
  51. ttk.Button(main_frame, text="浏览…", width=10, command=self.select_input).grid(row=0, column=2, padx=4)
  52. # 模板文件(图片)
  53. ttk.Label(main_frame, text="模板图片:").grid(row=1, column=0, sticky=tk.W, pady=6)
  54. ttk.Entry(main_frame, textvariable=self.template_var).grid(row=1, column=1, sticky=(tk.W, tk.E), padx=6)
  55. ttk.Button(main_frame, text="浏览…", width=10, command=self.select_template).grid(row=1, column=2, padx=4)
  56. # 输出文件夹
  57. ttk.Label(main_frame, text="输出文件夹:").grid(row=2, column=0, sticky=tk.W, pady=6)
  58. ttk.Entry(main_frame, textvariable=self.output_var).grid(row=2, column=1, sticky=(tk.W, tk.E), padx=6)
  59. ttk.Button(main_frame, text="浏览…", width=10, command=self.select_output).grid(row=2, column=2, padx=4)
  60. # 参数配置区域
  61. param_frame = ttk.LabelFrame(main_frame, text="合成参数(自动保存)", padding="10")
  62. param_frame.grid(row=3, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=10)
  63. # —— 缩放 / 偏移 ——
  64. ttk.Label(param_frame, text="缩放比例(% 输出页宽):").grid(row=0, column=0, sticky=tk.W, padx=4)
  65. ttk.Entry(param_frame, textvariable=self.scale_var, width=10).grid(row=0, column=1, sticky=tk.W, padx=4)
  66. ttk.Label(param_frame, text="例:100 = 模板宽度填满 60mm").grid(row=0, column=2, sticky=tk.W, padx=4)
  67. ttk.Label(param_frame, text="X 偏移(点):").grid(row=1, column=0, sticky=tk.W, padx=4, pady=6)
  68. ttk.Entry(param_frame, textvariable=self.offset_x_var, width=10).grid(row=1, column=1, sticky=tk.W, padx=4, pady=6)
  69. ttk.Label(param_frame, text="相对于 60mm 页面左上角,0 表示贴左边").grid(row=1, column=2, sticky=tk.W, padx=4, pady=6)
  70. ttk.Label(param_frame, text="Y 偏移(点):").grid(row=2, column=0, sticky=tk.W, padx=4)
  71. ttk.Entry(param_frame, textvariable=self.offset_y_var, width=10).grid(row=2, column=1, sticky=tk.W, padx=4)
  72. ttk.Label(param_frame, text="相对于 50mm 页面左上角,0 表示贴顶部").grid(row=2, column=2, sticky=tk.W, padx=4)
  73. # —— 文件名取段配置 ——
  74. ttk.Separator(param_frame, orient="horizontal").grid(
  75. row=3, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=8
  76. )
  77. ttk.Label(param_frame, text="文件名 - 主段(第几段):").grid(row=4, column=0, sticky=tk.W, padx=4, pady=4)
  78. ttk.Entry(param_frame, textvariable=self.seg1_var, width=10).grid(row=4, column=1, sticky=tk.W, padx=4, pady=4)
  79. ttk.Label(param_frame, text="从 1 开始计数(1 = 第一行文字)").grid(row=4, column=2, sticky=tk.W, padx=4, pady=4)
  80. ttk.Label(param_frame, text="文件名 - 拼接段(第几段):").grid(row=5, column=0, sticky=tk.W, padx=4, pady=4)
  81. ttk.Entry(param_frame, textvariable=self.seg2_var, width=10).grid(row=5, column=1, sticky=tk.W, padx=4, pady=4)
  82. ttk.Checkbutton(param_frame, text="启用拼接段(不存在时自动忽略)",
  83. variable=self.seg2_enable_var).grid(row=5, column=2, sticky=tk.W, padx=4, pady=4)
  84. # 开始按钮
  85. btn_frame = ttk.Frame(main_frame)
  86. btn_frame.grid(row=4, column=0, columnspan=3, pady=10)
  87. self.start_btn = ttk.Button(btn_frame, text="▶ 开始处理", command=self.start_process)
  88. self.start_btn.pack()
  89. # 日志区域
  90. ttk.Label(main_frame, text="处理日志:").grid(row=5, column=0, sticky=tk.W, pady=(8, 0))
  91. self.log_text = scrolledtext.ScrolledText(
  92. main_frame, height=16, wrap=tk.WORD, state=tk.NORMAL, font=("Consolas", 10)
  93. )
  94. self.log_text.grid(row=6, column=0, columnspan=3, sticky=(tk.W, tk.E, tk.N, tk.S), pady=4)
  95. main_frame.rowconfigure(6, weight=1)
  96. # 参数变更自动保存
  97. for var in (self.scale_var, self.offset_x_var, self.offset_y_var,
  98. self.seg1_var, self.seg2_var):
  99. var.trace_add("write", lambda *args: self.save_config())
  100. self.seg2_enable_var.trace_add("write", lambda *args: self.save_config())
  101. # ---------- 路径选择 ----------
  102. def select_input(self):
  103. file_path = filedialog.askopenfilename(filetypes=[("PDF 文件", "*.pdf")])
  104. if file_path:
  105. self.input_var.set(file_path)
  106. self.save_config()
  107. def select_template(self):
  108. file_path = filedialog.askopenfilename(
  109. filetypes=[
  110. ("图片文件", "*.png *.jpg *.jpeg *.bmp *.gif *.tiff *.webp"),
  111. ("PNG 图片", "*.png"),
  112. ("JPEG 图片", "*.jpg *.jpeg"),
  113. ("所有文件", "*.*")
  114. ]
  115. )
  116. if file_path:
  117. self.template_var.set(file_path)
  118. self.save_config()
  119. def select_output(self):
  120. folder = filedialog.askdirectory()
  121. if folder:
  122. self.output_var.set(folder)
  123. self.save_config()
  124. # ---------- 配置持久化 ----------
  125. def save_config(self):
  126. config = {
  127. "input_file": self.input_var.get(),
  128. "template": self.template_var.get(),
  129. "output_dir": self.output_var.get(),
  130. "scale_percent": self.scale_var.get(),
  131. "offset_x": self.offset_x_var.get(),
  132. "offset_y": self.offset_y_var.get(),
  133. "seg1": self.seg1_var.get(),
  134. "seg2": self.seg2_var.get(),
  135. "seg2_enable": bool(self.seg2_enable_var.get()),
  136. }
  137. try:
  138. with open(CONFIG_FILE, "w", encoding="utf-8") as f:
  139. json.dump(config, f, ensure_ascii=False, indent=2)
  140. except Exception:
  141. pass
  142. def load_config(self):
  143. if not os.path.exists(CONFIG_FILE):
  144. return
  145. try:
  146. with open(CONFIG_FILE, "r", encoding="utf-8") as f:
  147. config = json.load(f)
  148. self.input_var.set(config.get("input_file", ""))
  149. self.template_var.set(config.get("template", ""))
  150. self.output_var.set(config.get("output_dir", ""))
  151. self.scale_var.set(config.get("scale_percent", "100"))
  152. self.offset_x_var.set(config.get("offset_x", "0"))
  153. self.offset_y_var.set(config.get("offset_y", "0"))
  154. self.seg1_var.set(config.get("seg1", "1"))
  155. self.seg2_var.set(config.get("seg2", "4"))
  156. self.seg2_enable_var.set(bool(config.get("seg2_enable", True)))
  157. except Exception:
  158. pass
  159. # ---------- 日志 ----------
  160. def log(self, msg):
  161. self.log_text.insert(tk.END, msg + "\n")
  162. self.log_text.see(tk.END)
  163. self.root.update_idletasks()
  164. # ---------- 文字提取 ----------
  165. def extract_text(self, page):
  166. text = page.get_text().strip()
  167. if len(text) >= 2:
  168. return text
  169. try:
  170. import pytesseract
  171. pix = page.get_pixmap(dpi=300)
  172. mode = "RGBA" if pix.alpha else "RGB"
  173. img = Image.frombytes(mode, [pix.width, pix.height], pix.samples)
  174. if mode == "RGBA":
  175. img = img.convert("RGB")
  176. text = pytesseract.image_to_string(img, lang="chi_sim+eng").strip()
  177. return text
  178. except Exception:
  179. return ""
  180. # ---------- 数字字符修正 ----------
  181. @staticmethod
  182. def fix_digits(s):
  183. """
  184. 对条码字体常见的 OCR 混淆字符做修正。
  185. - 单字符映射:¨→0 ©→1 ª→2 «→3 ®→6
  186. - ° 后紧跟一位数字:视为该数字(去掉 °)
  187. - 单独 °:视为 0
  188. """
  189. if not s:
  190. return s
  191. table = {"¨": "0", "©": "1", "ª": "2", "«": "3", "®": "6"}
  192. for k, v in table.items():
  193. s = s.replace(k, v)
  194. s = re.sub(r"°(\d)", r"\1", s) # °7 → 7
  195. s = s.replace("°", "0") # 单独 ° → 0
  196. return s
  197. # ---------- 文件名生成(可配置段号) ----------
  198. def get_filename_from_text(self, text):
  199. """
  200. 按配置的段号(从 1 开始)取文字作为文件名。
  201. - seg1(主段):必取;越界则返回空字符串
  202. - seg2(拼接段):可选,启用且存在时用下划线拼到主段后面
  203. 段之间用下划线连接。
  204. """
  205. if not text:
  206. return ""
  207. lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
  208. if not lines:
  209. return ""
  210. def pick(seg_no):
  211. """段号从 1 开始;非法或越界返回空字符串"""
  212. try:
  213. n = int(str(seg_no).strip())
  214. except (TypeError, ValueError):
  215. return ""
  216. if n < 1 or n > len(lines):
  217. return ""
  218. return lines[n - 1]
  219. first = pick(self.seg1_var.get())
  220. if not first:
  221. return ""
  222. parts = [self.fix_digits(first)]
  223. if self.seg2_enable_var.get():
  224. second = pick(self.seg2_var.get())
  225. if second:
  226. parts.append(self.fix_digits(second))
  227. name = "_".join(parts)
  228. return self.sanitize_filename(name)
  229. def sanitize_filename(self, text):
  230. if not text:
  231. return ""
  232. name = text[:80] # 限制长度
  233. name = re.sub(r'[\\/:*?"<>|\s]+', "_", name)
  234. return name.strip("._")
  235. # ---------- 核心处理 ----------
  236. def start_process(self):
  237. t = threading.Thread(target=self.process, daemon=True)
  238. t.start()
  239. def process(self):
  240. self.start_btn.config(state=tk.DISABLED)
  241. self.log_text.delete(1.0, tk.END)
  242. input_file = self.input_var.get()
  243. template_file = self.template_var.get()
  244. output_dir = self.output_var.get()
  245. # 解析参数
  246. try:
  247. scale_percent = float(self.scale_var.get().strip() or "100")
  248. offset_x = float(self.offset_x_var.get().strip() or "0")
  249. offset_y = float(self.offset_y_var.get().strip() or "0")
  250. except ValueError:
  251. self.log("❌ 错误:缩放比例、X 偏移、Y 偏移必须是有效数字")
  252. self.start_btn.config(state=tk.NORMAL)
  253. return
  254. # 校验段号
  255. try:
  256. seg1_no = int(self.seg1_var.get().strip() or "1")
  257. except ValueError:
  258. self.log("❌ 错误:文件名 - 主段必须是整数")
  259. self.start_btn.config(state=tk.NORMAL)
  260. return
  261. if seg1_no < 1:
  262. self.log("❌ 错误:文件名 - 主段必须 ≥ 1")
  263. self.start_btn.config(state=tk.NORMAL)
  264. return
  265. if self.seg2_enable_var.get():
  266. try:
  267. seg2_no = int(self.seg2_var.get().strip() or "4")
  268. except ValueError:
  269. self.log("❌ 错误:文件名 - 拼接段必须是整数")
  270. self.start_btn.config(state=tk.NORMAL)
  271. return
  272. if seg2_no < 1:
  273. self.log("❌ 错误:文件名 - 拼接段必须 ≥ 1")
  274. self.start_btn.config(state=tk.NORMAL)
  275. return
  276. if not all([input_file, template_file, output_dir]):
  277. self.log("❌ 错误:请完整填写输入文件、模板图片和输出文件夹")
  278. self.start_btn.config(state=tk.NORMAL)
  279. return
  280. if not os.path.isfile(input_file):
  281. self.log(f"❌ 错误:输入文件不存在:{input_file}")
  282. self.start_btn.config(state=tk.NORMAL)
  283. return
  284. if not os.path.isfile(template_file):
  285. self.log(f"❌ 错误:模板图片不存在:{template_file}")
  286. self.start_btn.config(state=tk.NORMAL)
  287. return
  288. if not os.path.exists(output_dir):
  289. os.makedirs(output_dir, exist_ok=True)
  290. self.log(f"📄 输入文件:{os.path.basename(input_file)}")
  291. self.log(f"🖼 模板图片:{os.path.basename(template_file)}")
  292. self.log(f"📐 输出页面尺寸:60mm × 50mm({TARGET_W:.1f}pt × {TARGET_H:.1f}pt)")
  293. self.log(f"⚙ 参数:缩放 {scale_percent}% 宽,偏移 ({offset_x}, {offset_y})")
  294. if self.seg2_enable_var.get():
  295. self.log(f"🏷 文件名取段:主段 = 第 {seg1_no} 段,拼接段 = 第 {seg2_no} 段\n")
  296. else:
  297. self.log(f"🏷 文件名取段:仅主段 = 第 {seg1_no} 段\n")
  298. try:
  299. # 获取模板图片尺寸
  300. with Image.open(template_file) as template_img:
  301. template_w, template_h = template_img.size
  302. doc = fitz.open(input_file)
  303. total_pages = len(doc)
  304. if total_pages == 0:
  305. self.log("⚠ 该文件没有页面,已跳过")
  306. doc.close()
  307. self.start_btn.config(state=tk.NORMAL)
  308. return
  309. self.log(f"📑 共 {total_pages} 页,全部处理\n")
  310. for idx in range(total_pages):
  311. page = doc[idx]
  312. page_num = idx + 1
  313. # 从该页提取文字作为文件名
  314. page_text = self.extract_text(page)
  315. safe_name = self.get_filename_from_text(page_text)
  316. if not safe_name:
  317. base = os.path.splitext(os.path.basename(input_file))[0]
  318. safe_name = f"{base}_page{page_num}"
  319. # 创建固定 60×50mm 的新页面
  320. new_doc = fitz.open()
  321. new_page = new_doc.new_page(width=TARGET_W, height=TARGET_H)
  322. # 1) 输入页作为背景(原尺寸放置,超出部分自然被裁剪)
  323. src_rect = page.rect
  324. new_page.show_pdf_page(
  325. fitz.Rect(0, 0, src_rect.width, src_rect.height),
  326. doc, idx, overlay=False
  327. )
  328. # 2) 计算模板目标矩形(缩放基准为输出页面宽度)
  329. target_w = TARGET_W * (scale_percent / 100.0)
  330. scale = target_w / template_w
  331. target_h = template_h * scale
  332. target_rect = fitz.Rect(offset_x, offset_y,
  333. offset_x + target_w,
  334. offset_y + target_h)
  335. # 3) 叠加模板图片(前景)
  336. new_page.insert_image(target_rect, filename=template_file)
  337. # 保存(自动处理重名)
  338. out_name = f"{safe_name}.pdf"
  339. out_path = os.path.join(output_dir, out_name)
  340. counter = 1
  341. while os.path.exists(out_path):
  342. stem, ext = os.path.splitext(out_name)
  343. out_path = os.path.join(output_dir, f"{stem}_{counter}{ext}")
  344. counter += 1
  345. new_doc.save(out_path)
  346. new_doc.close()
  347. self.log(f"✔ 第 {page_num} 页 → {os.path.basename(out_path)}")
  348. doc.close()
  349. self.log(f"\n🎉 全部完成!共导出 {total_pages} 个 PDF")
  350. except Exception as e:
  351. self.log(f"\n❌ 发生错误:{str(e)}")
  352. self.start_btn.config(state=tk.NORMAL)
  353. if __name__ == "__main__":
  354. root = tk.Tk()
  355. app = PDFMergeApp(root)
  356. root.mainloop()