import os import json import io import zipfile import csv import tempfile import gradio as gr from PIL import Image, ImageDraw, ImageFont DEFAULT_FONTS = [ "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", "/System/Library/Fonts/PingFang.ttc", "simhei.ttf", "arial.ttf" ] def load_font(font_file, size): if font_file: return ImageFont.truetype(font_file, size) for font_path in DEFAULT_FONTS: try: return ImageFont.truetype(font_path, size) except OSError: continue return ImageFont.load_default() def sanitize_filename(name): safe = "".join([c for c in str(name) if c.isalnum() or c in (" ", "-", "_")]).strip() return safe or "certificate" def parse_rows_from_inputs(data_str, csv_bytes): if csv_bytes: content = csv_bytes.decode("utf-8", errors="ignore") reader = csv.DictReader(io.StringIO(content)) return [row for row in reader] if data_str: try: data = json.loads(data_str or "[]") return data if isinstance(data, list) else [] except json.JSONDecodeError: return [] return [] def wrap_text(text, font, max_width, draw): if not max_width or max_width <= 0: return text lines = [] current = "" for ch in text: trial = current + ch w = draw.textlength(trial, font=font) if w <= max_width or not current: current = trial else: lines.append(current) current = ch if current: lines.append(current) return "\n".join(lines) def generate_certificates_gradio( bg_image, font_bytes, data_csv_bytes, data_json_str, config_str, filename_tpl, export_scale, logo_image, logo_x, logo_y, logo_scale, ): if bg_image is None: raise gr.Error("请先上传背景图片") try: config = json.loads(config_str or "[]") except json.JSONDecodeError: raise gr.Error("字段配置 JSON 无效,请检查格式") data_list = parse_rows_from_inputs(data_json_str, data_csv_bytes) if not data_list: raise gr.Error("没有有效的数据行,请检查 CSV 或 JSON 输入") try: export_scale_val = float(export_scale or 1) except ValueError: export_scale_val = 1 if export_scale_val <= 0: export_scale_val = 1 try: logo_x_val = int(logo_x or 0) logo_y_val = int(logo_y or 0) logo_scale_val = float(logo_scale or 0) except ValueError: logo_x_val = 0 logo_y_val = 0 logo_scale_val = 0 zip_buffer = io.BytesIO() with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file: base_image = bg_image.convert("RGB") if export_scale_val != 1: w, h = base_image.size base_image = base_image.resize( (int(w * export_scale_val), int(h * export_scale_val)), Image.LANCZOS, ) font_stream = None if font_bytes: font_stream = io.BytesIO(font_bytes) for idx, row in enumerate(data_list): img = base_image.copy() draw = ImageDraw.Draw(img) for field in config: text_tpl = field.get("template", "") x = int(field.get("x", 0)) * export_scale_val y = int(field.get("y", 0)) * export_scale_val size = int(field.get("size", 30)) * export_scale_val color = field.get("color", "#000000") align = field.get("align", "left") stroke_width = int(field.get("stroke_width", 0)) stroke_color = field.get("stroke_color", None) shadow_dx = int(field.get("shadow_dx", 0)) shadow_dy = int(field.get("shadow_dy", 0)) shadow_color = field.get("shadow_color", None) max_width = int(field.get("max_width", 0)) * export_scale_val try: text = text_tpl.format(**row) except Exception: text = text_tpl if font_stream: font_stream.seek(0) font = ImageFont.truetype(font_stream, int(size)) else: font = load_font(None, int(size)) anchor = "la" if align == "center": anchor = "ma" elif align == "right": anchor = "ra" text_wrapped = wrap_text(text, font, max_width, draw) if shadow_color and (shadow_dx or shadow_dy): draw.text( (x + shadow_dx, y + shadow_dy), text_wrapped, fill=shadow_color, font=font, anchor=anchor, stroke_width=stroke_width if stroke_color else 0, stroke_fill=stroke_color or None, ) draw.text( (x, y), text_wrapped, fill=color, font=font, anchor=anchor, stroke_width=stroke_width if stroke_color else 0, stroke_fill=stroke_color or None, ) if logo_image is not None and logo_scale_val > 0: logo = logo_image.convert("RGBA") lw, lh = logo.size target_w = max(1, int(lw * logo_scale_val)) target_h = max(1, int(lh * logo_scale_val)) logo_resized = logo.resize((target_w, target_h), Image.LANCZOS) img.paste( logo_resized, (int(logo_x_val * export_scale_val), int(logo_y_val * export_scale_val)), logo_resized, ) out = io.BytesIO() img.save(out, format="PNG") try: name = (filename_tpl or "{index}.png").format(index=idx + 1, **row) except Exception: name = f"certificate_{idx + 1}.png" name = sanitize_filename(os.path.splitext(name)[0]) + ".png" zip_file.writestr(name, out.getvalue()) zip_buffer.seek(0) tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".zip") tmp.write(zip_buffer.getvalue()) tmp.flush() tmp.close() return tmp.name default_config = json.dumps( [ { "template": "{name}", "x": 800, "y": 600, "size": 60, "color": "#000000", "align": "center", "stroke_width": 0, "stroke_color": "#000000", "shadow_dx": 0, "shadow_dy": 0, "shadow_color": "#000000", "max_width": 0, } ], ensure_ascii=False, indent=2, ) with gr.Blocks(title="证书批量生成大师 | Certificate Master") as demo: gr.Markdown("# 🎓 证书批量生成大师\n批量生成带个性化信息的证书图片,并打包为 ZIP 下载。") with gr.Row(): with gr.Column(): bg_image = gr.Image(label="背景图片(必选)", type="pil") font_file = gr.File(label="字体文件(可选,ttf/otf)", type="bytes") with gr.Row(): data_csv = gr.File(label="学员数据 CSV(可选)", type="bytes") data_json = gr.Textbox( label="或 JSON 数据(可选)", lines=4, placeholder='[{"name": "张三", "course": "Python 进阶", "date": "2024-01-01"}]', ) config_box = gr.Textbox( label="字段配置 JSON(高级)", value=default_config, lines=10, ) filename_tpl = gr.Textbox( label="文件名模板", value="{name}.png", placeholder="{name}.png 或 certificate_{index}.png", ) export_scale = gr.Number( label="导出缩放倍数", value=1, ) with gr.Row(): logo_image = gr.Image(label="徽章 / Logo(可选)", type="pil") with gr.Row(): logo_x = gr.Number(label="Logo X 坐标", value=0) logo_y = gr.Number(label="Logo Y 坐标", value=0) logo_scale = gr.Number(label="Logo 缩放倍数", value=0.0) generate_button = gr.Button("生成并下载 ZIP", variant="primary") with gr.Column(): result_zip = gr.File(label="生成结果 ZIP") gr.Markdown( "提示:\n" "- CSV 第一行应为表头,如 name,course,date\n" "- 字段配置 JSON 中的 template 支持使用 {name}、{course} 等占位符\n" "- 坐标和字号单位与背景图像像素一致" ) generate_button.click( fn=generate_certificates_gradio, inputs=[ bg_image, font_file, data_csv, data_json, config_box, filename_tpl, export_scale, logo_image, logo_x, logo_y, logo_scale, ], outputs=result_zip, ) if __name__ == "__main__": demo.launch(server_name="0.0.0.0", server_port=7860)