use std::io::{Read, Write};
use clap::{Parser, Subcommand, ValueEnum};
#[derive(Parser)]
#[command(name = "fulgur-chart", version)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Render(RenderArgs),
Schema(SchemaArgs),
}
#[derive(Parser)]
struct SchemaArgs {
#[arg(long, default_value = "chartjs")]
dsl: String,
}
#[derive(Parser)]
struct RenderArgs {
#[arg(num_args = 1..)]
spec: Vec<String>,
#[arg(short, long)]
output: Option<String>,
#[arg(long)]
out_dir: Option<String>,
#[arg(long, value_enum)]
format: Option<Format>,
#[arg(long)]
width: Option<f64>,
#[arg(long)]
height: Option<f64>,
#[arg(long)]
strict: bool,
#[arg(long, default_value = "chartjs")]
dsl: String,
#[arg(long, default_value_t = 1.0)]
scale: f32,
#[arg(long)]
font: Option<String>,
}
#[derive(Clone, ValueEnum)]
enum Format {
Svg,
Png,
}
fn main() {
let cli = Cli::parse();
match cli.command {
Command::Render(args) => run_render(args),
Command::Schema(args) => run_schema(args),
}
}
fn run_render(args: RenderArgs) {
if args.dsl != "chartjs" && args.dsl != "vegalite" {
eprintln!("error: unsupported DSL '{}' (supported: chartjs, vegalite)", args.dsl);
std::process::exit(1);
}
let font_bytes: Option<Vec<u8>> = match &args.font {
Some(path) => match std::fs::read(path) {
Ok(b) => Some(b),
Err(e) => {
eprintln!("error: failed to read font '{path}': {e}");
std::process::exit(1);
}
},
None => None,
};
match &args.out_dir {
None => run_single(&args, &font_bytes),
Some(out_dir) => run_batch(&args, out_dir, &font_bytes),
}
}
fn run_single(args: &RenderArgs, font_bytes: &Option<Vec<u8>>) {
if args.spec.is_empty() {
eprintln!("error: no input spec provided");
std::process::exit(1);
}
if args.spec.len() > 1 {
eprintln!("error: multiple input specs require --out-dir");
std::process::exit(1);
}
let spec_path = &args.spec[0];
let output = match &args.output {
Some(o) => o,
None => {
eprintln!("error: --output (-o) is required for single-spec mode");
std::process::exit(1);
}
};
let json = match read_spec(spec_path) {
Ok(s) => s,
Err(e) => {
eprintln!("error: failed to read input: {e}");
std::process::exit(1);
}
};
let format = args.format.clone().unwrap_or_else(|| detect_format(output));
let bytes = match render_one(&json, args, &format, font_bytes) {
Ok(b) => b,
Err((code, msg)) => {
eprintln!("{msg}");
std::process::exit(code);
}
};
if let Err(e) = write_output(output, &bytes) {
eprintln!("error: write failed: {e}");
std::process::exit(3);
}
}
fn run_batch(args: &RenderArgs, out_dir: &str, font_bytes: &Option<Vec<u8>>) {
if args.output.is_some() {
eprintln!("error: --out-dir and --output cannot be used together");
std::process::exit(1);
}
let format = args.format.clone().unwrap_or(Format::Svg);
let ext = match format {
Format::Svg => "svg",
Format::Png => "png",
};
let mut seen_stems: Vec<String> = Vec::new();
let mut outputs: Vec<(std::path::PathBuf, Vec<u8>)> = Vec::new();
for spec_path in &args.spec {
if spec_path == "-" {
eprintln!("error: stdin ('-') is not supported in batch mode");
std::process::exit(1);
}
let stem = match std::path::Path::new(spec_path).file_stem() {
Some(s) => s.to_string_lossy().into_owned(),
None => {
eprintln!("error: cannot determine output stem for '{spec_path}'");
std::process::exit(1);
}
};
if seen_stems.contains(&stem) {
eprintln!("error: output name conflict: multiple inputs would produce '{stem}.{ext}'");
std::process::exit(1);
}
seen_stems.push(stem.clone());
let json = match std::fs::read_to_string(spec_path) {
Ok(s) => s,
Err(e) => {
eprintln!("error: failed to read '{spec_path}': {e}");
std::process::exit(1);
}
};
let bytes = match render_one(&json, args, &format, font_bytes) {
Ok(b) => b,
Err((code, msg)) => {
eprintln!("{spec_path}: {msg}");
std::process::exit(code);
}
};
let out_path = std::path::Path::new(out_dir).join(format!("{stem}.{ext}"));
outputs.push((out_path, bytes));
}
if let Err(e) = std::fs::create_dir_all(out_dir) {
eprintln!("error: failed to create output directory '{out_dir}': {e}");
std::process::exit(3);
}
for (out_path, _) in &outputs {
if out_path.exists() && !out_path.is_file() {
eprintln!("error: output path is not a file: {}", out_path.display());
std::process::exit(3);
}
}
for (out_path, bytes) in &outputs {
if let Err(e) = std::fs::write(out_path, bytes) {
eprintln!("error: write failed '{}': {e}", out_path.display());
std::process::exit(3);
}
}
}
fn parse_spec(json: &str, dsl: &str, strict: bool) -> Result<fulgur_chart::ir::ChartSpec, String> {
match dsl {
"vegalite" => fulgur_chart::frontend::vegalite::parse(json, strict),
_ => fulgur_chart::frontend::chartjs::parse(json, strict), }
}
fn render_one(
json: &str,
args: &RenderArgs,
format: &Format,
font_bytes: &Option<Vec<u8>>,
) -> Result<Vec<u8>, (i32, String)> {
let mut spec_ir =
parse_spec(json, &args.dsl, false).map_err(|e| (1, format!("error: parse failed: {e}")))?;
if args.strict {
parse_spec(json, &args.dsl, true).map_err(|e| (2, format!("error: strict violation: {e}")))?;
}
if let Some(w) = args.width {
spec_ir.width = w;
}
if let Some(h) = args.height {
spec_ir.height = h;
}
let svg = match font_bytes {
Some(bytes) => fulgur_chart::render::render_chart_with_font(&spec_ir, bytes)
.map_err(|e| (1, format!("error: render failed: {e}")))?,
None => fulgur_chart::render::render_chart(&spec_ir),
};
match format {
Format::Svg => Ok(svg.into_bytes()),
Format::Png => {
let res = match font_bytes {
Some(fb) => fulgur_chart::raster::svg_to_png_with_font(&svg, args.scale, fb),
None => fulgur_chart::raster::svg_to_png(&svg, args.scale),
};
res.map_err(|e| (3, format!("error: PNG conversion failed: {e}")))
}
}
}
fn read_spec(path: &str) -> std::io::Result<String> {
if path == "-" {
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
Ok(buf)
} else {
std::fs::read_to_string(path)
}
}
fn write_output(path: &str, bytes: &[u8]) -> std::io::Result<()> {
if path == "-" {
let mut out = std::io::stdout();
out.write_all(bytes)?;
out.flush()?;
Ok(())
} else {
std::fs::write(path, bytes)
}
}
fn detect_format(output: &str) -> Format {
if output != "-"
&& std::path::Path::new(output)
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("png"))
{
Format::Png
} else {
Format::Svg
}
}
fn run_schema(args: SchemaArgs) {
let json = match args.dsl.as_str() {
"chartjs" => {
let schema = schemars::schema_for!(fulgur_chart::schema::ChartJsSpec);
serde_json::to_string_pretty(&schema).expect("schema serialization failed")
}
"vegalite" => {
let schema = schemars::schema_for!(fulgur_chart::schema::VegaLiteSpec);
serde_json::to_string_pretty(&schema).expect("schema serialization failed")
}
other => {
eprintln!("error: unsupported DSL '{other}' (supported: chartjs, vegalite)");
std::process::exit(1);
}
};
println!("{json}");
}