kerykeion.charts.kerykeion_chart_svg

This is part of Kerykeion (C) 2025 Giacomo Battaglia

   1# -*- coding: utf-8 -*-
   2"""
   3    This is part of Kerykeion (C) 2025 Giacomo Battaglia
   4"""
   5
   6
   7import logging
   8import swisseph as swe
   9from typing import get_args
  10
  11from kerykeion.settings.kerykeion_settings import get_settings
  12from kerykeion.aspects.synastry_aspects import SynastryAspects
  13from kerykeion.aspects.natal_aspects import NatalAspects
  14from kerykeion.astrological_subject import AstrologicalSubject
  15from kerykeion.kr_types import KerykeionException, ChartType, KerykeionPointModel, Sign, ActiveAspect
  16from kerykeion.kr_types import ChartTemplateDictionary
  17from kerykeion.kr_types.kr_models import AstrologicalSubjectModel, CompositeSubjectModel
  18from kerykeion.kr_types.settings_models import KerykeionSettingsCelestialPointModel, KerykeionSettingsModel
  19from kerykeion.kr_types.kr_literals import KerykeionChartTheme, KerykeionChartLanguage, AxialCusps, Planet
  20from kerykeion.charts.charts_utils import (
  21    draw_zodiac_slice,
  22    convert_latitude_coordinate_to_string,
  23    convert_longitude_coordinate_to_string,
  24    draw_aspect_line,
  25    draw_transit_ring_degree_steps,
  26    draw_degree_ring,
  27    draw_transit_ring,
  28    draw_first_circle,
  29    draw_second_circle,
  30    draw_third_circle,
  31    draw_aspect_grid,
  32    draw_houses_cusps_and_text_number,
  33    draw_transit_aspect_list,
  34    draw_transit_aspect_grid,
  35    calculate_moon_phase_chart_params,
  36    draw_house_grid,
  37    draw_planet_grid,
  38)
  39from kerykeion.charts.draw_planets import draw_planets # type: ignore
  40from kerykeion.utilities import get_houses_list, inline_css_variables_in_svg
  41from kerykeion.settings.config_constants import DEFAULT_ACTIVE_POINTS, DEFAULT_ACTIVE_ASPECTS
  42from pathlib import Path
  43from scour.scour import scourString
  44from string import Template
  45from typing import Union, List, Literal
  46from datetime import datetime
  47
  48class KerykeionChartSVG:
  49    """
  50    KerykeionChartSVG generates astrological chart visualizations as SVG files.
  51
  52    This class supports creating full chart SVGs, wheel-only SVGs, and aspect-grid-only SVGs
  53    for various chart types including Natal, ExternalNatal, Transit, Synastry, and Composite.
  54    Charts are rendered using XML templates and drawing utilities, with customizable themes,
  55    language, active points, and aspects.
  56    The rendered SVGs can be saved to a specified output directory or, by default, to the user's home directory.
  57
  58    NOTE:
  59        The generated SVG files are optimized for web use, opening in browsers. If you want to
  60        use them in other applications, you might need to adjust the SVG settings or styles.
  61
  62    Args:
  63        first_obj (AstrologicalSubject | AstrologicalSubjectModel | CompositeSubjectModel):
  64            The primary astrological subject for the chart.
  65        chart_type (ChartType, optional):
  66            The type of chart to generate ('Natal', 'ExternalNatal', 'Transit', 'Synastry', 'Composite').
  67            Defaults to 'Natal'.
  68        second_obj (AstrologicalSubject | AstrologicalSubjectModel, optional):
  69            The secondary subject for Transit or Synastry charts. Not required for Natal or Composite.
  70        new_output_directory (str | Path, optional):
  71            Directory to write generated SVG files. Defaults to the user's home directory.
  72        new_settings_file (Path | dict | KerykeionSettingsModel, optional):
  73            Path or settings object to override default chart configuration (colors, fonts, aspects).
  74        theme (KerykeionChartTheme, optional):
  75            CSS theme for the chart. If None, no default styles are applied. Defaults to 'classic'.
  76        double_chart_aspect_grid_type (Literal['list', 'table'], optional):
  77            Specifies rendering style for double-chart aspect grids. Defaults to 'list'.
  78        chart_language (KerykeionChartLanguage, optional):
  79            Language code for chart labels. Defaults to 'EN'.
  80        active_points (list[Planet | AxialCusps], optional):
  81            List of celestial points and angles to include. Defaults to DEFAULT_ACTIVE_POINTS.
  82            Example:
  83            ["Sun", "Moon", "Mercury", "Venus"]
  84
  85        active_aspects (list[ActiveAspect], optional):
  86            List of aspects (name and orb) to calculate. Defaults to DEFAULT_ACTIVE_ASPECTS.
  87            Example:
  88            [
  89                {"name": "conjunction", "orb": 10},
  90                {"name": "opposition", "orb": 10},
  91                {"name": "trine", "orb": 8},
  92                {"name": "sextile", "orb": 6},
  93                {"name": "square", "orb": 5},
  94                {"name": "quintile", "orb": 1},
  95            ]
  96
  97    Public Methods:
  98        makeTemplate(minify=False, remove_css_variables=False) -> str:
  99            Render the full chart SVG as a string without writing to disk. Use `minify=True`
 100            to remove whitespace and quotes, and `remove_css_variables=True` to embed CSS vars.
 101
 102        makeSVG(minify=False, remove_css_variables=False) -> None:
 103            Generate and write the full chart SVG file to the output directory.
 104            Filenames follow the pattern:
 105            '{subject.name} - {chart_type} Chart.svg'.
 106
 107        makeWheelOnlyTemplate(minify=False, remove_css_variables=False) -> str:
 108            Render only the chart wheel (no aspect grid) as an SVG string.
 109
 110        makeWheelOnlySVG(minify=False, remove_css_variables=False) -> None:
 111            Generate and write the wheel-only SVG file:
 112            '{subject.name} - {chart_type} Chart - Wheel Only.svg'.
 113
 114        makeAspectGridOnlyTemplate(minify=False, remove_css_variables=False) -> str:
 115            Render only the aspect grid as an SVG string.
 116
 117        makeAspectGridOnlySVG(minify=False, remove_css_variables=False) -> None:
 118            Generate and write the aspect-grid-only SVG file:
 119            '{subject.name} - {chart_type} Chart - Aspect Grid Only.svg'.
 120    """
 121
 122    # Constants
 123    _BASIC_CHART_VIEWBOX = "0 0 820 550.0"
 124    _WIDE_CHART_VIEWBOX = "0 0 1200 546.0"
 125    _TRANSIT_CHART_WITH_TABLE_VIWBOX = "0 0 960 546.0"
 126
 127    _DEFAULT_HEIGHT = 550
 128    _DEFAULT_FULL_WIDTH = 1200
 129    _DEFAULT_NATAL_WIDTH = 820
 130    _DEFAULT_FULL_WIDTH_WITH_TABLE = 960
 131    _PLANET_IN_ZODIAC_EXTRA_POINTS = 10
 132
 133    # Set at init
 134    first_obj: Union[AstrologicalSubject, AstrologicalSubjectModel]
 135    second_obj: Union[AstrologicalSubject, AstrologicalSubjectModel, None]
 136    chart_type: ChartType
 137    new_output_directory: Union[Path, None]
 138    new_settings_file: Union[Path, None, KerykeionSettingsModel, dict]
 139    output_directory: Path
 140    new_settings_file: Union[Path, None, KerykeionSettingsModel, dict]
 141    theme: Union[KerykeionChartTheme, None]
 142    double_chart_aspect_grid_type: Literal["list", "table"]
 143    chart_language: KerykeionChartLanguage
 144    active_points: List[Union[Planet, AxialCusps]]
 145    active_aspects: List[ActiveAspect]
 146
 147    # Internal properties
 148    fire: float
 149    earth: float
 150    air: float
 151    water: float
 152    first_circle_radius: float
 153    second_circle_radius: float
 154    third_circle_radius: float
 155    width: Union[float, int]
 156    language_settings: dict
 157    chart_colors_settings: dict
 158    planets_settings: dict
 159    aspects_settings: dict
 160    user: Union[AstrologicalSubject, AstrologicalSubjectModel, CompositeSubjectModel]
 161    available_planets_setting: List[KerykeionSettingsCelestialPointModel]
 162    height: float
 163    location: str
 164    geolat: float
 165    geolon: float
 166    template: str
 167
 168    def __init__(
 169        self,
 170        first_obj: Union[AstrologicalSubject, AstrologicalSubjectModel, CompositeSubjectModel],
 171        chart_type: ChartType = "Natal",
 172        second_obj: Union[AstrologicalSubject, AstrologicalSubjectModel, None] = None,
 173        new_output_directory: Union[str, None] = None,
 174        new_settings_file: Union[Path, None, KerykeionSettingsModel, dict] = None,
 175        theme: Union[KerykeionChartTheme, None] = "classic",
 176        double_chart_aspect_grid_type: Literal["list", "table"] = "list",
 177        chart_language: KerykeionChartLanguage = "EN",
 178        active_points: List[Union[Planet, AxialCusps]] = DEFAULT_ACTIVE_POINTS,
 179    active_aspects: List[ActiveAspect] = DEFAULT_ACTIVE_ASPECTS,
 180    ):
 181        """
 182        Initialize the chart generator with subject data and configuration options.
 183
 184        Args:
 185            first_obj (AstrologicalSubject, AstrologicalSubjectModel, or CompositeSubjectModel):
 186                Primary astrological subject instance.
 187            chart_type (ChartType, optional):
 188                Type of chart to generate (e.g., 'Natal', 'Transit').
 189            second_obj (AstrologicalSubject or AstrologicalSubjectModel, optional):
 190                Secondary subject for Transit or Synastry charts.
 191            new_output_directory (str or Path, optional):
 192                Base directory to save generated SVG files.
 193            new_settings_file (Path, dict, or KerykeionSettingsModel, optional):
 194                Custom settings source for chart colors, fonts, and aspects.
 195            theme (KerykeionChartTheme or None, optional):
 196                CSS theme to apply; None for default styling.
 197            double_chart_aspect_grid_type (Literal['list','table'], optional):
 198                Layout style for double-chart aspect grids ('list' or 'table').
 199            chart_language (KerykeionChartLanguage, optional):
 200                Language code for chart labels (e.g., 'EN', 'IT').
 201            active_points (List[Planet or AxialCusps], optional):
 202                Celestial points to include in the chart visualization.
 203            active_aspects (List[ActiveAspect], optional):
 204                Aspects to calculate, each defined by name and orb.
 205        """
 206        home_directory = Path.home()
 207        self.new_settings_file = new_settings_file
 208        self.chart_language = chart_language
 209        self.active_points = active_points
 210        self.active_aspects = active_aspects
 211
 212        if new_output_directory:
 213            self.output_directory = Path(new_output_directory)
 214        else:
 215            self.output_directory = home_directory
 216
 217        self.parse_json_settings(new_settings_file)
 218        self.chart_type = chart_type
 219
 220        # Kerykeion instance
 221        self.user = first_obj
 222
 223        self.available_planets_setting = []
 224        for body in self.planets_settings:
 225            if body["name"] not in active_points:
 226                continue
 227            else:
 228                body["is_active"] = True
 229
 230            self.available_planets_setting.append(body)
 231
 232        # Available bodies
 233        available_celestial_points_names = []
 234        for body in self.available_planets_setting:
 235            available_celestial_points_names.append(body["name"].lower())
 236
 237        self.available_kerykeion_celestial_points: list[KerykeionPointModel] = []
 238        for body in available_celestial_points_names:
 239            self.available_kerykeion_celestial_points.append(self.user.get(body))
 240
 241        # Makes the sign number list.
 242        if self.chart_type == "Natal" or self.chart_type == "ExternalNatal":
 243            natal_aspects_instance = NatalAspects(
 244                self.user, new_settings_file=self.new_settings_file,
 245                active_points=active_points,
 246                active_aspects=active_aspects,
 247            )
 248            self.aspects_list = natal_aspects_instance.relevant_aspects
 249
 250        elif self.chart_type == "Transit" or self.chart_type == "Synastry":
 251            if not second_obj:
 252                raise KerykeionException("Second object is required for Transit or Synastry charts.")
 253
 254            # Kerykeion instance
 255            self.t_user = second_obj
 256
 257            # Aspects
 258            if self.chart_type == "Transit":
 259                synastry_aspects_instance = SynastryAspects(
 260                    self.t_user,
 261                    self.user,
 262                    new_settings_file=self.new_settings_file,
 263                    active_points=active_points,
 264                    active_aspects=active_aspects,
 265                )
 266
 267            else:
 268                synastry_aspects_instance = SynastryAspects(
 269                    self.user,
 270                    self.t_user,
 271                    new_settings_file=self.new_settings_file,
 272                    active_points=active_points,
 273                    active_aspects=active_aspects,
 274                )
 275
 276            self.aspects_list = synastry_aspects_instance.relevant_aspects
 277
 278            self.t_available_kerykeion_celestial_points = []
 279            for body in available_celestial_points_names:
 280                self.t_available_kerykeion_celestial_points.append(self.t_user.get(body))
 281
 282        elif self.chart_type == "Composite":
 283            if not isinstance(first_obj, CompositeSubjectModel):
 284                raise KerykeionException("First object must be a CompositeSubjectModel instance.")
 285
 286            self.aspects_list = NatalAspects(self.user, new_settings_file=self.new_settings_file, active_points=active_points).relevant_aspects
 287
 288        # Double chart aspect grid type
 289        self.double_chart_aspect_grid_type = double_chart_aspect_grid_type
 290
 291        # screen size
 292        self.height = self._DEFAULT_HEIGHT
 293        if self.chart_type == "Synastry" or self.chart_type == "Transit":
 294            self.width = self._DEFAULT_FULL_WIDTH
 295        elif self.double_chart_aspect_grid_type == "table" and self.chart_type == "Transit":
 296            self.width = self._DEFAULT_FULL_WIDTH_WITH_TABLE
 297        else:
 298            self.width = self._DEFAULT_NATAL_WIDTH
 299
 300        if self.chart_type in ["Natal", "ExternalNatal", "Synastry"]:
 301            self.location = self.user.city
 302            self.geolat = self.user.lat
 303            self.geolon =  self.user.lng
 304
 305        elif self.chart_type == "Composite":
 306            self.location = ""
 307            self.geolat = (self.user.first_subject.lat + self.user.second_subject.lat) / 2
 308            self.geolon = (self.user.first_subject.lng + self.user.second_subject.lng) / 2
 309
 310        elif self.chart_type in ["Transit"]:
 311            self.location = self.t_user.city
 312            self.geolat = self.t_user.lat
 313            self.geolon = self.t_user.lng
 314            self.t_name = self.language_settings["transit_name"]
 315
 316        # Default radius for the chart
 317        self.main_radius = 240
 318
 319        # Set circle radii based on chart type
 320        if self.chart_type == "ExternalNatal":
 321            self.first_circle_radius, self.second_circle_radius, self.third_circle_radius = 56, 92, 112
 322        else:
 323            self.first_circle_radius, self.second_circle_radius, self.third_circle_radius = 0, 36, 120
 324
 325        # Initialize element points
 326        self.fire = 0.0
 327        self.earth = 0.0
 328        self.air = 0.0
 329        self.water = 0.0
 330
 331        # Calculate element points from planets
 332        self._calculate_elements_points_from_planets()
 333
 334        # Set up theme
 335        if theme not in get_args(KerykeionChartTheme) and theme is not None:
 336            raise KerykeionException(f"Theme {theme} is not available. Set None for default theme.")
 337
 338        self.set_up_theme(theme)
 339
 340    def set_up_theme(self, theme: Union[KerykeionChartTheme, None] = None) -> None:
 341        """
 342        Load and apply a CSS theme for the chart visualization.
 343
 344        Args:
 345            theme (KerykeionChartTheme or None): Name of the theme to apply. If None, no CSS is applied.
 346        """
 347        if theme is None:
 348            self.color_style_tag = ""
 349            return
 350
 351        theme_dir = Path(__file__).parent / "themes"
 352
 353        with open(theme_dir / f"{theme}.css", "r") as f:
 354            self.color_style_tag = f.read()
 355
 356    def set_output_directory(self, dir_path: Path) -> None:
 357        """
 358        Set the directory where generated SVG files will be saved.
 359
 360        Args:
 361            dir_path (Path): Target directory for SVG output.
 362        """
 363        self.output_directory = dir_path
 364        logging.info(f"Output direcotry set to: {self.output_directory}")
 365
 366    def parse_json_settings(self, settings_file_or_dict: Union[Path, dict, KerykeionSettingsModel, None]) -> None:
 367        """
 368        Load and parse chart configuration settings.
 369
 370        Args:
 371            settings_file_or_dict (Path, dict, or KerykeionSettingsModel):
 372                Source for custom chart settings.
 373        """
 374        settings = get_settings(settings_file_or_dict)
 375
 376        self.language_settings = settings["language_settings"][self.chart_language]
 377        self.chart_colors_settings = settings["chart_colors"]
 378        self.planets_settings = settings["celestial_points"]
 379        self.aspects_settings = settings["aspects"]
 380
 381    def _draw_zodiac_circle_slices(self, r):
 382        """
 383        Draw zodiac circle slices for each sign.
 384
 385        Args:
 386            r (float): Outer radius of the zodiac ring.
 387
 388        Returns:
 389            str: Concatenated SVG elements for zodiac slices.
 390        """
 391        sings = get_args(Sign)
 392        output = ""
 393        for i, sing in enumerate(sings):
 394            output += draw_zodiac_slice(
 395                c1=self.first_circle_radius,
 396                chart_type=self.chart_type,
 397                seventh_house_degree_ut=self.user.seventh_house.abs_pos,
 398                num=i,
 399                r=r,
 400                style=f'fill:{self.chart_colors_settings[f"zodiac_bg_{i}"]}; fill-opacity: 0.5;',
 401                type=sing,
 402            )
 403
 404        return output
 405
 406    def _calculate_elements_points_from_planets(self):
 407        """
 408        Compute elemental point totals based on active planetary positions.
 409
 410        Iterates over each active planet to determine its zodiac element and adds extra points
 411        if the planet is in a related sign. Updates self.fire, self.earth, self.air, and self.water.
 412
 413        Returns:
 414            None
 415        """
 416
 417        ZODIAC = (
 418            {"name": "Ari", "element": "fire"},
 419            {"name": "Tau", "element": "earth"},
 420            {"name": "Gem", "element": "air"},
 421            {"name": "Can", "element": "water"},
 422            {"name": "Leo", "element": "fire"},
 423            {"name": "Vir", "element": "earth"},
 424            {"name": "Lib", "element": "air"},
 425            {"name": "Sco", "element": "water"},
 426            {"name": "Sag", "element": "fire"},
 427            {"name": "Cap", "element": "earth"},
 428            {"name": "Aqu", "element": "air"},
 429            {"name": "Pis", "element": "water"},
 430        )
 431
 432        # Available bodies
 433        available_celestial_points_names = []
 434        for body in self.available_planets_setting:
 435            available_celestial_points_names.append(body["name"].lower())
 436
 437        # Make list of the points sign
 438        points_sign = []
 439        for planet in available_celestial_points_names:
 440            points_sign.append(self.user.get(planet).sign_num)
 441
 442        for i in range(len(self.available_planets_setting)):
 443            # element: get extra points if planet is in own zodiac sign.
 444            related_zodiac_signs = self.available_planets_setting[i]["related_zodiac_signs"]
 445            cz = points_sign[i]
 446            extra_points = 0
 447            if related_zodiac_signs != []:
 448                for e in range(len(related_zodiac_signs)):
 449                    if int(related_zodiac_signs[e]) == int(cz):
 450                        extra_points = self._PLANET_IN_ZODIAC_EXTRA_POINTS
 451
 452            ele = ZODIAC[points_sign[i]]["element"]
 453            if ele == "fire":
 454                self.fire = self.fire + self.available_planets_setting[i]["element_points"] + extra_points
 455
 456            elif ele == "earth":
 457                self.earth = self.earth + self.available_planets_setting[i]["element_points"] + extra_points
 458
 459            elif ele == "air":
 460                self.air = self.air + self.available_planets_setting[i]["element_points"] + extra_points
 461
 462            elif ele == "water":
 463                self.water = self.water + self.available_planets_setting[i]["element_points"] + extra_points
 464
 465    def _draw_all_aspects_lines(self, r, ar):
 466        """
 467        Render SVG lines for all aspects in the chart.
 468
 469        Args:
 470            r (float): Radius at which aspect lines originate.
 471            ar (float): Radius at which aspect lines terminate.
 472
 473        Returns:
 474            str: SVG markup for all aspect lines.
 475        """
 476        out = ""
 477        for aspect in self.aspects_list:
 478            aspect_name = aspect["aspect"]
 479            aspect_color = next((a["color"] for a in self.aspects_settings if a["name"] == aspect_name), None)
 480            if aspect_color:
 481                out += draw_aspect_line(
 482                    r=r,
 483                    ar=ar,
 484                    aspect=aspect,
 485                    color=aspect_color,
 486                    seventh_house_degree_ut=self.user.seventh_house.abs_pos
 487                )
 488        return out
 489
 490    def _draw_all_transit_aspects_lines(self, r, ar):
 491        """
 492        Render SVG lines for all transit aspects in the chart.
 493
 494        Args:
 495            r (float): Radius at which transit aspect lines originate.
 496            ar (float): Radius at which transit aspect lines terminate.
 497
 498        Returns:
 499            str: SVG markup for all transit aspect lines.
 500        """
 501        out = ""
 502        for aspect in self.aspects_list:
 503            aspect_name = aspect["aspect"]
 504            aspect_color = next((a["color"] for a in self.aspects_settings if a["name"] == aspect_name), None)
 505            if aspect_color:
 506                out += draw_aspect_line(
 507                    r=r,
 508                    ar=ar,
 509                    aspect=aspect,
 510                    color=aspect_color,
 511                    seventh_house_degree_ut=self.user.seventh_house.abs_pos
 512                )
 513        return out
 514
 515    def _create_template_dictionary(self) -> ChartTemplateDictionary:
 516        """
 517        Assemble chart data and rendering instructions into a template dictionary.
 518
 519        Gathers styling, dimensions, and SVG fragments for chart components based on
 520        chart type and subjects.
 521
 522        Returns:
 523            ChartTemplateDictionary: Populated structure of template variables.
 524        """
 525        # Initialize template dictionary
 526        template_dict: dict = {}
 527
 528        # Set the color style tag
 529        template_dict["color_style_tag"] = self.color_style_tag
 530
 531        # Set chart dimensions
 532        template_dict["chart_height"] = self.height
 533        template_dict["chart_width"] = self.width
 534
 535        # Set viewbox based on chart type
 536        if self.chart_type in ["Natal", "ExternalNatal", "Composite"]:
 537            template_dict['viewbox'] = self._BASIC_CHART_VIEWBOX
 538        elif self.double_chart_aspect_grid_type == "table" and self.chart_type == "Transit":
 539            template_dict['viewbox'] = self._TRANSIT_CHART_WITH_TABLE_VIWBOX
 540        else:
 541            template_dict['viewbox'] = self._WIDE_CHART_VIEWBOX
 542
 543        # Generate rings and circles based on chart type
 544        if self.chart_type in ["Transit", "Synastry"]:
 545            template_dict["transitRing"] = draw_transit_ring(self.main_radius, self.chart_colors_settings["paper_1"], self.chart_colors_settings["zodiac_transit_ring_3"])
 546            template_dict["degreeRing"] = draw_transit_ring_degree_steps(self.main_radius, self.user.seventh_house.abs_pos)
 547            template_dict["first_circle"] = draw_first_circle(self.main_radius, self.chart_colors_settings["zodiac_transit_ring_2"], self.chart_type)
 548            template_dict["second_circle"] = draw_second_circle(self.main_radius, self.chart_colors_settings['zodiac_transit_ring_1'], self.chart_colors_settings['paper_1'], self.chart_type)
 549            template_dict['third_circle'] = draw_third_circle(self.main_radius, self.chart_colors_settings['zodiac_transit_ring_0'], self.chart_colors_settings['paper_1'], self.chart_type, self.third_circle_radius)
 550
 551            if self.double_chart_aspect_grid_type == "list":
 552                title = ""
 553                if self.chart_type == "Synastry":
 554                    title = self.language_settings.get("couple_aspects", "Couple Aspects")
 555                else:
 556                    title = self.language_settings.get("transit_aspects", "Transit Aspects")
 557
 558                template_dict["makeAspectGrid"] = draw_transit_aspect_list(title, self.aspects_list, self.planets_settings, self.aspects_settings)
 559            else:
 560                template_dict["makeAspectGrid"] = draw_transit_aspect_grid(self.chart_colors_settings['paper_0'], self.available_planets_setting, self.aspects_list, 550, 450)
 561
 562            template_dict["makeAspects"] = self._draw_all_transit_aspects_lines(self.main_radius, self.main_radius - 160)
 563        else:
 564            template_dict["transitRing"] = ""
 565            template_dict["degreeRing"] = draw_degree_ring(self.main_radius, self.first_circle_radius, self.user.seventh_house.abs_pos, self.chart_colors_settings["paper_0"])
 566            template_dict['first_circle'] = draw_first_circle(self.main_radius, self.chart_colors_settings["zodiac_radix_ring_2"], self.chart_type, self.first_circle_radius)
 567            template_dict["second_circle"] = draw_second_circle(self.main_radius, self.chart_colors_settings["zodiac_radix_ring_1"], self.chart_colors_settings["paper_1"], self.chart_type, self.second_circle_radius)
 568            template_dict['third_circle'] = draw_third_circle(self.main_radius, self.chart_colors_settings["zodiac_radix_ring_0"], self.chart_colors_settings["paper_1"], self.chart_type, self.third_circle_radius)
 569            template_dict["makeAspectGrid"] = draw_aspect_grid(self.chart_colors_settings['paper_0'], self.available_planets_setting, self.aspects_list)
 570
 571            template_dict["makeAspects"] = self._draw_all_aspects_lines(self.main_radius, self.main_radius - self.third_circle_radius)
 572
 573        # Set chart title
 574        if self.chart_type == "Synastry":
 575            template_dict["stringTitle"] = f"{self.user.name} {self.language_settings['and_word']} {self.t_user.name}"
 576        elif self.chart_type == "Transit":
 577            template_dict["stringTitle"] = f"{self.language_settings['transits']} {self.t_user.day}/{self.t_user.month}/{self.t_user.year}"
 578        elif self.chart_type in ["Natal", "ExternalNatal"]:
 579            template_dict["stringTitle"] = self.user.name
 580        elif self.chart_type == "Composite":
 581            template_dict["stringTitle"] = f"{self.user.first_subject.name} {self.language_settings['and_word']} {self.user.second_subject.name}"
 582
 583        # Zodiac Type Info
 584        if self.user.zodiac_type == 'Tropic':
 585            zodiac_info = f"{self.language_settings.get('zodiac', 'Zodiac')}: {self.language_settings.get('tropical', 'Tropical')}"
 586        else:
 587            mode_const = "SIDM_" + self.user.sidereal_mode # type: ignore
 588            mode_name = swe.get_ayanamsa_name(getattr(swe, mode_const))
 589            zodiac_info = f"{self.language_settings.get('ayanamsa', 'Ayanamsa')}: {mode_name}"
 590
 591        template_dict["bottom_left_0"] = f"{self.language_settings.get('houses_system_' + self.user.houses_system_identifier, self.user.houses_system_name)} {self.language_settings.get('houses', 'Houses')}"
 592        template_dict["bottom_left_1"] = zodiac_info
 593
 594        if self.chart_type in ["Natal", "ExternalNatal", "Synastry"]:
 595            template_dict["bottom_left_2"] = f'{self.language_settings.get("lunar_phase", "Lunar Phase")} {self.language_settings.get("day", "Day").lower()}: {self.user.lunar_phase.get("moon_phase", "")}'
 596            template_dict["bottom_left_3"] = f'{self.language_settings.get("lunar_phase", "Lunar Phase")}: {self.language_settings.get(self.user.lunar_phase.moon_phase_name.lower().replace(" ", "_"), self.user.lunar_phase.moon_phase_name)}'
 597            template_dict["bottom_left_4"] = f'{self.language_settings.get(self.user.perspective_type.lower().replace(" ", "_"), self.user.perspective_type)}'
 598        elif self.chart_type == "Transit":
 599            template_dict["bottom_left_2"] = f'{self.language_settings.get("lunar_phase", "Lunar Phase")}: {self.language_settings.get("day", "Day")} {self.t_user.lunar_phase.get("moon_phase", "")}'
 600            template_dict["bottom_left_3"] = f'{self.language_settings.get("lunar_phase", "Lunar Phase")}: {self.t_user.lunar_phase.moon_phase_name}'
 601            template_dict["bottom_left_4"] = f'{self.language_settings.get(self.t_user.perspective_type.lower().replace(" ", "_"), self.t_user.perspective_type)}'
 602        elif self.chart_type == "Composite":
 603            template_dict["bottom_left_2"] = f'{self.user.first_subject.perspective_type}'
 604            template_dict["bottom_left_3"] = f'{self.language_settings.get("composite_chart", "Composite Chart")} - {self.language_settings.get("midpoints", "Midpoints")}'
 605            template_dict["bottom_left_4"] = ""
 606
 607        # Draw moon phase
 608        moon_phase_dict = calculate_moon_phase_chart_params(
 609            self.user.lunar_phase["degrees_between_s_m"],
 610            self.geolat
 611        )
 612
 613        template_dict["lunar_phase_rotate"] = moon_phase_dict["lunar_phase_rotate"]
 614        template_dict["lunar_phase_circle_center_x"] = moon_phase_dict["circle_center_x"]
 615        template_dict["lunar_phase_circle_radius"] = moon_phase_dict["circle_radius"]
 616
 617        if self.chart_type == "Composite":
 618            template_dict["top_left_1"] = f"{datetime.fromisoformat(self.user.first_subject.iso_formatted_local_datetime).strftime('%Y-%m-%d %H:%M')}"
 619        # Set location string
 620        elif len(self.location) > 35:
 621            split_location = self.location.split(",")
 622            if len(split_location) > 1:
 623                template_dict["top_left_1"] = split_location[0] + ", " + split_location[-1]
 624                if len(template_dict["top_left_1"]) > 35:
 625                    template_dict["top_left_1"] = template_dict["top_left_1"][:35] + "..."
 626            else:
 627                template_dict["top_left_1"] = self.location[:35] + "..."
 628        else:
 629            template_dict["top_left_1"] = self.location
 630
 631        # Set chart name
 632        if self.chart_type in ["Synastry", "Transit"]:
 633            template_dict["top_left_0"] = f"{self.user.name}:"
 634        elif self.chart_type in ["Natal", "ExternalNatal"]:
 635            template_dict["top_left_0"] = f'{self.language_settings["info"]}:'
 636        elif self.chart_type == "Composite":
 637            template_dict["top_left_0"] = f'{self.user.first_subject.name}'
 638
 639        # Set additional information for Synastry chart type
 640        if self.chart_type == "Synastry":
 641            template_dict["top_left_3"] = f"{self.t_user.name}: "
 642            template_dict["top_left_4"] = self.t_user.city
 643            template_dict["top_left_5"] = f"{self.t_user.year}-{self.t_user.month}-{self.t_user.day} {self.t_user.hour:02d}:{self.t_user.minute:02d}"
 644        elif self.chart_type == "Composite":
 645            template_dict["top_left_3"] = self.user.second_subject.name
 646            template_dict["top_left_4"] = f"{datetime.fromisoformat(self.user.second_subject.iso_formatted_local_datetime).strftime('%Y-%m-%d %H:%M')}"
 647            latitude_string = convert_latitude_coordinate_to_string(self.user.second_subject.lat, self.language_settings['north_letter'], self.language_settings['south_letter'])
 648            longitude_string = convert_longitude_coordinate_to_string(self.user.second_subject.lng, self.language_settings['east_letter'], self.language_settings['west_letter'])
 649            template_dict["top_left_5"] = f"{latitude_string} / {longitude_string}"
 650        else:
 651            latitude_string = convert_latitude_coordinate_to_string(self.geolat, self.language_settings['north'], self.language_settings['south'])
 652            longitude_string = convert_longitude_coordinate_to_string(self.geolon, self.language_settings['east'], self.language_settings['west'])
 653            template_dict["top_left_3"] = f"{self.language_settings['latitude']}: {latitude_string}"
 654            template_dict["top_left_4"] = f"{self.language_settings['longitude']}: {longitude_string}"
 655            template_dict["top_left_5"] = f"{self.language_settings['type']}: {self.language_settings.get(self.chart_type, self.chart_type)}"
 656
 657
 658        # Set paper colors
 659        template_dict["paper_color_0"] = self.chart_colors_settings["paper_0"]
 660        template_dict["paper_color_1"] = self.chart_colors_settings["paper_1"]
 661
 662        # Set planet colors
 663        for planet in self.planets_settings:
 664            planet_id = planet["id"]
 665            template_dict[f"planets_color_{planet_id}"] = planet["color"] # type: ignore
 666
 667        # Set zodiac colors
 668        for i in range(12):
 669            template_dict[f"zodiac_color_{i}"] = self.chart_colors_settings[f"zodiac_icon_{i}"] # type: ignore
 670
 671        # Set orb colors
 672        for aspect in self.aspects_settings:
 673            template_dict[f"orb_color_{aspect['degree']}"] = aspect['color'] # type: ignore
 674
 675        # Drawing functions
 676        template_dict["makeZodiac"] = self._draw_zodiac_circle_slices(self.main_radius)
 677
 678        first_subject_houses_list = get_houses_list(self.user)
 679
 680        # Draw houses grid and cusps
 681        if self.chart_type in ["Transit", "Synastry"]:
 682            second_subject_houses_list = get_houses_list(self.t_user)
 683
 684            template_dict["makeHousesGrid"] = draw_house_grid(
 685                main_subject_houses_list=first_subject_houses_list,
 686                secondary_subject_houses_list=second_subject_houses_list,
 687                chart_type=self.chart_type,
 688                text_color=self.chart_colors_settings["paper_0"],
 689                house_cusp_generale_name_label=self.language_settings["cusp"]
 690            )
 691
 692            template_dict["makeHouses"] = draw_houses_cusps_and_text_number(
 693                r=self.main_radius,
 694                first_subject_houses_list=first_subject_houses_list,
 695                standard_house_cusp_color=self.chart_colors_settings["houses_radix_line"],
 696                first_house_color=self.planets_settings[12]["color"],
 697                tenth_house_color=self.planets_settings[13]["color"],
 698                seventh_house_color=self.planets_settings[14]["color"],
 699                fourth_house_color=self.planets_settings[15]["color"],
 700                c1=self.first_circle_radius,
 701                c3=self.third_circle_radius,
 702                chart_type=self.chart_type,
 703                second_subject_houses_list=second_subject_houses_list,
 704                transit_house_cusp_color=self.chart_colors_settings["houses_transit_line"],
 705            )
 706
 707        else:
 708            template_dict["makeHousesGrid"] = draw_house_grid(
 709                main_subject_houses_list=first_subject_houses_list,
 710                chart_type=self.chart_type,
 711                text_color=self.chart_colors_settings["paper_0"],
 712                house_cusp_generale_name_label=self.language_settings["cusp"]
 713            )
 714
 715            template_dict["makeHouses"] = draw_houses_cusps_and_text_number(
 716                r=self.main_radius,
 717                first_subject_houses_list=first_subject_houses_list,
 718                standard_house_cusp_color=self.chart_colors_settings["houses_radix_line"],
 719                first_house_color=self.planets_settings[12]["color"],
 720                tenth_house_color=self.planets_settings[13]["color"],
 721                seventh_house_color=self.planets_settings[14]["color"],
 722                fourth_house_color=self.planets_settings[15]["color"],
 723                c1=self.first_circle_radius,
 724                c3=self.third_circle_radius,
 725                chart_type=self.chart_type,
 726            )
 727
 728        # Draw planets
 729        if self.chart_type in ["Transit", "Synastry"]:
 730            template_dict["makePlanets"] = draw_planets(
 731                available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
 732                available_planets_setting=self.available_planets_setting,
 733                second_subject_available_kerykeion_celestial_points=self.t_available_kerykeion_celestial_points,
 734                radius=self.main_radius,
 735                main_subject_first_house_degree_ut=self.user.first_house.abs_pos,
 736                main_subject_seventh_house_degree_ut=self.user.seventh_house.abs_pos,
 737                chart_type=self.chart_type,
 738                third_circle_radius=self.third_circle_radius,
 739            )
 740        else:
 741            template_dict["makePlanets"] = draw_planets(
 742                available_planets_setting=self.available_planets_setting,
 743                chart_type=self.chart_type,
 744                radius=self.main_radius,
 745                available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
 746                third_circle_radius=self.third_circle_radius,
 747                main_subject_first_house_degree_ut=self.user.first_house.abs_pos,
 748                main_subject_seventh_house_degree_ut=self.user.seventh_house.abs_pos
 749            )
 750
 751        # Draw elements percentages
 752        total = self.fire + self.water + self.earth + self.air
 753
 754        fire_percentage = int(round(100 * self.fire / total))
 755        earth_percentage = int(round(100 * self.earth / total))
 756        air_percentage = int(round(100 * self.air / total))
 757        water_percentage = int(round(100 * self.water / total))
 758
 759        template_dict["fire_string"] = f"{self.language_settings['fire']} {fire_percentage}%"
 760        template_dict["earth_string"] = f"{self.language_settings['earth']} {earth_percentage}%"
 761        template_dict["air_string"] = f"{self.language_settings['air']} {air_percentage}%"
 762        template_dict["water_string"] = f"{self.language_settings['water']} {water_percentage}%"
 763
 764        # Draw planet grid
 765        if self.chart_type in ["Transit", "Synastry"]:
 766            if self.chart_type == "Transit":
 767                second_subject_table_name = self.language_settings["transit_name"]
 768            else:
 769                second_subject_table_name = self.t_user.name
 770
 771            template_dict["makePlanetGrid"] = draw_planet_grid(
 772                planets_and_houses_grid_title=self.language_settings["planets_and_house"],
 773                subject_name=self.user.name,
 774                available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
 775                chart_type=self.chart_type,
 776                text_color=self.chart_colors_settings["paper_0"],
 777                celestial_point_language=self.language_settings["celestial_points"],
 778                second_subject_name=second_subject_table_name,
 779                second_subject_available_kerykeion_celestial_points=self.t_available_kerykeion_celestial_points,
 780            )
 781        else:
 782            if self.chart_type == "Composite":
 783                subject_name = f"{self.user.first_subject.name} {self.language_settings['and_word']} {self.user.second_subject.name}"
 784            else:
 785                subject_name = self.user.name
 786
 787            template_dict["makePlanetGrid"] = draw_planet_grid(
 788                planets_and_houses_grid_title=self.language_settings["planets_and_house"],
 789                subject_name=subject_name,
 790                available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
 791                chart_type=self.chart_type,
 792                text_color=self.chart_colors_settings["paper_0"],
 793                celestial_point_language=self.language_settings["celestial_points"],
 794            )
 795
 796        # Set date time string
 797        if self.chart_type in ["Composite"]:
 798            # First Subject Latitude and Longitude
 799            latitude = convert_latitude_coordinate_to_string(self.user.first_subject.lat, self.language_settings["north_letter"], self.language_settings["south_letter"])
 800            longitude = convert_longitude_coordinate_to_string(self.user.first_subject.lng, self.language_settings["east_letter"], self.language_settings["west_letter"])
 801            template_dict["top_left_2"] = f"{latitude} {longitude}"
 802        else:
 803            dt = datetime.fromisoformat(self.user.iso_formatted_local_datetime)
 804            custom_format = dt.strftime('%Y-%m-%d %H:%M [%z]')
 805            custom_format = custom_format[:-3] + ':' + custom_format[-3:]
 806            template_dict["top_left_2"] = f"{custom_format}"
 807
 808        return ChartTemplateDictionary(**template_dict)
 809
 810    def makeTemplate(self, minify: bool = False, remove_css_variables = False) -> str:
 811        """
 812        Render the full chart SVG as a string.
 813
 814        Reads the XML template, substitutes variables, and optionally inlines CSS
 815        variables and minifies the output.
 816
 817        Args:
 818            minify (bool): Remove whitespace and quotes for compactness.
 819            remove_css_variables (bool): Embed CSS variable definitions.
 820
 821        Returns:
 822            str: SVG markup as a string.
 823        """
 824        td = self._create_template_dictionary()
 825
 826        DATA_DIR = Path(__file__).parent
 827        xml_svg = DATA_DIR / "templates" / "chart.xml"
 828
 829        # read template
 830        with open(xml_svg, "r", encoding="utf-8", errors="ignore") as f:
 831            template = Template(f.read()).substitute(td)
 832
 833        # return filename
 834
 835        logging.debug(f"Template dictionary keys: {td.keys()}")
 836
 837        self._create_template_dictionary()
 838
 839        if remove_css_variables:
 840            template = inline_css_variables_in_svg(template)
 841
 842        if minify:
 843            template = scourString(template).replace('"', "'").replace("\n", "").replace("\t","").replace("    ", "").replace("  ", "")
 844
 845        else:
 846            template = template.replace('"', "'")
 847
 848        return template
 849
 850    def makeSVG(self, minify: bool = False, remove_css_variables = False):
 851        """
 852        Generate and save the full chart SVG to disk.
 853
 854        Calls makeTemplate to render the SVG, then writes a file named
 855        "{subject.name} - {chart_type} Chart.svg" in the output directory.
 856
 857        Args:
 858            minify (bool): Pass-through to makeTemplate for compact output.
 859            remove_css_variables (bool): Pass-through to makeTemplate to embed CSS variables.
 860
 861        Returns:
 862            None
 863        """
 864
 865        self.template = self.makeTemplate(minify, remove_css_variables)
 866
 867        chartname = self.output_directory / f"{self.user.name} - {self.chart_type} Chart.svg"
 868
 869        with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file:
 870            output_file.write(self.template)
 871
 872        print(f"SVG Generated Correctly in: {chartname}")
 873
 874    def makeWheelOnlyTemplate(self, minify: bool = False, remove_css_variables = False):
 875        """
 876        Render the wheel-only chart SVG as a string.
 877
 878        Reads the wheel-only XML template, substitutes chart data, and applies optional
 879        CSS inlining and minification.
 880
 881        Args:
 882            minify (bool): Remove whitespace and quotes for compactness.
 883            remove_css_variables (bool): Embed CSS variable definitions.
 884
 885        Returns:
 886            str: SVG markup for the chart wheel only.
 887        """
 888
 889        with open(Path(__file__).parent / "templates" / "wheel_only.xml", "r", encoding="utf-8", errors="ignore") as f:
 890            template = f.read()
 891
 892        template_dict = self._create_template_dictionary()
 893        template = Template(template).substitute(template_dict)
 894
 895        if remove_css_variables:
 896            template = inline_css_variables_in_svg(template)
 897
 898        if minify:
 899            template = scourString(template).replace('"', "'").replace("\n", "").replace("\t","").replace("    ", "").replace("  ", "")
 900
 901        else:
 902            template = template.replace('"', "'")
 903
 904        return template
 905
 906    def makeWheelOnlySVG(self, minify: bool = False, remove_css_variables = False):
 907        """
 908        Generate and save wheel-only chart SVG to disk.
 909
 910        Calls makeWheelOnlyTemplate and writes a file named
 911        "{subject.name} - {chart_type} Chart - Wheel Only.svg" in the output directory.
 912
 913        Args:
 914            minify (bool): Pass-through to makeWheelOnlyTemplate for compact output.
 915            remove_css_variables (bool): Pass-through to makeWheelOnlyTemplate to embed CSS variables.
 916
 917        Returns:
 918            None
 919        """
 920
 921        template = self.makeWheelOnlyTemplate(minify, remove_css_variables)
 922        chartname = self.output_directory / f"{self.user.name} - {self.chart_type} Chart - Wheel Only.svg"
 923
 924        with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file:
 925            output_file.write(template)
 926
 927        print(f"SVG Generated Correctly in: {chartname}")
 928
 929    def makeAspectGridOnlyTemplate(self, minify: bool = False, remove_css_variables = False):
 930        """
 931        Render the aspect-grid-only chart SVG as a string.
 932
 933        Reads the aspect-grid XML template, generates the aspect grid based on chart type,
 934        and applies optional CSS inlining and minification.
 935
 936        Args:
 937            minify (bool): Remove whitespace and quotes for compactness.
 938            remove_css_variables (bool): Embed CSS variable definitions.
 939
 940        Returns:
 941            str: SVG markup for the aspect grid only.
 942        """
 943
 944        with open(Path(__file__).parent / "templates" / "aspect_grid_only.xml", "r", encoding="utf-8", errors="ignore") as f:
 945            template = f.read()
 946
 947        template_dict = self._create_template_dictionary()
 948
 949        if self.chart_type in ["Transit", "Synastry"]:
 950            aspects_grid = draw_transit_aspect_grid(self.chart_colors_settings['paper_0'], self.available_planets_setting, self.aspects_list)
 951        else:
 952            aspects_grid = draw_aspect_grid(self.chart_colors_settings['paper_0'], self.available_planets_setting, self.aspects_list, x_start=50, y_start=250)
 953
 954        template = Template(template).substitute({**template_dict, "makeAspectGrid": aspects_grid})
 955
 956        if remove_css_variables:
 957            template = inline_css_variables_in_svg(template)
 958
 959        if minify:
 960            template = scourString(template).replace('"', "'").replace("\n", "").replace("\t","").replace("    ", "").replace("  ", "")
 961
 962        else:
 963            template = template.replace('"', "'")
 964
 965        return template
 966
 967    def makeAspectGridOnlySVG(self, minify: bool = False, remove_css_variables = False):
 968        """
 969        Generate and save aspect-grid-only chart SVG to disk.
 970
 971        Calls makeAspectGridOnlyTemplate and writes a file named
 972        "{subject.name} - {chart_type} Chart - Aspect Grid Only.svg" in the output directory.
 973
 974        Args:
 975            minify (bool): Pass-through to makeAspectGridOnlyTemplate for compact output.
 976            remove_css_variables (bool): Pass-through to makeAspectGridOnlyTemplate to embed CSS variables.
 977
 978        Returns:
 979            None
 980        """
 981
 982        template = self.makeAspectGridOnlyTemplate(minify, remove_css_variables)
 983        chartname = self.output_directory / f"{self.user.name} - {self.chart_type} Chart - Aspect Grid Only.svg"
 984
 985        with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file:
 986            output_file.write(template)
 987
 988        print(f"SVG Generated Correctly in: {chartname}")
 989
 990if __name__ == "__main__":
 991    from kerykeion.utilities import setup_logging
 992    from kerykeion.composite_subject_factory import CompositeSubjectFactory
 993    setup_logging(level="debug")
 994
 995    first = AstrologicalSubject("John Lennon", 1940, 10, 9, 18, 30, "Liverpool", "GB")
 996    second = AstrologicalSubject("Paul McCartney", 1942, 6, 18, 15, 30, "Liverpool", "GB")
 997
 998    # Internal Natal Chart
 999    internal_natal_chart = KerykeionChartSVG(first)
