import type { FastifyInstance } from "fastify"; import Papa from "papaparse"; import sharp from "sharp"; import { z } from "zod"; import { createToolRoute } from "../tool-factory.js"; const settingsSchema = z.object({ kind: z.enum(["bar", "line", "pie"]).default("bar"), title: z.string().max(120).optional(), width: z.number().int().min(320).max(2048).default(960), height: z.number().int().min(240).max(1536).default(540), }); const PALETTE = [ "#4e79a7", "#f28e2b", "#e15759", "#76b7b2", "#59a14f", "#edc948", "#b07aa1", "#ff9da7", "#9c755f", "#bab0ac", ]; /** XML-escape user-supplied text before embedding in SVG. */ function escapeXml(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } interface DataPoint { label: string; value: number; } function parseInput(buf: Buffer, filename: string): DataPoint[] { const lower = filename.toLowerCase(); if (lower.endsWith(".json")) { const raw: unknown = JSON.parse(buf.toString("utf8")); // Array of {label, value} if (Array.isArray(raw)) { return raw.map((item: Record) => ({ label: String(item.label ?? ""), value: Number(item.value), })); } // Object: key -> number if (raw && typeof raw === "object") { return Object.entries(raw as Record).map(([label, value]) => ({ label, value: Number(value), })); } throw new Error("JSON must be an array of {label,value} or an object"); } // CSV: column 1 = label, column 2 = numeric value const parsed = Papa.parse(buf.toString("utf8"), { header: false, skipEmptyLines: true, }); if (parsed.errors.length > 0) { throw new Error(`CSV parse failed: ${parsed.errors[0].message}`); } const rows = parsed.data; // Skip header row if first row col2 is non-numeric let start = 0; if (rows.length > 1 && Number.isNaN(Number(rows[0][1]))) { start = 1; } return rows.slice(start).map((row) => ({ label: String(row[0] ?? ""), value: Number(row[1]), })); } function renderBarSvg(data: DataPoint[], w: number, h: number, title: string | undefined): string { const margin = { top: title ? 40 : 20, right: 20, bottom: 60, left: 50 }; const plotW = w - margin.left - margin.right; const plotH = h - margin.top - margin.bottom; const maxVal = Math.max(...data.map((d) => d.value), 1); const barW = plotW / data.length; const rotateLabels = data.length > 8; let bars = ""; let labels = ""; for (let i = 0; i < data.length; i++) { const barH = (data[i].value / maxVal) * plotH; const x = margin.left + i * barW + barW * 0.1; const y = margin.top + plotH - barH; const bw = barW * 0.8; bars += ``; const lx = margin.left + i * barW + barW / 2; const ly = margin.top + plotH + 14; if (rotateLabels) { labels += `${escapeXml(data[i].label)}`; } else { labels += `${escapeXml(data[i].label)}`; } } // Axis line const axisLine = ``; const titleSvg = title ? `${escapeXml(title)}` : ""; return `${titleSvg}${axisLine}${bars}${labels}`; } function renderLineSvg(data: DataPoint[], w: number, h: number, title: string | undefined): string { const margin = { top: title ? 40 : 20, right: 20, bottom: 60, left: 50 }; const plotW = w - margin.left - margin.right; const plotH = h - margin.top - margin.bottom; const maxVal = Math.max(...data.map((d) => d.value), 1); const points: string[] = []; let dots = ""; let labels = ""; const rotateLabels = data.length > 8; for (let i = 0; i < data.length; i++) { const x = margin.left + (i / Math.max(data.length - 1, 1)) * plotW; const y = margin.top + plotH - (data[i].value / maxVal) * plotH; points.push(`${x},${y}`); dots += ``; const ly = margin.top + plotH + 14; if (rotateLabels) { labels += `${escapeXml(data[i].label)}`; } else { labels += `${escapeXml(data[i].label)}`; } } const polyline = ``; const axisLine = ``; const titleSvg = title ? `${escapeXml(title)}` : ""; return `${titleSvg}${axisLine}${polyline}${dots}${labels}`; } function renderPieSvg(data: DataPoint[], w: number, h: number, title: string | undefined): string { const cx = w / 2 - 60; const cy = h / 2 + (title ? 10 : 0); const r = Math.min(cx, cy) - 30; const total = data.reduce((s, d) => s + d.value, 0) || 1; let angle = -Math.PI / 2; let slices = ""; let legend = ""; const legendX = cx + r + 30; for (let i = 0; i < data.length; i++) { const fraction = data[i].value / total; const endAngle = angle + fraction * Math.PI * 2; const x1 = cx + r * Math.cos(angle); const y1 = cy + r * Math.sin(angle); const x2 = cx + r * Math.cos(endAngle); const y2 = cy + r * Math.sin(endAngle); const largeArc = fraction > 0.5 ? 1 : 0; slices += ``; const ly = 40 + i * 18; legend += ``; legend += `${escapeXml(data[i].label)}`; angle = endAngle; } const titleSvg = title ? `${escapeXml(title)}` : ""; return `${titleSvg}${slices}${legend}`; } export function registerChartMaker(app: FastifyInstance) { createToolRoute(app, { toolId: "chart-maker", settingsSchema, process: async () => { throw new Error("chart-maker is v2-only"); }, processV2: async (ctx) => { const settings = settingsSchema.parse(ctx.settings); const input = ctx.inputs[0]; const base = input.filename.replace(/\.[^.]+$/, ""); let data: DataPoint[]; try { data = parseInput(input.buffer, input.filename); } catch (err) { throw new Error(err instanceof Error ? err.message : "Failed to parse input"); } if (data.length === 0) { throw new Error("No data points found in input"); } if (data.length > 100) { throw new Error("Too many data points (max 100)"); } // Validate numeric values for (const point of data) { if (Number.isNaN(point.value)) { throw new Error("Column 2 must be numeric"); } } // Negative values render as invalid/degenerate SVG (negative bar heights, // backward pie arcs that Sharp silently drops); reject with a clear message. if (data.some((point) => point.value < 0)) { throw new Error("Chart values must be zero or greater"); } let svg: string; switch (settings.kind) { case "bar": svg = renderBarSvg(data, settings.width, settings.height, settings.title); break; case "line": svg = renderLineSvg(data, settings.width, settings.height, settings.title); break; case "pie": svg = renderPieSvg(data, settings.width, settings.height, settings.title); break; } const pngBuffer = await sharp(Buffer.from(svg)).png().toBuffer(); return { buffer: pngBuffer, filename: `${base}_chart.png`, contentType: "image/png", }; }, }); }