Files
cert/certgen/render.py
T

239 lines
8.3 KiB
Python

"""Рендеринг документов: 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)