Fix bar charts, fonts, axis controls, donut support, and Playwright export

Issue 1 – Bar charts blank/lines only:
- Silent fall-through on unsupported chart_types (donut, stacked_bar, area)
  now raises ValueError instead of producing axes-only output
- Zero-width bars on duplicate/single dates fixed via sorted-gap calculation
- Donut chart type added (ring with percentage labels)
- Pie/donut routing now triggers on any() instead of all()

Issue 2 – Axis controls not applying:
- AxisSpec gains date_min/date_max (x-axis clamping via prompts)
- y-bounds no longer silently widened when user sets min_val/max_val
- Tick clamping: ticks outside user range are dropped not widened
- New dual_y_axis layout with independent left/right Y-axes and y_axis_side per series
- Endpoint Y-axis labels (min/max) always render even when spacing is tight

Issue 3+4 – Font fallback & InDesign compatibility:
- Replace CairoSVG with Playwright/headless Chromium for PNG and PDF export
- Chromium honours @font-face base64 data URIs → Roboto Condensed in all exports
- PDF output contains embedded TTF subsets and real text operators (selectable
  in InDesign/Illustrator, no path-outlining, consistent across regions)
- FastAPI lifespan manages persistent Playwright browser instance

Issue 5 – Stroke weight drift:
- All stroke_width values now carry explicit "px" unit suffix
- SVG root gets width="…px" height="…px" so 1 SVG px = 0.75 PDF pt exactly

AI improvements:
- Prompts document date_min/date_max, scale_kind, dual_y_axis, donut
- Rule 9 softened: user-specified ranges are honoured even if they crop data
- Refinement uses deep-merge so tick_interval/min_val/date_min are never
  accidentally reset to None when Claude modifies unrelated fields
- New donut few-shot example added

Library upgrades: anthropic 0.84→0.97, fastapi 0.135→0.136,
pandas 3.0.1→3.0.2, pydantic 2.12→2.13, uvicorn 0.41→0.46;
cairosvg removed, playwright 1.58.0 added.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-04-28 13:15:26 +01:00
parent db853cea9e
commit d52f088243
14 changed files with 676 additions and 366 deletions

View file

@ -1,10 +1,12 @@
FROM python:3.11-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
libcairo2 libpango-1.0-0 libpangocairo-1.0-0 curl fontconfig \
curl fontconfig \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Install Playwright Chromium and its OS dependencies
RUN playwright install --with-deps chromium
COPY . .
RUN mkdir -p /app/output && \
mkdir -p /usr/share/fonts/truetype/roboto && \

View file

