import threading from datetime import date, datetime, timedelta from pathlib import Path from time import monotonic from PIL import Image, ImageDraw, ImageFont 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 PDF_GENERATION_WAIT_TIMEOUT_SECONDS = 120 class PdfGenerationBusy(RuntimeError): pass class PdfGenerationLimiter: def __init__(self) -> None: self._condition = threading.Condition() self._active = 0 def acquire(self, limit: int, timeout_seconds: int = PDF_GENERATION_WAIT_TIMEOUT_SECONDS) -> None: deadline = monotonic() + timeout_seconds with self._condition: while self._active >= limit: remaining = deadline - monotonic() if remaining <= 0: raise PdfGenerationBusy("当前PDF生成任务较多,请稍后重试") self._condition.wait(remaining) self._active += 1 def release(self) -> None: with self._condition: self._active = max(0, self._active - 1) self._condition.notify_all() pdf_generation_limiter = PdfGenerationLimiter() def pdf_cache_path(certificate_no: str) -> Path: safe_name = certificate_no.replace("/", "_") year = safe_name[2:6] if len(safe_name) >= 6 else "misc" folder = data_path("pdf-cache") / year folder.mkdir(parents=True, exist_ok=True) return folder / f"{safe_name}.pdf" def cached_pdf_is_fresh(path: Path) -> bool: if not path.exists(): return False modified_at = datetime.fromtimestamp(path.stat().st_mtime) 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, project: ProjectCourse | None, token: CertificateAccessToken, concurrency_limit: int = 2, ) -> Path: output_path = pdf_cache_path(certificate.certificate_no) 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 pdf_generation_limiter.acquire(max(1, concurrency_limit)) try: 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=pdf_resolution(image)) finally: pdf_generation_limiter.release() 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 = 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" paper_patch(image, (150, 455, 760, 119), x_scale, y_scale) 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 * 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_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, 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/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", "/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: path, index = item if isinstance(item, tuple) else (item, 0) if Path(path).exists(): try: return ImageFont.truetype(path, max(1, round(size * scale)), index=index) except Exception: pass return ImageFont.load_default() def date_parts(value: date | str | None) -> tuple[str, str, str]: if isinstance(value, date): return str(value.year), str(value.month), str(value.day) raw = str(value or "").strip() for fmt in ("%Y-%m-%d", "%Y/%m/%d"): try: d = datetime.strptime(raw[:10], fmt) return str(d.year), str(d.month), str(d.day) except ValueError: pass today = date.today() return str(today.year), str(today.month), str(today.day) 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((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), 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 = 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 * 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: max_width = 900 - x text_font = font(27, "kai", True) if draw.textlength(value, font=text_font) <= max_width: draw.text((x, y), value, fill="#111111", font=text_font) draw.text((x + 0.45, y), value, fill="#111111", font=text_font) return y start_y = y + 34 lines = split_by_width(draw, value, text_font, 690)[:2] for index, line in enumerate(lines): line_y = start_y + index * 34 draw.text((185, line_y), line, fill="#111111", font=text_font) draw.text((185.45, line_y), line, fill="#111111", font=text_font) 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: float, scale: float, x_scale: float, y_scale: float, ) -> None: x = start_x y = start_y line_start = 185 * x_scale line_height = 42 * y_scale for value, style, gap_before in segments: 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) * 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 * x_scale, y), char, fill="#111111", font=text_font, anchor="ls") x += width def split_by_width(draw: ImageDraw.ImageDraw, value: str, text_font: ImageFont.ImageFont, max_width: int) -> list[str]: lines: list[str] = [] line = "" for char in value: next_line = line + char if line and draw.textlength(next_line, font=text_font) > max_width: lines.append(line) line = char else: line = next_line if line: lines.append(line) return lines def draw_fit(draw: ImageDraw.ImageDraw, pos: tuple[int, int], value: str, text_font: ImageFont.ImageFont, fill: str, max_width: int) -> None: font_obj = text_font size = getattr(text_font, "size", 24) while draw.textlength(value, font=font_obj) > max_width and size > 12: size -= 1 font_obj = font(size, "song", True) draw.text(pos, value, fill=fill, font=font_obj)