f5dec15e4d
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>
245 lines
10 KiB
Python
245 lines
10 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}")
|
||
|
||
# ── 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"
|