Longform report pages kept the D1/D9/Moon and varga headings but skipHtml stripped the engine's inline SVG. Emit a jyotish-chart JSON fence beside each SVG and render a diamond chart on the reader without relaxing HTML sanitization. BUG-607. Co-authored-by: Cursor <cursoragent@cursor.com>
137 lines
3.7 KiB
Python
137 lines
3.7 KiB
Python
#!/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
|