1000    internal_natal_chart.makeSVG()
1001
1002    # External Natal Chart
1003    external_natal_chart = KerykeionChartSVG(first, "ExternalNatal", second)
1004    external_natal_chart.makeSVG()
1005
1006    # Synastry Chart
1007    synastry_chart = KerykeionChartSVG(first, "Synastry", second)
1008    synastry_chart.makeSVG()
1009
1010    # Transits Chart
1011    transits_chart = KerykeionChartSVG(first, "Transit", second)
1012    transits_chart.makeSVG()
1013
1014    # Sidereal Birth Chart (Lahiri)
1015    sidereal_subject = AstrologicalSubject("John Lennon Lahiri", 1940, 10, 9, 18, 30, "Liverpool", "GB", zodiac_type="Sidereal", sidereal_mode="LAHIRI")
1016    sidereal_chart = KerykeionChartSVG(sidereal_subject)
1017    sidereal_chart.makeSVG()
1018
1019    # Sidereal Birth Chart (Fagan-Bradley)
1020    sidereal_subject = AstrologicalSubject("John Lennon Fagan-Bradley", 1940, 10, 9, 18, 30, "Liverpool", "GB", zodiac_type="Sidereal", sidereal_mode="FAGAN_BRADLEY")
1021    sidereal_chart = KerykeionChartSVG(sidereal_subject)
1022    sidereal_chart.makeSVG()
1023
1024    # Sidereal Birth Chart (DeLuce)
1025    sidereal_subject = AstrologicalSubject("John Lennon DeLuce", 1940, 10, 9, 18, 30, "Liverpool", "GB", zodiac_type="Sidereal", sidereal_mode="DELUCE")
1026    sidereal_chart = KerykeionChartSVG(sidereal_subject)
1027    sidereal_chart.makeSVG()
1028
1029    # Sidereal Birth Chart (J2000)
1030    sidereal_subject = AstrologicalSubject("John Lennon J2000", 1940, 10, 9, 18, 30, "Liverpool", "GB", zodiac_type="Sidereal", sidereal_mode="J2000")
1031    sidereal_chart = KerykeionChartSVG(sidereal_subject)
1032    sidereal_chart.makeSVG()
1033
1034    # House System Morinus
1035    morinus_house_subject = AstrologicalSubject("John Lennon - House System Morinus", 1940, 10, 9, 18, 30, "Liverpool", "GB", houses_system_identifier="M")
1036    morinus_house_chart = KerykeionChartSVG(morinus_house_subject)
1037    morinus_house_chart.makeSVG()
1038
1039    ## To check all the available house systems uncomment the following code:
1040    # from kerykeion.kr_types import HousesSystemIdentifier
1041    # from typing import get_args
1042    # for i in get_args(HousesSystemIdentifier):
1043    #     alternatives_house_subject = AstrologicalSubject(f"John Lennon - House System {i}", 1940, 10, 9, 18, 30, "Liverpool", "GB", houses_system=i)
1044    #     alternatives_house_chart = KerykeionChartSVG(alternatives_house_subject)
1045    #     alternatives_house_chart.makeSVG()
1046
1047    # With True Geocentric Perspective
1048    true_geocentric_subject = AstrologicalSubject("John Lennon - True Geocentric", 1940, 10, 9, 18, 30, "Liverpool", "GB", perspective_type="True Geocentric")
1049    true_geocentric_chart = KerykeionChartSVG(true_geocentric_subject)
1050    true_geocentric_chart.makeSVG()
1051
1052    # With Heliocentric Perspective
1053    heliocentric_subject = AstrologicalSubject("John Lennon - Heliocentric", 1940, 10, 9, 18, 30, "Liverpool", "GB", perspective_type="Heliocentric")
1054    heliocentric_chart = KerykeionChartSVG(heliocentric_subject)
1055    heliocentric_chart.makeSVG()
1056
1057    # With Topocentric Perspective
1058    topocentric_subject = AstrologicalSubject("John Lennon - Topocentric", 1940, 10, 9, 18, 30, "Liverpool", "GB", perspective_type="Topocentric")
1059    topocentric_chart = KerykeionChartSVG(topocentric_subject)
1060    topocentric_chart.makeSVG()
1061
1062    # Minified SVG
1063    minified_subject = AstrologicalSubject("John Lennon - Minified", 1940, 10, 9, 18, 30, "Liverpool", "GB")
1064    minified_chart = KerykeionChartSVG(minified_subject)
1065    minified_chart.makeSVG(minify=True)
1066
1067    # Dark Theme Natal Chart
1068    dark_theme_subject = AstrologicalSubject("John Lennon - Dark Theme", 1940, 10, 9, 18, 30, "Liverpool", "GB")
1069    dark_theme_natal_chart = KerykeionChartSVG(dark_theme_subject, theme="dark")
1070    dark_theme_natal_chart.makeSVG()
1071
1072    # Dark High Contrast Theme Natal Chart
1073    dark_high_contrast_theme_subject = AstrologicalSubject("John Lennon - Dark High Contrast Theme", 1940, 10, 9, 18, 30, "Liverpool", "GB")
1074    dark_high_contrast_theme_natal_chart = KerykeionChartSVG(dark_high_contrast_theme_subject, theme="dark-high-contrast")
1075    dark_high_contrast_theme_natal_chart.makeSVG()
1076
1077    # Light Theme Natal Chart
1078    light_theme_subject = AstrologicalSubject("John Lennon - Light Theme", 1940, 10, 9, 18, 30, "Liverpool", "GB")
1079    light_theme_natal_chart = KerykeionChartSVG(light_theme_subject, theme="light")
1080    light_theme_natal_chart.makeSVG()
1081
1082    # Dark Theme External Natal Chart
1083    dark_theme_external_subject = AstrologicalSubject("John Lennon - Dark Theme External", 1940, 10, 9, 18, 30, "Liverpool", "GB")
1084    dark_theme_external_chart = KerykeionChartSVG(dark_theme_external_subject, "ExternalNatal", second, theme="dark")
1085    dark_theme_external_chart.makeSVG()
1086
1087    # Dark Theme Synastry Chart
1088    dark_theme_synastry_subject = AstrologicalSubject("John Lennon - DTS", 1940, 10, 9, 18, 30, "Liverpool", "GB")
1089    dark_theme_synastry_chart = KerykeionChartSVG(dark_theme_synastry_subject, "Synastry", second, theme="dark")
1090    dark_theme_synastry_chart.makeSVG()
1091
1092    # Wheel Natal Only Chart
1093    wheel_only_subject = AstrologicalSubject("John Lennon - Wheel Only", 1940, 10, 9, 18, 30, "Liverpool", "GB")
1094    wheel_only_chart = KerykeionChartSVG(wheel_only_subject)
1095    wheel_only_chart.makeWheelOnlySVG()
1096
1097    # Wheel External Natal Only Chart
1098    wheel_external_subject = AstrologicalSubject("John Lennon - Wheel External Only", 1940, 10, 9, 18, 30, "Liverpool", "GB")
1099    wheel_external_chart = KerykeionChartSVG(wheel_external_subject, "ExternalNatal", second)
1100    wheel_external_chart.makeWheelOnlySVG()
1101
1102    # Wheel Synastry Only Chart
1103    wheel_synastry_subject = AstrologicalSubject("John Lennon - Wheel Synastry Only", 1940, 10, 9, 18, 30, "Liverpool", "GB")
1104    wheel_synastry_chart = KerykeionChartSVG(wheel_synastry_subject, "Synastry", second)
1105    wheel_synastry_chart.makeWheelOnlySVG()
1106
1107    # Wheel Transit Only Chart
1108    wheel_transit_subject = AstrologicalSubject("John Lennon - Wheel Transit Only", 1940, 10, 9, 18, 30, "Liverpool", "GB")
1109    wheel_transit_chart = KerykeionChartSVG(wheel_transit_subject, "Transit", second)
1110    wheel_transit_chart.makeWheelOnlySVG()
1111
1112    # Wheel Sidereal Birth Chart (Lahiri) Dark Theme
1113    sidereal_dark_subject = AstrologicalSubject("John Lennon Lahiri - Dark Theme", 1940, 10, 9, 18, 30, "Liverpool", "GB", zodiac_type="Sidereal", sidereal_mode="LAHIRI")
1114    sidereal_dark_chart = KerykeionChartSVG(sidereal_dark_subject, theme="dark")
1115    sidereal_dark_chart.makeWheelOnlySVG()
1116
1117    # Wheel Sidereal Birth Chart (Fagan-Bradley) Light Theme
1118    sidereal_light_subject = AstrologicalSubject("John Lennon Fagan-Bradley - Light Theme", 1940, 10, 9, 18, 30, "Liverpool", "GB", zodiac_type="Sidereal", sidereal_mode="FAGAN_BRADLEY")
1119    sidereal_light_chart = KerykeionChartSVG(sidereal_light_subject, theme="light")
1120    sidereal_light_chart.makeWheelOnlySVG()
1121
1122    # Aspect Grid Only Natal Chart
1123    aspect_grid_only_subject = AstrologicalSubject("John Lennon - Aspect Grid Only", 1940, 10, 9, 18, 30, "Liverpool", "GB")
1124    aspect_grid_only_chart = KerykeionChartSVG(aspect_grid_only_subject)
1125    aspect_grid_only_chart.makeAspectGridOnlySVG()
1126
1127    # Aspect Grid Only Dark Theme Natal Chart
1128    aspect_grid_dark_subject = AstrologicalSubject("John Lennon - Aspect Grid Dark Theme", 1940, 10, 9, 18, 30, "Liverpool", "GB")
1129    aspect_grid_dark_chart = KerykeionChartSVG(aspect_grid_dark_subject, theme="dark")
1130    aspect_grid_dark_chart.makeAspectGridOnlySVG()
1131
1132    # Aspect Grid Only Light Theme Natal Chart
1133    aspect_grid_light_subject = AstrologicalSubject("John Lennon - Aspect Grid Light Theme", 1940, 10, 9, 18, 30, "Liverpool", "GB")
1134    aspect_grid_light_chart = KerykeionChartSVG(aspect_grid_light_subject, theme="light")
1135    aspect_grid_light_chart.makeAspectGridOnlySVG()
1136
1137    # Synastry Chart Aspect Grid Only
1138    aspect_grid_synastry_subject = AstrologicalSubject("John Lennon - Aspect Grid Synastry", 1940, 10, 9, 18, 30, "Liverpool", "GB")
1139    aspect_grid_synastry_chart = KerykeionChartSVG(aspect_grid_synastry_subject, "Synastry", second)
1140    aspect_grid_synastry_chart.makeAspectGridOnlySVG()
1141
1142    # Transit Chart Aspect Grid Only
1143    aspect_grid_transit_subject = AstrologicalSubject("John Lennon - Aspect Grid Transit", 1940, 10, 9, 18, 30, "Liverpool", "GB")
1144    aspect_grid_transit_chart = KerykeionChartSVG(aspect_grid_transit_subject, "Transit", second)
1145    aspect_grid_transit_chart.makeAspectGridOnlySVG()
1146
1147    # Synastry Chart Aspect Grid Only Dark Theme
1148    aspect_grid_dark_synastry_subject = AstrologicalSubject("John Lennon - Aspect Grid Dark Synastry", 1940, 10, 9, 18, 30, "Liverpool", "GB")
1149    aspect_grid_dark_synastry_chart = KerykeionChartSVG(aspect_grid_dark_synastry_subject, "Synastry", second, theme="dark")
1150    aspect_grid_dark_synastry_chart.makeAspectGridOnlySVG()
1151
1152    # Synastry Chart With draw_transit_aspect_list table
1153    synastry_chart_with_table_list_subject = AstrologicalSubject("John Lennon - SCTWL", 1940, 10, 9, 18, 30, "Liverpool", "GB")
1154    synastry_chart_with_table_list = KerykeionChartSVG(synastry_chart_with_table_list_subject, "Synastry", second, double_chart_aspect_grid_type="list", theme="dark")
1155    synastry_chart_with_table_list.makeSVG()
1156
1157    # Transit Chart With draw_transit_aspect_grid table
1158    transit_chart_with_table_grid_subject = AstrologicalSubject("John Lennon - TCWTG", 1940, 10, 9, 18, 30, "Liverpool", "GB")
1159    transit_chart_with_table_grid = KerykeionChartSVG(transit_chart_with_table_grid_subject, "Transit", second, double_chart_aspect_grid_type="table", theme="dark")
1160    transit_chart_with_table_grid.makeSVG()
1161
1162    # Chines Language Chart
1163    chinese_subject = AstrologicalSubject("Hua Chenyu", 1990, 2, 7, 12, 0, "Hunan", "CN")
1164    chinese_chart = KerykeionChartSVG(chinese_subject, chart_language="CN")
1165    chinese_chart.makeSVG()
1166
1167    # French Language Chart
1168    french_subject = AstrologicalSubject("Jeanne Moreau", 1928, 1, 23, 10, 0, "Paris", "FR")
1169    french_chart = KerykeionChartSVG(french_subject, chart_language="FR")
1170    french_chart.makeSVG()
1171
1172    # Spanish Language Chart
1173    spanish_subject = AstrologicalSubject("Antonio Banderas", 1960, 8, 10, 12, 0, "Malaga", "ES")
1174    spanish_chart = KerykeionChartSVG(spanish_subject, chart_language="ES")
1175    spanish_chart.makeSVG()
1176
1177    # Portuguese Language Chart
1178    portuguese_subject = AstrologicalSubject("Cristiano Ronaldo", 1985, 2, 5, 5, 25, "Funchal", "PT")
1179    portuguese_chart = KerykeionChartSVG(portuguese_subject, chart_language="PT")
1180    portuguese_chart.makeSVG()
1181
1182    # Italian Language Chart
1183    italian_subject = AstrologicalSubject("Sophia Loren", 1934, 9, 20, 2, 0, "Rome", "IT")
1184    italian_chart = KerykeionChartSVG(italian_subject, chart_language="IT")
1185    italian_chart.makeSVG()
1186
1187    # Russian Language Chart
1188    russian_subject = AstrologicalSubject("Mikhail Bulgakov", 1891, 5, 15, 12, 0, "Kiev", "UA")
1189    russian_chart = KerykeionChartSVG(russian_subject, chart_language="RU")
1190    russian_chart.makeSVG()
1191
1192    # Turkish Language Chart
1193    turkish_subject = AstrologicalSubject("Mehmet Oz", 1960, 6, 11, 12, 0, "Istanbul", "TR")
1194    turkish_chart = KerykeionChartSVG(turkish_subject, chart_language="TR")
1195    turkish_chart.makeSVG()
1196
1197    # German Language Chart
1198    german_subject = AstrologicalSubject("Albert Einstein", 1879, 3, 14, 11, 30, "Ulm", "DE")
1199    german_chart = KerykeionChartSVG(german_subject, chart_language="DE")
1200    german_chart.makeSVG()
1201
1202    # Hindi Language Chart
1203    hindi_subject = AstrologicalSubject("Amitabh Bachchan", 1942, 10, 11, 4, 0, "Allahabad", "IN")
1204    hindi_chart = KerykeionChartSVG(hindi_subject, chart_language="HI")
1205    hindi_chart.makeSVG()
1206
1207    # Kanye West Natal Chart
1208    kanye_west_subject = AstrologicalSubject("Kanye", 1977, 6, 8, 8, 45, "Atlanta", "US")
1209    kanye_west_chart = KerykeionChartSVG(kanye_west_subject)
1210    kanye_west_chart.makeSVG()
1211
1212    # Composite Chart
1213    angelina = AstrologicalSubject("Angelina Jolie", 1975, 6, 4, 9, 9, "Los Angeles", "US", lng=-118.15, lat=34.03, tz_str="America/Los_Angeles")
1214    brad = AstrologicalSubject("Brad Pitt", 1963, 12, 18, 6, 31, "Shawnee", "US", lng=-96.56, lat=35.20, tz_str="America/Chicago")
1215
1216    composite_subject_factory = CompositeSubjectFactory(angelina, brad)
1217    composite_subject_model = composite_subject_factory.get_midpoint_composite_subject_model()
1218    composite_chart = KerykeionChartSVG(composite_subject_model, "Composite")
1219    composite_chart.makeSVG()
class KerykeionChartSVG:
 49class KerykeionChartSVG:
 50    """
 51    KerykeionChartSVG generates astrological chart visualizations as SVG files.
 52
 53    This class supports creating full chart SVGs, wheel-only SVGs, and aspect-grid-only SVGs
 54    for various chart types including Natal, ExternalNatal, Transit, Synastry, and Composite.
 55    Charts are rendered using XML templates and drawing utilities, with customizable themes,
 56    language, active points, and aspects.
 57    The rendered SVGs can be saved to a specified output directory or, by default, to the user's home directory.
 58
 59    NOTE:
 60        The generated SVG files are optimized for web use, opening in browsers. If you want to
 61        use them in other applications, you might need to adjust the SVG settings or styles.
 62
 63    Args:
 64        first_obj (AstrologicalSubject | AstrologicalSubjectModel | CompositeSubjectModel):
 65            The primary astrological subject for the chart.
 66        chart_type (ChartType, optional):
 67            The type of chart to generate ('Natal', 'ExternalNatal', 'Transit', 'Synastry', 'Composite').
 68            Defaults to 'Natal'.
 69        second_obj (AstrologicalSubject | AstrologicalSubjectModel, optional):
 70            The secondary subject for Transit or Synastry charts. Not required for Natal or Composite.
 71        new_output_directory (str | Path, optional):
 72            Directory to write generated SVG files. Defaults to the user's home directory.
 73        new_settings_file (Path | dict | KerykeionSettingsModel, optional):
 74            Path or settings object to override default chart configuration (colors, fonts, aspects).
 75        theme (KerykeionChartTheme, optional):
 76            CSS theme for the chart. If None, no default styles are applied. Defaults to 'classic'.
 77        double_chart_aspect_grid_type (Literal['list', 'table'], optional):
 78            Specifies rendering style for double-chart aspect grids. Defaults to 'list'.
 79        chart_language (KerykeionChartLanguage, optional):
 80            Language code for chart labels. Defaults to 'EN'.
 81        active_points (list[Planet | AxialCusps], optional):
 82            List of celestial points and angles to include. Defaults to DEFAULT_ACTIVE_POINTS.
 83            Example:
 84            ["Sun", "Moon", "Mercury", "Venus"]
 85
 86        active_aspects (list[ActiveAspect], optional):
 87            List of aspects (name and orb) to calculate. Defaults to DEFAULT_ACTIVE_ASPECTS.
 88            Example:
 89            [
 90                {"name": "conjunction", "orb": 10},
 91                {"name": "opposition", "orb": 10},
 92                {"name": "trine", "orb": 8},
 93                {"name": "sextile", "orb": 6},
 94                {"name": "square", "orb": 5},
 95                {"name": "quintile", "orb": 1},
 96            ]
 97
 98    Public Methods:
 99        makeTemplate(minify=False, remove_css_variables=False) -> str:
100            Render the full chart SVG as a string without writing to disk. Use `minify=True`
101            to remove whitespace and quotes, and `remove_css_variables=True` to embed CSS vars.
102
103        makeSVG(minify=False, remove_css_variables=False) -> None:
104            Generate and write the full chart SVG file to the output directory.
105            Filenames follow the pattern:
106            '{subject.name} - {chart_type} Chart.svg'.
107
108        makeWheelOnlyTemplate(minify=False, remove_css_variables=False) -> str:
109            Render only the chart wheel (no aspect grid) as an SVG string.
110
111        makeWheelOnlySVG(minify=False, remove_css_variables=False) -> None:
112            Generate and write the wheel-only SVG file:
113            '{subject.name} - {chart_type} Chart - Wheel Only.svg'.
114
115        makeAspectGridOnlyTemplate(minify=False, remove_css_variables=False) -> str:
116            Render only the aspect grid as an SVG string.
117
118        makeAspectGridOnlySVG(minify=False, remove_css_variables=False) -> None:
119            Generate and write the aspect-grid-only SVG file:
120            '{subject.name} - {chart_type} Chart - Aspect Grid Only.svg'.
121    """
122
123    # Constants
124    _BASIC_CHART_VIEWBOX = "0 0 820 550.0"
125    _WIDE_CHART_VIEWBOX = "0 0 1200 546.0"
126    _TRANSIT_CHART_WITH_TABLE_VIWBOX = "0 0 960 546.0"
127
128    _DEFAULT_HEIGHT = 550
129    _DEFAULT_FULL_WIDTH = 1200
130    _DEFAULT_NATAL_WIDTH = 820
131    _DEFAULT_FULL_WIDTH_WITH_TABLE = 960
132    _PLANET_IN_ZODIAC_EXTRA_POINTS = 10
133
134    # Set at init
135    first_obj: Union[AstrologicalSubject, AstrologicalSubjectModel]
136    second_obj: Union[AstrologicalSubject, AstrologicalSubjectModel, None]
137    chart_type: ChartType
138    new_output_directory: Union[Path, None]
139    new_settings_file: Union[Path, None, KerykeionSettingsModel, dict]
140    output_directory: Path
141    new_settings_file: Union[Path, None, KerykeionSettingsModel, dict]
142    theme: Union[KerykeionChartTheme, None]
143    double_chart_aspect_grid_type: Literal["list", "table"]
144    chart_language: KerykeionChartLanguage
145    active_points: List[Union[Planet, AxialCusps]]
146    active_aspects: List[ActiveAspect]
147
148    # Internal properties
149    fire: float
150    earth: float
151    air: float
152    water: float
153    first_circle_radius: float
154    second_circle_radius: float
155    third_circle_radius: float
156    width: Union[float, int]
157    language_settings: dict
158    chart_colors_settings: dict
159    planets_settings: dict
160    aspects_settings: dict
161    user: Union[AstrologicalSubject, AstrologicalSubjectModel, CompositeSubjectModel]
162    available_planets_setting: List[KerykeionSettingsCelestialPointModel]
163    height: float
164    location: str
165    geolat: float
166    geolon: float
167    template: str
168
169    def __init__(
170        self,
171        first_obj: Union[AstrologicalSubject, AstrologicalSubjectModel, CompositeSubjectModel],
172        chart_type: ChartType = "Natal",
173        second_obj: Union[AstrologicalSubject, AstrologicalSubjectModel, None] = None,
174        new_output_directory: Union[str, None] = None,
175        new_settings_file: Union[Path, None, KerykeionSettingsModel, dict] = None,
176        theme: Union[KerykeionChartTheme, None] = "classic",
177        double_chart_aspect_grid_type: Literal["list", "table"] = "list",
178        chart_language: KerykeionChartLanguage = "EN",
179        active_points: List[Union[Planet, AxialCusps]] = DEFAULT_ACTIVE_POINTS,
180    active_aspects: List[ActiveAspect] = DEFAULT_ACTIVE_ASPECTS,
181    ):
182        """
183        Initialize the chart generator with subject data and configuration options.
184
185        Args:
186            first_obj (AstrologicalSubject, AstrologicalSubjectModel, or CompositeSubjectModel):
187                Primary astrological subject instance.
188            chart_type (ChartType, optional):
189                Type of chart to generate (e.g., 'Natal', 'Transit').
190            second_obj (AstrologicalSubject or AstrologicalSubjectModel, optional):
191                Secondary subject for Transit or Synastry charts.
192            new_output_directory (str or Path, optional):
193                Base directory to save generated SVG files.
194            new_settings_file (Path, dict, or KerykeionSettingsModel, optional):
195                Custom settings source for chart colors, fonts, and aspects.
196            theme (KerykeionChartTheme or None, optional):
197                CSS theme to apply; None for default styling.
198            double_chart_aspect_grid_type (Literal['list','table'], optional):
199                Layout style for double-chart aspect grids ('list' or 'table').
200            chart_language (KerykeionChartLanguage, optional):
201                Language code for chart labels (e.g., 'EN', 'IT').
202            active_points (List[Planet or AxialCusps], optional):
203                Celestial points to include in the chart visualization.
204            active_aspects (List[ActiveAspect], optional):
205                Aspects to calculate, each defined by name and orb.
206        """
207        home_directory = Path.home()
208        self.new_settings_file = new_settings_file
209        self.chart_language = chart_language
210        self.active_points = active_points
211        self.active_aspects = active_aspects
212
213        if new_output_directory:
214            self.output_directory = Path(new_output_directory)
215        else:
216            self.output_directory = home_directory
217
218        self.parse_json_settings(new_settings_file)
219        self.chart_type = chart_type
220
221        # Kerykeion instance
222        self.user = first_obj
223
224        self.available_planets_setting = []
225        for body in self.planets_settings:
226            if body["name"] not in active_points:
227                continue
228            else:
229                body["is_active"] = True
230
231            self.available_planets_setting.append(body)
232
233        # Available bodies
234        available_celestial_points_names = []
235        for body in self.available_planets_setting:
236            available_celestial_points_names.append(body["name"].lower())
237
238        self.available_kerykeion_celestial_points: list[KerykeionPointModel] = []
239        for body in available_celestial_points_names:
240            self.available_kerykeion_celestial_points.append(self.user.get(body))
241
242        # Makes the sign number list.
243        if self.chart_type == "Natal" or self.chart_type == "ExternalNatal":
244            natal_aspects_instance = NatalAspects(
245                self.user, new_settings_file=self.new_settings_file,
246                active_points=active_points,
247                active_aspects=active_aspects,
248            )
249            self.aspects_list = natal_aspects_instance.relevant_aspects
250
251        elif self.chart_type == "Transit" or self.chart_type == "Synastry":
252            if not second_obj:
253                raise KerykeionException("Second object is required for Transit or Synastry charts.")
254
255            # Kerykeion instance
256            self.t_user = second_obj
257
258            # Aspects
259            if self.chart_type == "Transit":
260                synastry_aspects_instance = SynastryAspects(
261                    self.t_user,
262                    self.user,
263                    new_settings_file=self.new_settings_file,
264                    active_points=active_points,
265                    active_aspects=active_aspects,
266                )
267
268            else:
269                synastry_aspects_instance = SynastryAspects(
270                    self.user,
271                    self.t_user,
272                    new_settings_file=self.new_settings_file,
273                    active_points=active_points,
274                    active_aspects=active_aspects,
275                )
276
277            self.aspects_list = synastry_aspects_instance.relevant_aspects
278
279            self.t_available_kerykeion_celestial_points = []
280            for body in available_celestial_points_names:
281                self.t_available_kerykeion_celestial_points.append(self.t_user.get(body))
282
283        elif self.chart_type == "Composite":
284            if not isinstance(first_obj, CompositeSubjectModel):
285                raise KerykeionException("First object must be a CompositeSubjectModel instance.")
286
287            self.aspects_list = NatalAspects(self.user, new_settings_file=self.new_settings_file, active_points=active_points).relevant_aspects
288
289        # Double chart aspect grid type
290        self.double_chart_aspect_grid_type = double_chart_aspect_grid_type
291
292        # screen size
293        self.height = self._DEFAULT_HEIGHT
294        if self.chart_type == "Synastry" or self.chart_type == "Transit":
295            self.width = self._DEFAULT_FULL_WIDTH
296        elif self.double_chart_aspect_grid_type == "table" and self.chart_type == "Transit":
297            self.width = self._DEFAULT_FULL_WIDTH_WITH_TABLE
298        else:
299            self.width = self._DEFAULT_NATAL_WIDTH
300
301        if self.chart_type in ["Natal", "ExternalNatal", "Synastry"]:
302            self.location = self.user.city
303            self.geolat = self.user.lat
304            self.geolon =  self.user.lng
305
306        elif self.chart_type == "Composite":
307            self.location = ""
308            self.geolat = (self.user.first_subject.lat + self.user.second_subject.lat) / 2
309            self.geolon = (self.user.first_subject.lng + self.user.second_subject.lng) / 2
310
311        elif self.chart_type in ["Transit"]:
312            self.location = self.t_user.city
313            self.geolat = self.t_user.lat
314            self.geolon = self.t_user.lng
315            self.t_name = self.language_settings["transit_name"]
316
317        # Default radius for the chart
318        self.main_radius = 240
319
320        # Set circle radii based on chart type
321        if self.chart_type == "ExternalNatal":
322            self.first_circle_radius, self.second_circle_radius, self.third_circle_radius = 56, 92, 112
323        else:
324            self.first_circle_radius, self.second_circle_radius, self.third_circle_radius = 0, 36, 120
325
326        # Initialize element points
327        self.fire = 0.0
328        self.earth = 0.0
329        self.air = 0.0
330        self.water = 0.0
331
332        # Calculate element points from planets
333        self._calculate_elements_points_from_planets()
334
335        # Set up theme
336        if theme not in get_args(KerykeionChartTheme) and theme is not None:
337            raise KerykeionException(f"Theme {theme} is not available. Set None for default theme.")
338
339        self.set_up_theme(theme)
340
341    def set_up_theme(self, theme: Union[KerykeionChartTheme, None] = None) -> None:
342        """
343        Load and apply a CSS theme for the chart visualization.
344
345        Args:
346            theme (KerykeionChartTheme or None): Name of the theme to apply. If None, no CSS is applied.
347        """
348        if theme is None:
349            self.color_style_tag = ""
350            return
351
352        theme_dir = Path(__file__).parent / "themes"
353
354        with open(theme_dir / f"{theme}.css", "r") as f:
355            self.color_style_tag = f.read()
356
357    def set_output_directory(self, dir_path: Path) -> None:
358        """
359        Set the directory where generated SVG files will be saved.
360
361        Args:
362            dir_path (Path): Target directory for SVG output.
363        """
364        self.output_directory = dir_path
365        logging.info(f"Output direcotry set to: {self.output_directory}")
366
367    def parse_json_settings(self, settings_file_or_dict: Union[Path, dict, KerykeionSettingsModel, None]) -> None:
368        """
369        Load and parse chart configuration settings.
370
371        Args:
372            settings_file_or_dict (Path, dict, or KerykeionSettingsModel):
373                Source for custom chart settings.
374        """
375        settings = get_settings(settings_file_or_dict)
376
377        self.language_settings = settings["language_settings"][self.chart_language]
378        self.chart_colors_settings = settings["chart_colors"]
379        self.planets_settings = settings["celestial_points"]
380        self.aspects_settings = settings["aspects"]
381
382    def _draw_zodiac_circle_slices(self, r):
383        """
384        Draw zodiac circle slices for each sign.
385
386        Args:
387            r (float): Outer radius of the zodiac ring.
388
389        Returns:
390            str: Concatenated SVG elements for zodiac slices.
391        """
392        sings = get_args(Sign)
393        output = ""
394        for i, sing in enumerate(sings):
395            output += draw_zodiac_slice(
396                c1=self.first_circle_radius,
397                chart_type=self.chart_type,
398                seventh_house_degree_ut=self.user.seventh_house.abs_pos,
399                num=i,
400                r=r,
401                style=f'fill:{self.chart_colors_settings[f"zodiac_bg_{i}"]}; fill-opacity: 0.5;',
402                type=sing,
403            )
404
405        return output
406
407    def _calculate_elements_points_from_planets(self):
408        """
409        Compute elemental point totals based on active planetary positions.
410
411        Iterates over each active planet to determine its zodiac element and adds extra points
412        if the planet is in a related sign. Updates self.fire, self.earth, self.air, and self.water.
413
414        Returns:
415            None
416        """
417
418        ZODIAC = (
419            {"name": "Ari", "element": "fire"},
420            {"name": "Tau", "element": "earth"},
421            {"name": "Gem", "element": "air"},
422            {"name": "Can", "element": "water"},
423            {"name": "Leo", "element": "fire"},
424            {"name": "Vir", "element": "earth"},
425            {"name": "Lib", "element": "air"},
426            {"name": "Sco", "element": "water"},
427            {"name": "Sag", "element": "fire"},
428            {"name": "Cap", "element": "earth"},
429            {"name": "Aqu", "element": "air"},
430            {"name": "Pis", "element": "water"},
431        )
432
433        # Available bodies
434        available_celestial_points_names = []
435        for body in self.available_planets_setting:
436            available_celestial_points_names.append(body["name"].lower())
437
438        # Make list of the points sign
439        points_sign = []
440        for planet in available_celestial_points_names:
441            points_sign.append(self.user.get(planet).sign_num)
442
443        for i in range(len(self.available_planets_setting)):
444            # element: get extra points if planet is in own zodiac sign.
445            related_zodiac_signs = self.available_planets_setting[i]["related_zodiac_signs"]
446            cz = points_sign[i]
447            extra_points = 0
448            if related_zodiac_signs != []:
449                for e in range(len(related_zodiac_signs)):
450                    if int(related_zodiac_signs[e]) == int(cz):
451                        extra_points = self._PLANET_IN_ZODIAC_EXTRA_POINTS
452
453            ele = ZODIAC[points_sign[i]]["element"]
454            if ele == "fire":
455                self.fire = self.fire + self.available_planets_setting[i]["element_points"] + extra_points
456
457            elif ele == "earth":
458                self.earth = self.earth + self.available_planets_setting[i]["element_points"] + extra_points
459
460            elif ele == "air":
461                self.air = self.air + self.available_planets_setting[i]["element_points"] + extra_points
462
463            elif ele == "water":
464                self.water = self.water + self.available_planets_setting[i]["element_points"] + extra_points
465
466    def _draw_all_aspects_lines(self, r, ar):
467        """
468        Render SVG lines for all aspects in the chart.
469
470        Args:
471            r (float): Radius at which aspect lines originate.
472            ar (float): Radius at which aspect lines terminate.
473
474        Returns:
475            str: SVG markup for all aspect lines.
476        """
477        out = ""
478        for aspect in self.aspects_list:
479            aspect_name = aspect["aspect"]
480            aspect_color = next((a["color"] for a in self.aspects_settings if a["name"] == aspect_name), None)
481            if aspect_color:
482                out += draw_aspect_line(
483                    r=r,
484                    ar=ar,
485                    aspect=aspect,
486                    color=aspect_color,
487                    seventh_house_degree_ut=self.user.seventh_house.abs_pos
488                )
489        return out
490
491    def _draw_all_transit_aspects_lines(self, r, ar):
492        """
493        Render SVG lines for all transit aspects in the chart.
494
495        Args:
496            r (float): Radius at which transit aspect lines originate.
497            ar (float): Radius at which transit aspect lines terminate.
498
499        Returns:
500            str: SVG markup for all transit aspect lines.
501        """
502        out = ""
503        for aspect in self.aspects_list:
504            aspect_name = aspect["aspect"]
505            aspect_color = next((a["color"] for a in self.aspects_settings if a["name"] == aspect_name), None)
506            if aspect_color:
507                out += draw_aspect_line(
508                    r=r,
509                    ar=ar,
510                    aspect=aspect,
511                    color=aspect_color,
512                    seventh_house_degree_ut=self.user.seventh_house.abs_pos
513                )
514        return out
515
516    def _create_template_dictionary(self) -> ChartTemplateDictionary:
517        """
518        Assemble chart data and rendering instructions into a template dictionary.
519
520        Gathers styling, dimensions, and SVG fragments for chart components based on
521        chart type and subjects.
522
523        Returns:
524            ChartTemplateDictionary: Populated structure of template variables.
525        """
526        # Initialize template dictionary
527        template_dict: dict = {}
528
529        # Set the color style tag
530        template_dict["color_style_tag"] = self.color_style_tag
531
532        # Set chart dimensions
533        template_dict["chart_height"] = self.height
534        template_dict["chart_width"] = self.width
535
536        # Set viewbox based on chart type
537        if self.chart_type in ["Natal", "ExternalNatal", "Composite"]:
538            template_dict['viewbox'] = self._BASIC_CHART_VIEWBOX
539        elif self.double_chart_aspect_grid_type == "table" and self.chart_type == "Transit":
540            template_dict['viewbox'] = self._TRANSIT_CHART_WITH_TABLE_VIWBOX
541        else:
542            template_dict['viewbox'] = self._WIDE_CHART_VIEWBOX
543
544        # Generate rings and circles based on chart type
545        if self.chart_type in ["Transit", "Synastry"]:
546            template_dict["transitRing"] = draw_transit_ring(self.main_radius, self.chart_colors_settings["paper_1"], self.chart_colors_settings["zodiac_transit_ring_3"])
547            template_dict["degreeRing"] = draw_transit_ring_degree_steps(self.main_radius, self.user.seventh_house.abs_pos)
548            template_dict["first_circle"] = draw_first_circle(self.main_radius, self.chart_colors_settings["zodiac_transit_ring_2"], self.chart_type)
549            template_dict["second_circle"] = draw_second_circle(self.main_radius, self.chart_colors_settings['zodiac_transit_ring_1'], self.chart_colors_settings['paper_1'], self.chart_type)
550            template_dict['third_circle'] = draw_third_circle(self.main_radius, self.chart_colors_settings['zodiac_transit_ring_0'], self.chart_colors_settings['paper_1'], self.chart_type, self.third_circle_radius)
551
552            if self.double_chart_aspect_grid_type == "list":
553                title = ""
554                if self.chart_type == "Synastry":
555                    title = self.language_settings.get("couple_aspects", "Couple Aspects")
556                else:
557                    title = self.language_settings.get("transit_aspects", "Transit Aspects")
558
559                template_dict["makeAspectGrid"] = draw_transit_aspect_list(title, self.aspects_list, self.planets_settings, self.aspects_settings)
560            else:
561                template_dict["makeAspectGrid"] = draw_transit_aspect_grid(self.chart_colors_settings['paper_0'], self.available_planets_setting, self.aspects_list, 550, 450)
562
563            template_dict["makeAspects"] = self._draw_all_transit_aspects_lines(self.main_radius, self.main_radius - 160)
564        else:
565            template_dict["transitRing"] = ""
566            template_dict["degreeRing"] = draw_degree_ring(self.main_radius, self.first_circle_radius, self.user.seventh_house.abs_pos, self.chart_colors_settings["paper_0"])
567            template_dict['first_circle'] = draw_first_circle(self.main_radius, self.chart_colors_settings["zodiac_radix_ring_2"], self.chart_type, self.first_circle_radius)
568            template_dict["second_circle"] = draw_second_circle(self.main_radius, self.chart_colors_settings["zodiac_radix_ring_1"], self.chart_colors_settings["paper_1"], self.chart_type, self.second_circle_radius)
569            template_dict['third_circle'] = draw_third_circle(self.main_radius, self.chart_colors_settings["zodiac_radix_ring_0"], self.chart_colors_settings["paper_1"], self.chart_type, self.third_circle_radius)
570            template_dict["makeAspectGrid"] = draw_aspect_grid(self.chart_colors_settings['paper_0'], self.available_planets_setting, self.aspects_list)
571
572            template_dict["makeAspects"] = self._draw_all_aspects_lines(self.main_radius, self.main_radius - self.third_circle_radius)
573
574        # Set chart title
575        if self.chart_type == "Synastry":
576            template_dict["stringTitle"] = f"{self.user.name} {self.language_settings['and_word']} {self.t_user.name}"
577        elif self.chart_type == "Transit":
578            template_dict["stringTitle"] = f"{self.language_settings['transits']} {self.t_user.day}/{self.t_user.month}/{self.t_user.year}"
579        elif self.chart_type in ["Natal", "ExternalNatal"]:
580            template_dict["stringTitle"] = self.user.name
581        elif self.chart_type == "Composite":
582            template_dict["stringTitle"] = f"{self.user.first_subject.name} {self.language_settings['and_word']} {self.user.second_subject.name}"
583
584        # Zodiac Type Info
585        if self.user.zodiac_type == 'Tropic':
586            zodiac_info = f"{self.language_settings.get('zodiac', 'Zodiac')}: {self.language_settings.get('tropical', 'Tropical')}"
587        else:
588            mode_const = "SIDM_" + self.user.sidereal_mode # type: ignore
589            mode_name = swe.get_ayanamsa_name(getattr(swe, mode_const))
590            zodiac_info = f"{self.language_settings.get('ayanamsa', 'Ayanamsa')}: {mode_name}"
591
592        template_dict["bottom_left_0"] = f"{self.language_settings.get('houses_system_' + self.user.houses_system_identifier, self.user.houses_system_name)} {self.language_settings.get('houses', 'Houses')}"
593        template_dict["bottom_left_1"] = zodiac_info
594
595        if self.chart_type in ["Natal", "ExternalNatal", "Synastry"]:
596            template_dict["bottom_left_2"] = f'{self.language_settings.get("lunar_phase", "Lunar Phase")} {self.language_settings.get("day", "Day").lower()}: {self.user.lunar_phase.get("moon_phase", "")}'
597            template_dict["bottom_left_3"] = f'{self.language_settings.get("lunar_phase", "Lunar Phase")}: {self.language_settings.get(self.user.lunar_phase.moon_phase_name.lower().replace(" ", "_"), self.user.lunar_phase.moon_phase_name)}'
598            template_dict["bottom_left_4"] = f'{self.language_settings.get(self.user.perspective_type.lower().replace(" ", "_"), self.user.perspective_type)}'
599        elif self.chart_type == "Transit":
600            template_dict["bottom_left_2"] = f'{self.language_settings.get("lunar_phase", "Lunar Phase")}: {self.language_settings.get("day", "Day")} {self.t_user.lunar_phase.get("moon_phase", "")}'
601            template_dict["bottom_left_3"] = f'{self.language_settings.get("lunar_phase", "Lunar Phase")}: {self.t_user.lunar_phase.moon_phase_name}'
602            template_dict["bottom_left_4"] = f'{self.language_settings.get(self.t_user.perspective_type.lower().replace(" ", "_"), self.t_user.perspective_type)}'
603        elif self.chart_type == "Composite":
604            template_dict["bottom_left_2"] = f'{self.user.first_subject.perspective_type}'
605            template_dict["bottom_left_3"] = f'{self.language_settings.get("composite_chart", "Composite Chart")} - {self.language_settings.get("midpoints", "Midpoints")}'
606            template_dict["bottom_left_4"] = ""
607
608        # Draw moon phase
609        moon_phase_dict = calculate_moon_phase_chart_params(
610            self.user.lunar_phase["degrees_between_s_m"],
611            self.geolat
612        )
613
614        template_dict["lunar_phase_rotate"] = moon_phase_dict["lunar_phase_rotate"]
615        template_dict["lunar_phase_circle_center_x"] = moon_phase_dict["circle_center_x"]
616        template_dict["lunar_phase_circle_radius"] = moon_phase_dict["circle_radius"]
617
618        if self.chart_type == "Composite":
619            template_dict["top_left_1"] = f"{datetime.fromisoformat(self.user.first_subject.iso_formatted_local_datetime).strftime('%Y-%m-%d %H:%M')}"
620        # Set location string
621        elif len(self.location) > 35:
622            split_location = self.location.split(",")
623            if len(split_location) > 1:
624                template_dict["top_left_1"] = split_location[0] + ", " + split_location[-1]
625                if len(template_dict["top_left_1"]) > 35:
626                    template_dict["top_left_1"] = template_dict["top_left_1"][:35] + "..."
627            else:
628                template_dict["top_left_1"] = self.location[:35] + "..."
629        else:
630            template_dict["top_left_1"] = self.location
631
632        # Set chart name
633        if self.chart_type in ["Synastry", "Transit"]:
634            template_dict["top_left_0"] = f"{self.user.name}:"
635        elif self.chart_type in ["Natal", "ExternalNatal"]:
636            template_dict["top_left_0"] = f'{self.language_settings["info"]}:'
637        elif self.chart_type == "Composite":
638            template_dict["top_left_0"] = f'{self.user.first_subject.name}'
639
640        # Set additional information for Synastry chart type
641        if self.chart_type == "Synastry":
642            template_dict["top_left_3"] = f"{self.t_user.name}: "
643            template_dict["top_left_4"] = self.t_user.city
644            template_dict["top_left_5"] = f"{self.t_user.year}-{self.t_user.month}-{self.t_user.day} {self.t_user.hour:02d}:{self.t_user.minute:02d}"
645        elif self.chart_type == "Composite":
646            template_dict["top_left_3"] = self.user.second_subject.name
647            template_dict["top_left_4"] = f"{datetime.fromisoformat(self.user.second_subject.iso_formatted_local_datetime).strftime('%Y-%m-%d %H:%M')}"
648            latitude_string = convert_latitude_coordinate_to_string(self.user.second_subject.lat, self.language_settings['north_letter'], self.language_settings['south_letter'])
649            longitude_string = convert_longitude_coordinate_to_string(self.user.second_subject.lng, self.language_settings['east_letter'], self.language_settings['west_letter'])
650            template_dict["top_left_5"] = f"{latitude_string} / {longitude_string}"
651        else:
652            latitude_string = convert_latitude_coordinate_to_string(self.geolat, self.language_settings['north'], self.language_settings['south'])
653            longitude_string = convert_longitude_coordinate_to_string(self.geolon, self.language_settings['east'], self.language_settings['west'])
654            template_dict["top_left_3"] = f"{self.language_settings['latitude']}: {latitude_string}"
655            template_dict["top_left_4"] = f"{self.language_settings['longitude']}: {longitude_string}"
656            template_dict["top_left_5"] = f"{self.language_settings['type']}: {self.language_settings.get(self.chart_type, self.chart_type)}"
657
658
659        # Set paper colors
660        template_dict["paper_color_0"] = self.chart_colors_settings["paper_0"]
661        template_dict["paper_color_1"] = self.chart_colors_settings["paper_1"]
662
663        # Set planet colors
664        for planet in self.planets_settings:
665            planet_id = planet["id"]
666            template_dict[f"planets_color_{planet_id}"] = planet["color"] # type: ignore
667
668        # Set zodiac colors
669        for i in range(12):
670            template_dict[f"zodiac_color_{i}"] = self.chart_colors_settings[f"zodiac_icon_{i}"] # type: ignore
671
672        # Set orb colors
673        for aspect in self.aspects_settings:
674            template_dict[f"orb_color_{aspect['degree']}"] = aspect['color'] # type: ignore
675
676        # Drawing functions
677        template_dict["makeZodiac"] = self._draw_zodiac_circle_slices(self.main_radius)
678
679        first_subject_houses_list = get_houses_list(self.user)
680
681        # Draw houses grid and cusps
682        if self.chart_type in ["Transit", "Synastry"]:
683            second_subject_houses_list = get_houses_list(self.t_user)
684
685            template_dict["makeHousesGrid"] = draw_house_grid(
686                main_subject_houses_list=first_subject_houses_list,
687                secondary_subject_houses_list=second_subject_houses_list,
688                chart_type=self.chart_type,
689                text_color=self.chart_colors_settings["paper_0"],
690                house_cusp_generale_name_label=self.language_settings["cusp"]
691            )
692
693            template_dict["makeHouses"] = draw_houses_cusps_and_text_number(
694                r=self.main_radius,
695                first_subject_houses_list=first_subject_houses_list,
696                standard_house_cusp_color=self.chart_colors_settings["houses_radix_line"],
697                first_house_color=self.planets_settings[12]["color"],
698                tenth_house_color=self.planets_settings[13]["color"],
699                seventh_house_color=self.planets_settings[14]["color"],
700                fourth_house_color=self.planets_settings[15]["color"],
701                c1=self.first_circle_radius,
702                c3=self.third_circle_radius,
703                chart_type=self.chart_type,
704                second_subject_houses_list=second_subject_houses_list,
705                transit_house_cusp_color=self.chart_colors_settings["houses_transit_line"],
706            )
707
708        else:
709            template_dict["makeHousesGrid"] = draw_house_grid(
710                main_subject_houses_list=first_subject_houses_list,
711                chart_type=self.chart_type,
712                text_color=self.chart_colors_settings["paper_0"],
713                house_cusp_generale_name_label=self.language_settings["cusp"]
714            )
715
716            template_dict["makeHouses"] = draw_houses_cusps_and_text_number(
717                r=self.main_radius,
718                first_subject_houses_list=first_subject_houses_list,
719                standard_house_cusp_color=self.chart_colors_settings["houses_radix_line"],
720                first_house_color=self.planets_settings[12]["color"],
721                tenth_house_color=self.planets_settings[13]["color"],
722                seventh_house_color=self.planets_settings[14]["color"],
723                fourth_house_color=self.planets_settings[15]["color"],
724                c1=self.first_circle_radius,
725                c3=self.third_circle_radius,
726                chart_type=self.chart_type,
727            )
728
729        # Draw planets
730        if self.chart_type in ["Transit", "Synastry"]:
731            template_dict["makePlanets"] = draw_planets(
732                available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
733                available_planets_setting=self.available_planets_setting,
734                second_subject_available_kerykeion_celestial_points=self.t_available_kerykeion_celestial_points,
735                radius=self.main_radius,
736                main_subject_first_house_degree_ut=self.user.first_house.abs_pos,
737                main_subject_seventh_house_degree_ut=self.user.seventh_house.abs_pos,
738                chart_type=self.chart_type,
739                third_circle_radius=self.third_circle_radius,
740            )
741        else:
742            template_dict["makePlanets"] = draw_planets(
743                available_planets_setting=self.available_planets_setting,
744                chart_type=self.chart_type,
745                radius=self.main_radius,
746                available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
747                third_circle_radius=self.third_circle_radius,
748                main_subject_first_house_degree_ut=self.user.first_house.abs_pos,
749                main_subject_seventh_house_degree_ut=self.user.seventh_house.abs_pos
750            )
751
752        # Draw elements percentages
753        total = self.fire + self.water + self.earth + self.air
754
755        fire_percentage = int(round(100 * self.fire / total))
756        earth_percentage = int(round(100 * self.earth / total))
757        air_percentage = int(round(100 * self.air / total))
758        water_percentage = int(round(100 * self.water / total))
759
760        template_dict["fire_string"] = f"{self.language_settings['fire']} {fire_percentage}%"
761        template_dict["earth_string"] = f"{self.language_settings['earth']} {earth_percentage}%"
762        template_dict["air_string"] = f"{self.language_settings['air']} {air_percentage}%"
763        template_dict["water_string"] = f"{self.language_settings['water']} {water_percentage}%"
764
765        # Draw planet grid
766        if self.chart_type in ["Transit", "Synastry"]:
767            if self.chart_type == "Transit":
768                second_subject_table_name = self.language_settings["transit_name"]
769            else:
770                second_subject_table_name = self.t_user.name
771
772            template_dict["makePlanetGrid"] = draw_planet_grid(
773                planets_and_houses_grid_title=self.language_settings["planets_and_house"],
774                subject_name=self.user.name,
775                available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
776                chart_type=self.chart_type,
777                text_color=self.chart_colors_settings["paper_0"],
778                celestial_point_language=self.language_settings["celestial_points"],
779                second_subject_name=second_subject_table_name,
780                second_subject_available_kerykeion_celestial_points=self.t_available_kerykeion_celestial_points,
781            )
782        else:
783            if self.chart_type == "Composite":
784                subject_name = f"{self.user.first_subject.name} {self.language_settings['and_word']} {self.user.second_subject.name}"
785            else:
786                subject_name = self.user.name
787
788            template_dict["makePlanetGrid"] = draw_planet_grid(
789                planets_and_houses_grid_title=self.language_settings["planets_and_house"],
790                subject_name=subject_name,
791                available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
792                chart_type=self.chart_type,
793                text_color=self.chart_colors_settings["paper_0"],
794                celestial_point_language=self.language_settings["celestial_points"],
795            )
796
797        # Set date time string
798        if self.chart_type in ["Composite"]:
799            # First Subject Latitude and Longitude
800            latitude = convert_latitude_coordinate_to_string(self.user.first_subject.lat, self.language_settings["north_letter"], self.language_settings["south_letter"])
801            longitude = convert_longitude_coordinate_to_string(self.user.first_subject.lng, self.language_settings["east_letter"], self.language_settings["west_letter"])
802            template_dict["top_left_2"] = f"{latitude} {longitude}"
803        else:
804            dt = datetime.fromisoformat(self.user.iso_formatted_local_datetime)
805            custom_format = dt.strftime('%Y-%m-%d %H:%M [%z]')
806            custom_format = custom_format[:-3] + ':' + custom_format[-3:]
807            template_dict["top_left_2"] = f"{custom_format}"
808
809        return ChartTemplateDictionary(**template_dict)
810
811    def makeTemplate(self, minify: bool = False, remove_css_variables = False) -> str:
812        """
813        Render the full chart SVG as a string.
814
815        Reads the XML template, substitutes variables, and optionally inlines CSS
816        variables and minifies the output.
817
818        Args:
819            minify (bool): Remove whitespace and quotes for compactness.
820            remove_css_variables (bool): Embed CSS variable definitions.
821
822        Returns:
823            str: SVG markup as a string.
824        """
825        td = self._create_template_dictionary()
826
827        DATA_DIR = Path(__file__).parent
828        xml_svg = DATA_DIR / "templates" / "chart.xml"
829
830        # read template
831        with open(xml_svg, "r", encoding="utf-8", errors="ignore") as f:
832            template = Template(f.read()).substitute(td)
833
834        # return filename
835
836        logging.debug(f"Template dictionary keys: {td.keys()}")
837
838        self._create_template_dictionary()
839
840        if remove_css_variables:
841            template = inline_css_variables_in_svg(template)
842
843        if minify:
844            template = scourString(template).replace('"', "'").replace("\n", "").replace("\t","").replace("    ", "").replace("  ", "")
845
846        else:
847            template = template.replace('"', "'")
848
849        return template
850
851    def makeSVG(self, minify: bool = False, remove_css_variables = False):
852        """
853        Generate and save the full chart SVG to disk.
854
855        Calls makeTemplate to render the SVG, then writes a file named
856        "{subject.name} - {chart_type} Chart.svg" in the output directory.
857
858        Args:
859            minify (bool): Pass-through to makeTemplate for compact output.
860            remove_css_variables (bool): Pass-through to makeTemplate to embed CSS variables.
861
862        Returns:
863            None
864        """
865
866        self.template = self.makeTemplate(minify, remove_css_variables)
867
868        chartname = self.output_directory / f"{self.user.name} - {self.chart_type} Chart.svg"
869
870        with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file:
871            output_file.write(self.template)
872
873        print(f"SVG Generated Correctly in: {chartname}")
874
875    def makeWheelOnlyTemplate(self, minify: bool = False, remove_css_variables = False):
876        """
877        Render the wheel-only chart SVG as a string.
878
879        Reads the wheel-only XML template, substitutes chart data, and applies optional
880        CSS inlining and minification.
881
882        Args:
883            minify (bool): Remove whitespace and quotes for compactness.
884            remove_css_variables (bool): Embed CSS variable definitions.
885
886        Returns:
887            str: SVG markup for the chart wheel only.
888        """
889
890        with open(Path(__file__).parent / "templates" / "wheel_only.xml", "r", encoding="utf-8", errors="ignore") as f:
891            template = f.read()
892
893        template_dict = self._create_template_dictionary()
894        template = Template(template).substitute(template_dict)
895
896        if remove_css_variables:
897            template = inline_css_variables_in_svg(template)
898
899        if minify:
900            template = scourString(template).replace('"', "'").replace("\n", "").replace("\t","").replace("    ", "").replace("  ", "")
901
902        else:
903            template = template.replace('"', "'")
904
905        return template
906
907    def makeWheelOnlySVG(self, minify: bool = False, remove_css_variables = False):
908        """
909        Generate and save wheel-only chart SVG to disk.
910
911        Calls makeWheelOnlyTemplate and writes a file named
912        "{subject.name} - {chart_type} Chart - Wheel Only.svg" in the output directory.
913
914        Args:
915            minify (bool): Pass-through to makeWheelOnlyTemplate for compact output.
916            remove_css_variables (bool): Pass-through to makeWheelOnlyTemplate to embed CSS variables.
917
918        Returns:
919            None
920        """
921
922        template = self.makeWheelOnlyTemplate(minify, remove_css_variables)
923        chartname = self.output_directory / f"{self.user.name} - {self.chart_type} Chart - Wheel Only.svg"
924
925        with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file:
926            output_file.write(template)
927
928        print(f"SVG Generated Correctly in: {chartname}")
929
930    def makeAspectGridOnlyTemplate(self, minify: bool = False, remove_css_variables = False):
931        """
932        Render the aspect-grid-only chart SVG as a string.
933
934        Reads the aspect-grid XML template, generates the aspect grid based on chart type,
935        and applies optional CSS inlining and minification.
936
937        Args:
938            minify (bool): Remove whitespace and quotes for compactness.
939            remove_css_variables (bool): Embed CSS variable definitions.
940
941        Returns:
942            str: SVG markup for the aspect grid only.
943        """
944
945        with open(Path(__file__).parent / "templates" / "aspect_grid_only.xml", "r", encoding="utf-8", errors="ignore") as f:
946            template = f.read()
947
948        template_dict = self._create_template_dictionary()
949
950        if self.chart_type in ["Transit", "Synastry"]:
951            aspects_grid = draw_transit_aspect_grid(self.chart_colors_settings['paper_0'], self.available_planets_setting, self.aspects_list)
952        else:
953            aspects_grid = draw_aspect_grid(self.chart_colors_settings['paper_0'], self.available_planets_setting, self.aspects_list, x_start=50, y_start=250)
954
955        template = Template(template).substitute({**template_dict, "makeAspectGrid": aspects_grid})
956
957        if remove_css_variables:
958            template = inline_css_variables_in_svg(template)
959
960        if minify:
961            template = scourString(template).replace('"', "'").replace("\n", "").replace("\t","").replace("    ", "").replace("  ", "")
962
963        else:
964            template = template.replace('"', "'")
965
966        return template
967
968    def makeAspectGridOnlySVG(self, minify: bool = False, remove_css_variables = False):
969        """
970        Generate and save aspect-grid-only chart SVG to disk.
971
972        Calls makeAspectGridOnlyTemplate and writes a file named
973        "{subject.name} - {chart_type} Chart - Aspect Grid Only.svg" in the output directory.
974
975        Args:
976            minify (bool): Pass-through to makeAspectGridOnlyTemplate for compact output.
977            remove_css_variables (bool): Pass-through to makeAspectGridOnlyTemplate to embed CSS variables.
978
979        Returns:
980            None
981        """
982
983        template = self.makeAspectGridOnlyTemplate(minify, remove_css_variables)
984        chartname = self.output_directory / f"{self.user.name} - {self.chart_type} Chart - Aspect Grid Only.svg"
985
986        with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file:
987            output_file.write(template)
988
989        print(f"SVG Generated Correctly in: {chartname}")

