Initial commit: certgen — сервис генерации сертификатов

This commit is contained in:
apuc committed 2026-10-16 17:17:47 +03:00
commit 49bac43e16
23 files changed
+3046

No files matched your search

+136
View File
@@ -0,0 +1,136 @@
"""Встроенные шаблоны и генерация фонов."""
from __future__ import annotations
from pathlib import Path
from PIL import Image, ImageDraw
from .config import TEMPLATES_DIR
GOLD = (198, 158, 45)
NAVY = (27, 42, 74)
MAROON = (122, 31, 43)
CREAM = (255, 253, 247)
CLASSIC_PAGE = {"width": 1754, "height": 1240} # A4 landscape @ ~150dpi
LETTER_PAGE = {"width": 1240, "height": 1754} # A4 portrait @ ~150dpi
# Меняйте при правке встроенных шаблонов — тогда они обновятся у пользователей.
BUILTIN_VERSION = "2"
def _diamond(d: ImageDraw.ImageDraw, cx: float, cy: float, r: float, fill) -> None:
d.polygon([(cx, cy - r), (cx + r, cy), (cx, cy + r), (cx - r, cy)], fill=fill)
def _frame(img: Image.Image, outer: int = 34) -> ImageDraw.ImageDraw:
W, H = img.size
d = ImageDraw.Draw(img)
d.rectangle([outer, outer, W - outer, H - outer], outline=GOLD, width=8)
d.rectangle([outer + 24, outer + 24, W - outer - 24, H - outer - 24], outline=NAVY, width=2)
d.rectangle([outer + 34, outer + 34, W - outer - 34, H - outer - 34], outline=GOLD, width=1)
inset = outer + 24
for cx, cy in [(inset, inset), (W - inset, inset), (inset, H - inset), (W - inset, H - inset)]:
_diamond(d, cx, cy, 18, GOLD)
_diamond(d, cx, cy, 8, NAVY)
_diamond(d, cx, cy, 3, CREAM)
return d
def draw_classic_bg(path: Path) -> None:
W, H = CLASSIC_PAGE["width"], CLASSIC_PAGE["height"]
img = Image.new("RGB", (W, H), CREAM)
d = _frame(img)
# разделитель под заголовком
d.line([(W * 0.30, H * 0.245), (W * 0.70, H * 0.245)], fill=GOLD, width=3)
_diamond(d, W * 0.5, H * 0.245, 9, GOLD)
# линия для подписи
d.line([(W * 0.42, H * 0.925), (W * 0.58, H * 0.925)], fill=NAVY, width=2)
img.save(path, "PNG")
def draw_letter_bg(path: Path) -> None:
W, H = LETTER_PAGE["width"], LETTER_PAGE["height"]
img = Image.new("RGB", (W, H), CREAM)
d = _frame(img)
d.line([(W * 0.26, H * 0.205), (W * 0.74, H * 0.205)], fill=GOLD, width=3)
_diamond(d, W * 0.5, H * 0.205, 9, GOLD)
d.line([(W * 0.42, H * 0.925), (W * 0.58, H * 0.925)], fill=NAVY, width=2)
img.save(path, "PNG")
def ensure_assets() -> None:
"""Создать фоны встроенных шаблонов, если их ещё нет."""
builtin_dir = TEMPLATES_DIR / "builtin"
builtin_dir.mkdir(parents=True, exist_ok=True)
classic = builtin_dir / "classic.png"
letter = builtin_dir / "letter.png"
if not classic.exists():
draw_classic_bg(classic)
if not letter.exists():
draw_letter_bg(letter)
def builtin_templates() -> list[dict]:
return [
{
"slug": "classic",
"title": "СЕРТИФИКАТ",
"kind": "image",
"background": "builtin/classic.png",
"page": CLASSIC_PAGE,
"defaults": {
"title": "СЕРТИФИКАТ",
"body": "за успешное прохождение программы",
},
"layout": [
{"key": "title", "x": 0.5, "y": 0.175, "size": 96, "font": "serif-bold",
"color": "#1B2A4A", "align": "center", "uppercase": True,
"letter_spacing": 10, "max_width": 0.9},
{"text": "настоящий сертификат подтверждает, что", "x": 0.5, "y": 0.315,
"size": 34, "font": "serif-italic", "color": "#5a5a5a", "align": "center"},
{"key": "fio", "x": 0.5, "y": 0.435, "size": 96, "font": "serif-bold",
"color": "#7A1F2B", "align": "center", "max_width": 0.82},
{"key": "body", "x": 0.5, "y": 0.645, "size": 40, "font": "serif",
"color": "#333333", "align": "center", "max_width": 0.72, "line_spacing": 1.45},
{"text": "Дата выдачи", "x": 0.26, "y": 0.795, "size": 22, "font": "sans",
"color": "#8a8a8a", "align": "center"},
{"key": "date", "x": 0.26, "y": 0.845, "size": 32, "font": "serif-bold",
"color": "#1B2A4A", "align": "center"},
{"text": "Номер документа", "x": 0.74, "y": 0.795, "size": 22, "font": "sans",
"color": "#8a8a8a", "align": "center"},
{"key": "number", "x": 0.74, "y": 0.845, "size": 32, "font": "serif-bold",
"color": "#1B2A4A", "align": "center"},
],
},
{
"slug": "letter",
"title": "БЛАГОДАРСТВЕННОЕ ПИСЬМО",
"kind": "image",
"background": "builtin/letter.png",
"page": LETTER_PAGE,
"defaults": {
"title": "БЛАГОДАРСТВЕННОЕ ПИСЬМО",
"body": "за вклад в развитие и активное участие",
},
"layout": [
{"key": "title", "x": 0.5, "y": 0.15, "size": 62, "font": "serif-bold",
"color": "#1B2A4A", "align": "center", "uppercase": True,
"letter_spacing": 6, "max_width": 0.86},
{"text": "объявляется благодарность", "x": 0.5, "y": 0.255, "size": 32,
"font": "serif-italic", "color": "#5a5a5a", "align": "center"},
{"key": "fio", "x": 0.5, "y": 0.36, "size": 70, "font": "serif-bold",
"color": "#7A1F2B", "align": "center", "max_width": 0.84},
{"key": "body", "x": 0.5, "y": 0.50, "size": 34, "font": "serif",
"color": "#333333", "align": "center", "max_width": 0.74, "line_spacing": 1.5},
{"text": "Дата", "x": 0.27, "y": 0.80, "size": 22, "font": "sans",
"color": "#8a8a8a", "align": "center"},
{"key": "date", "x": 0.27, "y": 0.85, "size": 30, "font": "serif-bold",
"color": "#1B2A4A", "align": "center"},
{"text": "Номер", "x": 0.73, "y": 0.80, "size": 22, "font": "sans",
"color": "#8a8a8a", "align": "center"},
{"key": "number", "x": 0.73, "y": 0.85, "size": 30, "font": "serif-bold",
"color": "#1B2A4A", "align": "center"},
],
},
]