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