KerykeionChartSVG generates astrological chart visualizations as SVG files.

This class supports creating full chart SVGs, wheel-only SVGs, and aspect-grid-only SVGs for various chart types including Natal, ExternalNatal, Transit, Synastry, and Composite. Charts are rendered using XML templates and drawing utilities, with customizable themes, language, active points, and aspects. The rendered SVGs can be saved to a specified output directory or, by default, to the user's home directory.

NOTE: The generated SVG files are optimized for web use, opening in browsers. If you want to use them in other applications, you might need to adjust the SVG settings or styles.

Args: first_obj (AstrologicalSubject | AstrologicalSubjectModel | CompositeSubjectModel): The primary astrological subject for the chart. chart_type (ChartType, optional): The type of chart to generate ('Natal', 'ExternalNatal', 'Transit', 'Synastry', 'Composite'). Defaults to 'Natal'. second_obj (AstrologicalSubject | AstrologicalSubjectModel, optional): The secondary subject for Transit or Synastry charts. Not required for Natal or Composite. new_output_directory (str | Path, optional): Directory to write generated SVG files. Defaults to the user's home directory. new_settings_file (Path | dict | KerykeionSettingsModel, optional): Path or settings object to override default chart configuration (colors, fonts, aspects). theme (KerykeionChartTheme, optional): CSS theme for the chart. If None, no default styles are applied. Defaults to 'classic'. double_chart_aspect_grid_type (Literal['list', 'table'], optional): Specifies rendering style for double-chart aspect grids. Defaults to 'list'. chart_language (KerykeionChartLanguage, optional): Language code for chart labels. Defaults to 'EN'. active_points (list[Planet | AxialCusps], optional): List of celestial points and angles to include. Defaults to DEFAULT_ACTIVE_POINTS. Example: ["Sun", "Moon", "Mercury", "Venus"]

