Sfoglia il codice sorgente

refactor: embed Astronomicon font, add PNG/JPG output, split chart modules, fix datetime convention

- Extract chart styles/proportions/aspect-styles to chart_styles.py
- Extract font embedding + image conversion to chart_helpers.py
- Embed Astronomicon.ttf as base64 data URI in SVG output
- Add format parameter (svg/png/jpg) to all render tools
- Split chart_renderer into chart_font + chart_styles + chart_helpers + chart_renderer
- Fix persons DB datetime convention: naive local + IANA timezone (not UTC)
- _get_person_birth_data returns naive local + timezone, _byId tools return local in response
- Fix incorrect DB entries for chaka and einstein that had wrong offsets
- Add AGENTS.md with datetime convention reference
- Update server-guide.md and PROJECT.md with datetime convention docs
- Add pillow and cairosvg to requirements.txt
Lukas Goldschmidt 2 settimane fa
parent
commit
2a531c6c1b

+ 65 - 0
AGENTS.md

@@ -0,0 +1,65 @@
+# AGENTS.md — Astro-MCP Agent Guide
+
+## Datetime Convention (CRITICAL)
+
+This server has **two distinct datetime formats**. Using the wrong one produces
+silent errors.
+
+### 1. Person Database (persons table)
+
+- `birth_datetime`: **naive local time** — no `Z`, no `+01:00`
+- `timezone`: IANA name (e.g. `"America/Chicago"`, `"Europe/Berlin"`)
+
+Example: Chaka Khan born 9:05 PM in Chicago → store as:
+- `birth_datetime = "1953-03-23T21:05:00"`
+- `timezone = "America/Chicago"`
+
+The conversion to UTC happens inside `_get_person_birth_data()` using
+`zoneinfo.ZoneInfo`. Do NOT pre-convert to UTC before storing.
+
+### 2. Direct-Call Tools (calculate_natal_chart, render_natal_chart, etc.)
+
+- `birth_datetime`: **UTC or offset-aware** ISO 8601
+
+Examples:
+- UTC: `"1990-05-15T10:30:00Z"` or `"1990-05-15T10:30:00+00:00"`
+- Offset-aware: `"1990-05-15T12:30:00+02:00"`
+
+These tools pass the datetime directly to the ephemeris server. No timezone
+conversion is performed.
+
+### 3. _byId Tools (calculate_natal_chart_by_id, render_natal_chart_by_id)
+
+- Pass `person_id` (or nickname) only.
+- Timezone conversion is handled automatically by `_get_person_birth_data()`.
+
+### Historical Dates (pre-1890)
+
+Before standardized timezones, IANA data uses Local Mean Time (LMT) which can
+differ from modern UTC offsets by minutes. For example:
+- Einstein, 1879, Ulm: LMT = UTC+0:53:28 (not modern CET UTC+1)
+- The `zoneinfo` module handles this correctly when given the IANA name.
+
+Always provide the `tz` field with the IANA name. Do NOT compute the UTC offset
+manually for historical dates.
+
+## Quick Reference
+
+| Context | Format | Example |
+|---------|--------|---------|
+| DB `birth_datetime` | Naive local | `"1953-03-23T21:05:00"` |
+| DB `timezone` | IANA name | `"America/Chicago"` |
+| Direct-call tools | UTC or offset-aware | `"1953-03-24T03:05:00Z"` |
+| _byId tools | person_id only | `"chaka"` |
+
+## Common Mistakes
+
+1. **Storing UTC in the DB** — produces wrong charts because `_get_person_birth_data()`
+   expects local time. If you store `"03:05:00"` (UTC) without a timezone, the
+   server treats it as 3:05 AM local Chicago = 08:05 UTC (wrong by 5 hours).
+2. **Storing offset-aware datetime in the DB** — if `birth_datetime` has an offset
+   (e.g. `"-07:05"`), `_normalize_datetime()` uses that offset directly and
+   ignores the `timezone` column. The offset may be wrong (e.g. Chicago is CST
+   = UTC-6, not UTC-7).
+3. **Missing `tz` field** — falls back to LMT from longitude, which is approximately
+   correct but not precise. Always set `tz`.

+ 14 - 2
PROJECT.md

@@ -74,15 +74,27 @@ No `pyswisseph` -- all astronomical data comes from ephemeris-mcp via MCP client
        id TEXT PRIMARY KEY,
        id TEXT PRIMARY KEY,
        name TEXT NOT NULL,
        name TEXT NOT NULL,
        nickname TEXT UNIQUE,
        nickname TEXT UNIQUE,
-       birth_datetime TEXT NOT NULL,
+       birth_datetime TEXT NOT NULL,  -- naive LOCAL time (no offset)
+       birthplace TEXT,
        latitude REAL NOT NULL,
        latitude REAL NOT NULL,
        longitude REAL NOT NULL,
        longitude REAL NOT NULL,
        elevation REAL DEFAULT 0.0,
        elevation REAL DEFAULT 0.0,
+       timezone TEXT,                  -- IANA name (e.g. "Europe/Vienna")
+       alive INTEGER DEFAULT 1,
+       private INTEGER DEFAULT 0,
+       gender TEXT,
+       description TEXT,
+       notes TEXT,
+       birth_time_known INTEGER DEFAULT 1,
        created_at TEXT NOT NULL,
        created_at TEXT NOT NULL,
        updated_at TEXT
        updated_at TEXT
    );
    );
-   -- Migration: ALTER TABLE persons ADD COLUMN birthplace TEXT
    ```
    ```
+   **Datetime storage convention:** `birth_datetime` is always naive local time
+   (no `Z`, no `+01:00`). The IANA `timezone` column is combined with it by
+   `_get_person_birth_data()` to produce UTC. Do NOT store UTC or offset-aware
+   datetimes in this column. For historical dates (pre-1890), `zoneinfo` will
+   use LMT automatically when given the IANA name.
 
 
 ## MCP Tool Surface (12 tools)
 ## MCP Tool Surface (12 tools)
 
 

+ 24 - 11
agent-guides/server-guide.md

@@ -52,28 +52,41 @@ is responsible for conversion. Use ISO 8601 format:
 Tools that look up persons by ID/nickname (e.g. `calculate_natal_chart_by_id`)
 Tools that look up persons by ID/nickname (e.g. `calculate_natal_chart_by_id`)
 automatically convert to UTC. Just pass the person identifier.
 automatically convert to UTC. Just pass the person identifier.
 
 
-## Person Management
+### Person Management
 
 
-### Adding a Person
+#### Adding a Person
 
 
 Use `person_manage` with `action: "add"`. Required fields:
 Use `person_manage` with `action: "add"`. Required fields:
 - `name`: Full name
 - `name`: Full name
-- `birth_datetime`: ISO 8601 datetime (any format — will be stored)
+- `birth_datetime`: ISO 8601 **naive local time** (no offset, e.g. `"1990-05-15T10:30:00"`)
 - `latitude`, `longitude`: Birth coordinates
 - `latitude`, `longitude`: Birth coordinates
-- `tz`: IANA timezone name (e.g. `"Europe/Vienna"`)
+- `tz`: IANA timezone name (e.g. `"Europe/Vienna"`, `"America/New_York"`)
 
 
 Optional: `nickname`, `birthplace`, `elevation`, `gender`, `description`, `notes`,
 Optional: `nickname`, `birthplace`, `elevation`, `gender`, `description`, `notes`,
 `birth_time_known`.
 `birth_time_known`.
 
 
-### Important: Timezone Must Be Set
+#### Datetime Convention (CRITICAL)
 
 
-When adding a person, ALWAYS provide the `tz` field with the correct IANA
-timezone name. This is essential for accurate chart calculation, especially for:
-- Historical dates (before ~1890) where LMT differs from modern timezones
-- Locations that observed DST differently in the past
-- Any date where the UTC offset is not obvious
+The persons database stores **naive local time** + **IANA timezone name**. The
+conversion to UTC happens exactly once, inside `_get_person_birth_data()`, which
+combines them using `zoneinfo.ZoneInfo`. Rules:
 
 
-### Listing and Updating
+- `birth_datetime` in the DB: **always naive, always local** (no `Z`, no `+01:00`)
+- `timezone`: IANA name (e.g. `"America/Chicago"`, `"Europe/Berlin"`)
+- `longitude`: fallback for LMT only when `timezone` is NULL (pre-1900 dates)
+
+**Do NOT store UTC in the DB.** Do NOT add an offset to `birth_datetime`. If you
+store `"1953-03-23T03:05:00+00:00"` (UTC), the conversion will treat it as
+already-UTC and the chart will be wrong. Instead store `"1953-03-23T21:05:00"`
+(local Chicago time) with `tz="America/Chicago"`.
+
+**Historical note:** Before ~1890, IANA timezones use Local Mean Time (LMT)
+which can differ from modern UTC offsets by minutes. For example, Einstein's
+birth in Ulm (1879) is LMT = UTC+0:53:28, not the modern CET (UTC+1). The
+`zoneinfo` module handles this correctly when given the IANA name. Always
+provide `tz` for historical dates — do not compute the UTC offset manually.
+
+#### Listing and Updating
 
 
 - `person_manage` with `action: "list"` — list all persons
 - `person_manage` with `action: "list"` — list all persons
 - `person_manage` with `action: "get"` — retrieve by ID or nickname
 - `person_manage` with `action: "get"` — retrieve by ID or nickname

