glyphweave 0.3.0

Shape-constrained SVG word clouds, built for speed. Fast Rust CLI + library.
Documentation
use crate::cli::args::PaletteKind;
use glyphweave::core::error::GlyphWeaveError;

pub fn resolve_colors(
	explicit_colors: Option<Vec<String>>,
	palette_kind: PaletteKind,
	palette_base: &str,
	palette_size: usize,
) -> Result<Vec<String>, GlyphWeaveError> {
	if let Some(colors) = explicit_colors {
		if colors.is_empty() {
			return Err(GlyphWeaveError::InvalidConfig(
				"--colors provided but empty".to_string(),
			));
		}
		return Ok(colors);
	}

	generate_palette(palette_kind, palette_base, palette_size)
}

pub fn generate_palette(
	kind: PaletteKind,
	base_hex: &str,
	size: usize,
) -> Result<Vec<String>, GlyphWeaveError> {
	let size = size.max(1);

	match kind {
		PaletteKind::Pastel => Ok(repeat_palette(
			&[
				"#B8E1FF", "#FFD6E0", "#FFF3B0", "#CDEAC0", "#E4C1F9", "#FEE1C7",
			],
			size,
		)),
		PaletteKind::Earth => Ok(repeat_palette(
			&[
				"#3E2723", "#6D4C41", "#8D6E63", "#A1887F", "#5D4037", "#4E342E",
			],
			size,
		)),
		PaletteKind::Vibrant => Ok(repeat_palette(
			&[
				"#FF3B30", "#FF9500", "#FFCC00", "#34C759", "#007AFF", "#AF52DE",
			],
			size,
		)),
		_ => {
			let (r, g, b) = parse_hex_color(base_hex)?;
			let (h, s, l) = rgb_to_hsl(r, g, b);
			let mut out = Vec::with_capacity(size);

			for i in 0..size {
				let t = if size == 1 {
					0.5
				} else {
					i as f32 / (size - 1) as f32
				};

				let (hh, ss, ll) = match kind {
					PaletteKind::Auto => {
						let hues = [h - 30.0, h, h + 30.0, h + 160.0, h + 200.0, h + 240.0];
						let hue = hues[i % hues.len()];
						let light = 0.35 + 0.35 * t;
						(hue, (s * 0.85).clamp(0.35, 0.85), light)
					}
					PaletteKind::Complementary => {
						let hue = if i % 2 == 0 { h } else { h + 180.0 };
						let light = if i % 2 == 0 {
							0.35 + 0.25 * t
						} else {
							0.45 + 0.25 * t
						};
						(hue, (s * 0.9).clamp(0.4, 0.9), light)
					}
					PaletteKind::Triadic => {
						let hue = h + 120.0 * (i % 3) as f32;
						let light = 0.33 + 0.35 * t;
						(hue, (s * 0.88).clamp(0.4, 0.9), light)
					}
					PaletteKind::Analogous => {
						let spread = 60.0;
						let hue = h + spread * (t - 0.5);
						(
							hue,
							(s * 0.85).clamp(0.35, 0.85),
							(l * 0.65 + 0.15 + 0.3 * t).clamp(0.2, 0.82),
						)
					}
					PaletteKind::Monochrome => (h, (s * 0.75).clamp(0.15, 0.75), 0.2 + 0.6 * t),
					PaletteKind::Pastel | PaletteKind::Earth | PaletteKind::Vibrant => {
						unreachable!()
					}
				};

				let (rr, gg, bb) = hsl_to_rgb(hh, ss, ll);
				out.push(format!("#{:02X}{:02X}{:02X}", rr, gg, bb));
			}

			Ok(out)
		}
	}
}

fn repeat_palette(colors: &[&str], size: usize) -> Vec<String> {
	let mut out = Vec::with_capacity(size);
	for i in 0..size {
		out.push(colors[i % colors.len()].to_string());
	}
	out
}

fn parse_hex_color(input: &str) -> Result<(u8, u8, u8), GlyphWeaveError> {
	let text = input.trim();
	let clean = text.strip_prefix('#').unwrap_or(text);

	if clean.len() != 6 {
		return Err(GlyphWeaveError::InvalidConfig(format!(
			"invalid palette base color '{input}', expected #RRGGBB"
		)));
	}

	let r = u8::from_str_radix(&clean[0..2], 16).map_err(|_| {
		GlyphWeaveError::InvalidConfig(format!(
			"invalid palette base color '{input}', expected #RRGGBB"
		))
	})?;
	let g = u8::from_str_radix(&clean[2..4], 16).map_err(|_| {
		GlyphWeaveError::InvalidConfig(format!(
			"invalid palette base color '{input}', expected #RRGGBB"
		))
	})?;
	let b = u8::from_str_radix(&clean[4..6], 16).map_err(|_| {
		GlyphWeaveError::InvalidConfig(format!(
			"invalid palette base color '{input}', expected #RRGGBB"
		))
	})?;

	Ok((r, g, b))
}

fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
	let r = r as f32 / 255.0;
	let g = g as f32 / 255.0;
	let b = b as f32 / 255.0;

	let max = r.max(g.max(b));
	let min = r.min(g.min(b));
	let delta = max - min;

	let l = (max + min) / 2.0;

	if delta.abs() < f32::EPSILON {
		return (0.0, 0.0, l);
	}

	let s = delta / (1.0 - (2.0 * l - 1.0).abs());

	let h = if (max - r).abs() < f32::EPSILON {
		60.0 * (((g - b) / delta) % 6.0)
	} else if (max - g).abs() < f32::EPSILON {
		60.0 * (((b - r) / delta) + 2.0)
	} else {
		60.0 * (((r - g) / delta) + 4.0)
	};

	(normalize_hue(h), s, l)
}

fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
	let h = normalize_hue(h);

	let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
	let x = c * (1.0 - (((h / 60.0) % 2.0) - 1.0).abs());
	let m = l - c / 2.0;

	let (r1, g1, b1) = if h < 60.0 {
		(c, x, 0.0)
	} else if h < 120.0 {
		(x, c, 0.0)
	} else if h < 180.0 {
		(0.0, c, x)
	} else if h < 240.0 {
		(0.0, x, c)
	} else if h < 300.0 {
		(x, 0.0, c)
	} else {
		(c, 0.0, x)
	};

	let r = ((r1 + m) * 255.0).round().clamp(0.0, 255.0) as u8;
	let g = ((g1 + m) * 255.0).round().clamp(0.0, 255.0) as u8;
	let b = ((b1 + m) * 255.0).round().clamp(0.0, 255.0) as u8;

	(r, g, b)
}

fn normalize_hue(h: f32) -> f32 {
	let mut hue = h % 360.0;
	if hue < 0.0 {
		hue += 360.0;
	}
	hue
}

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

	#[test]
	fn complementary_palette_has_expected_size() {
		let colors = generate_palette(PaletteKind::Complementary, "#3366CC", 6)
			.expect("palette should be generated");
		assert_eq!(colors.len(), 6);
		assert!(colors.iter().all(|c| c.starts_with('#') && c.len() == 7));
	}
}