Trae Assistant commited on
Commit
1310213
·
1 Parent(s): e53f24a

修改部署方式

Browse files
Files changed (3) hide show
  1. README.md +2 -3
  2. app.py +236 -120
  3. requirements.txt +1 -2
README.md CHANGED
@@ -3,11 +3,10 @@ title: Certificate Master
3
  emoji: 🎓
4
  colorFrom: indigo
5
  colorTo: blue
6
- sdk: docker
7
  pinned: false
8
  license: mit
9
- short_description: 批量证书生成大师 - 可视化拖拽设计
10
- app_port: 7860
11
  ---
12
 
13
  # 🎓 证书批量生成大师 (Certificate Master)
 
3
  emoji: 🎓
4
  colorFrom: indigo
5
  colorTo: blue
6
+ sdk: gradio
7
  pinned: false
8
  license: mit
9
+ short_description: 批量证书生成大师 - Gradio 版本
 
10
  ---
11
 
12
  # 🎓 证书批量生成大师 (Certificate Master)
app.py CHANGED
@@ -3,12 +3,10 @@ import json
3
  import io
4
  import zipfile
5
  import csv
6
- from flask import Flask, render_template, request, send_file, jsonify
7
- from PIL import Image, ImageDraw, ImageFont
8
 
9
- app = Flask(__name__)
10
- app.config["MAX_CONTENT_LENGTH"] = 32 * 1024 * 1024
11
- app.config["JSON_AS_ASCII"] = False
12
 
