"""Renderers for Bazi (Four Pillars) charts.
This module provides different output formats for Bazi charts:
- BaziRichRenderer: Beautiful terminal output using Rich library
- BaziSVGRenderer: Visual SVG chart rendering
- BaziProseRenderer: Natural language prose output
"""
from typing import TYPE_CHECKING, Any
from stellium.chinese.core import Element
if TYPE_CHECKING:
from stellium.chinese.bazi.models import BaZiChart
try:
from rich import box
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
RICH_AVAILABLE = True
except ImportError:
RICH_AVAILABLE = False
# Element colors for Rich output
ELEMENT_COLORS = {
Element.WOOD: "green",
Element.FIRE: "red",
Element.EARTH: "yellow",
Element.METAL: "white",
Element.WATER: "blue",
}
[docs]
class BaziRichRenderer:
"""Render Bazi charts using Rich library for beautiful terminal output.
Requires: pip install rich
Example:
>>> from stellium.chinese.bazi import BaZiEngine
>>> from stellium.chinese.bazi.renderers import BaziRichRenderer
>>> from datetime import datetime
>>>
>>> engine = BaZiEngine(timezone_offset_hours=-8)
>>> chart = engine.calculate(datetime(1994, 1, 6, 11, 47))
>>>
>>> renderer = BaziRichRenderer()
>>> renderer.print_chart(chart) # Prints to terminal
"""
def __init__(self) -> None:
"""Initialize Rich renderer."""
if not RICH_AVAILABLE:
raise ImportError(
"Rich library not available. Install with: pip install rich"
)
self.console = Console(record=True)
[docs]
def print_chart(
self,
chart: "BaZiChart",
show_hidden_stems: bool = True,
show_ten_gods: bool = True,
show_summary: bool = True,
) -> None:
"""Print Bazi chart to terminal with Rich formatting.
Args:
chart: The BaZiChart to render
show_hidden_stems: Whether to show hidden stems in branches
show_ten_gods: Whether to show Ten Gods relationships
show_summary: Whether to show element/polarity summary
"""
console = Console()
# Title
console.print()
console.print(
Panel(
f"[bold]八字 Bazi Chart[/bold]\n{chart.birth_datetime.strftime('%Y-%m-%d %H:%M')}",
style="cyan",
expand=False,
)
)
# Main pillars table
table = self._create_pillars_table(chart, show_hidden_stems, show_ten_gods)
console.print(table)
# Summary panel
if show_summary:
self._print_summary(console, chart)
[docs]
def render_chart(
self,
chart: "BaZiChart",
show_hidden_stems: bool = True,
show_ten_gods: bool = True,
show_summary: bool = True,
) -> str:
"""Render Bazi chart to string (for file output).
Returns plain text with ANSI codes stripped.
"""
# Create fresh console for recording
console = Console(record=True)
# Title
console.print()
console.print("八字 Bazi Chart")
console.print(f"{chart.birth_datetime.strftime('%Y-%m-%d %H:%M')}")
console.print()
# Main pillars table
table = self._create_pillars_table(chart, show_hidden_stems, show_ten_gods)
console.print(table)
# Summary
if show_summary:
self._print_summary(console, chart)
return console.export_text()
def _create_pillars_table(
self,
chart: "BaZiChart",
show_hidden_stems: bool,
show_ten_gods: bool,
) -> Table:
"""Create the main pillars table."""
from stellium.chinese.bazi.analysis import calculate_ten_god
table = Table(
show_header=True,
header_style="bold magenta",
box=box.ROUNDED,
title="Four Pillars (四柱)",
title_style="bold cyan",
)
# Pillar labels for columns
pillar_names = ["Year", "Month", "Day", "Hour"]
pillar_hanzi = ["年", "月", "日", "时"]
# Add columns dynamically
table.add_column("", style="dim") # Row labels
for eng, chn in zip(pillar_names, pillar_hanzi, strict=True):
# Day pillar gets bold style since it contains the Day Master
style = "bold" if eng == "Day" else None
table.add_column(f"{eng} ({chn})", justify="center", style=style)
# Ten Gods row
if show_ten_gods:
gods_row = ["十神"]
for pillar in chart.pillars:
god = calculate_ten_god(chart.day_master, pillar.stem)
gods_row.append(f"{god.hanzi}")
table.add_row(*gods_row, style="cyan")
# Stems row
stems_row = ["天干"]
for pillar in chart.pillars:
stem = pillar.stem
color = ELEMENT_COLORS.get(stem.element, "white")
stems_row.append(f"[{color}]{stem.hanzi}[/{color}] {stem.element.hanzi}")
table.add_row(*stems_row)
# Branches row
branches_row = ["地支"]
for pillar in chart.pillars:
branch = pillar.branch
color = ELEMENT_COLORS.get(branch.element, "white")
branches_row.append(f"[{color}]{branch.hanzi}[/{color}] {branch.animal}")
table.add_row(*branches_row)
# Hidden stems rows
if show_hidden_stems:
max_hidden = max(len(p.hidden_stems) for p in chart.pillars)
hidden_labels = ["藏干(本)", "藏干(中)", "藏干(余)"]
for i in range(max_hidden):
row_label = (
hidden_labels[i] if i < len(hidden_labels) else f"藏干({i + 1})"
)
hidden_row = [row_label]
for pillar in chart.pillars:
if i < len(pillar.hidden_stems):
hs = pillar.hidden_stems[i]
god = calculate_ten_god(chart.day_master, hs)
color = ELEMENT_COLORS.get(hs.element, "white")
hidden_row.append(
f"[{color}]{hs.hanzi}[/{color}]{god.hanzi[:1]}"
)
else:
hidden_row.append("")
table.add_row(*hidden_row, style="dim")
return table
def _print_summary(self, console: Console, chart: "BaZiChart") -> None:
"""Print summary information below the chart."""
from stellium.chinese.bazi.analysis import (
analyze_ten_gods,
count_ten_god_categories,
)
console.print()
# Day Master info
dm = chart.day_master
dm_color = ELEMENT_COLORS.get(dm.element, "white")
console.print(
f"Day Master (日主): [{dm_color}]{dm.hanzi}[/{dm_color}] "
f"({dm.element.english} {dm.polarity.value})"
)
console.print(f"Eight Characters: {chart.hanzi}")
# Element counts
console.print()
console.print("[bold]Element Balance (including hidden stems):[/bold]")
element_counts = chart.element_counts(include_hidden=True)
elements_text = []
for elem in Element:
count = element_counts.get(elem, 0)
color = ELEMENT_COLORS.get(elem, "white")
elements_text.append(f"[{color}]{elem.hanzi}[/{color}]: {count}")
console.print(" " + " ".join(elements_text))
# Ten Gods category counts
relations = analyze_ten_gods(chart, include_hidden=True)
categories = count_ten_god_categories(relations)
console.print()
console.print("[bold]Ten Gods Categories:[/bold]")
cat_text = []
for cat in ["Self", "Companion", "Output", "Wealth", "Power", "Resource"]:
count = categories.get(cat, 0)
cat_text.append(f"{cat}: {count}")
console.print(" " + " ".join(cat_text))
[docs]
class BaziProseRenderer:
"""Render Bazi charts as natural language prose.
Designed for pasting into conversations or documents.
Example:
>>> renderer = BaziProseRenderer()
>>> prose = renderer.render(chart)
>>> print(prose)
"""
def __init__(self, bullet: str = "•") -> None:
"""Initialize prose renderer.
Args:
bullet: Character to use for list items
"""
self.bullet = bullet
[docs]
def render(
self,
chart: "BaZiChart",
include_hidden_stems: bool = True,
include_ten_gods: bool = True,
) -> str:
"""Render chart as prose text.
Args:
chart: The BaZiChart to render
include_hidden_stems: Include hidden stem analysis
include_ten_gods: Include Ten Gods relationships
Returns:
Natural language description of the chart
"""
from stellium.chinese.bazi.analysis import (
analyze_ten_gods,
count_ten_god_categories,
)
paragraphs = []
# Opening with datetime and Day Master
dm = chart.day_master
opening = (
f"Bazi chart for {chart.birth_datetime.strftime('%B %d, %Y at %I:%M %p')}. "
f"The Day Master is {dm.hanzi} ({dm.pinyin}), "
f"a {dm.polarity.value} {dm.element.english} person."
)
paragraphs.append(opening)
# Eight Characters
paragraphs.append(f"Eight Characters (八字): {chart.hanzi}")
# Four Pillars breakdown
pillar_names = ["Year", "Month", "Day", "Hour"]
pillar_lines = ["The Four Pillars:"]
for name, pillar in zip(pillar_names, chart.pillars, strict=False):
pillar_lines.append(
f"{self.bullet} {name}: {pillar.hanzi} "
f"({pillar.stem.element.english} {pillar.branch.animal})"
)
paragraphs.append("\n".join(pillar_lines))
# Ten Gods analysis
if include_ten_gods:
relations = analyze_ten_gods(chart, include_hidden=include_hidden_stems)
main_relations = [r for r in relations if not r.is_hidden]
gods_lines = ["Ten Gods (十神) in main stems:"]
for r in main_relations:
if r.pillar_name == "day":
gods_lines.append(
f"{self.bullet} {r.pillar_name.capitalize()}: {r.stem.hanzi} - Self (日主)"
)
else:
gods_lines.append(
f"{self.bullet} {r.pillar_name.capitalize()}: {r.stem.hanzi} - "
f"{r.ten_god.english} ({r.ten_god.chinese})"
)
paragraphs.append("\n".join(gods_lines))
# Category summary
categories = count_ten_god_categories(relations)
cat_parts = [f"{cat}: {count}" for cat, count in sorted(categories.items())]
paragraphs.append("Ten Gods category distribution: " + ", ".join(cat_parts))
# Element balance
element_counts = chart.element_counts(include_hidden=include_hidden_stems)
elem_parts = [
f"{elem.english}: {count}" for elem, count in element_counts.items()
]
paragraphs.append("Element balance: " + ", ".join(elem_parts))
return "\n\n".join(paragraphs)
[docs]
class BaziSVGRenderer:
"""Render Bazi charts as SVG images.
Creates a visual representation of the Four Pillars with:
- Color-coded elements
- Hidden stems shown below main characters
- Ten Gods labels
- Element balance visualization
Example:
>>> renderer = BaziSVGRenderer()
>>> svg_content = renderer.render(chart)
>>> with open("chart.svg", "w") as f:
... f.write(svg_content)
"""
# Colors matching Chinese element associations
ELEMENT_COLORS = {
Element.WOOD: "#4caf50", # Green
Element.FIRE: "#f44336", # Red
Element.EARTH: "#795548", # Brown
Element.METAL: "#9e9e9e", # Gray/Silver
Element.WATER: "#2196f3", # Blue
}
def __init__(
self,
width: int = 600,
height: int = 400,
font_family: str = "Noto Sans SC, SimSun, Microsoft YaHei, sans-serif",
) -> None:
"""Initialize SVG renderer.
Args:
width: SVG width in pixels
height: SVG height in pixels
font_family: CSS font-family for Chinese characters
"""
self.width = width
self.height = height
self.font_family = font_family
[docs]
def render(
self,
chart: "BaZiChart",
show_hidden_stems: bool = True,
show_ten_gods: bool = True,
title: str | None = None,
) -> str:
"""Render chart as SVG string.
Args:
chart: The BaZiChart to render
show_hidden_stems: Show hidden stems row
show_ten_gods: Show Ten Gods labels
title: Optional title (defaults to birth datetime)
Returns:
SVG content as string
"""
from stellium.chinese.bazi.analysis import calculate_ten_god
# Layout constants
col_width = self.width / 4
header_height = 50
row_height = 45
padding = 20
# Calculate total height needed
num_rows = 2 # stems + branches
if show_ten_gods:
num_rows += 1
if show_hidden_stems:
max_hidden = max(len(p.hidden_stems) for p in chart.pillars)
num_rows += max_hidden
total_height = (
header_height + (num_rows * row_height) + padding * 2 + 60
) # extra for title
svg_parts = [
f'<svg xmlns="http://www.w3.org/2000/svg" '
f'width="{self.width}" height="{total_height}" '
f'viewBox="0 0 {self.width} {total_height}">',
"<style>",
f" .title {{ font-family: {self.font_family}; font-size: 18px; font-weight: bold; }}",
f" .header {{ font-family: {self.font_family}; font-size: 14px; fill: #666; }}",
f" .hanzi {{ font-family: {self.font_family}; font-size: 24px; font-weight: bold; }}",
f" .label {{ font-family: {self.font_family}; font-size: 12px; fill: #888; }}",
f" .god {{ font-family: {self.font_family}; font-size: 14px; fill: #666; }}",
f" .hidden {{ font-family: {self.font_family}; font-size: 16px; }}",
"</style>",
'<rect width="100%" height="100%" fill="#fafafa"/>',
]
# Title
title_text = (
title or f"Bazi Chart - {chart.birth_datetime.strftime('%Y-%m-%d %H:%M')}"
)
svg_parts.append(
f'<text x="{self.width / 2}" y="30" text-anchor="middle" class="title">{title_text}</text>'
)
# Column headers
pillar_labels = [
("Year", "年柱"),
("Month", "月柱"),
("Day", "日柱"),
("Hour", "时柱"),
]
y_start = 60
for i, (eng, chn) in enumerate(pillar_labels):
x = col_width * i + col_width / 2
svg_parts.append(
f'<text x="{x}" y="{y_start}" text-anchor="middle" class="header">{eng}</text>'
)
svg_parts.append(
f'<text x="{x}" y="{y_start + 16}" text-anchor="middle" class="header">{chn}</text>'
)
current_y = y_start + header_height - 20
# Ten Gods row
if show_ten_gods:
for i, pillar in enumerate(chart.pillars):
god = calculate_ten_god(chart.day_master, pillar.stem)
x = col_width * i + col_width / 2
svg_parts.append(
f'<text x="{x}" y="{current_y}" text-anchor="middle" class="god">{god.hanzi}</text>'
)
current_y += row_height
# Stems row
for i, pillar in enumerate(chart.pillars):
x = col_width * i + col_width / 2
color = self.ELEMENT_COLORS.get(pillar.stem.element, "#333")
svg_parts.append(
f'<text x="{x}" y="{current_y}" text-anchor="middle" class="hanzi" fill="{color}">'
f"{pillar.stem.hanzi}</text>"
)
# Element label
svg_parts.append(
f'<text x="{x + 20}" y="{current_y}" text-anchor="start" class="label">'
f"{pillar.stem.element.hanzi}</text>"
)
current_y += row_height
# Branches row
for i, pillar in enumerate(chart.pillars):
x = col_width * i + col_width / 2
color = self.ELEMENT_COLORS.get(pillar.branch.element, "#333")
svg_parts.append(
f'<text x="{x}" y="{current_y}" text-anchor="middle" class="hanzi" fill="{color}">'
f"{pillar.branch.hanzi}</text>"
)
# Animal label
svg_parts.append(
f'<text x="{x + 20}" y="{current_y}" text-anchor="start" class="label">'
f"{pillar.branch.animal}</text>"
)
current_y += row_height
# Hidden stems rows
if show_hidden_stems:
max_hidden = max(len(p.hidden_stems) for p in chart.pillars)
for row_idx in range(max_hidden):
for i, pillar in enumerate(chart.pillars):
if row_idx < len(pillar.hidden_stems):
hs = pillar.hidden_stems[row_idx]
x = col_width * i + col_width / 2
color = self.ELEMENT_COLORS.get(hs.element, "#333")
god = calculate_ten_god(chart.day_master, hs)
svg_parts.append(
f'<text x="{x}" y="{current_y}" text-anchor="middle" class="hidden" fill="{color}">'
f"{hs.hanzi}{god.hanzi[:1]}</text>"
)
current_y += row_height - 10
# Day Master summary at bottom
dm = chart.day_master
dm_color = self.ELEMENT_COLORS.get(dm.element, "#333")
summary_y = current_y + 20
svg_parts.append(
f'<text x="{self.width / 2}" y="{summary_y}" text-anchor="middle" class="label">'
f'Day Master: <tspan fill="{dm_color}" font-weight="bold">{dm.hanzi}</tspan> '
f"({dm.element.english} {dm.polarity.value}) | "
f"八字: {chart.hanzi}</text>"
)
svg_parts.append("</svg>")
return "\n".join(svg_parts)
[docs]
def render_to_file(
self,
chart: "BaZiChart",
filepath: str,
**kwargs: Any,
) -> None:
"""Render chart and save to file.
Args:
chart: The BaZiChart to render
filepath: Output file path (should end in .svg)
**kwargs: Additional arguments passed to render()
"""
svg_content = self.render(chart, **kwargs)
with open(filepath, "w", encoding="utf-8") as f:
f.write(svg_content)