#!/usr/bin/env python3 """ build_infographic.py — Generate self-contained HTML/SVG infographics from JSON data. Supported infographic types: - stats: KPI / statistics card grid - comparison: Grouped bar chart with multiple series - flow: Process / step-by-step flow diagram - dashboard: Mixed layout (stats + chart + breakdown donut) Usage: python3 build_infographic.py config.json cat config.json | python3 build_infographic.py Output: self-contained HTML file (all CSS/SVG inline, no external deps). """ import json import math import os import sys from html import escape as _esc # --------------------------------------------------------------------------- # Color palettes # --------------------------------------------------------------------------- PALETTES = { "ocean": { "bg": "#eef4fb", "card": "#ffffff", "primary": "#0077b6", "text": "#023e8a", "text_light": "#4a6fa5", "colors": ["#0077b6", "#00b4d8", "#48cae4", "#90e0ef", "#0096c7", "#005f8a"], "up": "#0a9396", "down": "#e63946", }, "sunset": { "bg": "#fef6f0", "card": "#ffffff", "primary": "#e76f51", "text": "#1d3557", "text_light": "#6b7b8d", "colors": ["#e63946", "#f4845f", "#f7b267", "#e76f51", "#c1121f", "#d4a373"], "up": "#2a9d8f", "down": "#e63946", }, "forest": { "bg": "#eef7ee", "card": "#ffffff", "primary": "#2d6a4f", "text": "#1b4332", "text_light": "#6b8f71", "colors": ["#2d6a4f", "#40916c", "#52b788", "#74c69d", "#95d5b2", "#1b4332"], "up": "#2d6a4f", "down": "#c1121f", }, "berry": { "bg": "#f8f0fa", "card": "#ffffff", "primary": "#7b2cbf", "text": "#3c096c", "text_light": "#8a6baa", "colors": ["#7b2cbf", "#9d4edd", "#c77dff", "#e0aaff", "#5a189a", "#240046"], "up": "#2a9d8f", "down": "#e63946", }, "vibrant": { "bg": "#f5f6fa", "card": "#ffffff", "primary": "#4361ee", "text": "#1a1a2e", "text_light": "#6c757d", "colors": ["#4361ee", "#f72585", "#4cc9f0", "#7209b7", "#3a0ca3", "#f77f00"], "up": "#06d6a0", "down": "#ef476f", }, "corporate": { "bg": "#f0f2f5", "card": "#ffffff", "primary": "#1a365d", "text": "#1a202c", "text_light": "#718096", "colors": ["#1a365d", "#2b6cb0", "#3182ce", "#63b3ed", "#2c5282", "#4a5568"], "up": "#38a169", "down": "#e53e3e", }, "pastel": { "bg": "#fafafa", "card": "#ffffff", "primary": "#6c8ebf", "text": "#333333", "text_light": "#888888", "colors": ["#6c8ebf", "#d4a5a5", "#a8d8b9", "#f0d9b5", "#b8b8d1", "#f5c6aa"], "up": "#68b684", "down": "#d4a5a5", }, "earth": { "bg": "#f9f5f0", "card": "#ffffff", "primary": "#8b5e3c", "text": "#3e2723", "text_light": "#795548", "colors": ["#8b5e3c", "#a0522d", "#c49a6c", "#ddc9a3", "#6d4c41", "#5d4037"], "up": "#558b2f", "down": "#c62828", }, } _PALETTE_NAMES = list(PALETTES.keys()) # --------------------------------------------------------------------------- # SVG icon paths (viewBox 0 0 24 24) # --------------------------------------------------------------------------- ICONS = { "arrow-up": '', "arrow-down": '', "trending-up": '', "trending-down": '', "users": '', "user": '', "money": '', "percent": '', "globe": '', "clock": '', "check": '', "star": '', "target": '', "zap": '', "chart-bar": '', "chart-pie": '', "database": '', "rocket": '', "shield": '', "heart": '', "light": '', "search": '', "mail": '', "settings": '', "flag": '', } # --------------------------------------------------------------------------- # Utility helpers # --------------------------------------------------------------------------- def _pick_palette(name, data): if name and name != "auto" and name in PALETTES: return PALETTES[name] idx = hash(json.dumps(data, sort_keys=True, ensure_ascii=False)) % len(_PALETTE_NAMES) return PALETTES[_PALETTE_NAMES[idx]] def _get_color(palette, index): colors = palette["colors"] return colors[index % len(colors)] def _svg_icon(name, size=24, color="currentColor"): path_data = ICONS.get(name, ICONS.get("star", "")) needs_fill = "fill=\"none\"" not in path_data and "stroke=" not in path_data fill_attr = f' fill="{_esc(color)}"' if needs_fill else "" return ( f'{path_data}' ) def _format_number(v): if isinstance(v, str): return v if isinstance(v, float): if v >= 1_000_000: return f"{v / 1_000_000:.1f}M" if v >= 1_000: return f"{v / 1_000:.1f}K" return f"{v:.1f}" if isinstance(v, int): if v >= 1_000_000: return f"{v / 1_000_000:.1f}M" if v >= 1_000: return f"{v:,}" return str(v) return str(v) # --------------------------------------------------------------------------- # SVG chart generators # --------------------------------------------------------------------------- def _svg_bar_chart(data, palette, chart_w=760, chart_h=360): categories = data.get("categories", []) series_list = data.get("series", []) if not categories or not series_list: return '