13
  DEFAULT_FONTS = [
14
  "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
@@ -32,16 +30,18 @@ def sanitize_filename(name):
32
  safe = "".join([c for c in str(name) if c.isalnum() or c in (" ", "-", "_")]).strip()
33
  return safe or "certificate"
34
 
35
- def parse_rows(data_str, data_file):
36
- if data_file:
37
- content = data_file.read().decode("utf-8", errors="ignore")
38
  reader = csv.DictReader(io.StringIO(content))
39
  return [row for row in reader]
40
- try:
41
- data = json.loads(data_str or "[]")
42
- return data if isinstance(data, list) else []
43
- except json.JSONDecodeError:
44
- return []
 
 
45
 
46
  def wrap_text(text, font, max_width, draw):
47
  if not max_width or max_width <= 0:
@@ -60,121 +60,237 @@ def wrap_text(text, font, max_width, draw):
60
  lines.append(current)
61
  return "\n".join(lines)
62
 
63
- @app.route("/")
64
- def index():
65
- return render_template("index.html")
 
 
 
 
 
 
 
 
 
 
 
 
66
 
67
- @app.route("/api/health")
68
- def health():
69
  try:
70
- return jsonify({"status": "ok"}), 200
71
- except Exception as e:
72
- return jsonify({"status": "error", "message": str(e)}), 500
 
 
 
 
73
 
74
- @app.route("/api/generate", methods=["POST"])
75
- def generate_certificates():
76
  try:
77
- if "background" not in request.files:
78
- return jsonify({"error": "No background image provided"}), 400
79
- bg_file = request.files["background"]
80
- font_file = request.files.get("font")
81
- data_file = request.files.get("data_file")
82
- logo_file = request.files.get("logo")
83
-
84
- config_str = request.form.get("config", "[]")
85
- data_str = request.form.get("data", "[]")
86
- filename_tpl = request.form.get("filename_template", "{index}.png")
87
- export_scale = float(request.form.get("export_scale", "1"))
88
- if export_scale <= 0:
89
- export_scale = 1
90
- logo_x = int(float(request.form.get("logo_x", "0")))
91
- logo_y = int(float(request.form.get("logo_y", "0")))
92
- logo_scale = float(request.form.get("logo_scale", "0"))
93
 
94
- try:
95
- config = json.loads(config_str)
96
- except json.JSONDecodeError:
97
- return jsonify({"error": "Invalid JSON in config"}), 400
98
-
99
- data_list = parse_rows(data_str, data_file)
100
- if not data_list:
101
- return jsonify({"error": "No data rows provided"}), 400
102
-
103
- zip_buffer = io.BytesIO()
104
- with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
105
- base_image = Image.open(bg_file).convert("RGB")
106
- if export_scale != 1:
107
- w, h = base_image.size
108
- base_image = base_image.resize((int(w * export_scale), int(h * export_scale)), Image.LANCZOS)
109
-
110
- font_bytes = None
111
- if font_file:
112
- font_bytes = io.BytesIO(font_file.read())
113
-
114
- for idx, row in enumerate(data_list):
115
- img = base_image.copy()
116
- draw = ImageDraw.Draw(img)
117
- for field in config:
118
- text_tpl = field.get("template", "")
119
- x = int(field.get("x", 0)) * export_scale
120
- y = int(field.get("y", 0)) * export_scale
121
- size = int(field.get("size", 30)) * export_scale
122
- color = field.get("color", "#000000")
123
- align = field.get("align", "left")
124
- stroke_width = int(field.get("stroke_width", 0))
125
- stroke_color = field.get("stroke_color", None)
126
- shadow_dx = int(field.get("shadow_dx", 0))
127
- shadow_dy = int(field.get("shadow_dy", 0))
128
- shadow_color = field.get("shadow_color", None)
129
- max_width = int(field.get("max_width", 0)) * export_scale
130
-
131
- try:
132
- text = text_tpl.format(**row)
133
- except Exception:
134
- text = text_tpl
135
-
136
- if font_bytes:
137
- font_bytes.seek(0)
138
- font = ImageFont.truetype(font_bytes, int(size))
139
- else:
140
- font = load_font(None, int(size))
141
-
142
- anchor = "la"
143
- if align == "center":
144
- anchor = "ma"
145
- elif align == "right":
146
- anchor = "ra"
147
-
148
- text_wrapped = wrap_text(text, font, max_width, draw)
149
- if shadow_color and (shadow_dx or shadow_dy):
150
- draw.text((x + shadow_dx, y + shadow_dy), text_wrapped, fill=shadow_color, font=font, anchor=anchor,
151
- stroke_width=stroke_width if stroke_color else 0, stroke_fill=stroke_color or None)
152
- draw.text((x, y), text_wrapped, fill=color, font=font, anchor=anchor,
153
- stroke_width=stroke_width if stroke_color else 0, stroke_fill=stroke_color or None)
154
-
155
- if logo_file and logo_scale > 0:
156
- logo_file.stream.seek(0)
157
- logo = Image.open(logo_file).convert("RGBA")
158
- lw, lh = logo.size
159
- target_w = max(1, int(lw * logo_scale))
160
- target_h = max(1, int(lh * logo_scale))
161
- logo_resized = logo.resize((target_w, target_h), Image.LANCZOS)
162
- img.paste(logo_resized, (int(logo_x * export_scale), int(logo_y * export_scale)), logo_resized)
163
-
164
- out = io.BytesIO()
165
- img.save(out, format="PNG")
166
 
167
  try:
168
- name = filename_tpl.format(index=idx + 1, **row)
169
  except Exception:
170
- name = f"certificate_{idx + 1}.png"
171
- name = sanitize_filename(os.path.splitext(name)[0]) + ".png"
172
- zip_file.writestr(name, out.getvalue())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
 
174
- zip_buffer.seek(0)
175
- return send_file(zip_buffer, mimetype="application/zip", as_attachment=True, download_name="certificates.zip")
176
- except Exception as e:
177
- return jsonify({"error": str(e)}), 500
178
 
179
  if __name__ == "__main__":
180
- app.run(host="0.0.0.0", port=7860, debug=False)
 
3
  import io
4
  import zipfile
5
  import csv
6
+ import tempfile
 
7
 
8
+ import gradio as gr
9
+ from PIL import Image, ImageDraw, ImageFont
 
10
 
11
  DEFAULT_FONTS = [
12
  "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
 
30
  safe = "".join([c for c in str(name) if c.isalnum() or c in (" ", "-", "_")]).strip()
31
  return safe or "certificate"
32
 
33
+ def parse_rows_from_inputs(data_str, csv_bytes):
34
+ if csv_bytes:
35
+ content = csv_bytes.decode("utf-8", errors="ignore")
36
  reader = csv.DictReader(io.StringIO(content))
37
  return [row for row in reader]
38
+ if data_str:
39
+ try:
40
+ data = json.loads(data_str or "[]")
41
+ return data if isinstance(data, list) else []
42
+ except json.JSONDecodeError:
43
+ return []
44
+ return []
45
 
46
  def wrap_text(text, font, max_width, draw):
47
  if not max_width or max_width <= 0:
 
60
  lines.append(current)
61
  return "\n".join(lines)
62
 
63
+ def generate_certificates_gradio(
64
+ bg_image,
65
+ font_bytes,
66
+ data_csv_bytes,
67
+ data_json_str,
68
+ config_str,
69
+ filename_tpl,
70
+ export_scale,
71
+ logo_image,
72
+ logo_x,
73
+ logo_y,
74
+ logo_scale,
75
+ ):
76
+ if bg_image is None:
77
+ raise gr.Error("请先上传背景图片")
78
 
 
 
79
  try:
80
+ config = json.loads(config_str or "[]")
81
+ except json.JSONDecodeError:
82
+ raise gr.Error("字段配置 JSON 无效,请检查格式")
83
+
84
+ data_list = parse_rows_from_inputs(data_json_str, data_csv_bytes)
85
+ if not data_list:
86
+ raise gr.Error("没有有效的数据行,请检查 CSV 或 JSON 输入")
87
 
 
 
88
  try:
89
+ export_scale_val = float(export_scale or 1)
90
+ except ValueError:
91
+ export_scale_val = 1
92
+ if export_scale_val <= 0:
93
+ export_scale_val = 1
 
 
 
 
 
 
 
 
 
 
 
94
 
95
+ try:
96
+ logo_x_val = int(logo_x or 0)
97
+ logo_y_val = int(logo_y or 0)
98
+ logo_scale_val = float(logo_scale or 0)
99
+ except ValueError:
100
+ logo_x_val = 0
101
+ logo_y_val = 0
102
+ logo_scale_val = 0
103
+
104
+ zip_buffer = io.BytesIO()
105
+ with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
106
+ base_image = bg_image.convert("RGB")
107
+ if export_scale_val != 1:
108
+ w, h = base_image.size
109
+ base_image = base_image.resize(
110
+ (int(w * export_scale_val), int(h * export_scale_val)),
111
+ Image.LANCZOS,
112
+ )
113
+
114
+ font_stream = None
115
+ if font_bytes:
116
+ font_stream = io.BytesIO(font_bytes)
117
+
118
+ for idx, row in enumerate(data_list):
119
+ img = base_image.copy()
120
+ draw = ImageDraw.Draw(img)
121
+ for field in config:
122
+ text_tpl = field.get("template", "")
123
+ x = int(field.get("x", 0)) * export_scale_val
124
+ y = int(field.get("y", 0)) * export_scale_val
125
+ size = int(field.get("size", 30)) * export_scale_val
126
+ color = field.get("color", "#000000")
127
+ align = field.get("align", "left")
128
+ stroke_width = int(field.get("stroke_width", 0))
129
+ stroke_color = field.get("stroke_color", None)
130
+ shadow_dx = int(field.get("shadow_dx", 0))
131
+ shadow_dy = int(field.get("shadow_dy", 0))
132
+ shadow_color = field.get("shadow_color", None)
133
+ max_width = int(field.get("max_width", 0)) * export_scale_val
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
 
135
  try:
136
+ text = text_tpl.format(**row)
137
  except Exception:
138
+ text = text_tpl
139
+
140
+ if font_stream:
141
+ font_stream.seek(0)
142
+ font = ImageFont.truetype(font_stream, int(size))
143
+ else:
144
+ font = load_font(None, int(size))
145
+
146
+ anchor = "la"
147
+ if align == "center":
148
+ anchor = "ma"
149
+ elif align == "right":
150
+ anchor = "ra"
151
+
152
+ text_wrapped = wrap_text(text, font, max_width, draw)
153
+ if shadow_color and (shadow_dx or shadow_dy):
154
+ draw.text(
155
+ (x + shadow_dx, y + shadow_dy),
156
+ text_wrapped,
157
+ fill=shadow_color,
158
+ font=font,
159
+ anchor=anchor,
160
+ stroke_width=stroke_width if stroke_color else 0,
161
+ stroke_fill=stroke_color or None,
162
+ )
163
+ draw.text(
164
+ (x, y),
165
+ text_wrapped,
166
+ fill=color,
167
+ font=font,
168
+ anchor=anchor,
169
+ stroke_width=stroke_width if stroke_color else 0,
170
+ stroke_fill=stroke_color or None,
171
+ )
172
+
173
+ if logo_image is not None and logo_scale_val > 0:
174
+ logo = logo_image.convert("RGBA")
175
+ lw, lh = logo.size
176
+ target_w = max(1, int(lw * logo_scale_val))
177
+ target_h = max(1, int(lh * logo_scale_val))
178
+ logo_resized = logo.resize((target_w, target_h), Image.LANCZOS)
179
+ img.paste(
180
+ logo_resized,
181
+ (int(logo_x_val * export_scale_val), int(logo_y_val * export_scale_val)),
182
+ logo_resized,
183
+ )
184
+
185
+ out = io.BytesIO()
186
+ img.save(out, format="PNG")
187
+
188
+ try:
189
+ name = (filename_tpl or "{index}.png").format(index=idx + 1, **row)
190
+ except Exception:
191
+ name = f"certificate_{idx + 1}.png"
192
+ name = sanitize_filename(os.path.splitext(name)[0]) + ".png"
193
+ zip_file.writestr(name, out.getvalue())
194
+
195
+ zip_buffer.seek(0)
196
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".zip")
197
+ tmp.write(zip_buffer.getvalue())
198
+ tmp.flush()
199
+ tmp.close()
200
+ return tmp.name
201
+
202
+
203
+ default_config = json.dumps(
204
+ [
205
+ {
206
+ "template": "{name}",
207
+ "x": 800,
208
+ "y": 600,
209
+ "size": 60,
210
+ "color": "#000000",
211
+ "align": "center",
212
+ "stroke_width": 0,
213
+ "stroke_color": "#000000",
214
+ "shadow_dx": 0,
215
+ "shadow_dy": 0,
216
+ "shadow_color": "#000000",
217
+ "max_width": 0,
218
+ }
219
+ ],
220
+ ensure_ascii=False,
221
+ indent=2,
222
+ )
223
+
224
+
225
+ with gr.Blocks(title="证书批量生成大师 | Certificate Master") as demo:
226
+ gr.Markdown("# 🎓 证书批量生成大师\n批量生成带个性化信息的证书图片,并打包为 ZIP 下载。")
227
+
228
+ with gr.Row():
229
+ with gr.Column():
230
+ bg_image = gr.Image(label="背景图片(必选)", type="pil")
231
+ font_file = gr.File(label="字体文件(可选,ttf/otf)", type="bytes")
232
+
233
+ with gr.Row():
234
+ data_csv = gr.File(label="学员数据 CSV(可选)", type="bytes")
235
+ data_json = gr.Textbox(
236
+ label="或 JSON 数据(可选)",
237
+ lines=4,
238
+ placeholder='[{"name": "张三", "course": "Python 进阶", "date": "2024-01-01"}]',
239
+ )
240
+
241
+ config_box = gr.Textbox(
242
+ label="字段配置 JSON(高级)",
243
+ value=default_config,
244
+ lines=10,
245
+ )
246
+
247
+ filename_tpl = gr.Textbox(
248
+ label="文件名模板",
249
+ value="{name}.png",
250
+ placeholder="{name}.png 或 certificate_{index}.png",
251
+ )
252
+
253
+ export_scale = gr.Number(
254
+ label="导出缩放倍数",
255
+ value=1,
256
+ )
257
+
258
+ with gr.Row():
259
+ logo_image = gr.Image(label="徽章 / Logo(可选)", type="pil")
260
+ with gr.Row():
261
+ logo_x = gr.Number(label="Logo X 坐标", value=0)
262
+ logo_y = gr.Number(label="Logo Y 坐标", value=0)
263
+ logo_scale = gr.Number(label="Logo 缩放倍数", value=0.0)
264
+
265
+ generate_button = gr.Button("生成并下载 ZIP", variant="primary")
266
+
267
+ with gr.Column():
268
+ result_zip = gr.File(label="生成结果 ZIP")
269
+ gr.Markdown(
270
+ "提示:\n"
271
+ "- CSV 第一行应为表头,如 name,course,date\n"
272
+ "- 字段配置 JSON 中的 template 支持使用 {name}、{course} 等占位符\n"
273
+ "- 坐标和字号单位与背景图像像素一致"
274
+ )
275
+
276
+ generate_button.click(
277
+ fn=generate_certificates_gradio,
278
+ inputs=[
279
+ bg_image,
280
+ font_file,
281
+ data_csv,
282
+ data_json,
283
+ config_box,
284
+ filename_tpl,
285
+ export_scale,
286
+ logo_image,
287
+ logo_x,
288
+ logo_y,
289
+ logo_scale,
290
+ ],
291
+ outputs=result_zip,
292
+ )
293
 
 
 
 
 
294
 
295
  if __name__ == "__main__":
296
+ demo.launch(server_name="0.0.0.0", server_port=7860)
requirements.txt CHANGED
@@ -1,3 +1,2 @@
1
- Flask>=3.0.0
2
  Pillow>=10.0.0
3
- gunicorn>=21.2.0
 
 
1
  Pillow>=10.0.0
2
+ gradio>=4.0.0