glyphweave 0.3.0

Shape-constrained SVG word clouds, built for speed. Fast Rust CLI + library.
Documentation
use crate::core::error::GlyphWeaveError;
use crate::core::model::{CanvasConfig, Rotation};
use fontdue::Font;
use image::{ImageBuffer, Rgba};
use ndarray::Array2;
use std::path::Path;

pub fn calculate_text_size(
	text: &str,
	font: &Font,
	font_size: usize,
	padding: usize,
	rotation: Rotation,
) -> (usize, usize) {
	let metrics: Vec<_> = text
		.chars()
		.map(|c| font.metrics(c, font_size as f32))
		.collect();
	let width = metrics.iter().map(|m| m.advance_width).sum::<f32>().ceil() as usize + 2 * padding;
	let height = metrics.iter().map(|m| m.height).max().unwrap_or(0) + 2 * padding;

	match rotation {
		Rotation::Deg0 => (width, height),
		Rotation::Deg90 => (height, width),
	}
}

pub fn calculate_auto_font_size(canvas: &CanvasConfig, text: &str, font: &Font) -> usize {
	let available_width = canvas.width.saturating_sub(2 * canvas.margin);
	let available_height = canvas.height.saturating_sub(2 * canvas.margin);

	let mut low = 1usize;
	let mut high = available_height.max(1);
	let mut best = 1usize;

	while low <= high {
		let mid = low + (high - low) / 2;
		let (w, h) = calculate_text_size(text, font, mid, 0, Rotation::Deg0);
		if w <= available_width && h <= available_height {
			best = mid;
			low = mid + 1;
		} else {
			if mid == 0 {
				break;
			}
			high = mid.saturating_sub(1);
		}
	}

	best
}

pub fn build_shape_mask(
	canvas: &CanvasConfig,
	text: &str,
	font: &Font,
	font_size: usize,
) -> Array2<bool> {
	let mut mask = Array2::from_elem((canvas.height, canvas.width), false);

	let metrics: Vec<_> = text
		.chars()
		.map(|c| font.metrics(c, font_size as f32))
		.collect();
	let text_width = metrics.iter().map(|m| m.advance_width).sum::<f32>().ceil() as usize;
	let text_height = metrics.iter().map(|m| m.height).max().unwrap_or(0);

	let offset_x = canvas.margin
		+ (canvas
			.width
			.saturating_sub(2 * canvas.margin)
			.saturating_sub(text_width))
			/ 2;
	let offset_y = canvas.margin
		+ (canvas
			.height
			.saturating_sub(2 * canvas.margin)
			.saturating_sub(text_height))
			/ 2;

	let mut cursor_x = offset_x;

	for (ch, glyph_metrics) in text.chars().zip(metrics.iter()) {
		let (raster_metrics, bitmap) = font.rasterize(ch, font_size as f32);

		for y in 0..raster_metrics.height {
			for x in 0..raster_metrics.width {
				let pixel = bitmap[y * raster_metrics.width + x];
				if pixel > 127 {
					let px = cursor_x + x;
					let py = offset_y + y;
					if px < canvas.width && py < canvas.height {
						mask[[py, px]] = true;
					}
				}
			}
		}

		cursor_x += glyph_metrics.advance_width.ceil() as usize;
	}

	mask
}

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

pub fn mask_centroid(mask: &Array2<bool>) -> (usize, usize) {
	let mut sum_x = 0usize;
	let mut sum_y = 0usize;
	let mut count = 0usize;

	for ((y, x), value) in mask.indexed_iter() {
		if *value {
			sum_x += x;
			sum_y += y;
			count += 1;
		}
	}

	if count == 0 {
		return (0, 0);
	}

	(sum_x / count, sum_y / count)
}

pub fn mask_to_image(mask: &Array2<bool>) -> ImageBuffer<Rgba<u8>, Vec<u8>> {
	let (height, width) = mask.dim();
	let mut image = ImageBuffer::new(width as u32, height as u32);

	for ((y, x), occupied) in mask.indexed_iter() {
		let pixel = if *occupied {
			Rgba([255, 255, 255, 255])
		} else {
			Rgba([0, 0, 0, 0])
		};
		image.put_pixel(x as u32, y as u32, pixel);
	}

	image
}

pub fn save_mask_image(mask: &Array2<bool>, path: &Path) -> Result<(), GlyphWeaveError> {
	let image = mask_to_image(mask);
	image.save(path)?;
	Ok(())
}

#[cfg(all(test, feature = "embedded_fonts"))]
mod tests {
	use super::*;

	#[test]
	fn auto_font_size_and_mask_are_valid() {
		let font = crate::font::load_default_embedded_font().expect("embedded font should load");
		let canvas = CanvasConfig {
			width: 800,
			height: 400,
			margin: 20,
		};
		let size = calculate_auto_font_size(&canvas, "HELLO", &font);
		assert!(size > 0);

		let mask = build_shape_mask(&canvas, "HELLO", &font, size);
		assert_eq!(mask.dim(), (400, 800));
		assert!(total_usable_area(&mask) > 0);
	}
}