#!/usr/bin/env python3 """Structured North-Indian chart fences for longform report Markdown. Engine SVG stays in the Markdown for exporters. The report page never renders that HTML; it reads the ``jyotish-chart`` fence beside each SVG. """ from __future__ import annotations import json import re from typing import Any PLANET_ORDER = ( "Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu", ) SIGNS = ( "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces", ) CHART_ID_RE = re.compile(r"^(D\d{1,3}|MOON)$") FENCE_RE = re.compile(r"```jyotish-chart\n(.*)\n```") _D_ID_RE = re.compile(r"\b(D\d{1,3})\b") def resolve_chart_id(chart_id: str | None, title: str) -> str: """Prefer an explicit varga prefix (``D2_Hora`` → ``D2``), else parse the title.""" if chart_id: prefix = str(chart_id).split("_", 1)[0] if CHART_ID_RE.match(prefix): return prefix if re.search(r"Moon", title, re.I): return "MOON" match = _D_ID_RE.search(title) if match and CHART_ID_RE.match(match.group(1)): return match.group(1) raise ValueError("chart id missing") def _degree_in_sign(row: dict[str, Any]) -> float: for field in ("degree_in_sign", "degree_in_sign_raw", "longitude_in_sign"): value = row.get(field) if isinstance(value, (int, float)): degree = float(value) % 30.0 if degree < 0: degree += 30.0 return round(degree, 2) for field in ("degree", "degree_raw", "longitude", "lon"): value = row.get(field) if isinstance(value, (int, float)): degree = float(value) % 30.0 if degree < 0: degree += 30.0 return round(degree, 2) return 0.0 def _sign_name(row: dict[str, Any]) -> str | None: sign = row.get("sign") or row.get("rashi") or row.get("sign_name") if sign in SIGNS: return str(sign) return None def build_chart_block( chart_id: str, title: str, positions: dict, ascendant_row: dict, retrograde_by_planet: dict[str, bool], ) -> str: """Return a one-line JSON fence. Never interpolates user-supplied strings.""" resolved_id = resolve_chart_id(chart_id, title) if not isinstance(ascendant_row, dict): ascendant_row = {} asc_sign = _sign_name(ascendant_row) if not asc_sign: raise ValueError("ascendant sign missing") planets: list[dict[str, Any]] = [] source = positions if isinstance(positions, dict) else {} for name in PLANET_ORDER: row = source.get(name) if not isinstance(row, dict): continue sign = _sign_name(row) if not sign: continue planets.append( { "name": name, "sign": sign, "degree": _degree_in_sign(row), "retrograde": bool(retrograde_by_planet.get(name, False)), } ) if len(planets) >= 9: break payload = { "version": 1, "id": resolved_id, "title": str(title), "layout": "north", "ascendant": { "sign": asc_sign, "degree": _degree_in_sign(ascendant_row), }, "planets": planets, } body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) return f"```jyotish-chart\n{body}\n```" def extract_chart_blocks(markdown: str) -> list[dict[str, Any]]: blocks: list[dict[str, Any]] = [] for match in FENCE_RE.finditer(markdown or ""): blocks.append(json.loads(match.group(1))) return blocks