Files
Jyotisha/scripts/report_chart_block.py
T
Jesse_ChenandCursor 2fdcb14fd6 fix(report): draw North Indian charts from fences after skipHtml dropped SVG
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>
2026-09-09 15:11:00 +08:00

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