Files
astrololo/services/render/app/latex.py
T
gitea b55cab06ca
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m54s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m49s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 32s
Testy / Kontrola składni wszystkich warstw (push) Successful in 18s
feat: aspektarian, deklinacja i antyscja trafiają do raportu i PDF-a
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>
2026-07-28 20:41:39 +02:00

234 lines
9.2 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, 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,
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"