active_aspects (list[ActiveAspect], optional):
    List of aspects (name and orb) to calculate. Defaults to DEFAULT_ACTIVE_ASPECTS.
    Example:
    [
        {"name": "conjunction", "orb": 10},
        {"name": "opposition", "orb": 10},
        {"name": "trine", "orb": 8},
        {"name": "sextile", "orb": 6},
        {"name": "square", "orb": 5},
        {"name": "quintile", "orb": 1},
    ]

Public Methods: makeTemplate(minify=False, remove_css_variables=False) -> str: Render the full chart SVG as a string without writing to disk. Use minify=True to remove whitespace and quotes, and remove_css_variables=True to embed CSS vars.

makeSVG(minify=False, remove_css_variables=False) -> None:
    Generate and write the full chart SVG file to the output directory.
    Filenames follow the pattern:
    '{subject.name} - {chart_type} Chart.svg'.

makeWheelOnlyTemplate(minify=False, remove_css_variables=False) -> str:
    Render only the chart wheel (no aspect grid) as an SVG string.

makeWheelOnlySVG(minify=False, remove_css_variables=False) -> None:
    Generate and write the wheel-only SVG file:
    '{subject.name} - {chart_type} Chart - Wheel Only.svg'.

makeAspectGridOnlyTemplate(minify=False, remove_css_variables=False) -> str:
    Render only the aspect grid as an SVG string.