No chart data provided.

' pad_l, pad_r, pad_t, pad_b = 65, 20, 30, 55 plot_w = chart_w - pad_l - pad_r plot_h = chart_h - pad_t - pad_b all_vals = [v for s in series_list for v in s.get("values", [])] if not all_vals: return '

No values in series.

' max_val = max(all_vals) * 1.15 or 1 n_cat = len(categories) n_ser = len(series_list) group_w = plot_w / n_cat bar_gap = max(2, group_w * 0.1) bar_w = max(8, (group_w - bar_gap * 2) / n_ser) lines = [ f'' ] n_ticks = 5 for i in range(n_ticks + 1): val = max_val * i / n_ticks y = pad_t + plot_h - (plot_h * i / n_ticks) lines.append( f'' ) lines.append( f'{_format_number(val)}' ) for ci, cat in enumerate(categories): cx = pad_l + group_w * ci + group_w / 2 y = pad_t + plot_h + 20 lines.append( f'{_esc(str(cat))}' ) for si, series in enumerate(series_list): color = _get_color(palette, si) for ci, val in enumerate(series.get("values", [])): bar_h = (val / max_val) * plot_h if max_val else 0 x = pad_l + group_w * ci + bar_gap + bar_w * si y = pad_t + plot_h - bar_h lines.append( f'' f'{_esc(str(series.get("name", "")))}: {_format_number(val)}' ) if n_ser > 1: leg_y = chart_h - 8 leg_x = pad_l for si, series in enumerate(series_list): color = _get_color(palette, si) lines.append( f'' ) lines.append( f'{_esc(str(series.get("name", "")))}' ) leg_x += 16 + len(str(series.get("name", ""))) * 7 + 20 lines.append("") return "\n".join(lines) def _svg_donut_chart(items, palette, size=280): if not items: return "" total = sum(it.get("value", 0) for it in items) or 1 cx, cy = size / 2, size / 2 outer_r = size / 2 - 10 inner_r = outer_r * 0.6 lines = [ f'' ] angle = 0 for i, it in enumerate(items): val = it.get("value", 0) sweep = (val / total) * 360 if sweep < 0.5: angle += sweep continue color = it.get("color") or _get_color(palette, i) a1 = math.radians(angle - 90) a2 = math.radians(angle + sweep - 90) ox1, oy1 = cx + outer_r * math.cos(a1), cy + outer_r * math.sin(a1) ox2, oy2 = cx + outer_r * math.cos(a2), cy + outer_r * math.sin(a2) ix1, iy1 = cx + inner_r * math.cos(a2), cy + inner_r * math.sin(a2) ix2, iy2 = cx + inner_r * math.cos(a1), cy + inner_r * math.sin(a1) large = 1 if sweep > 180 else 0 d = ( f"M {ox1:.2f} {oy1:.2f} " f"A {outer_r:.2f} {outer_r:.2f} 0 {large} 1 {ox2:.2f} {oy2:.2f} " f"L {ix1:.2f} {iy1:.2f} " f"A {inner_r:.2f} {inner_r:.2f} 0 {large} 0 {ix2:.2f} {iy2:.2f} Z" ) label_text = _esc(str(it.get("label", ""))) pct = f"{val / total * 100:.1f}%" lines.append( f'' f"{label_text}: {pct}" ) angle += sweep lines.append( f'' f"{_format_number(total)}" ) lines.append( f'Total' ) lines.append("") return "\n".join(lines) def _svg_flow_diagram(steps, palette, step_w=180, step_h=90, gap=50): n = len(steps) if n == 0: return "" total_w = n * step_w + (n - 1) * gap + 40 total_h = step_h + 80 lines = [ f'' ] lines.append('') lines.append( f'' f'' f'' ) lines.append('') y_center = total_h / 2 for i, step in enumerate(steps): x = 20 + i * (step_w + gap) color = _get_color(palette, i) ry = y_center - step_h / 2 lines.append( f'' ) lines.append( f'' ) icon_name = step.get("icon", "check") step_num = step.get("step", i + 1) title = _esc(str(step.get("title", f"Step {step_num}"))) desc = _esc(str(step.get("description", ""))) badge_cx = x + 22 badge_cy = ry + 22 lines.append( f'' ) lines.append( f'{step_num}' ) lines.append( f'{title}' ) if desc: lines.append( f'{desc}' ) if i < n - 1: ax1 = x + step_w + 4 ax2 = x + step_w + gap - 4 lines.append( f'' ) lines.append("") return "\n".join(lines) # --------------------------------------------------------------------------- # Section builders # --------------------------------------------------------------------------- def _build_stats(data, palette): items = data if isinstance(data, list) else data.get("items", data.get("stats", [])) if not items: return '

