Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b55cab06ca | |||
| 8b17327ab5 | |||
| 72c52c83cd |
@@ -0,0 +1,54 @@
|
|||||||
|
services:
|
||||||
|
data:
|
||||||
|
image: gitea.czernobog.pl/gitea/astrololo-data:4b17f2dd
|
||||||
|
container_name: astrololo-data
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DATA_PROVIDER: ${DATA_PROVIDER:-excel}
|
||||||
|
EXCEL_DIR: /app/data_files
|
||||||
|
CACHE_DIR: /app/.cache
|
||||||
|
INDEXED_KEYS: name,id,symbol
|
||||||
|
volumes:
|
||||||
|
- ./services/data/data_files:/app/data_files
|
||||||
|
- data_cache:/app/.cache
|
||||||
|
ports:
|
||||||
|
- "8002:8002"
|
||||||
|
|
||||||
|
logic:
|
||||||
|
image: gitea.czernobog.pl/gitea/astrololo-logic:4b17f2dd
|
||||||
|
container_name: astrololo-logic
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DATA_URL: http://data:8002
|
||||||
|
EPHEMERIS_ENGINE: own # silnik własny (permisywny) — domyślny
|
||||||
|
# adres silnika B (AGPL) używany tylko w trybie porównawczym (profil comparison)
|
||||||
|
ENGINE_SWISSEPH_URL: http://engine-swisseph:8003
|
||||||
|
depends_on:
|
||||||
|
- data
|
||||||
|
ports:
|
||||||
|
- "8001:8001"
|
||||||
|
|
||||||
|
# Silnik B (AGPL) — OPCJONALNY, izolowany. Startuje tylko z profilem "comparison":
|
||||||
|
# docker compose --profile comparison up
|
||||||
|
# Nie wchodzi do domyślnego (zamkniętego) produktu — patrz services/engine-swisseph/LICENSE.
|
||||||
|
engine-swisseph:
|
||||||
|
image: gitea.czernobog.pl/gitea/astrololo-engine-swisseph:latest
|
||||||
|
container_name: astrololo-engine-swisseph
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles: ["comparison"]
|
||||||
|
ports:
|
||||||
|
- "8003:8003"
|
||||||
|
|
||||||
|
presentation:
|
||||||
|
image: gitea.czernobog.pl/gitea/astrololo-presentation:latest
|
||||||
|
container_name: astrololo-presentation
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
LOGIC_URL: http://logic:8001
|
||||||
|
depends_on:
|
||||||
|
- logic
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
data_cache:
|
||||||
@@ -443,3 +443,164 @@ def render_aspectarian(chart: dict, theme: str = "screen") -> str:
|
|||||||
return (f'<svg class="aspectarian" viewBox="0 0 {dim:.1f} {dim:.1f}" role="img" '
|
return (f'<svg class="aspectarian" viewBox="0 0 {dim:.1f} {dim:.1f}" role="img" '
|
||||||
f'aria-label="Aspektarian — siatka aspektów obiekt na obiekt" '
|
f'aria-label="Aspektarian — siatka aspektów obiekt na obiekt" '
|
||||||
f'xmlns="http://www.w3.org/2000/svg">{body}</svg>')
|
f'xmlns="http://www.w3.org/2000/svg">{body}</svg>')
|
||||||
|
|
||||||
|
|
||||||
|
# ── deklinacja i antyscja (LOG-07 → kosmogram etap 6) ─────────────────────
|
||||||
|
# Polskie nazwy zjawisk pozazodiakalnych — tylko do dymków.
|
||||||
|
_OOZ_PL = {
|
||||||
|
"parallel": "paralela", "contraparallel": "kontrparalela",
|
||||||
|
"antiscion": "antyscja", "contra_antiscion": "kontrantyscja",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _dashed(x1: float, y1: float, x2: float, y2: float, stroke: str,
|
||||||
|
w: float = 0.8, opacity: float = 0.9, dash: str = "4 3") -> str:
|
||||||
|
return (f'<line x1="{x1:.2f}" y1="{y1:.2f}" x2="{x2:.2f}" y2="{y2:.2f}" '
|
||||||
|
f'stroke="{stroke}" stroke-width="{w}" opacity="{opacity}" '
|
||||||
|
f'stroke-dasharray="{dash}"/>')
|
||||||
|
|
||||||
|
|
||||||
|
_DECL_W, _DECL_H = 470.0, 300.0
|
||||||
|
_DPX0, _DPX1 = 54.0, 452.0 # obszar rysunku w poziomie
|
||||||
|
_DPY0, _DPY1 = 30.0, 262.0 # góra (+Rmax) i dół (−Rmax)
|
||||||
|
|
||||||
|
|
||||||
|
def render_declination(chart: dict, theme: str = "screen") -> str:
|
||||||
|
"""Wykres deklinacji (LOG-07, etap 6). Pionowa skala deklinacji: obiekty na
|
||||||
|
tej samej wysokości są w PARALELI (działa jak koniunkcja poza ekliptyką), na
|
||||||
|
lustrzanych względem równika — w KONTRPARALELI (jak opozycja). Zwrotniki (±ε)
|
||||||
|
to granica „poza zakresem" (OOB): ciało za nimi ma większą deklinację niż
|
||||||
|
kiedykolwiek Słońce. Kolory łączników jak na kole. '' przy < 2 obiektach."""
|
||||||
|
T = _THEMES.get(theme, _THEMES["screen"])
|
||||||
|
glyph_font = GLYPH_FONT if theme == "print" else ""
|
||||||
|
|
||||||
|
objs = [p for p in (chart.get("positions") or []) if p.get("declination") is not None]
|
||||||
|
if len(objs) < 2:
|
||||||
|
return ""
|
||||||
|
eps = float(chart.get("obliquity") or 23.4367)
|
||||||
|
maxabs = max(abs(float(p["declination"])) for p in objs)
|
||||||
|
Rmax = max(eps + 3.0, maxabs + 2.0) # skala mieści zwrotniki i OOB
|
||||||
|
|
||||||
|
def y_of(dec: float) -> float:
|
||||||
|
return _DPY0 + (Rmax - dec) / (2.0 * Rmax) * (_DPY1 - _DPY0)
|
||||||
|
|
||||||
|
parts: list[str] = []
|
||||||
|
# strefy OOB (za zwrotnikami) — delikatne tło
|
||||||
|
yN, yS = y_of(eps), y_of(-eps)
|
||||||
|
parts.append(f'<rect x="{_DPX0:.1f}" y="{_DPY0:.1f}" width="{_DPX1 - _DPX0:.1f}" '
|
||||||
|
f'height="{yN - _DPY0:.1f}" fill="{T["retro"]}" opacity="0.07"/>')
|
||||||
|
parts.append(f'<rect x="{_DPX0:.1f}" y="{yS:.1f}" width="{_DPX1 - _DPX0:.1f}" '
|
||||||
|
f'height="{_DPY1 - yS:.1f}" fill="{T["retro"]}" opacity="0.07"/>')
|
||||||
|
# równik (0°) i zwrotniki (±ε)
|
||||||
|
yeq = y_of(0.0)
|
||||||
|
parts.append(_line(_DPX0, yeq, _DPX1, yeq, T["line"], 1.0, 0.9))
|
||||||
|
parts.append(_text(_DPX0 - 8, yeq, "0°", size=8, fill=T["muted"]))
|
||||||
|
for d, lab in ((eps, f"+{eps:.0f}°"), (-eps, f"−{eps:.0f}°")):
|
||||||
|
yy = y_of(d)
|
||||||
|
parts.append(_dashed(_DPX0, yy, _DPX1, yy, T["accent"], 0.8, 0.8))
|
||||||
|
parts.append(_text(_DPX0 - 8, yy, lab, size=8, fill=T["accent"]))
|
||||||
|
parts.append(_text((_DPX0 + _DPX1) / 2, 16,
|
||||||
|
"deklinacja — poza ±ε (zwrotniki) obiekt jest OOB", size=8, fill=T["muted"]))
|
||||||
|
|
||||||
|
# X równomiernie wg POSORTOWANEJ deklinacji — paralele lądują obok siebie
|
||||||
|
order = sorted(range(len(objs)), key=lambda i: float(objs[i]["declination"]))
|
||||||
|
n = len(order)
|
||||||
|
xy: dict = {}
|
||||||
|
for slot, idx in enumerate(order):
|
||||||
|
x = _DPX0 + (slot + 0.5) / n * (_DPX1 - _DPX0)
|
||||||
|
xy[objs[idx]["name"]] = (x, y_of(float(objs[idx]["declination"])))
|
||||||
|
|
||||||
|
# łączniki paralel/kontrparalel (zielony/czerwony — jak koniunkcja/opozycja)
|
||||||
|
for par in (chart.get("parallels") or []):
|
||||||
|
a = xy.get(par.get("obj1")); b = xy.get(par.get("obj2"))
|
||||||
|
if not a or not b:
|
||||||
|
continue
|
||||||
|
kind = par.get("type")
|
||||||
|
color = T["aspects"]["conjunction"] if kind == "parallel" else T["aspects"]["opposition"]
|
||||||
|
orb = float(par.get("orb", 0.0))
|
||||||
|
w, op = _aspect_pen(orb, par.get("allowed"))
|
||||||
|
tip = f'{par.get("obj1")} {_OOZ_PL.get(kind, kind)} {par.get("obj2")} · orb {orb:.2f}°'
|
||||||
|
parts.append(_line(a[0], a[1], b[0], b[1], color, w, op, title=tip))
|
||||||
|
|
||||||
|
# glify na wysokości ich deklinacji + podpis stopnia; OOB wyróżnione
|
||||||
|
for p in objs:
|
||||||
|
x, y = xy[p["name"]]
|
||||||
|
oob = bool(p.get("out_of_bounds"))
|
||||||
|
dec = float(p["declination"])
|
||||||
|
tip = f'{p.get("name")} · dekl. {dec:+.2f}°' + (" · OOB" if oob else "")
|
||||||
|
parts.append(_text(x, y, p.get("glyph") or "", size=15,
|
||||||
|
fill=T["retro"] if oob else T["ink"],
|
||||||
|
cls="glyph", font=glyph_font, title=tip))
|
||||||
|
parts.append(_text(x, y + 12, f"{dec:+.1f}", size=7,
|
||||||
|
fill=T["retro"] if oob else T["muted"]))
|
||||||
|
|
||||||
|
body = "".join(parts)
|
||||||
|
return (f'<svg class="declination" viewBox="0 0 {_DECL_W:.0f} {_DECL_H:.0f}" role="img" '
|
||||||
|
f'aria-label="Wykres deklinacji — paralele i out-of-bounds" '
|
||||||
|
f'xmlns="http://www.w3.org/2000/svg">{body}</svg>')
|
||||||
|
|
||||||
|
|
||||||
|
_ANT_W, _ANT_H = 470.0, 150.0
|
||||||
|
_ANT_X0, _ANT_X1 = 22.0, 448.0
|
||||||
|
_ANT_Y = 100.0 # oś ekliptyki
|
||||||
|
|
||||||
|
|
||||||
|
def render_antiscia(chart: dict, theme: str = "screen") -> str:
|
||||||
|
"""Oś antyscji (LOG-07, etap 6). Ekliptyka rozwinięta w poziomą oś; pionowo
|
||||||
|
zaznaczona OŚ PRZESILEŃ (0° Raka / 0° Koziorożca) — względem niej odbija się
|
||||||
|
antyscja, a względem osi równonocy (0° Barana / Wagi) — kontrantyscja. Pary
|
||||||
|
połączone łukiem (zielony antyscja / czerwony kontrantyscja). '' przy < 2
|
||||||
|
obiektach."""
|
||||||
|
T = _THEMES.get(theme, _THEMES["screen"])
|
||||||
|
glyph_font = GLYPH_FONT if theme == "print" else ""
|
||||||
|
objs = [p for p in (chart.get("positions") or []) if p.get("decimal") is not None]
|
||||||
|
if len(objs) < 2:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def x_of(lon: float) -> float:
|
||||||
|
return _ANT_X0 + (lon % 360.0) / 360.0 * (_ANT_X1 - _ANT_X0)
|
||||||
|
|
||||||
|
parts: list[str] = []
|
||||||
|
parts.append(_line(_ANT_X0, _ANT_Y, _ANT_X1, _ANT_Y, T["line"], 1.0, 0.9))
|
||||||
|
signs = chart.get("sign_glyphs") or []
|
||||||
|
for i in range(12):
|
||||||
|
xb = x_of(i * 30.0)
|
||||||
|
parts.append(_line(xb, _ANT_Y - 5, xb, _ANT_Y + 5, T["line"], 0.6, 0.6))
|
||||||
|
if i < len(signs):
|
||||||
|
parts.append(_text(x_of(i * 30.0 + 15.0), _ANT_Y + 17, signs[i].get("glyph") or "",
|
||||||
|
size=11, fill=T["muted"], cls="glyph", font=glyph_font))
|
||||||
|
# osie odbicia: przesileń (90/270) mocno, równonocy (0/180) delikatnie
|
||||||
|
for lon, col, op in ((90.0, T["accent"], 0.9), (270.0, T["accent"], 0.9),
|
||||||
|
(0.0, T["muted"], 0.5), (180.0, T["muted"], 0.5)):
|
||||||
|
xx = x_of(lon)
|
||||||
|
parts.append(_dashed(xx, _ANT_Y - 44, xx, _ANT_Y + 24, col, 0.9, op))
|
||||||
|
parts.append(_text((_ANT_X0 + _ANT_X1) / 2, 15,
|
||||||
|
"oś przesileń — antyscja · oś równonocy — kontrantyscja",
|
||||||
|
size=8, fill=T["muted"]))
|
||||||
|
|
||||||
|
lon_by = {p["name"]: float(p["decimal"]) for p in objs}
|
||||||
|
for row in (chart.get("antiscia") or []):
|
||||||
|
la = lon_by.get(row.get("obj1")); lb = lon_by.get(row.get("obj2"))
|
||||||
|
if la is None or lb is None:
|
||||||
|
continue
|
||||||
|
kind = row.get("type")
|
||||||
|
color = T["aspects"]["conjunction"] if kind == "antiscion" else T["aspects"]["opposition"]
|
||||||
|
xa, xb = x_of(la), x_of(lb)
|
||||||
|
orb = float(row.get("orb", 0.0))
|
||||||
|
tip = f'{row.get("obj1")} {_OOZ_PL.get(kind, kind)} {row.get("obj2")} · orb {orb:.2f}°'
|
||||||
|
midx = (xa + xb) / 2.0
|
||||||
|
top = _ANT_Y - 24 - abs(xb - xa) * 0.05
|
||||||
|
parts.append(f'<path d="M {xa:.1f} {_ANT_Y - 6:.1f} Q {midx:.1f} {top:.1f} '
|
||||||
|
f'{xb:.1f} {_ANT_Y - 6:.1f}" fill="none" stroke="{color}" '
|
||||||
|
f'stroke-width="1.2" opacity="0.9"><title>{escape(tip)}</title></path>')
|
||||||
|
|
||||||
|
for p in objs:
|
||||||
|
x = x_of(float(p["decimal"]))
|
||||||
|
parts.append(_text(x, _ANT_Y - 12, p.get("glyph") or "", size=12, fill=T["ink"],
|
||||||
|
cls="glyph", font=glyph_font,
|
||||||
|
title=f'{p.get("name")} · {p.get("in_sign", "")}'.strip(" ·")))
|
||||||
|
|
||||||
|
body = "".join(parts)
|
||||||
|
return (f'<svg class="antiscia" viewBox="0 0 {_ANT_W:.0f} {_ANT_H:.0f}" role="img" '
|
||||||
|
f'aria-label="Oś antyscji — odbicia względem przesileń i równonocy" '
|
||||||
|
f'xmlns="http://www.w3.org/2000/svg">{body}</svg>')
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ def chart_compute(
|
|||||||
from app import chartwheel # kosmogram (PRE-12), SVG po stronie serwera
|
from app import chartwheel # kosmogram (PRE-12), SVG po stronie serwera
|
||||||
ctx["wheel_svg"] = chartwheel.render(ctx["result"])
|
ctx["wheel_svg"] = chartwheel.render(ctx["result"])
|
||||||
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
|
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:
|
except (httpx.HTTPError,) as e:
|
||||||
ctx["error"] = _logic_error(e)
|
ctx["error"] = _logic_error(e)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -145,6 +147,8 @@ def compile_build(
|
|||||||
from app import chartwheel
|
from app import chartwheel
|
||||||
ctx["wheel_svg"] = chartwheel.render(ctx["result"])
|
ctx["wheel_svg"] = chartwheel.render(ctx["result"])
|
||||||
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
|
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:
|
except (httpx.HTTPError,) as e:
|
||||||
ctx["error"] = _logic_error(e)
|
ctx["error"] = _logic_error(e)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -176,7 +180,7 @@ def compile_pdf(payload: dict):
|
|||||||
except (ValueError, TypeError) as e:
|
except (ValueError, TypeError) as e:
|
||||||
return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422)
|
return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422)
|
||||||
|
|
||||||
wheel_svg = ""
|
figures: list[dict] = []
|
||||||
try:
|
try:
|
||||||
chart = logic.positions(
|
chart = logic.positions(
|
||||||
when_utc_iso=iso_utc,
|
when_utc_iso=iso_utc,
|
||||||
@@ -186,17 +190,25 @@ def compile_pdf(payload: dict):
|
|||||||
)
|
)
|
||||||
from app import chartwheel
|
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:
|
except httpx.HTTPError as e:
|
||||||
# Brak rysunku nie może zablokować raportu — tekst jest ważniejszy.
|
# Brak rysunków nie może zablokować raportu — tekst jest ważniejszy.
|
||||||
log_note = _logic_error(e)
|
data = {**data, "wheel_error": _logic_error(e)}
|
||||||
chart, wheel_svg = None, ""
|
|
||||||
data = {**data, "wheel_error": log_note}
|
|
||||||
|
|
||||||
report = {
|
report = {
|
||||||
"person": payload.get("person") or "",
|
"person": payload.get("person") or "",
|
||||||
"data": {**data, "moment_utc": label},
|
"data": {**data, "moment_utc": label},
|
||||||
"wheel_svg": wheel_svg,
|
"figures": figures,
|
||||||
"natal": payload.get("natal") or {},
|
"natal": payload.get("natal") or {},
|
||||||
"predictions": payload.get("predictions") or [],
|
"predictions": payload.get("predictions") or [],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,13 @@ svg.wheel text.glyph { font-size: inherit; }
|
|||||||
.aspectarian-fig figcaption { margin-top: .4rem; }
|
.aspectarian-fig figcaption { margin-top: .4rem; }
|
||||||
svg.aspectarian text.glyph { font-size: inherit; }
|
svg.aspectarian text.glyph { font-size: inherit; }
|
||||||
|
|
||||||
|
/* Wykres deklinacji i oś antyscji (LOG-07, etap 6) — szersze, pełna szerokość. */
|
||||||
|
.declination-fig, .antiscia-fig { margin: 1.25rem 0 .75rem; text-align: center; }
|
||||||
|
.declination-fig svg.declination { width: 100%; max-width: 560px; height: auto; }
|
||||||
|
.antiscia-fig svg.antiscia { width: 100%; max-width: 560px; height: auto; }
|
||||||
|
.declination-fig figcaption, .antiscia-fig figcaption { margin-top: .4rem; }
|
||||||
|
svg.declination text.glyph, svg.antiscia text.glyph { font-size: inherit; }
|
||||||
|
|
||||||
/* Powiększenie na pełne okno (PRE-25). Kliknięcie rozciąga wykres na cały ekran,
|
/* Powiększenie na pełne okno (PRE-25). Kliknięcie rozciąga wykres na cały ekran,
|
||||||
ponowne wraca. z-index 1100 CELOWO pomiędzy: ponad kontrolki Leafleta (1000),
|
ponowne wraca. z-index 1100 CELOWO pomiędzy: ponad kontrolki Leafleta (1000),
|
||||||
ale PONIŻEJ okna postępu (1200) — gdy trwa pisanie horoskopu, log operacji ma
|
ale PONIŻEJ okna postępu (1200) — gdy trwa pisanie horoskopu, log operacji ma
|
||||||
|
|||||||
@@ -170,6 +170,13 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# Aspekty pozazodiakalne (LOG-07): paralele deklinacji i antyscja #}
|
{# Aspekty pozazodiakalne (LOG-07): paralele deklinacji i antyscja #}
|
||||||
|
{% if declination_svg %}
|
||||||
|
<figure class="declination-fig">
|
||||||
|
{{ declination_svg | safe }}
|
||||||
|
<figcaption class="muted small">Wykres deklinacji — obiekty na tej samej wysokości są w <strong>paraleli</strong> (jak koniunkcja poza ekliptyką, zielony łącznik), lustrzane względem równika — w <strong>kontrparaleli</strong> (jak opozycja, czerwony). Poza zwrotnikami (±ε, strefa cieniowana) obiekt jest <strong>OOB</strong>. Najedź po szczegóły.</figcaption>
|
||||||
|
</figure>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if result.parallels %}
|
{% if result.parallels %}
|
||||||
<div class="meta" title="Ciała na tej samej (paralela) lub przeciwnej (kontrparalela) deklinacji — działają jak koniunkcja / opozycja poza ekliptyką">Paralele deklinacji ({{ result.parallels | length }})</div>
|
<div class="meta" title="Ciała na tej samej (paralela) lub przeciwnej (kontrparalela) deklinacji — działają jak koniunkcja / opozycja poza ekliptyką">Paralele deklinacji ({{ result.parallels | length }})</div>
|
||||||
<table class="angles">
|
<table class="angles">
|
||||||
@@ -188,6 +195,13 @@
|
|||||||
</table>
|
</table>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if antiscia_svg %}
|
||||||
|
<figure class="antiscia-fig">
|
||||||
|
{{ antiscia_svg | safe }}
|
||||||
|
<figcaption class="muted small">Oś antyscji — ekliptyka rozwinięta w poziom; pionowa oś przesileń (0° Raka / Koziorożca) to lustro <strong>antyscji</strong>, oś równonocy (0° Barana / Wagi) — <strong>kontrantyscji</strong>. Łuki łączą pary. Najedź po szczegóły.</figcaption>
|
||||||
|
</figure>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if result.antiscia %}
|
{% if result.antiscia %}
|
||||||
<div class="meta" title="Odbicie względem osi przesileń (antyscja) lub równonocy (kontrantyscja) — punkty „dzielące” tę samą długość dnia">Antyscja ({{ result.antiscia | length }})</div>
|
<div class="meta" title="Odbicie względem osi przesileń (antyscja) lub równonocy (kontrantyscja) — punkty „dzielące” tę samą długość dnia">Antyscja ({{ result.antiscia | length }})</div>
|
||||||
<table class="angles">
|
<table class="angles">
|
||||||
|
|||||||
@@ -91,6 +91,20 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
|
|||||||
</figure>
|
</figure>
|
||||||
{% endif %}
|
{% 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 ────────────────────────────────────────────── #}
|
{# ── 3. Dane policzone ────────────────────────────────────────────── #}
|
||||||
<div class="meta">Horoskop · silnik {{ result.engine }}
|
<div class="meta">Horoskop · silnik {{ result.engine }}
|
||||||
{% if result.house_system %}· domy {{ result.house_system }}{% endif %}
|
{% if result.house_system %}· domy {{ result.house_system }}{% endif %}
|
||||||
|
|||||||
@@ -429,3 +429,117 @@ def test_aspectarian_print_theme_has_no_css_vars():
|
|||||||
svg = chartwheel.render_aspectarian(_chart_with_aspects(), theme="print")
|
svg = chartwheel.render_aspectarian(_chart_with_aspects(), theme="print")
|
||||||
assert "var(--" not in svg
|
assert "var(--" not in svg
|
||||||
assert "#a83232" in svg # square napięty w wariancie druku
|
assert "#a83232" in svg # square napięty w wariancie druku
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────── etap 6: deklinacja i antyscja (LOG-07) ───────────────────
|
||||||
|
|
||||||
|
def _chart_with_declination():
|
||||||
|
chart = _chart_with_objects() # Sun/Moon/Venus/Mercury
|
||||||
|
chart["obliquity"] = 23.44
|
||||||
|
decs = {"Sun": 15.0, "Moon": 15.2, "Venus": -15.0, "Mercury": 25.5}
|
||||||
|
for p in chart["positions"]:
|
||||||
|
p["declination"] = decs[p["name"]]
|
||||||
|
if p["name"] == "Mercury":
|
||||||
|
p["out_of_bounds"] = True # 25.5° > ε=23.44 → OOB
|
||||||
|
chart["parallels"] = [
|
||||||
|
{"obj1": "Sun", "obj2": "Moon", "type": "parallel",
|
||||||
|
"orb": 0.2, "allowed": 1.0, "dec1": 15.0, "dec2": 15.2},
|
||||||
|
{"obj1": "Sun", "obj2": "Venus", "type": "contraparallel",
|
||||||
|
"orb": 0.0, "allowed": 1.0, "dec1": 15.0, "dec2": -15.0},
|
||||||
|
]
|
||||||
|
return chart
|
||||||
|
|
||||||
|
|
||||||
|
def test_declination_is_well_formed_xml():
|
||||||
|
svg = chartwheel.render_declination(_chart_with_declination())
|
||||||
|
svg = (svg.replace("var(--line)", "#000").replace("var(--accent)", "#000")
|
||||||
|
.replace("var(--muted)", "#000").replace("var(--ink)", "#000"))
|
||||||
|
assert minidom.parseString(svg).documentElement.tagName == "svg"
|
||||||
|
|
||||||
|
|
||||||
|
def test_declination_empty_for_fewer_than_two():
|
||||||
|
chart = _chart_with_declination()
|
||||||
|
chart["positions"] = chart["positions"][:1]
|
||||||
|
assert chartwheel.render_declination(chart) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_declination_draws_equator_and_tropics():
|
||||||
|
"""Równik (0°) i dwie linie zwrotników (kreskowane) muszą być."""
|
||||||
|
svg = chartwheel.render_declination(_chart_with_declination())
|
||||||
|
assert ">0°<" in svg
|
||||||
|
assert svg.count("stroke-dasharray") >= 2 # ±ε kreskowane
|
||||||
|
|
||||||
|
|
||||||
|
def test_declination_object_glyphs_present():
|
||||||
|
svg = chartwheel.render_declination(_chart_with_declination())
|
||||||
|
for g in ("☉", "☽", "♀", "☿"):
|
||||||
|
assert g in svg
|
||||||
|
|
||||||
|
|
||||||
|
def test_declination_oob_object_is_highlighted():
|
||||||
|
"""Merkury poza zwrotnikami (OOB) w kolorze wyróżnienia + z „OOB" w dymku."""
|
||||||
|
svg = chartwheel.render_declination(_chart_with_declination())
|
||||||
|
assert "#ff9b6a" in svg # kolor retro/OOB (motyw ekranowy)
|
||||||
|
assert "OOB" in svg
|
||||||
|
|
||||||
|
|
||||||
|
def test_declination_parallel_and_contraparallel_colors():
|
||||||
|
"""Paralela jak koniunkcja (zielony), kontrparalela jak opozycja (czerwony)."""
|
||||||
|
svg = chartwheel.render_declination(_chart_with_declination())
|
||||||
|
assert "#8fae7a" in svg # parallel → zielony
|
||||||
|
assert "#cf6a6a" in svg # contraparallel → czerwony
|
||||||
|
assert "paralela" in svg # dymek
|
||||||
|
|
||||||
|
|
||||||
|
def test_declination_print_theme_has_no_css_vars():
|
||||||
|
svg = chartwheel.render_declination(_chart_with_declination(), theme="print")
|
||||||
|
assert "var(--" not in svg
|
||||||
|
|
||||||
|
|
||||||
|
def _chart_with_antiscia():
|
||||||
|
chart = _chart_with_objects()
|
||||||
|
chart["antiscia"] = [
|
||||||
|
{"obj1": "Sun", "obj2": "Moon", "type": "antiscion", "orb": 0.3, "allowed": 1.0},
|
||||||
|
{"obj1": "Venus", "obj2": "Mercury", "type": "contra_antiscion", "orb": 0.5, "allowed": 1.0},
|
||||||
|
]
|
||||||
|
return chart
|
||||||
|
|
||||||
|
|
||||||
|
def test_antiscia_is_well_formed_xml():
|
||||||
|
svg = chartwheel.render_antiscia(_chart_with_antiscia())
|
||||||
|
svg = (svg.replace("var(--line)", "#000").replace("var(--accent)", "#000")
|
||||||
|
.replace("var(--muted)", "#000").replace("var(--ink)", "#000"))
|
||||||
|
assert minidom.parseString(svg).documentElement.tagName == "svg"
|
||||||
|
|
||||||
|
|
||||||
|
def test_antiscia_empty_for_fewer_than_two():
|
||||||
|
chart = _chart_with_antiscia()
|
||||||
|
chart["positions"] = chart["positions"][:1]
|
||||||
|
assert chartwheel.render_antiscia(chart) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_antiscia_draws_reflection_axes():
|
||||||
|
"""Oś przesileń (i równonocy) — linie kreskowane."""
|
||||||
|
svg = chartwheel.render_antiscia(_chart_with_antiscia())
|
||||||
|
assert svg.count("stroke-dasharray") >= 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_antiscia_pair_colors_and_tooltip():
|
||||||
|
"""Antyscja jak koniunkcja (zielony łuk), kontrantyscja jak opozycja (czerwony)."""
|
||||||
|
svg = chartwheel.render_antiscia(_chart_with_antiscia())
|
||||||
|
assert "#8fae7a" in svg # antiscion → zielony
|
||||||
|
assert "#cf6a6a" in svg # contra_antiscion → czerwony
|
||||||
|
assert "antyscja" in svg and "<path" in svg
|
||||||
|
|
||||||
|
|
||||||
|
def test_antiscia_renders_axis_even_without_pairs():
|
||||||
|
"""Bez par (ciasny orb) oś i obiekty i tak coś mówią."""
|
||||||
|
chart = _chart_with_objects()
|
||||||
|
chart["antiscia"] = []
|
||||||
|
svg = chartwheel.render_antiscia(chart)
|
||||||
|
assert svg and "stroke-dasharray" in svg and "☉" in svg
|
||||||
|
|
||||||
|
|
||||||
|
def test_antiscia_print_theme_has_no_css_vars():
|
||||||
|
svg = chartwheel.render_antiscia(_chart_with_antiscia(), theme="print")
|
||||||
|
assert "var(--" not in svg
|
||||||
|
|||||||
@@ -61,6 +61,33 @@ def test_person_name_heads_the_report():
|
|||||||
assert "form.person" in TPL
|
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 ────────────────
|
# ──────────────────────── zbieranie materiału z magazynów ────────────────
|
||||||
|
|
||||||
def test_reads_stores_through_their_api_not_raw_storage():
|
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():
|
def test_wheel_failure_does_not_block_the_report():
|
||||||
"""Brak rysunku ma dać raport bez kosmogramu, a nie brak raportu — tekst
|
"""Brak rysunków ma dać raport bez nich, a nie brak raportu — tekst kosztował
|
||||||
kosztował wywołanie modelu, rysunek policzymy zawsze."""
|
wywołanie modelu, rysunki policzymy zawsze."""
|
||||||
assert "wheel_svg = \"\"" in MAIN or 'wheel_svg, = ""' in MAIN
|
assert "figures: list[dict] = []" in MAIN
|
||||||
assert "Brak rysunku nie może zablokować raportu" in MAIN
|
assert "Brak rysunków nie może zablokować raportu" in MAIN
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────── pomocnicze ──────────────────────────
|
# ─────────────────────────────────── pomocnicze ──────────────────────────
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ from app.latex import build
|
|||||||
|
|
||||||
log = logging.getLogger("astrololo.render")
|
log = logging.getLogger("astrololo.render")
|
||||||
|
|
||||||
WHEEL_SVG = "wheel.svg"
|
|
||||||
WHEEL_PDF = "wheel.pdf"
|
|
||||||
DOC = "report"
|
DOC = "report"
|
||||||
TIMEOUT = 120 # xelatex na dużym tekście potrzebuje chwili
|
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)
|
return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=TIMEOUT)
|
||||||
|
|
||||||
|
|
||||||
def _svg_to_pdf(svg: str, work: pathlib.Path) -> str | None:
|
def _svg_to_pdf(svg: str, work: pathlib.Path, stem: str) -> str | None:
|
||||||
"""Zwraca nazwę pliku PDF z kosmogramem albo None, gdy nie ma czego rysować."""
|
"""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():
|
if not svg or not svg.strip():
|
||||||
return None
|
return None
|
||||||
(work / WHEEL_SVG).write_text(svg, encoding="utf-8")
|
svg_name, pdf_name = f"{stem}.svg", f"{stem}.pdf"
|
||||||
proc = _run(["rsvg-convert", "-f", "pdf", "-o", WHEEL_PDF, WHEEL_SVG], work)
|
(work / svg_name).write_text(svg, encoding="utf-8")
|
||||||
if proc.returncode != 0 or not (work / WHEEL_PDF).exists():
|
proc = _run(["rsvg-convert", "-f", "pdf", "-o", pdf_name, svg_name], work)
|
||||||
# Brak rysunku nie może wywalić całego raportu — tekst jest ważniejszy.
|
if proc.returncode != 0 or not (work / pdf_name).exists():
|
||||||
log.warning("konwersja kosmogramu nie udała się: %s", (proc.stderr or "")[-300:])
|
log.warning("konwersja rysunku %s nie udała się: %s", stem, (proc.stderr or "")[-300:])
|
||||||
return None
|
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:
|
def to_pdf(report: dict) -> bytes:
|
||||||
"""Raport (dict) → bajty PDF. Rzuca RenderError, gdy LaTeX nie zbuduje pliku."""
|
"""Raport (dict) → bajty PDF. Rzuca RenderError, gdy LaTeX nie zbuduje pliku."""
|
||||||
with tempfile.TemporaryDirectory(prefix="astrololo-render-") as tmp:
|
with tempfile.TemporaryDirectory(prefix="astrololo-render-") as tmp:
|
||||||
work = pathlib.Path(tmp)
|
work = pathlib.Path(tmp)
|
||||||
wheel = _svg_to_pdf(report.get("wheel_svg") or "", work)
|
figures = _figures(report, work)
|
||||||
(work / f"{DOC}.tex").write_text(build(report, wheel), encoding="utf-8")
|
(work / f"{DOC}.tex").write_text(build(report, figures=figures), encoding="utf-8")
|
||||||
|
|
||||||
proc = _run(
|
proc = _run(
|
||||||
["xelatex", "-interaction=nonstopmode", "-halt-on-error", f"{DOC}.tex"],
|
["xelatex", "-interaction=nonstopmode", "-halt-on-error", f"{DOC}.tex"],
|
||||||
|
|||||||
@@ -160,12 +160,20 @@ PREAMBLE = r"""\documentclass[11pt,a4paper]{article}
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def build(report: dict, wheel_pdf: str | None = None) -> str:
|
def build(report: dict, wheel_pdf: str | None = None,
|
||||||
"""Składa źródło .tex. `wheel_pdf` to nazwa pliku z kosmogramem (już PDF)."""
|
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()
|
person = str(report.get("person") or "").strip()
|
||||||
data = report.get("data") or {}
|
data = report.get("data") or {}
|
||||||
natal = report.get("natal") or {}
|
natal = report.get("natal") or {}
|
||||||
predictions = report.get("predictions") or []
|
predictions = report.get("predictions") or []
|
||||||
|
if figures is None:
|
||||||
|
figures = [{"pdf": wheel_pdf, "caption": "Kosmogram"}] if wheel_pdf else []
|
||||||
|
|
||||||
parts = [PREAMBLE]
|
parts = [PREAMBLE]
|
||||||
|
|
||||||
@@ -191,12 +199,22 @@ def build(report: dict, wheel_pdf: str | None = None) -> str:
|
|||||||
parts.append(r"\end{tabular}")
|
parts.append(r"\end{tabular}")
|
||||||
parts.append(r"\vspace{1em}")
|
parts.append(r"\vspace{1em}")
|
||||||
|
|
||||||
# ── 3. kosmogram ────────────────────────────────────────────────────
|
# ── 3. rysunki kosmogramu ───────────────────────────────────────────
|
||||||
if wheel_pdf:
|
# 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"\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"\end{center}")
|
||||||
parts.append(r"\vspace{0.5em}")
|
parts.append(r"\vspace{0.4em}")
|
||||||
|
|
||||||
# ── 4. interpretacja natalna ────────────────────────────────────────
|
# ── 4. interpretacja natalna ────────────────────────────────────────
|
||||||
if natal.get("text"):
|
if natal.get("text"):
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ link_crypto.install(app, link_crypto.ENV_PRESENTATION_RENDER, "render")
|
|||||||
|
|
||||||
@app.post("/pdf")
|
@app.post("/pdf")
|
||||||
def build_pdf(report: dict) -> Response:
|
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
|
Zwracamy surowe bajty PDF-a, nie base64 — łącze i tak jest szyfrowane, więc
|
||||||
kodowanie tylko rozdmuchałoby odpowiedź o trzecią część.
|
kodowanie tylko rozdmuchałoby odpowiedź o trzecią część.
|
||||||
|
|||||||
@@ -130,6 +130,43 @@ def test_wheel_is_optional():
|
|||||||
assert "includegraphics" in build(_report(), wheel_pdf="wheel.pdf")
|
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) ─────────────
|
# ─────────────────── Markdown → LaTeX (nie surowy copy-paste) ─────────────
|
||||||
|
|
||||||
from app.latex import markdown_to_latex as md
|
from app.latex import markdown_to_latex as md
|
||||||
|
|||||||
Reference in New Issue
Block a user