优化证书生成与正式部署配置

This commit is contained in:
2026-06-15 18:23:46 +08:00
parent f1d7491288
commit 668e0403af
31 changed files with 1603 additions and 145 deletions

View File

@@ -7,6 +7,10 @@ from app.core.config import settings
from app.core.paths import data_path
from app.models import Certificate, CertificateAccessToken, Learner, ProjectCourse
DESIGN_WIDTH = 1024
DESIGN_HEIGHT = 759
PDF_BASE_RESOLUTION = 150.0
def pdf_cache_path(certificate_no: str) -> Path:
safe_name = certificate_no.replace("/", "_")
@@ -23,6 +27,25 @@ def cached_pdf_is_fresh(path: Path) -> bool:
return modified_at >= datetime.now() - timedelta(days=settings.pdf_cache_days)
def update_pdf_access_time(certificate: Certificate) -> None:
"""更新PDF最后访问时间"""
from datetime import datetime
certificate.pdf_last_accessed_at = datetime.utcnow()
def certificate_template_path() -> Path:
assets_dir = Path(__file__).resolve().parents[1] / "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_pdf(
certificate: Certificate,
learner: Learner,
@@ -30,71 +53,130 @@ def render_certificate_pdf(
token: CertificateAccessToken,
) -> Path:
output_path = pdf_cache_path(certificate.certificate_no)
template_path = Path(__file__).resolve().parents[1] / "assets" / "certificate-template.jpg"
template_path = certificate_template_path()
latest_renderer_mtime = max(template_path.stat().st_mtime, Path(__file__).stat().st_mtime)
if cached_pdf_is_fresh(output_path) and output_path.stat().st_mtime >= latest_renderer_mtime:
# 更新访问时间
update_pdf_access_time(certificate)
return output_path
image = render_certificate_image(certificate, learner, project)
image.save(output_path, "PDF", resolution=150.0)
image.save(output_path, "PDF", resolution=pdf_resolution(image))
certificate.pdf_status = "generated"
certificate.pdf_file_path = str(output_path)
# 更新访问时间
update_pdf_access_time(certificate)
return output_path
def render_certificate_image(certificate: Certificate, learner: Learner, project: ProjectCourse | None) -> Image.Image:
template = Path(__file__).resolve().parents[1] / "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
issue_year, issue_month, issue_day = date_parts(certificate.issue_date)
course_name = certificate.course_name or certificate.certificate_name or (project.name if project else certificate.project_code)
stage_input = (certificate.stage_name or "").strip()
stage_name = 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"
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.current_name, fill="#111111", font=font(32, "kai", True), anchor="mm")
draw.text(
(512 * x_scale, 414 * y_scale),
learner.current_name,
fill="#111111",
font=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)
draw_inline_flow(draw, [(f"\u201c{course_name}\u201d", "course", 0), (stage_name, "normal", 14)], x, y)
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)
draw_inline_flow(
draw,
[(f"\u201c{course_name}\u201d", "course", 0), (stage_name, "normal", 14)],
x,
y,
text_scale,
x_scale,
y_scale,
)
draw.text((512, 704), f"\u53d1\u8bc1\u65e5\u671f\uff1a{issue_year} \u5e74 {issue_month} \u6708 {issue_day} \u65e5", fill="#43382f", font=font(13, "song", True), anchor="mm")
draw.text((105, 76), f"\u8bc1\u4e66\u7f16\u53f7\uff1a{certificate.certificate_no}", fill="#111827", font=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{certificate.certificate_no}",
fill="#111827",
font=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
def font(size: int, kind: str = "song", bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
def 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"],
"hei": [r"C:\Windows\Fonts\simhei.ttf", r"C:\Windows\Fonts\msyhbd.ttc", r"C:\Windows\Fonts\msyh.ttc"],
"song": [r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simsun.ttc"],
"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/Supplemental/Songti.ttc", 1),
],
"hei": [
r"C:\Windows\Fonts\simhei.ttf",
r"C:\Windows\Fonts\msyhbd.ttc",
r"C:\Windows\Fonts\msyh.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),
],
"song": [
r"C:\Windows\Fonts\msyh.ttc",
r"C:\Windows\Fonts\simsun.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/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"] + paths
paths = [
r"C:\Windows\Fonts\msyhbd.ttc",
r"C:\Windows\Fonts\simhei.ttf",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
("/System/Library/Fonts/STHeiti Medium.ttc", 1),
("/System/Library/Fonts/Hiragino Sans GB.ttc", 2),
] + 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()
@@ -114,22 +196,71 @@ def date_parts(value: date | str | None) -> tuple[str, str, str]:
return str(today.year), str(today.month), str(today.day)
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")
image.paste(Image.blend(patch, cover, 0.38), (x, y))
image.paste(Image.blend(patch, cover, 0.38), (x, y), soft_edge_mask(w, h, round(16 * x_scale), round(10 * y_scale)))
def draw_inline(draw: ImageDraw.ImageDraw, x: float, y: int, value: str, size: int, gap: int, kind: str = "song", bold: bool = False) -> float:
text_font = font(size, kind, bold)
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 = font(13, "song", True, scale)
y = 688 * y_scale
for value, x in [(issue_year, 500), (issue_month, 535), (issue_day, 566)]:
draw.text((x * x_scale, y), value, fill="#111111", font=text_font, anchor="mm")
def draw_inline(
draw: ImageDraw.ImageDraw,
x: float,
y: float,
value: str,
size: int,
gap: int,
kind: str = "song",
bold: bool = False,
scale: float = 1.0,
x_scale: float = 1.0,
) -> float:
text_font = font(size, kind, bold, scale)
draw.text((x, y), value, fill="#111111", font=text_font, anchor="ls")
if bold:
draw.text((x + 0.45, y), value, fill="#111111", font=text_font, anchor="ls")
return x + draw.textlength(value, font=text_font) + gap
draw.text((x + 0.45 * x_scale, y), value, fill="#111111", font=text_font, anchor="ls")
return x + draw.textlength(value, font=text_font) + gap * x_scale
def draw_course_name(draw: ImageDraw.ImageDraw, value: str, x: float, y: int) -> int:
@@ -149,23 +280,31 @@ def draw_course_name(draw: ImageDraw.ImageDraw, value: 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 value, style, gap_before in segments:
x += gap_before
text_font = font(27, "kai", True) if style == "course" else font(24, "song", False)
x += gap_before * x_scale
text_font = font(27, "kai", True, scale) if style == "course" else font(24, "song", False, scale)
for char in value:
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