"""Kompilacja raportu do PDF (PRE-24). Dwa kroki zewnętrzne, oba w katalogu tymczasowym, który po sobie sprzątamy: 1. SVG kosmogramu → PDF (`rsvg-convert`). LaTeX nie wstawia SVG wprost; zamiana na PDF zachowuje wektor, więc rysunek zostaje ostry przy dowolnym powiększeniu. 2. .tex → PDF (`xelatex`). Świadomie NIE logujemy treści raportu — to dane urodzeniowe i opisy z baz. Do logu idzie wyłącznie ogon komunikatów LaTeXa przy błędzie, bo bez niego diagnoza jest niemożliwa. """ from __future__ import annotations import logging import pathlib import shutil import subprocess import tempfile from app.latex import build log = logging.getLogger("astrololo.render") DOC = "report" TIMEOUT = 120 # xelatex na dużym tekście potrzebuje chwili class RenderError(Exception): """Kompilacja się nie udała — z komunikatem nadającym się do pokazania.""" def _run(cmd: list[str], cwd: pathlib.Path) -> subprocess.CompletedProcess: return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=TIMEOUT) def _svg_to_pdf(svg: str, work: pathlib.Path, stem: str) -> str | None: """SVG → PDF (nazwa `stem`) albo None, gdy nie ma czego rysować lub konwersja padła. Brak rysunku nie może wywalić raportu — tekst jest ważniejszy.""" if not svg or not svg.strip(): return None svg_name, pdf_name = f"{stem}.svg", f"{stem}.pdf" (work / svg_name).write_text(svg, encoding="utf-8") proc = _run(["rsvg-convert", "-f", "pdf", "-o", pdf_name, svg_name], work) if proc.returncode != 0 or not (work / pdf_name).exists(): log.warning("konwersja rysunku %s nie udała się: %s", stem, (proc.stderr or "")[-300:]) return None return pdf_name def _figures(report: dict, work: pathlib.Path) -> list[dict]: """Rysunki z raportu → lista PDF-ów z podpisami. Kontrakt: `figures` = [{svg, caption}]; dla zgodności wstecznej wpada też pojedynczy `wheel_svg`.""" src = report.get("figures") if not src and report.get("wheel_svg"): src = [{"svg": report["wheel_svg"], "caption": "Kosmogram"}] out: list[dict] = [] for i, fig in enumerate(src or []): pdf = _svg_to_pdf(fig.get("svg") or "", work, f"fig{i}") if pdf: out.append({"pdf": pdf, "caption": fig.get("caption") or ""}) return out def to_pdf(report: dict) -> bytes: """Raport (dict) → bajty PDF. Rzuca RenderError, gdy LaTeX nie zbuduje pliku.""" with tempfile.TemporaryDirectory(prefix="astrololo-render-") as tmp: work = pathlib.Path(tmp) figures = _figures(report, work) (work / f"{DOC}.tex").write_text(build(report, figures=figures), encoding="utf-8") proc = _run( ["xelatex", "-interaction=nonstopmode", "-halt-on-error", f"{DOC}.tex"], work, ) pdf = work / f"{DOC}.pdf" if not pdf.exists(): tail = (proc.stdout or "")[-1200:] log.error("xelatex nie zbudował PDF-a; ogon logu: %s", tail) raise RenderError("LaTeX nie zbudował dokumentu. Ogon logu: " + tail[-400:]) return pdf.read_bytes() def toolchain_ready() -> dict: """Czy narzędzia są na miejscu — do /health, żeby awarię obrazu widzieć od razu, a nie przy pierwszym raporcie.""" return { "xelatex": bool(shutil.which("xelatex")), "rsvg-convert": bool(shutil.which("rsvg-convert")), }