makeAspectGridOnlySVG(minify=False, remove_css_variables=False) -> None:
    Generate and write the aspect-grid-only SVG file:
    '{subject.name} - {chart_type} Chart - Aspect Grid Only.svg'.
KerykeionChartSVG( first_obj: Union[kerykeion.astrological_subject.AstrologicalSubject, kerykeion.kr_types.kr_models.AstrologicalSubjectModel, kerykeion.kr_types.kr_models.CompositeSubjectModel], chart_type: Literal['Natal', 'ExternalNatal', 'Synastry', 'Transit', 'Composite'] = 'Natal', second_obj: Union[kerykeion.astrological_subject.AstrologicalSubject, kerykeion.kr_types.kr_models.AstrologicalSubjectModel, NoneType] = None, new_output_directory: Optional[str] = None, new_settings_file: Union[pathlib._local.Path, NoneType, kerykeion.kr_types.settings_models.KerykeionSettingsModel, dict] = None, theme: Optional[Literal['light', 'dark', 'dark-high-contrast', 'classic']] = 'classic', double_chart_aspect_grid_type: Literal['list', 'table'] = 'list', chart_language: Literal['EN', 'FR', 'PT', 'IT', 'CN', 'ES', 'RU', 'TR', 'DE', 'HI'] = 'EN', active_points: List[Union[Literal['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_Node', 'True_Node', 'Mean_South_Node', 'True_South_Node', 'Chiron', 'Mean_Lilith'], Literal['Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli']]] = ['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_Node', 'Chiron', 'Ascendant', 'Medium_Coeli', 'Mean_Lilith', 'Mean_South_Node'], active_aspects: List[kerykeion.kr_types.kr_models.ActiveAspect] = [{'name': 'conjunction', 'orb': 10}, {'name': 'opposition', 'orb': 10}, {'name': 'trine', 'orb': 8}, {'name': 'sextile', 'orb': 6}, {'name': 'square', 'orb': 5}, {'name': 'quintile', 'orb': 1}])
169    def __init__(
170        self,
171        first_obj: Union[AstrologicalSubject, AstrologicalSubjectModel, CompositeSubjectModel],
172        chart_type: ChartType = "Natal",
173        second_obj: Union[AstrologicalSubject, AstrologicalSubjectModel, None] = None,
174        new_output_directory: Union[str, None] = None,
175        new_settings_file: Union[Path, None, KerykeionSettingsModel, dict] = None,
176        theme: Union[KerykeionChartTheme, None] = "classic",
177        double_chart_aspect_grid_type: Literal["list", "table"] = "list",
178        chart_language: KerykeionChartLanguage = "EN",
179        active_points: List[Union[Planet, AxialCusps]] = DEFAULT_ACTIVE_POINTS,
180    active_aspects: List[ActiveAspect] = DEFAULT_ACTIVE_ASPECTS,
181    ):
182        """
183        Initialize the chart generator with subject data and configuration options.
184
185        Args:
186            first_obj (AstrologicalSubject, AstrologicalSubjectModel, or CompositeSubjectModel):
187                Primary astrological subject instance.
188            chart_type (ChartType, optional):
189                Type of chart to generate (e.g., 'Natal', 'Transit').
190            second_obj (AstrologicalSubject or AstrologicalSubjectModel, optional):
191                Secondary subject for Transit or Synastry charts.
192            new_output_directory (str or Path, optional):
193                Base directory to save generated SVG files.
194            new_settings_file (Path, dict, or KerykeionSettingsModel, optional):
195                Custom settings source for chart colors, fonts, and aspects.
196            theme (KerykeionChartTheme or None, optional):
197                CSS theme to apply; None for default styling.
198            double_chart_aspect_grid_type (Literal['list','table'], optional):
199                Layout style for double-chart aspect grids ('list' or 'table').
200            chart_language (KerykeionChartLanguage, optional):
201                Language code for chart labels (e.g., 'EN', 'IT').
202            active_points (List[Planet or AxialCusps], optional):
203                Celestial points to include in the chart visualization.
204            active_aspects (List[ActiveAspect], optional):
205                Aspects to calculate, each defined by name and orb.
206        """
207        home_directory = Path.home()
208        self.new_settings_file = new_settings_file
209        self.chart_language = chart_language
210        self.active_points = active_points
211        self.active_aspects = active_aspects
212
213        if new_output_directory:
214            self.output_directory = Path(new_output_directory)
215        else:
216            self.output_directory = home_directory
217
218        self.parse_json_settings(new_settings_file)
219        self.chart_type = chart_type
220
221        # Kerykeion instance
222        self.user = first_obj
223
224        self.available_planets_setting = []
225        for body in self.planets_settings:
226            if body["name"] not in active_points:
227                continue
228            else:
229                body["is_active"] = True
230
231            self.available_planets_setting.append(body)
232
233        # Available bodies
234        available_celestial_points_names = []
235        for body in self.available_planets_setting:
236            available_celestial_points_names.append(body["name"].lower())
237
238        self.available_kerykeion_celestial_points: list[KerykeionPointModel] = []
239        for body in available_celestial_points_names:
240            self.available_kerykeion_celestial_points.append(self.user.get(body))
241
242        # Makes the sign number list.
243        if self.chart_type == "Natal" or self.chart_type == "ExternalNatal":
244            natal_aspects_instance = NatalAspects(
245                self.user, new_settings_file=self.new_settings_file,
246                active_points=active_points,
247                active_aspects=active_aspects,
248            )
249            self.aspects_list = natal_aspects_instance.relevant_aspects
250
251        elif self.chart_type == "Transit" or self.chart_type == "Synastry":
252            if not second_obj:
253                raise KerykeionException("Second object is required for Transit or Synastry charts.")
254
255            # Kerykeion instance
256            self.t_user = second_obj
257
258            # Aspects
259            if self.chart_type == "Transit":
260                synastry_aspects_instance = SynastryAspects(
261                    self.t_user,
262                    self.user,
263                    new_settings_file=self.new_settings_file,
264                    active_points=active_points,
265                    active_aspects=active_aspects,
266                )
267
268            else:
269                synastry_aspects_instance = SynastryAspects(
270                    self.user,
271                    self.t_user,
272                    new_settings_file=self.new_settings_file,
273                    active_points=active_points,
274                    active_aspects=active_aspects,
275                )
276
277            self.aspects_list = synastry_aspects_instance.relevant_aspects
278
279            self.t_available_kerykeion_celestial_points = []
280            for body in available_celestial_points_names:
281                self.t_available_kerykeion_celestial_points.append(self.t_user.get(body))
282
283        elif self.chart_type == "Composite":
284            if not isinstance(first_obj, CompositeSubjectModel):
285                raise KerykeionException("First object must be a CompositeSubjectModel instance.")
286
287            self.aspects_list = NatalAspects(self.user, new_settings_file=self.new_settings_file, active_points=active_points).relevant_aspects
288
289        # Double chart aspect grid type
290        self.double_chart_aspect_grid_type = double_chart_aspect_grid_type
291
292        # screen size
293        self.height = self._DEFAULT_HEIGHT
294        if self.chart_type == "Synastry" or self.chart_type == "Transit":
295            self.width = self._DEFAULT_FULL_WIDTH
296        elif self.double_chart_aspect_grid_type == "table" and self.chart_type == "Transit":
297            self.width = self._DEFAULT_FULL_WIDTH_WITH_TABLE
298        else:
299            self.width = self._DEFAULT_NATAL_WIDTH
300
301        if self.chart_type in ["Natal", "ExternalNatal", "Synastry"]:
302            self.location = self.user.city
303            self.geolat = self.user.lat
304            self.geolon =  self.user.lng
305
306        elif self.chart_type == "Composite":
307            self.location = ""
308            self.geolat = (self.user.first_subject.lat + self.user.second_subject.lat) / 2
309            self.geolon = (self.user.first_subject.lng + self.user.second_subject.lng) / 2
310
311        elif self.chart_type in ["Transit"]:
312            self.location = self.t_user.city
313            self.geolat = self.t_user.lat
314            self.geolon = self.t_user.lng
315            self.t_name = self.language_settings["transit_name"]
316
317        # Default radius for the chart
318        self.main_radius = 240
319
320        # Set circle radii based on chart type
321        if self.chart_type == "ExternalNatal":
322            self.first_circle_radius, self.second_circle_radius, self.third_circle_radius = 56, 92, 112
323        else:
324            self.first_circle_radius, self.second_circle_radius, self.third_circle_radius = 0, 36, 120
325
326        # Initialize element points
327        self.fire = 0.0
328        self.earth = 0.0
329        self.air = 0.0
330        self.water = 0.0
331
332        # Calculate element points from planets
333        self._calculate_elements_points_from_planets()
334
335        # Set up theme
336        if theme not in get_args(KerykeionChartTheme) and theme is not None:
337            raise KerykeionException(f"Theme {theme} is not available. Set None for default theme.")
338
339        self.set_up_theme(theme)

