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
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>
261 lines
11 KiB
Python
261 lines
11 KiB
Python
"""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"
|