import shutil import subprocess import tempfile from pathlib import Path from jinja2 import Environment, FileSystemLoader from app.models import Recipe from app.pdf.escape import escape_latex, format_multiline ASSETS_DIR = Path(__file__).parent / "assets" TEMPLATES_DIR = Path(__file__).parent / "templates" _env = Environment( loader=FileSystemLoader(TEMPLATES_DIR), block_start_string="((*", block_end_string="*))", variable_start_string="(((", variable_end_string=")))", comment_start_string="((=", comment_end_string="=))", trim_blocks=True, lstrip_blocks=True, ) _TIMING_MACROS = [ ("preptime", "preptime"), ("cooktime", "cooktime"), ("baketime", "baketime"), ("marinade_time", "marinade"), ("cooltime", "cooltime"), ("robot_time", "robot"), ("servings", "servings"), ] class PdfRenderError(RuntimeError): def __init__(self, message: str, log_tail: str): super().__init__(message) self.log_tail = log_tail def _build_timing_line(recipe: Recipe) -> str: parts = [] for field, macro in _TIMING_MACROS: value = getattr(recipe, field) if value: parts.append(f"\\{macro}{{{escape_latex(value)}}}") return " ".join(parts) def _build_ingredient_lines(recipe: Recipe) -> list[str]: lines = [] for ing in recipe.ingredients: if ing.kind == "section_header": lines.append(f"\\textbf{{{escape_latex(ing.text)}}}\\\\") continue quantity = escape_latex(ing.quantity) if ing.quantity else "" unit = escape_latex(ing.unit) if ing.unit else "" text = escape_latex(ing.text) lines.append(f"\\unit[{quantity}]{{{unit}}} & {text}\\\\") return lines def _build_info_line(recipe: Recipe) -> str | None: parts = [] if recipe.kcal: parts.append(f"\\kcal{{{escape_latex(recipe.kcal)}}}") if recipe.kh: parts.append(f"\\KH{{{escape_latex(recipe.kh)}}}") if recipe.protein: parts.append(f"\\protein{{{escape_latex(recipe.protein)}}}") if recipe.fett: parts.append(f"\\fett{{{escape_latex(recipe.fett)}}}") return " ".join(parts) if parts else None def render_recipe_tex(recipe: Recipe) -> str: template = _env.get_template("recipe.tex.jinja") context = { "name": escape_latex(recipe.name), "image_filename": recipe.image_filename, "timing_line": _build_timing_line(recipe), "ingredient_lines": _build_ingredient_lines(recipe), "notes": format_multiline(recipe.notes) if recipe.notes else "", "step_items": [format_multiline(step.text) for step in recipe.steps], "info_line": _build_info_line(recipe), } return template.render(**context) def compile_recipe_pdf(recipe: Recipe, upload_dir: Path) -> bytes: tex_source = render_recipe_tex(recipe) with tempfile.TemporaryDirectory(prefix="recipe-pdf-") as tmp: workdir = Path(tmp) (workdir / "Pictures" / "ico").mkdir(parents=True) shutil.copy(ASSETS_DIR / "cuisine.sty", workdir / "cuisine.sty") for icon in (ASSETS_DIR / "icons").glob("*.png"): shutil.copy(icon, workdir / "Pictures" / "ico" / icon.name) if recipe.image_filename: src = upload_dir / recipe.image_filename if src.exists(): shutil.copy(src, workdir / "Pictures" / recipe.image_filename) tex_path = workdir / "recipe.tex" tex_path.write_text(tex_source, encoding="utf-8") for _ in range(2): subprocess.run( ["xelatex", "-interaction=nonstopmode", "-halt-on-error", tex_path.name], cwd=workdir, capture_output=True, text=True, timeout=60, ) pdf_path = workdir / "recipe.pdf" if not pdf_path.exists(): log_path = workdir / "recipe.log" log_tail = "" if log_path.exists(): log_tail = "\n".join(log_path.read_text(errors="ignore").splitlines()[-40:]) raise PdfRenderError("PDF compilation failed", log_tail) return pdf_path.read_bytes()