+ 3 - 0
requirements.txt

@@ -9,6 +9,9 @@ python-dotenv>=1.0.1
 starlette>=0.49.1
 starlette>=0.49.1
 sse-starlette>=3.4.4
 sse-starlette>=3.4.4
 requests>=2.31
 requests>=2.31
+svgwrite>=1.4
+pillow>=10.0
+cairosvg>=2.7
 
 
 # Testing
 # Testing
 pytest>=8.4
 pytest>=8.4

+ 94 - 0
src/astro_mcp/chart_helpers.py

@@ -0,0 +1,94 @@
+"""
+Chart renderer helpers — output format plumbing.
+
+Provides:
+  - SVG-to-image conversion (svg, png, jpg)
+  - Render envelope for consistent return dicts from chart renderers
+"""
+
+from __future__ import annotations
+
+_CONTENT_TYPES = {
+    "svg": "image/svg+xml",
+    "png": "image/png",
+    "jpg": "image/jpeg",
+    "jpeg": "image/jpeg",
+}
+
+
+def svg_to_image(svg_str: str, fmt: str, size: int) -> bytes:
+    """Convert an SVG string to the requested format.
+
+    Args:
+        svg_str: SVG document as a string.
+        fmt: Output format — "svg", "png", "jpg", or "jpeg".
+        size: Output width/height in pixels (square canvas).
+
+    Returns:
+        Bytes of the output content.
+    """
+    fmt = fmt.lower()
+    if fmt == "svg":
+        return svg_str.encode("utf-8")
+
+    # Lazy imports — cairosvg and pillow are only needed for raster output
+    if fmt in ("png", "jpg", "jpeg"):
+        import io
+
+        try:
+            import cairosvg
+        except ImportError:
+            raise ImportError(
+                "cairosvg is required for PNG/JPG output. "
+                "Install it with: pip install cairosvg"
+            )
+
+        png_bytes = cairosvg.svg2png(
+            bytestring=svg_str.encode("utf-8"),
+            output_width=size,
+            output_height=size,
+        )
+        if not isinstance(png_bytes, bytes):
+            raise RuntimeError("cairosvg.svg2png did not return bytes")
+
+        if fmt in ("jpg", "jpeg"):
+            try:
+                from PIL import Image
+            except ImportError:
+                raise ImportError(
+                    "Pillow is required for JPG output. "
+                    "Install it with: pip install pillow"
+                )
+            img = Image.open(io.BytesIO(png_bytes))
+            # Flatten alpha to white background
+            if img.mode == "RGBA":
+                bg = Image.new("RGB", img.size, (255, 255, 255))
+                bg.paste(img, mask=img.split()[3])
+                img = bg
+            buf = io.BytesIO()
+            img.save(buf, format="JPEG", quality=90)
+            return buf.getvalue()
+
+        return png_bytes
+
+    raise ValueError(f"Unsupported format: {fmt!r}. Use 'svg', 'png', 'jpg', or 'jpeg'.")
+
+
+def render_envelope(content: bytes | str, fmt: str, size: int) -> dict:
+    """Build the standard return dict for chart renderers.
+
+    Args:
+        content: SVG string or image bytes.
+        fmt: Output format identifier ("svg", "png", "jpg").
+        size: Canvas width/height in pixels.
+
+    Returns:
+        Dict with content, format, content_type, width, height.
+    """
+    return {
+        "content": content,
+        "format": fmt,
+        "content_type": _CONTENT_TYPES.get(fmt.lower(), "application/octet-stream"),
+        "width": size,
+        "height": size,
+    }

+ 26 - 157
src/astro_mcp/chart_renderer.py

@@ -30,139 +30,18 @@ import svgwrite
 from . import astrology
 from . import astrology
 from . import __version__
 from . import __version__
 from .chart_font import glyph, RETROGRADE
 from .chart_font import glyph, RETROGRADE
+from .chart_styles import (
+    THEMES,
+    BW_ASPECT_STYLES,
+    COLOR_ASPECT_STYLES,
+    ANGULAR_HOUSES,
+    font_face_css,
+    r as _r,
+)
+from .chart_helpers import svg_to_image, render_envelope
 
 
 logger = logging.getLogger("astro-mcp.chart_renderer")
 logger = logging.getLogger("astro-mcp.chart_renderer")
 
 
-# ── Font path (served by FastAPI static mount) ────────────────────────
-FONT_PATH = "/static/fonts/Astronomicon.ttf"
-
-# ── Color Themes ──────────────────────────────────────────────────────
-
-THEMES: dict[str, dict[str, Any]] = {
-    "color": {
-        "background": "#ffffff",
-        "ring_fill": "#f8f4e8",
-        "ring_stroke": "#8b7355",
-        "house_line": "#c9b896",
-        "house_fill_alt": "#f0ebe0",
-        "sign_text": "#4a3728",
-        "degree_text": "#6b5a4a",
-        "planet_text": "#1a1a2e",
-        "angle_text": "#8b0000",
-        "title_text": "#2c1810",
-        "data_text": "#4a3728",
-        "footer_text": "#999999",
-        "aspect_conjunction": "#e74c3c",
-        "aspect_opposition": "#e74c3c",
-        "aspect_square": "#c0392b",
-        "aspect_trine": "#2ecc71",
-        "aspect_sextile": "#3498db",
-        "aspect_quincunx": "#e67e22",
-        "aspect_minor": "#95a5a6",
-        "table_header": "#2c3e50",
-        "table_text": "#2c3e50",
-        "table_line": "#bdc3c7",
-        "table_row_alt": "#ecf0f1",
-        "zodiac_fire": "#e74c3c",
-        "zodiac_earth": "#27ae60",
-        "zodiac_air": "#f39c12",
-        "zodiac_water": "#3498db",
-        "axis_line": "#8b7355",
-        "tick_major": "#8b7355",
-        "tick_minor": "#c9b896",
-        "connector_line": "#aaaaaa",
-    },
-    "bw": {
-        "background": "#ffffff",
-        "ring_fill": "#ffffff",
-        "ring_stroke": "#000000",
-        "house_line": "#444444",
-        "house_fill_alt": "#f0f0f0",
-        "sign_text": "#000000",
-        "degree_text": "#333333",
-        "planet_text": "#000000",
-        "angle_text": "#000000",
-        "title_text": "#000000",
-        "data_text": "#333333",
-        "footer_text": "#666666",
-        "aspect_conjunction": "#000000",
-        "aspect_opposition": "#000000",
-        "aspect_square": "#000000",
-        "aspect_trine": "#000000",
-        "aspect_sextile": "#000000",
-        "aspect_quincunx": "#000000",
-        "aspect_minor": "#666666",
-        "table_header": "#000000",
-        "table_text": "#000000",
-        "table_line": "#999999",
-        "table_row_alt": "#f0f0f0",
-        "zodiac_fire": "#000000",
-        "zodiac_earth": "#000000",
-        "zodiac_air": "#000000",
-        "zodiac_water": "#000000",
-        "axis_line": "#000000",
-        "tick_major": "#000000",
-        "tick_minor": "#999999",
-        "connector_line": "#999999",
-    },
-    "dark": {
-        "background": "#0f1117",
-        "ring_fill": "#161b22",
-        "ring_stroke": "#30363d",
-        "house_line": "#21262d",
-        "house_fill_alt": "#1c2128",
-        "sign_text": "#8b949e",
-        "degree_text": "#6e7681",
-        "planet_text": "#c9d1d9",
-        "angle_text": "#ff7b72",
-        "title_text": "#58a6ff",
-        "data_text": "#8b949e",
-        "footer_text": "#484f58",
-        "aspect_conjunction": "#ff7b72",
-        "aspect_opposition": "#ff7b72",
-        "aspect_square": "#ff9a94",
-        "aspect_trine": "#3fb950",
-        "aspect_sextile": "#58a6ff",
-        "aspect_quincunx": "#d29922",
-        "aspect_minor": "#484f58",
-        "table_header": "#58a6ff",
-        "table_text": "#c9d1d9",
-        "table_line": "#30363d",
-        "table_row_alt": "#161b22",
-        "zodiac_fire": "#ff7b72",
-        "zodiac_earth": "#3fb950",
-        "zodiac_air": "#d29922",
-        "zodiac_water": "#58a6ff",
-        "axis_line": "#30363d",
-        "tick_major": "#8b949e",
-        "tick_minor": "#30363d",
-        "connector_line": "#484f58",
-    },
-}
-
-# B&W aspect line styles: (stroke_dasharray, stroke_width)
-BW_ASPECT_STYLES: dict[str, tuple[str, float]] = {
-    "conjunction":  ("none", 1.5),
-    "opposition":   ("none", 1.5),
-    "square":       ("6,3", 1.5),
-    "trine":        ("4,3", 1.0),
-    "sextile":      ("2,3", 1.0),
-    "quincunx":     ("5,2,1,2", 1.0),
-}
-
-# Color aspect line styles: (color_key, stroke_width)
-COLOR_ASPECT_STYLES: dict[str, tuple[str, float]] = {
-    "conjunction":  ("aspect_conjunction", 1.5),
-    "opposition":   ("aspect_opposition", 1.5),
-    "square":       ("aspect_square", 1.5),
-    "trine":        ("aspect_trine", 1.2),
-    "sextile":      ("aspect_sextile", 1.2),
-    "quincunx":     ("aspect_quincunx", 1.0),
-}
-
-# Angular houses (cusps 1, 4, 7, 10) get bolder lines
-ANGULAR_HOUSES = {1, 4, 7, 10}
-
 
 
 # ── Geometry helpers ──────────────────────────────────────────────────
 # ── Geometry helpers ──────────────────────────────────────────────────
 
 
