diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py index df2e16e..b77fbc0 100644 --- a/services/presentation/app/main.py +++ b/services/presentation/app/main.py @@ -147,6 +147,8 @@ def compile_build( from app import chartwheel ctx["wheel_svg"] = chartwheel.render(ctx["result"]) ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18 + ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07 (etap 6) + ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07 (etap 6) except (httpx.HTTPError,) as e: ctx["error"] = _logic_error(e) except ValueError as e: @@ -178,7 +180,7 @@ def compile_pdf(payload: dict): except (ValueError, TypeError) as e: return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422) - wheel_svg = "" + figures: list[dict] = [] try: chart = logic.positions( when_utc_iso=iso_utc, @@ -188,17 +190,25 @@ def compile_pdf(payload: dict): ) from app import chartwheel - wheel_svg = chartwheel.render(chart, theme="print") + # Zasada: co pokazujemy na stronie, ma trafić do PDF-a. Wszystkie rysunki + # w motywie DRUKU — samodzielny konwerter SVG→PDF nie zna arkusza, więc + # zmienne CSS i font glifów muszą być wprost w rysunku. + for svg, caption in ( + (chartwheel.render(chart, theme="print"), "Kosmogram"), + (chartwheel.render_aspectarian(chart, theme="print"), "Aspektarian — siatka aspektów"), + (chartwheel.render_declination(chart, theme="print"), "Wykres deklinacji"), + (chartwheel.render_antiscia(chart, theme="print"), "Oś antyscji"), + ): + if svg: + figures.append({"svg": svg, "caption": caption}) except httpx.HTTPError as e: - # Brak rysunku nie może zablokować raportu — tekst jest ważniejszy. - log_note = _logic_error(e) - chart, wheel_svg = None, "" - data = {**data, "wheel_error": log_note} + # Brak rysunków nie może zablokować raportu — tekst jest ważniejszy. + data = {**data, "wheel_error": _logic_error(e)} report = { "person": payload.get("person") or "", "data": {**data, "moment_utc": label}, - "wheel_svg": wheel_svg, + "figures": figures, "natal": payload.get("natal") or {}, "predictions": payload.get("predictions") or [], } diff --git a/services/presentation/app/templates/compile.html b/services/presentation/app/templates/compile.html index 34472d3..bf46d35 100644 --- a/services/presentation/app/templates/compile.html +++ b/services/presentation/app/templates/compile.html @@ -91,6 +91,20 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t {% endif %} + {% if declination_svg %} +
+ {{ declination_svg | safe }} +
Wykres deklinacji — paralele i out-of-bounds (LOG-07).
+
+ {% endif %} + + {% if antiscia_svg %} +
+ {{ antiscia_svg | safe }} +
Oś antyscji — odbicia względem przesileń i równonocy (LOG-07).
+
+ {% endif %} + {# ── 3. Dane policzone ────────────────────────────────────────────── #}
Horoskop · silnik {{ result.engine }} {% if result.house_system %}· domy {{ result.house_system }}{% endif %} diff --git a/services/presentation/tests/test_compile.py b/services/presentation/tests/test_compile.py index 1992c44..349dc9a 100644 --- a/services/presentation/tests/test_compile.py +++ b/services/presentation/tests/test_compile.py @@ -61,6 +61,33 @@ def test_person_name_heads_the_report(): assert "form.person" in TPL +# ───── wszystkie rysunki w raporcie i w PDF (nie tylko koło) ───── +# Zasada: co pokazujemy na stronie, ma trafić do podsumowania i do PDF-a. + +def test_report_shows_every_chart_figure(): + """Raport «Skompiluj» pokazuje wszystkie rysunki, nie tylko koło.""" + for cls in ("wheel-fig", "aspectarian-fig", "declination-fig", "antiscia-fig"): + assert cls in TPL, f"brak {cls} w raporcie" + + +def test_compile_page_renders_all_figures(): + """Handler /compile liczy wszystkie rysunki do podglądu raportu.""" + for var in ("aspectarian_svg", "declination_svg", "antiscia_svg"): + assert var in MAIN, f"/compile nie ustawia {var}" + + +def test_pdf_bundles_all_figures_in_print_theme(): + """compile_pdf składa KOMPLET rysunków (motyw druku) i wysyła jako `figures`.""" + assert '"figures": figures' in MAIN + for call in ( + 'render(chart, theme="print")', + 'render_aspectarian(chart, theme="print")', + 'render_declination(chart, theme="print")', + 'render_antiscia(chart, theme="print")', + ): + assert call in MAIN, f"PDF nie składa: {call}" + + # ──────────────────────── zbieranie materiału z magazynów ──────────────── def test_reads_stores_through_their_api_not_raw_storage(): diff --git a/services/presentation/tests/test_render_link.py b/services/presentation/tests/test_render_link.py index 63fba4d..691b14a 100644 --- a/services/presentation/tests/test_render_link.py +++ b/services/presentation/tests/test_render_link.py @@ -123,10 +123,10 @@ def test_pdf_button_exists(): def test_wheel_failure_does_not_block_the_report(): - """Brak rysunku ma dać raport bez kosmogramu, a nie brak raportu — tekst - kosztował wywołanie modelu, rysunek policzymy zawsze.""" - assert "wheel_svg = \"\"" in MAIN or 'wheel_svg, = ""' in MAIN - assert "Brak rysunku nie może zablokować raportu" in MAIN + """Brak rysunków ma dać raport bez nich, a nie brak raportu — tekst kosztował + wywołanie modelu, rysunki policzymy zawsze.""" + assert "figures: list[dict] = []" in MAIN + assert "Brak rysunków nie może zablokować raportu" in MAIN # ─────────────────────────────────── pomocnicze ────────────────────────── diff --git a/services/render/app/compile.py b/services/render/app/compile.py index 2e1bfc7..d90ac66 100644 --- a/services/render/app/compile.py +++ b/services/render/app/compile.py @@ -22,8 +22,6 @@ from app.latex import build log = logging.getLogger("astrololo.render") -WHEEL_SVG = "wheel.svg" -WHEEL_PDF = "wheel.pdf" DOC = "report" TIMEOUT = 120 # xelatex na dużym tekście potrzebuje chwili @@ -36,25 +34,40 @@ 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) -> str | None: - """Zwraca nazwę pliku PDF z kosmogramem albo None, gdy nie ma czego rysować.""" +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 - (work / WHEEL_SVG).write_text(svg, encoding="utf-8") - proc = _run(["rsvg-convert", "-f", "pdf", "-o", WHEEL_PDF, WHEEL_SVG], work) - if proc.returncode != 0 or not (work / WHEEL_PDF).exists(): - # Brak rysunku nie może wywalić całego raportu — tekst jest ważniejszy. - log.warning("konwersja kosmogramu nie udała się: %s", (proc.stderr or "")[-300:]) + 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 WHEEL_PDF + 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) - wheel = _svg_to_pdf(report.get("wheel_svg") or "", work) - (work / f"{DOC}.tex").write_text(build(report, wheel), encoding="utf-8") + 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"], diff --git a/services/render/app/latex.py b/services/render/app/latex.py index dd3dd0f..1f14e77 100644 --- a/services/render/app/latex.py +++ b/services/render/app/latex.py @@ -171,12 +171,20 @@ PREAMBLE = r"""\documentclass[11pt,a4paper]{article} """ -def build(report: dict, wheel_pdf: str | None = None) -> str: - """Składa źródło .tex. `wheel_pdf` to nazwa pliku z kosmogramem (już PDF).""" +def build(report: dict, wheel_pdf: str | None = None, + figures: list[dict] | None = None) -> str: + """Składa źródło .tex. + + `figures` to UPORZĄDKOWANA lista rysunków kosmogramu (koło, aspektarian, + deklinacja, antyscja…) — każdy `{"pdf": nazwa, "caption": podpis}`, już jako + PDF. Zasada: co pokazujemy na stronie, ma trafić i tu (raport = to samo, co + widać). `wheel_pdf` zostaje dla zgodności wstecznej — pojedynczy rysunek.""" person = str(report.get("person") or "").strip() data = report.get("data") or {} natal = report.get("natal") or {} predictions = report.get("predictions") or [] + if figures is None: + figures = [{"pdf": wheel_pdf, "caption": "Kosmogram"}] if wheel_pdf else [] parts = [PREAMBLE] @@ -202,12 +210,22 @@ def build(report: dict, wheel_pdf: str | None = None) -> str: parts.append(r"\end{tabular}") parts.append(r"\vspace{1em}") - # ── 3. kosmogram ──────────────────────────────────────────────────── - if wheel_pdf: + # ── 3. rysunki kosmogramu ─────────────────────────────────────────── + # Jedna reguła na wszystkie: keepaspectratio z limitem SZEROKOŚCI i WYSOKOŚCI. + # Koło i aspektarian są kwadratowe (ogranicza je wysokość), deklinacja i + # antyscja szerokie (ogranicza szerokość) — bez zniekształceń w obu razach. + for fig in figures: + pdf = fig.get("pdf") + if not pdf: + continue parts.append(r"\begin{center}") - parts.append(r"\includegraphics[width=0.82\textwidth]{" + wheel_pdf + r"}") + parts.append(r"\includegraphics[width=0.82\textwidth,height=0.46\textheight," + r"keepaspectratio]{" + pdf + r"}") + cap = fig.get("caption") + if cap: + parts.append(r"\\[-0.3em]{\small\itshape " + esc(cap) + r"}") parts.append(r"\end{center}") - parts.append(r"\vspace{0.5em}") + parts.append(r"\vspace{0.4em}") # ── 4. interpretacja natalna ──────────────────────────────────────── if natal.get("text"): diff --git a/services/render/app/main.py b/services/render/app/main.py index 3ff6a44..4c3f790 100644 --- a/services/render/app/main.py +++ b/services/render/app/main.py @@ -33,7 +33,8 @@ link_crypto.install(app, link_crypto.ENV_PRESENTATION_RENDER, "render") @app.post("/pdf") def build_pdf(report: dict) -> Response: - """Raport (JSON) → PDF. Wejście: person, data, wheel_svg, natal, predictions. + """Raport (JSON) → PDF. Wejście: person, data, figures[{svg,caption}] (albo + starsze wheel_svg — pojedynczy rysunek), natal, predictions. Zwracamy surowe bajty PDF-a, nie base64 — łącze i tak jest szyfrowane, więc kodowanie tylko rozdmuchałoby odpowiedź o trzecią część. diff --git a/services/render/tests/test_latex.py b/services/render/tests/test_latex.py index 5aee160..5b5eda8 100644 --- a/services/render/tests/test_latex.py +++ b/services/render/tests/test_latex.py @@ -132,6 +132,43 @@ def test_wheel_is_optional(): assert "includegraphics" in build(_report(), wheel_pdf="wheel.pdf") +# ─────────── wiele rysunków w raporcie (koło + aspektarian + …) ─────────── + +def test_all_figures_are_embedded_in_order(): + """Co pokazujemy na stronie, ma trafić do PDF-a. Rysunki idą w PODANEJ + kolejności — koło, potem aspektarian, deklinacja, antyscja.""" + figs = [ + {"pdf": "fig0.pdf", "caption": "Kosmogram"}, + {"pdf": "fig1.pdf", "caption": "Aspektarian"}, + {"pdf": "fig2.pdf", "caption": "Wykres deklinacji"}, + ] + tex = build(_report(), figures=figs) + assert tex.count("includegraphics") == 3 + assert tex.index("fig0.pdf") < tex.index("fig1.pdf") < tex.index("fig2.pdf") + + +def test_figure_captions_are_printed_and_escaped(): + tex = build(_report(), figures=[{"pdf": "fig0.pdf", "caption": "Deklinacja 50%"}]) + assert "Deklinacja 50\\%" in tex # podpis eskejpowany jak reszta + + +def test_figures_sit_between_data_and_natal(): + """Rysunki nadal w sekcji 3 — po danych, przed interpretacją natalną.""" + tex = build(_report(), figures=[{"pdf": "fig0.pdf", "caption": "Kosmogram"}]) + assert tex.index("Data urodzenia") < tex.index("fig0.pdf") < tex.index("Interpretacja natalna") + + +def test_wheel_pdf_still_works_as_single_figure(): + """Zgodność wsteczna: pojedynczy wheel_pdf = jeden rysunek.""" + tex = build(_report(), wheel_pdf="wheel.pdf") + assert tex.count("includegraphics") == 1 and "wheel.pdf" in tex + + +def test_figure_without_pdf_is_skipped(): + tex = build(_report(), figures=[{"caption": "brak pliku"}, {"pdf": "fig1.pdf"}]) + assert tex.count("includegraphics") == 1 and "fig1.pdf" in tex + + # ─────────────────── Markdown → LaTeX (nie surowy copy-paste) ───────────── from app.latex import markdown_to_latex as md