Initial commit: certgen — сервис генерации сертификатов
This commit is contained in:
commit
49bac43e16
23 files changed
+3046
No files matched your search
@@ -0,0 +1,238 @@
|
||||
"""Рендеринг документов: image-шаблоны (Pillow) и html-шаблоны (Chromium)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from .config import PDF_DPI, TEMPLATES_DIR, TMP_DIR
|
||||
from .fonts import find_font
|
||||
|
||||
MONTHS_RU = [
|
||||
"января", "февраля", "марта", "апреля", "мая", "июня",
|
||||
"июля", "августа", "сентября", "октября", "ноября", "декабря",
|
||||
]
|
||||
|
||||
|
||||
def russian_date(d: date | None = None) -> str:
|
||||
d = d or date.today()
|
||||
return f"{d.day} {MONTHS_RU[d.month - 1]} {d.year}"
|
||||
|
||||
|
||||
def substitute(text: str | None, values: dict) -> str:
|
||||
"""Подставить значения в {ключ} или {{ключ}}."""
|
||||
if text is None:
|
||||
return ""
|
||||
out = str(text)
|
||||
for k, v in values.items():
|
||||
s = "" if v is None else str(v)
|
||||
out = out.replace("{{" + str(k) + "}}", s)
|
||||
out = out.replace("{" + str(k) + "}", s)
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Текстовый слой (Pillow)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _measure(font: ImageFont.FreeTypeFont, s: str, spacing: float = 0) -> float:
|
||||
if not s:
|
||||
return 0.0
|
||||
w = font.getlength(s)
|
||||
if spacing:
|
||||
w += spacing * (len(s) - 1)
|
||||
return w
|
||||
|
||||
|
||||
def _wrap(font: ImageFont.FreeTypeFont, text: str, max_px: float | None,
|
||||
spacing: float = 0) -> list[str]:
|
||||
if not text:
|
||||
return [""]
|
||||
lines: list[str] = []
|
||||
for paragraph in str(text).split("\n"):
|
||||
if not paragraph.strip():
|
||||
lines.append("")
|
||||
continue
|
||||
if not max_px:
|
||||
lines.append(paragraph)
|
||||
continue
|
||||
words = paragraph.split(" ")
|
||||
cur = ""
|
||||
for word in words:
|
||||
trial = word if not cur else cur + " " + word
|
||||
if _measure(font, trial, spacing) <= max_px or not cur:
|
||||
cur = trial
|
||||
else:
|
||||
lines.append(cur)
|
||||
cur = word
|
||||
lines.append(cur)
|
||||
return lines
|
||||
|
||||
|
||||
def _draw_line(draw: ImageDraw.ImageDraw, text: str, font, x: float, y: float,
|
||||
color: str, spacing: float) -> None:
|
||||
if not spacing:
|
||||
draw.text((x, y), text, font=font, fill=color)
|
||||
return
|
||||
cursor = x
|
||||
for ch in text:
|
||||
draw.text((cursor, y), ch, font=font, fill=color)
|
||||
cursor += font.getlength(ch) + spacing
|
||||
|
||||
|
||||
def _draw_block(draw: ImageDraw.ImageDraw, lines: list[str], font, x: float, y: float,
|
||||
align: str, color: str, line_spacing: float, spacing: float,
|
||||
max_px: float | None) -> None:
|
||||
ascent, descent = font.getmetrics()
|
||||
lh = (ascent + descent) * line_spacing
|
||||
top = y - (lh * len(lines)) / 2.0
|
||||
for i, line in enumerate(lines):
|
||||
width = _measure(font, line, spacing)
|
||||
if align == "center":
|
||||
lx = x - width / 2.0
|
||||
elif align == "right":
|
||||
lx = x - width
|
||||
else:
|
||||
lx = x
|
||||
_draw_line(draw, line, font, lx, top + i * lh, color, spacing)
|
||||
|
||||
|
||||
def render_image_template(template: dict, values: dict,
|
||||
out_png: Path, out_pdf: Path | None = None) -> None:
|
||||
bg_path = TEMPLATES_DIR / template["background"]
|
||||
if not bg_path.exists():
|
||||
raise FileNotFoundError(f"Фон шаблона не найден: {bg_path}")
|
||||
|
||||
img = Image.open(bg_path).convert("RGB")
|
||||
W, H = img.size
|
||||
page = template.get("page") or {}
|
||||
ref_h = float(page.get("height") or H)
|
||||
scale = H / ref_h
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
for field in template.get("layout", []):
|
||||
if "key" in field:
|
||||
raw = values.get(field["key"], "")
|
||||
else:
|
||||
raw = field.get("text", "")
|
||||
text = substitute(raw, values)
|
||||
if field.get("uppercase"):
|
||||
text = text.upper()
|
||||
if not str(text).strip() and not field.get("keep_empty"):
|
||||
continue
|
||||
|
||||
size = max(1, int(round(float(field.get("size", 40)) * scale)))
|
||||
font = ImageFont.truetype(find_font(field.get("font", "sans")), size)
|
||||
|
||||
x = field.get("x", 0.5)
|
||||
y = field.get("y", 0.5)
|
||||
x = x * W if x <= 1 else x
|
||||
y = y * H if y <= 1 else y
|
||||
|
||||
maxw = field.get("max_width")
|
||||
max_px = (maxw * W if (maxw and maxw <= 1) else maxw)
|
||||
|
||||
spacing = float(field.get("letter_spacing", 0)) * scale
|
||||
lines = _wrap(font, text, max_px, spacing)
|
||||
_draw_block(
|
||||
draw, lines, font, x, y,
|
||||
field.get("align", "center"),
|
||||
field.get("color", "#000000"),
|
||||
float(field.get("line_spacing", 1.15)),
|
||||
spacing, max_px,
|
||||
)
|
||||
|
||||
out_png.parent.mkdir(parents=True, exist_ok=True)
|
||||
img.save(out_png, "PNG")
|
||||
if out_pdf is not None:
|
||||
img.save(out_pdf, "PDF", resolution=PDF_DPI)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# HTML-шаблоны (headless Chromium)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _chromium_binary() -> str | None:
|
||||
for name in ("chromium", "chromium-browser", "google-chrome",
|
||||
"google-chrome-stable", "chrome"):
|
||||
p = shutil.which(name)
|
||||
if p:
|
||||
return p
|
||||
for p in ("/snap/bin/chromium", "/usr/bin/chromium",
|
||||
"/usr/bin/google-chrome", "/usr/bin/chromium-browser"):
|
||||
if Path(p).exists():
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _chromium_workdir(binary: str) -> Path:
|
||||
# Snap-версия Chromium пишет только в ~/snap/chromium/common.
|
||||
if "/snap/" in binary:
|
||||
d = Path.home() / "snap" / "chromium" / "common" / ".certgen-work"
|
||||
else:
|
||||
d = TMP_DIR / "chromium"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def render_html(html: str, out_pdf: Path, out_png: Path | None = None,
|
||||
screenshot_size: tuple[int, int] = (1240, 1754)) -> None:
|
||||
binary = _chromium_binary()
|
||||
if not binary:
|
||||
raise RuntimeError(
|
||||
"Для HTML-шаблонов нужен Chromium/Chrome, но он не найден в системе."
|
||||
)
|
||||
work = _chromium_workdir(binary)
|
||||
run_dir = Path(tempfile.mkdtemp(prefix="run-", dir=work))
|
||||
try:
|
||||
html_file = run_dir / "document.html"
|
||||
html_file.write_text(html, encoding="utf-8")
|
||||
profile = run_dir / "profile"
|
||||
profile.mkdir(exist_ok=True)
|
||||
|
||||
base = [binary, "--headless=new", "--no-sandbox", "--disable-gpu",
|
||||
"--hide-scrollbars", "--no-pdf-header-footer",
|
||||
f"--user-data-dir={profile}"]
|
||||
|
||||
pdf_tmp = run_dir / "document.pdf"
|
||||
subprocess.run(base + [f"--print-to-pdf={pdf_tmp}", html_file.as_uri()],
|
||||
capture_output=True, timeout=120, check=False)
|
||||
if not pdf_tmp.exists():
|
||||
raise RuntimeError("Chromium не смог собрать PDF из HTML.")
|
||||
|
||||
out_pdf.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(pdf_tmp, out_pdf)
|
||||
|
||||
if out_png is not None:
|
||||
png_tmp = run_dir / "document.png"
|
||||
w, h = screenshot_size
|
||||
subprocess.run(base + [f"--window-size={int(w)},{int(h)}",
|
||||
f"--screenshot={png_tmp}", html_file.as_uri()],
|
||||
capture_output=True, timeout=120, check=False)
|
||||
if png_tmp.exists():
|
||||
shutil.copyfile(png_tmp, out_png)
|
||||
finally:
|
||||
shutil.rmtree(run_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def render_html_template(template: dict, values: dict, out_png: Path, out_pdf: Path) -> None:
|
||||
html = substitute(template.get("html", ""), values)
|
||||
page = template.get("page") or {}
|
||||
size = (int(page.get("width") or 1240), int(page.get("height") or 1754))
|
||||
render_html(html, out_pdf, out_png, screenshot_size=size)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Единая точка входа
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def render_certificate(template: dict, values: dict, out_png: Path, out_pdf: Path) -> None:
|
||||
kind = template.get("kind", "image")
|
||||
if kind == "html":
|
||||
render_html_template(template, values, out_png, out_pdf)
|
||||
else:
|
||||
render_image_template(template, values, out_png, out_pdf)
|
||||
Reference in new issue
Block a user