@@ -257,10 +136,6 @@ _R_CENTER       = 0.280   # inner center circle radius (was 0.18)
 _R_ASPECT       = 0.345   # aspect lines drawn at this radius from center
 _R_ASPECT       = 0.345   # aspect lines drawn at this radius from center
 
 
 
 
-def _r(outer_r: float, frac: float) -> float:
-    return outer_r * frac
-
-
 # ── Main natal wheel renderer ─────────────────────────────────────────
 # ── Main natal wheel renderer ─────────────────────────────────────────
 
 
 def render_natal_wheel(
 def render_natal_wheel(
@@ -273,8 +148,9 @@ def render_natal_wheel(
     include_houses: bool = False,
     include_houses: bool = False,
     title: str | None = None,
     title: str | None = None,
     subtitle: str | None = None,
     subtitle: str | None = None,
-) -> str:
-    """Render a natal chart wheel as SVG string.
+    format: str = "svg",
+) -> dict[str, Any]:
+    """Render a natal chart wheel as SVG (or raster image).
 
 
     The wheel fills a square canvas. ASC is always at 9 o'clock (left).
     The wheel fills a square canvas. ASC is always at 9 o'clock (left).
     MC is placed at its actual ecliptic longitude — it is NOT forced to 12 o'clock
     MC is placed at its actual ecliptic longitude — it is NOT forced to 12 o'clock
@@ -290,9 +166,10 @@ def render_natal_wheel(
         include_houses: Include house cusp table.
         include_houses: Include house cusp table.
         title: Override auto-generated title line.
         title: Override auto-generated title line.
         subtitle: Override auto-generated subtitle line.
         subtitle: Override auto-generated subtitle line.
+        format: Output format — "svg", "png", or "jpg". Default "svg".
 
 
     Returns:
     Returns:
-        SVG string.
+        Dict with content, format, content_type, width, height, and included.
     """
     """
     theme = THEMES.get(color_mode, THEMES["color"])
     theme = THEMES.get(color_mode, THEMES["color"])
     planets = chart_data.get("planets", [])
     planets = chart_data.get("planets", [])
@@ -329,14 +206,7 @@ def render_natal_wheel(
     dwg = svgwrite.Drawing(size=(canvas_w, canvas_h))
     dwg = svgwrite.Drawing(size=(canvas_w, canvas_h))
     dwg.set_desc(title="Astro-MCP Chart Wheel")
     dwg.set_desc(title="Astro-MCP Chart Wheel")
 
 
-    dwg.defs.add(dwg.style(f"""
-        @font-face {{
-            font-family: 'Astronomicon';
-            src: url('{FONT_PATH}') format('truetype');
-        }}
-        .zf {{ font-family: 'Astronomicon', serif; }}
-        .lbl {{ font-family: 'DejaVu Sans', 'Helvetica', Arial, sans-serif; }}
-    """))
+    dwg.defs.add(dwg.style(font_face_css()))
 
 
     # Background
     # Background
     dwg.add(dwg.rect(insert=(0, 0), size=(canvas_w, canvas_h), fill=theme["background"]))
     dwg.add(dwg.rect(insert=(0, 0), size=(canvas_w, canvas_h), fill=theme["background"]))
@@ -578,7 +448,8 @@ def render_natal_wheel(
             _render_tables_inline(dwg, chart_data, theme, table_x, table_y, size - 16,
             _render_tables_inline(dwg, chart_data, theme, table_x, table_y, size - 16,
                                   include_planets, include_houses)
                                   include_planets, include_houses)
 
 
-    return dwg.tostring()
+    svg_str = dwg.tostring()
+    return render_envelope(svg_to_image(svg_str, format, size), format, size)
 
 
 
 
 # ── Title block — corner ──────────────────────────────────────────────
 # ── Title block — corner ──────────────────────────────────────────────
@@ -635,7 +506,7 @@ def _render_title_corner(
     if lat is not None and lon is not None:
     if lat is not None and lon is not None:
         lat_dir = "N" if lat >= 0 else "S"
         lat_dir = "N" if lat >= 0 else "S"
         lon_dir = "E" if lon >= 0 else "W"
         lon_dir = "E" if lon >= 0 else "W"
-        lines.append((f"{abs(lat):.4f}°{lat_dir}  {abs(lon):.4f}°{lon_dir}", "7.5px", theme["data_text"], False))
+        lines.append((f"{abs(lat):.4f}\u00b0{lat_dir}  {abs(lon):.4f}\u00b0{lon_dir}", "7.5px", theme["data_text"], False))
 
 
     if subtitle:
     if subtitle:
         lines.append((subtitle, "7.5px", theme["data_text"], False))
         lines.append((subtitle, "7.5px", theme["data_text"], False))
@@ -796,7 +667,7 @@ def _render_planet_table(
 
 
     dwg.add(dwg.rect(insert=(x, y), size=(max_w, h), fill="none",
     dwg.add(dwg.rect(insert=(x, y), size=(max_w, h), fill="none",
                       stroke=theme["table_line"], stroke_width=0.5))
                       stroke=theme["table_line"], stroke_width=0.5))
-    dwg.add(dwg.rect(insert=(x, y), size=(max_w, header_h), fill=theme["table_header"]))
+    dwg.add(dwg.rect(insert=(x, y), size=(max_w, h), fill=theme["table_header"]))
 
 
     cols = [("Planet", 52), ("Sign", 44), ("Degree", 54), ("Hse", 30), ("Rx", 18)]
     cols = [("Planet", 52), ("Sign", 44), ("Degree", 54), ("Hse", 30), ("Rx", 18)]
     cx_pos = x + 5
     cx_pos = x + 5
@@ -838,7 +709,7 @@ def _render_house_table(
 
 
     dwg.add(dwg.rect(insert=(x, y), size=(max_w, h), fill="none",
     dwg.add(dwg.rect(insert=(x, y), size=(max_w, h), fill="none",
                       stroke=theme["table_line"], stroke_width=0.5))
                       stroke=theme["table_line"], stroke_width=0.5))
-    dwg.add(dwg.rect(insert=(x, y), size=(max_w, header_h), fill=theme["table_header"]))
+    dwg.add(dwg.rect(insert=(x, y), size=(max_w, h), fill=theme["table_header"]))
 
 
     cols = [("House", 42), ("Sign", 44), ("Cusp", 54)]
     cols = [("House", 42), ("Sign", 44), ("Cusp", 54)]
     cx_pos = x + 5
     cx_pos = x + 5
@@ -870,8 +741,9 @@ def render_transit_wheel(
     color_mode: str = "color",
     color_mode: str = "color",
     size: int = 700,
     size: int = 700,
     table_position: str = "none",
     table_position: str = "none",
+    format: str = "svg",
     **kwargs,
     **kwargs,
-) -> str:
+) -> dict[str, Any]:
     """Render a transit chart as bi-wheel (natal inner, transit outer)."""
     """Render a transit chart as bi-wheel (natal inner, transit outer)."""
     theme      = THEMES.get(color_mode, THEMES["color"])
     theme      = THEMES.get(color_mode, THEMES["color"])
     natal      = chart_data.get("natal_planets", [])
     natal      = chart_data.get("natal_planets", [])
@@ -895,11 +767,7 @@ def render_transit_wheel(
     r_center    = _r(outer_r, _R_CENTER)
     r_center    = _r(outer_r, _R_CENTER)
 
 
     dwg = svgwrite.Drawing(size=(canvas_w, canvas_h))
     dwg = svgwrite.Drawing(size=(canvas_w, canvas_h))
-    dwg.defs.add(dwg.style(f"""
-        @font-face {{ font-family: 'Astronomicon'; src: url('{FONT_PATH}') format('truetype'); }}
-        .zf {{ font-family: 'Astronomicon', serif; }}
-        .lbl {{ font-family: 'DejaVu Sans', 'Helvetica', Arial, sans-serif; }}
-    """))
+    dwg.defs.add(dwg.style(font_face_css()))
     dwg.add(dwg.rect(insert=(0, 0), size=(canvas_w, canvas_h), fill=theme["background"]))
     dwg.add(dwg.rect(insert=(0, 0), size=(canvas_w, canvas_h), fill=theme["background"]))
 
 
     # Title corner
     # Title corner
@@ -1009,7 +877,8 @@ def render_transit_wheel(
         text_anchor="start", class_="lbl", font_size="7px", fill=theme["footer_text"],
         text_anchor="start", class_="lbl", font_size="7px", fill=theme["footer_text"],
     ))
     ))
 
 
-    return dwg.tostring()
+    svg_str = dwg.tostring()
+    return render_envelope(svg_to_image(svg_str, format, size), format, size)
 
 
 
 
 # ── Synastry renderer (stub) ──────────────────────────────────────────
 # ── Synastry renderer (stub) ──────────────────────────────────────────
@@ -1030,7 +899,7 @@ RENDERERS = {
 }
 }
 
 
 
 
-def render(chart_data: dict[str, Any], **kwargs) -> str:
+def render(chart_data: dict[str, Any], **kwargs) -> dict[str, Any]:
     """Render a chart wheel. Auto-detects chart type from chart_data."""
     """Render a chart wheel. Auto-detects chart type from chart_data."""
     chart_type = chart_data.get("chart_type", "natal")
     chart_type = chart_data.get("chart_type", "natal")
     renderer   = RENDERERS.get(chart_type, render_natal_wheel)
     renderer   = RENDERERS.get(chart_type, render_natal_wheel)

+ 199 - 0
src/astro_mcp/chart_styles.py

@@ -0,0 +1,199 @@
+"""
+Chart style definitions for astro-mcp.
+
+Contains all visual presentation data: color themes, ring proportions,
+aspect line styles, and the @font-face CSS for the Astronomicon font.
+
+When adding a new theme, add an entry to THEMES["palette"].
+When adding a new color mode, add an entry to THEMES with its palette.
+"""
+
+from __future__ import annotations
+
+import base64
+import pathlib
+from typing import Any
+
+# ── Font path ──────────────────────────────────────────────────────────
+# src/astro_mcp/chart_styles.py → src/static/fonts/Astronomicon.ttf
+_FONT_FILE = pathlib.Path(__file__).parent.parent.parent / "static" / "fonts" / "Astronomicon.ttf"
+
+# ── Cached base64 data URI (loaded once per process) ──────────────────
+_FONT_DATA_URI: str | None = None
+
+
+def _get_font_data_uri() -> str:
+    """Load Astronomicon.ttf and return as base64 data URI."""
+    global _FONT_DATA_URI
+    if _FONT_DATA_URI is None:
+        with open(_FONT_FILE, "rb") as f:
+            b64 = base64.b64encode(f.read()).decode("ascii")
+        _FONT_DATA_URI = f"data:font/ttf;base64,{b64}"
+    return _FONT_DATA_URI
+
+
+def font_face_css() -> str:
+    """Return @font-face CSS block with embedded base64 font data."""
+    data_uri = _get_font_data_uri()
+    return f"""
+        @font-face {{
+            font-family: 'Astronomicon';
+            src: url('{data_uri}') format('truetype');
+        }}
+        .zf {{ font-family: 'Astronomicon', serif; }}
+        .lbl {{ font-family: 'DejaVu Sans', 'Helvetica', Arial, sans-serif; }}
+    """
+
+
+# ── Color Themes ──────────────────────────────────────────────────────
+
+THEMES: dict[str, dict[str, Any]] = {
+    "color": {
+        "background": "#ffffff",
+        "ring_fill": "#f8f4e8",
+        "ring_stroke": "#8b7355",
+        "house_line": "#c9b896",
+        "house_fill_alt": "#f0ebe0",
+        "sign_text": "#4a3728",
+        "degree_text": "#6b5a4a",
+        "planet_text": "#1a1a2e",
+        "angle_text": "#8b0000",
+        "title_text": "#2c1810",
+        "data_text": "#4a3728",
+        "footer_text": "#999999",
+        "aspect_conjunction": "#e74c3c",
+        "aspect_opposition": "#e74c3c",
+        "aspect_square": "#c0392b",
+        "aspect_trine": "#2ecc71",
+        "aspect_sextile": "#3498db",
+        "aspect_quincunx": "#e67e22",
+        "aspect_minor": "#95a5a6",
+        "table_header": "#2c3e50",
+        "table_text": "#2c3e50",
+        "table_line": "#bdc3c7",
+        "table_row_alt": "#ecf0f1",
+        "zodiac_fire": "#e74c3c",
+        "zodiac_earth": "#27ae60",
+        "zodiac_air": "#f39c12",
+        "zodiac_water": "#3498db",
+        "axis_line": "#8b7355",
+        "tick_major": "#8b7355",
+        "tick_minor": "#c9b896",
+        "connector_line": "#aaaaaa",
+    },
+    "bw": {
+        "background": "#ffffff",
+        "ring_fill": "#ffffff",
+        "ring_stroke": "#000000",
+        "house_line": "#444444",
+        "house_fill_alt": "#f0f0f0",
+        "sign_text": "#000000",
+        "degree_text": "#333333",
+        "planet_text": "#000000",
+        "angle_text": "#000000",
+        "title_text": "#000000",
+        "data_text": "#333333",
+        "footer_text": "#666666",
+        "aspect_conjunction": "#000000",
+        "aspect_opposition": "#000000",
+        "aspect_square": "#000000",
+        "aspect_trine": "#000000",
+        "aspect_sextile": "#000000",
+        "aspect_quincunx": "#000000",
+        "aspect_minor": "#666666",
+        "table_header": "#000000",
+        "table_text": "#000000",
+        "table_line": "#999999",
+        "table_row_alt": "#f0f0f0",
+        "zodiac_fire": "#000000",
+        "zodiac_earth": "#000000",
+        "zodiac_air": "#000000",
+        "zodiac_water": "#000000",
+        "axis_line": "#000000",
+        "tick_major": "#000000",
+        "tick_minor": "#999999",
+        "connector_line": "#999999",
+    },
+    "dark": {
+        "background": "#0f1117",
+        "ring_fill": "#161b22",
+        "ring_stroke": "#30363d",
+        "house_line": "#21262d",
+        "house_fill_alt": "#1c2128",
+        "sign_text": "#8b949e",
+        "degree_text": "#6e7681",
+        "planet_text": "#c9d1d9",
+        "angle_text": "#ff7b72",
+        "title_text": "#58a6ff",
+        "data_text": "#8b949e",
+        "footer_text": "#484f58",
+        "aspect_conjunction": "#ff7b72",
+        "aspect_opposition": "#ff7b72",
+        "aspect_square": "#ff9a94",
+        "aspect_trine": "#3fb950",
+        "aspect_sextile": "#58a6ff",
+        "aspect_quincunx": "#d29922",
+        "aspect_minor": "#484f58",
+        "table_header": "#58a6ff",
+        "table_text": "#c9d1d9",
+        "table_line": "#30363d",
+        "table_row_alt": "#161b22",
+        "zodiac_fire": "#ff7b72",
+        "zodiac_earth": "#3fb950",
+        "zodiac_air": "#d29922",
+        "zodiac_water": "#58a6ff",
+        "axis_line": "#30363d",
+        "tick_major": "#8b949e",
+        "tick_minor": "#30363d",
+        "connector_line": "#484f58",
+    },
+}
+
+# ── Aspect line styles ────────────────────────────────────────────────
+
+# B&W aspect line styles: (stroke_dasharray, stroke_width)
+BW_ASPECT_STYLES: dict[str, tuple[str, float]] = {
+    "conjunction":  ("none", 1.5),
+    "opposition":   ("none", 1.5),
+    "square":       ("6,3", 1.5),
+    "trine":        ("4,3", 1.0),
+    "sextile":      ("2,3", 1.0),
+    "quincunx":     ("5,2,1,2", 1.0),
+}
+
+# Color aspect line styles: (color_key, stroke_width)
+COLOR_ASPECT_STYLES: dict[str, tuple[str, float]] = {
+    "conjunction":  ("aspect_conjunction", 1.5),
+    "opposition":   ("aspect_opposition", 1.5),
+    "square":       ("aspect_square", 1.5),
+    "trine":        ("aspect_trine", 1.2),
+    "sextile":      ("aspect_sextile", 1.2),
+    "quincunx":     ("aspect_quincunx", 1.0),
+}
+
+# Angular houses (cusps 1, 4, 7, 10) get bolder lines
+ANGULAR_HOUSES = {1, 4, 7, 10}
+
+# ── Ring proportion constants (as fraction of outer_r) ────────────────
+# These mirror professional chart proportions.
+# outer_r = 1.0 (the canvas half-size)
+
+_R_OUTER        = 1.000   # outermost edge, tick marks end here
+_R_TICK_OUTER   = 0.980   # outer tick root (5° ticks)
+_R_TICK_INNER_5 = 0.955   # 5° tick inner end
+_R_TICK_INNER_1 = 0.967   # 1° tick inner end  (not drawn at this res)
+_R_ZODIAC_OUTER = 0.940   # outer zodiac band edge (stroke circle)
+_R_ZODIAC_GLYPH = 0.855   # center of zodiac band ((0.940+0.770)/2)
+_R_ZODIAC_INNER = 0.770   # inner zodiac band edge (stroke circle)
+_R_HOUSE_NUM    = 0.700   # house number label position
+_R_CUSP_INNER   = 0.350   # house cusp lines extend inward to here
+_R_PLANET       = 0.595   # planet glyph centre
+_R_PLANET_DEG   = 0.490   # planet degree label (inside glyph)
+_R_CONNECTOR    = 0.760   # connector tick inner end (near inner zodiac edge)
+_R_CENTER       = 0.280   # inner center circle radius (was 0.18)
+_R_ASPECT       = 0.345   # aspect lines drawn at this radius from center
+
+
+def r(outer_r: float, frac: float) -> float:
+    """Scale a ring proportion constant by the wheel's outer radius."""
+    return outer_r * frac

+ 118 - 112
src/astro_mcp/tools.py

@@ -876,7 +876,7 @@ async def person_manage(
         person_id: Required for get, update, delete.
         person_id: Required for get, update, delete.
         name: Person's full name (required for add).
         name: Person's full name (required for add).
         nickname: Optional short name for quick lookup (used with _byId tools).
         nickname: Optional short name for quick lookup (used with _byId tools).
-        birth_datetime: ISO 8601 UTC datetime (required for add).
+        birth_datetime: ISO 8601 naive local time, no offset (e.g. "1990-05-15T10:30:00").
         birthplace: Optional birth place name (e.g., "Zurich, Switzerland").
         birthplace: Optional birth place name (e.g., "Zurich, Switzerland").
         latitude: Birth latitude (required for add).
         latitude: Birth latitude (required for add).
         longitude: Birth longitude (required for add).
         longitude: Birth longitude (required for add).
@@ -1586,8 +1586,8 @@ async def _get_person_birth_data(person_id: str) -> dict[str, Any]:
     produce the correct UTC datetime, using zoneinfo for modern dates and
     produce the correct UTC datetime, using zoneinfo for modern dates and
     LMT (longitude/15) as fallback when no timezone is set.
     LMT (longitude/15) as fallback when no timezone is set.
 
 
-    Rule: birth_datetime in the returned dict is ALWAYS UTC. Callers must not
-    re-normalize it.
+    Rule: birth_datetime in the returned dict is ALWAYS naive local time.
+    birth_datetime_utc is for internal ephemeris calls only.
     """
     """
     from . import storage
     from . import storage
     from .ephemeris_client import _normalize_datetime
     from .ephemeris_client import _normalize_datetime
@@ -1602,7 +1602,9 @@ async def _get_person_birth_data(person_id: str) -> dict[str, Any]:
         lon=person["longitude"],
         lon=person["longitude"],
     )
     )
     return {
     return {
-        "birth_datetime": utc_dt,
+        "birth_datetime": person["birth_datetime"],   # naive local time
+        "timezone": person.get("timezone"),
+        "birth_datetime_utc": utc_dt,                  # for ephemeris calls
         "birthplace": person.get("birthplace"),
         "birthplace": person.get("birthplace"),
         "latitude": person["latitude"],
         "latitude": person["latitude"],
         "longitude": person["longitude"],
         "longitude": person["longitude"],
@@ -1641,8 +1643,8 @@ async def calculate_natal_chart_by_id(
     birth = await _get_person_birth_data(person_id)
     birth = await _get_person_birth_data(person_id)
     if "error" in birth:
     if "error" in birth:
         return birth
         return birth
-    return await calculate_natal_chart(
-        birth_datetime=birth["birth_datetime"],
+    result = await calculate_natal_chart(
+        birth_datetime=birth["birth_datetime_utc"],
         latitude=birth["latitude"],
         latitude=birth["latitude"],
         longitude=birth["longitude"],
         longitude=birth["longitude"],
         elevation=birth.get("elevation", 0.0),
         elevation=birth.get("elevation", 0.0),
@@ -1653,6 +1655,10 @@ async def calculate_natal_chart_by_id(
         include_karmic=include_karmic,
         include_karmic=include_karmic,
         top_n_aspects=top_n_aspects,
         top_n_aspects=top_n_aspects,
     )
     )
+    if "error" not in result:
+        result["input"]["birth_datetime"] = birth["birth_datetime"]
+        result["input"]["timezone"] = birth.get("timezone")
+    return result
 
 
 
 
 @mcp.tool()
 @mcp.tool()
@@ -1683,8 +1689,8 @@ Returns:
     birth = await _get_person_birth_data(person_id)
     birth = await _get_person_birth_data(person_id)
     if "error" in birth:
     if "error" in birth:
         return birth
         return birth
-    return await calculate_transit_chart(
-        birth_datetime=birth["birth_datetime"],
+    result = await calculate_transit_chart(
+        birth_datetime=birth["birth_datetime_utc"],
         transit_datetime=transit_datetime,
         transit_datetime=transit_datetime,
         latitude=birth["latitude"],
         latitude=birth["latitude"],
         longitude=birth["longitude"],
         longitude=birth["longitude"],
@@ -1694,6 +1700,10 @@ Returns:
         house_system=house_system,
         house_system=house_system,
         orb_limits=orb_limits,
         orb_limits=orb_limits,
     )
     )
+    if "error" not in result:
+        result["input"]["birth_datetime"] = birth["birth_datetime"]
+        result["input"]["timezone"] = birth.get("timezone")
+    return result
 
 
 
 
 @mcp.tool()
 @mcp.tool()
@@ -1732,11 +1742,11 @@ async def calculate_synastry_chart_by_id(
     p2 = await _get_person_birth_data(person2_id)
     p2 = await _get_person_birth_data(person2_id)
     if "error" in p2:
     if "error" in p2:
         return p2
         return p2
-    return await calculate_synastry_chart(
-        person1_datetime=p1["birth_datetime"],
+    result = await calculate_synastry_chart(
+        person1_datetime=p1["birth_datetime_utc"],
         person1_latitude=p1["latitude"],
         person1_latitude=p1["latitude"],
         person1_longitude=p1["longitude"],
         person1_longitude=p1["longitude"],
-        person2_datetime=p2["birth_datetime"],
+        person2_datetime=p2["birth_datetime_utc"],
         person2_latitude=p2["latitude"],
         person2_latitude=p2["latitude"],
         person2_longitude=p2["longitude"],
         person2_longitude=p2["longitude"],
         elevation=p1.get("elevation", 0.0),
         elevation=p1.get("elevation", 0.0),
@@ -1747,6 +1757,12 @@ async def calculate_synastry_chart_by_id(
         significator_filter=significator_filter,
         significator_filter=significator_filter,
         include_davison_full=include_davison_full,
         include_davison_full=include_davison_full,
     )
     )
+    if "error" not in result:
+        result["input"]["person1_birth_datetime"] = p1["birth_datetime"]
+        result["input"]["person1_timezone"] = p1.get("timezone")
+        result["input"]["person2_birth_datetime"] = p2["birth_datetime"]
+        result["input"]["person2_timezone"] = p2.get("timezone")
+    return result
 
 
 
 
 @mcp.tool()
 @mcp.tool()
@@ -1776,17 +1792,23 @@ Returns:
     p2 = await _get_person_birth_data(person2_id)
     p2 = await _get_person_birth_data(person2_id)
     if "error" in p2:
     if "error" in p2:
         return p2
         return p2
-    return await calculate_composite_chart(
-        person1_datetime=p1["birth_datetime"],
+    result = await calculate_composite_chart(
+        person1_datetime=p1["birth_datetime_utc"],
         person1_latitude=p1["latitude"],
         person1_latitude=p1["latitude"],
         person1_longitude=p1["longitude"],
         person1_longitude=p1["longitude"],
-        person2_datetime=p2["birth_datetime"],
+        person2_datetime=p2["birth_datetime_utc"],
         person2_latitude=p2["latitude"],
         person2_latitude=p2["latitude"],
         person2_longitude=p2["longitude"],
         person2_longitude=p2["longitude"],
         elevation=p1.get("elevation", 0.0),
         elevation=p1.get("elevation", 0.0),
         house_system=house_system,
         house_system=house_system,
         orb_limits=orb_limits,
         orb_limits=orb_limits,
     )
     )
+    if "error" not in result:
+        result["input"]["person1_birth_datetime"] = p1["birth_datetime"]
+        result["input"]["person1_timezone"] = p1.get("timezone")
+        result["input"]["person2_birth_datetime"] = p2["birth_datetime"]
+        result["input"]["person2_timezone"] = p2.get("timezone")
+    return result
 
 
 
 
 @mcp.tool()
 @mcp.tool()
@@ -1816,17 +1838,23 @@ Returns:
     p2 = await _get_person_birth_data(person2_id)
     p2 = await _get_person_birth_data(person2_id)
     if "error" in p2:
     if "error" in p2:
         return p2
         return p2
-    return await calculate_davison_chart(
-        person1_datetime=p1["birth_datetime"],
+    result = await calculate_davison_chart(
+        person1_datetime=p1["birth_datetime_utc"],
         person1_latitude=p1["latitude"],
         person1_latitude=p1["latitude"],
         person1_longitude=p1["longitude"],
         person1_longitude=p1["longitude"],
-        person2_datetime=p2["birth_datetime"],
+        person2_datetime=p2["birth_datetime_utc"],
         person2_latitude=p2["latitude"],
         person2_latitude=p2["latitude"],
         person2_longitude=p2["longitude"],
         person2_longitude=p2["longitude"],
         elevation=p1.get("elevation", 0.0),
         elevation=p1.get("elevation", 0.0),
         house_system=house_system,
         house_system=house_system,
         orb_limits=orb_limits,
         orb_limits=orb_limits,
     )
     )
+    if "error" not in result:
+        result["input"]["person1_birth_datetime"] = p1["birth_datetime"]
+        result["input"]["person1_timezone"] = p1.get("timezone")
+        result["input"]["person2_birth_datetime"] = p2["birth_datetime"]
+        result["input"]["person2_timezone"] = p2.get("timezone")
+    return result
 
 
 
 
 @mcp.tool()
 @mcp.tool()
@@ -1857,8 +1885,8 @@ Returns:
     birth = await _get_person_birth_data(person_id)
     birth = await _get_person_birth_data(person_id)
     if "error" in birth:
     if "error" in birth:
         return birth
         return birth
-    return await get_transit_preview(
-        birth_datetime=birth["birth_datetime"],
+    result = await get_transit_preview(
+        birth_datetime=birth["birth_datetime_utc"],
         latitude=birth["latitude"],
         latitude=birth["latitude"],
         longitude=birth["longitude"],
         longitude=birth["longitude"],
         start_date=start_date,
         start_date=start_date,
@@ -1867,6 +1895,10 @@ Returns:
         transit_longitude=transit_longitude,
         transit_longitude=transit_longitude,
         min_significance=min_significance,
         min_significance=min_significance,
     )
     )
+    if "error" not in result:
+        result["input"]["birth_datetime"] = birth["birth_datetime"]
+        result["input"]["timezone"] = birth.get("timezone")
+    return result
 
 
 
 
 # ═══════════════════════════════════════════════════════════════════════
 # ═══════════════════════════════════════════════════════════════════════
@@ -1916,12 +1948,12 @@ async def render_natal_chart(
     include_houses: bool = False,
     include_houses: bool = False,
     title: str | None = None,
     title: str | None = None,
     subtitle: str | None = None,
     subtitle: str | None = None,
+    format: str = "svg",
 ) -> dict[str, Any]:
 ) -> dict[str, Any]:
-    """Render a natal chart wheel as SVG.
+    """Render a natal chart wheel.
 
 
     Calculates planetary positions and renders a visual zodiac wheel with
     Calculates planetary positions and renders a visual zodiac wheel with
-    planets, houses, and aspect lines. The SVG can be displayed in a browser,
-    converted to PDF/PNG, or attached to a chat.
+    planets, houses, and aspect lines. Output as SVG or raster image (PNG/JPG).
 
 
     BIRTH DATA (required):
     BIRTH DATA (required):
         birth_datetime: ISO 8601 datetime with timezone (e.g. "1990-05-15T10:30:00+01:00").
         birth_datetime: ISO 8601 datetime with timezone (e.g. "1990-05-15T10:30:00+01:00").
@@ -1944,9 +1976,10 @@ async def render_natal_chart(
         include_houses: Include a house cusp table (requires table_position != "none").
         include_houses: Include a house cusp table (requires table_position != "none").
         title: Custom chart title. Auto-generated if not provided.
         title: Custom chart title. Auto-generated if not provided.
         subtitle: Custom subtitle. Auto-generated from birth data if not provided.
         subtitle: Custom subtitle. Auto-generated from birth data if not provided.
+        format: Output format — "svg" (default), "png", or "jpg".
 
 
     Returns:
     Returns:
-        Dict with "svg" (SVG string), "format" ("svg"), "width", "height",
+        Dict with "content", "format", "content_type", "width", "height",
         and "included" (list of what's in the chart).
         and "included" (list of what's in the chart).
     """
     """
     from .chart_renderer import render_natal_wheel
     from .chart_renderer import render_natal_wheel
@@ -1963,7 +1996,7 @@ async def render_natal_chart(
     if "error" in chart_data:
     if "error" in chart_data:
         return chart_data
         return chart_data
 
 
-    svg = render_natal_wheel(
+    result = render_natal_wheel(
         chart_data,
         chart_data,
         style=style,
         style=style,
         color_mode=color_mode,
         color_mode=color_mode,
@@ -1973,14 +2006,10 @@ async def render_natal_chart(
         include_houses=include_houses,
         include_houses=include_houses,
         title=title,
         title=title,
         subtitle=subtitle,
         subtitle=subtitle,
+        format=format,
     )
     )
-    return {
-        "svg": svg,
-        "format": "svg",
-        "width": size,
-        "height": size,
-        "included": _included_list(table_position, include_planets, include_houses),
-    }
+    result["included"] = _included_list(table_position, include_planets, include_houses)
+    return result
 
 
 
 
 # ── render_natal_chart_by_id ──────────────────────────────────────────
 # ── render_natal_chart_by_id ──────────────────────────────────────────
@@ -2000,11 +2029,12 @@ async def render_natal_chart_by_id(
     include_planets: bool = False,
     include_planets: bool = False,
     include_houses: bool = False,
     include_houses: bool = False,
     title: str | None = None,
     title: str | None = None,
+    format: str = "svg",
 ) -> dict[str, Any]:
 ) -> dict[str, Any]:
     """Render a natal chart wheel for a person from the database.
     """Render a natal chart wheel for a person from the database.
 
 
     Looks up birth data by person_id or nickname, calculates the chart,
     Looks up birth data by person_id or nickname, calculates the chart,
-    and renders it as an SVG wheel. Same rendering options as render_natal_chart.
+    and renders it as a wheel. Output as SVG or raster image (PNG/JPG).
 
 
     PERSON LOOKUP (required):
     PERSON LOOKUP (required):
         person_id: ID or nickname of a person in the persons database.
         person_id: ID or nickname of a person in the persons database.
@@ -2037,7 +2067,7 @@ async def render_natal_chart_by_id(
     if "error" in chart_data:
     if "error" in chart_data:
         return chart_data
         return chart_data
 
 
-    svg = render_natal_wheel(
+    result = render_natal_wheel(
         chart_data,
         chart_data,
         style=style,
         style=style,
         color_mode=color_mode,
         color_mode=color_mode,
@@ -2046,14 +2076,10 @@ async def render_natal_chart_by_id(
         include_planets=include_planets,
         include_planets=include_planets,
         include_houses=include_houses,
         include_houses=include_houses,
         title=title,
         title=title,
+        format=format,
     )
     )
-    return {
-        "svg": svg,
-        "format": "svg",
-        "width": size,
-        "height": size,
-        "included": _included_list(table_position, include_planets, include_houses),
-    }
+    result["included"] = _included_list(table_position, include_planets, include_houses)
+    return result
 
 
 
 
 # ── render_transit_chart ──────────────────────────────────────────────
 # ── render_transit_chart ──────────────────────────────────────────────
@@ -2075,12 +2101,13 @@ async def render_transit_chart(
     color_mode: str = "color",
     color_mode: str = "color",
     size: int = 600,
     size: int = 600,
     title: str | None = None,
     title: str | None = None,
+    format: str = "svg",
 ) -> dict[str, Any]:
 ) -> dict[str, Any]:
     """Render a transit chart as a bi-wheel (natal inner, transit outer).
     """Render a transit chart as a bi-wheel (natal inner, transit outer).
 
 
     Calculates natal and transiting planet positions, then renders a bi-wheel
     Calculates natal and transiting planet positions, then renders a bi-wheel
     showing natal planets inside and transiting planets outside, with
     showing natal planets inside and transiting planets outside, with
-    transit-to-natal aspect lines.
+    transit-to-natal aspect lines. Output as SVG or raster image (PNG/JPG).
 
 
     BIRTH DATA (required):
     BIRTH DATA (required):
         birth_datetime: ISO 8601 birth datetime with timezone.
         birth_datetime: ISO 8601 birth datetime with timezone.
@@ -2102,9 +2129,10 @@ async def render_transit_chart(
         color_mode: {_RENDER_COLOR_HELP}
         color_mode: {_RENDER_COLOR_HELP}
         size: {_RENDER_SIZE_HELP}
         size: {_RENDER_SIZE_HELP}
         title: {_RENDER_TITLE_HELP}
         title: {_RENDER_TITLE_HELP}
+        format: Output format — "svg" (default), "png", or "jpg".
 
 
     Returns:
     Returns:
-        Dict with "svg" (SVG string), "format", "width", "height", "included".
+        Dict with "content", "format", "content_type", "width", "height", "included".
     """
     """
     from .chart_renderer import render_transit_wheel
     from .chart_renderer import render_transit_wheel
 
 
@@ -2122,20 +2150,16 @@ async def render_transit_chart(
     if "error" in chart_data:
     if "error" in chart_data:
         return chart_data
         return chart_data
 
 
-    svg = render_transit_wheel(
+    result = render_transit_wheel(
         chart_data,
         chart_data,
         style=style,
         style=style,
         color_mode=color_mode,
         color_mode=color_mode,
         size=size,
         size=size,
         title=title,
         title=title,
+        format=format,
     )
     )
-    return {
-        "svg": svg,
-        "format": "svg",
-        "width": size,
-        "height": size,
-        "included": ["wheel"],
-    }
+    result["included"] = ["wheel"]
+    return result
 
 
 
 
 # ── render_transit_chart_by_id ────────────────────────────────────────
 # ── render_transit_chart_by_id ────────────────────────────────────────
@@ -2154,11 +2178,12 @@ async def render_transit_chart_by_id(
     color_mode: str = "color",
     color_mode: str = "color",
     size: int = 600,
     size: int = 600,
     title: str | None = None,
     title: str | None = None,
+    format: str = "svg",
 ) -> dict[str, Any]:
 ) -> dict[str, Any]:
     """Render a transit bi-wheel for a person from the database.
     """Render a transit bi-wheel for a person from the database.
 
 
     Looks up birth data by person_id, calculates transits for the given
     Looks up birth data by person_id, calculates transits for the given
-    date, and renders a bi-wheel chart.
+    date, and renders a bi-wheel chart. Output as SVG or raster image (PNG/JPG).
 
 
     PERSON LOOKUP (required):
     PERSON LOOKUP (required):
         person_id: ID or nickname of a person in the persons database.
         person_id: ID or nickname of a person in the persons database.
@@ -2194,20 +2219,16 @@ async def render_transit_chart_by_id(
     if "error" in chart_data:
     if "error" in chart_data:
         return chart_data
         return chart_data
 
 
-    svg = render_transit_wheel(
+    result = render_transit_wheel(
         chart_data,
         chart_data,
         style=style,
         style=style,
         color_mode=color_mode,
         color_mode=color_mode,
         size=size,
         size=size,
         title=title,
         title=title,
+        format=format,
     )
     )
-    return {
-        "svg": svg,
-        "format": "svg",
-        "width": size,
-        "height": size,
-        "included": ["wheel"],
-    }
+    result["included"] = ["wheel"]
+    return result
 
 
 
 
 # ── render_synastry_chart ─────────────────────────────────────────────
 # ── render_synastry_chart ─────────────────────────────────────────────
@@ -2230,11 +2251,12 @@ async def render_synastry_chart(
     color_mode: str = "color",
     color_mode: str = "color",
     size: int = 800,
     size: int = 800,
     title: str | None = None,
     title: str | None = None,
+    format: str = "svg",
 ) -> dict[str, Any]:
 ) -> dict[str, Any]:
     """Render a synastry (relationship) chart with two side-by-side wheels.
     """Render a synastry (relationship) chart with two side-by-side wheels.
 
 
     Calculates both natal charts and renders them side by side with
     Calculates both natal charts and renders them side by side with
-    interaspect lines between the two charts.
+    interaspect lines between the two charts. Output as SVG or raster image (PNG/JPG).
 
 
     PERSON 1 (required):
     PERSON 1 (required):
         person1_datetime: ISO 8601 birth datetime with timezone.
         person1_datetime: ISO 8601 birth datetime with timezone.
@@ -2278,20 +2300,16 @@ async def render_synastry_chart(
     if "error" in chart_data:
     if "error" in chart_data:
         return chart_data
         return chart_data
 
 
-    svg = render_synastry_wheel(
+    result = render_synastry_wheel(
         chart_data,
         chart_data,
         style=style,
         style=style,
         color_mode=color_mode,
         color_mode=color_mode,
         size=size,
         size=size,
         title=title,
         title=title,
+        format=format,
     )
     )
-    return {
-        "svg": svg,
-        "format": "svg",
-        "width": size,
-        "height": size,
-        "included": ["wheel"],
-    }
+    result["included"] = ["wheel"]
+    return result
 
 
 
 
 # ── render_synastry_chart_by_id ───────────────────────────────────────
 # ── render_synastry_chart_by_id ───────────────────────────────────────
@@ -2309,11 +2327,13 @@ async def render_synastry_chart_by_id(
     color_mode: str = "color",
     color_mode: str = "color",
     size: int = 800,
     size: int = 800,
     title: str | None = None,
     title: str | None = None,
+    format: str = "svg",
 ) -> dict[str, Any]:
 ) -> dict[str, Any]:
     """Render a synastry chart for two people from the database.
     """Render a synastry chart for two people from the database.
 
 
     Looks up both persons by ID or nickname, calculates their synastry,
     Looks up both persons by ID or nickname, calculates their synastry,
     and renders side-by-side natal wheels with interaspect lines.
     and renders side-by-side natal wheels with interaspect lines.
+    Output as SVG or raster image (PNG/JPG).
 
 
     PERSON LOOKUP (required):
     PERSON LOOKUP (required):
         person1_id: ID or nickname of person 1 in the persons database.
         person1_id: ID or nickname of person 1 in the persons database.
@@ -2345,20 +2365,16 @@ async def render_synastry_chart_by_id(
     if "error" in chart_data:
     if "error" in chart_data:
         return chart_data
         return chart_data
 
 
-    svg = render_synastry_wheel(
+    result = render_synastry_wheel(
         chart_data,
         chart_data,
         style=style,
         style=style,
         color_mode=color_mode,
         color_mode=color_mode,
         size=size,
         size=size,
         title=title,
         title=title,
+        format=format,
     )
     )
-    return {
-        "svg": svg,
-        "format": "svg",
-        "width": size,
-        "height": size,
-        "included": ["wheel"],
-    }
+    result["included"] = ["wheel"]
+    return result
 
 
 
 
 # ── render_composite_chart ────────────────────────────────────────────
 # ── render_composite_chart ────────────────────────────────────────────
@@ -2383,11 +2399,13 @@ async def render_composite_chart(
     include_planets: bool = False,
     include_planets: bool = False,
     include_houses: bool = False,
     include_houses: bool = False,
     title: str | None = None,
     title: str | None = None,
+    format: str = "svg",
 ) -> dict[str, Any]:
 ) -> dict[str, Any]:
     """Render a composite chart (midpoint method) as a single wheel.
     """Render a composite chart (midpoint method) as a single wheel.
 
 
     Calculates the composite chart from two people's birth data and renders
     Calculates the composite chart from two people's birth data and renders
     it as a standard natal-style wheel representing the relationship.
     it as a standard natal-style wheel representing the relationship.
+    Output as SVG or raster image (PNG/JPG).
 
 
     PERSON 1 (required):
     PERSON 1 (required):
         person1_datetime: ISO 8601 birth datetime with timezone.
         person1_datetime: ISO 8601 birth datetime with timezone.
@@ -2432,7 +2450,7 @@ async def render_composite_chart(
     if "error" in chart_data:
     if "error" in chart_data:
         return chart_data
         return chart_data
 
 
-    svg = render_natal_wheel(
+    result = render_natal_wheel(
         chart_data,
         chart_data,
         style=style,
         style=style,
         color_mode=color_mode,
         color_mode=color_mode,
@@ -2441,14 +2459,10 @@ async def render_composite_chart(
         include_planets=include_planets,
         include_planets=include_planets,
         include_houses=include_houses,
         include_houses=include_houses,
         title=title,
         title=title,
+        format=format,
     )
     )
-    return {
-        "svg": svg,
-        "format": "svg",
-        "width": size,
-        "height": size,
-        "included": _included_list(table_position, include_planets, include_houses),
-    }
+    result["included"] = _included_list(table_position, include_planets, include_houses)
+    return result
 
 
 
 
 # ── render_composite_chart_by_id ──────────────────────────────────────
 # ── render_composite_chart_by_id ──────────────────────────────────────
@@ -2468,11 +2482,12 @@ async def render_composite_chart_by_id(
     include_planets: bool = False,
     include_planets: bool = False,
     include_houses: bool = False,
     include_houses: bool = False,
     title: str | None = None,
     title: str | None = None,
+    format: str = "svg",
 ) -> dict[str, Any]:
 ) -> dict[str, Any]:
     """Render a composite chart for two people from the database.
     """Render a composite chart for two people from the database.
 
 
     Looks up both persons by ID, calculates the composite chart, and
     Looks up both persons by ID, calculates the composite chart, and
-    renders it as a single natal-style wheel.
+    renders it as a single natal-style wheel. Output as SVG or raster image (PNG/JPG).
 
 
     PERSON LOOKUP (required):
     PERSON LOOKUP (required):
         person1_id: ID or nickname of person 1 in the persons database.
         person1_id: ID or nickname of person 1 in the persons database.
@@ -2490,9 +2505,10 @@ async def render_composite_chart_by_id(
         include_planets: {_RENDER_PLANETS_HELP}
         include_planets: {_RENDER_PLANETS_HELP}
         include_houses: {_RENDER_HOUSES_HELP}
         include_houses: {_RENDER_HOUSES_HELP}
         title: {_RENDER_TITLE_HELP}
         title: {_RENDER_TITLE_HELP}
+        format: Output format — "svg" (default), "png", or "jpg".
 
 
     Returns:
     Returns:
-        Dict with "svg", "format", "width", "height", "included".
+        Dict with "content", "format", "content_type", "width", "height", "included".
     """
     """
     from .chart_renderer import render_natal_wheel
     from .chart_renderer import render_natal_wheel
 
 
@@ -2505,7 +2521,7 @@ async def render_composite_chart_by_id(
     if "error" in chart_data:
     if "error" in chart_data:
         return chart_data
         return chart_data
 
 
-    svg = render_natal_wheel(
+    result = render_natal_wheel(
         chart_data,
         chart_data,
         style=style,
         style=style,
         color_mode=color_mode,
         color_mode=color_mode,
@@ -2514,14 +2530,10 @@ async def render_composite_chart_by_id(
         include_planets=include_planets,
         include_planets=include_planets,
         include_houses=include_houses,
         include_houses=include_houses,
         title=title,
         title=title,
+        format=format,
     )
     )
-    return {
-        "svg": svg,
-        "format": "svg",
-        "width": size,
-        "height": size,
-        "included": _included_list(table_position, include_planets, include_houses),
-    }
+    result["included"] = _included_list(table_position, include_planets, include_houses)
+    return result
 
 
 
 
 # ── render_davison_chart ──────────────────────────────────────────────
 # ── render_davison_chart ──────────────────────────────────────────────
@@ -2546,11 +2558,12 @@ async def render_davison_chart(
     include_planets: bool = False,
     include_planets: bool = False,
     include_houses: bool = False,
     include_houses: bool = False,
     title: str | None = None,
     title: str | None = None,
+    format: str = "svg",
 ) -> dict[str, Any]:
 ) -> dict[str, Any]:
     """Render a Davison chart (midpoint in time and space) as a single wheel.
     """Render a Davison chart (midpoint in time and space) as a single wheel.
 
 
     Calculates the Davison chart from two people's birth data and renders
     Calculates the Davison chart from two people's birth data and renders
-    it as a standard natal-style wheel.
+    it as a standard natal-style wheel. Output as SVG or raster image (PNG/JPG).
 
 
     PERSON 1 (required):
     PERSON 1 (required):
         person1_datetime: ISO 8601 birth datetime with timezone.
         person1_datetime: ISO 8601 birth datetime with timezone.
@@ -2595,7 +2608,7 @@ async def render_davison_chart(
     if "error" in chart_data:
     if "error" in chart_data:
         return chart_data
         return chart_data
 
 
-    svg = render_natal_wheel(
+    result = render_natal_wheel(
         chart_data,
         chart_data,
         style=style,
         style=style,
         color_mode=color_mode,
         color_mode=color_mode,
@@ -2604,14 +2617,10 @@ async def render_davison_chart(
         include_planets=include_planets,
         include_planets=include_planets,
         include_houses=include_houses,
         include_houses=include_houses,
         title=title,
         title=title,
+        format=format,
     )
     )
-    return {
-        "svg": svg,
-        "format": "svg",
-        "width": size,
-        "height": size,
-        "included": _included_list(table_position, include_planets, include_houses),
-    }
+    result["included"] = _included_list(table_position, include_planets, include_houses)
+    return result
 
 
 
 
 # ── render_davison_chart_by_id ────────────────────────────────────────
 # ── render_davison_chart_by_id ────────────────────────────────────────
@@ -2631,11 +2640,12 @@ async def render_davison_chart_by_id(
     include_planets: bool = False,
     include_planets: bool = False,
     include_houses: bool = False,
     include_houses: bool = False,
     title: str | None = None,
     title: str | None = None,
+    format: str = "svg",
 ) -> dict[str, Any]:
 ) -> dict[str, Any]:
     """Render a Davison chart for two people from the database.
     """Render a Davison chart for two people from the database.
 
 
     Looks up both persons by ID, calculates the Davison chart, and
     Looks up both persons by ID, calculates the Davison chart, and
-    renders it as a single natal-style wheel.
+    renders it as a single natal-style wheel. Output as SVG or raster image.
 
 
     PERSON LOOKUP (required):
     PERSON LOOKUP (required):
         person1_id: ID or nickname of person 1 in the persons database.
         person1_id: ID or nickname of person 1 in the persons database.
@@ -2668,7 +2678,7 @@ async def render_davison_chart_by_id(
     if "error" in chart_data:
     if "error" in chart_data:
         return chart_data
         return chart_data
 
 
-    svg = render_natal_wheel(
+    result = render_natal_wheel(
         chart_data,
         chart_data,
         style=style,
         style=style,
         color_mode=color_mode,
         color_mode=color_mode,
@@ -2677,14 +2687,10 @@ async def render_davison_chart_by_id(
         include_planets=include_planets,
         include_planets=include_planets,
         include_houses=include_houses,
         include_houses=include_houses,
         title=title,
         title=title,
+        format=format,
     )
     )
-    return {
-        "svg": svg,
-        "format": "svg",
-        "width": size,
-        "height": size,
-        "included": _included_list(table_position, include_planets, include_houses),
-    }
+    result["included"] = _included_list(table_position, include_planets, include_houses)
+    return result
 
 
 
 
 # ── Helper ────────────────────────────────────────────────────────────
 # ── Helper ────────────────────────────────────────────────────────────