glyphweave 0.3.0

Shape-constrained SVG word clouds, built for speed. Fast Rust CLI + library.
Documentation
use crate::core::model::{CloudPlacement, Rotation, StyleConfig, WordEntry};
use crate::layout::LayoutRequest;
use crate::mask::calculate_text_size;
use fontdue::Font;
use indicatif::{ProgressBar, ProgressStyle};
use ndarray::Array2;
use rand::RngCore;

#[derive(Debug, Clone, Copy)]
pub struct Rect {
	pub x: usize,
	pub y: usize,
	pub w: usize,
	pub h: usize,
}

#[derive(Debug, Clone)]
pub struct PlacementCandidate {
	pub word: String,
	pub word_weight: f32,
	pub rect: Rect,
	pub font_size: usize,
	pub rotation: Rotation,
}

impl Rect {
	pub fn area(self) -> usize {
		self.w * self.h
	}
}

pub fn total_area(mask: &Array2<bool>) -> usize {
	mask.iter().filter(|&&v| v).count()
}

pub fn available_positions(mask: &Array2<bool>) -> Vec<(usize, usize)> {
	mask.indexed_iter()
		.filter_map(|((y, x), value)| if *value { Some((y, x)) } else { None })
		.collect()
}

pub fn is_area_available(mask: &Array2<bool>, rect: Rect) -> bool {
	if rect.w == 0 || rect.h == 0 {
		return false;
	}

	if rect.x + rect.w > mask.ncols() || rect.y + rect.h > mask.nrows() {
		return false;
	}

	for dy in 0..rect.h {
		for dx in 0..rect.w {
			if !mask[[rect.y + dy, rect.x + dx]] {
				return false;
			}
		}
	}

	true
}

pub fn occupy_area(mask: &mut Array2<bool>, rect: Rect) -> usize {
	let mut consumed = 0usize;

	for dy in 0..rect.h {
		for dx in 0..rect.w {
			let cell = &mut mask[[rect.y + dy, rect.x + dx]];
			if *cell {
				*cell = false;
				consumed += 1;
			}
		}
	}

	consumed
}

pub fn random_index(rng: &mut dyn RngCore, len: usize) -> usize {
	if len <= 1 {
		return 0;
	}
	(rng.next_u64() as usize) % len
}

pub fn pick_weighted_word<'a>(
	words: &'a [WordEntry],
	rng: &mut dyn RngCore,
) -> Option<&'a WordEntry> {
	if words.is_empty() {
		return None;
	}

	let total_weight = words
		.iter()
		.map(|w| w.weight.max(0.0) as f64)
		.fold(0.0f64, |acc, w| acc + w);

	if total_weight <= f64::EPSILON {
		return words.get(random_index(rng, words.len()));
	}

	let rand_unit = (rng.next_u64() as f64) / (u64::MAX as f64);
	let mut cursor = rand_unit * total_weight;

	for word in words {
		cursor -= word.weight.max(0.0) as f64;
		if cursor <= 0.0 {
			return Some(word);
		}
	}

	words.last()
}

pub fn pick_color<'a>(colors: &'a [String], rng: &mut dyn RngCore) -> &'a str {
	let idx = random_index(rng, colors.len());
	colors[idx].as_str()
}

pub fn random_unit_f32(rng: &mut dyn RngCore) -> f32 {
	(rng.next_u64() as f64 / u64::MAX as f64) as f32
}

pub fn descending_font_sizes(style: &StyleConfig) -> impl Iterator<Item = usize> {
	(*style.font_size_range.start()..=*style.font_size_range.end()).rev()
}

pub fn find_fit_at_position(
	mask: &Array2<bool>,
	x: usize,
	y: usize,
	word: &str,
	style: &StyleConfig,
	font: &Font,
) -> Option<(usize, Rotation, Rect)> {
	for size in descending_font_sizes(style) {
		for rotation in &style.rotations {
			let (w, h) = calculate_text_size(word, font, size, style.padding, *rotation);
			let rect = Rect { x, y, w, h };
			if is_area_available(mask, rect) {
				return Some((size, *rotation, rect));
			}
		}
	}

	None
}

pub fn sample_candidate(
	mask: &Array2<bool>,
	positions: &mut Vec<(usize, usize)>,
	request: &LayoutRequest<'_>,
	rng: &mut dyn RngCore,
	max_trials: usize,
) -> Option<PlacementCandidate> {
	for _ in 0..max_trials {
		if positions.is_empty() {
			return None;
		}

		let idx = random_index(rng, positions.len());
		let (y, x) = positions[idx];
		if !mask[[y, x]] {
			positions.swap_remove(idx);
			continue;
		}

		let word = pick_weighted_word(request.words, rng)?;
		if let Some((font_size, rotation, rect)) =
			find_fit_at_position(mask, x, y, &word.text, request.style, request.font)
		{
			return Some(PlacementCandidate {
				word: word.text.clone(),
				word_weight: word.weight.max(0.0),
				rect,
				font_size,
				rotation,
			});
		}
	}

	None
}

pub fn apply_candidate(
	mask: &mut Array2<bool>,
	candidate: &PlacementCandidate,
	color: &str,
) -> (CloudPlacement, usize) {
	let consumed = occupy_area(mask, candidate.rect);
	let placed = placement(
		&candidate.word,
		candidate.rect,
		candidate.font_size,
		color,
		candidate.rotation,
	);
	(placed, consumed)
}

pub fn candidate_quality(candidate: &PlacementCandidate, total_usable_area: usize) -> f32 {
	if total_usable_area == 0 {
		return 0.0;
	}
	let area_score = candidate.rect.area() as f32 / total_usable_area as f32;
	area_score + candidate.word_weight * 0.01
}

pub fn placement(
	word: &str,
	rect: Rect,
	font_size: usize,
	color: &str,
	rotation: Rotation,
) -> CloudPlacement {
	CloudPlacement {
		word: word.to_string(),
		x: rect.x,
		y: rect.y,
		font_size,
		color: color.to_string(),
		rotation,
	}
}

pub fn create_progress_bar(show_progress: bool) -> Option<ProgressBar> {
	if !show_progress {
		return None;
	}

	let pb = ProgressBar::new(100);
	pb.set_style(
		ProgressStyle::with_template("[{bar:40.cyan/blue}] {pos:>3}%")
			.expect("progress style template should be valid")
			.progress_chars("=>-"),
	);
	Some(pb)
}

pub fn update_progress(pb: &Option<ProgressBar>, percent: usize) {
	if let Some(progress) = pb {
		progress.set_position(percent.min(100) as u64);
	}
}

pub fn finish_progress(pb: &Option<ProgressBar>) {
	if let Some(progress) = pb {
		progress.finish_and_clear();
	}
}

pub fn intersects(a: Rect, b: Rect) -> bool {
	a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
}