fix: axis labels, bar charts, legend, PDF fonts per Nina's feedback

- layout: _PAD_BOTTOM 100→160, _PAD_LEFT 120→160 to prevent label clipping
- axes: fix _format_tick float-comparison bug (1.1→"1.1" not "1.10");
  add auto-rotation for categorical x-axis labels when they overflow slot width
- engine: tick guard (>20 ticks falls back to nice_ticks); stacked_bar falls
  back to bar instead of crashing; extend _find_date_column keywords
- series: bar width uses 25th-percentile gap instead of min to avoid invisible bars
- legend: line series use line swatch with dash pattern; proportional square size
- export: @font-face injected in HTML <head> for PDF to prevent T3Font in InDesign
- prompts: add categorical bar chart few-shot example; ban stacked_bar; improve
  tick_interval guidance; add bar chart refine examples

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-18 18:48:17 +01:00
parent d52f088243
commit d8461076b8
7 changed files with 175 additions and 52 deletions

View file

@ -22,9 +22,9 @@ SYSTEM_PROMPT = """You are a PIMCO chart specification generator. You take a use
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, 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)
4. **Bar charts**: Vertical bars for time-based comparisons. Uses scale_kind: "date" (default) on x_axis. Set chart_type: "bar" on each series.
5. **Categorical bar charts**: Bars for non-date categories (credit ratings, sectors, countries, regions). Set x_axis.scale_kind: "category". Use the category column as x, numeric columns as series with chart_type: "bar".
6. **Stacked bar charts**: NOT implemented always use chart_type: "bar" instead. Never output chart_type: "stacked_bar".
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)
@ -34,7 +34,7 @@ SYSTEM_PROMPT = """You are a PIMCO chart specification generator. You take a use
2. Assign color_index values to differentiate series (0-9)
3. For Y-axis: set suffix="%" if data is percentages, set label for units like "Percentage points"
4. For X-axis: use date_format like "%b %Y" (Aug 2020), "%Y" (2020), or "%b '%y" (Jan '22)
5. Choose tick_interval to produce clean, readable tick marks (e.g., 1 for 0-6 range, 0.5 for -1.0 to 1.5). Aim for 5-8 tick marks. If tick_interval is set, ensure min_val and max_val are multiples of tick_interval.
5. Choose tick_interval to produce clean, readable tick marks (e.g., 1 for 0-6 range, 0.5 for -1.0 to 1.5). Aim for 5-8 tick marks. Verify: (max_val - min_val) / tick_interval must be between 4 and 10. Never set tick_interval so small that it would produce more than 10 ticks. If tick_interval is set, ensure min_val and max_val are multiples of tick_interval.
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
@ -88,6 +88,14 @@ Your job is to apply ONLY the requested changes to the existing spec and return
- "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"
**Bar chart changes:**
- "Change the bar color to green" set color_index to the green palette index (e.g., 1 for olive, 4 for teal) on the bar series
- "Switch to categorical bars" set x_axis.scale_kind="category"
- "Switch to date bars" set x_axis.scale_kind="date" (or remove scale_kind)
- "Add a second bar series" add another series with chart_type="bar" and a different color_index
- "Make bars for [column]" set chart_type="bar" and data_column to that column name
- NEVER use chart_type "stacked_bar" always use "bar"
**Layout changes:**
- "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
@ -144,6 +152,24 @@ FEW_SHOT_EXAMPLES = [
}],
},
},
{
"brief": "Bar chart showing total returns by sector for Q4 2024. Sectors on x-axis, return percentage on y-axis.",
"data_summary": "Sheet 'Sectors': 8 rows x 2 columns\n Columns: Sector, Total_Return\n Sectors: Technology, Financials, Healthcare, Energy, Consumer, Utilities, Materials, Industrials\n Total_Return: min=-3.2, max=8.7",
"spec": {
"layout": "single",
"panels": [{
"title": "Sector total returns — Q4 2024",
"subtitle": None,
"x_axis": {"scale_kind": "category"},
"y_axis": {"suffix": "%", "min_val": -5, "max_val": 10, "tick_interval": 5},
"secondary_y_axis": None,
"series": [
{"label": "Total Return", "data_column": "Total_Return", "chart_type": "bar", "color_index": 0, "line_style": "solid", "y_axis_side": "primary"},
],
"annotations": [],
}],
},
},
{
"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",

View file

@ -202,24 +202,48 @@ def render_x_axis_categorical(
label_y = plot_bottom + 30
last_rendered_right = None
# Determine if rotation is needed: rotate when labels would collide horizontally
needs_rotation = False
if scale.categories and scale.step > 0:
max_text_w = max(_estimate_text_width(str(c), font["size"]) for c in scale.categories)
if max_text_w > scale.step * 0.9:
needs_rotation = True
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 needs_rotation:
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
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",
))
if needs_rotation:
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="end",
dominant_baseline="hanging",
transform=f"rotate(-90,{x},{label_y})",
))
else:
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
@ -295,9 +319,9 @@ def _format_tick(value: float, suffix: str) -> str:
abs_val = abs(value)
if abs_val >= 1:
if round(value * 10) == value * 10:
return f"{value:.1f}{suffix}"
return f"{value:.2f}{suffix}"
# Format to 2dp then strip trailing zeros — avoids float-comparison bugs
formatted = f"{value:.2f}".rstrip("0").rstrip(".")
return f"{formatted}{suffix}"
else:
magnitude = -math.floor(math.log10(abs_val))
decimals = max(magnitude + 1, 2)

