623603b157
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m34s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m35s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 35s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 19s
build-render / build (push) Successful in 45s
build / build (push) Successful in 50s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m46s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m33s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 28s
Testy / Kontrola składni wszystkich warstw (push) Successful in 21s
Dwie rzeczy w jednym PR (na prośbę).
1) MARKDOWN → LATEX w raporcie PDF (usługa render). Tekst od AI to markdown —
świetny w przeglądarce, ale w PDF `**gwiazdki**` i `## kratki` to dosłowny szum.
Nowy markdown_to_latex zamienia je na PRAWDZIWE formatowanie: nagłówki (degradowane
o poziom, żeby wpadły pod nasz \section „Interpretacja natalna", nie obok),
pogrubienia, kursywy, kod, listy punktowe i numerowane, linki (zostaje sam tekst,
URL bezużyteczny w druku).
Skaner liniowy, nie regex na całości — listy i akapity są wielolinijkowe.
Ucieczka znaków LaTeXa działa TAKŻE wewnątrz formatowania: `**wzrost 50%**` daje
`\textbf{wzrost 50\%}`, a nie zakomentowaną resztę linii. Sprawdzone na wrogim
tekście — `\end{document}` i `\input{}` w treści nie wyrywają się z dokumentu
nawet zawinięte w pogrubienie.
2) KOSMOGRAM — etap 4 (dopracowanie):
- Lots na kole: Fortuna ⊗ (jedyny Lot ze standardowym glifem) na promieniu POD
pierścieniem planet i w akcencie — od razu widać, że to punkt wyliczony, nie
ciało. Loty bez glifu (Spirit, Eros…) pomijamy, żeby nie zaśmiecać.
- Tooltipy: natywny <title> w SVG (bez JS-a). Obiekt → nazwa, pozycja, dom,
retrogradacja. Linia aspektu → „Słońce trygon Mars · orb 0.20°" (polskie nazwy
tylko do dymka; obliczenia trzymają angielskie). Fortuna → nazwa i pozycja.
- Stopnie cuspów przy szprychach. W whole sign cuspy są na 0° znaku — wtedy
pomijamy dwanaście zbędnych zer; w systemach kwadratowych (porphyry) stopień
realnie coś mówi, więc go pokazujemy.
Wszystko działa też w motywie druku (PDF): sprawdzone, że mimo Lotów, dymków
i cuspów SVG nadal nie ma zmiennych CSS.
Weryfikacja wizualna (porphyry): Fortuna przy AC, 12 stopni cuspów, 38 dymków
(24 aspekty + 13 obiektów + Fortuna). Testy: +12 markdown (render), +7 etap 4
(prezentacja). Całość: prezentacja 145, render 27.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
216 lines
8.3 KiB
Python
216 lines
8.3 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, kod, linki. Kolejność w alternatywie ma
|
||
# znaczenie — `**...**` musi być PRZED `*...*`, inaczej pogrubienie rozpadłoby się
|
||
# na dwie kursywy. Kursywę bierzemy tylko z gwiazdek (nie z podkreśleń): `_` pada
|
||
# w nazwach typu file_name, a jako kursywa dałoby fałszywe trafienia.
|
||
_INLINE = re.compile(
|
||
r"\*\*(?P<bold>.+?)\*\*"
|
||
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("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()
|
||
heading = re.match(r"^(#{1,6})\s+(.*)$", s)
|
||
bullet = re.match(r"^[-*+]\s+(.*)$", s)
|
||
number = re.match(r"^\d+[.)]\s+(.*)$", s)
|
||
if not s:
|
||
flush_para(); flush_list()
|
||
elif heading:
|
||
flush_para(); flush_list()
|
||
cmd = _HEADING_CMD.get(len(heading.group(1)), "paragraph")
|
||
out.append(f"\\{cmd}*{{{_inline(heading.group(2))}}}")
|
||
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) -> str:
|
||
"""Składa źródło .tex. `wheel_pdf` to nazwa pliku z kosmogramem (już PDF)."""
|
||
person = str(report.get("person") or "").strip()
|
||
data = report.get("data") or {}
|
||
natal = report.get("natal") or {}
|
||
predictions = report.get("predictions") or []
|
||
|
||
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}")
|
||
|
||
# ── 3. kosmogram ────────────────────────────────────────────────────
|
||
if wheel_pdf:
|
||
parts.append(r"\begin{center}")
|
||
parts.append(r"\includegraphics[width=0.82\textwidth]{" + wheel_pdf + r"}")
|
||
parts.append(r"\end{center}")
|
||
parts.append(r"\vspace{0.5em}")
|
||
|
||
# ── 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"
|