声明:
一、这是一个非常小众的工具类程序,如果您没有频繁的裁剪打印证件需求,这个工具将对您没用。
二、使用本工具将会自动向服务器发送IP地址、操作系统、内存大小等非敏感数据(不涉及个人隐私特征数据,不收集MAC地址),此举仅出于数据统计考虑,以方便后期的优化升级。

功能简介:
本工具可以导入证件照片,进行简单的四点定位即可完成证件裁剪,裁剪后的证件照会自动放到A4尺寸白色背景上,最终可以导出图片、导出PDF格式或直接发送到打印机打印文件。

问:为什么要做这个工具?
经常看我文章的知道,我是做代办行业的。大部分办事场景都需要客户提供身份证复印件,为了方便客户,也不需要客户带身份证去打印店扫描或复印,只需要发身份证的照片即可。我们拿到照片后需要打开PS或Word的裁剪功能进行拖拽裁剪,最终把身份证裁剪调整到合适的尺寸才能进行打印,整个过程耗时往往需要几分钟。而且如果客户拍摄的照片倾斜不正的情况下,只能借助PS工具修正。为了提升工作效率,特意用AI制作了这个工具。
问:为什么不设计成自动识别证件轮廓完成裁剪,那样不更省事?
我最开始想的就是软件能自动识别轮廓并完成裁剪,我想的还是太理想化了,AI写的代码对于证件轮廓的识别准确率太低了,对证件背景的要求太高,必须是纯色背景,不能有一丁点杂色。网上搜罗也没有找到靠谱的解决方案。索性采用了性价比最高的“四点定位法”,即依次用鼠标选取证件轮廓的左上、右上、右下、左下四个点即可快速完成裁剪,操作难度低,效率比想象的要高。
使用步骤:
1、解压zip文件,再双击“忠之托证易印 Zhicert PrintGo V1.0.0.exe”直接运行。(绿色软件,无需安装。解压后_internal目录里面的文件不能动,更不能删除,否则会运行出错。)

2、将身份证正反面照片同时拖进来。(也可以切换其他证件类型,不同的证件类型,预设了不同的尺寸。)

3、在图片预览区,点击右上角编辑裁切,进入编辑页面。

4、依次选取证件的左上、右上、右下、左下四个点。再点击完成并保存即可完成证件的裁剪。有多个图片的依次操作多个图片。


5、根据自己的需求可以选择直接打印、导出图片、导出PDF文件。

(效果如下图所示,黑色边框是为了方便在文章中展示后加的,实际输出的图片没有黑色边框。)

