"""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.+?)\*\*" r"|\*(?P[^*\n]+?)\*" r"|`(?P[^`\n]+?)`" r"|\[(?P[^\]]+)\]\((?P[^)]+)\)" ) 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"