feat: aspektarian, deklinacja i antyscja trafiają do raportu i PDF-a
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 11m13s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m49s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 29s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 20s
build-render / build (push) Successful in 44s
build / build (push) Successful in 54s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m19s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m59s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 34s
Testy / Kontrola składni wszystkich warstw (push) Successful in 25s

Zasada: co składamy, dodajemy i wyświetlamy, MA trafiać do finalnego raportu
(«Skompiluj») i do PDF-a — nie tylko na stronę /chart. Do tej pory do raportu i
druku szło samo koło; aspektarian (etap 5) i deklinacja/antyscja (etap 6) były
tylko w podglądzie horoskopu.

RAPORT «Skompiluj» (podgląd): handler /compile liczy teraz wszystkie cztery
rysunki, a szablon je pokazuje (koło, aspektarian, deklinacja, antyscja).

PDF (usługa render) — uogólnienie z jednego rysunku na LISTĘ:
- Kontrakt raportu: `figures = [{svg, caption}]` (uporządkowana). `wheel_svg`
  zostaje dla zgodności wstecznej jako pojedynczy rysunek.
- `compile.py`: każdy SVG osobno przez rsvg-convert → PDF (fig0…figN); braki/
  błędy pomijane (rysunek nie może wywalić raportu, tekst ważniejszy).
- `latex.py build()`: rysunki w sekcji 3 (po danych, przed natalną) w PODANEJ
  kolejności, każdy z podpisem. Jedna reguła składania: keepaspectratio z limitem
  szerokości i wysokości — kwadratowe (koło, aspektarian) ogranicza wysokość,
  szerokie (deklinacja, antyscja) szerokość, bez zniekształceń.
- `compile_pdf` (prezentacja): renderuje komplet w motywie DRUKU i wysyła jako
  `figures`.

Testy: render +5 (kolejność, podpisy, zgodność wsteczna, pomijanie pustych),
prezentacja +3 (raport pokazuje komplet, PDF składa komplet). Render 32,
prezentacja 173.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit was merged in pull request #41.
This commit is contained in:
2026-07-28 20:41:39 +02:00
parent 495f3734c5
commit f5dec15e4d
8 changed files with 150 additions and 30 deletions
+17 -7
View File
@@ -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 [],
}
@@ -91,6 +91,20 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
</figure>
{% endif %}
{% if declination_svg %}
<figure class="declination-fig">
{{ declination_svg | safe }}
<figcaption class="muted small">Wykres deklinacji — paralele i out-of-bounds (LOG-07).</figcaption>
</figure>
{% endif %}
{% if antiscia_svg %}
<figure class="antiscia-fig">
{{ antiscia_svg | safe }}
<figcaption class="muted small">Oś antyscji — odbicia względem przesileń i równonocy (LOG-07).</figcaption>
</figure>
{% endif %}
{# ── 3. Dane policzone ────────────────────────────────────────────── #}
<div class="meta">Horoskop · silnik {{ result.engine }}
{% if result.house_system %}· domy {{ result.house_system }}{% endif %}
@@ -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():
@@ -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 ──────────────────────────
+25 -12
View File
@@ -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"],
+24 -6
View File
@@ -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"):
+2 -1
View File
@@ -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ęść.
+37
View File
@@ -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