Source code for stellium.chinese.bazi.renderers

"""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)