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>
This commit is contained in:
Jesse_Chen
2026-09-09 15:11:00 +08:00
co-authored by Cursor
parent d5972ef44a
commit 2fdcb14fd6
23 changed files with 2628 additions and 124 deletions
+20 -2
View File
@@ -7,6 +7,7 @@
支持:D1(本命)/D9(Navamsa)/D10(Dasamsa)等分盘
"""
import re
from typing import Dict, List, Optional
SIGNS = ['Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo',
@@ -55,6 +56,21 @@ GRID_OFFSET_X = 50
GRID_OFFSET_Y = 50
SVG_WIDTH = GRID_OFFSET_X * 2 + CELL_SIZE * 4
SVG_HEIGHT = GRID_OFFSET_Y * 2 + CELL_SIZE * 4 + 60
_D_ID_RE = re.compile(r"\b(D\d{1,3})\b")
def _center_caption(title: str) -> tuple[str, str]:
"""Keep D1 as ``Rasi Chart / (D1)``; other titles follow the heading id."""
moon = bool(re.search(r"Moon", title, re.I))
match = _D_ID_RE.search(title or "")
chart_id = match.group(1) if match else None
if chart_id == "D1" and not moon:
return "Rasi Chart", "(D1)"
if moon:
return "Chart", "(Moon)"
if chart_id:
return "Chart", f"({chart_id})"
return "Chart", ""
def render_south_indian_chart(planets: Dict, asc_sign: str, title: str = "D1 — Rashi Chart") -> str:
@@ -117,8 +133,10 @@ def render_south_indian_chart(planets: Dict, asc_sign: str, title: str = "D1 —
# 中心区域(传统上写星盘信息)
cx = GRID_OFFSET_X + 1.5 * CELL_SIZE
cy = GRID_OFFSET_Y + 1.5 * CELL_SIZE
lines.append(f'<text x="{cx}" y="{cy-5}" text-anchor="middle" font-size="10" fill="#999">Rasi Chart</text>')
lines.append(f'<text x="{cx}" y="{cy+12}" text-anchor="middle" font-size="10" fill="#999">(D1)</text>')
caption, chart_id = _center_caption(title)
lines.append(f'<text x="{cx}" y="{cy-5}" text-anchor="middle" font-size="10" fill="#999">{caption}</text>')
if chart_id:
lines.append(f'<text x="{cx}" y="{cy+12}" text-anchor="middle" font-size="10" fill="#999">{chart_id}</text>')
lines.append('</svg>')
return '\n'.join(lines)
+28 -6
View File
@@ -4181,23 +4181,45 @@ def render_pl9_markdown(packet: dict) -> str:
chart_planets[planet_name] = {'sign': sign, 'degree': degree or 0}
return chart_planets
def _render_south_chart(positions: dict, ascendant_row: dict, title: str) -> str | None:
def _render_south_chart(
positions: dict,
ascendant_row: dict,
title: str,
chart_id: str | None = None,
) -> str | None:
chart_planets = _chart_planets_from_positions(positions)
asc_sign = ascendant_row.get('sign') if isinstance(ascendant_row, dict) else None
if not chart_planets or not asc_sign:
return None
try:
from chart_renderer import render_south_indian_chart
return render_south_indian_chart(chart_planets, asc_sign, title)
svg = render_south_indian_chart(chart_planets, asc_sign, title)
except Exception as exc:
return f'_图盘生成失败:{exc}_'
from report_chart_block import PLANET_ORDER, build_chart_block, resolve_chart_id
core_chart = packet.get('core_chart') if isinstance(packet.get('core_chart'), dict) else {}
natal_planets = core_chart.get('planets') if isinstance(core_chart.get('planets'), dict) else {}
retrograde_by_planet = {
name: bool(row.get('retrograde')) if isinstance(row, dict) else False
for name in PLANET_ORDER
for row in [natal_planets.get(name)]
}
block = build_chart_block(
resolve_chart_id(chart_id, title),
title,
positions if isinstance(positions, dict) else {},
ascendant_row if isinstance(ascendant_row, dict) else {},
retrograde_by_planet,
)
return f"{svg}\n\n{block}"
def _d1_chart_svg() -> str | None:
core_chart = packet.get('core_chart') if isinstance(packet.get('core_chart'), dict) else {}
return _render_south_chart(
core_chart.get('planets') if isinstance(core_chart.get('planets'), dict) else {},
core_chart.get('ascendant') if isinstance(core_chart.get('ascendant'), dict) else {},
'D1 — Rashi Chart (本命盘)',
'D1 — Rashi Chart本命盘',
'D1',
)
def _varga_full_sheet() -> dict:
@@ -4214,10 +4236,10 @@ def render_pl9_markdown(packet: dict) -> str:
if not chart:
return None
ascendant_row = chart.get('Ascendant') if isinstance(chart.get('Ascendant'), dict) else chart.get('Asc') if isinstance(chart.get('Asc'), dict) else {}
return _render_south_chart(chart, ascendant_row, title)
return _render_south_chart(chart, ascendant_row, title, chart_key.split('_', 1)[0])
def _d9_chart_svg() -> str | None:
return _varga_chart_svg('D9_Navamsa', 'D9 — Navamsa (婚盘)') or _varga_chart_svg('D9', 'D9 — Navamsa (婚盘)')
return _varga_chart_svg('D9_Navamsa', 'D9 — Navamsa婚盘') or _varga_chart_svg('D9', 'D9 — Navamsa婚盘')
def _moon_chart_section() -> list[str]:
d1_sheet = worksheets.get('d1_rasi_bhava') if isinstance(worksheets.get('d1_rasi_bhava'), dict) else {}
@@ -4227,7 +4249,7 @@ def render_pl9_markdown(packet: dict) -> str:
planets = raw.get('planets') if isinstance(raw.get('planets'), dict) else {}
if not reference or not planets:
return []
svg = _render_south_chart(planets, reference, 'Moon Chart(月亮参考盘)')
svg = _render_south_chart(planets, reference, 'Moon Chart(月亮参考盘)', 'MOON')
out = [
'#### Moon Chart(月亮参考盘)',
'',
+136
View File
@@ -0,0 +1,136 @@
#!/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
+2
View File
@@ -83,6 +83,8 @@ CORE_PYTEST_TARGETS = [
"tests/test_consultation_workflow_birth_time_sensitivity.py",
"tests/test_report_longform_parity.py",
"tests/test_report_longform_gaps2.py",
# 22 SVG fences beside each longform chart; skipHtml dropped the HTML (BUG-607).
"tests/test_report_chart_block.py",
"tests/test_timing_precision_contract.py",
"tests/test_flexible_birth_time_report_contract.py",
"tests/test_mcp_strict_workflow_finance.py",