View file

@ -290,6 +290,9 @@ def _compute_y_axis(panel, all_y_values):
while val <= stop + interval * 0.01:
y_ticks.append(round(val, 10))
val += interval
# Guard: if interval is too small (>20 ticks), fall back to nice_ticks
if len(y_ticks) > 20:
y_ticks = nice_ticks(y_min, y_max)
else:
y_ticks = nice_ticks(y_min, y_max)
@ -454,8 +457,11 @@ def _dispatch_series(d, s: SeriesSpec, sd: dict, x_scale, y_scale):
)
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
elif s.chart_type == "stacked_bar":
# stacked_bar not yet fully implemented — render as regular bar
render_bar_series(d, sd["x"], sd["y"], x_scale, y_scale, color=sd["color"])
elif s.chart_type in ("area", "pie", "donut"):
# These are 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."
@ -509,13 +515,14 @@ def _find_date_column(df: pd.DataFrame) -> str | None:
"date", "dates", "time", "timestamp", "period", "month", "quarter",
"year", "as of", "as_of", "report_date", "reporting_date",
"fiscal_year", "observation_date", "obs_date",
"fy", "qtr", "fiscal", "yr", "mo", "week", "monthly", "quarterly", "annual",
}
for col in df.columns:
if str(col).lower() in _DATE_EXACT:
return col
for col in df.columns:
col_lower = str(col).lower()
if "date" in col_lower or "time" in col_lower:
if any(k in col_lower for k in ("date", "time", "period", "quarter", "fiscal", "month", "year")):
return col
for col in df.columns:
if df[col].dtype == "datetime64[ns]":

View file

