2 Commits 317e173fb9 ... 26be5714d2

Autore SHA1 Messaggio Data
  Lukas Goldschmidt 26be5714d2 render test charts, intent paper 1 giorno fa
  Lukas Goldschmidt 2a531c6c1b refactor: embed Astronomicon font, add PNG/JPG output, split chart modules, fix datetime convention 2 settimane fa

+ 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`.

+ 173 - 0
AstroChart_Source_Reference.md

@@ -0,0 +1,173 @@
+# AstroChart — Source Code Reference
+
+**Library**: AstroChart (a.k.a. AstroDraw)
+**Description**: A free and open-source JavaScript/TypeScript library for generating SVG charts to display planets in astrology.
+**Doc site**: https://astrodraw.github.io/
+**GitHub repo**: https://github.com/AstroDraw/AstroChart
+**Branch**: `main`
+
+---
+
+## Repository Structure
+
+The source code lives under `project/` (not `src/`). Build output (the bundle consumed end-users) is in `dist/`.
+
+```
+AstroChart/
+├── dist/
+│   ├── astrochart.js              # Webpack-bundled build (99KB) — what the docs site loads
+│   └── project/                   # Sub-bundle for project-mode imports
+├── project/
+│   ├── src/                       # TypeScript source
+│   │   ├── index.ts               (172B)   — Public API entry, re-exports Chart
+│   │   ├── chart.ts               (4KB)    — Chart class, public entry point
+│   │   ├── svg.ts                 (84KB)   — SVG drawing primitives + all planet/zodiac glyphs
+│   │   ├── radix.ts               (19KB)   — Radix chart composition (drawBg, drawUniverse, drawPoints...)
+│   │   ├── transit.ts             (13KB)   — Transit chart overlay
+│   │   ├── aspect.ts              (8KB)    — Aspect calculation engine
+│   │   ├── zodiac.ts              (10KB)   — Zodiac logic, dignities, retrograde
+│   │   ├── settings.ts            (8KB)    — Default config (colors, ratios, symbols, scale)
+│   │   ├── utils.ts               (12KB)   — Geometry: getPointPosition, getRulerPositions, collision
+│   │   ├── animation/
+│   │   │   ├── animator.ts        (5KB)    — Animated chart transitions
+│   │   │   └── timer.ts           (1KB)    — Animation timing helper
+│   │   └── *.test.ts              — Jest test suite
+│   ├── examples/                  — Usage examples
+│   └── __tests__/                 — Additional tests
+├── website/                       # Docusaurus doc site source (what renders astrodraw.github.io)
+├── docs/                          # Doc site content (markdown)
+├── doc/                           # Older documentation
+├── package.json
+├── tsconfig.json
+├── webpack.config.js
+└── jest.config.js
+```
+
+---
+
+## Chart Rendering Flow
+
+### Public API
+
+```typescript
+// project/src/chart.ts
+class Chart {
+  constructor(elementId: string, width: number, height: number, settings?: Partial<Settings>)
+  radix(data: AstroData): Radix      // Draw a natal/radix chart
+  transit(data: AstroData): Transit  // Draw a transit overlay
+  scale(factor: number): void        // Scale the chart
+  calibrate(): Chart                 // Debug overlay
+}
+```
+
+```typescript
+// project/src/index.ts — re-exports Chart as default
+```
+
+### Drawing Pipeline (inside `Radix`)
+
+Order of operations in `Radix` constructor + draw methods:
+
+| Step | Method | File | What it does |
+|------|--------|------|--------------|
+| 1 | `drawBg()` | `project/src/radix.ts` | Background hemisphere via `paper.segment()` |
+| 2 | `drawUniverse()` | `project/src/radix.ts` | 12 zodiac sign color segments + 12 zodiac symbol glyphs |
+| 3 | `drawRuler()` | `project/src/radix.ts` | Degree tick marks around the wheel |
+| 4 | `drawPoints()` | `project/src/radix.ts` | Planet symbols, pointer lines, degree/retro/dignity labels |
+| 5 | `drawCusps()` | `project/src/radix.ts` | 12 cusp lines (dashed around planets), house numbers |
+| 6 | `drawAxis()` | `project/src/radix.ts` | Asc/Ds/Ic/Mc axis lines with symbols |
+| 7 | `drawCircles()` | `project/src/radix.ts` | Outer, inner, indoor circle strokes |
+
+Aspects are drawn via `Radix.aspects()` which uses `project/src/aspect.ts`.
+
+---
+
+## Key Source Files (download URLs)
+
+### Core rendering
+
+| File | Description |
+|------|-------------|
+| [svg.ts](https://raw.githubusercontent.com/AstroDraw/AstroChart/main/project/src/svg.ts) | SVG primitives: `segment()`, `circle()`, `line()`, `text()`, and 50+ glyph methods (`sun()`, `moon()`, `aries()`, `taurus()`, ... `pluto()`, `chiron()`, `nnode()`, `fortune()`). The `<path d="...">` arc math lives at lines ~1468-1478. |
+| [radix.ts](https://raw.githubusercontent.com/AstroDraw/AstroChart/main/project/src/radix.ts) | Chart composition — orchestrates all SVG calls. `LocatedPoint` interface, `AstroData` type, collision avoidance via `assemble()`. |
+| [chart.ts](https://raw.githubusercontent.com/AstroDraw/AstroChart/main/project/src/chart.ts) | Public entry point. Creates `SVG`, delegates to `Radix` or `Transit`. |
+
+### Data + logic
+
+| File | Description |
+|------|-------------|
+| [settings.ts](https://raw.githubusercontent.com/AstroDraw/AstroChart/main/project/src/settings.ts) | Default constants: `COLORS_SIGNS`, `SYMBOL_SIGNS`, `INNER_CIRCLE_RADIUS_RATIO`, `INDOOR_CIRCLE_RADIUS_RATIO`, `RULER_RADIUS`, `SYMBOL_SCALE`, `PADDING`, etc. |
+| [utils.ts](https://raw.githubusercontent.com/AstroDraw/AstroChart/main/project/src/utils.ts) | `getPointPosition()`, `getRulerPositions()`, `getDashedLinesPositions()`, `getDescriptionPosition()`, `assemble()`, `validate()`, `radiansToDegree()`. |
+| [zodiac.ts](https://raw.githubusercontent.com/AstroDraw/AstroChart/main/project/src/zodiac.ts) | Zodiac sign boundaries, essential dignities (domicile, exaltation, detriment, fall), retrograde detection. |
+| [aspect.ts](https://raw.githubusercontent.com/AstroDraw/AstroChart/main/project/src/aspect.ts) | `AspectCalculator` class — computes aspects between planet points (conjunction, opposition, trine, square, sextile, etc.). |
+| [transit.ts](https://raw.githubusercontent.com/AstroDraw/AstroChart/main/project/src/transit.ts) | Transit chart — draws on top of an existing radix. |
+| [animation/animator.ts](https://raw.githubusercontent.com/AstroDraw/AstroChart/main/project/src/animation/animator.ts) | Animated chart transitions. |
+| [animation/timer.ts](https://raw.githubusercontent.com/AstroDraw/AstroChart/main/project/src/animation/timer.ts) | Animation timing helper. |
+| [index.ts](https://raw.githubusercontent.com/AstroDraw/AstroChart/main/project/src/index.ts) | Public API barrel file. |
+
+### Built artifact
+
+| File | Description |
+|------|-------------|
+| [dist/astrochart.js](https://raw.githubusercontent.com/AstroDraw/AstroChart/main/dist/astrochart.js) | Webpack bundle — the file loaded by the doc site and distributed to end-users. |
+
+---
+
+## SVG Primitives API (svg.ts)
+
+| Method | Signature | Purpose |
+|--------|-----------|---------|
+| `segment()` | `(x, y, radius, a1, a2, thickness, lFlag?, sFlag?)` | Arc slice (used for sign segments, background) |
+| `circle()` | `(cx, cy, radius)` | Circle stroke (chart rings) |
+| `line()` | `(x1, y1, x2, y2)` | Straight line (cusps, pointers, ruler ticks) |
+| `text()` | `(txt, x, y, size, color)` | Symbol/text glyph |
+| `getSymbol()` | `(name, x, y)` | Dispatches to the correct glyph method |
+| `radialLine()` | — | Radial spoke from center to edge |
+
+**Key constants** (defined in `settings.ts`, used in `svg.ts` line ~1468):
+- `LARGE_ARC_FLAG = 1`
+- `SWEET_FLAG = 0`
+
+---
+
+## Doc Site (what renders the GitHub Pages UI)
+
+The documentation site that appears at https://astrodraw.github.io/ is built with **Docusaurus v2.3.1** (seen in `<meta name="generator">`).
+
+| File | Description |
+|------|-------------|
+| [website/](https://github.com/AstroDraw/AstroChart/tree/main/website) | Docusaurus source (docusaurus.config, sidebar, pages) |
+| [docs/](https://github.com/AstroDraw/AstroChart/tree/main/docs) | Doc content markdown |
+
+The homepage hero banner contains an inline SVG showing a React-like logo (unrelated to this library — it's Docusaurus template branding). The actual AstroChart demo rendering on the page uses the `Chart` class targeting `<div id="paper">`.
+
+---
+
+## Key Data Structures
+
+```typescript
+// From radix.ts
+export type Points = Record<string, number[]>
+export interface LocatedPoint {
+  name: string   // planet name, e.g. "Sun", "Moon"
+  x: number      // computed screen x
+  y: number      // computed screen y
+  r: number      // collision radius
+  angle: number  // shifted position in degrees
+  pointer?: number
+  index?: number
+}
+export interface AstroData {
+  planets: Points   // {"Sun":[120], "Moon":[45], ...}
+  cusps: number[]   // [300, 340, 30, 60, 75, 90, 116, 172, 210, 236, 250, 274]
+}
+```
+
+---
+
+## Notes
+
+- The library is **TypeScript** (compiled via webpack, not tsc).
+- No runtime dependencies — pure DOM SVG manipulation.
+- Tests use **Jest** (`jest.config.js`).
+- The `project/` subdirectory is the actual library source; the repo root contains build/config/docs.

+ 14 - 2
PROJECT.md

@@ -74,15 +74,27 @@ No `pyswisseph` -- all astronomical data comes from ephemeris-mcp via MCP client
        id TEXT PRIMARY KEY,
        name TEXT NOT NULL,
        nickname TEXT UNIQUE,
-       birth_datetime TEXT NOT NULL,
+       birth_datetime TEXT NOT NULL,  -- naive LOCAL time (no offset)
+       birthplace TEXT,
        latitude REAL NOT NULL,
        longitude REAL NOT NULL,
        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,
        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)
 

+ 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`)
 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:
 - `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
-- `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`,
 `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: "get"` — retrieve by ID or nickname

+ 166 - 0
render_test_charts.py

@@ -0,0 +1,166 @@
+#!/usr/bin/env python3
+"""
+Render test charts for two celebrities and save to /home/shared/.
+
+Usage:
+    python3 render_test_charts.py [--format svg|png|jpg] [--size N]
+"""
+
+import argparse
+import asyncio
+import json
+import sys
+import os
+
+# Ensure src is on the path
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
+
+from astro_mcp.tools import calculate_natal_chart, render_natal_chart
+
+
+# ── Birth data ───────────────────────────────────────────────────────
+
+PEOPLE = {
+    "donald_trump": {
+        "birth_datetime": "1946-06-14T11:00:00-04:00",
+        "latitude": 40.70,
+        "longitude": -73.80,
+        "elevation": 10,
+    },
+    "chaka_khan": {
+        "birth_datetime": "1953-03-23T11:00:00-06:00",
+        "latitude": 41.88,
+        "longitude": -87.63,
+        "elevation": 180,
+    },
+}
+
+
+async def render_person(name: str, birth_data: dict, fmt: str, size: int) -> dict:
+    """Calculate and render a natal chart for one person.
+
+    Returns a summary dict with moon info and output path.
+    """
+    # 1. Calculate chart data
+    chart_data = await calculate_natal_chart(
+        birth_datetime=birth_data["birth_datetime"],
+        latitude=birth_data["latitude"],
+        longitude=birth_data["longitude"],
+        elevation=birth_data["elevation"],
+    )
+    if "error" in chart_data:
+        print(f"  ERROR calculating {name}: {chart_data['error']}")
+        return None
+
+    # 2. Render
+    result = await render_natal_chart(
+        birth_datetime=birth_data["birth_datetime"],
+        latitude=birth_data["latitude"],
+        longitude=birth_data["longitude"],
+        elevation=birth_data["elevation"],
+        color_mode="color",
+        size=size,
+        format=fmt,
+        title=f"Natal Chart: {name.replace('_', ' ').title()}",
+    )
+    if "error" in result:
+        print(f"  ERROR rendering {name}: {result['error']}")
+        return None
+
+    # 3. Save
+    ext = fmt
+    filename = f"{name}_natal.{ext}"
+    outdir = "/home/shared"
+    os.makedirs(outdir, exist_ok=True)
+    outpath = os.path.join(outdir, filename)
+
+    content = result["content"]
+    mode = "w" if isinstance(content, str) else "wb"
+    with open(outpath, mode) as f:
+        f.write(content)
+
+    # 4. Extract moon info
+    moon_info = None
+    for p in chart_data.get("planets", []):
+        if p.get("body") == "moon":
+            moon_info = {
+                "absolute_lon": p.get("absolute_lon"),
+                "degree_within_sign": p.get("degree_within_sign"),
+                "sign": p.get("sign"),
+                "house": p.get("house"),
+                "retrograde": p.get("retrograde"),
+            }
+            break
+
+    return {
+        "name": name,
+        "output": outpath,
+        "format": result["format"],
+        "content_type": result["content_type"],
+        "size_bytes": len(content) if isinstance(content, bytes) else len(content.encode("utf-8")),
+        "moon": moon_info,
+        "all_planets": [
+            {"body": p.get("body"), "absolute_lon": p.get("absolute_lon"),
+             "degree_within_sign": p.get("degree_within_sign"),
+             "sign": p.get("sign"), "house": p.get("house"),
+             "retrograde": p.get("retrograde")}
+            for p in chart_data.get("planets", [])
+        ],
+        "houses_count": len(chart_data.get("houses", [])),
+        "aspects_count": len(chart_data.get("aspects", [])),
+    }
+
+
+async def main():
+    parser = argparse.ArgumentParser(description="Render test natal charts")
+    parser.add_argument("--format", default="svg", choices=["svg", "png", "jpg"],
+                        help="Output format (default: svg)")
+    parser.add_argument("--size", type=int, default=600,
+                        help="Canvas size in pixels (default: 600)")
+    parser.add_argument("--color-mode", default="color", choices=["color", "bw", "dark"],
+                        help="Color theme (default: color)")
+    args = parser.parse_args()
+
+    print(f"Rendering test charts — format={args.format}, size={args.size}px, theme={args.color_mode}")
+    print(f"Output directory: /home/shared/")
+    print()
+
+    results = []
+    for name, birth_data in PEOPLE.items():
+        print(f"Processing {name.replace('_', ' ').title()}...")
+        print(f"  Birth: {birth_data['birth_datetime']} @ {birth_data['latitude']}, {birth_data['longitude']}")
+        result = await render_person(name, birth_data, args.format, args.size)
+        if result:
+            results.append(result)
+            print(f"  Saved: {result['output']} ({result['size_bytes']:,} bytes)")
+            if result["moon"]:
+                m = result["moon"]
+                print(f"  MOON: {m['sign']} {m['degree_within_sign']:.1f}° (lon={m['absolute_lon']:.2f}°, house {m['house']})")
+            else:
+                print(f"  MOON: not found in data!")
+            print(f"  Planets: {len(result['all_planets'])}, Houses: {result['houses_count']}, Aspects: {result['aspects_count']}")
+        else:
+            print(f"  FAILED")
+        print()
+
+    # Summary comparison
+    print("=" * 60)
+    print("MOON COMPARISON")
+    print("=" * 60)
+    for r in results:
+        m = r.get("moon", {})
+        if m:
+            print(f"  {r['name']:20s} | Moon in {m.get('sign','?'):12s} {m.get('degree_within_sign',0):5.1f}° | House {m.get('house','?')} | Retrograde: {m.get('retrograde',False)}")
+        else:
+            print(f"  {r['name']:20s} | Moon data MISSING")
+    print()
+
+    # Save JSON summary
+    summary_path = "/home/shared/test_charts_summary.json"
+    with open(summary_path, "w") as f:
+        json.dump(results, f, indent=2, default=str)
+    print(f"Summary saved to: {summary_path}")
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 3 - 0
requirements.txt

@@ -9,6 +9,9 @@ python-dotenv>=1.0.1
 starlette>=0.49.1
 sse-starlette>=3.4.4
 requests>=2.31
+svgwrite>=1.4
+pillow>=10.0
+cairosvg>=2.7
 
 # Testing
 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 __version__
 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")
 
-# ── 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 ──────────────────────────────────────────────────
 
@@ -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
 
 
-def _r(outer_r: float, frac: float) -> float:
-    return outer_r * frac
-
-
 # ── Main natal wheel renderer ─────────────────────────────────────────
 
 def render_natal_wheel(
@@ -273,8 +148,9 @@ def render_natal_wheel(
     include_houses: bool = False,
     title: 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).
     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.
         title: Override auto-generated title line.
         subtitle: Override auto-generated subtitle line.
+        format: Output format — "svg", "png", or "jpg". Default "svg".
 
     Returns:
-        SVG string.
+        Dict with content, format, content_type, width, height, and included.
     """
     theme = THEMES.get(color_mode, THEMES["color"])
     planets = chart_data.get("planets", [])
@@ -329,14 +206,7 @@ def render_natal_wheel(
     dwg = svgwrite.Drawing(size=(canvas_w, canvas_h))
     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
     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,
                                   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 ──────────────────────────────────────────────
@@ -635,7 +506,7 @@ def _render_title_corner(
     if lat is not None and lon is not None:
         lat_dir = "N" if lat >= 0 else "S"
         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:
         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",
                       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)]
     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",
                       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)]
     cx_pos = x + 5
@@ -870,8 +741,9 @@ def render_transit_wheel(
     color_mode: str = "color",
     size: int = 700,
     table_position: str = "none",
+    format: str = "svg",
     **kwargs,
-) -> str:
+) -> dict[str, Any]:
     """Render a transit chart as bi-wheel (natal inner, transit outer)."""
     theme      = THEMES.get(color_mode, THEMES["color"])
     natal      = chart_data.get("natal_planets", [])
@@ -895,11 +767,7 @@ def render_transit_wheel(
     r_center    = _r(outer_r, _R_CENTER)
 
     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"]))
 
     # Title corner
@@ -1009,7 +877,8 @@ def render_transit_wheel(
         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) ──────────────────────────────────────────
@@ -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."""
     chart_type = chart_data.get("chart_type", "natal")
     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.
         name: Person's full name (required for add).
         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").
         latitude: Birth latitude (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
     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 .ephemeris_client import _normalize_datetime
@@ -1602,7 +1602,9 @@ async def _get_person_birth_data(person_id: str) -> dict[str, Any]:
         lon=person["longitude"],
     )
     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"),
         "latitude": person["latitude"],
         "longitude": person["longitude"],
@@ -1641,8 +1643,8 @@ async def calculate_natal_chart_by_id(
     birth = await _get_person_birth_data(person_id)
     if "error" in 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"],
         longitude=birth["longitude"],
         elevation=birth.get("elevation", 0.0),
@@ -1653,6 +1655,10 @@ async def calculate_natal_chart_by_id(
         include_karmic=include_karmic,
         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()
@@ -1683,8 +1689,8 @@ Returns:
     birth = await _get_person_birth_data(person_id)
     if "error" in 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,
         latitude=birth["latitude"],
         longitude=birth["longitude"],
@@ -1694,6 +1700,10 @@ Returns:
         house_system=house_system,
         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()
@@ -1732,11 +1742,11 @@ async def calculate_synastry_chart_by_id(
     p2 = await _get_person_birth_data(person2_id)
     if "error" in 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_longitude=p1["longitude"],
-        person2_datetime=p2["birth_datetime"],
+        person2_datetime=p2["birth_datetime_utc"],
         person2_latitude=p2["latitude"],
         person2_longitude=p2["longitude"],
         elevation=p1.get("elevation", 0.0),
@@ -1747,6 +1757,12 @@ async def calculate_synastry_chart_by_id(
         significator_filter=significator_filter,
         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()
@@ -1776,17 +1792,23 @@ Returns:
     p2 = await _get_person_birth_data(person2_id)
     if "error" in 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_longitude=p1["longitude"],
-        person2_datetime=p2["birth_datetime"],
+        person2_datetime=p2["birth_datetime_utc"],
         person2_latitude=p2["latitude"],
         person2_longitude=p2["longitude"],
         elevation=p1.get("elevation", 0.0),
         house_system=house_system,
         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()
@@ -1816,17 +1838,23 @@ Returns:
     p2 = await _get_person_birth_data(person2_id)
     if "error" in 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_longitude=p1["longitude"],
-        person2_datetime=p2["birth_datetime"],
+        person2_datetime=p2["birth_datetime_utc"],
         person2_latitude=p2["latitude"],
         person2_longitude=p2["longitude"],
         elevation=p1.get("elevation", 0.0),
         house_system=house_system,
         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()
@@ -1857,8 +1885,8 @@ Returns:
     birth = await _get_person_birth_data(person_id)
     if "error" in 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"],
         longitude=birth["longitude"],
         start_date=start_date,
@@ -1867,6 +1895,10 @@ Returns:
         transit_longitude=transit_longitude,
         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,
     title: str | None = None,
     subtitle: str | None = None,
+    format: str = "svg",
 ) -> 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
-    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_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").
         title: Custom chart title. Auto-generated if not provided.
         subtitle: Custom subtitle. Auto-generated from birth data if not provided.
+        format: Output format — "svg" (default), "png", or "jpg".
 
     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).
     """
     from .chart_renderer import render_natal_wheel
@@ -1963,7 +1996,7 @@ async def render_natal_chart(
     if "error" in chart_data:
         return chart_data
 
-    svg = render_natal_wheel(
+    result = render_natal_wheel(
         chart_data,
         style=style,
         color_mode=color_mode,
@@ -1973,14 +2006,10 @@ async def render_natal_chart(
         include_houses=include_houses,
         title=title,
         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 ──────────────────────────────────────────
@@ -2000,11 +2029,12 @@ async def render_natal_chart_by_id(
     include_planets: bool = False,
     include_houses: bool = False,
     title: str | None = None,
+    format: str = "svg",
 ) -> dict[str, Any]:
     """Render a natal chart wheel for a person from the database.
 
     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_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:
         return chart_data
 
-    svg = render_natal_wheel(
+    result = render_natal_wheel(
         chart_data,
         style=style,
         color_mode=color_mode,
@@ -2046,14 +2076,10 @@ async def render_natal_chart_by_id(
         include_planets=include_planets,
         include_houses=include_houses,
         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 ──────────────────────────────────────────────
@@ -2075,12 +2101,13 @@ async def render_transit_chart(
     color_mode: str = "color",
     size: int = 600,
     title: str | None = None,
+    format: str = "svg",
 ) -> dict[str, Any]:
     """Render a transit chart as a bi-wheel (natal inner, transit outer).
 
     Calculates natal and transiting planet positions, then renders a bi-wheel
     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_datetime: ISO 8601 birth datetime with timezone.
@@ -2102,9 +2129,10 @@ async def render_transit_chart(
         color_mode: {_RENDER_COLOR_HELP}
         size: {_RENDER_SIZE_HELP}
         title: {_RENDER_TITLE_HELP}
+        format: Output format — "svg" (default), "png", or "jpg".
 
     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
 
@@ -2122,20 +2150,16 @@ async def render_transit_chart(
     if "error" in chart_data:
         return chart_data
 
-    svg = render_transit_wheel(
+    result = render_transit_wheel(
         chart_data,
         style=style,
         color_mode=color_mode,
         size=size,
         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 ────────────────────────────────────────
@@ -2154,11 +2178,12 @@ async def render_transit_chart_by_id(
     color_mode: str = "color",
     size: int = 600,
     title: str | None = None,
+    format: str = "svg",
 ) -> dict[str, Any]:
     """Render a transit bi-wheel for a person from the database.
 
     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_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:
         return chart_data
 
-    svg = render_transit_wheel(
+    result = render_transit_wheel(
         chart_data,
         style=style,
         color_mode=color_mode,
         size=size,
         title=title,
+        format=format,
     )
-    return {
-        "svg": svg,
-        "format": "svg",
-        "width": size,
-        "height": size,
-        "included": ["wheel"],
-    }
+    result["included"] = ["wheel"]
+    return result
 
 
 # ── render_synastry_chart ─────────────────────────────────────────────
@@ -2230,11 +2251,12 @@ async def render_synastry_chart(
     color_mode: str = "color",
     size: int = 800,
     title: str | None = None,
+    format: str = "svg",
 ) -> dict[str, Any]:
     """Render a synastry (relationship) chart with two side-by-side wheels.
 
     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):
         person1_datetime: ISO 8601 birth datetime with timezone.
@@ -2278,20 +2300,16 @@ async def render_synastry_chart(
     if "error" in chart_data:
         return chart_data
 
-    svg = render_synastry_wheel(
+    result = render_synastry_wheel(
         chart_data,
         style=style,
         color_mode=color_mode,
         size=size,
         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 ───────────────────────────────────────
@@ -2309,11 +2327,13 @@ async def render_synastry_chart_by_id(
     color_mode: str = "color",
     size: int = 800,
     title: str | None = None,
+    format: str = "svg",
 ) -> dict[str, Any]:
     """Render a synastry chart for two people from the database.
 
     Looks up both persons by ID or nickname, calculates their synastry,
     and renders side-by-side natal wheels with interaspect lines.
+    Output as SVG or raster image (PNG/JPG).
 
     PERSON LOOKUP (required):
         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:
         return chart_data
 
-    svg = render_synastry_wheel(
+    result = render_synastry_wheel(
         chart_data,
         style=style,
         color_mode=color_mode,
         size=size,
         title=title,
+        format=format,
     )
-    return {
-        "svg": svg,
-        "format": "svg",
-        "width": size,
-        "height": size,
-        "included": ["wheel"],
-    }
+    result["included"] = ["wheel"]
+    return result
 
 
 # ── render_composite_chart ────────────────────────────────────────────
@@ -2383,11 +2399,13 @@ async def render_composite_chart(
     include_planets: bool = False,
     include_houses: bool = False,
     title: str | None = None,
+    format: str = "svg",
 ) -> dict[str, Any]:
     """Render a composite chart (midpoint method) as a single wheel.
 
     Calculates the composite chart from two people's birth data and renders
     it as a standard natal-style wheel representing the relationship.
+    Output as SVG or raster image (PNG/JPG).
 
     PERSON 1 (required):
         person1_datetime: ISO 8601 birth datetime with timezone.
@@ -2432,7 +2450,7 @@ async def render_composite_chart(
     if "error" in chart_data:
         return chart_data
 
-    svg = render_natal_wheel(
+    result = render_natal_wheel(
         chart_data,
         style=style,
         color_mode=color_mode,
@@ -2441,14 +2459,10 @@ async def render_composite_chart(
         include_planets=include_planets,
         include_houses=include_houses,
         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 ──────────────────────────────────────
@@ -2468,11 +2482,12 @@ async def render_composite_chart_by_id(
     include_planets: bool = False,
     include_houses: bool = False,
     title: str | None = None,
+    format: str = "svg",
 ) -> dict[str, Any]:
     """Render a composite chart for two people from the database.
 
     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):
         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_houses: {_RENDER_HOUSES_HELP}
         title: {_RENDER_TITLE_HELP}
+        format: Output format — "svg" (default), "png", or "jpg".
 
     Returns:
-        Dict with "svg", "format", "width", "height", "included".
+        Dict with "content", "format", "content_type", "width", "height", "included".
     """
     from .chart_renderer import render_natal_wheel
 
@@ -2505,7 +2521,7 @@ async def render_composite_chart_by_id(
     if "error" in chart_data:
         return chart_data
 
-    svg = render_natal_wheel(
+    result = render_natal_wheel(
         chart_data,
         style=style,
         color_mode=color_mode,
@@ -2514,14 +2530,10 @@ async def render_composite_chart_by_id(
         include_planets=include_planets,
         include_houses=include_houses,
         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 ──────────────────────────────────────────────
@@ -2546,11 +2558,12 @@ async def render_davison_chart(
     include_planets: bool = False,
     include_houses: bool = False,
     title: str | None = None,
+    format: str = "svg",
 ) -> dict[str, Any]:
     """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
-    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):
         person1_datetime: ISO 8601 birth datetime with timezone.
@@ -2595,7 +2608,7 @@ async def render_davison_chart(
     if "error" in chart_data:
         return chart_data
 
-    svg = render_natal_wheel(
+    result = render_natal_wheel(
         chart_data,
         style=style,
         color_mode=color_mode,
@@ -2604,14 +2617,10 @@ async def render_davison_chart(
         include_planets=include_planets,
         include_houses=include_houses,
         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 ────────────────────────────────────────
@@ -2631,11 +2640,12 @@ async def render_davison_chart_by_id(
     include_planets: bool = False,
     include_houses: bool = False,
     title: str | None = None,
+    format: str = "svg",
 ) -> dict[str, Any]:
     """Render a Davison chart for two people from the database.
 
     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):
         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:
         return chart_data
 
-    svg = render_natal_wheel(
+    result = render_natal_wheel(
         chart_data,
         style=style,
         color_mode=color_mode,
@@ -2677,14 +2687,10 @@ async def render_davison_chart_by_id(
         include_planets=include_planets,
         include_houses=include_houses,
         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 ────────────────────────────────────────────────────────────