glyphweave 0.3.0

Shape-constrained SVG word clouds, built for speed. Fast Rust CLI + library.
Documentation
use clap::{Parser, ValueEnum};
use glyphweave::core::error::GlyphWeaveError;
use glyphweave::core::model::{AlgorithmKind, FontSizeSpec, WordEntry};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
pub struct CliArgs {
	#[arg(long = "config", help = "Path to a TOML config file")]
	pub config: Option<PathBuf>,

	#[arg(long = "canvas-size", value_parser = parse_tuple)]
	pub canvas_size: Option<(usize, usize)>,

	#[arg(long = "canvas-margin")]
	pub canvas_margin: Option<usize>,

	#[arg(long = "words", value_delimiter = ',')]
	pub words: Vec<String>,

	#[arg(long = "word-file")]
	pub word_file: Option<PathBuf>,

	#[arg(long = "weights-file")]
	pub weights_file: Option<PathBuf>,

	#[arg(long = "word-size-range", value_parser = parse_tuple)]
	pub word_size_range: Option<(usize, usize)>,

	#[arg(long = "colors", value_delimiter = ',')]
	pub word_colors: Option<Vec<String>>,

	#[arg(
		long = "palette",
		value_enum,
		help = "Auto palette strategy for generated colors"
	)]
	pub palette: Option<PaletteKind>,

	#[arg(
		long = "palette-base",
		help = "Base color in #RRGGBB used by dynamic palettes"
	)]
	pub palette_base: Option<String>,

	#[arg(
		long = "palette-size",
		help = "Number of colors generated by the palette"
	)]
	pub palette_size: Option<usize>,

	#[arg(long = "rotations", value_delimiter = ',')]
	pub rotations: Option<Vec<u16>>,

	#[arg(short = 't', long = "text", required = true)]
	pub shape_text: String,

	#[arg(long = "text-size", value_parser = parse_shape_size)]
	pub shape_size: Option<FontSizeSpec>,

	#[arg(long = "algorithm", value_enum)]
	pub algorithm: Option<CliAlgorithm>,

	#[arg(long = "font")]
	pub font_path: Option<PathBuf>,

	#[arg(
		long = "choose-system-font",
		default_value_t = false,
		help = "Prompt to choose a system font when embedded/default font is unavailable"
	)]
	pub choose_system_font: bool,

	#[arg(long = "seed")]
	pub seed: Option<u64>,

	#[arg(long = "ratio")]
	pub threshold: Option<f32>,

	#[arg(long = "max-tries")]
	pub max_tries: Option<usize>,

	#[arg(long = "debug-mask-out")]
	pub debug_mask_out: Option<PathBuf>,

	#[arg(long = "no-progress", default_value_t = false)]
	pub no_progress: bool,

	#[arg(short = 'o', long = "output", required = true)]
	pub output: PathBuf,

	#[arg(short = 'v', long = "verbose", default_value_t = false)]
	pub verbose: bool,
}

#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum CliAlgorithm {
	RandomBaseline,
	FastGrid,
	SpiralGreedy,
	Mcts,
	SimulatedAnnealing,
}

impl CliAlgorithm {
	pub fn parse_text(text: &str) -> Option<Self> {
		match text.trim().to_ascii_lowercase().as_str() {
			"random-baseline" | "randombaseline" => Some(Self::RandomBaseline),
			"fast-grid" | "fastgrid" => Some(Self::FastGrid),
			"spiral-greedy" | "spiralgreedy" => Some(Self::SpiralGreedy),
			"mcts" => Some(Self::Mcts),
			"simulated-annealing" | "simulatedannealing" | "annealing" | "sa" => {
				Some(Self::SimulatedAnnealing)
			}
			_ => None,
		}
	}
}

impl From<CliAlgorithm> for AlgorithmKind {
	fn from(value: CliAlgorithm) -> Self {
		match value {
			CliAlgorithm::RandomBaseline => AlgorithmKind::RandomBaseline,
			CliAlgorithm::FastGrid => AlgorithmKind::FastGrid,
			CliAlgorithm::SpiralGreedy => AlgorithmKind::SpiralGreedy,
			CliAlgorithm::Mcts => AlgorithmKind::Mcts,
			CliAlgorithm::SimulatedAnnealing => AlgorithmKind::SimulatedAnnealing,
		}
	}
}

#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum PaletteKind {
	Auto,
	Complementary,
	Triadic,
	Analogous,
	Monochrome,
	Pastel,
	Earth,
	Vibrant,
}

