feat: Markdown→LaTeX w PDF + dopracowanie kosmogramu (PRE-24, PRE-12 etap 4)
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m34s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m35s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 35s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 19s
build-render / build (push) Successful in 45s
build / build (push) Successful in 50s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m46s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m33s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 28s
Testy / Kontrola składni wszystkich warstw (push) Successful in 21s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m34s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m35s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 35s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 19s
build-render / build (push) Successful in 45s
build / build (push) Successful in 50s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m46s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m33s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 28s
Testy / Kontrola składni wszystkich warstw (push) Successful in 21s
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 <title> 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>
This commit was merged in pull request #37.
This commit is contained in:
@@ -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_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_HNUM = 64.0 # promień numerów domów (tuż przy piaście)
|
||||||
R_PLANET = 150.0 # promień glifów obiektów
|
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_DEG = 130.0 # promień podpisu stopnia (pod glifem)
|
||||||
R_MARK = R_ZOD - 7.0 # koniec kreski wskazującej PRAWDZIWĄ pozycję na pasie
|
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]:
|
def _aspect_pen(orb: float, allowed: float | None) -> tuple[float, float]:
|
||||||
"""Grubość i przezroczystość linii aspektu wg orbu.
|
"""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,
|
def _line(x1: float, y1: float, x2: float, y2: float, stroke: str, w: float = 1.0,
|
||||||
opacity: float = 1.0) -> str:
|
opacity: float = 1.0, title: str = "") -> str:
|
||||||
return (f'<line x1="{x1:.2f}" y1="{y1:.2f}" x2="{x2:.2f}" y2="{y2:.2f}" '
|
attrs = (f'x1="{x1:.2f}" y1="{y1:.2f}" x2="{x2:.2f}" y2="{y2:.2f}" '
|
||||||
f'stroke="{stroke}" stroke-width="{w}" opacity="{opacity}"/>')
|
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)}</title></line>'
|
||||||
|
return f'<line {attrs}/>'
|
||||||
|
|
||||||
|
|
||||||
# Stack fontów z glifami astrologicznymi. Na stronie dostarcza go klasa CSS
|
# 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 = "",
|
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 ""
|
c = f' class="{cls}"' if cls else ""
|
||||||
f = f' font-family="{font}"' if font else ""
|
f = f' font-family="{font}"' if font else ""
|
||||||
|
t = f"<title>{escape(title)}</title>" if title else "" # dymek po najechaniu
|
||||||
return (f'<text x="{x:.2f}" y="{y:.2f}" font-size="{size}" fill="{fill}"'
|
return (f'<text x="{x:.2f}" y="{y:.2f}" font-size="{size}" fill="{fill}"'
|
||||||
f' text-anchor="middle" dominant-baseline="central"{c}{f}>{escape(s)}</text>')
|
f' text-anchor="middle" dominant-baseline="central"{c}{f}>{t}{escape(s)}</text>')
|
||||||
|
|
||||||
|
|
||||||
def spread(lons: list[float], min_sep: float = MIN_SEP, passes: int = 300) -> list[float]:
|
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"))
|
w, op = _aspect_pen(float(asp.get("orb", 0.0)), asp.get("allowed"))
|
||||||
ax1, ay1 = _pt(la, asc, R_HUB)
|
ax1, ay1 = _pt(la, asc, R_HUB)
|
||||||
bx1, by1 = _pt(lb, 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)
|
# drobne podziałki co 5°, mocniejsze co 10° (na pasie znaków)
|
||||||
for deg in range(0, 360, 5):
|
for deg in range(0, 360, 5):
|
||||||
@@ -253,6 +269,13 @@ def render(chart: dict, theme: str = "screen") -> str:
|
|||||||
mid = lon + span / 2.0
|
mid = lon + span / 2.0
|
||||||
hx, hy = _pt(mid, asc, R_HNUM)
|
hx, hy = _pt(mid, asc, R_HNUM)
|
||||||
parts.append(_text(hx, hy, str(c["house"]), size=11, fill=T["muted"]))
|
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
|
# 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")):
|
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))
|
parts.append(_line(mx2, my2, lx, ly, T["line"], 0.7, 0.9))
|
||||||
|
|
||||||
gx, gy = _pt(draw_lon, asc, R_PLANET)
|
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,
|
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
|
# Sam stopień w znaku, bez „°" — tak robią programy astrologiczne i tylko
|
||||||
# tak podpisy mieszczą się obok siebie w skupiskach. Retrogradacja: ℞ oraz
|
# 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,
|
parts.append(_text(dx, dy, label, size=8,
|
||||||
fill=T["retro"] if retro else T["muted"]))
|
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)
|
body = "".join(parts)
|
||||||
return (f'<svg class="wheel" viewBox="0 0 {_VIEW} {_VIEW}" role="img" '
|
return (f'<svg class="wheel" viewBox="0 0 {_VIEW} {_VIEW}" role="img" '
|
||||||
f'aria-label="Kosmogram — koło horoskopowe" '
|
f'aria-label="Kosmogram — koło horoskopowe" '
|
||||||
|
|||||||
@@ -81,7 +81,7 @@
|
|||||||
{% if wheel_svg %}
|
{% if wheel_svg %}
|
||||||
<figure class="wheel-fig">
|
<figure class="wheel-fig">
|
||||||
{{ wheel_svg | safe }}
|
{{ wheel_svg | safe }}
|
||||||
<figcaption class="muted small">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.</figcaption>
|
<figcaption class="muted small">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.</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
@@ -269,3 +269,71 @@ def test_print_theme_aspect_colors_have_no_css_vars():
|
|||||||
svg = chartwheel.render(_chart_with_aspects(), theme="print")
|
svg = chartwheel.render(_chart_with_aspects(), theme="print")
|
||||||
assert "var(--" not in svg
|
assert "var(--" not in svg
|
||||||
assert "#a83232" in svg # square napięty w wariancie druku
|
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
|
||||||
|
<title> 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 "<title>" 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
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ DejaVu Sans ma jedno i drugie, dlatego jest fontem głównym.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
# Znaki, które w LaTeXu mają własne znaczenie.
|
# Znaki, które w LaTeXu mają własne znaczenie.
|
||||||
#
|
#
|
||||||
# Odwrotnego ukośnika NIE MA na tej liście celowo. Jego zamiennik
|
# 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{}")
|
return out.replace(_BACKSLASH_MARK, r"\textbackslash{}")
|
||||||
|
|
||||||
|
|
||||||
def _paragraphs(text: str) -> str:
|
# Markdown inline: pogrubienie, kursywa, kod, linki. Kolejność w alternatywie ma
|
||||||
"""Tekst od modelu → akapity LaTeXa.
|
# 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")]
|
lines = str(text or "").replace("\r\n", "\n").split("\n")
|
||||||
return "\n\n".join(esc(b).replace("\n", " ") for b in blocks if b)
|
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}
|
PREAMBLE = r"""\documentclass[11pt,a4paper]{article}
|
||||||
@@ -117,7 +201,7 @@ def build(report: dict, wheel_pdf: str | None = None) -> str:
|
|||||||
# ── 4. interpretacja natalna ────────────────────────────────────────
|
# ── 4. interpretacja natalna ────────────────────────────────────────
|
||||||
if natal.get("text"):
|
if natal.get("text"):
|
||||||
parts.append(r"\section*{Interpretacja natalna}")
|
parts.append(r"\section*{Interpretacja natalna}")
|
||||||
parts.append(_paragraphs(natal["text"]))
|
parts.append(markdown_to_latex(natal["text"]))
|
||||||
|
|
||||||
# ── 5. predykcje okresowe ───────────────────────────────────────────
|
# ── 5. predykcje okresowe ───────────────────────────────────────────
|
||||||
if predictions:
|
if predictions:
|
||||||
@@ -125,7 +209,7 @@ def build(report: dict, wheel_pdf: str | None = None) -> str:
|
|||||||
for p in predictions:
|
for p in predictions:
|
||||||
span = f"{p.get('from_date', '')} – {p.get('to_date', '')}"
|
span = f"{p.get('from_date', '')} – {p.get('to_date', '')}"
|
||||||
parts.append(r"\subsection*{" + esc(span) + r"}")
|
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}")
|
parts.append(r"\end{document}")
|
||||||
return "\n".join(parts) + "\n"
|
return "\n".join(parts) + "\n"
|
||||||
|
|||||||
@@ -128,3 +128,84 @@ def test_missing_parts_are_simply_absent():
|
|||||||
def test_wheel_is_optional():
|
def test_wheel_is_optional():
|
||||||
assert "includegraphics" not in build(_report())
|
assert "includegraphics" not in build(_report())
|
||||||
assert "includegraphics" in build(_report(), wheel_pdf="wheel.pdf")
|
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) == ""
|
||||||
|
|||||||
Reference in New Issue
Block a user