Source code for stellium.planner.renderer

"""
PlannerRenderer - Generate beautiful PDF planners using Typst.

This module handles:
- Generating front matter (charts, ZR timeline, graphic ephemeris)
- Rendering daily pages with events
- Typst compilation to PDF
"""

from __future__ import annotations

import os
import tempfile
from dataclasses import dataclass
from datetime import date
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from stellium.planner.builder import PlannerConfig

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

    TYPST_AVAILABLE = True
except ImportError:
    TYPST_AVAILABLE = False


[docs] @dataclass class ChartPaths: """Paths to generated chart SVG files.""" natal: str | None = None progressed: str | None = None solar_return: str | None = None graphic_ephemeris: str | None = None zr_overview: str | None = None zr_timeline: str | None = None profection_wheel: str | None = None profection_table: str | None = None
[docs] class PlannerRenderer: """ Renders PDF planners using Typst typesetting. Generates: - Title page with planner info - Front matter section with charts - Month overview pages - Daily pages with transit events """ def __init__(self, config: PlannerConfig) -> None: """ Initialize renderer with configuration. Args: config: PlannerConfig from PlannerBuilder """ if not TYPST_AVAILABLE: raise ImportError( "Typst library not available. Install with: pip install typst" ) self.config = config self._temp_dir: str | None = None self._chart_paths = ChartPaths()
[docs] def render(self) -> bytes: """ Render the complete planner to PDF. Returns: PDF as bytes """ # Create temp directory for chart files self._temp_dir = tempfile.mkdtemp(prefix="stellium_planner_") try: # Generate charts self._generate_charts() # Collect events events_by_date = self._collect_events() # Generate Typst document typst_content = self._generate_typst_document(events_by_date) # Write to temp file and compile typst_path = os.path.join(self._temp_dir, "planner.typ") with open(typst_path, "w", encoding="utf-8") as f: f.write(typst_content) # Get font directories font_dirs = self._get_font_dirs() # Compile to PDF. Use the temp directory as the Typst project # root — every file referenced by the document (charts, SVGs) is # generated inside _temp_dir, and this avoids platform-specific # issues with root="/" on Windows where the temp dir may live on # a different drive than the POSIX root. pdf_bytes = typst_lib.compile( typst_path, root=self._temp_dir, font_paths=font_dirs, ) return pdf_bytes finally: # Clean up temp files self._cleanup_temp_files()
def _get_font_dirs(self) -> list[str]: """Get font directories for Typst compilation.""" base_font_dir = os.path.join( os.path.dirname( os.path.dirname(os.path.dirname(os.path.dirname(__file__))) ), "assets", "fonts", ) return [ base_font_dir, os.path.join(base_font_dir, "Cinzel_Decorative"), os.path.join(base_font_dir, "Crimson_Pro"), os.path.join(base_font_dir, "Crimson_Pro", "static"), ] def _cleanup_temp_files(self) -> None: """Clean up temporary files.""" import shutil if self._temp_dir and os.path.exists(self._temp_dir): shutil.rmtree(self._temp_dir) def _generate_charts(self) -> None: """Generate chart SVG files for front matter.""" from stellium.core.builder import ChartBuilder # Generate natal chart natal_chart = ChartBuilder.from_native(self.config.native).calculate() if self.config.include_natal_chart: natal_path = os.path.join(self._temp_dir, "natal.svg") natal_chart.draw(natal_path).with_zodiac_palette("rainbow").save() self._chart_paths.natal = natal_path # Generate progressed chart if self.config.include_progressed_chart: from stellium.core.multichart import MultiChartBuilder # Calculate age at start of planner birth_date = self.config.native.datetime.local_datetime.date() planner_start = self.config.start_date age = planner_start.year - birth_date.year if (planner_start.month, planner_start.day) < ( birth_date.month, birth_date.day, ): age -= 1 try: mc = MultiChartBuilder.progression(natal_chart, age=age).calculate() progressed_path = os.path.join(self._temp_dir, "progressed.svg") mc.draw(progressed_path).with_zodiac_palette("rainbow").save() self._chart_paths.progressed = progressed_path except Exception: pass # Skip if progression fails # Generate solar return if self.config.include_solar_return: from stellium.returns import ReturnBuilder try: sr_year = self.config.year or self.config.start_date.year sr_chart = ReturnBuilder.solar(natal_chart, sr_year).calculate() sr_path = os.path.join(self._temp_dir, "solar_return.svg") sr_chart.draw(sr_path).with_zodiac_palette("rainbow").save() self._chart_paths.solar_return = sr_path except Exception: pass # Generate graphic ephemeris with natal chart overlay if self.config.include_graphic_ephemeris: from stellium.visualization import GraphicEphemeris try: start_str = self.config.start_date.isoformat() end_str = self.config.end_date.isoformat() eph = GraphicEphemeris( start_date=start_str, end_date=end_str, harmonic=self.config.graphic_ephemeris_harmonic, natal_chart=natal_chart, # Include natal positions for transit aspects ) eph_path = os.path.join(self._temp_dir, "ephemeris.svg") eph.draw(eph_path) self._chart_paths.graphic_ephemeris = eph_path except Exception: pass # Generate ZR visualization if self.config.include_zr_timeline: self._generate_zr_charts(natal_chart) # Generate profection wheel visualization if self.config.include_profections: self._generate_profection_charts(natal_chart) def _generate_zr_charts(self, natal_chart) -> None: """Generate Zodiacal Releasing visualization SVGs.""" from stellium.core.builder import ChartBuilder from stellium.engines.releasing import ZodiacalReleasingAnalyzer from stellium.presentation.sections.zr_visualization import ( ZRVisualizationSection, ) try: # Re-calculate chart with ZR analyzer if not present if "zodiacal_releasing" not in natal_chart.metadata: natal_chart = ( ChartBuilder.from_native(self.config.native) .add_analyzer(ZodiacalReleasingAnalyzer([self.config.zr_lot])) .calculate() ) # Generate overview SVG overview_section = ZRVisualizationSection( lot=self.config.zr_lot, year=self.config.year or self.config.start_date.year, output="overview", ) overview_data = overview_section.generate_data(natal_chart) if overview_data.get("type") == "svg": overview_path = os.path.join(self._temp_dir, "zr_overview.svg") with open(overview_path, "w", encoding="utf-8") as f: f.write(overview_data["content"]) self._chart_paths.zr_overview = overview_path # Generate timeline SVG timeline_section = ZRVisualizationSection( lot=self.config.zr_lot, year=self.config.year or self.config.start_date.year, levels=(1, 2, 3), output="timeline", ) timeline_data = timeline_section.generate_data(natal_chart) if timeline_data.get("type") == "svg": timeline_path = os.path.join(self._temp_dir, "zr_timeline.svg") with open(timeline_path, "w", encoding="utf-8") as f: f.write(timeline_data["content"]) self._chart_paths.zr_timeline = timeline_path except Exception: pass # Skip if ZR generation fails def _generate_profection_charts(self, natal_chart) -> None: """Generate profection wheel visualization SVGs.""" from stellium.presentation.sections.profection_visualization import ( ProfectionVisualizationSection, ) try: # Calculate age at planner start birth_date = self.config.native.datetime.local_datetime.date() planner_start = self.config.start_date age = planner_start.year - birth_date.year if (planner_start.month, planner_start.day) < ( birth_date.month, birth_date.day, ): age -= 1 # Generate wheel SVG wheel_section = ProfectionVisualizationSection( age=age, compare_ages=[age, age + 1], show_wheel=True, show_table=False, ) wheel_data = wheel_section.generate_data(natal_chart) # Handle compound or direct SVG response if wheel_data.get("type") == "compound": for name, subdata in wheel_data.get("sections", []): if "Wheel" in name and subdata.get("type") == "svg": wheel_path = os.path.join( self._temp_dir, "profection_wheel.svg" ) with open(wheel_path, "w", encoding="utf-8") as f: f.write(subdata["content"]) self._chart_paths.profection_wheel = wheel_path break elif wheel_data.get("type") == "svg": wheel_path = os.path.join(self._temp_dir, "profection_wheel.svg") with open(wheel_path, "w", encoding="utf-8") as f: f.write(wheel_data["content"]) self._chart_paths.profection_wheel = wheel_path # Generate table SVG table_section = ProfectionVisualizationSection( age=age, compare_ages=[age, age + 1], show_wheel=False, show_table=True, ) table_data = table_section.generate_data(natal_chart) # Handle compound or direct SVG response if table_data.get("type") == "compound": for name, subdata in table_data.get("sections", []): if "Details" in name and subdata.get("type") == "svg": table_path = os.path.join( self._temp_dir, "profection_table.svg" ) with open(table_path, "w", encoding="utf-8") as f: f.write(subdata["content"]) self._chart_paths.profection_table = table_path break elif table_data.get("type") == "svg": table_path = os.path.join(self._temp_dir, "profection_table.svg") with open(table_path, "w", encoding="utf-8") as f: f.write(table_data["content"]) self._chart_paths.profection_table = table_path except Exception: pass # Skip if profection generation fails def _collect_events(self) -> dict[date, list]: """Collect all events for the planner period.""" from stellium.core.builder import ChartBuilder from stellium.planner.events import DailyEventCollector natal_chart = ChartBuilder.from_native(self.config.native).calculate() collector = DailyEventCollector( natal_chart=natal_chart, start=self.config.start_date, end=self.config.end_date, timezone=self.config.timezone, ) collector.collect_all( natal_transits=self.config.natal_transit_planets is not None or self.config.include_mundane_transits, transit_planets=self.config.natal_transit_planets, ingresses=self.config.ingress_planets is not None, ingress_planets=self.config.ingress_planets, stations=self.config.station_planets is not None, station_planets=self.config.station_planets, moon_phases=self.config.include_moon_phases, voc=self.config.include_voc, voc_mode=self.config.voc_mode, eclipses=True, ) return collector._events_by_date def _generate_typst_document(self, events_by_date: dict[date, list]) -> str: """Generate complete Typst document.""" parts = [] # Document preamble parts.append(self._get_preamble()) # Title page parts.append(self._render_title_page()) # Front matter parts.append(self._render_front_matter()) # Monthly pages parts.append(self._render_monthly_pages(events_by_date)) return "\n".join(parts) def _get_preamble(self) -> str: """Get Typst document preamble with styling.""" paper_name = { "a4": "a4", "a5": "a5", "letter": "us-letter", "half-letter": "a5", # Use A5 as closest standard size }.get(self.config.page_size, "a4") page_setup = f'paper: "{paper_name}"' binding = self.config.binding_margin return f"""// Stellium Astrological Planner // Generated with Typst // ============================================================================ // COLOR PALETTE - Warm mystical purple theme // ============================================================================ #let primary = rgb("#4a3353") #let secondary = rgb("#6b4d6e") #let accent = rgb("#8e6b8a") #let gold = rgb("#b8953d") #let cream = rgb("#faf8f5") #let text-dark = rgb("#2d2330") // ============================================================================ // PAGE SETUP // ============================================================================ #set page( {page_setup}, margin: (top: 0.6in, bottom: 0.6in, left: {0.7 + binding}in, right: 0.7in), fill: cream, header: context {{ if counter(page).get().first() > 2 [ #set text(font: "Cinzel Decorative", size: 7pt, fill: accent, tracking: 0.5pt) #h(1fr) Astrological Planner #h(1fr) ] }}, footer: context {{ set text(size: 7pt, fill: accent) h(1fr) counter(page).display("1") h(1fr) }}, ) // ============================================================================ // TYPOGRAPHY // ============================================================================ #set text( font: ("Crimson Pro", "Noto Sans Symbols 2", "Noto Sans Symbols", "Georgia", "serif"), size: 9pt, fill: text-dark, ) #set par(justify: true, leading: 0.7em) // Heading styles #show heading.where(level: 1): it => {{ set text(font: "Cinzel Decorative", size: 22pt, weight: "regular", fill: primary, tracking: 1.5pt) set par(justify: false) align(center)[#it.body] v(0.4em) }} #show heading.where(level: 2): it => {{ v(0.8em) block( width: 100%, fill: primary, inset: (x: 10pt, y: 6pt), radius: 2pt, )[ #set text(font: "Cinzel Decorative", size: 9pt, weight: "regular", fill: white, tracking: 0.5pt) #sym.star.stroked #it.body ] v(0.4em) }} #show heading.where(level: 3): it => {{ set text(font: "Cinzel Decorative", size: 9pt, weight: "regular", fill: secondary) v(0.3em) it.body v(0.2em) }} // ============================================================================ // HELPER FUNCTIONS // ============================================================================ #let star-divider = {{ set align(center) v(0.1in) box(width: 60%)[ #grid( columns: (1fr, auto, 1fr), align: (right, center, left), column-gutter: 8pt, line(length: 100%, stroke: 0.6pt + gold), text(fill: gold, size: 8pt)[#sym.star.stroked], line(length: 100%, stroke: 0.6pt + gold), ) ] v(0.1in) }} #let day-header(day-name, date-str, moon-info) = {{ block( width: 100%, fill: primary, inset: (x: 8pt, y: 5pt), radius: 2pt, )[ #set text(fill: white, size: 8pt) #text(font: "Cinzel Decorative", weight: "regular")[#day-name, #date-str] #h(1fr) #text(fill: cream.lighten(20%), size: 7pt)[#moon-info] ] }} #let event-row(time-str, event-text) = {{ grid( columns: (50pt, 1fr), gutter: 6pt, text(fill: secondary, size: 8pt)[#time-str], text(size: 8pt)[#event-text], ) }} """ def _render_title_page(self) -> str: """Render the title page.""" native = self.config.native name = getattr(native, "name", "Personal") or "Personal" year_str = "" if self.config.year: year_str = str(self.config.year) else: year_str = f"{self.config.start_date} - {self.config.end_date}" return f""" // ============================================================================ // TITLE PAGE // ============================================================================ #v(1.5in) #star-divider = Astrological Planner #star-divider #v(0.3in) #align(center)[ #text(font: "Cinzel Decorative", size: 14pt, fill: secondary)[ {self._escape(name)} ] #v(0.2in) #text(size: 11pt, fill: accent)[ {year_str} ] ] #v(1fr) #align(center)[ #text(font: "Cinzel Decorative", size: 8pt, fill: accent, style: "italic")[ Generated with Stellium ] ] #pagebreak() """ def _render_front_matter(self) -> str: """Render front matter pages with charts.""" parts = [] parts.append(""" // ============================================================================ // FRONT MATTER // ============================================================================ """) # Natal chart if self._chart_paths.natal: parts.append(f""" == Natal Chart #align(center)[ #box( stroke: 1pt + gold, radius: 4pt, clip: true, inset: 8pt, fill: white, image("{os.path.basename(self._chart_paths.natal)}", width: 85%) ) ] #pagebreak() """) # Progressed chart if self._chart_paths.progressed: parts.append(f""" == Progressed Chart #align(center)[ #box( stroke: 1pt + gold, radius: 4pt, clip: true, inset: 8pt, fill: white, image("{os.path.basename(self._chart_paths.progressed)}", width: 85%) ) ] #pagebreak() """) # Solar Return if self._chart_paths.solar_return: year = self.config.year or self.config.start_date.year parts.append(f""" == Solar Return {year} #align(center)[ #box( stroke: 1pt + gold, radius: 4pt, clip: true, inset: 8pt, fill: white, image("{os.path.basename(self._chart_paths.solar_return)}", width: 85%) ) ] #pagebreak() """) # Profections if self.config.include_profections: parts.append(self._render_profections()) # Zodiacal Releasing if self._chart_paths.zr_overview: lot_name = self.config.zr_lot.replace("Part of ", "") parts.append(f""" == Zodiacal Releasing Overview #align(center)[ #box( stroke: 1pt + gold, radius: 4pt, clip: true, inset: 8pt, fill: white, image("{os.path.basename(self._chart_paths.zr_overview)}", width: 95%) ) ] #pagebreak() """) if self._chart_paths.zr_timeline: lot_name = self.config.zr_lot.replace("Part of ", "") parts.append(f""" == Zodiacal Releasing from {lot_name} #align(center)[ #box( stroke: 1pt + gold, radius: 4pt, clip: true, inset: 8pt, fill: white, image("{os.path.basename(self._chart_paths.zr_timeline)}", width: 95%) ) ] #pagebreak() """) # Graphic Ephemeris if self._chart_paths.graphic_ephemeris: parts.append(f""" == Graphic Ephemeris #align(center)[ #box( stroke: 1pt + gold, radius: 4pt, clip: true, inset: 8pt, fill: white, image("{os.path.basename(self._chart_paths.graphic_ephemeris)}", width: 95%) ) ] #pagebreak() """) return "\n".join(parts) def _render_profections(self) -> str: """Render profection information with wheel visualization.""" parts = [] # If we have the wheel visualization, use it if self._chart_paths.profection_wheel: parts.append(f""" == Annual Profections #align(center)[ #box( stroke: 1pt + gold, radius: 4pt, clip: true, inset: 8pt, fill: white, image("{os.path.basename(self._chart_paths.profection_wheel)}", width: 95%) ) ] """) # If we have the table visualization, add it if self._chart_paths.profection_table: parts.append(f""" #v(0.3in) #align(center)[ #box( stroke: 1pt + gold, radius: 4pt, clip: true, inset: 8pt, fill: white, image("{os.path.basename(self._chart_paths.profection_table)}", width: 95%) ) ] """) # If we have visualizations, add explanation and page break if parts: parts.append(""" #v(0.3in) #text(size: 8pt, fill: accent)[ Annual profections advance the Ascendant one sign per year of life. The planet ruling that sign becomes the Lord of the Year, indicating themes and areas of focus for this annual period. The wheel shows ages 0-95 spiraling through the 12 houses. ] #pagebreak() """) return "\n".join(parts) # Fallback to text-only version if visualizations aren't available from stellium.core.builder import ChartBuilder from stellium.engines.profections import ProfectionEngine natal_chart = ChartBuilder.from_native(self.config.native).calculate() engine = ProfectionEngine(natal_chart) # Calculate age at planner start birth_date = self.config.native.datetime.local_datetime.date() planner_start = self.config.start_date age = planner_start.year - birth_date.year if (planner_start.month, planner_start.day) < ( birth_date.month, birth_date.day, ): age -= 1 try: result = engine.annual(age) profected_sign = result.profected_sign lord = result.ruler except Exception: profected_sign = "Unknown" lord = "Unknown" return f""" == Annual Profection #block( fill: rgb("#f9f6f7"), inset: 12pt, radius: 4pt, width: 100%, )[ #grid( columns: (120pt, 1fr), gutter: 8pt, row-gutter: 10pt, [#text(fill: secondary, weight: "semibold")[Age:]], [{age}], [#text(fill: secondary, weight: "semibold")[Profected Sign:]], [{profected_sign}], [#text(fill: secondary, weight: "semibold")[Lord of the Year:]], [{lord}], ) ] #v(0.5em) #text(size: 8pt, fill: accent)[ The Lord of the Year indicates themes and areas of focus for this annual period. Transits to and from {lord} may be especially significant. ] #pagebreak() """ def _render_monthly_pages(self, events_by_date: dict[date, list]) -> str: """Render month calendar grids and weekly detail pages.""" parts = [] parts.append(""" // ============================================================================ // CALENDAR PAGES // ============================================================================ """) # Process each month in the range current_month_start = self.config.start_date.replace(day=1) while current_month_start <= self.config.end_date: # Month calendar grid parts.append( self._render_month_calendar(current_month_start, events_by_date) ) # Weekly detail pages for this month parts.append(self._render_month_weeks(current_month_start, events_by_date)) # Move to next month if current_month_start.month == 12: current_month_start = current_month_start.replace( year=current_month_start.year + 1, month=1 ) else: current_month_start = current_month_start.replace( month=current_month_start.month + 1 ) return "\n".join(parts) def _get_week_start_day(self) -> int: """Get Python calendar firstweekday from config.""" # Python calendar: 0=Monday, 6=Sunday return 6 if self.config.week_starts_on == "sunday" else 0 def _get_day_headers(self) -> list[str]: """Get day header names based on week start.""" if self.config.week_starts_on == "sunday": return ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] else: return ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] def _render_month_calendar( self, month_start: date, events_by_date: dict[date, list] ) -> str: """Render a full-page month calendar grid with all events.""" import calendar month_name = month_start.strftime("%B %Y") cal = calendar.Calendar(firstweekday=self._get_week_start_day()) # Get all days in month (including padding from prev/next months) month_days = list(cal.itermonthdates(month_start.year, month_start.month)) # Build week rows weeks = [] for i in range(0, len(month_days), 7): week = month_days[i : i + 7] weeks.append(week) num_weeks = len(weeks) day_headers = self._get_day_headers() # Build day cells with full event listings day_cells = [] for week in weeks: for day in week: is_current_month = day.month == month_start.month events = events_by_date.get(day, []) if is_current_month else [] # Build cell content with ALL events (single column, may clip) if is_current_month: # Format each event line event_lines = [] for evt in events: time_str = evt.time.strftime("%I:%M").lstrip("0") am_pm = evt.time.strftime("%p").lower()[0] # 'a' or 'p' event_lines.append( f"#text(size: 5.5pt, fill: secondary)[{time_str}{am_pm}] " f"#text(size: 5.5pt)[{self._escape(evt.symbol)}]" ) # Single column for month grid (space is limited) events_display = ( " #linebreak() ".join(event_lines) if event_lines else "" ) # Day number prominent at top, events below cell = f"""table.cell( fill: cream, inset: 4pt, )[ #align(left)[ #text(size: 11pt, weight: "bold", fill: primary)[{day.day}] ] #v(2pt) {events_display} ]""" else: # Gray out days from other months cell = f"""table.cell( fill: rgb("#f5f3f0"), inset: 4pt, )[ #align(left)[ #text(size: 11pt, fill: accent)[{day.day}] ] ]""" day_cells.append(cell) rows_str = ",\n ".join(day_cells) # Build header row with correct day order header_cells = ",\n ".join( f'table.cell(fill: primary, inset: 6pt)[#align(center)[#text(fill: white, size: 8pt, weight: "bold")[{d}]]]' for d in day_headers ) return f""" #pagebreak() // Month: {month_name} #block(height: 100%)[ #align(center)[ #text(font: "Cinzel Decorative", size: 18pt, fill: primary, tracking: 1pt)[{month_name}] ] #v(0.12in) // Full-page calendar grid #block( width: 100%, height: 1fr, radius: 6pt, clip: true, stroke: 1pt + primary, )[ #table( columns: (1fr, 1fr, 1fr, 1fr, 1fr, 1fr, 1fr), rows: (auto, 1fr, 1fr, 1fr, 1fr, 1fr, {"1fr, " if num_weeks > 5 else ""}), stroke: 0.5pt + accent, align: left + top, // Header row {header_cells}, // Day cells {rows_str} ) ] ] """ def _render_month_weeks( self, month_start: date, events_by_date: dict[date, list] ) -> str: """Render weekly detail pages for a month.""" import calendar parts = [] cal = calendar.Calendar(firstweekday=self._get_week_start_day()) # Get weeks that contain days from this month month_days = list(cal.itermonthdates(month_start.year, month_start.month)) for i in range(0, len(month_days), 7): week = month_days[i : i + 7] # Only render week if it contains days from this month if any(d.month == month_start.month for d in week): parts.append(self._render_week_page(week, month_start, events_by_date)) return "\n".join(parts) def _render_week_page( self, week: list[date], month_start: date, events_by_date: dict[date, list] ) -> str: """Render a single week as a full page with 7 day boxes.""" # Week date range for header week_start = week[0] week_end = week[-1] week_header = ( f"{week_start.strftime('%b %d')} - {week_end.strftime('%b %d, %Y')}" ) # Build day boxes day_boxes = [] for day in week: is_current_month = day.month == month_start.month events = events_by_date.get(day, []) # Day header day_name = day.strftime("%A") day_num = day.day # Format events - single column, tight spacing event_lines = [] for event in events: time_str = event.time.strftime("%I:%M%p").lower().lstrip("0") event_lines.append( f"#text(fill: secondary, size: 6pt)[{time_str}] #text(size: 6pt)[{self._escape(event.description)}]" ) if event_lines: # Use tight spacing - set leading to minimal and join with linebreaks events_str = "#set par(leading: 0.3em)\n " + " #linebreak() ".join( event_lines ) else: events_str = ( '#text(fill: accent, size: 6pt, style: "italic")[No events]' ) if is_current_month: fill_color = "cream" text_color = "text-dark" else: fill_color = 'rgb("#f0ede8")' text_color = "accent" day_boxes.append(f""" box( width: 100%, height: 100%, stroke: 0.5pt + accent, radius: 2pt, fill: {fill_color}, inset: 5pt, clip: true, )[ #grid( columns: (1fr, auto), [#text(font: "Cinzel Decorative", size: 8pt, fill: {text_color})[{day_name}]], [#text(size: 12pt, weight: "bold", fill: {text_color})[{day_num}]], ) #line(length: 100%, stroke: 0.3pt + accent) #v(2pt) {events_str} ],""") days_content = "\n".join(day_boxes) return f""" #pagebreak() // Week: {week_header} #align(center)[ #text(font: "Cinzel Decorative", size: 11pt, fill: secondary)[{week_header}] ] #v(0.05in) #block(height: 1fr)[ #grid( columns: (1fr,), rows: (1fr, 1fr, 1fr, 1fr, 1fr, 1fr, 1fr), row-gutter: 3pt, {days_content} ) ] """ def _render_day(self, day: date, events: list) -> str: """Render a single day entry (legacy - kept for reference).""" day_name = day.strftime("%A") date_str = day.strftime("%B %d") # Get moon info (simplified for now) moon_info = "" # TODO: Add moon sign/phase # Format events event_rows = [] for event in events: time_str = event.time.strftime("%I:%M %p").lstrip("0") event_rows.append( f' #event-row("{time_str}", "{self._escape(event.description)}")' ) events_content = ( "\n".join(event_rows) if event_rows else " #text(fill: accent, size: 7pt)[No major transits]" ) return f""" #day-header("{day_name}", "{date_str}", "{moon_info}") #v(3pt) {events_content} #v(6pt) """ def _escape(self, text: str) -> str: """Escape text for Typst.""" if not text: return "" # Escape special Typst characters text = text.replace("\\", "\\\\") text = text.replace("#", "\\#") text = text.replace("$", "\\$") text = text.replace("@", "\\@") text = text.replace("<", "\\<") text = text.replace(">", "\\>") text = text.replace("[", "\\[") text = text.replace("]", "\\]") text = text.replace("{", "\\{") text = text.replace("}", "\\}") text = text.replace("_", "\\_") text = text.replace("*", "\\*") text = text.replace('"', '\\"') return text