impl PaletteKind {
	pub fn parse_text(text: &str) -> Option<Self> {
		match text.trim().to_ascii_lowercase().as_str() {
			"auto" => Some(Self::Auto),
			"complementary" | "complement" => Some(Self::Complementary),
			"triadic" | "triad" => Some(Self::Triadic),
			"analogous" | "analog" => Some(Self::Analogous),
			"monochrome" | "mono" => Some(Self::Monochrome),
			"pastel" => Some(Self::Pastel),
			"earth" | "earthy" => Some(Self::Earth),
			"vibrant" | "vivid" => Some(Self::Vibrant),
			_ => None,
		}
	}
}

pub fn collect_words(args: &CliArgs) -> Result<Vec<WordEntry>, GlyphWeaveError> {
	let mut table: BTreeMap<String, f32> = BTreeMap::new();

	for word in &args.words {
		let normalized = word.trim();
		if normalized.is_empty() {
			continue;
		}
		*table.entry(normalized.to_string()).or_insert(0.0) += 1.0;
	}

	if let Some(path) = &args.word_file {
		for (word, weight) in parse_word_file(path)? {
			*table.entry(word).or_insert(0.0) += weight;
		}
	}

	if let Some(path) = &args.weights_file {
		for (word, weight) in parse_word_file(path)? {
			if let Some(existing) = table.get_mut(&word) {
				*existing = weight;
			}
		}
	}

	let words = table
		.into_iter()
		.map(|(word, weight)| WordEntry::new(word, weight.max(0.0)))
		.collect::<Vec<_>>();

	if words.is_empty() {
		return Err(GlyphWeaveError::InvalidConfig(
			"no words provided: use --words or --word-file".to_string(),
		));
	}

	Ok(words)
}

pub fn parse_word_file(path: &Path) -> Result<Vec<(String, f32)>, GlyphWeaveError> {
	let content = std::fs::read_to_string(path)?;
	let mut out = Vec::new();

	for (index, raw_line) in content.lines().enumerate() {
		let line = raw_line.trim();
		if line.is_empty() || line.starts_with('#') {
			continue;
		}

		let Some((word, weight)) = parse_word_line(line) else {
			return Err(GlyphWeaveError::InvalidConfig(format!(
				"invalid word format in {} at line {}: '{line}'",
				path.display(),
				index + 1
			)));
		};
		out.push((word, weight));
	}

	Ok(out)
}

pub fn parse_shape_size_text(input: &str) -> Result<FontSizeSpec, String> {
	if input.eq_ignore_ascii_case("AutoFit") {
		return Ok(FontSizeSpec::AutoFit);
	}

	let size = input
		.trim()
		.parse::<usize>()
		.map_err(|_| "invalid font size".to_string())?;

	Ok(FontSizeSpec::Fixed(size))
}

fn parse_word_line(line: &str) -> Option<(String, f32)> {
	let mut parts = line.splitn(2, ',');
	let word = parts.next()?.trim();
	if word.is_empty() {
		return None;
	}

	let weight = if let Some(weight_raw) = parts.next() {
		weight_raw.trim().parse::<f32>().ok()?
	} else {
		1.0
	};

	Some((word.to_string(), weight))
}

fn parse_tuple(input: &str) -> Result<(usize, usize), String> {
	let parts: Vec<&str> = input.split(',').collect();
	if parts.len() != 2 {
		return Err("value must use WIDTH,HEIGHT format".to_string());
	}

	let first = parts[0]
		.trim()
		.parse::<usize>()
		.map_err(|_| "invalid first number".to_string())?;
	let second = parts[1]
		.trim()
		.parse::<usize>()
		.map_err(|_| "invalid second number".to_string())?;

	Ok((first, second))
}

fn parse_shape_size(input: &str) -> Result<FontSizeSpec, String> {
	parse_shape_size_text(input)
}

#[cfg(test)]
mod tests {
	use super::*;

	#[test]
	fn parse_word_line_supports_optional_weight() {
		let entry = parse_word_line("hello,2.5").expect("line should parse");
		assert_eq!(entry.0, "hello");
		assert_eq!(entry.1, 2.5);

		let entry = parse_word_line("world").expect("line should parse");
		assert_eq!(entry.0, "world");
		assert_eq!(entry.1, 1.0);
	}

	#[test]
	fn parse_algorithm_name() {
		assert!(matches!(
			CliAlgorithm::parse_text("simulated-annealing"),
			Some(CliAlgorithm::SimulatedAnnealing)
		));
		assert!(CliAlgorithm::parse_text("unknown").is_none());
	}
}