recipe-inator/backend/app/pdf/render.py

126 lines
4.1 KiB
Python

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()