From ace76183e5da5ec01a87dcf8f74e8797f50f0cbb Mon Sep 17 00:00:00 2001 From: migatu Date: Thu, 23 Jul 2026 20:46:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(logic):=20glify=20astrologiczne=20?= =?UTF-8?q?=E2=80=94=20konwersja=20tekst=E2=86=94symbol=20(LOG-22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Warunek wstępny pod kosmogram (PRE-12): planety i znaki na kole rysujemy symbolami. Nowy moduł engine/glyphs.py: - forward: nazwa → glif (planety, punkty, Loty, znaki, aspekty, retro ℞), - reverse: glif → nazwa (znosi obecność/brak selektora wariantu), - glyphify(): tokeny [XX z bazy → glify; przyklad z wymagan „[Sa [Pis 26°08' [conj [PF" → „♄ ♓ 26°08' ☌ ⊗" (stopnie nietkniete). DAN-18 „tylko tekst, nigdy emoji" potraktowane serio, TRZEMA warstwami — bo sam selektor NIE wystarcza (potwierdzone wizualnie w przegladarce): 1. w danych: selektor wariantu tekstowego U+FE0E na znakach zodiaku oraz ♀/♂ (maja wariant emoji); ☉ i reszta go nie dostaja (zbedny), 2. CSS font-variant-emoji:text, 3. CSS font-family celujacy w MONOCHROMATYCZNE fonty symboli PRZED emoji. Bez pkt. 3 macOS/Chromium i tak renderowal znaki ♈–♓ jako kolorowe kafelki Apple Color Emoji mimo VS15 — planety wychodzily tekstem, znaki nie. Stack „Apple Symbols / Segoe UI Symbol / Noto Sans Symbols2" naprawia render. NIGDZIE nie emitujemy U+FE0F (emoji) — pilnuje tego test. Wpiete w build_chart: kazda pozycja dostaje glyph (planeta) + sign_glyph (znak, zalezny od zodiaku, wiec po przesunieciu), osie i Loty tak samo (Fortuna ⊗, reszta Lotow bez standardowego symbolu → None), aspekty dostaja glif, result[„sign_glyphs"] to pierscien 12 znakow pod kolo. UI: kolumna „Sym." w tabeli pozycji (planeta + znak) i symbol przy aspekcie. Testy: 14 (kompletnosc — kazdy obiekt/znak/aspekt ma glif; dwukierunkowosc; DAN-18 brak FE0F, znaki maja VS15, ☉ nie; przyklad glyphify z wymagan). Calosc: logika 265 passed / 1 skipped, prezentacja 25. Render znakow potwierdzony wizualnie (czarno-biale symbole, nie emoji). Co-Authored-By: Claude Opus 4.8 --- docs/astrololo_wymagania.xlsx | Bin 34906 -> 34908 bytes services/logic/app/engine/chart.py | 21 ++- services/logic/app/engine/glyphs.py | 177 ++++++++++++++++++ services/logic/tests/test_glyphs.py | 114 +++++++++++ services/presentation/app/static/styles.css | 12 ++ .../presentation/app/templates/chart.html | 4 +- 6 files changed, 325 insertions(+), 3 deletions(-) create mode 100644 services/logic/app/engine/glyphs.py create mode 100644 services/logic/tests/test_glyphs.py diff --git a/docs/astrololo_wymagania.xlsx b/docs/astrololo_wymagania.xlsx index bf8dad8079f568219d3e346afc2cc0d351d0d5f0..b9258897793b5b5a42f1d647b883368c42edc0a8 100644 GIT binary patch delta 6759 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 diff --git a/services/logic/app/engine/chart.py b/services/logic/app/engine/chart.py index eccdc74..6d84699 100644 --- a/services/logic/app/engine/chart.py +++ b/services/logic/app/engine/chart.py @@ -36,6 +36,7 @@ def _shift_pos(pdict: dict, off: float) -> None: def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN, lots_method: str = "degree", zodiac: str = Z.TROPICAL) -> dict: + from app.engine import glyphs as GL from app.engine.aspects import find_aspects from app.engine.houses import mean_obliquity from app.engine.out_of_zodiac import ( @@ -64,6 +65,10 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str result["parallels"] = find_declination_aspects(result["positions"]) result["antiscia"] = find_antiscia(result["positions"]) + # glify aspektów (LOG-22) — symbol aspektu nie zależy od zodiaku + for a in result["aspects"]: + a["glyph"] = GL.aspect_glyph(a["aspect"]) + # offset zodiaku (LOG-04): syderyczny = ayanamsa, draconic = długość węzła node_lon = next((p.longitude for p in positions if p.name == "North Node"), None) off = Z.offset(zodiac, Z.julian_day(moment.when_utc), node_lon) @@ -73,6 +78,13 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str for pdict in result["positions"]: _shift_pos(pdict, off) + # glify obiektów (LOG-22): symbol planety jest niezmienniczy, symbol ZNAKU + # zależy od zodiaku, więc po przesunięciu. Pierścień 12 znaków pod kosmogram. + for pdict in result["positions"]: + pdict["glyph"] = GL.glyph_for(pdict["name"]) + pdict["sign_glyph"] = GL.sign_glyph(pdict["sign"]) + result["sign_glyphs"] = [{"sign": s, "glyph": GL.sign_glyph(s)} for s in SIGNS] + if not hasattr(engine, "sidereal"): return result @@ -89,9 +101,12 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str "Dsc": _fmt("Dsc", norm360(asc + 180.0), off), "IC": _fmt("IC", norm360(mc + 180.0), off), } + for a in result["angles"].values(): # glif znaku osi (LOG-22) + a["sign_glyph"] = GL.sign_glyph(a["sign"]) result["cusps"] = [ {"house": i + 1, "sign": SIGNS[sign_index(norm360(c - off))], - "in_sign": in_sign(norm360(c - off))} + "in_sign": in_sign(norm360(c - off)), + "sign_glyph": GL.sign_glyph(SIGNS[sign_index(norm360(c - off))])} for i, c in enumerate(cusp_list) ] for pdict, obj in zip(result["positions"], positions): @@ -110,7 +125,9 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str "longitude": decimal(norm360(lot["longitude"] - off)), # w wybranym zodiaku "sign": SIGNS[sign_index(norm360(lot["longitude"] - off))], "in_sign": in_sign(norm360(lot["longitude"] - off)), - "house": H.assign_house(lot["longitude"], cusp_list)} # dom po długości tropikalnej + "house": H.assign_house(lot["longitude"], cusp_list), # dom po długości tropikalnej + "glyph": GL.glyph_for(lot["name"]), # ⊗ dla Fortuny; reszta None (LOG-22) + "sign_glyph": GL.sign_glyph(SIGNS[sign_index(norm360(lot["longitude"] - off))])} for lot in compute_lots(pts, day, lots_method) ] return result diff --git a/services/logic/app/engine/glyphs.py b/services/logic/app/engine/glyphs.py new file mode 100644 index 0000000..331ac81 --- /dev/null +++ b/services/logic/app/engine/glyphs.py @@ -0,0 +1,177 @@ +"""Konwersja tekst ↔ symbol astrologiczny (LOG-22). + +Litery i słowa (Sa Pis 26°08' conj Fortune) ↔ glify (♄ ♓ 26°08' ☌ ⊗). Potrzebne +pod rysowanie kosmogramu (PRE-12) — planety i znaki na kole rysujemy symbolami. + +DWIE zasady z bazy wymagań, obie krytyczne: + * DAN-18: TYLKO tekstowy Unicode, NIGDY emoji. Część znaków (zodiak, ♀, ♂) ma + domyślnie prezentację emoji — kolorowy kwadrat zamiast czarno-białego glifu, + nieczytelny i niesterowalny przez CSS. Wymuszamy prezentację tekstową + selektorem wariantu U+FE0E (NIE U+FE0F, który robi odwrotnie). + * DAN-17: łańcuch musi znosić dowolny Unicode. Reverse (symbol→tekst) + normalizuje wejście, zdejmując selektory wariantu, żeby glif z FE0E i bez + dawał ten sam wynik. + +Glify zapisane przez \\u — jednoznaczne code-pointy, odporne na zniekształcenia +edytorów i samodokumentujące (widać, który to znak Unicode). +""" +from __future__ import annotations + +# selektory wariantu prezentacji +_TEXT = "︎" # VS15 — wymusza glif tekstowy (czarno-biały) +_EMOJI = "️" # VS16 — prezentacja emoji; NIGDY nie emitujemy, zdejmujemy przy reverse + + +def _t(cp: str) -> str: + """Znak z wymuszoną prezentacją tekstową (dokleja VS15).""" + return cp + _TEXT + + +# ── planety i światła ──────────────────────────────────────────────────── +# ♀ i ♂ mają wariant emoji → wymuszamy tekst; reszta jest tekstowa domyślnie. +PLANET = { + "Sun": "☉", # ☉ + "Moon": "☽", # ☽ + "Mercury": "☿", # ☿ + "Venus": _t("♀"), # ♀︎ + "Mars": _t("♂"), # ♂︎ + "Jupiter": "♃", # ♃ + "Saturn": "♄", # ♄ + "Uranus": "♅", # ♅ + "Neptune": "♆", # ♆ + "Pluto": "♇", # ♇ +} + +# ── punkty wirtualne ───────────────────────────────────────────────────── +POINT = { + "North Node": "☊", # ☊ + "South Node": "☋", # ☋ + "Lilith": "⚸", # ⚸ (Black Moon Lilith) + "Chiron": "⚷", # ⚷ +} + +# ── Lots (punkty arabskie) ─────────────────────────────────────────────── +# Standardowy glif ma tylko Fortuna (⊗). Reszta Lotów nie ma powszechnie +# wspieranego symbolu → zwracamy None i UI pokazuje nazwę. +LOT = { + "Fortune": "⊗", # ⊗ + "Part of Fortune": "⊗", +} + +# ── znaki zodiaku (wszystkie mają wariant emoji → wszystkie z VS15) ────── +_SIGN_CP = { + "Aries": "♈", "Taurus": "♉", "Gemini": "♊", "Cancer": "♋", + "Leo": "♌", "Virgo": "♍", "Libra": "♎", "Scorpio": "♏", + "Sagittarius": "♐", "Capricorn": "♑", "Aquarius": "♒", "Pisces": "♓", +} +SIGN = {name: _t(cp) for name, cp in _SIGN_CP.items()} + +# ── aspekty (nazwy jak w aspects.py + minor z abbreviations) ───────────── +ASPECT = { + "conjunction": "☌", # ☌ + "opposition": "☍", # ☍ + "trine": "△", # △ + "square": "□", # □ + "sextile": "⚹", # ⚹ + "semisextile": "⚺", # ⚺ + "quincunx": "⚻", # ⚻ + "semisquare": "∠", # ∠ +} + +# ── ruch ───────────────────────────────────────────────────────────────── +RETROGRADE = "℞" # ℞ +DIRECT = "D" # zwykłe „D" — brak dedykowanego glifu prostego ruchu + + +# ── forward: nazwa → glif ──────────────────────────────────────────────── + +def glyph_for(name: str) -> str | None: + """Glif obiektu/punktu/Lota po nazwie (jak w silniku). None, gdy brak.""" + return PLANET.get(name) or POINT.get(name) or LOT.get(name) + + +def sign_glyph(sign: str) -> str | None: + return SIGN.get(sign) + + +def aspect_glyph(aspect: str) -> str | None: + return ASPECT.get(aspect) + + +# ── reverse: glif → nazwa ──────────────────────────────────────────────── + +def _strip_variants(g: str) -> str: + """Zdejmuje selektory wariantu — glif z FE0E i bez daje ten sam klucz.""" + return g.replace(_TEXT, "").replace(_EMOJI, "") + + +def _reverse(mapping: dict[str, str]) -> dict[str, str]: + # pierwsze wystąpienie wygrywa (Fortune vs Part of Fortune → 'Fortune') + out: dict[str, str] = {} + for name, g in mapping.items(): + out.setdefault(_strip_variants(g), name) + return out + + +_PLANET_POINT_REV = _reverse({**PLANET, **POINT, **{"Fortune": LOT["Fortune"]}}) +_SIGN_REV = _reverse(SIGN) +_ASPECT_REV = _reverse(ASPECT) + + +def name_for_glyph(g: str) -> str | None: + """Obiekt/punkt po glifie (znosi obecność lub brak selektora wariantu).""" + return _PLANET_POINT_REV.get(_strip_variants(g)) + + +def sign_for_glyph(g: str) -> str | None: + return _SIGN_REV.get(_strip_variants(g)) + + +def aspect_for_glyph(g: str) -> str | None: + return _ASPECT_REV.get(_strip_variants(g)) + + +# ── glifikacja tekstu sygnifikatora (składnia [XX z bazy) ──────────────── +# Nasze sygnifikatory zapisane są tokenami z prefiksem [ (np. „[Sa [conj [PF"). +# glyphify zamienia znane tokeny na glify, resztę zostawia. To druga strona +# abbreviations.expand: tam token → słowo, tu token → symbol. +import re as _re + +# token po nawiasie (np. „Su", „Tau", „conj", „PF") → glif +_TOKEN_GLYPH: dict[str, str] = {} +_ABBR_TO_NAME = { + "Su": "Sun", "Mo": "Moon", "Me": "Mercury", "Ve": "Venus", "Ma": "Mars", + "Ju": "Jupiter", "Sa": "Saturn", "Ur": "Uranus", "Ne": "Neptune", "Pl": "Pluto", + "NN": "North Node", "SN": "South Node", "Lilith": "Lilith", "Chiron": "Chiron", + "PF": "Fortune", "Fortune": "Fortune", +} +for _abbr, _name in _ABBR_TO_NAME.items(): + _g = glyph_for(_name) + if _g: + _TOKEN_GLYPH[_abbr] = _g +_SIGN_ABBR_NAME = { + "Ari": "Aries", "Tau": "Taurus", "Gem": "Gemini", "Can": "Cancer", "Leo": "Leo", + "Vir": "Virgo", "Lib": "Libra", "Sco": "Scorpio", "Sag": "Sagittarius", + "Cap": "Capricorn", "Aqu": "Aquarius", "Pis": "Pisces", +} +for _abbr, _name in _SIGN_ABBR_NAME.items(): + _TOKEN_GLYPH[_abbr] = SIGN[_name] +_ASP_ABBR_NAME = { + "conj": "conjunction", "opp": "opposition", "tri": "trine", "sq": "square", + "sex": "sextile", "semisex": "semisextile", "quincunx": "quincunx", "semisq": "semisquare", +} +for _abbr, _name in _ASP_ABBR_NAME.items(): + if _name in ASPECT: + _TOKEN_GLYPH[_abbr] = ASPECT[_name] + +_TOKEN_RE = _re.compile(r"\[([A-Za-z]+)") + + +def glyphify(text: str) -> str: + """Zamienia tokeny [XX na glify; nieznane zostawia bez zmiany. Dodatkowo + »Rx«/»R« → ℞. Nie parsuje stopni — te i tak są czytelne (26°08').""" + if not text: + return text + out = _TOKEN_RE.sub(lambda m: _TOKEN_GLYPH.get(m.group(1), m.group(0)), text) + out = _re.sub(r"\bR[x]?\b", RETROGRADE, out) + return out diff --git a/services/logic/tests/test_glyphs.py b/services/logic/tests/test_glyphs.py new file mode 100644 index 0000000..06dfa7c --- /dev/null +++ b/services/logic/tests/test_glyphs.py @@ -0,0 +1,114 @@ +"""Konwersja tekst ↔ symbol astrologiczny (LOG-22). + +Sedno: glify muszą być KOMPLETNE (każdy obiekt/znak/aspekt, który liczymy, ma +symbol), DWUKIERUNKOWE (symbol → tekst z powrotem) i TEKSTOWE, nigdy emoji +(DAN-18) — inaczej na kosmogramie wyjdą kolorowe kwadraty zamiast czarno-białych +symboli. +""" +import pytest + +from app.engine import glyphs as G +from app.engine.aspects import MAJOR +from app.engine.formats import SIGNS +from app.engine.models import DEFAULT_OBJECTS + +VS15, VS16 = "︎", "️" + + +def _all_glyphs(): + return (list(G.PLANET.values()) + list(G.POINT.values()) + list(G.LOT.values()) + + list(G.SIGN.values()) + list(G.ASPECT.values()) + [G.RETROGRADE]) + + +# ------------------------------------------------------------- kompletność + +def test_every_default_object_has_a_glyph(): + """Każdy obiekt z kanonicznego zestawu musi mieć symbol — inaczej na kole + zostanie dziura.""" + missing = [name for name in DEFAULT_OBJECTS if G.glyph_for(name) is None] + assert not missing, f"obiekty bez glifu: {missing}" + + +def test_every_sign_has_a_glyph(): + missing = [s for s in SIGNS if G.sign_glyph(s) is None] + assert not missing, f"znaki bez glifu: {missing}" + + +def test_every_major_aspect_has_a_glyph(): + missing = [a for a in MAJOR if G.aspect_glyph(a) is None] + assert not missing, f"aspekty główne bez glifu: {missing}" + + +def test_fortune_and_retrograde_from_requirement(): + """Wymóg wprost wymienia ⊗ (Fortuna) i ℞ (retrogradacja).""" + assert G.glyph_for("Fortune") == "⊗" + assert G.RETROGRADE == "℞" + + +# ------------------------------------------------------ DAN-18: tekst, nie emoji + +def test_no_glyph_uses_emoji_variation_selector(): + """VS16 (U+FE0F) wymusiłby prezentację emoji — nie może go być NIGDZIE.""" + offenders = [g for g in _all_glyphs() if VS16 in g] + assert not offenders, f"glify z wariantem emoji (FE0F): {offenders!r}" + + +def test_signs_force_text_presentation(): + """Znaki zodiaku mają domyślnie wariant emoji → MUSZĄ nieść VS15.""" + for s in SIGNS: + assert VS15 in G.sign_glyph(s), f"{s} bez wymuszenia tekstu (VS15)" + + +def test_venus_mars_force_text_but_sun_does_not(): + """♀/♂ mają wariant emoji (VS15 konieczny); ☉ nie ma (VS15 zbędny).""" + assert VS15 in G.glyph_for("Venus") + assert VS15 in G.glyph_for("Mars") + assert VS15 not in G.glyph_for("Sun") # bez zaśmiecania niepotrzebnym selektorem + + +# ---------------------------------------------------------- dwukierunkowość + +def test_object_round_trip(): + for name in DEFAULT_OBJECTS: + assert G.name_for_glyph(G.glyph_for(name)) == name + + +def test_sign_round_trip(): + for s in SIGNS: + assert G.sign_for_glyph(G.sign_glyph(s)) == s + + +def test_aspect_round_trip(): + for a in MAJOR: + assert G.aspect_for_glyph(G.aspect_glyph(a)) == a + + +def test_reverse_tolerates_missing_variant_selector(): + """Symbol wklejony bez VS15 też musi się rozpoznać — bo z zewnątrz przyjdzie + dowolna forma (DAN-17: łańcuch znosi każdy Unicode).""" + assert G.sign_for_glyph("♓") == "Pisces" # ♓ bez VS15 + assert G.sign_for_glyph("♓" + VS15) == "Pisces" # ♓ z VS15 + assert G.name_for_glyph("♀") == "Venus" # ♀ bez VS15 + + +# --------------------------------------------------------- glifikacja tekstu + +def test_glyphify_matches_requirement_example(): + """Przykład z wymagań: [Sa [Pis 26°08' [conj [PF -> ♄ ♓ 26°08' ☌ ⊗.""" + out = G.glyphify("[Sa [Pis 26°08' [conj [PF") + assert G.glyph_for("Saturn") in out + assert G.sign_glyph("Pisces") in out + assert G.aspect_glyph("conjunction") in out + assert G.glyph_for("Fortune") in out + assert "26°08'" in out # stopnie nietknięte + + +def test_glyphify_handles_retrograde_and_leaves_unknown(): + out = G.glyphify("[Ma Rx w [Xyz") + assert G.glyph_for("Mars") in out + assert G.RETROGRADE in out + assert "[Xyz" in out # nieznany token bez zmian + + +def test_glyphify_empty_is_safe(): + assert G.glyphify("") == "" diff --git a/services/presentation/app/static/styles.css b/services/presentation/app/static/styles.css index 0f218c2..a089f20 100644 --- a/services/presentation/app/static/styles.css +++ b/services/presentation/app/static/styles.css @@ -32,6 +32,18 @@ h1 { margin: 0; font-size: 2rem; letter-spacing: .5px; } button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); } .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; } td.retro { color: #ff9b6a; font-weight: 600; } +/* Glify astrologiczne (LOG-22). Trzy warstwy obrony przed prezentacją emoji + (DAN-18: kolorowe kwadraty są nieczytelne i niesterowalne kolorem): + 1) selektor wariantu U+FE0E w samych danych (glyphs.py), + 2) font-variant-emoji:text — nowsza własność CSS, + 3) font-family celujący w MONOCHROMATYCZNE fonty symboli PRZED jakimkolwiek + fontem emoji. Bez tego macOS/Chromium i tak sięga po Apple Color Emoji dla + znaków zodiaku (♈–♓), mimo VS15 — potwierdzone wizualnie. „Apple Symbols", + „Segoe UI Symbol", „Noto Sans Symbols2" mają te glify w wersji tekstowej. */ +.glyph { font-size: 1.15em; line-height: 1; font-variant-emoji: text; + font-family: "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols2", + "DejaVu Sans", sans-serif; } +td.glyph { color: var(--accent); white-space: nowrap; } form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; } .row { display: flex; gap: .5rem; } .row input[type=text] { flex: 1; } diff --git a/services/presentation/app/templates/chart.html b/services/presentation/app/templates/chart.html index 6eedb7f..deeccf0 100644 --- a/services/presentation/app/templates/chart.html +++ b/services/presentation/app/templates/chart.html @@ -86,6 +86,7 @@ + @@ -93,6 +94,7 @@ {% for p in result.positions %} + @@ -140,7 +142,7 @@ {% for a in result.aspects %} - + {% endfor %}
Sym. ObiektZnakW znakuDomKier.Prędkość °/d Dekl.
{{ p.glyph or '' }}{% if p.sign_glyph %} {{ p.sign_glyph }}{% endif %} {{ p.name }} {{ p.sign }} {{ p.in_sign }}
Obiekt 1AspektObiekt 2OrbA/S
{{ a.obj1 }}{{ a.aspect }}{{ a.obj2 }}{{ '%.2f'|format(a.orb) }}°{{ a['as'] if a['as'] is defined else '—' }}
{{ a.obj1 }}{% if a.glyph %}{{ a.glyph }} {% endif %}{{ a.aspect }}{{ a.obj2 }}{{ '%.2f'|format(a.orb) }}°{{ a['as'] if a['as'] is defined else '—' }}
-- 2.52.0