2 İşlemeler 317e173fb9 ... 26be5714d2

Yazar SHA1 Mesaj Tarih
  Lukas Goldschmidt 26be5714d2 render test charts, intent paper 1 gün önce
  Lukas Goldschmidt 2a531c6c1b refactor: embed Astronomicon font, add PNG/JPG output, split chart modules, fix datetime convention 2 hafta önce

+ 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,
        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

+ 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
 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 ────────────────────────────────────────────────────────────