下载地址(复制到浏览器打开):
https://open.zztuo.cn/tool/ZhicertPrintGo/download.html
Python源码:
import osimport mathimport tempfileimport tkinter as tkfrom tkinter import filedialog, messagebox, scrolledtext, ttkimport cv2import numpy as npfrom PIL import Image, ImageTk, ImageWinfrom reportlab.lib.pagesizes import A4from reportlab.pdfgen import canvasfrom datetime import datetimeimport threadingimport webbrowserimport win32printimport win32uiimport win32conimport platformimport socketimport sysimport ctypesimport subprocess# ====================== 拖拽模块预导入(原版可用核心写法,修复识别异常) ======================DND_AVAILABLE = FalseTkinterDnD = NoneDND_FILES = Nonetry:from tkinterdnd2 import TkinterDnD, DND_FILESDND_AVAILABLE = Trueexcept ImportError:DND_AVAILABLE = False# ====================== 全局配置常量 ======================APP_VERSION = "V1.0.0"APP_NAME = "忠之托证易印 Zhicert PrintGo(证件照片快速裁剪打印工具)"# 全局唯一互斥名,防止程序二次启动弹出黑框MUTEX_NAME = "ZhicertPrintGo_SingleInstance_Mutex_20260628"DPI = 300MM_TO_INCH = 1 / 25.4A4_WIDTH_PX = int(210 * MM_TO_INCH * DPI)A4_HEIGHT_PX = int(297 * MM_TO_INCH * DPI)try:RESAMPLE_LANCZOS = Image.Resampling.LANCZOSexcept AttributeError:RESAMPLE_LANCZOS = Image.ANTIALIASCARD_SPECS = {"居民身份证": {"width_mm": 85.6,"height_mm": 54.0,"two_per_page": True,"desc": "正反面上下排版,一页两张","filename": "身份证_A4"},"银行卡": {"width_mm": 85.5,"height_mm": 54.0,"two_per_page": False,"desc": "单张排版","filename": "银行卡_A4"},"驾驶证": {"width_mm": 80.0,"height_mm": 54.0,"two_per_page": False,"desc": "单张排版","filename": "驾驶证_A4"},"行驶证": {"width_mm": 88.0,"height_mm": 60.0,"two_per_page": False,"desc": "单张排版","filename": "行驶证_A4"},"一寸证件照": {"width_mm": 25.0,"height_mm": 35.0,"two_per_page": False,"desc": "单张排版","filename": "一寸证件照_A4"}}DEFAULT_CARD_TYPE = "居民身份证"MARGIN_PX = int(10 * MM_TO_INCH * DPI)ID_GAP_PX = int(10 * MM_TO_INCH * DPI)TEMP_DIR = tempfile.gettempdir()TEMP_PREFIX = "idcard_tmp_"DEFAULT_BASE_NAME = CARD_SPECS[DEFAULT_CARD_TYPE]["filename"]SAVE_FOLDER = os.path.expanduser("~/Desktop")PREVIEW_FIX_WIDTH = 440PREVIEW_FIX_HEIGHT = 200# ====================== 判断是否为PyInstaller打包后的exe ======================def is_frozen_exe():return getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS")# ====================== 硬件信息采集函数(纯ctypes,无psutil,不再弹出wmic.exe) ======================def get_cpu_model():try:# 隐藏窗口执行wmic,不弹出黑框startupinfo = subprocess.STARTUPINFO()startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOWresult = subprocess.check_output(["wmic", "cpu", "get", "name", "/value"],startupinfo=startupinfo,stderr=subprocess.STDOUT).decode("utf-8", errors="ignore")for line in result.splitlines():if "=" in line:k, v = line.split("=", 1)if k.strip() == "Name":return v.strip()return "未知CPU"except Exception:return "未知CPU"def get_total_memory_gb():try:import ctypeskernel32 = ctypes.WinDLL("kernel32", use_last_error=True)class MEMORYSTATUSEX(ctypes.Structure):_fields_ = [("dwLength", ctypes.c_uint),("dwMemoryLoad", ctypes.c_uint),("ullTotalPhys", ctypes.c_ulonglong),("ullAvailPhys", ctypes.c_ulonglong),("ullTotalPageFile", ctypes.c_ulonglong),("ullAvailPageFile", ctypes.c_ulonglong),("ullTotalVirtual", ctypes.c_ulonglong),("ullAvailVirtual", ctypes.c_ulonglong),("ullAvailExtendedVirtual", ctypes.c_ulonglong),]ms = MEMORYSTATUSEX()ms.dwLength = ctypes.sizeof(MEMORYSTATUSEX)kernel32.GlobalMemoryStatusEx(ctypes.byref(ms))total_gb = round(ms.ullTotalPhys / (1024**3), 2)return f"{total_gb} GB"except Exception:return "未知内存"# 改用Windows API获取分辨率,子线程安全,不再新建Tkdef get_screen_resolution():try:import ctypesuser32 = ctypes.windll.user32screen_w = user32.GetSystemMetrics(0)screen_h = user32.GetSystemMetrics(1)return f"{screen_w}×{screen_h}"except Exception:return "未知分辨率"# ====================== 窗口工具函数 ======================def center_window(win, parent):win.update_idletasks()w = win.winfo_width()h = win.winfo_height()x = parent.winfo_x() + (parent.winfo_width() - w) // 2y = parent.winfo_y() + (parent.winfo_height() - h) // 2win.geometry(f"{w}x{h}+{x}+{y}")def center_root_window(root, win_width, win_height):sw = root.winfo_screenwidth()sh = root.winfo_screenheight()x = (sw - win_width) // 2y = (sh - win_height) // 2root.geometry(f"{win_width}x{win_height}+{x}+{y}")def get_printer_list():printers = []try:enum_printer = win32print.EnumPrinters(win32print.PRINTER_ENUM_LOCAL, None, 1)for item in enum_printer:printers.append(item[2])except Exception:passreturn printers# ====================== 四点裁切编辑器 ======================class FourPointEditor:def __init__(self, parent, src_pil, finish_callback, card_w_mm, card_h_mm):self.parent = parentself.on_finish = finish_callbackself.origin_pil = src_pil.copy()self.work_cv = cv2.cvtColor(np.array(self.origin_pil), cv2.COLOR_RGB2BGR)self.card_w_px = int(card_w_mm * MM_TO_INCH * DPI * 1.1)self.card_h_px = int(card_h_mm * MM_TO_INCH * DPI * 1.1)self.four_points = []self.dragging_point_idx = -1self.scale = 1.0self.offset_x = 0self.offset_y = 0self.tk_img_cache = Noneself.img_item_id = Noneself.annotation_tags = []self.img_view_x = 0self.img_view_y = 0self.fit_scale = 1.0self.win = tk.Toplevel(parent)self.win.withdraw()self.win.title("四点手动裁切编辑器(点四点→可拖拽微调,保存退出自动裁切)")self.win.geometry("900x700")self.win.transient(parent)self.win.grab_set()self.build_ui()self.bind_mouse()self.win.after(120, self.refresh_canvas)center_window(self.win, parent)self.win.deiconify()def build_ui(self):top_frame = tk.Frame(self.win, padx=5, pady=5)top_frame.pack(fill=tk.X)tk.Button(top_frame, text="重置整张图", command=self.reset_all, bg="#ffdd99").pack(side=tk.LEFT, padx=3)tk.Button(top_frame, text="清空四个角点", command=self.clear_points).pack(side=tk.LEFT, padx=3)tk.Label(top_frame, text="操作模式:四点透视裁切(点完可拖拽微调点位)", fg="#2277cc").pack(side=tk.LEFT, padx=20)tk.Button(top_frame, text="保存并退出", command=self.apply_close, bg="#b2e8b2").pack(side=tk.RIGHT, padx=3)tk.Button(top_frame, text="取消", command=self.win.destroy).pack(side=tk.RIGHT, padx=3)tip_frame = tk.Frame(self.win)tip_frame.pack(fill=tk.X, padx=5)tip_text = "操作:依次点击【左上→右上→右下→左下】四个角;按住蓝点可拖动微调对齐;对齐后点击【保存并退出】自动完成裁切"tk.Label(tip_frame, text=tip_text, fg="#c0392b").pack()canvas_container = tk.Frame(self.win)canvas_container.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)self.canvas = tk.Canvas(canvas_container, bg="#dddddd")self.canvas.pack(fill=tk.BOTH, expand=True)def bind_mouse(self):self.canvas.bind("<Button-1>", self.mouse_down)self.canvas.bind("<B1-Motion>", self.mouse_drag_move)self.canvas.bind("<ButtonRelease-1>", self.mouse_up_release)self.canvas.bind("<MouseWheel>", self.mouse_wheel)self.canvas.bind("<ButtonPress-2>", self.pan_start)self.canvas.bind("<B2-Motion>", self.pan_move)self.pan_origin_x = 0self.pan_origin_y = 0def pan_start(self, e):self.pan_origin_x = e.xself.pan_origin_y = e.ydef pan_move(self, e):dx = e.x - self.pan_origin_xdy = e.y - self.pan_origin_yself.offset_x += dxself.offset_y += dyself.pan_origin_x = e.xself.pan_origin_y = e.yself.refresh_canvas()def pos_to_img(self, cx, cy):ix = (cx - self.img_view_x) / self.fit_scaleiy = (cy - self.img_view_y) / self.fit_scalereturn ix, iydef img_to_pos(self, ix, iy):cx = self.img_view_x + ix * self.fit_scalecy = self.img_view_y + iy * self.fit_scalereturn cx, cydef get_point_under_mouse(self, mx, my):hit_radius = 10for idx, (x, y) in enumerate(self.four_points):px, py = self.img_to_pos(x, y)dist = math.hypot(mx - px, my - py)if dist < hit_radius:return idxreturn -1def refresh_canvas(self):cw = self.canvas.winfo_width()ch = self.canvas.winfo_height()if cw <= 1 or ch <= 1:returnh_img, w_img = self.work_cv.shape[:2]disp_pil = Image.fromarray(cv2.cvtColor(self.work_cv, cv2.COLOR_BGR2RGB))base_fit = min(cw / w_img, ch / h_img)self.fit_scale = base_fit * self.scaledisp_w = int(w_img * self.fit_scale)disp_h = int(h_img * self.fit_scale)disp_resize = disp_pil.resize((disp_w, disp_h), RESAMPLE_LANCZOS)self.tk_img_cache = ImageTk.PhotoImage(disp_resize)self.img_view_x = (cw - disp_w) / 2 + self.offset_xself.img_view_y = (ch - disp_h) / 2 + self.offset_yfor tag in self.annotation_tags:self.canvas.delete(tag)self.annotation_tags.clear()if self.img_item_id is None:self.img_item_id = self.canvas.create_image(self.img_view_x, self.img_view_y, anchor=tk.NW, image=self.tk_img_cache)else:self.canvas.coords(self.img_item_id, self.img_view_x, self.img_view_y)self.canvas.itemconfig(self.img_item_id, image=self.tk_img_cache)corner_names = ["左上", "右上", "右下", "左下"]for idx, (x, y) in enumerate(self.four_points):px, py = self.img_to_pos(x, y)oval_id = self.canvas.create_oval(px-7, py-7, px+7, py+7, fill="blue", outline="white", tags=("anno",))text_id = self.canvas.create_text(px, py-12, text=corner_names[idx], fill="blue", font=("Arial", 9), tags=("anno",))self.annotation_tags.append(oval_id)self.annotation_tags.append(text_id)if len(self.four_points) >= 2:for i in range(len(self.four_points)-1):x1, y1 = self.img_to_pos(*self.four_points[i])x2, y2 = self.img_to_pos(*self.four_points[i+1])line_id = self.canvas.create_line(x1, y1, x2, y2, fill="blue", width=2, tags=("anno",))self.annotation_tags.append(line_id)if len(self.four_points) == 4:x1, y1 = self.img_to_pos(*self.four_points[3])x2, y2 = self.img_to_pos(*self.four_points[0])line_id = self.canvas.create_line(x1, y1, x2, y2, fill="blue", width=2, tags=("anno",))self.annotation_tags.append(line_id)def clear_points(self):self.four_points.clear()self.dragging_point_idx = -1self.refresh_canvas()def reset_all(self):self.work_cv = cv2.cvtColor(np.array(self.origin_pil), cv2.COLOR_RGB2BGR)self.four_points.clear()self.dragging_point_idx = -1self.scale = 1.0self.offset_x = 0self.offset_y = 0self.img_item_id = Noneself.annotation_tags.clear()self.refresh_canvas()def do_perspective_cut(self):if len(self.four_points) != 4:return Falsepts = np.array(self.four_points, dtype=np.float32)dst = np.array([[0,0],[self.card_w_px-1,0],[self.card_w_px-1,self.card_h_px-1],[0,self.card_h_px-1]], dtype=np.float32)M = cv2.getPerspectiveTransform(pts, dst)self.work_cv = cv2.warpPerspective(self.work_cv, M, (self.card_w_px, self.card_h_px), borderMode=cv2.BORDER_REPLICATE)self.four_points.clear()self.dragging_point_idx = -1self.img_item_id = Noneself.annotation_tags.clear()return Truedef mouse_down(self, e):hit_idx = self.get_point_under_mouse(e.x, e.y)if hit_idx != -1:self.dragging_point_idx = hit_idxreturnix, iy = self.pos_to_img(e.x, e.y)h, w = self.work_cv.shape[:2]if ix < 0 or iy < 0 or ix > w or iy > h:returnif len(self.four_points) < 4:self.four_points.append((ix, iy))self.refresh_canvas()def mouse_drag_move(self, e):if self.dragging_point_idx == -1:returnix, iy = self.pos_to_img(e.x, e.y)h, w = self.work_cv.shape[:2]ix = max(0, min(w, ix))iy = max(0, min(h, iy))self.four_points[self.dragging_point_idx] = (ix, iy)self.refresh_canvas()def mouse_up_release(self, e):self.dragging_point_idx = -1def mouse_wheel(self, e):factor = 1.1 if e.delta > 0 else 0.9self.scale = max(0.2, min(3.5, self.scale * factor))self.refresh_canvas()def apply_close(self):if len(self.four_points) == 4:self.do_perspective_cut()final_pil = Image.fromarray(cv2.cvtColor(self.work_cv, cv2.COLOR_BGR2RGB))self.on_finish(final_pil)self.win.destroy()# ====================== 主程序类 ======================class IDCardProcessor:def __init__(self, root):self.root = rootself.root.title(f"{APP_NAME}{APP_VERSION}")win_w = 1090win_h = 800center_root_window(self.root, win_w, win_h)self.root.minsize(960, 780)self.img_list = []self.selected_origin_paths = []self.output_pdf_path = ""self.current_card_type = DEFAULT_CARD_TYPEspec = CARD_SPECS[self.current_card_type]self.card_w_mm = spec["width_mm"]self.card_h_mm = spec["height_mm"]self.card_w_px = int(self.card_w_mm * MM_TO_INCH * DPI)self.card_h_px = int(self.card_h_mm * MM_TO_INCH * DPI)self.tk_preview1 = Noneself.tk_preview2 = Noneself.frame_up = Noneself.label_up = Noneself.btn_edit1 = Noneself.frame_down = Noneself.label_down = Noneself.btn_edit2 = Noneself.swap_btn = Noneself.save_path_var = tk.StringVar(value=SAVE_FOLDER)self.save_name_var = tk.StringVar(value=DEFAULT_BASE_NAME)self.printer_list = get_printer_list()self.selected_printer = tk.StringVar()self.build_ui()self.root.bind("<Configure>", self.window_resize_event)self.root.protocol("WM_DELETE_WINDOW", self.on_app_close)self.init_printer_combobox()self.update_two_side_visibility()self.update_swap_btn_visible()self.set_log_drag_tip()def set_log_drag_tip(self):self.text_log.tag_config("red_tip", foreground="#dd0000")tip_text = "【提示:可直接将图片拖拽到此区域快速导入】\n【处理身份证照片时需同时选中身份证正反面。】\n"self.text_log.insert(tk.END, tip_text, "red_tip")def window_resize_event(self, event):passdef on_app_close(self):self.clear_temp_files()self.root.destroy()def init_printer_combobox(self):if self.printer_list:try:default_printer = win32print.GetDefaultPrinter()except Exception:default_printer = ""self.printer_combo['values'] = self.printer_listif default_printer in self.printer_list:self.selected_printer.set(default_printer)else:self.selected_printer.set(self.printer_list[0])def build_ui(self):pad_x = 8pad_y = 4root_pane = ttk.PanedWindow(self.root, orient=tk.VERTICAL)root_pane.pack(fill=tk.BOTH, expand=True)top_container = ttk.Frame(root_pane)root_pane.add(top_container, weight=3)bottom_all = ttk.Frame(root_pane)root_pane.add(bottom_all, weight=1)top_frame = ttk.Frame(top_container)top_frame.pack(fill=tk.X)type_frame = ttk.Frame(top_frame)type_frame.pack(fill=tk.X, padx=6, pady=pad_y)ttk.Label(type_frame, text="证件类型:").pack(side=tk.LEFT)self.card_type_var = tk.StringVar(value=self.current_card_type)card_cbx = ttk.Combobox(type_frame, textvariable=self.card_type_var, state="readonly", width=18)card_cbx["values"] = list(CARD_SPECS.keys())card_cbx.pack(side=tk.LEFT, padx=5)card_cbx.bind("<<ComboboxSelected>>", self.on_card_type_change)self.card_tip_label = ttk.Label(type_frame, text=CARD_SPECS[self.current_card_type]["desc"], foreground="#226622")self.card_tip_label.pack(side=tk.LEFT, padx=15)ttk.Separator(top_frame, orient=tk.HORIZONTAL).pack(fill=tk.X, padx=6, pady=3)toolbar_frame = ttk.Frame(top_frame)toolbar_frame.pack(fill=tk.X, padx=6, pady=pad_y)ttk.Button(toolbar_frame, text="选择图片", command=self.select_files, width=18).pack(side=tk.LEFT, padx=4)ttk.Label(toolbar_frame, text="说明:快速裁切证件并生成A4打印排版", foreground="#c0392b").pack(side=tk.LEFT, padx=12)ttk.Button(toolbar_frame, text="关于软件", command=self.show_about_window).pack(side=tk.RIGHT, padx=4)ttk.Separator(top_frame, orient=tk.HORIZONTAL).pack(fill=tk.X, padx=6, pady=3)middle_container = ttk.Frame(top_container)middle_container.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)middle_container.columnconfigure(0, weight=1)middle_container.columnconfigure(1, weight=1)left_frame = ttk.LabelFrame(middle_container, text="处理日志")left_frame.grid(row=0, column=0, sticky="nsew", padx=4)left_inner = ttk.Frame(left_frame)left_inner.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)self.text_log = scrolledtext.ScrolledText(left_inner)self.text_log.pack(fill=tk.BOTH, expand=True)# 绑定日志框拖拽if DND_AVAILABLE:self.text_log.drop_target_register(DND_FILES)self.text_log.dnd_bind('<<Drop>>', self.drop_files)right_frame = ttk.LabelFrame(middle_container, text="预览")right_frame.grid(row=0, column=1, sticky="nsew", padx=4)right_inner = ttk.Frame(right_frame)right_inner.pack(fill=tk.BOTH, expand=True, padx=6, pady=6)top_bar = ttk.Frame(right_inner)top_bar.pack(fill=tk.X, pady=(0, 8))ttk.Label(top_bar, text="", width=1).pack(side=tk.LEFT)self.swap_btn = ttk.Button(top_bar, text="互换正反面", command=self.swap_two_face, width=12)self.swap_btn.pack(side=tk.RIGHT)card1 = ttk.LabelFrame(right_inner, text="图片一")card1.pack(fill=tk.X, pady=(0, 10))self.frame_up = card1card1_inner = ttk.Frame(card1)card1_inner.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)bar1 = ttk.Frame(card1_inner)bar1.pack(fill=tk.X, pady=(0,5))ttk.Label(bar1, text="").pack(side=tk.LEFT)self.btn_edit1 = ttk.Button(bar1, text="编辑裁切", command=lambda:self.open_editor(0), width=10)self.btn_edit1.pack(side=tk.RIGHT)canvas1 = tk.Canvas(card1_inner, bg="#f7f7f7", relief=tk.SUNKEN, width=PREVIEW_FIX_WIDTH, height=PREVIEW_FIX_HEIGHT)canvas1.pack(pady=2)self.label_up = canvas1card2 = ttk.LabelFrame(right_inner, text="图片二")card2.pack(fill=tk.X)self.frame_down = card2card2_inner = ttk.Frame(card2)card2_inner.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)bar2 = ttk.Frame(card2_inner)bar2.pack(fill=tk.X, pady=(0,5))ttk.Label(bar2, text="").pack(side=tk.LEFT)self.btn_edit2 = ttk.Button(bar2, text="编辑裁切", command=lambda:self.open_editor(1), width=10)self.btn_edit2.pack(side=tk.RIGHT)canvas2 = tk.Canvas(card2_inner, bg="#f7f7f7", relief=tk.SUNKEN, width=PREVIEW_FIX_WIDTH, height=PREVIEW_FIX_HEIGHT)canvas2.pack(pady=2)self.label_down = canvas2save_cfg_frame = ttk.Frame(bottom_all)save_cfg_frame.pack(fill=tk.X, padx=6, pady=pad_y)ttk.Label(save_cfg_frame, text="保存目录:").pack(side=tk.LEFT)ttk.Entry(save_cfg_frame, textvariable=self.save_path_var, width=32).pack(side=tk.LEFT, padx=4)ttk.Button(save_cfg_frame, text="选择目录", command=self.select_save_dir).pack(side=tk.LEFT, padx=3)ttk.Label(save_cfg_frame, text="文件名:").pack(side=tk.LEFT, padx=10)ttk.Entry(save_cfg_frame, textvariable=self.save_name_var, width=18).pack(side=tk.LEFT, padx=4)ttk.Label(save_cfg_frame, text="打印机:").pack(side=tk.LEFT, padx=10)self.printer_combo = ttk.Combobox(save_cfg_frame, textvariable=self.selected_printer, state="readonly", width=26)self.printer_combo.pack(side=tk.LEFT, padx=4)ttk.Separator(bottom_all, orient=tk.HORIZONTAL).pack(fill=tk.X, padx=6, pady=3)bottom_btn_frame = ttk.Frame(bottom_all)bottom_btn_frame.pack(pady=8)btn_reload = ttk.Button(bottom_btn_frame, text="重新载入原图", command=self.reimport_clear, width=15)btn_reload.grid(row=0, column=0, padx=6)btn_print = ttk.Button(bottom_btn_frame, text="直接打印", command=self.print_file, width=15)btn_print.grid(row=0, column=1, padx=6)btn_clear = ttk.Button(bottom_btn_frame, text="清空全部", command=self.clear_all, width=15)btn_clear.grid(row=0, column=2, padx=6)btn_export_img = ttk.Button(bottom_btn_frame, text="导出图片", command=self.save_combine_img, width=15)btn_export_img.grid(row=0, column=3, padx=6)btn_export_pdf = ttk.Button(bottom_btn_frame, text="导出PDF", command=self.save_pdf, width=15)btn_export_pdf.grid(row=0, column=4, padx=6)def update_swap_btn_visible(self):if len(self.img_list) >= 2:self.swap_btn.pack(side=tk.RIGHT)else:self.swap_btn.pack_forget()def update_two_side_visibility(self):spec = CARD_SPECS[self.current_card_type]if spec["two_per_page"]:self.frame_down.pack(fill=tk.X, pady=(2,6))self.btn_edit2.config(state=tk.NORMAL)else:self.frame_down.pack_forget()self.btn_edit2.config(state=tk.DISABLED)def show_about_window(self):about_win = tk.Toplevel(self.root)about_win.withdraw()about_win.title("关于软件")about_win.geometry("420x260")about_win.resizable(False, False)about_win.transient(self.root)about_win.grab_set()main_frame = ttk.Frame(about_win, padding=15)main_frame.pack(fill=tk.BOTH, expand=True)ttk.Label(main_frame, text="忠之托·证易印 Zhicert PrintGo", font=("微软雅黑",12,"bold")).pack(anchor="w")ttk.Label(main_frame, text=f"软件版本:{APP_VERSION}", font=("微软雅黑",10)).pack(anchor="w", pady=(6,0))desc_text = """功能简介:支持身份证、银行卡、驾驶证、行驶证、一寸照四点透视矫正自动A4排版、一键导出图片/PDF、直连打印机打印"""ttk.Label(main_frame, text=desc_text, justify="left").pack(anchor="w", pady=(8,12))btn_frame = ttk.Frame(main_frame)btn_frame.pack(fill=tk.X)def check_update():messagebox.showinfo("版本检测", f"当前已是最新版本 {APP_VERSION}")def open_more():webbrowser.open("https://open.zztuo.cn/tool/ZhicertPrintGo/")ttk.Button(btn_frame, text="检查更新", command=check_update).pack(side=tk.LEFT, expand=True, fill=tk.X, padx=2)ttk.Button(btn_frame, text="了解更多", command=open_more).pack(side=tk.LEFT, expand=True, fill=tk.X, padx=2)ttk.Button(btn_frame, text="关闭", command=about_win.destroy).pack(side=tk.LEFT, expand=True, fill=tk.X, padx=2)center_window(about_win, self.root)about_win.deiconify()def select_save_dir(self):path = filedialog.askdirectory(title="选择文件保存目录")if path:self.save_path_var.set(path)global SAVE_FOLDERSAVE_FOLDER = pathself.log(f"已设置保存目录:{path}")def on_card_type_change(self, event):new_type = self.card_type_var.get()if new_type == self.current_card_type:returnres = messagebox.askyesno("确认切换", f"切换证件类型为【{new_type}】将清空当前所有图片数据,是否继续?")if not res:self.card_type_var.set(self.current_card_type)returnself.current_card_type = new_typespec = CARD_SPECS[self.current_card_type]self.card_tip_label.config(text=spec["desc"])self.card_w_mm = spec["width_mm"]self.card_h_mm = spec["height_mm"]self.card_w_px = int(self.card_w_mm * MM_TO_INCH * DPI)self.card_h_px = int(self.card_h_mm * MM_TO_INCH * DPI)self.save_name_var.set(spec["filename"])self.log(f"切换证件类型:{self.current_card_type},尺寸 {self.card_w_mm}×{self.card_h_mm} mm")self.update_two_side_visibility()self.clear_all()def swap_two_face(self):spec = CARD_SPECS[self.current_card_type]if spec["two_per_page"] is False:messagebox.showinfo("提示", "当前证件设置为单张模式,无需正反面互换")returnif len(self.img_list) < 2:messagebox.showinfo("提示", "需要同时两张图片才可互换")returnself.img_list[0], self.img_list[1] = self.img_list[1], self.img_list[0]self.log("已互换【图片一 ↔ 图片二】位置")self.refresh_two_preview()def reimport_clear(self):if not self.selected_origin_paths:messagebox.showinfo("提示", "没有已选择的原图")returnself.img_list.clear()self.process_manual_raw()def open_editor(self, idx):spec = CARD_SPECS[self.current_card_type]if idx == 1 and not spec["two_per_page"]:messagebox.showinfo("提示", "当前证件仅需单张,无需编辑第二张")returnif idx >= len(self.img_list):messagebox.showinfo("提示", "该位置暂无图片")returnsrc_img = self.img_list[idx].copy()pic_name = "图片一" if idx == 0 else "图片二"def done(new_img):self.img_list[idx] = new_imgself.log(f"{pic_name}四点裁切调整完成")self.refresh_two_preview()self.update_swap_btn_visible()FourPointEditor(self.root, src_img, done, self.card_w_mm, self.card_h_mm)def log(self, msg):now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")line = f"[{now_str}] {msg}"self.text_log.insert(tk.END, line + "\n")self.text_log.see(tk.END)self.root.update_idletasks()def clear_temp_files(self):for fname in os.listdir(TEMP_DIR):if fname.startswith(TEMP_PREFIX):try:os.remove(os.path.join(TEMP_DIR, fname))except Exception:passdef process_manual_raw(self):self.img_list.clear()spec = CARD_SPECS[self.current_card_type]self.log(f"===== 当前证件:{self.current_card_type} =====")max_img = 2 if spec["two_per_page"] else 1for path in self.selected_origin_paths[:max_img]:try:pil = Image.open(path).convert("RGB")self.img_list.append(pil)self.log(f"载入原图:{os.path.basename(path)},可点击对应编辑按钮裁切")except Exception as e:self.log(f"读取失败:{path}{str(e)}")self.refresh_two_preview()self.update_swap_btn_visible()def select_files(self):spec = CARD_SPECS[self.current_card_type]max_select = 2 if spec["two_per_page"] else 1tips = f"选择图片(最多{max_select}张)"paths = filedialog.askopenfilenames(title=tips,filetypes=[("图片", "*.jpg *.jpeg *.png *.bmp"), ("所有文件", "*.*")])if not paths:returnself.selected_origin_paths = list(paths[:max_select])self.img_list.clear()self.process_manual_raw()def drop_files(self, event):# 使用tkinterdnd2原生splitlist解析,官方适配{}包裹多路径,杜绝手动分割bugpaths = self.root.splitlist(event.data)spec = CARD_SPECS[self.current_card_type]max_select = 2 if spec["two_per_page"] else 1valid = []for p in paths:p = p.strip()if not os.path.isfile(p):continueext = os.path.splitext(p)[1].lower()if ext in (".jpg", ".jpeg", ".png", ".bmp"):valid.append(p)if not valid:self.log("拖拽内容不含有效图片文件,已忽略")returncontent = self.text_log.get("1.0", tk.END).strip()tip_keyword = "【提示:可直接将图片拖拽到此区域快速导入】\n【处理身份证照片时需同时选中身份证正反面。】\n"if tip_keyword in content:self.text_log.delete("1.0", tk.END)self.selected_origin_paths = valid[:max_select]self.img_list.clear()self.process_manual_raw()def fit_preview_fixed(self, pil_img):if pil_img is None:return Nonew, h = pil_img.sizemax_w = PREVIEW_FIX_WIDTH - 20max_h = PREVIEW_FIX_HEIGHT - 20scale = min(max_w / w, max_h / h)new_w = int(w * scale)new_h = int(h * scale)return pil_img.resize((new_w, new_h), RESAMPLE_LANCZOS)def refresh_two_preview(self):self.label_up.delete("preview_img")self.label_down.delete("preview_img")self.tk_preview1 = Noneself.tk_preview2 = Noneif len(self.img_list) >= 1:fit1 = self.fit_preview_fixed(self.img_list[0])self.tk_preview1 = ImageTk.PhotoImage(fit1)x_off = (PREVIEW_FIX_WIDTH - fit1.width) // 2y_off = (PREVIEW_FIX_HEIGHT - fit1.height) // 2self.label_up.create_image(x_off, y_off, anchor="nw", image=self.tk_preview1, tags="preview_img")if len(self.img_list) >= 2:fit2 = self.fit_preview_fixed(self.img_list[1])self.tk_preview2 = ImageTk.PhotoImage(fit2)x_off = (PREVIEW_FIX_WIDTH - fit2.width) // 2y_off = (PREVIEW_FIX_HEIGHT - fit2.height) // 2self.label_down.create_image(x_off, y_off, anchor="nw", image=self.tk_preview2, tags="preview_img")def layout_a4_canvas(self):a4_canvas = Image.new("RGB", (A4_WIDTH_PX, A4_HEIGHT_PX), "white")if not self.img_list:return a4_canvasspec = CARD_SPECS[self.current_card_type]two_page = spec["two_per_page"]if two_page and len(self.img_list)>=2:total_h = self.card_h_px * 2 + ID_GAP_PXelse:total_h = self.card_h_pxarea_w = A4_WIDTH_PX - MARGIN_PX * 2area_h = A4_HEIGHT_PX - MARGIN_PX * 2base_x = MARGIN_PX + (area_w - self.card_w_px) // 2base_y = MARGIN_PX + (area_h - total_h) // 2img1 = self.img_list[0].resize((self.card_w_px, self.card_h_px), RESAMPLE_LANCZOS)a4_canvas.paste(img1, (base_x, base_y))if two_page and len(self.img_list) == 2:img2 = self.img_list[1].resize((self.card_w_px, self.card_h_px), RESAMPLE_LANCZOS)a4_canvas.paste(img2, (base_x, base_y + self.card_h_px + ID_GAP_PX))return a4_canvasdef save_combine_img(self):if not self.img_list:messagebox.showwarning("提示", "暂无处理完成图片")returnfolder = self.save_path_var.get().strip()filename = self.save_name_var.get().strip()if not folder:messagebox.showwarning("提示", "请先设置保存目录")returnfull_path = os.path.join(folder, f"{filename}.jpg")canvas = self.layout_a4_canvas()canvas.save(full_path, quality=99, subsampling=0)self.log(f"图片已保存:{full_path}")messagebox.showinfo("保存成功", full_path)def save_pdf(self):if not self.img_list:messagebox.showwarning("提示", "暂无处理完成图片")returnfolder = self.save_path_var.get().strip()filename = self.save_name_var.get().strip()if not folder:messagebox.showwarning("提示", "请先设置保存目录")returnfull_path = os.path.join(folder, f"{filename}.pdf")canvas_img = self.layout_a4_canvas()tmp_png = os.path.join(TEMP_DIR, TEMP_PREFIX + "pdf_temp.png")canvas_img.save(tmp_png, quality=99)c = canvas.Canvas(full_path, pagesize=A4)w, h = A4c.drawImage(tmp_png, 0, 0, width=w, height=h)c.save()self.output_pdf_path = full_pathself.log(f"PDF已保存:{full_path}")messagebox.showinfo("保存成功", full_path)def print_file(self):if not self.img_list:messagebox.showwarning("提示", "暂无排版图片,无法打印")returnprinter_name = self.selected_printer.get().strip()if not printer_name:messagebox.showwarning("提示", "请先选择一台打印机")returntry:img = self.layout_a4_canvas()hprinter = win32print.OpenPrinter(printer_name)hdc = win32ui.CreateDC()hdc.CreatePrinterDC(printer_name)hdc.StartDoc("证件排版打印任务")hdc.StartPage()print_dpi_x = hdc.GetDeviceCaps(win32con.LOGPIXELSX)print_dpi_y = hdc.GetDeviceCaps(win32con.LOGPIXELSY)inch_w = 210 / 25.4inch_h = 297 / 25.4dst_w = int(inch_w * print_dpi_x)dst_h = int(inch_h * print_dpi_y)img_print = img.resize((dst_w, dst_h), RESAMPLE_LANCZOS)dib = ImageWin.Dib(img_print)dib.draw(hdc.GetHandleOutput(), (0, 0, dst_w, dst_h))hdc.EndPage()hdc.EndDoc()hdc.DeleteDC()win32print.ClosePrinter(hprinter)self.log(f"打印任务已提交至打印机:{printer_name}")messagebox.showinfo("提交成功", f"图像已发送至【{printer_name}】!")except Exception as e:err_msg = str(e)self.log(f"打印失败:{err_msg}")messagebox.showerror("打印异常", f"失败原因:{err_msg}\n请检查打印机是否在线、驱动是否正常")def clear_all(self):self.selected_origin_paths.clear()self.img_list.clear()self.refresh_two_preview()self.text_log.delete(1.0, tk.END)self.set_log_drag_tip()self.clear_temp_files()self.log("已清空全部图片与日志数据")self.update_swap_btn_visible()# ====================== 程序入口(保留单例互斥锁 + 拖拽窗口创建逻辑) ======================if __name__ == "__main__":mutex = Noneif is_frozen_exe():mutex = ctypes.windll.kernel32.CreateMutexW(None, False, MUTEX_NAME)last_err = ctypes.windll.kernel32.GetLastError()if last_err == 183:ctypes.windll.kernel32.CloseHandle(mutex)ctypes.windll.user32.MessageBoxW(0, "程序已经正在运行,请勿重复打开", "重复启动提示", 0x10)sys.exit(0)# 拖拽库存在则创建TkinterDnD根窗口,否则普通Tkif DND_AVAILABLE:root = TkinterDnD.Tk()else:root = tk.Tk()app = IDCardProcessor(root)root.mainloop()app.clear_temp_files()# 释放互斥内核对象if is_frozen_exe() and mutex is not None:ctypes.windll.kernel32.ReleaseMutex(mutex)ctypes.windll.kernel32.CloseHandle(mutex)
夜雨聆风