Source code for stellium.visualization.atlas.renderer

"""
AtlasRenderer - Generate atlas PDFs using Typst typesetting.

Renders chart SVGs and compiles them into a multi-page PDF.
"""

from __future__ import annotations

import os
import tempfile
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from stellium.visualization.atlas.config import AtlasConfig, AtlasEntry

# Check if typst is available
try:
    import typst as typst_lib

    TYPST_AVAILABLE = True
except ImportError:
    TYPST_AVAILABLE = False


[docs] class AtlasRenderer: """ Renders atlas PDF using Typst typesetting. Generates chart SVGs for each entry, embeds them in a Typst document, and compiles to PDF. """ def __init__(self, config: AtlasConfig) -> None: """ Initialize renderer with configuration. Args: config: AtlasConfig from AtlasBuilder """ if not TYPST_AVAILABLE: raise ImportError( "Typst library not available. Install with: pip install typst" ) self.config = config self._temp_dir: str | None = None self._svg_paths: list[str] = []
[docs] def render(self) -> bytes: """ Render the complete atlas to PDF. Returns: PDF as bytes """ # Create temp directory for chart files self._temp_dir = tempfile.mkdtemp(prefix="stellium_atlas_") try: # Generate chart SVGs self._generate_charts() # Generate Typst document typst_content = self._generate_typst_document() # Write to temp file and compile typst_path = os.path.join(self._temp_dir, "atlas.typ") with open(typst_path, "w", encoding="utf-8") as f: f.write(typst_content) # Get font directories font_dirs = self._get_font_dirs() # Compile to PDF. Use the temp directory as the Typst project # root — all chart SVGs are generated inside _temp_dir, and this # avoids platform-specific issues with root="/" on Windows where # the temp dir may live on a different drive than the POSIX root. pdf_bytes = typst_lib.compile( typst_path, root=self._temp_dir, font_paths=font_dirs, ) return pdf_bytes finally: # Clean up temp files self._cleanup_temp_files()
def _get_font_dirs(self) -> list[str]: """Get font directories for Typst compilation.""" base_font_dir = os.path.join( os.path.dirname( os.path.dirname(os.path.dirname(os.path.dirname(__file__))) ), "assets", "fonts", ) return [ base_font_dir, os.path.join(base_font_dir, "Cinzel_Decorative"), os.path.join(base_font_dir, "Crimson_Pro"), os.path.join(base_font_dir, "Crimson_Pro", "static"), os.path.join(base_font_dir, "Noto_Sans_Symbols"), os.path.join(base_font_dir, "Noto_Sans_Symbols_2"), os.path.join(base_font_dir, "Symbola"), ] def _cleanup_temp_files(self) -> None: """Clean up temporary files.""" import shutil if self._temp_dir and os.path.exists(self._temp_dir): shutil.rmtree(self._temp_dir) def _generate_charts(self) -> None: """Generate chart files for all entries.""" for i, entry in enumerate(self.config.entries): svg_path = self._generate_chart_svg(entry, i) self._svg_paths.append(svg_path) def _generate_chart_svg(self, entry: AtlasEntry, index: int) -> str: """ Generate SVG file for one entry. Args: entry: AtlasEntry with native and chart config index: Entry index for filename Returns: Path to generated SVG file """ from stellium.core.builder import ChartBuilder from stellium.engines import ModernAspectEngine # Calculate chart with aspects chart = ( ChartBuilder.from_native(entry.native) .with_aspects(ModernAspectEngine()) .calculate() ) # Generate SVG based on chart_type svg_path = os.path.join(self._temp_dir, f"chart_{index}.svg") if entry.chart_type == "wheel": builder = chart.draw(svg_path) # Apply configuration if self.config.show_header: builder.with_header() builder.with_theme(self.config.theme) builder.with_zodiac_palette(self.config.zodiac_palette) # Info corners if self.config.show_aspect_counts: builder.with_aspect_counts() if self.config.show_element_modality: builder.with_element_modality_table() # Extended tables if self.config.show_extended_tables: builder.with_tables(position="right") builder.save() elif entry.chart_type == "dial": degrees = entry.chart_options.get("degrees", 90) builder = chart.draw_dial(svg_path, degrees=degrees) # Apply configuration if self.config.show_header: builder.with_header() builder.with_theme(self.config.theme) builder.save() else: raise ValueError(f"Unknown chart_type: {entry.chart_type}") return svg_path def _generate_typst_document(self) -> str: """Generate complete Typst document.""" parts = [] # Document preamble parts.append(self._get_preamble()) # Optional title page if self.config.title: parts.append(self._render_title_page()) # Group entries by event type for section dividers births = [] events = [] other = [] for i, entry in enumerate(self.config.entries): # Check if entry has event_type (Notable has it, Native doesn't) event_type = getattr(entry.native, "event_type", None) if event_type == "birth": births.append((i, self._svg_paths[i], entry)) elif event_type == "event": events.append((i, self._svg_paths[i], entry)) else: other.append((i, self._svg_paths[i], entry)) # Render sections with dividers if we have both types has_sections = len(births) > 0 and (len(events) > 0 or len(other) > 0) # Births section if births: if has_sections: parts.append(self._render_section_divider("Births", len(births))) for i, svg_path, _entry in births: parts.append(self._render_chart_page(svg_path, i)) # Events section if events: if has_sections: parts.append( self._render_section_divider("Historical Events", len(events)) ) for i, svg_path, _entry in events: parts.append(self._render_chart_page(svg_path, i)) # Other (entries without event_type, e.g., plain Native objects) if other: if has_sections and (births or events): parts.append(self._render_section_divider("Charts", len(other))) for i, svg_path, _entry in other: parts.append(self._render_chart_page(svg_path, i)) return "\n".join(parts) def _get_preamble(self) -> str: """Get Typst document preamble with styling.""" # Handle landscape for extended tables if self.config.show_extended_tables: # Landscape - use flipped parameter page_size = { "a4": '"a4"', "letter": '"us-letter"', "half-letter": '"us-letter"', # Fall back to letter for half }.get(self.config.page_size, '"us-letter"') page_flipped = "true" else: # Portrait dimensions page_size = { "a4": '"a4"', "letter": '"us-letter"', "half-letter": '"us-letter"', }.get(self.config.page_size, '"us-letter"') page_flipped = "false" return f"""// Stellium Chart Atlas // Generated with Typst // ============================================================================ // COLOR PALETTE // ============================================================================ #let primary = rgb("#4a3353") #let secondary = rgb("#6b4d6e") #let accent = rgb("#8e6b8a") #let gold = rgb("#b8953d") #let cream = rgb("#faf8f5") #let text-dark = rgb("#2d2330") // ============================================================================ // PAGE SETUP // ============================================================================ #set page( paper: {page_size}, flipped: {page_flipped}, margin: 0.4in, fill: cream, ) // ============================================================================ // TYPOGRAPHY // ============================================================================ #set text( font: ("Crimson Pro", "Noto Sans Symbols 2", "Noto Sans Symbols", "Symbola", "Georgia", "serif"), size: 11pt, fill: text-dark, ) """ def _render_section_divider(self, section_name: str, count: int) -> str: """Render a section divider page.""" return f""" // ============================================================================ // SECTION: {section_name} // ============================================================================ #pagebreak() #align(center + horizon)[ #box(width: 70%)[ #line(length: 100%, stroke: 0.75pt + gold) #v(0.3in) #text(font: "Cinzel Decorative", size: 32pt, fill: primary, tracking: 1.5pt)[ {self._escape(section_name)} ] #v(0.15in) #text(size: 14pt, fill: secondary)[ {count} {"chart" if count == 1 else "charts"} ] #v(0.3in) #line(length: 100%, stroke: 0.75pt + gold) ] ] #pagebreak() """ def _render_title_page(self) -> str: """Render the title page.""" title = self._escape(self.config.title or "Chart Atlas") return f""" // ============================================================================ // TITLE PAGE // ============================================================================ #align(center + horizon)[ #box(width: 70%)[ #line(length: 100%, stroke: 0.75pt + gold) #v(0.2in) #text(font: "Cinzel Decorative", size: 28pt, fill: primary, tracking: 1.5pt)[ {title} ] #v(0.2in) #line(length: 100%, stroke: 0.75pt + gold) ] ] #v(1fr) #align(center)[ #text(font: "Cinzel Decorative", size: 9pt, fill: accent, style: "italic")[ Generated with Stellium ] ] #pagebreak() """ def _render_chart_page(self, svg_path: str, index: int) -> str: """ Render a single chart page. Args: svg_path: Path to chart SVG file index: Chart index Returns: Typst markup for the chart page """ # Reference the SVG by its basename. The SVG lives inside # self._temp_dir (see _generate_chart_svg) which is also the Typst # project root, so Typst will resolve the image relative to the .typ # source file in the same directory. rel_path = os.path.basename(svg_path).replace("\\", "/") # Use align center+horizon to center the chart on the page # width: 100% and height: 100% with fit: contain ensures it fills # the page while maintaining aspect ratio return f""" // Chart {index + 1} #align(center + horizon)[ #image("{rel_path}", width: 100%, height: 100%, fit: "contain") ] #pagebreak() """ def _escape(self, text: str) -> str: """Escape text for Typst.""" if not text: return "" # Escape special Typst characters text = text.replace("\\", "\\\\") text = text.replace("#", "\\#") text = text.replace("$", "\\$") text = text.replace("@", "\\@") text = text.replace("<", "\\<") text = text.replace(">", "\\>") text = text.replace("[", "\\[") text = text.replace("]", "\\]") text = text.replace("{", "\\{") text = text.replace("}", "\\}") text = text.replace("_", "\\_") text = text.replace("*", "\\*") text = text.replace('"', '\\"') return text