Files
2026-06-23 13:59:56 +08:00

374 lines
14 KiB
Python

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("当前查询人数较多,请稍后重试")
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 mark_pdf_generated(certificate: Certificate, output_path: Path) -> None:
certificate.pdf_status = "generated"
certificate.pdf_file_path = str(output_path)
update_pdf_access_time(certificate)
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:
mark_pdf_generated(certificate, output_path)
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:
mark_pdf_generated(certificate, output_path)
return output_path
image = render_certificate_image(certificate, learner, project)
image.save(output_path, "PDF", resolution=pdf_resolution(image))
finally:
pdf_generation_limiter.release()
mark_pdf_generated(certificate, output_path)
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)