Spaces:
Sleeping
Sleeping
| 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) | |