998c83b26e
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 11m9s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m59s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 39s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 18s
build-render / build (push) Successful in 8m52s
build / build (push) Successful in 7m9s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 13m40s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 10m3s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 36s
Testy / Kontrola składni wszystkich warstw (push) Successful in 28s
Dwie realne dziury w markdown→LaTeX, obie WIDOCZNE w gotowym PDF:
1. `***mocne***` (pogrubienie+kursywa) łapało się jako `**` + zgubiona gwiazdka →
`\textbf{*mocne}*`, czyli w druku zostawał wisior `*`. Dokładam alternatywę
`\*\*\*…\*\*\*` PRZED `**` i `*` (kolejność od najdłuższego znacznika) →
`\textbf{\textit{…}}`.
2. Nagłówek bez spacji po kratkach (`##Tytuł`), zamknięty ATX (`## Tytuł ##`)
i 7+ kratek trafiały do akapitu i były eskejpowane jako `\#\#…`. Pułapka: w
LaTeXu `\#` renderuje się jako `#`, więc w PDF WIDAĆ było `##`, choć w źródle
`.tex` jest `\#\#`. Dlatego stary test (`"##" not in out`) tego nie łapał —
podłańcuch `##` nie występuje w `\#\#`. Nagłówki traktujemy teraz pobłażliwie:
dowolna liczba kratek na starcie, spacja nieobowiązkowa, końcowe kratki
ucinane, gołe kratki bez treści pomijane. Żaden znacznik nagłówka nie ostaje.
Nowa asercja w testach patrzy pod kątem RENDERU: brak `\#` i brak wiszącej
gwiazdki (poza gwiazdką poleceń `\section*` itd.). +7 testów regresji, render 34.
UWAGA DEPLOY: obraz render w rejestrze to wciąż 56131b20 (sprzed markdown, LOG-27),
bo build-render (TeX Live) padał na runnerze z „no space left on device". Ten PR
dotyka services/render/**, więc powinien wywołać build — ale najpierw trzeba
zwolnić miejsce na runnerze, inaczej i ten build padnie.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
227 lines
9.2 KiB
Python
227 lines
9.2 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) -> 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"
|