From 623603b15776eb3f7ea95d1a2b78b72bd0e80d9e Mon Sep 17 00:00:00 2001 From: migatu Date: Sat, 25 Jul 2026 19:26:58 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Markdown=E2=86=92LaTeX=20w=20PDF=20+=20?= =?UTF-8?q?dopracowanie=20kosmogramu=20(PRE-24,=20PRE-12=20etap=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dwie rzeczy w jednym PR (na prośbę). 1) MARKDOWN → LATEX w raporcie PDF (usługa render). Tekst od AI to markdown — świetny w przeglądarce, ale w PDF `**gwiazdki**` i `## kratki` to dosłowny szum. Nowy markdown_to_latex zamienia je na PRAWDZIWE formatowanie: nagłówki (degradowane o poziom, żeby wpadły pod nasz \section „Interpretacja natalna", nie obok), pogrubienia, kursywy, kod, listy punktowe i numerowane, linki (zostaje sam tekst, URL bezużyteczny w druku). Skaner liniowy, nie regex na całości — listy i akapity są wielolinijkowe. Ucieczka znaków LaTeXa działa TAKŻE wewnątrz formatowania: `**wzrost 50%**` daje `\textbf{wzrost 50\%}`, a nie zakomentowaną resztę linii. Sprawdzone na wrogim tekście — `\end{document}` i `\input{}` w treści nie wyrywają się z dokumentu nawet zawinięte w pogrubienie. 2) KOSMOGRAM — etap 4 (dopracowanie): - Lots na kole: Fortuna ⊗ (jedyny Lot ze standardowym glifem) na promieniu POD pierścieniem planet i w akcencie — od razu widać, że to punkt wyliczony, nie ciało. Loty bez glifu (Spirit, Eros…) pomijamy, żeby nie zaśmiecać. - Tooltipy: natywny w SVG (bez JS-a). Obiekt → nazwa, pozycja, dom, retrogradacja. Linia aspektu → „Słońce trygon Mars · orb 0.20°" (polskie nazwy tylko do dymka; obliczenia trzymają angielskie). Fortuna → nazwa i pozycja. - Stopnie cuspów przy szprychach. W whole sign cuspy są na 0° znaku — wtedy pomijamy dwanaście zbędnych zer; w systemach kwadratowych (porphyry) stopień realnie coś mówi, więc go pokazujemy. Wszystko działa też w motywie druku (PDF): sprawdzone, że mimo Lotów, dymków i cuspów SVG nadal nie ma zmiennych CSS. Weryfikacja wizualna (porphyry): Fortuna przy AC, 12 stopni cuspów, 38 dymków (24 aspekty + 13 obiektów + Fortuna). Testy: +12 markdown (render), +7 etap 4 (prezentacja). Całość: prezentacja 145, render 27. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- services/presentation/app/chartwheel.py | 65 ++++++++++-- .../presentation/app/templates/chart.html | 2 +- .../presentation/tests/test_chartwheel.py | 68 ++++++++++++ services/render/app/latex.py | 100 ++++++++++++++++-- services/render/tests/test_latex.py | 81 ++++++++++++++ 5 files changed, 300 insertions(+), 16 deletions(-) diff --git a/services/presentation/app/chartwheel.py b/services/presentation/app/chartwheel.py index 68f1611..41734a8 100644 --- a/services/presentation/app/chartwheel.py +++ b/services/presentation/app/chartwheel.py @@ -35,6 +35,7 @@ R_TICK = 172.0 # koniec drobnych podziałek stopni (do wewnątrz od pasa) R_HUB = 48.0 # centralne kółko — tu kończą się szprychy domów i osie R_HNUM = 64.0 # promień numerów domów (tuż przy piaście) R_PLANET = 150.0 # promień glifów obiektów +R_LOT = 116.0 # promień glifów Lotów — pod pierścieniem planet (to nie ciała) R_DEG = 130.0 # promień podpisu stopnia (pod glifem) R_MARK = R_ZOD - 7.0 # koniec kreski wskazującej PRAWDZIWĄ pozycję na pasie @@ -82,6 +83,14 @@ _THEMES = { } +# Polskie nazwy aspektów — tylko do dymków; obliczenia trzymają nazwy angielskie. +_ASPECT_PL = { + "conjunction": "koniunkcja", "sextile": "sekstyl", "square": "kwadratura", + "trine": "trygon", "opposition": "opozycja", "semisextile": "półsekstyl", + "semisquare": "półkwadratura", "quincunx": "kwinkunks", +} + + def _aspect_pen(orb: float, allowed: float | None) -> tuple[float, float]: """Grubość i przezroczystość linii aspektu wg orbu. @@ -102,9 +111,13 @@ def _pt(lon: float, asc: float, r: float) -> tuple[float, float]: def _line(x1: float, y1: float, x2: float, y2: float, stroke: str, w: float = 1.0, - opacity: float = 1.0) -> str: - return (f'<line x1="{x1:.2f}" y1="{y1:.2f}" x2="{x2:.2f}" y2="{y2:.2f}" ' - f'stroke="{stroke}" stroke-width="{w}" opacity="{opacity}"/>') + opacity: float = 1.0, title: str = "") -> str: + attrs = (f'x1="{x1:.2f}" y1="{y1:.2f}" x2="{x2:.2f}" y2="{y2:.2f}" ' + f'stroke="{stroke}" stroke-width="{w}" opacity="{opacity}"') + # <title> jako dziecko daje natywny dymek po najechaniu — bez JS-a. + if title: + return f'<line {attrs}><title>{escape(title)}' + return f'' # Stack fontów z glifami astrologicznymi. Na stronie dostarcza go klasa CSS @@ -116,11 +129,12 @@ GLYPH_FONT = ("Apple Symbols, Segoe UI Symbol, Noto Sans Symbols2, " def _text(x: float, y: float, s: str, *, size: float, fill: str, cls: str = "", - font: str = "") -> str: + font: str = "", title: str = "") -> str: c = f' class="{cls}"' if cls else "" f = f' font-family="{font}"' if font else "" + t = f"{escape(title)}" if title else "" # dymek po najechaniu return (f'{escape(s)}') + f' text-anchor="middle" dominant-baseline="central"{c}{f}>{t}{escape(s)}') def spread(lons: list[float], min_sep: float = MIN_SEP, passes: int = 300) -> list[float]: @@ -222,7 +236,9 @@ def render(chart: dict, theme: str = "screen") -> str: w, op = _aspect_pen(float(asp.get("orb", 0.0)), asp.get("allowed")) ax1, ay1 = _pt(la, asc, R_HUB) bx1, by1 = _pt(lb, asc, R_HUB) - parts.append(_line(ax1, ay1, bx1, by1, color, w, op)) + tip = (f'{asp.get("obj1")} {_ASPECT_PL.get(asp.get("aspect"), asp.get("aspect"))} ' + f'{asp.get("obj2")} · orb {float(asp.get("orb", 0.0)):.2f}°') + parts.append(_line(ax1, ay1, bx1, by1, color, w, op, title=tip)) # drobne podziałki co 5°, mocniejsze co 10° (na pasie znaków) for deg in range(0, 360, 5): @@ -253,6 +269,13 @@ def render(chart: dict, theme: str = "screen") -> str: mid = lon + span / 2.0 hx, hy = _pt(mid, asc, R_HNUM) parts.append(_text(hx, hy, str(c["house"]), size=11, fill=T["muted"])) + # Stopień cuspu tuż przy szprysze, na pasie. W whole sign wszystkie cuspy + # są na 0° znaku — wtedy pomijamy, żeby nie kłaść dwunastu zbędnych zer; + # w systemach kwadratowych ta liczba realnie coś mówi. + deg_in_sign = lon % 30.0 + if deg_in_sign >= 0.5: + cx1, cy1 = _pt(lon, asc, R_ZOD - 9) + parts.append(_text(cx1, cy1, str(int(deg_in_sign)), size=6.5, fill=T["muted"])) # osie: Asc–Dsc i MC–IC przez całe koło, wyróżnione akcentem for a, b, la, lb in (("Asc", "Dsc", "AC", "DC"), ("MC", "IC", "MC", "IC")): @@ -286,8 +309,15 @@ def render(chart: dict, theme: str = "screen") -> str: parts.append(_line(mx2, my2, lx, ly, T["line"], 0.7, 0.9)) gx, gy = _pt(draw_lon, asc, R_PLANET) + tip = obj.get("name") or "" + if obj.get("in_sign"): + tip += f" · {obj['in_sign']}" + if obj.get("house"): + tip += f" · dom {obj['house']}" + if retro: + tip += " · retrogradacja" parts.append(_text(gx, gy, obj.get("glyph") or "", size=16, - fill=T["ink"], cls="glyph", font=glyph_font)) + fill=T["ink"], cls="glyph", font=glyph_font, title=tip)) # Sam stopień w znaku, bez „°" — tak robią programy astrologiczne i tylko # tak podpisy mieszczą się obok siebie w skupiskach. Retrogradacja: ℞ oraz @@ -297,6 +327,27 @@ def render(chart: dict, theme: str = "screen") -> str: parts.append(_text(dx, dy, label, size=8, fill=T["retro"] if retro else T["muted"])) + # ── Lots (LOG-08): tylko te z glifem (Fortuna ⊗) ────────────────── + # Rysowane NA PIASTOWEJ stronie planet (R_LOT < R_PLANET) i akcentem, żeby + # od razu było widać, że to punkt wyliczony, nie ciało niebieskie. Bez + # rozsuwania — Lotów z glifem jest mało, a i tak siedzą pod pierścieniem planet. + for lot in (chart.get("lots") or []): + glyph = lot.get("glyph") + if not glyph or lot.get("longitude") is None: + continue + lon = float(lot["longitude"]) + mx1, my1 = _pt(lon, asc, R_ZOD) + mx2, my2 = _pt(lon, asc, R_MARK) + parts.append(_line(mx1, my1, mx2, my2, T["accent"], 1.0, 0.7)) + lx, ly = _pt(lon, asc, R_LOT) + tip = lot.get("name") or "" + if lot.get("in_sign"): + tip += f" · {lot['in_sign']}" + if lot.get("house"): + tip += f" · dom {lot['house']}" + parts.append(_text(lx, ly, glyph, size=13, fill=T["accent"], + cls="glyph", font=glyph_font, title=tip)) + body = "".join(parts) return (f' {{ wheel_svg | safe }} -
Kosmogram — znaki, domy, osie, obiekty i aspekty. Kreska na pasie wskazuje dokładną pozycję; glif bywa odsunięty, gdy obiekty stoją ciasno. Linie w środku: niebieskie — harmonijne, czerwone — napięte, grubsze = ciaśniejszy orb.
+
Kosmogram — znaki, domy, osie, obiekty, Lots (⊗) i aspekty. Linie w środku: niebieskie — harmonijne, czerwone — napięte, grubsze = ciaśniejszy orb. Najedź na obiekt lub linię, by zobaczyć szczegóły.
{% endif %} diff --git a/services/presentation/tests/test_chartwheel.py b/services/presentation/tests/test_chartwheel.py index c5075aa..ae0bd6e 100644 --- a/services/presentation/tests/test_chartwheel.py +++ b/services/presentation/tests/test_chartwheel.py @@ -269,3 +269,71 @@ def test_print_theme_aspect_colors_have_no_css_vars(): svg = chartwheel.render(_chart_with_aspects(), theme="print") assert "var(--" not in svg assert "#a83232" in svg # square napięty w wariancie druku + + +# ─────────────────────────── etap 4: dopracowanie ──────────────────────── + +def test_objects_have_hover_tooltips(): + """Najechanie na glif pokazuje nazwę, pozycję, dom i retrogradację (natywny + w SVG — bez JS-a).""" + chart = _chart_with_objects() + for p in chart["positions"]: # fixture jest minimalny; dodaj etykiety + p["in_sign"] = "Tau 10°" + p["house"] = 9 + svg = chartwheel.render(chart) + assert "" in svg + assert "Sun · Tau 10° · dom 9" in svg # nazwa + pozycja + dom w dymku + + +def test_retrograde_object_says_so_in_tooltip(): + svg = chartwheel.render(_chart_with_objects()) # Mercury jest Rx + assert "retrogradacja" in svg + + +def test_aspect_lines_have_polish_tooltips(): + svg = chartwheel.render(_chart_with_aspects()) + assert "trygon" in svg # Sun–Mars trine + assert "kwadratura" in svg # Moon–Mars square + assert "orb" in svg + + +def test_part_of_fortune_is_drawn(): + """Fortuna (⊗) na kole — jedyny Lot ze standardowym glifem.""" + chart = _chart_with_objects() + chart["lots"] = [ + {"name": "Fortune", "longitude": 120.14, "glyph": "⊗", "in_sign": "Leo 0°", "house": 1}, + {"name": "Spirit", "longitude": 137.66, "glyph": None, "in_sign": "Leo 17°"}, + ] + svg = chartwheel.render(chart) + assert "⊗" in svg + assert "Fortune · " in svg # dymek Lota + + +def test_lots_without_glyph_are_skipped(): + """Loty bez standardowego symbolu (Spirit, Eros…) nie zaśmiecają koła.""" + chart = _chart_with_objects() + chart["lots"] = [{"name": "Spirit", "longitude": 137.66, "glyph": None}] + before = chartwheel.render(chart).count("<text") + chart["lots"] = [] + after = chartwheel.render(chart).count("<text") + assert before == after # Spirit nic nie dorysował + + +def test_cusp_degrees_shown_for_quadrant_hidden_for_whole_sign(): + """Whole sign: cuspy na 0° znaku → pomijamy zbędne zera. System kwadratowy: + stopnie realnie coś mówią, więc je pokazujemy.""" + whole = _sample_chart() # cuspy na 0/30/60… + assert chartwheel.render(whole).count('font-size="6.5"') == 0 + quad = _sample_chart() + for i, c in enumerate(quad["cusps"]): # przesuń cuspy poza granice znaków + c["decimal"] = (c["decimal"] + 12.5) % 360.0 + assert chartwheel.render(quad).count('font-size="6.5"') == 12 + + +def test_print_theme_survives_stage4_features(): + """Motyw druku (PDF) nadal bez zmiennych CSS mimo Lotów, dymków i cuspów.""" + chart = _chart_with_aspects() + chart["lots"] = [{"name": "Fortune", "longitude": 120.0, "glyph": "⊗", "in_sign": "Leo 0°"}] + svg = chartwheel.render(chart, theme="print") + assert "var(--" not in svg + assert "⊗" in svg and "<title>" in svg diff --git a/services/render/app/latex.py b/services/render/app/latex.py index 195d5b3..ab16404 100644 --- a/services/render/app/latex.py +++ b/services/render/app/latex.py @@ -13,6 +13,8 @@ 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 @@ -52,14 +54,96 @@ def esc(text: object) -> str: return out.replace(_BACKSLASH_MARK, r"\textbackslash{}") -def _paragraphs(text: str) -> str: - """Tekst od modelu → akapity LaTeXa. +# 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>[^)]+)\)" +) - Model oddziela akapity pustą linią. Zamieniamy je na osobne akapity, a same - złamania wiersza w środku akapitu zostawiamy LaTeXowi do złożenia. + +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ą. """ - 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) + 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} @@ -117,7 +201,7 @@ def build(report: dict, wheel_pdf: str | None = None) -> str: # ── 4. interpretacja natalna ──────────────────────────────────────── if natal.get("text"): parts.append(r"\section*{Interpretacja natalna}") - parts.append(_paragraphs(natal["text"])) + parts.append(markdown_to_latex(natal["text"])) # ── 5. predykcje okresowe ─────────────────────────────────────────── if predictions: @@ -125,7 +209,7 @@ def build(report: dict, wheel_pdf: str | None = None) -> str: 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(markdown_to_latex(p.get("text"))) parts.append(r"\end{document}") return "\n".join(parts) + "\n" diff --git a/services/render/tests/test_latex.py b/services/render/tests/test_latex.py index 2633f36..1191716 100644 --- a/services/render/tests/test_latex.py +++ b/services/render/tests/test_latex.py @@ -128,3 +128,84 @@ def test_missing_parts_are_simply_absent(): def test_wheel_is_optional(): assert "includegraphics" not in build(_report()) assert "includegraphics" in build(_report(), wheel_pdf="wheel.pdf") + + +# ─────────────────── Markdown → LaTeX (nie surowy copy-paste) ───────────── + +from app.latex import markdown_to_latex as md + + +def test_bold_and_italic_become_commands(): + out = md("To **ważne** i *podkreślone*.") + assert r"\textbf{ważne}" in out + assert r"\textit{podkreślone}" in out + assert "**" not in out and "*" not in out # znaczniki znikają + + +def test_headings_are_demoted_under_our_section(): + """Markdownowe nagłówki idą POD nasz `\\section*` (natalna/predykcje): + # → subsection, ## → subsubsection, ### i głębsze → paragraph.""" + assert r"\subsection*{Tytuł}" in md("# Tytuł") + assert r"\subsubsection*{Podtytuł}" in md("## Podtytuł") + assert r"\paragraph*{Głębiej}" in md("### Głębiej") + assert "#" not in md("## Podtytuł") # kratki znikają + + +def test_bullet_list_becomes_itemize(): + out = md("- pierwszy\n- drugi\n- trzeci") + assert r"\begin{itemize}" in out and r"\end{itemize}" in out + assert out.count(r"\item ") == 3 + + +def test_numbered_list_becomes_enumerate(): + out = md("1. raz\n2. dwa") + assert r"\begin{enumerate}" in out and r"\end{enumerate}" in out + assert out.count(r"\item ") == 2 + + +def test_switching_list_type_closes_the_previous(): + """Punktory po numerach nie mogą wpaść do jednego środowiska.""" + out = md("1. numer\n- punkt") + assert r"\end{enumerate}" in out and r"\begin{itemize}" in out + + +def test_inline_code_becomes_texttt(): + assert r"\texttt{kod}" in md("użyj `kod` tutaj") + + +def test_link_keeps_text_drops_url(): + out = md("zobacz [stronę](https://example.com/x)") + assert "stronę" in out + assert "example.com" not in out and "http" not in out + + +def test_paragraphs_are_separated(): + out = md("Pierwszy akapit.\n\nDrugi akapit.") + assert "Pierwszy akapit." in out and "Drugi akapit." in out + assert out.count("\n\n") >= 1 + + +def test_special_chars_inside_formatting_are_escaped(): + """Ucieczka LaTeXa działa TAKŻE wewnątrz pogrubienia — inaczej `%` w bold + zakomentowałby resztę linii.""" + out = md("**wzrost 50% & więcej**") + assert r"\textbf{wzrost 50\% \& więcej}" in out + + +def test_no_markdown_markers_leak_through(): + """Żaden surowy znacznik markdown nie ma trafić do PDF.""" + out = md("## Tytuł\n\n**b** i *i* oraz `c`\n\n- lista") + for marker in ("##", "**", "`"): + assert marker not in out + + +def test_bold_before_italic_not_two_italics(): + """`**x**` to pogrubienie, nie dwie kursywy — kolejność w regexie.""" + out = md("**mocno**") + assert r"\textbf{mocno}" in out + assert r"\textit{" not in out + + +def test_empty_and_none_are_safe(): + assert md("") == "" + assert md(None) == ""