No stats data provided.

' cards = [] for i, item in enumerate(items): label = _esc(str(item.get("label", ""))) value = _esc(str(item.get("value", ""))) icon_name = item.get("icon", "star") trend = item.get("trend", "") trend_dir = item.get("trend_dir", "up") color = _get_color(palette, i) trend_color = palette["up"] if trend_dir == "up" else palette["down"] trend_icon = "trending-up" if trend_dir == "up" else "trending-down" trend_html = "" if trend: trend_html = ( f'
' f'{_svg_icon(trend_icon, 16, trend_color)} {_esc(str(trend))}' f"
" ) cards.append( f'
' f'
' f'' f"{label}" f'
' f"{_svg_icon(icon_name, 22, color)}
" f'
{value}
' f"{trend_html}
" ) return ( '
{" ".join(cards)}
' ) def _build_comparison(data, palette): chart_title = data.get("chart_title", "") title_html = "" if chart_title: title_html = ( f'

{_esc(chart_title)}

' ) chart_svg = _svg_bar_chart(data, palette) return ( f'
{title_html}{chart_svg}
' ) def _build_flow(data, palette): steps = data if isinstance(data, list) else data.get("steps", []) if not steps: return '

No flow data provided.

' return ( f'
' f"{_svg_flow_diagram(steps, palette)}
" ) def _build_dashboard(data, palette): sections = [] stats_data = data.get("stats", []) if stats_data: sections.append(_build_stats(stats_data, palette)) chart_data = data.get("chart") if chart_data: sections.append(_build_comparison(chart_data, palette)) breakdown = data.get("breakdown", []) if breakdown: donut_svg = _svg_donut_chart(breakdown, palette) legend_items = [] for i, it in enumerate(breakdown): color = it.get("color") or _get_color(palette, i) label = _esc(str(it.get("label", ""))) val = it.get("value", 0) legend_items.append( f'
' f'' f'{label}' f'' f"{_format_number(val)}
" ) sections.append( f'
' f'
{donut_svg}
' f'
' f'{"".join(legend_items)}
' ) flow_data = data.get("flow", []) if flow_data: sections.append(_build_flow(flow_data, palette)) return "\n".join(sections) # --------------------------------------------------------------------------- # HTML wrapper # --------------------------------------------------------------------------- def _wrap_html(title, subtitle, body_content, footer, palette): return f""" {_esc(title)}

