use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::sync::atomic::{AtomicU32, Ordering};
use clap::{Parser, Subcommand};
use covecto_core::{
ColorMode, Engine, HierarchicalMode, OptimizeConfig, OptimizePreset, OutputFormat,
PathSimplifyMode, SplinePreset, VectorizeConfig, convert_output, load_image, vectorize,
vectorize_to,
};
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use rayon::prelude::*;
use serde::Serialize;
use std::io::Write;
use tracing::info;
#[derive(Parser)]
#[command(name = "covecto", version, about)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Vectorize(Box<VectorizeArgs>),
Serve {
#[arg(short, long, default_value = "3000")]
port: u16,
},
}
#[derive(clap::Args)]
struct VectorizeArgs {
#[arg(value_name = "INPUT")]
input: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
output_dir: Option<PathBuf>,
#[arg(long)]
output_template: Option<String>,
#[arg(short = 'F', long, default_value = "svg")]
format: String,
#[arg(short, long, default_value = "auto")]
engine: String,
#[arg(long)]
preset: Option<String>,
#[arg(long)]
profile: Option<String>,
#[arg(long)]
color_precision: Option<i32>,
#[arg(long)]
filter_speckle: Option<usize>,
#[arg(long)]
corner_threshold: Option<i32>,
#[arg(long)]
splice_threshold: Option<i32>,
#[arg(long)]
color_mode: Option<String>,
#[arg(long)]
hierarchical: Option<String>,
#[arg(long)]
path_simplify: Option<String>,
#[arg(long)]
layer_difference: Option<i32>,
#[arg(long)]
length_threshold: Option<f64>,
#[arg(long)]
max_iterations: Option<usize>,
#[arg(long)]
path_precision: Option<u32>,
#[arg(long, default_value = "true")]
optimize: bool,
#[arg(long, default_value = "default")]
optimize_preset: String,
#[arg(long)]
multipass: bool,
#[arg(long, default_value = "10")]
multipass_iterations: usize,
#[arg(short = 'R', long)]
recursive: bool,
#[arg(long)]
json: bool,
#[arg(long)]
json_pretty: bool,
#[arg(long)]
no_progress: bool,
#[arg(long)]
dry_run: bool,
}
fn parse_engine(s: &str) -> anyhow::Result<Engine> {
s.parse::<Engine>()
.map_err(|e| anyhow::anyhow!(e.to_string()))
}
fn parse_spline_preset(s: &str) -> anyhow::Result<SplinePreset> {
s.parse::<SplinePreset>()
.map_err(|e| anyhow::anyhow!(e.to_string()))
}
fn parse_opt_preset(s: &str) -> OptimizePreset {
match s.to_lowercase().as_str() {
"safe" => OptimizePreset::Safe,
"none" => OptimizePreset::None,
_ => OptimizePreset::Default,
}
}
fn parse_color_mode(s: &str) -> anyhow::Result<ColorMode> {
s.parse::<ColorMode>()
.map_err(|e| anyhow::anyhow!(e.to_string()))
}
fn parse_hierarchical(s: &str) -> anyhow::Result<HierarchicalMode> {
s.parse::<HierarchicalMode>()
.map_err(|e| anyhow::anyhow!(e.to_string()))
}
fn parse_path_simplify(s: &str) -> anyhow::Result<PathSimplifyMode> {
s.parse::<PathSimplifyMode>()
.map_err(|e| anyhow::anyhow!(e.to_string()))
}
fn apply_profile(name: &str, config: &mut VectorizeConfig) {
covecto_core::apply_profile(name, config);
}
#[derive(Clone)]
struct VectorizeOpts {
output: Option<PathBuf>,
output_dir: Option<PathBuf>,
output_template: Option<String>,
format: String,
engine: String,
preset: Option<String>,
profile: Option<String>,
color_precision: Option<i32>,
filter_speckle: Option<usize>,
corner_threshold: Option<i32>,
splice_threshold: Option<i32>,
color_mode: Option<String>,
hierarchical: Option<String>,
path_simplify: Option<String>,
layer_difference: Option<i32>,
length_threshold: Option<f64>,
max_iterations: Option<usize>,
path_precision: Option<u32>,
optimize: bool,
optimize_preset: String,
multipass: bool,
multipass_iterations: usize,
recursive: bool,
json: bool,
json_pretty: bool,
no_progress: bool,
dry_run: bool,
}
impl VectorizeOpts {
fn to_config(&self) -> anyhow::Result<VectorizeConfig> {
let mut config = VectorizeConfig {
engine: parse_engine(&self.engine)?,
optimize: self.optimize,
optimize_config: OptimizeConfig {
preset: parse_opt_preset(&self.optimize_preset),
multipass: self.multipass,
multipass_iterations: self.multipass_iterations,
},
spline_preset: self
.preset
.as_deref()
.map(parse_spline_preset)
.transpose()?,
color_precision: self.color_precision,
filter_speckle: self.filter_speckle,
corner_threshold: self.corner_threshold,
splice_threshold: self.splice_threshold,
color_mode: self
.color_mode
.as_deref()
.map(parse_color_mode)
.transpose()?,
hierarchical: self
.hierarchical
.as_deref()
.map(parse_hierarchical)
.transpose()?,
path_simplify_mode: self
.path_simplify
.as_deref()
.map(parse_path_simplify)
.transpose()?,
layer_difference: self.layer_difference,
length_threshold: self.length_threshold,
max_iterations: self.max_iterations,
path_precision: self.path_precision,
};
if let Some(ref profile) = self.profile {
apply_profile(profile, &mut config);
}
Ok(config)
}
}
fn parse_output_format(s: &str) -> anyhow::Result<OutputFormat> {
s.parse::<OutputFormat>()
.map_err(|e| anyhow::anyhow!(e.to_string()))
}
fn output_extension(format: &OutputFormat) -> &'static str {
format.extension()
}
#[derive(Serialize)]
struct JsonResult {
path: String,
output: String,
engine: String,
format: String,
bytes: usize,
paths: usize,
time_ms: u64,
input_size: (u32, u32),
compression_ratio: f64,
}
impl JsonResult {
fn from_result(
input: &Path,
output: &Path,
result: &covecto_core::VectorizeResult,
format: &OutputFormat,
output_bytes: usize,
) -> Self {
Self {
path: input.display().to_string(),
output: output.display().to_string(),
engine: result.engine_used.to_string(),
format: format.extension().to_string(),
bytes: output_bytes,
paths: result.metadata.path_count,
time_ms: result.metadata.processing_time_ms,
input_size: result.metadata.input_size,
compression_ratio: result.metadata.compression_ratio,
}
}
}
fn apply_output_template(template: &str, stem: &str, ext: &str) -> String {
template.replace("{stem}", stem).replace("{ext}", ext)
}
fn vectorize_single_raw(
input: &Path,
output: Option<&Path>,
config: &VectorizeConfig,
format: &OutputFormat,
) -> anyhow::Result<(covecto_core::VectorizeResult, usize, PathBuf)> {
let img = load_image(input)?;
let req = covecto_core::VectorizeRequest::new(img).with_config(config.clone());
let out_path = match output {
Some(p) => p.to_path_buf(),
None => {
let stem = input.file_stem().unwrap().to_string_lossy().to_string();
let ext = format.extension();
input
.parent()
.unwrap_or(Path::new("."))
.join(format!("{stem}.{ext}"))
}
};
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
let (bytes, result) = if *format == OutputFormat::Svg {
let file = std::fs::File::create(&out_path)?;
let mut writer = std::io::BufWriter::new(file);
let stream_result = vectorize_to(&mut writer, &req)?;
writer.flush()?;
let svg_bytes = stream_result.metadata.svg_byte_size;
let result = covecto_core::VectorizeResult {
svg: String::new(), engine_used: stream_result.engine_used,
metadata: stream_result.metadata,
};
(svg_bytes, result)
} else {
let result = vectorize(&req)?;
let (bytes, _content_type) = convert_output(&result.svg, *format)?;
std::fs::write(&out_path, &bytes)?;
(bytes.len(), result)
};
info!(
"✓ {} → {} ({}ms, {} bytes, {} paths, engine={}, format={})",
input.display(),
out_path.display(),
result.metadata.processing_time_ms,
bytes,
result.metadata.path_count,
result.engine_used,
format.extension(),
);
Ok((result, bytes, out_path))
}
fn make_spinner(msg: &str) -> ProgressBar {
let spinner = ProgressBar::new_spinner();
spinner.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.cyan} {msg}")
.unwrap(),
);
spinner.set_message(msg.to_string());
spinner
}
fn make_progress(total: usize) -> (MultiProgress, ProgressBar) {
let multi = MultiProgress::new();
let pb = multi.add(ProgressBar::new(total as u64));
pb.set_style(
ProgressStyle::default_bar()
.template("{spinner:.cyan} [{bar:30.cyan/blue}] {pos}/{len} {msg}")
.unwrap()
.progress_chars("█▓░"),
);
pb.set_message("vectorizing".to_string());
(multi, pb)
}
fn is_image_file(path: &Path) -> bool {
matches!(
path.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase())
.as_deref(),
Some("png" | "jpg" | "jpeg" | "webp" | "bmp" | "gif" | "tiff" | "tif" | "avif")
)
}
fn is_glob_pattern(s: &str) -> bool {
s.contains('*') || s.contains('?') || s.contains('[')
}
fn collect_files(input: &Path, recursive: bool) -> anyhow::Result<Vec<PathBuf>> {
let input_str = input.to_string_lossy();
if is_glob_pattern(&input_str) {
let pattern = if recursive {
if !input_str.contains('/') && !input_str.contains('\\') {
format!("./**/{input_str}")
} else {
input_str.to_string()
}
} else {
input_str.to_string()
};
let entries: Vec<PathBuf> = glob::glob(&pattern)?
.filter_map(|e| e.ok())
.filter(|p| p.is_file() && is_image_file(p))
.collect();
if entries.is_empty() {
anyhow::bail!("No files matched pattern: {pattern}");
}
return Ok(entries);
}
if input.is_file() {
return Ok(vec![input.to_path_buf()]);
}
if input.is_dir() {
if recursive {
let entries: Vec<PathBuf> = walkdir::WalkDir::new(input)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.map(|e| e.into_path())
.filter(|p| is_image_file(p))
.collect();
if entries.is_empty() {
anyhow::bail!("No image files found in: {}", input.display());
}
Ok(entries)
} else {
let entries: Vec<PathBuf> = std::fs::read_dir(input)?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.is_file() && is_image_file(p))
.collect();
if entries.is_empty() {
anyhow::bail!("No image files found in: {}", input.display());
}
Ok(entries)
}
} else {
anyhow::bail!("Input not found: {}", input.display());
}
}
fn compute_output_path(
input: &Path,
root: &Path,
out_dir: &Path,
template: &str,
ext: &str,
preserve_structure: bool,
) -> PathBuf {
let stem = input.file_stem().unwrap().to_string_lossy().to_string();
let filename = apply_output_template(template, &stem, ext);
if preserve_structure {
if let Ok(rel) = input.strip_prefix(root)
&& let Some(parent) = rel.parent()
&& parent != Path::new("")
&& parent != Path::new(".")
{
return out_dir.join(parent).join(filename);
}
}
out_dir.join(filename)
}
fn detect_engine_preview(path: &Path) -> String {
match image::image_dimensions(path) {
Ok((w, h)) => {
if w <= 256 && h <= 256 {
"pixel-exact".to_string()
} else {
"spline".to_string()
}
}
Err(_) => "auto".to_string(),
}
}
fn cmd_vectorize(input: &Path, opts: &VectorizeOpts) -> anyhow::Result<()> {
let config = opts.to_config()?;
let format = parse_output_format(&opts.format)?;
let ext = output_extension(&format);
let template = opts.output_template.as_deref().unwrap_or("{stem}.{ext}");
let files = collect_files(input, opts.recursive)?;
if files.len() == 1
&& input.is_file()
&& !opts.recursive
&& !is_glob_pattern(&input.to_string_lossy())
{
let file = &files[0];
if opts.dry_run {
let out_path = opts.output.clone().unwrap_or_else(|| {
let stem = file.file_stem().unwrap().to_string_lossy().to_string();
PathBuf::from(apply_output_template(template, &stem, ext))
});
let engine = detect_engine_preview(file);
println!(
"Would process: {} → {} (engine: {}, format: {})",
file.display(),
out_path.display(),
engine,
ext,
);
return Ok(());
}
let out_path = opts.output.clone().unwrap_or_else(|| {
let stem = file.file_stem().unwrap().to_string_lossy().to_string();
PathBuf::from(apply_output_template(template, &stem, ext))
});
if opts.json {
let show_progress = !opts.no_progress;
let spinner = if show_progress {
Some(make_spinner(&format!(
"{} → {} ",
file.display(),
out_path.display()
)))
} else {
None
};
match vectorize_single_raw(file, Some(&out_path), &config, &format) {
Ok((result, bytes, out)) => {
if let Some(s) = spinner {
s.finish_and_clear();
}
let json_result = JsonResult::from_result(file, &out, &result, &format, bytes);
let json_output = if opts.json_pretty {
serde_json::to_string_pretty(&json_result)?
} else {
serde_json::to_string(&json_result)?
};
println!("{json_output}");
}
Err(e) => {
if let Some(s) = spinner {
s.finish_and_clear();
}
return Err(e);
}
}
} else if opts.output.is_some() {
let show_progress = !opts.no_progress;
let spinner = if show_progress {
Some(make_spinner(&format!(
"{} → {} ",
file.display(),
out_path.display()
)))
} else {
None
};
let res = vectorize_single_raw(file, Some(&out_path), &config, &format);
if let Some(s) = spinner {
s.finish_and_clear();
}
res?;
} else {
let img = load_image(file)?;
let req = covecto_core::VectorizeRequest::new(img).with_config(config.clone());
let result = vectorize(&req)?;
let (bytes, _ct) = convert_output(&result.svg, format)?;
std::io::Write::write_all(&mut std::io::stdout(), &bytes)?;
}
return Ok(());
}
let root = input;
let preserve_structure = opts.recursive && !is_glob_pattern(&input.to_string_lossy());
let out_dir = opts
.output_dir
.clone()
.or_else(|| opts.output.clone())
.unwrap_or_else(|| root.join("vectorized"));
std::fs::create_dir_all(&out_dir)?;
if opts.dry_run {
println!("Would process {} files:", files.len());
for file in &files {
let out_path =
compute_output_path(file, root, &out_dir, template, ext, preserve_structure);
let engine = detect_engine_preview(file);
println!(
" {} → {} (engine: {})",
file.display(),
out_path.display(),
engine
);
}
return Ok(());
}
let show_progress = !opts.no_progress && !files.is_empty();
let (multi, pb) = if show_progress {
let (m, p) = make_progress(files.len());
(Some(m), Some(p))
} else {
(None, None)
};
let out_paths: Vec<PathBuf> = {
let mut used_names = HashSet::with_capacity(files.len());
files
.iter()
.map(|file| {
let mut out_path =
compute_output_path(file, root, &out_dir, template, ext, preserve_structure);
if !preserve_structure {
let name = out_path.file_name().unwrap().to_string_lossy().to_string();
if !used_names.insert(name) {
let stem = file.file_stem().unwrap().to_string_lossy().to_string();
let parent_dir = file
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.unwrap_or("unknown");
let mut candidate = format!("{parent_dir}_{stem}.{ext}");
let mut counter: usize = 1;
while !used_names.insert(candidate.clone()) {
counter += 1;
candidate = format!("{parent_dir}_{stem}_{counter}.{ext}");
}
out_path = out_dir.join(candidate);
}
}
out_path
})
.collect()
};
let count = AtomicU32::new(0);
let error_count = AtomicU32::new(0);
let error_list = Mutex::new(Vec::new());
let indexed_results: Vec<(usize, JsonResult)> = files
.par_iter()
.zip(out_paths.par_iter())
.enumerate()
.filter_map(|(idx, (file, out_path))| {
let res = vectorize_single_raw(file, Some(out_path), &config, &format);
if let Some(ref p) = pb {
p.inc(1);
}
match res {
Ok((result, bytes, out)) => {
count.fetch_add(1, Ordering::Relaxed);
if opts.json {
Some((
idx,
JsonResult::from_result(file, &out, &result, &format, bytes),
))
} else {
None
}
}
Err(e) => {
error_count.fetch_add(1, Ordering::Relaxed);
error_list
.lock()
.unwrap()
.push((file.display().to_string(), e.to_string()));
None
}
}
})
.collect();
if let Some(p) = pb {
p.finish_and_clear();
}
drop(multi);
let errors = error_list.into_inner().unwrap();
for (path, err) in &errors {
eprintln!("\u{2717} {path}: {err}");
}
let count = count.load(Ordering::Relaxed);
let error_count = errors.len() as u32;
let mut sorted_results = indexed_results;
sorted_results.sort_by_key(|(idx, _)| *idx);
let results: Vec<JsonResult> = sorted_results.into_iter().map(|(_, r)| r).collect();
if !opts.json && !opts.no_progress {
println!("Done: {count} converted, {error_count} errors");
}
if opts.json {
let json_output = if opts.json_pretty {
serde_json::to_string_pretty(&results)?
} else {
serde_json::to_string(&results)?
};
println!("{json_output}");
}
Ok(())
}
fn from_args(args: VectorizeArgs) -> VectorizeOpts {
VectorizeOpts {
output: args.output,
output_dir: args.output_dir,
output_template: args.output_template,
format: args.format,
engine: args.engine,
preset: args.preset,
profile: args.profile,
color_precision: args.color_precision,
filter_speckle: args.filter_speckle,
corner_threshold: args.corner_threshold,
splice_threshold: args.splice_threshold,
color_mode: args.color_mode,
hierarchical: args.hierarchical,
path_simplify: args.path_simplify,
layer_difference: args.layer_difference,
length_threshold: args.length_threshold,
max_iterations: args.max_iterations,
path_precision: args.path_precision,
optimize: args.optimize,
optimize_preset: args.optimize_preset,
multipass: args.multipass,
multipass_iterations: args.multipass_iterations,
recursive: args.recursive,
json: args.json || args.json_pretty,
json_pretty: args.json_pretty,
no_progress: args.no_progress,
dry_run: args.dry_run,
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt::init();
let cli = Cli::parse();
match cli.command {
Commands::Vectorize(args) => {
let input = args.input.clone();
let opts = from_args(*args);
cmd_vectorize(&input, &opts)?;
}
Commands::Serve { port } => {
covecto_api::run_server(port).await?;
}
}
Ok(())
}