Initialize the chart generator with subject data and configuration options.

Args: first_obj (AstrologicalSubject, AstrologicalSubjectModel, or CompositeSubjectModel): Primary astrological subject instance. chart_type (ChartType, optional): Type of chart to generate (e.g., 'Natal', 'Transit'). second_obj (AstrologicalSubject or AstrologicalSubjectModel, optional): Secondary subject for Transit or Synastry charts. new_output_directory (str or Path, optional): Base directory to save generated SVG files. new_settings_file (Path, dict, or KerykeionSettingsModel, optional): Custom settings source for chart colors, fonts, and aspects. theme (KerykeionChartTheme or None, optional): CSS theme to apply; None for default styling. double_chart_aspect_grid_type (Literal['list','table'], optional): Layout style for double-chart aspect grids ('list' or 'table'). chart_language (KerykeionChartLanguage, optional): Language code for chart labels (e.g., 'EN', 'IT'). active_points (List[Planet or AxialCusps], optional): Celestial points to include in the chart visualization. active_aspects (List[ActiveAspect], optional): Aspects to calculate, each defined by name and orb.

chart_type: Literal['Natal', 'ExternalNatal', 'Synastry', 'Transit', 'Composite']
new_output_directory: Optional[pathlib._local.Path]
new_settings_file: Union[pathlib._local.Path, NoneType, kerykeion.kr_types.settings_models.KerykeionSettingsModel, dict]
output_directory: pathlib._local.Path
theme: Optional[Literal['light', 'dark', 'dark-high-contrast', 'classic']]
double_chart_aspect_grid_type: Literal['list', 'table']
chart_language: Literal['EN', 'FR', 'PT', 'IT', 'CN', 'ES', 'RU', 'TR', 'DE', 'HI']
active_points: List[Union[Literal['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_Node', 'True_Node', 'Mean_South_Node', 'True_South_Node', 'Chiron', 'Mean_Lilith'], Literal['Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli']]]
fire: float
earth: float
air: float
water: float
first_circle_radius: float
second_circle_radius: float
third_circle_radius: float
width: Union[float, int]
language_settings: dict
chart_colors_settings: dict
planets_settings: dict
aspects_settings: dict
height: float
location: str
geolat: float
geolon: float
template: str
available_kerykeion_celestial_points: list[kerykeion.kr_types.kr_models.KerykeionPointModel]
main_radius
def set_up_theme( self, theme: Optional[Literal['light', 'dark', 'dark-high-contrast', 'classic']] = None) -> None:
341    def set_up_theme(self, theme: Union[KerykeionChartTheme, None] = None) -> None:
342        """
343        Load and apply a CSS theme for the chart visualization.
344
345        Args:
346            theme (KerykeionChartTheme or None): Name of the theme to apply. If None, no CSS is applied.
347        """
348        if theme is None:
349            self.color_style_tag = ""
350            return
351
352        theme_dir = Path(__file__).parent / "themes"
353
354        with open(theme_dir / f"{theme}.css", "r") as f:
355            self.color_style_tag = f.read()

