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:
@@ -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)
|
||||
|
||||
@@ -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(月亮参考盘)',
|
||||
'',
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user