Files
astrololo/services/render/app/latex.py
T
gitea f24616d342
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m4s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m57s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 34s
Testy / Kontrola składni wszystkich warstw (push) Successful in 22s
feat(render): raport PDF przez LaTeX jako osobna usluga (PRE-24)
Ostatnia z osmiu wskazowek partnerow. Nowa usluga services/render sklada raport:
generuje plik posredni .tex i kompiluje go XeLaTeX-em do PDF.

OSOBNY komponent, nie czesc prezentacji — ta sama zasada co przy izolacji
swissepha (LOG-27). TeX Live wazy setki megabajtow; w obrazie produktu
spowalnialby kazdy build, a tak aktualizuje sie niezaleznie i jego awaria nie
kladzie aplikacji, tylko przycisk „Pobierz PDF".

SZYFROWANIE, o ktore prosiles: lacze prezentacja↔render idzie tak samo jak
pozostale, ale z WLASNYM, TRZECIM kluczem (LINK_KEY_PRESENTATION_RENDER). Osobny,
bo tym laczem plynie CALY raport — dane urodzeniowe i opisy z baz — wiec przejecie
go nie moze otwierac lacza do logiki ani do danych. Fail-closed: bez klucza pod
nie wstaje. Test kopii link_crypto obejmuje teraz cztery uslugi.

Uklad PDF wg prosby: imie i nazwisko -> wprowadzone dane -> RYSUNEK kosmogramu
-> interpretacja natalna -> predykcje okresowe. Test pilnuje kolejnosci.

Dwie rzeczy, ktore wyszly dopiero przy skladaniu tego kawalka:

1. KOSMOGRAM NIE NADAWAL SIE DO PDF. Uzywa zmiennych CSS (var(--line)) i klasy
   .glyph, ktorej font podaje styles.css — a samodzielny konwerter SVG→PDF nie zna
   naszego arkusza. Wyszlyby czarne kreski BEZ SYMBOLI. Stad wariant „print":
   konkretne kolory na bialym tle i font glifow wpisany wprost w rysunek. Przy
   okazji zalatwia to etap 4 planu kola (wariant do druku).

2. UCIECZKA ZNAKOW LATEXA byla zepsuta — i zlapal to moj wlasny test. Zamiana
   „\” na \textbackslash{} szla w tej samej petli co nawiasy, wiec kolejne
   podmiany ucieklyby nawiasy dopiero co wstawione: wychodzilo
   \textbackslash\{\}. Poprawka: ukosnik chowany pod znacznik zastepczy i
   rozwijany na koncu. To nie kosmetyka — niezauwazony „%” komentuje RESZTE LINII,
   wiec zdanie od modelu urywaloby sie w polowie, a PDF powstawalby normalnie,
   tylko krotszy. Test na wrogim tekscie sprawdza tez, ze \end{document} ani
   \input{} nie wyrwa sie z dokumentu.

Usluga nie zapisuje nic poza katalogiem tymczasowym, ktory sprzata po sobie;
w manifescie readOnlyRootFilesystem + emptyDir na /tmp. /health raportuje
obecnosc xelatex i rsvg-convert, zeby zepsuty obraz bylo widac od razu.

Zweryfikowane na zywo (TestClient uslugi render): zadanie bez szyfrowania
z POPRAWNYM tokenem -> 400; obcy klucz -> 400 i tajny opis nie wraca; wlasciwy
klucz -> zadanie dochodzi do aplikacji, tresci baz NIE MA na kablu, odpowiedz
zaszyfrowana. Manifesty przechodza kubectl apply --dry-run=server na zywym
klastrze.

CZEGO NIE SPRAWDZILEM: samej kompilacji PDF. W tym srodowisku nie ma ani TeX
Live, ani dzialajacego runtime'u kontenerow (docker CLI jest, daemon nie) —
probowalem zbudowac obraz i sie nie dalo. Sprawdzone jest wszystko dookola:
generowanie .tex, ucieczka znakow, szyfrowanie, kontrakt API, samowystarczalnosc
SVG. Pierwsze uruchomienie na klastrze trzeba obejrzec — dlatego PRE-24 zostaje
jako „W trakcie", nie „Zrobione".

Instrukcja wdrozenia: docs/wdrozenie-render-pdf.md (klucz, obraz, merge,
weryfikacja, znane ograniczenia).

Testy: 15 nowych (render) + 14 (lacze i wariant druku w prezentacji).
Calosc: prezentacja 131, logika 265/1 skip, render 15.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 19:21:49 +02:00

132 lines
5.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
# 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{}")
def _paragraphs(text: str) -> str:
"""Tekst od modelu → akapity LaTeXa.
Model oddziela akapity pustą linią. Zamieniamy je na osobne akapity, a same
złamania wiersza w środku akapitu zostawiamy LaTeXowi do złożenia.
"""
blocks = [b.strip() for b in str(text or "").split("\n\n")]
return "\n\n".join(esc(b).replace("\n", " ") for b in blocks if b)
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(_paragraphs(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(_paragraphs(p.get("text")))
parts.append(r"\end{document}")
return "\n".join(parts) + "\n"