@ -2,13 +2,44 @@
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.
For PDF: @font-face is duplicated in the HTML <head> (not just SVG <defs>) so
Chromium's PDF engine embeds TrueType subsets (CIDFont) rather than Type 3 fonts,
which prevents InDesign from substituting T3Font.
"""
from __future__ import annotations
import base64
from pathlib import Path
_playwright = None
_browser = None
# Resolve fonts directory relative to this file: app/renderer/ → app/static/fonts/
_FONTS_DIR = Path(__file__).parent.parent / "static" / "fonts"
_FONT_ENTRIES = [
("Roboto Condensed", "RobotoCondensed-Light.ttf", "300"),
("Roboto Condensed", "RobotoCondensed-Regular.ttf", "normal"),
("Roboto Condensed", "RobotoCondensed-Bold.ttf", "bold"),
]
def _build_font_css() -> str:
"""Build @font-face CSS for Roboto Condensed from the bundled TTF files."""
css = ""
for family, filename, weight in _FONT_ENTRIES:
font_path = _FONTS_DIR / filename
if font_path.exists():
b64 = base64.b64encode(font_path.read_bytes()).decode("ascii")
css += (
f"@font-face{{"
f"font-family:'{family}';"
f"src:url('data:font/truetype;base64,{b64}') format('truetype');"
f"font-weight:{weight};font-style:normal;"
f"}}"
)
return css
async def init_browser() -> None:
"""Start persistent Playwright/Chromium instance. Call once at app startup."""
@ -29,11 +60,12 @@ async def close_browser() -> None:
_playwright = None
def _html_wrapper(svg_str: str, width: int, height: int) -> str:
def _html_wrapper(svg_str: str, width: int, height: int, font_css: str = "") -> 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"{font_css}"
f"</style></head>"
f"<body style='width:{width}px;height:{height}px'>"
f"{svg_str}"
@ -63,10 +95,15 @@ async def svg_to_pdf(svg_str: str, width: int, height: int) -> bytes:
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).
Font CSS is injected into the HTML <head> (not just SVG defs) so Chromium
embeds TrueType subsets as CIDFont rather than Type 3, preventing InDesign
T3Font substitution.
"""
font_css = _build_font_css()
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.set_content(_html_wrapper(svg_str, width, height, font_css), wait_until="load")
await page.wait_for_timeout(200)
pdf_bytes = await page.pdf(
width=f"{width}px",

View file

@ -5,8 +5,8 @@ from dataclasses import dataclass
from app.models.style import LAYOUT
_PAD_TOP_MIN = 160
_PAD_BOTTOM = 100
_PAD_LEFT = 120
_PAD_BOTTOM = 160
_PAD_LEFT = 160
_PAD_RIGHT_STANDARD = 30
_PAD_RIGHT_PIE = 320
_PAD_RIGHT_DUAL_Y = 130 # right-side Y-axis labels for dual_y_axis layout

View file

@ -44,9 +44,10 @@ def legend_row_count(
square = LAYOUT["legend_square_size"]
square_to_text_gap = 10
available_width = plot_right - plot_left
swatch_size = max(square, int(font["size"] * 0.45))
item_widths = [
square + square_to_text_gap + _estimate_text_width(s.label, font["size"])
swatch_size + square_to_text_gap + _estimate_text_width(s.label, font["size"])
for s in series_list
]
@ -93,7 +94,7 @@ def render_legend(
items = []
for s in series_list:
color = palette[s.color_index % len(palette)] if s.color_index is not None else palette[0]
items.append((s.label, color))
items.append((s.label, color, s.chart_type, s.line_style))
if not items:
return
@ -105,9 +106,44 @@ def render_legend(
_render_vertical(d, items, font, square, plot_right, plot_top)
def _render_swatch(
d: draw.Drawing,
x: float,
y: float,
color: str,
chart_type: str,
line_style: str,
swatch_size: int,
):
"""Render a legend swatch: line segment for line series, filled square for bars/pie."""
if chart_type == "line":
# Horizontal line segment matching the series line style
line_len = max(swatch_size, 24)
dash = None
if line_style == "dashed":
dash = LAYOUT["dash_pattern"]
elif line_style == "dotted":
dash = LAYOUT["dot_pattern"]
d.append(draw.Line(
x, y, x + line_len, y,
stroke=color,
stroke_width=f"{LAYOUT['line_weight']}px",
stroke_dasharray=dash if dash else "none",
stroke_linecap="round",
))
return line_len
else:
d.append(draw.Rectangle(
x, y - swatch_size / 2,
swatch_size, swatch_size,
fill=color,
))
return swatch_size
def _render_horizontal(
d: draw.Drawing,
items: list[tuple[str, str]],
items: list[tuple[str, str, str, str]],
font: dict,
square: int,
plot_left: float,
@ -121,11 +157,13 @@ def _render_horizontal(
"""
square_to_text_gap = 10
available_width = plot_right - plot_left
# Proportional swatch size aligned to font cap height
swatch_size = max(square, int(font["size"] * 0.45))
# Compute each item's total pixel width: square + gap + text
# Compute each item's total pixel width: swatch + gap + text
item_widths = [
square + square_to_text_gap + _estimate_text_width(label, font["size"])
for label, _ in items
swatch_size + square_to_text_gap + _estimate_text_width(label, font["size"])
for label, _, _, _ in items
]
# Pack items into rows, each row limited to available_width
@ -156,19 +194,14 @@ def _render_horizontal(
x = plot_right - row_total
for i in row_indices:
label, color = items[i]
label, color, chart_type, line_style = items[i]
iw = item_widths[i]
# Colored square, vertically centred on the text baseline
d.append(draw.Rectangle(
x, y - square / 2,
square, square,
fill=color,
))
rendered_w = _render_swatch(d, x, y, color, chart_type, line_style, swatch_size)
# Label text
d.append(draw.Text(
label, font["size"],
x + square + square_to_text_gap, y,
x + rendered_w + square_to_text_gap, y,
font_family=f"{font['family']}, sans-serif",
font_weight=font["weight"],
fill=COLORS["legend_text"],
@ -179,7 +212,7 @@ def _render_horizontal(
def _render_vertical(
d: draw.Drawing,
items: list[tuple[str, str]],
items: list[tuple[str, str, str, str]],
font: dict,
square: int,
plot_right: float,
@ -188,22 +221,17 @@ def _render_vertical(
"""Stacked column of legend items, placed to the right of the plot area."""
gap = LAYOUT["legend_vertical_gap"]
square_to_text_gap = 10
swatch_size = max(square, int(font["size"] * 0.45))
x = plot_right + 20
y = plot_top
item_height = font["size"] + gap
for label, color in items:
# Colored square
d.append(draw.Rectangle(
x, y - square / 2,
square, square,
fill=color,
))
# Label text
for label, color, chart_type, line_style in items:
rendered_w = _render_swatch(d, x, y, color, chart_type, line_style, swatch_size)
d.append(draw.Text(
label, font["size"],
x + square + square_to_text_gap, y,
x + rendered_w + square_to_text_gap, y,
font_family=f"{font['family']}, sans-serif",
font_weight=font["weight"],
fill=COLORS["legend_text"],

View file

@ -167,10 +167,11 @@ def render_bar_series(
# CategoricalScale provides equal-width slots
bw = x_scale.step * 0.7
else:
# DateScale: find minimum non-zero gap between consecutive x positions
# DateScale: use 25th-percentile gap to avoid tiny bars from irregular spacing
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
gaps = sorted(px_list[i + 1] - px_list[i] for i in range(len(px_list) - 1) if px_list[i + 1] - px_list[i] > 0)
ref_gap = gaps[len(gaps) // 4] if gaps else 40
bw = ref_gap * 0.7
baseline_y = y_scale(baseline)