Files
astrololo/services/render/app/latex.py
T
gitea 7266f671a4
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m30s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m28s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 17s
Testy / Kontrola składni wszystkich warstw (push) Successful in 8s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m28s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m28s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 18s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 8s
feat(domy): Placidus i Koch + jawny fallback poza kołem podbiegunowym
Etap 2: dwa systemy łuku dobowego — jedyne, które mają miejsca, gdzie po prostu
NIE ISTNIEJĄ. Koch zgadza się z wyrocznią co do zera; Placidus do 1,7e-6°, przy
czym to próg zbieżności WYROCZNI, nie nasz: nasze cuspy spełniają definicję
Placidusa z dokładnością 1e-12° (osobny test, działa bez swissepha).

Placidus jako jedyny nie ma wzoru zamkniętego — cusp jest zdefiniowany warunkiem
na samego siebie („punkt, który przebył 1/3 swojego półłuku"), więc iterujemy po
punkcie stałym do 1e-11°. Brak zbieżności traktujemy jako wyjście poza dziedzinę,
nie jako wynik.

Koch okazał się natomiast ZAMKNIĘTY: jego kryterium to czas od wschodu stopnia
stojącego na MC, a półłuk tego stopnia znamy wprost z jego deklinacji. Definicja
za Astrodienst (astro.com/astrowiki/en/Koch_House_System) — nie zgadywana.

Powyżej koła podbiegunowego oba odmawiają liczenia i wyrocznia odmawia dokładnie
tych samych przypadków (5245/20 000 losowych, zero rozjazdów dziedziny). Odmowa
jest wyjątkiem, nie liczbą: cicha podmiana systemu jest niewykrywalna z wykresu.

Ustępstwo wobec rzeczywistości siedzi osobno, w cusps_detailed(): podstawia
Porphyry'ego i ZAWSZE zostawia ślad. Ten ślad idzie wszystkimi trzema wyjściami —
na ekran (ramka, nie „muted"), w prompt do modelu (inaczej napisze „Twój Placidus"
o Porphyrym) i do PDF-a, w ramce PRZED rysunkami. Astrolog z Tromsø dostaje
wynik i wie, że go dostał inaczej.

Przy okazji: nazwy systemów były zaszyte w czterech szablonach naraz. Przy trzech
systemach uchodziło to na sucho, przy dziesięciu nie — jest katalog w jednym
miejscu, a testy szablonów RENDERUJĄ je zamiast szukać tekstu w źródle, więc
łapią też literówki w Jinja.

Większe jądro efemeryd (de441): świadomie zdegradowane do „nice to have" —
rozszerza wyłącznie zakres dat, nie poprawia niczego w obecnym. Odnotowane
w domain.py przy JD_MIN/JD_MAX, żeby nie wróciło po cichu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:04:33 +02:00

261 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Budowa dokumentu LaTeX raportu (PRE-24).
Układ wg prośby partnerów:
1. imię i nazwisko,
2. wprowadzone dane,
3. rysunek kosmogramu,
4. interpretacja natalna,
5. predykcje okresowe.
Silnik: XeLaTeX. Nie pdflatex — tekst zawiera polskie znaki ORAZ glify
astrologiczne (♄ ♓ ☉ …), a te wymagają silnika unicode'owego z fontem systemowym.
DejaVu Sans ma jedno i drugie, dlatego jest fontem głównym.
"""
from __future__ import annotations
import re
# Znaki, które w LaTeXu mają własne znaczenie.
#
# Odwrotnego ukośnika NIE MA na tej liście celowo. Jego zamiennik
# (`\textbackslash{}`) sam zawiera nawiasy klamrowe, więc gdyby szedł w tej samej
# pętli, kolejne podmiany uciekłyby nawiasy, które dopiero co wstawiliśmy —
# i zamiast ukośnika wyszedłby dosłowny tekst `\textbackslash\{\}`. Dlatego
# ukośnik chowamy najpierw pod znacznik zastępczy, a rozwijamy na samym końcu.
_ESCAPES = [
("{", r"\{"),
("}", r"\}"),
("$", r"\$"),
("&", r"\&"),
("#", r"\#"),
("_", r"\_"),
("%", r"\%"),
("~", r"\textasciitilde{}"),
("^", r"\textasciicircum{}"),
]
# Znak spoza tekstu użytkownika — nie da się go wpisać, więc nie da się nim
# oszukać ucieczki.
_BACKSLASH_MARK = "\x00astrololo-backslash\x00"
def esc(text: object) -> str:
"""Ucieka znaki specjalne LaTeXa.
Najgroźniejszy jest `%`: bez ucieczki komentuje RESZTĘ LINII, więc tekst od
modelu urwałby się w środku zdania i nikt by tego nie zauważył — PDF
powstałby normalnie, tylko krótszy. Dlatego to nie kosmetyka, a warunek
poprawności raportu.
"""
out = "" if text is None else str(text)
out = out.replace("\\", _BACKSLASH_MARK) # schowaj, żeby nie ucierpiał niżej
for ch, rep in _ESCAPES:
out = out.replace(ch, rep)
return out.replace(_BACKSLASH_MARK, r"\textbackslash{}")
# Markdown inline: pogrubienie+kursywa, pogrubienie, kursywa, kod, linki. Kolejność
# w alternatywie ma znaczenie i jest OD NAJDŁUŻSZEGO znacznika: `***...***` przed
# `**...**` przed `*...*`. Inaczej `***mocne***` złapałoby się jako `**` + zgubiona
# gwiazdka i w PDF zostawałby wisior `*` (realny błąd, który to naprawia). Kursywę
# bierzemy tylko z gwiazdek (nie z podkreśleń): `_` pada w nazwach typu file_name.
_INLINE = re.compile(
r"\*\*\*(?P<bolditalic>[^\n]+?)\*\*\*"
r"|\*\*(?P<bold>[^\n]+?)\*\*"
r"|\*(?P<ital>[^*\n]+?)\*"
r"|`(?P<code>[^`\n]+?)`"
r"|\[(?P<ltext>[^\]]+)\]\((?P<lurl>[^)]+)\)"
)
def _inline(text: str) -> str:
"""Formatowanie w linii → LaTeX. Tekst poza znacznikami jest ESKEJPOWANY;
same znaczniki stają się poleceniami, więc ich nawiasy nie są uciekane."""
out: list[str] = []
pos = 0
for m in _INLINE.finditer(text):
out.append(esc(text[pos:m.start()]))
if m.group("bolditalic") is not None:
out.append(r"\textbf{\textit{" + esc(m.group("bolditalic")) + "}}")
elif m.group("bold") is not None:
out.append(r"\textbf{" + esc(m.group("bold")) + "}")
elif m.group("ital") is not None:
out.append(r"\textit{" + esc(m.group("ital")) + "}")
elif m.group("code") is not None:
out.append(r"\texttt{" + esc(m.group("code")) + "}")
else: # link — w druku URL jest bezużyteczny, zostawiamy sam tekst
out.append(esc(m.group("ltext")))
pos = m.end()
out.append(esc(text[pos:]))
return "".join(out)
# Nagłówki od modelu DEGRADUJEMY o poziom: „Interpretacja natalna" to nasza
# `\section*`, więc markdownowe `#` ma być pod nią (subsection), nie obok.
_HEADING_CMD = {1: "subsection", 2: "subsubsection"}
def markdown_to_latex(text: str) -> str:
"""Markdown od modelu → LaTeX. Markdown świetnie czyta się w przeglądarce,
ale w PDF `**gwiazdki**` i `## kratki` to dosłowny szum — tu zamieniamy je na
prawdziwe formatowanie: nagłówki, pogrubienia, kursywy i listy.
Skaner liniowy (nie regex na całości), bo listy i akapity są wielolinijkowe
i trzeba wiedzieć, gdzie się zaczynają i kończą.
"""
lines = str(text or "").replace("\r\n", "\n").split("\n")
out: list[str] = []
para: list[str] = []
items: list[str] = []
env: str | None = None # 'itemize' | 'enumerate'
def flush_para() -> None:
if para:
out.append(_inline(" ".join(s.strip() for s in para)))
para.clear()
def flush_list() -> None:
nonlocal env
if items:
body = "\n".join(r"\item " + _inline(it) for it in items)
out.append(f"\\begin{{{env}}}\n{body}\n\\end{{{env}}}")
items.clear()
env = None
for ln in lines:
s = ln.strip()
# Nagłówek celowo POBŁAŻLIWIE: dowolna liczba kratek na początku, spacja po
# nich NIEobowiązkowa, końcowe kratki (zamknięty ATX `## … ##`) ucinane.
# `\#` w LaTeXu renderuje się jako `#`, więc gdyby `##Bez spacji` trafiło do
# akapitu, w PDF WIDAĆ BY było `##`. Tu żaden znacznik nagłówka nie ostaje.
heading = re.match(r"^(#+)[ \t]*(.*?)[ \t]*#*$", s)
bullet = re.match(r"^[-*+]\s+(.*)$", s)
number = re.match(r"^\d+[.)]\s+(.*)$", s)
if not s:
flush_para(); flush_list()
elif heading: # linia zaczyna się od kratek
flush_para(); flush_list()
content = heading.group(2)
if content: # gołe kratki (bez treści) po prostu pomijamy
level = min(len(heading.group(1)), 6)
cmd = _HEADING_CMD.get(level, "paragraph")
out.append(f"\\{cmd}*{{{_inline(content)}}}")
elif bullet:
flush_para()
if env not in (None, "itemize"):
flush_list()
env = "itemize"; items.append(bullet.group(1))
elif number:
flush_para()
if env not in (None, "enumerate"):
flush_list()
env = "enumerate"; items.append(number.group(1))
else:
flush_list(); para.append(ln)
flush_para(); flush_list()
return "\n\n".join(out)
PREAMBLE = r"""\documentclass[11pt,a4paper]{article}
\usepackage{fontspec}
\usepackage[margin=2.4cm]{geometry}
\usepackage{graphicx}
% DejaVu Sans ma i polskie znaki, i glify astrologiczne — jeden font na oba
% zadania oszczędza nam dobierania fontu zapasowego.
\setmainfont{DejaVu Sans}
\setlength{\parindent}{0pt}
\setlength{\parskip}{0.6em}
\pagestyle{plain}
\begin{document}
"""
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]
# ── 1. imię i nazwisko ──────────────────────────────────────────────
parts.append(r"{\Huge\bfseries " + esc(person or "Raport astrologiczny") + r"\par}")
parts.append(r"\vspace{0.3em}")
# ── 2. wprowadzone dane ─────────────────────────────────────────────
rows = [
("Data urodzenia", data.get("date")),
("Godzina (lokalna)", data.get("time")),
("Offset względem GMT", data.get("tz_offset")),
("Szerokość", data.get("lat")),
("Długość", data.get("lon")),
("Miejsce", data.get("place")),
("System domów", data.get("house_system")),
("Zodiak", data.get("zodiac")),
]
parts.append(r"\begin{tabular}{@{}ll@{}}")
for label, value in rows:
if value not in (None, ""):
parts.append(esc(label) + r" & " + esc(value) + r"\\")
parts.append(r"\end{tabular}")
parts.append(r"\vspace{1em}")
# ── 2a. ostrzeżenia o systemie domów ────────────────────────────────
# Muszą stać PRZED rysunkami: kosmogram narysowany po fallbacku wygląda
# bezbłędnie, więc jedyną informacją, że podział domów jest z innego systemu,
# jest ten akapit. W ramce, żeby nie dało się go wziąć za podpis.
warnings = [w for w in (report.get("warnings") or []) if str(w).strip()]
if warnings:
parts.append(r"\begin{center}")
parts.append(r"\fbox{\begin{minipage}{0.92\textwidth}\small")
for i, w in enumerate(warnings):
if i:
parts.append(r"\par\medskip")
parts.append(esc(str(w)))
parts.append(r"\end{minipage}}")
parts.append(r"\end{center}")
parts.append(r"\vspace{1em}")
# ── 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,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.4em}")
# ── 4. interpretacja natalna ────────────────────────────────────────
if natal.get("text"):
parts.append(r"\section*{Interpretacja natalna}")
parts.append(markdown_to_latex(natal["text"]))
# ── 5. predykcje okresowe ───────────────────────────────────────────
if predictions:
parts.append(r"\section*{Predykcje okresowe}")
for p in predictions:
span = f"{p.get('from_date', '')} {p.get('to_date', '')}"
parts.append(r"\subsection*{" + esc(span) + r"}")
parts.append(markdown_to_latex(p.get("text")))
parts.append(r"\end{document}")
return "\n".join(parts) + "\n"