//! rustfig-cli: convert, inspect and benchmark figures; run the MCP server. use rustfig_engine::Engine; use rustfig_geom::Point; use rustfig_model::{Document, Object, ObjectKind, PolyKind, Polyline, Style}; use rustfig_render::{raster, SceneOptions}; use serde_json::{json, Value}; use std::process::ExitCode; use std::time::Instant; const USAGE: &str = "usage: rustfig-cli convert IN OUT [--format LANG] [--magnification PCT] [--dpi N] [--margin FIGUNITS] [--background #rrggbb] [--option KEY=VALUE]... OUT: .fig/.rustfig save; otherwise an export language (fig2dev's -L names), from --format or the file's extension; --option passes one of fig2dev's options by name or letter (border=10, g=lightblue, N=true, Z=4w) rustfig-cli formats [LANG] list the export languages, or one language's options rustfig-cli info FILE rustfig-cli perf [--paths N] check performance budgets on a synthetic document rustfig-cli bench FILE time render, save and open for one file rustfig-cli mcp run the MCP server on stdio rustfig-cli fig2dev ARGS... fig2dev's own command line (fig2dev -h for its options)"; fn main() -> ExitCode { // fig2dev's command line runs exactly as the program itself (stdin, // stdout, stderr, exit status), before anything here looks at the arguments. if rustfig_engine::fig2dev::process_args().first().map(String::as_str) == Some("fig2dev") { let mut args = rustfig_engine::fig2dev::process_args(); args.drain(..1.min(args.len())); return ExitCode::from(rustfig_engine::fig2dev::run_cli(args)); } let args: Vec = rustfig_engine::fig2dev::process_args(); let result = match args.first().map(String::as_str) { Some("convert") => convert(&args[1..]), Some("info") => info(&args[1..]), Some("formats") => formats(args.get(1).map(String::as_str)), Some("perf") => perf(&args[1..]), Some("bench") => bench(&args[1..]), Some("mcp") => rustfig_mcp::Server::new().serve_stdio().map_err(|e| e.to_string()), Some("--version") => { println!("rustfig-cli {}", env!("CARGO_PKG_VERSION")); Ok(()) } _ => Err(USAGE.to_string()), }; match result { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("{e}"); ExitCode::FAILURE } } } fn opt(args: &[String], name: &str) -> Option { args.iter() .position(|a| a == name) .and_then(|i| args.get(i + 1)) .cloned() } fn open(path: &str) -> Result { let mut e = Engine::new(); e.execute("file.open", &json!({ "path": path })) .map_err(|e| e.message)?; Ok(e) } fn convert(args: &[String]) -> Result<(), String> { let (Some(input), Some(output)) = (args.first(), args.get(1)) else { return Err(USAGE.into()); }; let mut e = open(input)?; let lower = output.to_ascii_lowercase(); if lower.ends_with(".fig") || lower.ends_with(".rustfig") || lower.ends_with(".xfig") { e.execute("file.save", &json!({ "path": output })) .map_err(|e| e.message)?; return Ok(()); } let mut p = json!({ "path": output }); for (flag, key) in [ ("--magnification", "magnification"), ("--dpi", "dpi"), ("--margin", "margin"), ] { if let Some(v) = opt(args, flag) { let n: f64 = v.parse().map_err(|_| format!("{flag} needs a number"))?; p[key] = json!(n); } } if let Some(bg) = opt(args, "--background") { p["background"] = json!(bg); } if let Some(f) = opt(args, "--format") { p["format"] = json!(f); } let mut options = serde_json::Map::new(); for (i, a) in args.iter().enumerate() { if a != "--option" { continue; } let kv = args.get(i + 1).ok_or("--option needs KEY=VALUE")?; let (k, v) = kv.split_once('=').unwrap_or((kv.as_str(), "true")); // Numbers stay text: the engine reads them, and depth lists like // -5 or titles like 2024 must stay as written. let value = match v { "true" => json!(true), "false" => json!(false), _ => json!(v), }; options.insert(k.to_string(), value); } if !options.is_empty() { p["options"] = serde_json::Value::Object(options); } e.execute("file.export", &p).map_err(|e| e.message)?; Ok(()) } fn formats(lang: Option<&str>) -> Result<(), String> { let mut e = Engine::new(); let v = e.execute("file.export_formats", &json!({})).map_err(|e| e.message)?; let all = v["formats"].as_array().cloned().unwrap_or_default(); if let Some(lang) = lang { let f = all .iter() .find(|f| f["id"].as_str().is_some_and(|id| id.eq_ignore_ascii_case(lang))) .ok_or_else(|| format!("unknown language {lang:?}"))?; println!( "{} options (--option KEY=VALUE; KEY may be the fig2dev letter):", f["id"].as_str().unwrap_or_default() ); let id = f["id"].as_str().unwrap_or_default(); for o in f["options"].as_array().into_iter().flatten() { if !option_shown(id, o) { continue; } println!( " -{} {:18} {:7} {}", o["letter"].as_str().unwrap_or_default(), o["key"].as_str().unwrap_or_default(), o["arg"].as_str().unwrap_or_default(), o["help"].as_str().unwrap_or_default(), ); } return Ok(()); } for f in &all { let status = match (f["status"].as_str(), f["task"].as_str()) { (Some("available"), _) => "available".to_string(), (Some(s), Some(t)) => format!("{s} ({t})"), (s, _) => s.unwrap_or_default().to_string(), }; println!( "{:10} .{:9} {:34} {}", f["id"].as_str().unwrap_or_default(), f["extension"].as_str().unwrap_or_default(), f["description"].as_str().unwrap_or_default(), status ); } Ok(()) } /// Whether `formats LANG` lists option `o` (from `file.export_formats`) /// of language `lang`: only options RustFig honours and that do something /// for `lang` (fig2dev accepts some and ignores them, like the page /// options for EPS). fn option_shown(lang: &str, o: &Value) -> bool { if o["available"].as_bool() != Some(true) { return false; } let key = o["key"].as_str().unwrap_or_default(); let letter = o["letter"].as_str().unwrap_or_default(); match rustfig_engine::fig2dev::options::options_for(lang).find(|s| s.key == key && s.letter.to_string() == letter) { Some(s) => s.effective_for(lang), // An option of a ported fig2dev driver, by letter. None => key == letter, } } fn info(args: &[String]) -> Result<(), String> { let path = args.first().ok_or(USAGE)?; let mut e = open(path)?; let v = e.execute("doc.info", &json!({})).map_err(|e| e.message)?; println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()); Ok(()) } /// A synthetic document of `n` paths (boxes, polylines, splines, circles, texts). fn synthetic(n: usize) -> Document { let mut doc = Document::new(); let cols = (n as f64).sqrt().ceil().max(1.0) as usize; for i in 0..n { let x = (i % cols) as f64 * 300.0; let y = (i / cols) as f64 * 300.0; let mut s = Style::default(); s.depth = (i % 100) as i32; s.pen_color = rustfig_model::ColorRef((i % 32) as i32); if i % 3 == 0 { s.area_fill = 20; s.fill_color = rustfig_model::ColorRef(((i + 5) % 32) as i32); } let kind = match i % 5 { 0 => ObjectKind::Polyline(Polyline::rect(s, Point::new(x, y), Point::new(x + 200.0, y + 150.0))), 1 => ObjectKind::Polyline(Polyline::new( PolyKind::Polyline, s, vec![ Point::new(x, y), Point::new(x + 100.0, y + 200.0), Point::new(x + 200.0, y), ], )), 2 => ObjectKind::Spline(rustfig_model::Spline::new( rustfig_model::SplineKind::X, false, s, vec![ Point::new(x, y), Point::new(x + 80.0, y + 200.0), Point::new(x + 160.0, y), Point::new(x + 240.0, y + 200.0), ], )), 3 => ObjectKind::Ellipse(rustfig_model::Ellipse { kind: rustfig_model::EllipseKind::CircleRadius, style: s, direction: 1, angle: 0.0, center: Point::new(x + 100.0, y + 100.0), radii: Point::new(90.0, 90.0), start: Point::new(x, y), end: Point::new(x, y), }), _ => ObjectKind::Polyline(Polyline::new( PolyKind::Polygon, s, vec![ Point::new(x, y), Point::new(x + 200.0, y + 50.0), Point::new(x + 100.0, y + 200.0), ], )), }; doc.objects.push(std::sync::Arc::new(Object::new(kind))); } doc.assign_ids(); doc } fn time(f: impl FnOnce() -> T) -> (T, f64) { let t = Instant::now(); let r = f(); (r, t.elapsed().as_secs_f64() * 1000.0) } fn load_average() -> Option { let s = std::fs::read_to_string("/proc/loadavg").ok()?; s.split_whitespace().next()?.parse().ok() } struct Report { rows: Vec<(String, f64, f64)>, } impl Report { fn add(&mut self, name: &str, ms: f64, budget: f64) { self.rows.push((name.to_string(), ms, budget)); } fn print(&self) -> bool { let mut ok = true; println!("{:<28} {:>10} {:>10} status", "measurement", "ms", "budget"); for (n, ms, b) in &self.rows { let pass = ms <= b; ok &= pass; println!("{n:<28} {ms:>10.1} {b:>10.0} {}", if pass { "ok" } else { "OVER" }); } if let Some(l) = load_average() { if l > 2.0 { println!("warning: load average {l:.1}; timings are noisy"); } } ok } } fn measure_doc(doc: &Document, report: &mut Report, scale_budget: f64) -> Result<(), String> { let (scene, ms) = time(|| { rustfig_render::build_scene( doc, &SceneOptions { screen: true, ..Default::default() }, ) }); report.add("scene build", ms, 400.0 * scale_budget); let (bb, ms) = time(|| rustfig_format::bridge::document_bounds(doc)); report.add("bounds (fig2dev)", ms, 200.0 * scale_budget); let (w, h) = (1280u32, 860u32); let mut view = rustfig_engine::View { size: (f64::from(w), f64::from(h)), ..Default::default() }; view.fit(&bb); let render = |view: &rustfig_engine::View| -> Result { let mut pm = raster::Pixmap::new(w, h).ok_or("cannot allocate pixmap")?; let ((), ms) = time(|| { raster::rasterize( &scene, &mut pm, &raster::RasterOptions { transform: view.transform(), min_line_px: 1.0, adjust_px: 0.0, pattern_k: 1, pattern: rustfig_render::pattern::PatternFrame::screen(), }, ) }); Ok(ms) }; report.add("render (fit)", render(&view)?, 1500.0 * scale_budget); view.pan_pixels(200.0, 100.0); report.add("render (pan)", render(&view)?, 1500.0 * scale_budget); let c = rustfig_geom::Point::new(640.0, 430.0); view.zoom_about(view.zoom * 4.0, c); report.add("render (zoom x4)", render(&view)?, 800.0 * scale_budget); let mut e = Engine::new(); e.replace_document(doc.clone(), None); let pts: Vec = (0..1000) .map(|i| { Point::new( bb.min.x + (i as f64 * 37.0) % bb.width().max(1.0), bb.min.y + (i as f64 * 53.0) % bb.height().max(1.0), ) }) .collect(); let (_, ms) = time(|| pts.iter().filter_map(|p| e.hit_test(*p, 30.0)).count()); report.add("hit test (per click)", ms / 1000.0, 20.0 * scale_budget); // A1, A9: one-object edits and their undo, on the document plus 5,000 // texts (re-measuring every text made each edit cost milliseconds). for i in 0..5000 { let (x, y) = ( bb.min.x + (i % 100) as f64 * 400.0, bb.max.y + 600.0 + (i / 100) as f64 * 300.0, ); e.execute("shape.text", &json!({"x": x, "y": y, "text": format!("label {i}")})) .map_err(|e| e.message)?; } let ids: Vec = e.doc().objects.iter().step_by(97).take(50).map(|o| o.id.0).collect(); let (_, ms) = time(|| { for id in &ids { let _ = e.execute("transform.move", &json!({"ids": [id], "dx": 15, "dy": 0})); } }); report.add("edit (one object)", ms / ids.len().max(1) as f64, 10.0 * scale_budget); let (_, ms) = time(|| { for _ in &ids { e.undo(); } }); report.add("undo (one step)", ms / ids.len().max(1) as f64, 5.0 * scale_budget); let (text, ms) = time(|| rustfig_format::write_fig(doc)); report.add("save .fig", ms, 800.0 * scale_budget); let (parsed, ms) = time(|| rustfig_format::read_fig(text.as_bytes())); parsed.map_err(|e| e.to_string())?; report.add("open .fig", ms, 1200.0 * scale_budget); // As `file.export` exports: Fig text through fig2dev's reader and driver. let (svg, ms) = time(|| rustfig_engine::fig2dev::export(doc, "svg", &[], None)); svg?; report.add("export SVG", ms, 1500.0 * scale_budget); Ok(()) } fn perf(args: &[String]) -> Result<(), String> { // Timings must not depend on the installed fonts (F1). rustfig_engine::text::set_builtin_fonts(true); let n = opt(args, "--paths") .and_then(|v| v.parse().ok()) .unwrap_or(50_000usize) .clamp(1, 2_000_000); println!("synthetic document: {n} paths"); let doc = synthetic(n); let mut report = Report { rows: Vec::new() }; measure_doc(&doc, &mut report, (n as f64 / 50_000.0).max(0.02))?; if report.print() { Ok(()) } else { Err("some budgets were exceeded".into()) } } fn bench(args: &[String]) -> Result<(), String> { rustfig_engine::text::set_builtin_fonts(true); // see perf() let path = args.first().ok_or(USAGE)?; let (e, ms) = time(|| open(path)); let e = e?; println!("open: {ms:.1} ms, {} objects", e.doc().leaf_count()); let mut report = Report { rows: Vec::new() }; measure_doc(e.doc(), &mut report, 1.0)?; report.print(); Ok(()) } #[cfg(test)] mod tests { use super::*; /// The keys `formats LANG` lists. fn shown(lang: &str) -> Vec { let mut e = Engine::new(); let v = e.execute("file.export_formats", &json!({})).unwrap(); let f = v["formats"] .as_array() .unwrap() .iter() .find(|f| f["id"] == lang) .unwrap() .clone(); f["options"] .as_array() .unwrap() .iter() .filter(|o| option_shown(lang, o)) .map(|o| o["key"].as_str().unwrap().to_string()) .collect() } #[test] fn formats_lists_only_effective_options() { let eps = shown("eps"); assert!(eps.contains(&"border".to_string()), "{eps:?}"); assert!( eps.contains(&"grid".to_string()) && !shown("svg").contains(&"grid".to_string()), "{eps:?}" ); for k in ["center", "page_mode", "paper_size", "quality"] { assert!(!eps.contains(&k.to_string()), "eps lists {k}: {eps:?}"); } let png = shown("png"); assert!(png.contains(&"smoothing".to_string()), "{png:?}"); assert!(!png.contains(&"quality".to_string()), "-q is JPEG only: {png:?}"); assert!(shown("jpeg").contains(&"quality".to_string())); assert!(shown("svg").contains(&"paper_size".to_string()), "svg -z is ported"); } } .