glyphweave 0.3.0

Shape-constrained SVG word clouds, built for speed. Fast Rust CLI + library.
Documentation
use crate::core::error::GlyphWeaveError;
use fontdue::{Font, FontSettings};
use std::collections::HashSet;
use std::path::{Path, PathBuf};

pub fn font_family_name(font: &Font) -> String {
	font.name().unwrap_or("Unknown").to_string()
}

pub fn load_font_from_file<P: AsRef<Path>>(path: P) -> Result<Font, GlyphWeaveError> {
	let path_ref = path.as_ref();
	let font_data = std::fs::read(path_ref).map_err(|err| {
		GlyphWeaveError::FontLoad(format!(
			"failed to read font '{}': {err}",
			path_ref.display()
		))
	})?;

	Font::from_bytes(font_data, FontSettings::default()).map_err(|err| {
		GlyphWeaveError::FontLoad(format!(
			"failed to parse font '{}': {err}",
			path_ref.display()
		))
	})
}

pub fn discover_system_font_candidates() -> Vec<PathBuf> {
	let mut candidates = Vec::new();
	let mut seen = HashSet::new();

	for path in preferred_system_font_paths() {
		push_font_candidate(path, &mut candidates, &mut seen);
	}

	for root in system_font_roots() {
		collect_font_files(&root, 0, 4, &mut candidates, &mut seen);
	}

	candidates.sort_by(|a, b| {
		score_font_path(a)
			.cmp(&score_font_path(b))
			.then_with(|| a.as_os_str().cmp(b.as_os_str()))
	});
	candidates
}

pub fn load_system_font() -> Result<(Font, PathBuf), GlyphWeaveError> {
	let candidates = discover_system_font_candidates();
	load_system_font_from_candidates(&candidates)
}

pub fn load_system_font_from_candidates(
	candidates: &[PathBuf],
) -> Result<(Font, PathBuf), GlyphWeaveError> {
	let mut parse_failures = 0usize;

	for path in candidates {
		match load_font_from_file(path) {
			Ok(font) => return Ok((font, path.clone())),
			Err(_) => parse_failures += 1,
		}
	}

	if candidates.is_empty() {
		return Err(GlyphWeaveError::FontLoad(
			"no system font candidates found; provide --font <path> or enable embedded_fonts feature"
				.to_string(),
		));
	}

	Err(GlyphWeaveError::FontLoad(format!(
		"found {} system font candidates, but none could be parsed ({parse_failures} failures); provide --font <path>",
		candidates.len()
	)))
}

#[cfg(feature = "embedded_fonts")]
pub fn load_default_embedded_font() -> Result<Font, GlyphWeaveError> {
	Font::from_bytes(
		crate::embedded_fonts::NOTO_SANS_SC_REGULAR,
		FontSettings::default(),
	)
	.map_err(|err| GlyphWeaveError::FontLoad(format!("failed to parse embedded font: {err}")))
}

#[cfg(not(feature = "embedded_fonts"))]
pub fn load_default_embedded_font() -> Result<Font, GlyphWeaveError> {
	Err(GlyphWeaveError::FontLoad(
		"no font provided and embedded_fonts feature is disabled".to_string(),
	))
}

fn push_font_candidate(path: PathBuf, out: &mut Vec<PathBuf>, seen: &mut HashSet<PathBuf>) {
	if !is_supported_font_file(&path) {
		return;
	}

	if path.exists() && seen.insert(path.clone()) {
		out.push(path);
	}
}

fn collect_font_files(
	dir: &Path,
	depth: usize,
	max_depth: usize,
	out: &mut Vec<PathBuf>,
	seen: &mut HashSet<PathBuf>,
) {
	if depth > max_depth || !dir.exists() {
		return;
	}

	let Ok(entries) = std::fs::read_dir(dir) else {
		return;
	};

	for entry in entries.flatten() {
		let path = entry.path();
		if path.is_dir() {
			collect_font_files(&path, depth + 1, max_depth, out, seen);
			continue;
		}

		push_font_candidate(path, out, seen);
	}
}

fn is_supported_font_file(path: &Path) -> bool {
	let Some(ext) = path.extension().and_then(|value| value.to_str()) else {
		return false;
	};
	matches!(ext.to_ascii_lowercase().as_str(), "ttf" | "otf")
}

fn preferred_system_font_paths() -> Vec<PathBuf> {
	let mut paths = Vec::new();

	if cfg!(target_os = "macos") {
		paths.push(PathBuf::from("/Library/Fonts/NotoSansSC-Regular.ttf"));
		paths.push(PathBuf::from("/Library/Fonts/SourceHanSansSC-Regular.otf"));
		paths.push(PathBuf::from(
			"/System/Library/Fonts/Supplemental/Arial.ttf",
		));
	}

	if cfg!(target_os = "linux") {
		paths.push(PathBuf::from(
			"/usr/share/fonts/truetype/noto/NotoSansSC-Regular.ttf",
		));
		paths.push(PathBuf::from(
			"/usr/share/fonts/opentype/noto/NotoSansSC-Regular.otf",
		));
		paths.push(PathBuf::from(
			"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
		));
	}

	if cfg!(target_os = "windows")
		&& let Ok(windir) = std::env::var("WINDIR")
	{
		paths.push(PathBuf::from(&windir).join("Fonts").join("arial.ttf"));
		paths.push(PathBuf::from(&windir).join("Fonts").join("segoeui.ttf"));
	}

	if let Ok(custom_font) = std::env::var("SHAPECLOUD_FONT") {
		paths.insert(0, PathBuf::from(custom_font));
	}

	paths
}

fn system_font_roots() -> Vec<PathBuf> {
	let mut roots = Vec::new();

	if cfg!(target_os = "macos") {
		roots.push(PathBuf::from("/System/Library/Fonts"));
		roots.push(PathBuf::from("/Library/Fonts"));
		if let Ok(home) = std::env::var("HOME") {
			roots.push(PathBuf::from(home).join("Library/Fonts"));
		}
	}

	if cfg!(target_os = "linux") {
		roots.push(PathBuf::from("/usr/share/fonts"));
		roots.push(PathBuf::from("/usr/local/share/fonts"));
		if let Ok(home) = std::env::var("HOME") {
			roots.push(PathBuf::from(&home).join(".local/share/fonts"));
			roots.push(PathBuf::from(home).join(".fonts"));
		}
	}

	if cfg!(target_os = "windows")
		&& let Ok(windir) = std::env::var("WINDIR")
	{
		roots.push(PathBuf::from(windir).join("Fonts"));
	}

	roots
}

fn score_font_path(path: &Path) -> usize {
	let path_str = path
		.file_name()
		.and_then(|value| value.to_str())
		.unwrap_or_default()
		.to_ascii_lowercase();

	[
		"notosanssc",
		"sourcehansanssc",
		"notosanscjk",
		"sourcehansans",
		"pingfang",
		"hiraginosansgb",
		"microsoftyahei",
		"simhei",
		"simsun",
		"dejavusans",
		"roboto",
		"segoeui",
		"arial",
	]
	.iter()
	.position(|needle| path_str.contains(needle))
	.unwrap_or(usize::MAX)
}