Load and apply a CSS theme for the chart visualization.

Args: theme (KerykeionChartTheme or None): Name of the theme to apply. If None, no CSS is applied.

def set_output_directory(self, dir_path: pathlib._local.Path) -> None:
357    def set_output_directory(self, dir_path: Path) -> None:
358        """
359        Set the directory where generated SVG files will be saved.
360
361        Args:
362            dir_path (Path): Target directory for SVG output.
363        """
364        self.output_directory = dir_path
365        logging.info(f"Output direcotry set to: {self.output_directory}")

Set the directory where generated SVG files will be saved.

Args: dir_path (Path): Target directory for SVG output.

def parse_json_settings( self, settings_file_or_dict: Union[pathlib._local.Path, dict, kerykeion.kr_types.settings_models.KerykeionSettingsModel, NoneType]) -> None:
367    def parse_json_settings(self, settings_file_or_dict: Union[Path, dict, KerykeionSettingsModel, None]) -> None:
368        """
369        Load and parse chart configuration settings.
370
371        Args:
372            settings_file_or_dict (Path, dict, or KerykeionSettingsModel):
373                Source for custom chart settings.
374        """
375        settings = get_settings(settings_file_or_dict)
376
377        self.language_settings = settings["language_settings"][self.chart_language]
378        self.chart_colors_settings = settings["chart_colors"]
379        self.planets_settings = settings["celestial_points"]
380        self.aspects_settings = settings["aspects"]