{_esc(title)}

{"

" + _esc(subtitle) + "

" if subtitle else ""}
{body_content}
{"
" + _esc(footer) + "
" if footer else ""}
""" # --------------------------------------------------------------------------- # Validation # --------------------------------------------------------------------------- def _validate_config(config): errors = [] if not isinstance(config, dict): errors.append("Config must be a JSON object") return errors ig_type = config.get("type", "stats") valid_types = {"stats", "comparison", "flow", "dashboard"} if ig_type not in valid_types: errors.append(f"Invalid type '{ig_type}'. Must be one of: {', '.join(sorted(valid_types))}") if "data" not in config: errors.append("Missing required field: data") output = config.get("output", "") if output: out_dir = os.path.dirname(os.path.abspath(output)) if not os.path.isdir(out_dir): errors.append(f"Output directory does not exist: {out_dir}") return errors # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def main(): if len(sys.argv) > 1 and sys.argv[1] not in ("-h", "--help"): input_path = sys.argv[1] try: with open(input_path, "r", encoding="utf-8") as f: config = json.load(f) except FileNotFoundError: print(json.dumps({"status": "error", "message": f"File not found: {input_path}"})) sys.exit(1) except json.JSONDecodeError as e: print(json.dumps({"status": "error", "message": f"Invalid JSON: {e}"})) sys.exit(1) elif not sys.stdin.isatty(): try: config = json.load(sys.stdin) except json.JSONDecodeError as e: print(json.dumps({"status": "error", "message": f"Invalid JSON from stdin: {e}"})) sys.exit(1) else: print("Usage: python3 build_infographic.py ", file=sys.stderr) print(" cat config.json | python3 build_infographic.py", file=sys.stderr) sys.exit(1) errors = _validate_config(config) if errors: print(json.dumps({"status": "error", "errors": errors})) sys.exit(1) title = config.get("title", "Infographic") subtitle = config.get("subtitle", "") footer = config.get("footer", "") ig_type = config.get("type", "stats") palette = _pick_palette(config.get("palette", "auto"), config.get("data", {})) data = config.get("data", {}) if ig_type == "stats": body = _build_stats(data, palette) elif ig_type == "comparison": body = _build_comparison(data, palette) elif ig_type == "flow": body = _build_flow(data, palette) elif ig_type == "dashboard": body = _build_dashboard(data, palette) else: body = _build_stats(data, palette) html = _wrap_html(title, subtitle, body, footer, palette) output_path = config.get("output", "infographic.html") out_dir = os.path.dirname(os.path.abspath(output_path)) os.makedirs(out_dir, exist_ok=True) with open(output_path, "w", encoding="utf-8") as f: f.write(html) result = { "status": "success", "output": os.path.abspath(output_path), "type": ig_type, "title": title, "palette": config.get("palette", "auto"), "size_bytes": len(html.encode("utf-8")), } print(json.dumps(result, ensure_ascii=False)) if __name__ == "__main__": main()