126 lines
4.1 KiB
Python
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()
|