glyphweave 0.3.0

Shape-constrained SVG word clouds, built for speed. Fast Rust CLI + library.
Documentation
use crate::core::error::GlyphWeaveError;
use crate::layout::common::{
	Rect, create_progress_bar, descending_font_sizes, finish_progress, is_area_available,
	occupy_area, pick_color, pick_weighted_word, placement, total_area, update_progress,
};
use crate::layout::{LayoutRequest, LayoutResult, LayoutStrategy};
use crate::mask::{calculate_text_size, mask_centroid};
use rand::RngCore;

const SEARCH_RADIUS_LIMIT: usize = 220;

pub struct SpiralGreedyStrategy;

impl LayoutStrategy for SpiralGreedyStrategy {
	fn place(
		&self,
		request: &LayoutRequest<'_>,
		rng: &mut dyn RngCore,
	) -> Result<LayoutResult, GlyphWeaveError> {
		let mut mask = request.mask.clone();
		let total_usable_area = total_area(&mask);
		if total_usable_area == 0 {
			return Err(GlyphWeaveError::Generation(
				"shape mask has no usable area".to_string(),
			));
		}

		let mut center = mask_centroid(&mask);
		let offsets = spiral_offsets(SEARCH_RADIUS_LIMIT);
		let mut placements = Vec::new();
		let mut attempts = 0usize;
		let mut used_area = 0usize;
		let progress = create_progress_bar(request.show_progress);

		while attempts < request.max_try_count {
			let fill_ratio = used_area as f32 / total_usable_area as f32;
			if fill_ratio >= request.ratio_threshold {
				break;
			}

			attempts += 1;
			let Some(word_entry) = pick_weighted_word(request.words, rng) else {
				break;
			};

			let mut placed = None;
			'font_search: for size in descending_font_sizes(request.style) {
				for rotation in &request.style.rotations {
					let (w, h) = calculate_text_size(
						&word_entry.text,
						request.font,
						size,
						request.style.padding,
						*rotation,
					);
					for &(dy, dx) in &offsets {
						let x = center.0 as isize + dx;
						let y = center.1 as isize + dy;

						if x < 0 || y < 0 {
							continue;
						}

						let rect = Rect {
							x: x as usize,
							y: y as usize,
							w,
							h,
						};

						if is_area_available(&mask, rect) {
							placed = Some((size, *rotation, rect));
							break 'font_search;
						}
					}
				}
			}

			if let Some((font_size, rotation, rect)) = placed {
				used_area += occupy_area(&mut mask, rect);
				let color = pick_color(&request.style.colors, rng);
				placements.push(placement(
					&word_entry.text,
					rect,
					font_size,
					color,
					rotation,
				));

				center = (rect.x, rect.y);
				if !mask[[
					center.1.min(mask.nrows() - 1),
					center.0.min(mask.ncols() - 1),
				]] {
					center = mask_centroid(&mask);
				}
			}

			let ratio_progress = (used_area * 100) / total_usable_area;
			let try_progress = (attempts * 100) / request.max_try_count;
			update_progress(&progress, ratio_progress.max(try_progress));
		}

		finish_progress(&progress);

		Ok(LayoutResult {
			placements,
			attempts,
			used_area,
		})
	}
}

fn spiral_offsets(radius_limit: usize) -> Vec<(isize, isize)> {
	let mut offsets = Vec::with_capacity(radius_limit * radius_limit);
	offsets.push((0, 0));

	let mut x = 0isize;
	let mut y = 0isize;
	let mut step = 1isize;

	while x.unsigned_abs() <= radius_limit && y.unsigned_abs() <= radius_limit {
		for _ in 0..step {
			x += 1;
			offsets.push((y, x));
		}
		for _ in 0..step {
			y += 1;
			offsets.push((y, x));
		}
		step += 1;

		for _ in 0..step {
			x -= 1;
			offsets.push((y, x));
		}
		for _ in 0..step {
			y -= 1;
			offsets.push((y, x));
		}
		step += 1;

		if step as usize > radius_limit * 2 {
			break;
		}
	}

	offsets
}