优化证书生成与正式部署配置
This commit is contained in:
@@ -31,6 +31,9 @@ CAPTCHAS: dict[str, tuple[str, float]] = {}
|
||||
SMS_CODES: dict[str, tuple[str, float]] = {}
|
||||
LOG_RETENTION_DAYS = 180
|
||||
HIGH_RISK_LOG_RETENTION_DAYS = 365
|
||||
DESIGN_WIDTH = 1024
|
||||
DESIGN_HEIGHT = 759
|
||||
PDF_BASE_RESOLUTION = 150.0
|
||||
|
||||
COL_NAME = "\u59d3\u540d"
|
||||
COL_PHONE = "\u624b\u673a\u53f7"
|
||||
@@ -440,38 +443,51 @@ def font_name() -> str:
|
||||
return "Helvetica"
|
||||
|
||||
|
||||
def pil_font(size: int, kind: str = "song", bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||||
def pil_font(size: int, kind: str = "song", bold: bool = False, scale: float = 1.0) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||||
choices = {
|
||||
"kai": [
|
||||
r"C:\Windows\Fonts\simkai.ttf", r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simsun.ttc",
|
||||
"/usr/share/fonts/opentype/noto/NotoSerifCJK-Regular.ttc",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/System/Library/Fonts/Supplemental/Kaiti.ttc",
|
||||
("/System/Library/Fonts/Hiragino Sans GB.ttc", 2),
|
||||
"/System/Library/Fonts/PingFang.ttc",
|
||||
"/System/Library/Fonts/Supplemental/Songti.ttc",
|
||||
("/System/Library/Fonts/Supplemental/Songti.ttc", 1),
|
||||
],
|
||||
"hei": [
|
||||
r"C:\Windows\Fonts\simhei.ttf", r"C:\Windows\Fonts\msyhbd.ttc", r"C:\Windows\Fonts\msyh.ttc",
|
||||
"/System/Library/Fonts/STHeiti Medium.ttc",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
("/System/Library/Fonts/STHeiti Medium.ttc", 1),
|
||||
("/System/Library/Fonts/Hiragino Sans GB.ttc", 2),
|
||||
"/System/Library/Fonts/Supplemental/Heiti.ttc",
|
||||
"/System/Library/Fonts/PingFang.ttc",
|
||||
],
|
||||
"song": [
|
||||
r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simsun.ttc",
|
||||
"/System/Library/Fonts/Supplemental/Songti.ttc",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/opentype/noto/NotoSerifCJK-Regular.ttc",
|
||||
("/System/Library/Fonts/Supplemental/Songti.ttc", 3),
|
||||
("/System/Library/Fonts/Supplemental/Songti.ttc", 6),
|
||||
("/System/Library/Fonts/Hiragino Sans GB.ttc", 0),
|
||||
"/System/Library/Fonts/PingFang.ttc",
|
||||
"/System/Library/Fonts/STHeiti Light.ttc",
|
||||
("/System/Library/Fonts/STHeiti Light.ttc", 1),
|
||||
],
|
||||
}
|
||||
paths = choices.get(kind, choices["song"])
|
||||
if bold and kind == "song":
|
||||
paths = [
|
||||
r"C:\Windows\Fonts\msyhbd.ttc", r"C:\Windows\Fonts\simhei.ttf",
|
||||
"/System/Library/Fonts/STHeiti Medium.ttc",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
|
||||
("/System/Library/Fonts/STHeiti Medium.ttc", 1),
|
||||
("/System/Library/Fonts/Hiragino Sans GB.ttc", 2),
|
||||
"/System/Library/Fonts/Supplemental/Heiti.ttc",
|
||||
] + paths
|
||||
for item in paths:
|
||||
if Path(item).exists():
|
||||
path, index = item if isinstance(item, tuple) else (item, 0)
|
||||
if Path(path).exists():
|
||||
try:
|
||||
return ImageFont.truetype(item, size)
|
||||
return ImageFont.truetype(path, max(1, round(size * scale)), index=index)
|
||||
except Exception:
|
||||
pass
|
||||
return ImageFont.load_default()
|
||||
@@ -500,12 +516,23 @@ def draw_fit(draw: ImageDraw.ImageDraw, pos: tuple[int, int], text: str, font: I
|
||||
draw.text(pos, text, fill=fill, font=font_obj)
|
||||
|
||||
|
||||
def draw_inline(draw: ImageDraw.ImageDraw, x: float, y: int, text: str, size: int, gap: int, kind: str = "song", bold: bool = False) -> float:
|
||||
font = pil_font(size, kind, bold)
|
||||
def draw_inline(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
x: float,
|
||||
y: float,
|
||||
text: str,
|
||||
size: int,
|
||||
gap: int,
|
||||
kind: str = "song",
|
||||
bold: bool = False,
|
||||
scale: float = 1.0,
|
||||
x_scale: float = 1.0,
|
||||
) -> float:
|
||||
font = pil_font(size, kind, bold, scale)
|
||||
draw.text((x, y), text, fill="#111111", font=font, anchor="ls")
|
||||
if bold:
|
||||
draw.text((x + 0.45, y), text, fill="#111111", font=font, anchor="ls")
|
||||
return x + draw.textlength(text, font=font) + gap
|
||||
draw.text((x + 0.45 * x_scale, y), text, fill="#111111", font=font, anchor="ls")
|
||||
return x + draw.textlength(text, font=font) + gap * x_scale
|
||||
|
||||
|
||||
def draw_course_name(draw: ImageDraw.ImageDraw, text: str, x: float, y: int) -> int:
|
||||
@@ -525,23 +552,31 @@ def draw_course_name(draw: ImageDraw.ImageDraw, text: str, x: float, y: int) ->
|
||||
return start_y + (len(lines) - 1) * 34
|
||||
|
||||
|
||||
def draw_inline_flow(draw: ImageDraw.ImageDraw, segments: list[tuple[str, str, int]], start_x: float, start_y: int) -> None:
|
||||
def draw_inline_flow(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
segments: list[tuple[str, str, int]],
|
||||
start_x: float,
|
||||
start_y: float,
|
||||
scale: float,
|
||||
x_scale: float,
|
||||
y_scale: float,
|
||||
) -> None:
|
||||
x = start_x
|
||||
y = start_y
|
||||
line_start = 185
|
||||
line_height = 42
|
||||
line_start = 185 * x_scale
|
||||
line_height = 42 * y_scale
|
||||
for text, style, gap_before in segments:
|
||||
x += gap_before
|
||||
text_font = pil_font(27, "kai", True) if style == "course" else pil_font(24, "song", False)
|
||||
x += gap_before * x_scale
|
||||
text_font = pil_font(27, "kai", True, scale) if style == "course" else pil_font(24, "song", False, scale)
|
||||
for char in text:
|
||||
max_right = 900 if y == start_y else 730
|
||||
max_right = (900 if y == start_y else 730) * x_scale
|
||||
width = draw.textlength(char, font=text_font)
|
||||
if x + width > max_right and x > line_start:
|
||||
x = line_start
|
||||
y += line_height
|
||||
draw.text((x, y), char, fill="#111111", font=text_font, anchor="ls")
|
||||
if style == "course":
|
||||
draw.text((x + 0.45, y), char, fill="#111111", font=text_font, anchor="ls")
|
||||
draw.text((x + 0.45 * x_scale, y), char, fill="#111111", font=text_font, anchor="ls")
|
||||
x += width
|
||||
|
||||
|
||||
@@ -560,55 +595,119 @@ def split_by_width(draw: ImageDraw.ImageDraw, text: str, text_font: ImageFont.Im
|
||||
return lines
|
||||
|
||||
|
||||
def paper_patch(image: Image.Image, box: tuple[int, int, int, int]) -> None:
|
||||
def scaled_box(box: tuple[int, int, int, int], x_scale: float, y_scale: float) -> tuple[int, int, int, int]:
|
||||
x, y, w, h = box
|
||||
return round(x * x_scale), round(y * y_scale), round(w * x_scale), round(h * y_scale)
|
||||
|
||||
|
||||
def to_xyxy(box: tuple[int, int, int, int]) -> tuple[int, int, int, int]:
|
||||
x, y, w, h = box
|
||||
return x, y, x + w, y + h
|
||||
|
||||
|
||||
def paper_patch(image: Image.Image, box: tuple[int, int, int, int], x_scale: float, y_scale: float) -> None:
|
||||
x, y, w, h = scaled_box(box, x_scale, y_scale)
|
||||
patch = Image.new("RGB", (w, h), "#fbf7ef")
|
||||
source = image.crop((72, max(0, y), 160, max(0, y) + h))
|
||||
source = image.crop((round(72 * x_scale), y, round(160 * x_scale), y + h))
|
||||
for tx in range(0, w, source.width):
|
||||
patch.paste(source.crop((0, 0, min(source.width, w - tx), h)), (tx, 0))
|
||||
cover = Image.new("RGB", (w, h), "#fbf7ef")
|
||||
patch = Image.blend(patch, cover, 0.38)
|
||||
image.paste(patch, (x, y))
|
||||
image.paste(patch, (x, y), soft_edge_mask(w, h, round(16 * x_scale), round(10 * y_scale)))
|
||||
|
||||
|
||||
def soft_edge_mask(width: int, height: int, feather_x: int, feather_y: int) -> Image.Image:
|
||||
feather_x = max(1, feather_x)
|
||||
feather_y = max(1, feather_y)
|
||||
mask = Image.new("L", (width, height), 255)
|
||||
pixels = mask.load()
|
||||
for y in range(height):
|
||||
vertical = min(1.0, y / feather_y, (height - 1 - y) / feather_y)
|
||||
for x in range(width):
|
||||
horizontal = min(1.0, x / feather_x, (width - 1 - x) / feather_x)
|
||||
pixels[x, y] = max(0, min(255, round(255 * min(horizontal, vertical))))
|
||||
return mask
|
||||
|
||||
|
||||
def draw_issue_date_numbers(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
issue_year: str,
|
||||
issue_month: str,
|
||||
issue_day: str,
|
||||
scale: float,
|
||||
x_scale: float,
|
||||
y_scale: float,
|
||||
) -> None:
|
||||
text_font = pil_font(13, "song", True, scale)
|
||||
y = 688 * y_scale
|
||||
for text, x in [(issue_year, 500), (issue_month, 535), (issue_day, 566)]:
|
||||
draw.text((x * x_scale, y), text, fill="#111111", font=text_font, anchor="mm")
|
||||
|
||||
|
||||
def certificate_template_path() -> Path:
|
||||
assets_dir = ROOT / "static" / "assets"
|
||||
for name in ["certificate-template.png", "certificate-template.jpg"]:
|
||||
path = assets_dir / name
|
||||
if path.exists():
|
||||
return path
|
||||
raise FileNotFoundError("Certificate template image not found")
|
||||
|
||||
|
||||
def pdf_resolution(image: Image.Image) -> float:
|
||||
return PDF_BASE_RESOLUTION * (image.width / DESIGN_WIDTH)
|
||||
|
||||
|
||||
def render_certificate_image(conn: sqlite3.Connection, cert: sqlite3.Row) -> Image.Image:
|
||||
template = ROOT / "static" / "assets" / "certificate-template.jpg"
|
||||
template = certificate_template_path()
|
||||
image = Image.open(template).convert("RGB")
|
||||
base_image = image.copy()
|
||||
draw = ImageDraw.Draw(image)
|
||||
x_scale = image.width / DESIGN_WIDTH
|
||||
y_scale = image.height / DESIGN_HEIGHT
|
||||
text_scale = (x_scale + y_scale) / 2
|
||||
learner = conn.execute("select * from learners where id=?", (cert["learner_id"],)).fetchone()
|
||||
learner_name = (learner["current_name"] if learner else "") or ""
|
||||
issue_year, issue_month, issue_day = date_parts(cert["issue_date"])
|
||||
|
||||
for box in [(150, 442, 760, 132), (404, 675, 220, 38)]:
|
||||
paper_patch(image, box)
|
||||
paper_patch(image, (150, 455, 760, 119), x_scale, y_scale)
|
||||
|
||||
draw.text((512, 414), learner_name, fill="#111111", font=pil_font(32, "kai", True), anchor="mm")
|
||||
draw.text(
|
||||
(512 * x_scale, 414 * y_scale),
|
||||
learner_name,
|
||||
fill="#111111",
|
||||
font=pil_font(32, "kai", True, text_scale),
|
||||
anchor="mm",
|
||||
)
|
||||
|
||||
x = 185.0
|
||||
y = 494
|
||||
x = draw_inline(draw, x, y, "\u5728", 24, 18)
|
||||
x = draw_inline(draw, x, y, issue_year, 24, 8, "kai", True)
|
||||
x = draw_inline(draw, x, y, "\u5e74", 24, 12)
|
||||
x = draw_inline(draw, x, y, issue_month, 24, 6, "kai", True)
|
||||
x = draw_inline(draw, x, y, "\u6708", 24, 12)
|
||||
x = draw_inline(draw, x, y, issue_day, 24, 4, "kai", True)
|
||||
x = draw_inline(draw, x, y, "\u65e5\u81f3", 24, 12)
|
||||
x = draw_inline(draw, x, y, issue_year, 24, 8, "kai", True)
|
||||
x = draw_inline(draw, x, y, "\u5e74", 24, 12)
|
||||
x = draw_inline(draw, x, y, issue_month, 24, 6, "kai", True)
|
||||
x = draw_inline(draw, x, y, "\u6708", 24, 12)
|
||||
x = draw_inline(draw, x, y, issue_day, 24, 4, "kai", True)
|
||||
x = draw_inline(draw, x, y, "\u65e5\u5b8c\u6210\u4e86", 24, 8)
|
||||
x = 185.0 * x_scale
|
||||
y = 494 * y_scale
|
||||
x = draw_inline(draw, x, y, "\u5728", 24, 18, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, issue_year, 24, 8, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, "\u5e74", 24, 12, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, issue_month, 24, 6, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, "\u6708", 24, 12, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, issue_day, 24, 4, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, "\u65e5\u81f3", 24, 12, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, issue_year, 24, 8, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, "\u5e74", 24, 12, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, issue_month, 24, 6, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, "\u6708", 24, 12, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, issue_day, 24, 4, scale=text_scale, x_scale=x_scale)
|
||||
x = draw_inline(draw, x, y, "\u65e5\u5b8c\u6210\u4e86", 24, 8, scale=text_scale, x_scale=x_scale)
|
||||
course_text = f"\u201c{cert['course_name'] or cert['certificate_name'] or cert['project_code']}\u201d"
|
||||
stage_input = (cert["stage_name"] or "").strip()
|
||||
stage = f"{stage_input}\u8bfe\u7a0b\u7684\u4e13\u4e1a\u5b66\u4e60\u3002" if stage_input else "\u521d\u7ea7\u8bfe\u7a0b\u7684\u4e13\u4e1a\u5b66\u4e60\u3002"
|
||||
draw_inline_flow(draw, [(course_text, "course", 0), (stage, "normal", 14)], x, y)
|
||||
draw_inline_flow(draw, [(course_text, "course", 0), (stage, "normal", 14)], x, y, text_scale, x_scale, y_scale)
|
||||
|
||||
issue_label = f"\u53d1\u8bc1\u65e5\u671f\uff1a{issue_year} \u5e74 {issue_month} \u6708 {issue_day} \u65e5"
|
||||
draw.text((512, 704), issue_label, fill="#43382f", font=pil_font(13, "song", True), anchor="mm")
|
||||
draw.text((105, 76), f"\u8bc1\u4e66\u7f16\u53f7\uff1a{cert['certificate_no']}", fill="#111827", font=pil_font(14, "hei", True))
|
||||
image.paste(base_image.crop((720, 535, 950, 629)), (720, 535))
|
||||
draw_issue_date_numbers(draw, issue_year, issue_month, issue_day, text_scale, x_scale, y_scale)
|
||||
draw.text(
|
||||
(105 * x_scale, 76 * y_scale),
|
||||
f"\u8bc1\u4e66\u7f16\u53f7\uff1a{cert['certificate_no']}",
|
||||
fill="#111827",
|
||||
font=pil_font(14, "hei", True, text_scale),
|
||||
)
|
||||
mentor_box = scaled_box((720, 535, 230, 94), x_scale, y_scale)
|
||||
image.paste(base_image.crop(to_xyxy(mentor_box)), mentor_box[:2])
|
||||
return image
|
||||
|
||||
|
||||
@@ -616,12 +715,12 @@ def build_pdf(conn: sqlite3.Connection, cert: sqlite3.Row) -> Path:
|
||||
pdf_dir = DATA / "pdf-cache"
|
||||
pdf_dir.mkdir(exist_ok=True)
|
||||
path = pdf_dir / f"{cert['certificate_no']}.pdf"
|
||||
template = ROOT / "static" / "assets" / "certificate-template.jpg"
|
||||
template = certificate_template_path()
|
||||
renderer_mtime = Path(__file__).stat().st_mtime
|
||||
if path.exists() and path.stat().st_mtime >= max(template.stat().st_mtime, renderer_mtime):
|
||||
return path
|
||||
image = render_certificate_image(conn, cert)
|
||||
image.save(path, "PDF", resolution=150.0)
|
||||
image.save(path, "PDF", resolution=pdf_resolution(image))
|
||||
conn.execute("update certificates set pdf_status=? where id=?", ("generated", cert["id"]))
|
||||
return path
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 91 KiB |
BIN
local_app/static/assets/certificate-template.png
Normal file
BIN
local_app/static/assets/certificate-template.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -702,6 +702,64 @@
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.toolbar input {
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
max-width: 460px;
|
||||
}
|
||||
.toolbar select {
|
||||
min-width: 140px;
|
||||
}
|
||||
.table-wrap {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
min-width: 680px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
th {
|
||||
background: var(--surface-soft);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
td {
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
}
|
||||
tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
tbody tr:hover {
|
||||
background: var(--primary-light);
|
||||
}
|
||||
td:last-child,
|
||||
th:last-child {
|
||||
min-width: 168px;
|
||||
}
|
||||
.actions {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.actions button {
|
||||
flex-shrink: 0;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -775,7 +833,7 @@
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.dialog {
|
||||
position: fixed;
|
||||
@@ -836,6 +894,19 @@
|
||||
.side { position: static; }
|
||||
.form-grid, .stats { grid-template-columns: 1fr; }
|
||||
.cert-meta { grid-template-columns: 1fr; }
|
||||
.main {
|
||||
padding: 20px;
|
||||
}
|
||||
.toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
.toolbar input,
|
||||
.toolbar select,
|
||||
.toolbar button {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 图形验证码弹窗 */
|
||||
@@ -1130,9 +1201,9 @@
|
||||
function esc(v) { return String(v ?? "").replace(/[&<>"']/g, s => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[s])); }
|
||||
function statusPill(value) { return `<span class="pill ${value === "voided" || value === "disabled" || value === "deleted" ? "bad" : ""}">${esc(value)}</span>`; }
|
||||
function table(rows, cols, actions) {
|
||||
if (!rows.length) return `<div class="panel"><p class="muted">暂无数据</p></div>`;
|
||||
return `<table><thead><tr>${cols.map(c=>`<th>${c[1]}</th>`).join("")}${actions?"<th>操作</th>":""}</tr></thead><tbody>` +
|
||||
rows.map(r => `<tr>${cols.map(c=>`<td>${c[2] ? c[2](r[c[0]], r) : esc(r[c[0]])}</td>`).join("")}${actions?`<td><div class="actions">${actions(r)}</div></td>`:""}</tr>`).join("") + `</tbody></table>`;
|
||||
if (!rows.length) return `<p class="muted">暂无数据</p>`;
|
||||
return `<div class="table-wrap"><table><thead><tr>${cols.map(c=>`<th>${c[1]}</th>`).join("")}${actions?"<th>操作</th>":""}</tr></thead><tbody>` +
|
||||
rows.map(r => `<tr>${cols.map(c=>`<td>${c[2] ? c[2](r[c[0]], r) : esc(r[c[0]])}</td>`).join("")}${actions?`<td><div class="actions">${actions(r)}</div></td>`:""}</tr>`).join("") + `</tbody></table></div>`;
|
||||
}
|
||||
function showDialog(title, html) { dialogTitle.textContent = title; dialogBody.innerHTML = html; dialog.classList.remove("hidden"); }
|
||||
function closeDialog() { dialog.classList.add("hidden"); if (previewObjectUrl) { URL.revokeObjectURL(previewObjectUrl); previewObjectUrl = ""; } }
|
||||
|
||||
Reference in New Issue
Block a user