Load and parse chart configuration settings.

Args: settings_file_or_dict (Path, dict, or KerykeionSettingsModel): Source for custom chart settings.

def makeTemplate(self, minify: bool = False, remove_css_variables=False) -> str:
811    def makeTemplate(self, minify: bool = False, remove_css_variables = False) -> str:
812        """
813        Render the full chart SVG as a string.
814
815        Reads the XML template, substitutes variables, and optionally inlines CSS
816        variables and minifies the output.
817
818        Args:
819            minify (bool): Remove whitespace and quotes for compactness.
820            remove_css_variables (bool): Embed CSS variable definitions.
821
822        Returns:
823            str: SVG markup as a string.
824        """
825        td = self._create_template_dictionary()
826
827        DATA_DIR = Path(__file__).parent
828        xml_svg = DATA_DIR / "templates" / "chart.xml"
829
830        # read template
831        with open(xml_svg, "r", encoding="utf-8", errors="ignore") as f:
832            template = Template(f.read()).substitute(td)
833
834        # return filename
835
836        logging.debug(f"Template dictionary keys: {td.keys()}")
837
838        self._create_template_dictionary()
839
840        if remove_css_variables:
841            template = inline_css_variables_in_svg(template)
842
843        if minify:
844            template = scourString(template).replace('"', "'").replace("\n", "").replace("\t","").replace("    ", "").replace("  ", "")
845
846        else:
847            template = template.replace('"', "'")
848
849        return template

Render the full chart SVG as a string.

Reads the XML template, substitutes variables, and optionally inlines CSS variables and minifies the output.

Args: minify (bool): Remove whitespace and quotes for compactness. remove_css_variables (bool): Embed CSS variable definitions.

Returns: str: SVG markup as a string.

def makeSVG(self, minify: bool = False, remove_css_variables=False):
851    def makeSVG(self, minify: bool = False, remove_css_variables = False):
852        """
853        Generate and save the full chart SVG to disk.
854
855        Calls makeTemplate to render the SVG, then writes a file named
856        "{subject.name} - {chart_type} Chart.svg" in the output directory.
857
858        Args:
859            minify (bool): Pass-through to makeTemplate for compact output.
860            remove_css_variables (bool): Pass-through to makeTemplate to embed CSS variables.
861
862        Returns:
863            None
864        """
865
866        self.template = self.makeTemplate(minify, remove_css_variables)
867
868        chartname = self.output_directory / f"{self.user.name} - {self.chart_type} Chart.svg"
869
870        with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file:
871            output_file.write(self.template)
872
873        print(f"SVG Generated Correctly in: {chartname}")

Generate and save the full chart SVG to disk.

Calls makeTemplate to render the SVG, then writes a file named "{subject.name} - {chart_type} Chart.svg" in the output directory.

Args: minify (bool): Pass-through to makeTemplate for compact output. remove_css_variables (bool): Pass-through to makeTemplate to embed CSS variables.

Returns: None

def makeWheelOnlyTemplate(self, minify: bool = False, remove_css_variables=False):
875    def makeWheelOnlyTemplate(self, minify: bool = False, remove_css_variables = False):
876        """
877        Render the wheel-only chart SVG as a string.
878
879        Reads the wheel-only XML template, substitutes chart data, and applies optional
880        CSS inlining and minification.
881
882        Args:
883            minify (bool): Remove whitespace and quotes for compactness.
884            remove_css_variables (bool): Embed CSS variable definitions.
885
886        Returns:
887            str: SVG markup for the chart wheel only.
888        """
889
890        with open(Path(__file__).parent / "templates" / "wheel_only.xml", "r", encoding="utf-8", errors="ignore") as f:
891            template = f.read()
892
893        template_dict = self._create_template_dictionary()
894        template = Template(template).substitute(template_dict)
895
896        if remove_css_variables:
897            template = inline_css_variables_in_svg(template)
898
899        if minify:
900            template = scourString(template).replace('"', "'").replace("\n", "").replace("\t","").replace("    ", "").replace("  ", "")
901
902        else:
903            template = template.replace('"', "'")
904
905        return template

Render the wheel-only chart SVG as a string.

Reads the wheel-only XML template, substitutes chart data, and applies optional CSS inlining and minification.

Args: minify (bool): Remove whitespace and quotes for compactness. remove_css_variables (bool): Embed CSS variable definitions.

Returns: str: SVG markup for the chart wheel only.

def makeWheelOnlySVG(self, minify: bool = False, remove_css_variables=False):
907    def makeWheelOnlySVG(self, minify: bool = False, remove_css_variables = False):
908        """
909        Generate and save wheel-only chart SVG to disk.
910
911        Calls makeWheelOnlyTemplate and writes a file named
912        "{subject.name} - {chart_type} Chart - Wheel Only.svg" in the output directory.
913
914        Args:
915            minify (bool): Pass-through to makeWheelOnlyTemplate for compact output.
916            remove_css_variables (bool): Pass-through to makeWheelOnlyTemplate to embed CSS variables.
917
918        Returns:
919            None
920        """
921
922        template = self.makeWheelOnlyTemplate(minify, remove_css_variables)
923        chartname = self.output_directory / f"{self.user.name} - {self.chart_type} Chart - Wheel Only.svg"
924
925        with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file:
926            output_file.write(template)
927
928        print(f"SVG Generated Correctly in: {chartname}")

Generate and save wheel-only chart SVG to disk.

Calls makeWheelOnlyTemplate and writes a file named "{subject.name} - {chart_type} Chart - Wheel Only.svg" in the output directory.

Args: minify (bool): Pass-through to makeWheelOnlyTemplate for compact output. remove_css_variables (bool): Pass-through to makeWheelOnlyTemplate to embed CSS variables.

Returns: None

def makeAspectGridOnlyTemplate(self, minify: bool = False, remove_css_variables=False):
930    def makeAspectGridOnlyTemplate(self, minify: bool = False, remove_css_variables = False):
931        """
932        Render the aspect-grid-only chart SVG as a string.
933
934        Reads the aspect-grid XML template, generates the aspect grid based on chart type,
935        and applies optional CSS inlining and minification.
936
937        Args:
938            minify (bool): Remove whitespace and quotes for compactness.
939            remove_css_variables (bool): Embed CSS variable definitions.
940
941        Returns:
942            str: SVG markup for the aspect grid only.
943        """
944
945        with open(Path(__file__).parent / "templates" / "aspect_grid_only.xml", "r", encoding="utf-8", errors="ignore") as f:
946            template = f.read()
947
948        template_dict = self._create_template_dictionary()
949
950        if self.chart_type in ["Transit", "Synastry"]:
951            aspects_grid = draw_transit_aspect_grid(self.chart_colors_settings['paper_0'], self.available_planets_setting, self.aspects_list)
952        else:
953            aspects_grid = draw_aspect_grid(self.chart_colors_settings['paper_0'], self.available_planets_setting, self.aspects_list, x_start=50, y_start=250)
954
955        template = Template(template).substitute({**template_dict, "makeAspectGrid": aspects_grid})
956
957        if remove_css_variables:
958            template = inline_css_variables_in_svg(template)
959
960        if minify:
961            template = scourString(template).replace('"', "'").replace("\n", "").replace("\t","").replace("    ", "").replace("  ", "")
962
963        else:
964            template = template.replace('"', "'")
965
966        return template

Render the aspect-grid-only chart SVG as a string.

Reads the aspect-grid XML template, generates the aspect grid based on chart type, and applies optional CSS inlining and minification.

Args: minify (bool): Remove whitespace and quotes for compactness. remove_css_variables (bool): Embed CSS variable definitions.

Returns: str: SVG markup for the aspect grid only.

def makeAspectGridOnlySVG(self, minify: bool = False, remove_css_variables=False):
968    def makeAspectGridOnlySVG(self, minify: bool = False, remove_css_variables = False):
969        """
970        Generate and save aspect-grid-only chart SVG to disk.
971
972        Calls makeAspectGridOnlyTemplate and writes a file named
973        "{subject.name} - {chart_type} Chart - Aspect Grid Only.svg" in the output directory.
974
975        Args:
976            minify (bool): Pass-through to makeAspectGridOnlyTemplate for compact output.
977            remove_css_variables (bool): Pass-through to makeAspectGridOnlyTemplate to embed CSS variables.
978
979        Returns:
980            None
981        """
982
983        template = self.makeAspectGridOnlyTemplate(minify, remove_css_variables)
984        chartname = self.output_directory / f"{self.user.name} - {self.chart_type} Chart - Aspect Grid Only.svg"
985
986        with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file:
987            output_file.write(template)
988
989        print(f"SVG Generated Correctly in: {chartname}")

Generate and save aspect-grid-only chart SVG to disk.

Calls makeAspectGridOnlyTemplate and writes a file named "{subject.name} - {chart_type} Chart - Aspect Grid Only.svg" in the output directory.

Args: minify (bool): Pass-through to makeAspectGridOnlyTemplate for compact output. remove_css_variables (bool): Pass-through to makeAspectGridOnlyTemplate to embed CSS variables.

Returns: None