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