Files
training-software/certificates/renderer.py
Paperclip CTO 94bba1ec5d
All checks were successful
CI Build / Build Container (push) Successful in 5s
CI Quality / Ruff Lint (push) Successful in 6s
CI Security / Bandit + pip-audit (push) Successful in 21s
CI Tests / Django Tests (push) Successful in 35s
CI Tests / OpenAPI Schema (push) Successful in 16s
TRA-408 isolate certificate rendering in internal service
2026-05-19 14:23:20 +02:00

149 lines
4.3 KiB
Python

"""
LaTeX certificate renderer.
Takes a certificate context dict and renders it to a PDF file via pdflatex.
Returns the path to the rendered PDF.
"""
from __future__ import annotations
import hashlib
import os
import subprocess
import tempfile
from string import Template
LATEX_TEMPLATE = r"""
\documentclass[12pt,a4paper]{article}
\usepackage[margin=2cm]{geometry}
\usepackage{fontenc}
\usepackage[utf8]{inputenc}
\usepackage{xcolor}
\usepackage{graphicx}
\pagestyle{empty}
\begin{document}
\begin{center}
{\Huge\bfseries Certificate of Completion}\\[1.5cm]
{\Large This certifies that}\\[0.8cm]
{\LARGE\bfseries $recipient_name}\\[0.8cm]
{\large has successfully completed}\\[0.5cm]
{\Large\bfseries $course_title}\\[0.5cm]
{\normalsize on $issued_date}\\[1.5cm]
{\small Serial: $serial_number}\\[0.3cm]
{\small Verify at: $verification_url}
\end{center}
\end{document}
"""
class RenderError(Exception):
pass
ALLOWED_CONTEXT_FIELDS = {
"recipient_name": 128,
"course_title": 160,
"issued_date": 64,
"serial_number": 64,
"verification_url": 512,
}
def _escape_latex(value: str) -> str:
"""Escape special LaTeX characters in user-supplied strings."""
special = {
"&": r"\&",
"%": r"\%",
"$": r"\$",
"#": r"\#",
"_": r"\_",
"{": r"\{",
"}": r"\}",
"~": r"\textasciitilde{}",
"^": r"\^{}",
"\\": r"\textbackslash{}",
}
return "".join(special.get(c, c) for c in value)
def validate_certificate_context(context: dict) -> dict:
if not isinstance(context, dict):
raise ValueError("context must be an object")
unknown_fields = set(context.keys()) - set(ALLOWED_CONTEXT_FIELDS.keys())
if unknown_fields:
raise ValueError("context includes unsupported fields")
clean = {}
for key, max_len in ALLOWED_CONTEXT_FIELDS.items():
raw = context.get(key, "")
if not isinstance(raw, str):
raise ValueError(f"{key} must be a string")
value = raw.strip()
if not value:
raise ValueError(f"{key} must not be empty")
if len(value) > max_len:
raise ValueError(f"{key} exceeds max length {max_len}")
clean[key] = value
return clean
def build_latex_source(context: dict) -> str:
safe = {k: _escape_latex(str(v)) for k, v in context.items()}
return Template(LATEX_TEMPLATE).safe_substitute(safe)
def render_certificate_to_bytes(context: dict) -> bytes:
validated = validate_certificate_context(context)
latex_source = build_latex_source(validated)
with tempfile.TemporaryDirectory() as tmpdir:
tex_file = os.path.join(tmpdir, "certificate.tex")
with open(tex_file, "w", encoding="utf-8") as f:
f.write(latex_source)
try:
result = subprocess.run(
["pdflatex", "-interaction=nonstopmode", "-output-directory", tmpdir, tex_file],
capture_output=True,
text=True,
timeout=30,
)
except FileNotFoundError:
raise RenderError("pdflatex not found — ensure TeX Live is installed.")
except subprocess.TimeoutExpired:
raise RenderError("pdflatex timed out after 30 seconds.")
if result.returncode != 0:
raise RenderError(f"pdflatex failed:\n{result.stdout[-2000:]}")
rendered_pdf = os.path.join(tmpdir, "certificate.pdf")
if not os.path.exists(rendered_pdf):
raise RenderError("pdflatex ran successfully but no PDF was produced.")
with open(rendered_pdf, "rb") as f:
return f.read()
def render_certificate_pdf(context: dict, output_path: str) -> str:
"""
Render a certificate PDF from the given context dict.
Saves to output_path (must be a .pdf path).
Returns output_path on success.
Raises RenderError on failure.
"""
pdf_bytes = render_certificate_to_bytes(context)
os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, "wb") as f:
f.write(pdf_bytes)
return output_path
def compute_pdf_hash(pdf_path: str) -> str:
"""Compute SHA-256 hex digest of a PDF file."""
sha = hashlib.sha256()
with open(pdf_path, "rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
sha.update(chunk)
return sha.hexdigest()