@ -9,13 +9,9 @@ from app.ai.prompts import SYSTEM_PROMPT, REFINE_SYSTEM_PROMPT, FEW_SHOT_EXAMPLE
def interpret_brief(brief: str, data_summary: str) -> ChartSpec:
"""Send brief + data summary to Claude and get back a ChartSpec.
Uses Claude's tool use feature to force structured JSON output.
"""
"""Send brief + data summary to Claude and get back a ChartSpec."""
client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
# Build few-shot messages
messages = []
for ex in FEW_SHOT_EXAMPLES:
messages.append({
@ -44,7 +40,6 @@ def interpret_brief(brief: str, data_summary: str) -> ChartSpec:
],
})
# Add the actual request
messages.append({
"role": "user",
"content": f"DATA AVAILABLE:\n{data_summary}\n\nBRIEF:\n{brief}",
@ -80,15 +75,15 @@ def refine_spec(
) -> ChartSpec:
"""Refine an existing ChartSpec based on a natural language edit instruction.
Sends the current spec + conversation history + edit to Claude,
which returns an updated ChartSpec.
After Claude returns an updated spec, the result is deep-merged against the
current spec so that any field Claude omitted keeps its prior value rather
than reverting to the Pydantic default.
"""
client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
# Build conversation context from history
history_text = "\n".join(
f"{'User' if h['role'] == 'user' else 'System'}: {h['message']}"
for h in history[:-1] # exclude the latest edit (we send it separately)
for h in history[:-1]
)
current_spec_json = json.dumps(current_spec.model_dump(), indent=2, default=str)
@ -124,6 +119,39 @@ def refine_spec(
for block in response.content:
if block.type == "tool_use":
return ChartSpec.model_validate(block.input)
new_spec = ChartSpec.model_validate(block.input)
return _deep_merge_spec(current_spec, new_spec)
raise ValueError("Claude did not return an updated chart specification")
def _deep_merge_spec(base: ChartSpec, updated: ChartSpec) -> ChartSpec:
"""Merge `updated` onto `base`, preserving non-None base values wherever
`updated` has reverted a field to its Pydantic default (None / empty).
This prevents Claude from accidentally resetting tick_interval, min_val,
date_min, etc. when it wasn't asked to touch those fields.
"""
base_dict = base.model_dump()
updated_dict = updated.model_dump()
merged = _merge_dicts(base_dict, updated_dict)
return ChartSpec.model_validate(merged)
def _merge_dicts(base: dict, updated: dict) -> dict:
result = dict(base)
for key, updated_val in updated.items():
base_val = base.get(key)
if isinstance(updated_val, dict) and isinstance(base_val, dict):
result[key] = _merge_dicts(base_val, updated_val)
elif isinstance(updated_val, list) and isinstance(base_val, list):
# For lists (panels, series, annotations): if updated is non-empty, use it
# If updated returned empty but base had content, keep base
result[key] = updated_val if updated_val else base_val
elif updated_val is None and base_val is not None:
# Claude reset a previously-set field to None — keep the base value
result[key] = base_val
else:
result[key] = updated_val
return result

View file

@ -21,9 +21,13 @@ SYSTEM_PROMPT = """You are a PIMCO chart specification generator. You take a use
## Chart Types Available
1. **Multi-line time series** (layout: "single"): Multiple data columns as separate line series
2. **Line chart with annotations**: Single or dual series with ellipse highlights on interesting regions
3. **Dual-panel side-by-side** (layout: "dual_panel"): Two related charts shown together, often with trend lines and shaded fill areas
4. **Bar charts**: Vertical bars for categorical or time-based comparisons
5. **Stacked bar charts**: Multiple categories stacked
3. **Dual-panel side-by-side** (layout: "dual_panel"): Two related charts shown together, each with its own independent axes, often with trend lines and shaded fill areas
4. **Bar charts**: Vertical bars for time-based comparisons. Uses scale_kind: "date" (default) on x_axis
5. **Categorical bar charts**: Bars for non-date categories (credit ratings, sectors, countries). Set x_axis.scale_kind: "category"
6. **Stacked bar charts**: Multiple categories stacked (use chart_type: "bar" for now)
7. **Donut charts** (chart_type: "donut"): Ring chart with percentage labels. All series in the panel must be donut type
8. **Pie charts** (chart_type: "pie"): All series in the panel must be pie type
9. **Dual Y-axis** (layout: "dual_y_axis"): One panel, two independent Y-axes (primary on left, secondary on right), shared X-axis. Use when two metrics share an X-axis but have incompatible scales (e.g. price % left, spread bps right)
## Rules for ChartSpec Output
1. Always use the exact column names from the data summary
@ -34,15 +38,25 @@ SYSTEM_PROMPT = """You are a PIMCO chart specification generator. You take a use
6. For dual panels, ensure each panel's series reference columns that exist in the same data sheet
7. When a brief mentions trend lines or reference levels, create separate series with dashed/dotted line_style
8. Use shaded_fill when the brief asks for deviation/gap highlighting between two series
9. Set min_val/max_val to encompass all data with a small buffer. Never set ranges tighter than the actual data.
9. Set min_val/max_val when the user specifies a range. Honor user-specified ranges even if they crop some data points only add a small buffer when the brief does NOT specify bounds.
10. Keep series labels concise (under 30 characters) to avoid legend overflow.
11. If numeric data has very small values (e.g. min=-0.0042, max=0.012) but the brief or column name suggests percentages, the data is likely in decimal form (Excel stores -0.42% as -0.0042). Inform the user and set the Y-axis accordingly do NOT set suffix="%" unless values are already in display form (e.g. -0.42 for -0.42%).
## Axis range controls
- x_axis.date_min / x_axis.date_max: ISO date string ("2010-01-01") or year ("2015") to restrict the x-axis range. Use this when the user says "start at 2010", "show only 2015 onwards", etc.
- y_axis.min_val / y_axis.max_val: Numeric bounds for the y-axis. Claude should set these exactly as the user requests.
## Dual Y-axis setup
- Set layout: "dual_y_axis" and provide one panel
- Assign y_axis_side: "primary" or "secondary" on each SeriesSpec
- Set panel.y_axis for the left (primary) axis and panel.secondary_y_axis for the right axis
- Example: price index (left, 0200) + spread in bps (right, 0500) on same time axis
## Important
- Only reference column names that appear in the DATA SUMMARY
- The date column is auto-detected - you don't need to include it as a series
- The date column is auto-detected you don't need to include it as a series
- Keep titles concise and professional
- Choose appropriate axis ranges: set min_val/max_val if the brief suggests specific ranges, otherwise leave as null for auto-scaling
- For donut/pie: use data_column pointing to a column whose last non-null value is the slice value
"""
REFINE_SYSTEM_PROMPT = """You are a PIMCO chart specification editor. You receive:
@ -50,7 +64,7 @@ REFINE_SYSTEM_PROMPT = """You are a PIMCO chart specification editor. You receiv
2. A conversation history showing how the chart was built
3. A new edit request from the user
Your job is to apply ONLY the requested changes to the existing spec and return the full updated ChartSpec. Preserve everything that wasn't explicitly asked to change.
Your job is to apply ONLY the requested changes to the existing spec and return the full updated ChartSpec. Preserve everything that wasn't explicitly asked to change — including all axis fields, tick_interval, line_weight, annotations, etc.
## Common edit requests and how to handle them:
@ -67,18 +81,23 @@ Your job is to apply ONLY the requested changes to the existing spec and return
- "Add an ellipse around 2023-2024" add an annotation
**Axis changes:**
- "Y-axis from 0 to 10" set min_val=0, max_val=10
- "Show years only on X-axis" set date_format="%Y"
- "Add % to Y-axis" set suffix="%"
- "Y-axis from 0 to 10" set min_val=0, max_val=10 on y_axis
- "Show years only on X-axis" set date_format="%Y" on x_axis
- "Add % to Y-axis" set suffix="%" on y_axis
- "Start the x-axis at 2010" set x_axis.date_min="2010-01-01"
- "End the x-axis at 2024" set x_axis.date_max="2024-12-31"
- "X-axis from 2015 to 2023" set x_axis.date_min="2015-01-01", x_axis.date_max="2023-12-31"
**Layout changes:**
- "Make it dual panel" change layout to "dual_panel" and split series
- "Make it dual panel" change layout to "dual_panel" and split series across two panels
- "Add a secondary right axis for [series]" change layout to "dual_y_axis", set y_axis_side="secondary" on that series, add secondary_y_axis spec
## Rules
- Return the COMPLETE updated ChartSpec (not just the diff)
- Only modify what was asked keep all other fields identical
- If the edit is ambiguous, make the most reasonable interpretation
- Maintain valid column references from the data summary
- NEVER reset tick_interval, min_val, max_val, or date_min/date_max to null unless explicitly asked to
"""
FEW_SHOT_EXAMPLES = [
@ -92,12 +111,13 @@ FEW_SHOT_EXAMPLES = [
"subtitle": None,
"x_axis": {"date_format": "%b %Y"},
"y_axis": {"suffix": "%", "min_val": -1, "max_val": 6, "tick_interval": 1},
"secondary_y_axis": None,
"series": [
{"label": "U.S.", "data_column": "US_10Y", "chart_type": "line", "color_index": 0, "line_style": "solid"},
{"label": "Australia", "data_column": "AU_10Y", "chart_type": "line", "color_index": 1, "line_style": "solid"},
{"label": "U.K.", "data_column": "UK_10Y", "chart_type": "line", "color_index": 2, "line_style": "solid"},
{"label": "Germany", "data_column": "DE_10Y", "chart_type": "line", "color_index": 3, "line_style": "solid"},
{"label": "Japan", "data_column": "JP_10Y", "chart_type": "line", "color_index": 4, "line_style": "solid"},
{"label": "U.S.", "data_column": "US_10Y", "chart_type": "line", "color_index": 0, "line_style": "solid", "y_axis_side": "primary"},
{"label": "Australia", "data_column": "AU_10Y", "chart_type": "line", "color_index": 1, "line_style": "solid", "y_axis_side": "primary"},
{"label": "U.K.", "data_column": "UK_10Y", "chart_type": "line", "color_index": 2, "line_style": "solid", "y_axis_side": "primary"},
{"label": "Germany", "data_column": "DE_10Y", "chart_type": "line", "color_index": 3, "line_style": "solid", "y_axis_side": "primary"},
{"label": "Japan", "data_column": "JP_10Y", "chart_type": "line", "color_index": 4, "line_style": "solid", "y_axis_side": "primary"},
],
"annotations": [],
}],
@ -113,9 +133,10 @@ FEW_SHOT_EXAMPLES = [
"subtitle": None,
"x_axis": {"date_format": "%Y"},
"y_axis": {"label": "Percentage points", "min_val": -1.0, "max_val": 1.5, "tick_interval": 0.5},
"secondary_y_axis": None,
"series": [
{"label": "Tech investment", "data_column": "Tech_Investment", "chart_type": "line", "color_index": 0, "line_style": "solid"},
{"label": "Non-tech investment", "data_column": "Nontech_Investment", "chart_type": "line", "color_index": 2, "line_style": "solid"},
{"label": "Tech investment", "data_column": "Tech_Investment", "chart_type": "line", "color_index": 0, "line_style": "solid", "y_axis_side": "primary"},
{"label": "Non-tech investment", "data_column": "Nontech_Investment", "chart_type": "line", "color_index": 2, "line_style": "solid", "y_axis_side": "primary"},
],
"annotations": [
{"type": "ellipse", "x_start": "2023-06-01", "x_end": "2024-12-31", "y_start": -0.3, "y_end": 1.2},
@ -123,4 +144,25 @@ FEW_SHOT_EXAMPLES = [
}],
},
},
{
"brief": "Donut chart showing regional allocation: North America 45%, Europe 30%, Asia Pacific 20%, Other 5%.",
"data_summary": "Sheet 'Allocation': 1 row x 5 columns\n Columns: Region, NorthAmerica, Europe, AsiaPacific, Other\n NorthAmerica: 45.0, Europe: 30.0, AsiaPacific: 20.0, Other: 5.0",
"spec": {
"layout": "single",
"panels": [{
"title": "Regional Allocation",
"subtitle": None,
"x_axis": {"scale_kind": "category"},
"y_axis": {},
"secondary_y_axis": None,
"series": [
{"label": "North America", "data_column": "NorthAmerica", "chart_type": "donut", "color_index": 0, "line_style": "solid", "y_axis_side": "primary"},
{"label": "Europe", "data_column": "Europe", "chart_type": "donut", "color_index": 3, "line_style": "solid", "y_axis_side": "primary"},
{"label": "Asia Pacific", "data_column": "AsiaPacific", "chart_type": "donut", "color_index": 4, "line_style": "solid", "y_axis_side": "primary"},
{"label": "Other", "data_column": "Other", "chart_type": "donut", "color_index": 6, "line_style": "solid", "y_axis_side": "primary"},
],
"annotations": [],
}],
},
},
]

View file

@ -4,6 +4,7 @@ from __future__ import annotations
import json
import uuid
import traceback
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, UploadFile, File, Form, Request
@ -27,9 +28,17 @@ from app.ai.spec_validator import validate_and_fix_spec
from app.models.chart_spec import ChartSpec
from app.models.style import LAYOUT
from app.renderer.engine import render_chart
import cairosvg
from app.renderer import export as svg_export
app = FastAPI(title="PIMCO Chart Generator", root_path="/Pimco-charts")
@asynccontextmanager
async def lifespan(app: FastAPI):
await svg_export.init_browser()
yield
await svg_export.close_browser()
app = FastAPI(title="PIMCO Chart Generator", root_path="/Pimco-charts", lifespan=lifespan)
app.add_middleware(AuthMiddleware)
app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET_KEY, https_only=True, same_site="lax")
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="127.0.0.1")
@ -39,7 +48,6 @@ templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
OUTPUT_DIR.mkdir(exist_ok=True)
# Simple in-memory session store: session_id -> {spec, data_path, sheets, summary, history}
_sessions: dict[str, dict] = {}
@ -70,14 +78,12 @@ async def generate(
height: int = Form(1440),
):
try:
# Save uploaded file
session_id = uuid.uuid4().hex[:12]
data_path = OUTPUT_DIR / f"data_{session_id}_{file.filename}"
with open(data_path, "wb") as f:
content = await file.read()
f.write(content)
# Load and prepare data
sheets = load_file(data_path)
if sheet and sheet in sheets:
sheets = {sheet: sheets[sheet]}
@ -88,11 +94,9 @@ async def generate(
summary = summarize_data(prepared)
# Interpret brief with Claude
spec = interpret_brief(brief, summary)
spec = validate_and_fix_spec(spec, prepared)
# Store session
_sessions[session_id] = {
"spec": spec,
"data_path": str(data_path),
@ -106,8 +110,7 @@ async def generate(
],
}
# Render
svg, base_name, spec_json = _render_and_save(spec, prepared, width, height)
svg, base_name, spec_json = await _render_and_save(spec, prepared, width, height)
return templates.TemplateResponse("preview.html", {
"request": request,
@ -146,19 +149,15 @@ async def refine(
summary = session["summary"]
history = session["history"]
# Add the edit to history
history.append({"role": "user", "message": edit})
# Ask Claude to refine the spec
new_spec = refine_spec(old_spec, edit, summary, history)
new_spec = validate_and_fix_spec(new_spec, session["prepared"])
# Update session
session["spec"] = new_spec
history.append({"role": "assistant", "message": "Chart updated."})
# Render
svg, base_name, spec_json = _render_and_save(
svg, base_name, spec_json = await _render_and_save(
new_spec, session["prepared"], session["width"], session["height"]
)
@ -195,17 +194,16 @@ async def download(filename: str):
return FileResponse(filepath, media_type=media_type, filename=filename)
def _render_and_save(
async def _render_and_save(
spec: ChartSpec,
prepared: dict,
width: int,
height: int,
) -> tuple[str, str, str]:
"""Render a ChartSpec and save SVG, PNG, and PDF. Returns (svg_str, base_name, spec_json)."""
LAYOUT["single"]["width"] = width
LAYOUT["single"]["height"] = height
LAYOUT["dual_panel"]["width"] = width
LAYOUT["dual_panel"]["height"] = height
for layout_key in ("single", "dual_panel", "dual_y_axis"):
LAYOUT[layout_key]["width"] = width
LAYOUT[layout_key]["height"] = height
data_dict = {}
if len(prepared) == 1:
@ -219,16 +217,16 @@ def _render_and_save(
base_name = f"chart_{uuid.uuid4().hex[:8]}"
svg_bytes = svg.encode("utf-8")
# Save SVG
with open(OUTPUT_DIR / f"{base_name}.svg", "w") as f:
f.write(svg)
# Save PNG (scale=1 preserves the native SVG pixel dimensions; CairoSVG
# defaults to 96 DPI which at 2560×1440 already produces a crisp raster)
cairosvg.svg2png(bytestring=svg_bytes, write_to=str(OUTPUT_DIR / f"{base_name}.png"), dpi=150)
png_bytes = await svg_export.svg_to_png(svg, width, height)
with open(OUTPUT_DIR / f"{base_name}.png", "wb") as f:
f.write(png_bytes)
# Save PDF
cairosvg.svg2pdf(bytestring=svg_bytes, write_to=str(OUTPUT_DIR / f"{base_name}.pdf"), dpi=150)
pdf_bytes = await svg_export.svg_to_pdf(svg, width, height)
with open(OUTPUT_DIR / f"{base_name}.pdf", "wb") as f:
f.write(pdf_bytes)
spec_json = json.dumps(spec.model_dump(), indent=2, default=str)
return svg, base_name, spec_json

View file

@ -12,6 +12,9 @@ class AxisSpec(BaseModel):
min_val: float | None = None
max_val: float | None = None
tick_interval: float | None = None
date_min: str | None = None # ISO date string or year "2015" — clamps x-axis range
date_max: str | None = None
scale_kind: Literal["date", "category"] = "date"
class ShadedFillSpec(BaseModel):
@ -23,11 +26,12 @@ class ShadedFillSpec(BaseModel):
class SeriesSpec(BaseModel):
label: str
data_column: str = Field(description="Column name from uploaded data")
chart_type: Literal["line", "bar", "stacked_bar", "area", "pie"] = "line"
chart_type: Literal["line", "bar", "stacked_bar", "area", "pie", "donut"] = "line"
color_index: int | None = None
line_style: Literal["solid", "dashed", "dotted"] = "solid"
line_weight: float | None = None
shaded_fill: ShadedFillSpec | None = None
y_axis_side: Literal["primary", "secondary"] = "primary" # for dual_y_axis layout
class AnnotationSpec(BaseModel):
@ -44,10 +48,11 @@ class PanelSpec(BaseModel):
subtitle: str | None = None
x_axis: AxisSpec
y_axis: AxisSpec
secondary_y_axis: AxisSpec | None = None # right-side axis for dual_y_axis layout
series: list[SeriesSpec]
annotations: list[AnnotationSpec] = Field(default_factory=list)
class ChartSpec(BaseModel):
layout: Literal["single", "dual_panel"] = "single"
layout: Literal["single", "dual_panel", "dual_y_axis"] = "single"
panels: list[PanelSpec]

View file

@ -34,6 +34,7 @@ FONTS = {
LAYOUT = {
"single": {"width": 2560, "height": 1440},
"dual_panel": {"width": 2560, "height": 1440},
"dual_y_axis": {"width": 2560, "height": 1440},
"margins": {"top": 15, "right": 15, "bottom": 15, "left": 15},
"panel_gap": 100,
"line_weight": 15.0,

View file

@ -4,9 +4,8 @@ from __future__ import annotations
from datetime import datetime
import drawsvg as draw
from app.models.style import COLORS, FONTS, LAYOUT
from app.renderer.scale import LinearScale, DateScale, nice_ticks, nice_date_ticks
from app.renderer.scale import LinearScale, DateScale, CategoricalScale, nice_ticks, nice_date_ticks
# Pixel spacing threshold below which tick labels are rotated
_ROTATION_THRESHOLD_PX = 96
@ -37,77 +36,88 @@ def render_y_axis(
ticks: list[float] | None = None,
suffix: str = "",
label: str | None = None,
side: str = "left",
):
"""Render Y-axis with horizontal gridlines and left-aligned tick labels."""
"""Render Y-axis with horizontal gridlines and tick labels.
Args:
side: "left" (default) or "right" determines label placement and gridline drawing.
Right-side axis does NOT redraw gridlines (they were drawn by the left axis).
"""
if ticks is None:
ticks = nice_ticks(scale.domain_min, scale.domain_max)
font = FONTS["axis_label"]
# Compute pixel positions for all ticks to determine label spacing
tick_ys = [scale(t) for t in ticks]
# Determine if labels need rotation (too close together)
needs_rotation = False
if len(tick_ys) >= 2:
spacings = [abs(tick_ys[i] - tick_ys[i - 1]) for i in range(1, len(tick_ys))]
min_spacing = min(spacings)
if min_spacing < _ROTATION_THRESHOLD_PX:
if min(spacings) < _ROTATION_THRESHOLD_PX:
needs_rotation = True
label_half_h = font["size"] / 2
last_rendered_bottom: float | None = None
endpoint_ys = {tick_ys[0], tick_ys[-1]} if tick_ys else set()
for tick_val, y in zip(ticks, tick_ys):
# Horizontal gridline — always drawn regardless of label overlap
if tick_val == 0:
stroke_w = LAYOUT["zero_axis_weight"]
else:
stroke_w = LAYOUT["gridline_weight"]
# Gridlines — only drawn by left/primary axis to avoid duplicates
if side == "left":
stroke_w = LAYOUT["zero_axis_weight"] if tick_val == 0 else LAYOUT["gridline_weight"]
d.append(draw.Line(
plot_left, y, plot_right, y,
stroke=COLORS["gridline"],
stroke_width=stroke_w,
stroke_width=f"{stroke_w}px",
))
# Skip label if it would overlap the previous rendered label
# Endpoint labels (min/max) always render; interior labels skip when too close
label_top = y - label_half_h
if last_rendered_bottom is not None and label_top < last_rendered_bottom + 5:
is_endpoint = y in endpoint_ys
if (
not is_endpoint
and last_rendered_bottom is not None
and label_top < last_rendered_bottom + 5
):
continue
# Tick label
label_text = _format_tick(tick_val, suffix)
if side == "right":
lx = plot_right + 15
anchor = "start"
else:
lx = plot_left - 15
ly = y
anchor = "end"
if needs_rotation:
d.append(draw.Text(
label_text, font["size"],
lx, ly,
lx, y,
font_family=f"{font['family']}, sans-serif",
font_weight=font["weight"],
fill=COLORS["axis_text"],
text_anchor="middle",
dominant_baseline="middle",
transform=f"rotate(-90,{lx},{ly})",
transform=f"rotate(-90,{lx},{y})",
))
else:
d.append(draw.Text(
label_text, font["size"],
lx, ly,
lx, y,
font_family=f"{font['family']}, sans-serif",
font_weight=font["weight"],
fill=COLORS["axis_text"],
text_anchor="end",
text_anchor=anchor,
dominant_baseline="middle",
))
last_rendered_bottom = y + label_half_h
# Y-axis title (rotated) if provided
if label:
from app.renderer.typography import render_axis_label
mid_y = (scale.range_min + scale.range_max) / 2
if side == "right":
render_axis_label(d, label, plot_right + 90, mid_y, rotation=90)
else:
render_axis_label(d, label, plot_left - 90, mid_y, rotation=-90)
@ -132,34 +142,28 @@ def render_x_axis(
date_format = "%b '%y"
font = FONTS["axis_label"]
tick_xs = [scale(t) for t in ticks]
# Determine if labels need rotation (too close together)
needs_rotation = False
if len(tick_xs) >= 2:
spacings = [abs(tick_xs[i] - tick_xs[i - 1]) for i in range(1, len(tick_xs))]
min_spacing = min(spacings)
if min_spacing < _ROTATION_THRESHOLD_PX:
if min(spacings) < _ROTATION_THRESHOLD_PX:
needs_rotation = True
label_y = plot_bottom + 30
last_rendered_right = None # Track rightmost edge of last rendered label
last_rendered_right = None
for tick_date, x in zip(ticks, tick_xs):
label_text = tick_date.strftime(date_format)
text_width = _estimate_text_width(label_text, font["size"])
if needs_rotation:
# Rotated labels are anchored at the right, so their extent goes upward
# Overlap check: the label's horizontal "footprint" is approximately font_size wide
label_left = x - font["size"] / 2
label_right = x + font["size"] / 2
else:
label_left = x - text_width / 2
label_right = x + text_width / 2
# Skip if overlapping with previous label (with 5px clearance)
if last_rendered_right is not None and label_left < last_rendered_right + 5:
continue
@ -188,6 +192,37 @@ def render_x_axis(
last_rendered_right = label_right
def render_x_axis_categorical(
d: draw.Drawing,
scale: CategoricalScale,
plot_bottom: float,
):
"""Render X-axis with categorical string labels below the plot area."""
font = FONTS["axis_label"]
label_y = plot_bottom + 30
last_rendered_right = None
for cat in scale.categories:
x = scale(cat)
text_width = _estimate_text_width(str(cat), font["size"])
label_left = x - text_width / 2
label_right = x + text_width / 2
if last_rendered_right is not None and label_left < last_rendered_right + 5:
continue
d.append(draw.Text(
str(cat), font["size"],
x, label_y,
font_family=f"{font['family']}, sans-serif",
font_weight=font["weight"],
fill=COLORS["axis_text"],
text_anchor="middle",
dominant_baseline="hanging",
))
last_rendered_right = label_right
def render_x_axis_numeric(
d: draw.Drawing,
scale: LinearScale,
@ -200,15 +235,12 @@ def render_x_axis_numeric(
ticks = nice_ticks(scale.domain_min, scale.domain_max)
font = FONTS["axis_label"]
tick_xs = [scale(t) for t in ticks]
# Determine if labels need rotation (too close together)
needs_rotation = False
if len(tick_xs) >= 2:
spacings = [abs(tick_xs[i] - tick_xs[i - 1]) for i in range(1, len(tick_xs))]
min_spacing = min(spacings)
if min_spacing < _ROTATION_THRESHOLD_PX:
if min(spacings) < _ROTATION_THRESHOLD_PX:
needs_rotation = True
label_y = plot_bottom + 30
@ -254,11 +286,7 @@ def render_x_axis_numeric(
def _format_tick(value: float, suffix: str) -> str:
"""Format a tick value with adaptive precision.
Shows only as many decimal places as needed to represent the value
clearly avoids truncating small values like -0.0042 to -0.00.
"""
"""Format a tick value with adaptive precision."""
import math
if value == 0:
return f"0{suffix}"
@ -267,15 +295,12 @@ def _format_tick(value: float, suffix: str) -> str:
abs_val = abs(value)
if abs_val >= 1:
# For values >= 1, one or two decimals is sufficient
if round(value * 10) == value * 10:
return f"{value:.1f}{suffix}"
return f"{value:.2f}{suffix}"
else:
# For values < 1, show enough significant digits (at least 2)
magnitude = -math.floor(math.log10(abs_val))
decimals = max(magnitude + 1, 2)
formatted = f"{value:.{decimals}f}"
# Strip trailing zeros after the decimal point
formatted = formatted.rstrip("0").rstrip(".")
return f"{formatted}{suffix}"

View file

@ -12,8 +12,8 @@ from app.config import FONTS_DIR
from app.models.chart_spec import ChartSpec, PanelSpec, SeriesSpec
from app.models.style import COLORS, LAYOUT
from app.renderer.layout import compute_layout, PanelBounds
from app.renderer.scale import LinearScale, DateScale, nice_ticks, nice_date_ticks
from app.renderer.axes import render_y_axis, render_x_axis
from app.renderer.scale import LinearScale, DateScale, CategoricalScale, nice_ticks, nice_date_ticks
from app.renderer.axes import render_y_axis, render_x_axis, render_x_axis_categorical
from app.renderer.series import render_line_series, render_shaded_fill, render_bar_series
from app.renderer.legend import render_legend
from app.renderer.typography import render_title, render_subtitle
@ -22,29 +22,19 @@ from app.renderer.pie import render_pie_series
def render_chart(spec: ChartSpec, data: dict[str, pd.DataFrame]) -> str:
"""Render a ChartSpec into an SVG string.
Args:
spec: The chart specification
data: Dict mapping sheet/table names to DataFrames.
A key of "_default" is used when there's only one data source.
Returns:
SVG string
"""
"""Render a ChartSpec into an SVG string."""
has_pie = bool(spec.panels) and all(
s.chart_type == "pie"
s.chart_type in ("pie", "donut")
for panel in spec.panels
for s in panel.series
)
# Compute dynamic top padding to prevent title/subtitle/legend overlap.
# Panel width is independent of top padding, so estimate it from canvas dims.
from app.renderer.legend import legend_row_count
from app.models.style import FONTS as _FONTS
_margin = LAYOUT["margins"]["top"]
_canvas_w = LAYOUT[spec.layout if spec.layout in LAYOUT else "single"]["width"]
_est_panel_w = _canvas_w - 2 * _margin - 120 - 30 # approx left+right content padding
_layout_key = spec.layout if spec.layout in LAYOUT else "single"
_canvas_w = LAYOUT[_layout_key]["width"]
_est_panel_w = _canvas_w - 2 * _margin - 120 - 30
if spec.layout == "dual_panel":
_est_panel_w = (_est_panel_w - LAYOUT["panel_gap"]) / 2
@ -53,12 +43,10 @@ def render_chart(spec: ChartSpec, data: dict[str, pd.DataFrame]) -> str:
default=1,
)
_has_subtitle = any(bool(p.subtitle) for p in spec.panels)
_TITLE_H = _FONTS["title"]["size"] # 46
_SUBTITLE_H = _FONTS["subtitle"]["size"] # 24
_LEGEND_ROW_H = _FONTS["legend"]["size"] + 12 # 54
_TITLE_H = _FONTS["title"]["size"]
_SUBTITLE_H = _FONTS["subtitle"]["size"]
_LEGEND_ROW_H = _FONTS["legend"]["size"] + 12
_GAP = 10
# Space needed above the plot area:
# gap from edge + title + gap + [subtitle + gap] + legend rows + gap to plot
_pad_top = (
15 + _TITLE_H + _GAP
+ (_SUBTITLE_H + _GAP if _has_subtitle else 0)
@ -70,63 +58,53 @@ def render_chart(spec: ChartSpec, data: dict[str, pd.DataFrame]) -> str:
spec.layout, vertical_legend=has_pie, pad_top=_pad_top
)
d = draw.Drawing(canvas_w, canvas_h)
# Explicit pixel units so Chromium's SVG→PDF conversion uses 1px = 0.75pt exactly
d = draw.Drawing(canvas_w, canvas_h, id_prefix="c")
d.set_pixel_scale(1)
# Embed fonts
_embed_fonts(d)
# Background
d.append(draw.Rectangle(0, 0, canvas_w, canvas_h, fill=COLORS["background"]))
# Render each panel
for i, panel_spec in enumerate(spec.panels):
if i >= len(panel_bounds):
break
bounds = panel_bounds[i]
# Support per-panel data: check for _panel_0, _panel_1 keys
panel_key = f"_panel_{i}"
if panel_key in data:
panel_data = {panel_key: data[panel_key]}
else:
panel_data = data
_render_panel(d, panel_spec, bounds, panel_data)
panel_data = {panel_key: data[panel_key]} if panel_key in data else data
_render_panel(d, panel_spec, bounds, panel_data, layout=spec.layout)
return d.as_svg()
svg = d.as_svg()
# Inject explicit width/height units into <svg> root so Chromium treats 1 unit = 1px
svg = svg.replace(
f'width="{canvas_w}" height="{canvas_h}"',
f'width="{canvas_w}px" height="{canvas_h}px"',
1,
)
return svg
def _embed_fonts(d: draw.Drawing):
"""Embed Roboto Condensed fonts as base64 @font-face in SVG.
Embeds Light (300), Regular (normal), and Bold (bold) weights so browsers
render all chart text in the correct Roboto Condensed variant.
CairoSVG/Pango resolves fonts via system fontconfig see Dockerfile for
system font installation that ensures export fidelity.
"""
"""Embed Roboto Condensed fonts as base64 @font-face — honored by Chromium."""
font_css = ""
# (family_name, path, weight) tuples
font_entries = [
("Roboto Condensed", FONTS_DIR / "RobotoCondensed-Light.ttf", "300"),
("Roboto Condensed", FONTS_DIR / "RobotoCondensed-Regular.ttf", "normal"),
("Roboto Condensed", FONTS_DIR / "RobotoCondensed-Bold.ttf", "bold"),
]
for family_name, font_path, weight in font_entries:
if font_path.exists():
with open(font_path, "rb") as f:
b64 = base64.b64encode(f.read()).decode("ascii")
font_css += f"""
@font-face {{
font-family: '{family_name}';
src: url('data:font/truetype;base64,{b64}') format('truetype');
font-weight: {weight};
font-style: normal;
}}
"""
font_css += (
f"@font-face {{"
f"font-family:'{family_name}';"
f"src:url('data:font/truetype;base64,{b64}') format('truetype');"
f"font-weight:{weight};font-style:normal;"
f"}}"
)
if font_css:
style = draw.Raw(f"<defs><style type='text/css'>{font_css}</style></defs>")
d.append(style)
d.append(draw.Raw(f"<defs><style type='text/css'>{font_css}</style></defs>"))
def _render_pie_panel(
@ -135,21 +113,19 @@ def _render_pie_panel(
bounds: PanelBounds,
data: dict[str, pd.DataFrame],
):
"""Render a pie-chart panel (skips axis/scale setup)."""
"""Render a pie/donut panel."""
df = _resolve_dataframe(data)
if df is None or df.empty:
return
palette = COLORS["palette"]
labels = []
values = []
colors = []
labels, values, colors = [], [], []
is_donut = any(s.chart_type == "donut" for s in panel.series)
for idx, s in enumerate(panel.series):
col = _find_column(df, s.data_column)
if col is None:
continue
# Use last non-null row value as the slice value
numeric = pd.to_numeric(df[col], errors="coerce").dropna()
if numeric.empty:
continue
@ -164,7 +140,8 @@ def _render_pie_panel(
cy = (bounds.top + bounds.bottom) / 2
radius = min(bounds.right - bounds.left, bounds.bottom - bounds.top) / 2 * 0.85
render_pie_series(d, labels, values, colors, cx, cy, radius)
render_pie_series(d, labels, values, colors, cx, cy, radius,
inner_radius_ratio=0.6 if is_donut else 0.0)
render_title(d, panel.title, bounds.left, bounds.top - 130)
if panel.subtitle:
@ -178,42 +155,53 @@ def _render_panel(
panel: PanelSpec,
bounds: PanelBounds,
data: dict[str, pd.DataFrame],
layout: str = "single",
):
"""Render a single chart panel."""
# Route pie charts to dedicated renderer
if panel.series and all(s.chart_type == "pie" for s in panel.series):
# Route pie/donut to dedicated renderer
if panel.series and all(s.chart_type in ("pie", "donut") for s in panel.series):
_render_pie_panel(d, panel, bounds, data)
return
# Resolve the DataFrame to use
df = _resolve_dataframe(data)
if df is None or df.empty:
return
# Detect date column
date_col = _find_date_column(df)
if date_col is None:
use_categorical = panel.x_axis.scale_kind == "category"
# Build x-axis scale
if use_categorical:
x_scale, x_categories, date_min, date_max = _build_categorical_scale(df, panel, bounds)
if x_scale is None:
return
else:
x_scale, date_min, date_max = _build_date_scale(df, panel, bounds)
if x_scale is None:
return
x_categories = None
dates = pd.to_datetime(df[date_col])
# Collect Y values for all series to compute axis range
all_y_values = []
series_data = {}
# Collect Y values
all_y_values: list[float] = []
series_data: dict[str, dict] = {}
palette = COLORS["palette"]
if not use_categorical:
date_col = _find_date_column(df)
x_vals_shared = pd.to_datetime(df[date_col]).tolist() if date_col else []
else:
x_vals_shared = x_categories
for idx, s in enumerate(panel.series):
col = _find_column(df, s.data_column)
if col is None:
continue
y_vals = pd.to_numeric(df[col], errors="coerce")
x_vals = x_vals_shared
series_data[s.label] = {
"x": dates.tolist(),
"x": x_vals,
"y": y_vals.tolist(),
"spec": s,
"color": palette[
(s.color_index if s.color_index is not None else idx) % len(palette)
],
"color": palette[(s.color_index if s.color_index is not None else idx) % len(palette)],
}
valid = y_vals.dropna()
if not valid.empty:
@ -222,24 +210,81 @@ def _render_panel(
if not all_y_values:
return
# Compute axis ranges
y_min = panel.y_axis.min_val if panel.y_axis.min_val is not None else min(all_y_values)
y_max = panel.y_axis.max_val if panel.y_axis.max_val is not None else max(all_y_values)
if layout == "dual_y_axis":
_render_dual_y_panel_body(d, panel, bounds, series_data, x_scale, date_min, date_max,
use_categorical, x_categories)
else:
_render_standard_panel_body(d, panel, bounds, series_data, all_y_values, x_scale,
date_min, date_max, use_categorical)
# Add padding
if panel.y_axis.min_val is None or panel.y_axis.max_val is None:
def _build_date_scale(df, panel, bounds):
"""Build DateScale honouring spec date_min/date_max. Returns (scale, date_min, date_max) or (None,…)."""
date_col = _find_date_column(df)
if date_col is None:
return None, None, None
dates = pd.to_datetime(df[date_col])
valid_dates = dates.dropna()
date_min = valid_dates.min().to_pydatetime()
date_max = valid_dates.max().to_pydatetime()
if panel.x_axis.date_min:
try:
date_min = pd.to_datetime(panel.x_axis.date_min).to_pydatetime()
except Exception:
pass
if panel.x_axis.date_max:
try:
date_max = pd.to_datetime(panel.x_axis.date_max).to_pydatetime()
except Exception:
pass
x_scale = DateScale(date_min, date_max, bounds.left, bounds.right)
return x_scale, date_min, date_max
def _build_categorical_scale(df, panel, bounds):
"""Build CategoricalScale from first non-date column. Returns (scale, categories, None, None)."""
date_col = _find_date_column(df)
cat_col = None
for col in df.columns:
if col != date_col:
cat_col = col
break
if cat_col is None:
cat_col = df.columns[0]
categories = [str(v) for v in df[cat_col].dropna().tolist()]
if not categories:
return None, None, None, None
x_scale = CategoricalScale(categories, bounds.left, bounds.right)
return x_scale, categories, None, None
def _compute_y_axis(panel, all_y_values):
"""Compute y_min, y_max, y_ticks respecting user-set bounds."""
user_min = panel.y_axis.min_val
user_max = panel.y_axis.max_val
y_min = user_min if user_min is not None else min(all_y_values)
y_max = user_max if user_max is not None else max(all_y_values)
if user_min is None or user_max is None:
y_range = y_max - y_min
if panel.y_axis.min_val is None:
if y_range == 0:
y_range = abs(y_min) or 1
if user_min is None:
y_min -= y_range * 0.05
if panel.y_axis.max_val is None:
if user_max is None:
y_max += y_range * 0.05
# Generate Y ticks
if panel.y_axis.tick_interval and panel.y_axis.tick_interval > 0:
# Manual loop instead of np.arange to avoid floating-point accumulation
interval = panel.y_axis.tick_interval
start = y_min if panel.y_axis.min_val is None else panel.y_axis.min_val
stop = y_max if panel.y_axis.max_val is None else panel.y_axis.max_val
start = y_min
stop = y_max
y_ticks = []
val = start
while val <= stop + interval * 0.01:
@ -248,37 +293,96 @@ def _render_panel(
else:
y_ticks = nice_ticks(y_min, y_max)
# Only widen auto bounds, never override user-set bounds
if y_ticks:
if user_min is None:
y_min = min(y_min, y_ticks[0])
if user_max is None:
y_max = max(y_max, y_ticks[-1])
valid_dates = dates.dropna()
date_min = valid_dates.min().to_pydatetime()
date_max = valid_dates.max().to_pydatetime()
# Clamp ticks to [y_min, y_max] so no tick plots outside the user range
y_ticks = [t for t in y_ticks if y_min - 1e-9 <= t <= y_max + 1e-9]
# Create scales (note: Y is inverted - top of screen is smaller Y pixel value)
return y_min, y_max, y_ticks
def _render_standard_panel_body(
d, panel, bounds, series_data, all_y_values, x_scale, date_min, date_max, use_categorical
):
y_min, y_max, y_ticks = _compute_y_axis(panel, all_y_values)
y_scale = LinearScale(y_min, y_max, bounds.bottom, bounds.top)
x_scale = DateScale(date_min, date_max, bounds.left, bounds.right)
# Render axes
render_y_axis(
d, y_scale,
plot_left=bounds.left,
plot_right=bounds.right,
ticks=y_ticks,
suffix=panel.y_axis.suffix or "",
label=panel.y_axis.label,
plot_left=bounds.left, plot_right=bounds.right,
ticks=y_ticks, suffix=panel.y_axis.suffix or "", label=panel.y_axis.label,
)
if use_categorical:
render_x_axis_categorical(d, x_scale, bounds.bottom)
else:
date_ticks = nice_date_ticks(date_min, date_max)
render_x_axis(
d, x_scale,
plot_bottom=bounds.bottom,
ticks=date_ticks,
date_format=panel.x_axis.date_format,
render_x_axis(d, x_scale, bounds.bottom, ticks=date_ticks, date_format=panel.x_axis.date_format)
_render_clip_and_series(d, panel, bounds, series_data, x_scale, y_scale, date_min, date_max, y_min, y_max)
_render_labels(d, panel, bounds)
def _render_dual_y_panel_body(
d, panel, bounds, series_data, x_scale, date_min, date_max, use_categorical, x_categories
):
"""Render a panel with two independent Y-axes (left primary, right secondary)."""
sec_spec = panel.secondary_y_axis or panel.y_axis
primary_values = [
v
for label, sd in series_data.items()
if sd["spec"].y_axis_side == "primary"
for v in sd["y"]
if v is not None and not (isinstance(v, float) and v != v)
]
secondary_values = [
v
for label, sd in series_data.items()
if sd["spec"].y_axis_side == "secondary"
for v in sd["y"]
if v is not None and not (isinstance(v, float) and v != v)
]
if not primary_values:
primary_values = [v for sd in series_data.values() for v in sd["y"] if v is not None]
if not secondary_values:
secondary_values = primary_values
primary_panel = panel.model_copy(update={"y_axis": panel.y_axis})
secondary_panel = panel.model_copy(update={"y_axis": sec_spec})
y_min_p, y_max_p, y_ticks_p = _compute_y_axis(primary_panel, primary_values)
y_min_s, y_max_s, y_ticks_s = _compute_y_axis(secondary_panel, secondary_values)
y_scale_primary = LinearScale(y_min_p, y_max_p, bounds.bottom, bounds.top)
y_scale_secondary = LinearScale(y_min_s, y_max_s, bounds.bottom, bounds.top)
# Primary (left) axis draws gridlines; secondary (right) does not
render_y_axis(
d, y_scale_primary,
plot_left=bounds.left, plot_right=bounds.right,
ticks=y_ticks_p, suffix=panel.y_axis.suffix or "", label=panel.y_axis.label,
side="left",
)
render_y_axis(
d, y_scale_secondary,
plot_left=bounds.left, plot_right=bounds.right,
ticks=y_ticks_s, suffix=sec_spec.suffix or "", label=sec_spec.label,
side="right",
)
# Define a clipPath so data series cannot render outside the plot area
if use_categorical:
render_x_axis_categorical(d, x_scale, bounds.bottom)
else:
date_ticks = nice_date_ticks(date_min, date_max)
render_x_axis(d, x_scale, bounds.bottom, ticks=date_ticks, date_format=panel.x_axis.date_format)
clip_id = f"plot-clip-{int(bounds.left)}-{int(bounds.top)}"
d.append(draw.Raw(
f'<defs><clipPath id="{clip_id}">'
@ -288,28 +392,43 @@ def _render_panel(
))
clipped = draw.Group(clip_path=f"url(#{clip_id})")
# Render shaded fills first (behind lines)
for label, sd in series_data.items():
s = sd["spec"]
y_scale = y_scale_secondary if s.y_axis_side == "secondary" else y_scale_primary
_dispatch_series(clipped, s, sd, x_scale, y_scale)
d.append(clipped)
_render_labels(d, panel, bounds)
def _render_clip_and_series(d, panel, bounds, series_data, x_scale, y_scale, date_min, date_max, y_min, y_max):
clip_id = f"plot-clip-{int(bounds.left)}-{int(bounds.top)}"
d.append(draw.Raw(
f'<defs><clipPath id="{clip_id}">'
f'<rect x="{bounds.left}" y="{bounds.top}" '
f'width="{bounds.width}" height="{bounds.height}"/>'
f'</clipPath></defs>'
))
clipped = draw.Group(clip_path=f"url(#{clip_id})")
# Shaded fills first (behind lines)
for label, sd in series_data.items():
s = sd["spec"]
if s.shaded_fill:
ref_label = s.shaded_fill.reference_series
if ref_label in series_data:
ref_sd = series_data[ref_label]
# Align data lengths
min_len = min(len(sd["x"]), len(sd["y"]), len(ref_sd["y"]))
render_shaded_fill(
clipped,
sd["x"][:min_len],
sd["y"][:min_len],
ref_sd["y"][:min_len],
clipped, sd["x"][:min_len], sd["y"][:min_len], ref_sd["y"][:min_len],
x_scale, y_scale,
above_color=s.shaded_fill.above_color,
below_color=s.shaded_fill.below_color,
)
# Render annotations (behind lines)
# Annotations (behind series)
for ann in panel.annotations:
if ann.type == "ellipse":
if ann.type == "ellipse" and date_min is not None:
try:
x_start = pd.to_datetime(ann.x_start).to_pydatetime() if ann.x_start else date_min
x_end = pd.to_datetime(ann.x_end).to_pydatetime() if ann.x_end else date_max
@ -319,46 +438,47 @@ def _render_panel(
except Exception:
pass
# Render data series
# Series
for label, sd in series_data.items():
s = sd["spec"]
if s.chart_type == "line":
render_line_series(
clipped,
sd["x"], sd["y"],
x_scale, y_scale,
color=sd["color"],
line_style=s.line_style,
line_weight=s.line_weight,
)
elif s.chart_type == "bar":
render_bar_series(
clipped,
sd["x"], sd["y"],
x_scale, y_scale,
color=sd["color"],
)
_dispatch_series(clipped, sd["spec"], sd, x_scale, y_scale)
d.append(clipped)
# Compute vertical positions bottom-up from bounds.top so nothing overlaps.
def _dispatch_series(d, s: SeriesSpec, sd: dict, x_scale, y_scale):
"""Dispatch a single series to the correct renderer. Raises on unsupported types."""
if s.chart_type == "line":
render_line_series(
d, sd["x"], sd["y"], x_scale, y_scale,
color=sd["color"], line_style=s.line_style, line_weight=s.line_weight,
)
elif s.chart_type == "bar":
render_bar_series(d, sd["x"], sd["y"], x_scale, y_scale, color=sd["color"])
elif s.chart_type in ("stacked_bar", "area", "pie", "donut"):
# These are either handled upstream (pie/donut) or not yet implemented
raise ValueError(
f"chart_type='{s.chart_type}' for series '{s.label}' cannot be rendered in a standard panel. "
f"Use a dedicated panel with all series of the same type."
)
else:
raise ValueError(f"Unsupported chart_type '{s.chart_type}' for series '{s.label}'.")
def _render_labels(d, panel, bounds):
"""Render title, subtitle, and horizontal legend above the plot area."""
from app.renderer.legend import legend_row_count
from app.models.style import FONTS as _FONTS_LOCAL
_TITLE_H = _FONTS_LOCAL["title"]["size"] # 46
_SUBTITLE_H = _FONTS_LOCAL["subtitle"]["size"] # 24
_LEGEND_ROW_H = _FONTS_LOCAL["legend"]["size"] + 12 # 54
from app.models.style import FONTS as _FL
_TITLE_H = _FL["title"]["size"]
_SUBTITLE_H = _FL["subtitle"]["size"]
_LEGEND_ROW_H = _FL["legend"]["size"] + 12
_GAP = 10
n_legend_rows = legend_row_count(panel.series, bounds.left, bounds.right)
_TEXT_HALF = _FL["legend"]["size"] // 2
_TEXT_HALF = _FONTS_LOCAL["legend"]["size"] // 2 # 21px: half text cap-height
# Bottom of legend block sits _GAP px above the plot area
legend_bottom_row_center_y = bounds.top - _GAP - _TEXT_HALF
# Top edge of the top legend row's text
legend_block_top_y = legend_bottom_row_center_y - (n_legend_rows - 1) * _LEGEND_ROW_H - _TEXT_HALF
# Subtitle sits _GAP px above the legend block (if present)
if panel.subtitle:
subtitle_y = legend_block_top_y - _GAP - _SUBTITLE_H
title_y = subtitle_y - _GAP - _TITLE_H
@ -370,8 +490,6 @@ def _render_panel(
if panel.subtitle:
render_subtitle(d, panel.subtitle, bounds.left, subtitle_y)
# Legend (horizontal, multi-line if needed, right-aligned above plot)
# Pass the computed bottom-row center Y so rows stack correctly
render_legend(
d, panel.series, bounds.left, bounds.right, bounds.top, bounds.bottom,
mode="horizontal", legend_bottom_y=legend_bottom_row_center_y,
@ -379,7 +497,6 @@ def _render_panel(
def _resolve_dataframe(data: dict[str, pd.DataFrame]) -> pd.DataFrame | None:
"""Get the primary DataFrame from the data dict."""
if "_default" in data:
return data["_default"]
if data:
@ -388,32 +505,21 @@ def _resolve_dataframe(data: dict[str, pd.DataFrame]) -> pd.DataFrame | None:
def _find_date_column(df: pd.DataFrame) -> str | None:
"""Auto-detect the date/time column in a DataFrame.
Mirrors _detect_date_column() in app/data/transformer.py keep in sync.
"""
_DATE_EXACT = {
"date", "dates", "time", "timestamp", "period", "month", "quarter",
"year", "as of", "as_of", "report_date", "reporting_date",
"fiscal_year", "observation_date", "obs_date",
}
# Exact name match
for col in df.columns:
if str(col).lower() in _DATE_EXACT:
return col
# Substring match
for col in df.columns:
col_lower = str(col).lower()
if "date" in col_lower or "time" in col_lower:
return col
# Dtype check
for col in df.columns:
if df[col].dtype == "datetime64[ns]":
return col
# Try parsing first column
first_col = df.columns[0]
try:
parsed = pd.to_datetime(df[first_col].head(10), errors="coerce")
@ -421,24 +527,17 @@ def _find_date_column(df: pd.DataFrame) -> str | None:
return first_col
except Exception:
pass
return None
def _find_column(df: pd.DataFrame, name: str) -> str | None:
"""Find a column by exact or fuzzy match."""
if name in df.columns:
return name
# Case-insensitive match
lower_map = {c.lower(): c for c in df.columns}
if name.lower() in lower_map:
return lower_map[name.lower()]
# Fuzzy match
import difflib
matches = difflib.get_close_matches(name.lower(), [c.lower() for c in df.columns], n=1, cutoff=0.6)
if matches:
return lower_map[matches[0]]
return None

78
app/renderer/export.py Normal file
View file

@ -0,0 +1,78 @@
"""Playwright-based SVG export to PNG and PDF.
Headless Chromium honours @font-face with base64 data URIs, so Roboto Condensed
renders correctly in all exports no CairoFont-0-0 fallback, no outlined glyphs.
"""
from __future__ import annotations
_playwright = None
_browser = None
async def init_browser() -> None:
"""Start persistent Playwright/Chromium instance. Call once at app startup."""
global _playwright, _browser
from playwright.async_api import async_playwright
_playwright = await async_playwright().start()
_browser = await _playwright.chromium.launch(headless=True)
async def close_browser() -> None:
"""Shut down browser gracefully. Call at app shutdown."""
global _playwright, _browser
if _browser:
await _browser.close()
_browser = None
if _playwright:
await _playwright.stop()
_playwright = None
def _html_wrapper(svg_str: str, width: int, height: int) -> str:
return (
f"<!DOCTYPE html><html><head>"
f"<style>*{{margin:0;padding:0;overflow:hidden}}"
f"@page{{size:{width}px {height}px;margin:0}}"
f"</style></head>"
f"<body style='width:{width}px;height:{height}px'>"
f"{svg_str}"
f"</body></html>"
)
async def svg_to_png(svg_str: str, width: int, height: int) -> bytes:
"""Render SVG to PNG via headless Chromium. Returns raw PNG bytes."""
page = await _browser.new_page(viewport={"width": width, "height": height})
try:
await page.set_content(_html_wrapper(svg_str, width, height), wait_until="load")
# Brief wait for @font-face to finish loading
await page.wait_for_timeout(200)
png_bytes = await page.screenshot(
type="png",
clip={"x": 0, "y": 0, "width": width, "height": height},
)
finally:
await page.close()
return png_bytes
async def svg_to_pdf(svg_str: str, width: int, height: int) -> bytes:
"""Render SVG to PDF via headless Chromium.
The resulting PDF contains embedded TTF subsets and real text operators,
making text fully selectable and editable in InDesign and Illustrator.
Stroke weights match the SVG preview exactly (1px SVG = 0.75pt PDF).
"""
page = await _browser.new_page(viewport={"width": width, "height": height})
try:
await page.set_content(_html_wrapper(svg_str, width, height), wait_until="load")
await page.wait_for_timeout(200)
pdf_bytes = await page.pdf(
width=f"{width}px",
height=f"{height}px",
print_background=True,
)
finally:
await page.close()
return pdf_bytes

View file

@ -4,20 +4,16 @@ from __future__ import annotations
from dataclasses import dataclass
from app.models.style import LAYOUT
# Internal content padding added on top of the 15px outer whitespace margin.
# These reserve space for title/subtitle, axis labels, and legends.
# _PAD_TOP is computed dynamically by render_chart() based on series count;
# this value is the fallback minimum.
_PAD_TOP_MIN = 160
_PAD_BOTTOM = 100 # X-axis labels (42pt) + gap
_PAD_LEFT = 120 # Y-axis tick labels + rotated axis title
_PAD_RIGHT_STANDARD = 30 # Line/bar: minimal right space (legend is at top)
_PAD_RIGHT_PIE = 320 # Pie: space for vertical legend on the right
_PAD_BOTTOM = 100
_PAD_LEFT = 120
_PAD_RIGHT_STANDARD = 30
_PAD_RIGHT_PIE = 320
_PAD_RIGHT_DUAL_Y = 130 # right-side Y-axis labels for dual_y_axis layout
@dataclass
class PanelBounds:
"""Pixel boundaries for a chart panel's plot area."""
left: float
right: float
top: float
@ -37,22 +33,15 @@ def compute_layout(
vertical_legend: bool = False,
pad_top: int | None = None,
) -> tuple[int, int, list[PanelBounds]]:
"""Compute canvas dimensions and panel bounds.
Args:
layout_type: "single" or "dual_panel"
vertical_legend: True for pie charts that use a right-side vertical legend
Returns: (canvas_width, canvas_height, list_of_panel_bounds)
"""
outer = LAYOUT["margins"]["top"] # All sides are equal (15px)
pad_right = _PAD_RIGHT_PIE if vertical_legend else _PAD_RIGHT_STANDARD
"""Compute canvas dimensions and panel bounds."""
outer = LAYOUT["margins"]["top"]
_pad_top = max(pad_top, _PAD_TOP_MIN) if pad_top is not None else _PAD_TOP_MIN
if layout_type == "dual_panel":
dims = LAYOUT["dual_panel"]
w, h = dims["width"], dims["height"]
gap = LAYOUT["panel_gap"]
pad_right = _PAD_RIGHT_PIE if vertical_legend else _PAD_RIGHT_STANDARD
plot_left = outer + _PAD_LEFT
plot_top = outer + _pad_top
@ -74,9 +63,21 @@ def compute_layout(
)
return w, h, [left_panel, right_panel]
elif layout_type == "dual_y_axis":
dims = LAYOUT["dual_y_axis"]
w, h = dims["width"], dims["height"]
panel = PanelBounds(
left=outer + _PAD_LEFT,
right=w - outer - _PAD_RIGHT_DUAL_Y,
top=outer + _pad_top,
bottom=h - outer - _PAD_BOTTOM,
)
return w, h, [panel]
else: # single
dims = LAYOUT["single"]
w, h = dims["width"], dims["height"]
pad_right = _PAD_RIGHT_PIE if vertical_legend else _PAD_RIGHT_STANDARD
panel = PanelBounds(
left=outer + _PAD_LEFT,
right=w - outer - pad_right,

View file

@ -1,9 +1,9 @@
"""Pie chart renderer."""
"""Pie and donut chart renderer."""
from __future__ import annotations
import math
import drawsvg as draw
from app.models.style import COLORS
from app.models.style import COLORS, FONTS
def render_pie_series(
@ -14,46 +14,78 @@ def render_pie_series(
cx: float,
cy: float,
radius: float,
inner_radius_ratio: float = 0.0,
):
"""Draw a pie chart as SVG arc paths.
"""Draw a pie or donut chart.
Args:
d: The SVG drawing.
labels: Slice labels.
values: Numeric values for each slice (need not sum to any fixed total).
colors: Fill color per slice.
cx, cy: Centre of the pie.
radius: Outer radius in pixels.
inner_radius_ratio: 0.0 = solid pie; 0.6 = standard donut ring.
"""
total = sum(v for v in values if v and v > 0)
if total == 0:
return
start_angle = -math.pi / 2 # Start at 12 o'clock
inner_r = radius * inner_radius_ratio
start_angle = -math.pi / 2 # 12 o'clock
for i, (label, value, color) in enumerate(zip(labels, values, colors)):
font = FONTS["axis_label"]
for label, value, color in zip(labels, values, colors):
if not value or value <= 0:
continue
sweep = 2 * math.pi * (value / total)
end_angle = start_angle + sweep
# Arc path
x1 = cx + radius * math.cos(start_angle)
y1 = cy + radius * math.sin(start_angle)
x2 = cx + radius * math.cos(end_angle)
y2 = cy + radius * math.sin(end_angle)
x1_out = cx + radius * math.cos(start_angle)
y1_out = cy + radius * math.sin(start_angle)
x2_out = cx + radius * math.cos(end_angle)
y2_out = cy + radius * math.sin(end_angle)
large_arc = 1 if sweep > math.pi else 0
if inner_radius_ratio > 0:
# Donut: outer arc + inner arc (reversed)
x1_in = cx + inner_r * math.cos(end_angle)
y1_in = cy + inner_r * math.sin(end_angle)
x2_in = cx + inner_r * math.cos(start_angle)
y2_in = cy + inner_r * math.sin(start_angle)
path_d = (
f"M {x1_out:.2f} {y1_out:.2f} "
f"A {radius} {radius} 0 {large_arc} 1 {x2_out:.2f} {y2_out:.2f} "
f"L {x1_in:.2f} {y1_in:.2f} "
f"A {inner_r} {inner_r} 0 {large_arc} 0 {x2_in:.2f} {y2_in:.2f} "
f"Z"
)
else:
# Solid pie
path_d = (
f"M {cx} {cy} "
f"L {x1:.2f} {y1:.2f} "
f"A {radius} {radius} 0 {large_arc} 1 {x2:.2f} {y2:.2f} "
f"L {x1_out:.2f} {y1_out:.2f} "
f"A {radius} {radius} 0 {large_arc} 1 {x2_out:.2f} {y2_out:.2f} "
f"Z"
)
p = draw.Path(d=path_d, fill=color, stroke="#FFFFFF", stroke_width=2)
d.append(p)
d.append(draw.Path(d=path_d, fill=color, stroke="#FFFFFF", stroke_width="2px"))
# Percentage label at slice midpoint
mid_angle = start_angle + sweep / 2
label_r = (radius + inner_r) / 2 if inner_radius_ratio > 0 else radius * 0.65
lx = cx + label_r * math.cos(mid_angle)
ly = cy + label_r * math.sin(mid_angle)
pct = value / total * 100
if pct >= 4: # skip tiny slice labels
d.append(draw.Text(
f"{pct:.0f}%",
font["size"] * 0.75,
lx, ly,
font_family=f"{font['family']}, sans-serif",
font_weight="bold",
fill="#FFFFFF",
text_anchor="middle",
dominant_baseline="middle",
))
start_angle = end_angle

View file

@ -49,6 +49,28 @@ class DateScale:
return self.date_min + timedelta(seconds=seconds)
class CategoricalScale:
"""Maps category labels (strings) to evenly spaced pixel centre positions."""
def __init__(self, categories: list[str], range_min: float, range_max: float):
self.categories = list(categories)
self.range_min = range_min
self.range_max = range_max
n = len(self.categories)
self._step = (range_max - range_min) / n if n > 0 else 0
self._positions = {
cat: range_min + self._step * (i + 0.5)
for i, cat in enumerate(self.categories)
}
def __call__(self, value: str) -> float:
return self._positions.get(str(value), self.range_min)
@property
def step(self) -> float:
return self._step
def nice_ticks(data_min: float, data_max: float, target_count: int = 6) -> list[float]:
"""Generate aesthetically pleasing tick values for an axis."""
if data_min == data_max:

View file

@ -4,7 +4,7 @@ from __future__ import annotations
from datetime import datetime
import drawsvg as draw
from app.models.style import COLORS, LAYOUT
from app.renderer.scale import LinearScale, DateScale
from app.renderer.scale import LinearScale, DateScale, CategoricalScale
def render_line_series(
@ -32,7 +32,6 @@ def render_line_series(
if len(points) < 2:
return
# Build path string for smoother rendering
path_data = f"M {points[0][0]},{points[0][1]}"
for px, py in points[1:]:
path_data += f" L {px},{py}"
@ -46,7 +45,7 @@ def render_line_series(
d.append(draw.Path(
d=path_data,
stroke=color,
stroke_width=weight,
stroke_width=f"{weight}px",
fill="none",
stroke_dasharray=dash if dash else "none",
stroke_linejoin="round",
@ -64,17 +63,13 @@ def render_shaded_fill(
above_color: str = "blue",
below_color: str = "pink",
):
"""Render shaded regions between a data series and a reference series.
Blue fill where data > reference, pink fill where data < reference.
"""
"""Render shaded regions between a data series and a reference series."""
if not x_values or len(x_values) < 2:
return
color_above = COLORS["shaded_above"] if above_color == "blue" else COLORS["shaded_below"]
color_below = COLORS["shaded_below"] if below_color == "pink" else COLORS["shaded_above"]
# Build pixel-space points
px_points = []
for i, xv in enumerate(x_values):
yd = y_data[i] if i < len(y_data) else None
@ -93,7 +88,6 @@ def render_shaded_fill(
if len(px_points) < 2:
return
# Split into segments at crossings and fill each
segments_above = []
segments_below = []
current_above = []
@ -107,14 +101,11 @@ def render_shaded_fill(
prev_diff = prev_py_d - prev_py_r
curr_diff = py_d - py_r
# Check for crossing (sign change) - note: y-axis is inverted in SVG
if prev_diff * curr_diff < 0:
# Linear interpolation to find crossing point
t = prev_diff / (prev_diff - curr_diff)
cross_px = prev_px + t * (px - prev_px)
cross_py = prev_py_d + t * (py_d - prev_py_d)
# Close current segment at crossing
if current_above:
current_above.append((cross_px, cross_py, cross_py))
segments_above.append(current_above)
@ -124,13 +115,9 @@ def render_shaded_fill(
segments_below.append(current_below)
current_below = [(cross_px, cross_py, cross_py)]
# In SVG, smaller y = higher on screen
# py_d < py_r means data is ABOVE reference visually
if py_d <= py_r: # data above or equal
if py_d <= py_r:
current_above.append((px, py_d, py_r))
if not current_below or current_below[-1] != (px, py_d, py_r):
pass # only add to above
else: # data below
else:
current_below.append((px, py_d, py_r))
if current_above:
@ -138,7 +125,6 @@ def render_shaded_fill(
if current_below:
segments_below.append(current_below)
# Render fill paths
for seg in segments_above:
_render_fill_segment(d, seg, color_above)
for seg in segments_below:
@ -146,49 +132,46 @@ def render_shaded_fill(
def _render_fill_segment(d: draw.Drawing, points: list[tuple], fill_color: str):
"""Render a single filled region between data and reference lines."""
if len(points) < 2:
return
# Forward path along data line
path_data = f"M {points[0][0]},{points[0][1]}"
for px, py_d, py_r in points[1:]:
path_data += f" L {px},{py_d}"
# Backward path along reference line
for px, py_d, py_r in reversed(points):
path_data += f" L {px},{py_r}"
path_data += " Z"
d.append(draw.Path(
d=path_data,
fill=fill_color,
stroke="none",
))
d.append(draw.Path(d=path_data, fill=fill_color, stroke="none"))
def render_bar_series(
d: draw.Drawing,
x_values: list[datetime],
x_values: list,
y_values: list[float],
x_scale: DateScale,
x_scale, # DateScale | CategoricalScale
y_scale: LinearScale,
color: str,
bar_width: float | None = None,
baseline: float = 0,
):
"""Render a bar chart series."""
"""Render a bar chart series. Supports date and categorical x-axes."""
if not x_values or not y_values:
return
n = len(x_values)
if n < 2:
total_width = 40
if bar_width is not None:
bw = bar_width
elif hasattr(x_scale, "step"):
# CategoricalScale provides equal-width slots
bw = x_scale.step * 0.7
else:
total_width = abs(x_scale(x_values[1]) - x_scale(x_values[0]))
# DateScale: find minimum non-zero gap between consecutive x positions
px_list = sorted({x_scale(xv) for xv in x_values})
gaps = [px_list[i + 1] - px_list[i] for i in range(len(px_list) - 1) if px_list[i + 1] - px_list[i] > 0]
bw = (min(gaps) if gaps else 40) * 0.7
bw = bar_width or total_width * 0.7
baseline_y = y_scale(baseline)
for xv, yv in zip(x_values, y_values):
@ -197,16 +180,11 @@ def render_bar_series(
px = x_scale(xv)
py = y_scale(yv)
if py == baseline_y: # zero-height bar — skip
continue
x_left = px - bw / 2
if py < baseline_y:
# Bar goes up
d.append(draw.Rectangle(
x_left, py, bw, baseline_y - py,
fill=color, stroke="none",
))
d.append(draw.Rectangle(x_left, py, bw, baseline_y - py, fill=color, stroke="none"))
else:
# Bar goes down
d.append(draw.Rectangle(
x_left, baseline_y, bw, py - baseline_y,
fill=color, stroke="none",
))
d.append(draw.Rectangle(x_left, baseline_y, bw, py - baseline_y, fill=color, stroke="none"))

View file

@ -2,7 +2,7 @@ annotated-doc==0.0.4
itsdangerous==2.2.0
PyJWT[crypto]==2.8.0
annotated-types==0.7.0
anthropic==0.84.0
anthropic==0.97.0
anyio==4.12.1
certifi==2026.2.25
click==8.3.1
@ -10,7 +10,7 @@ distro==1.9.0
docstring_parser==0.17.0
drawsvg==2.4.1
et_xmlfile==2.0.0
fastapi==0.135.1
fastapi==0.136.1
h11==0.16.0
httpcore==1.0.9
httpx==0.28.1
@ -20,9 +20,9 @@ jiter==0.13.0
MarkupSafe==3.0.3
numpy==2.4.2
openpyxl==3.1.5
pandas==3.0.1
pydantic==2.12.5
pydantic_core==2.41.5
pandas==3.0.2
playwright==1.58.0
pydantic==2.13.3
python-dateutil==2.9.0.post0
python-dotenv==1.2.2
python-multipart==0.0.22
@ -31,5 +31,4 @@ sniffio==1.3.1
starlette==0.52.1
typing-inspection==0.4.2
typing_extensions==4.15.0
uvicorn==0.41.0
cairosvg==2.7.1
uvicorn==0.46.0