glyphore 0.2.0

MapLibre glyph PBF generation library and command-line interface
Documentation
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicUsize, Ordering};

static NEXT_TEMP_DIRECTORY: AtomicUsize = AtomicUsize::new(0);

const INFO_JSON: &str = concat!(
	"{\"familyName\":\"Noto Sans\",\"styleName\":\"Regular\",",
	"\"fontstackName\":\"Noto Sans Regular\",\"coveredRanges\":",
	"[0,256,512,768,1024,1280,7424,7680,7936,8192,8448,8704,",
	"8960,9472,9728,10496,11264,11776,42752,64256,65024,65280],",
	"\"glyphCount\":2188}\n",
);

struct TempDirectory(PathBuf);

impl TempDirectory {
	fn new(label: &str) -> Self {
		let sequence = NEXT_TEMP_DIRECTORY.fetch_add(1, Ordering::Relaxed);
		let path = std::env::temp_dir().join(format!(
			"glyphore-cli-{label}-{}-{sequence}",
			std::process::id(),
		));
		fs::create_dir_all(&path).unwrap();
		Self(path)
	}

	fn path(&self) -> &Path {
		&self.0
	}
}

impl Drop for TempDirectory {
	fn drop(&mut self) {
		let _ = fs::remove_dir_all(&self.0);
	}
}

fn fixture_font() -> PathBuf {
	Path::new(env!("CARGO_MANIFEST_DIR"))
		.join("../glyphore-core/tests/fixtures/NotoSans-Regular.ttf")
}

fn fixture_directory() -> PathBuf {
	fixture_font().parent().unwrap().to_owned()
}

fn golden_directory() -> PathBuf {
	Path::new(env!("CARGO_MANIFEST_DIR")).join("../glyphore-core/tests/golden")
}

fn run_cli(arguments: &[&str]) -> Output {
	Command::new(env!("CARGO_BIN_EXE_glyphore"))
		.args(arguments)
		.output()
		.unwrap()
}

fn assert_success(output: &Output) {
	assert!(
		output.status.success(),
		"CLI failed:\nstdout:\n{}\nstderr:\n{}",
		String::from_utf8_lossy(&output.stdout),
		String::from_utf8_lossy(&output.stderr),
	);
}

fn font_output_directory(output: &Path) -> PathBuf {
	output.join("Noto Sans Regular")
}

fn assert_complete_output(output: &Path) {
	let mut directories = fs::read_dir(output)
		.unwrap()
		.map(|entry| entry.unwrap().file_name().into_string().unwrap())
		.collect::<Vec<_>>();
	directories.sort();
	assert_eq!(directories, ["Noto Sans Regular"]);

	let font_directory = font_output_directory(output);
	let mut ranges = fs::read_dir(&font_directory)
		.unwrap()
		.map(|entry| entry.unwrap().file_name().into_string().unwrap())
		.collect::<Vec<_>>();
	ranges.sort();
	assert_eq!(ranges.len(), 22);
	for start in [0, 256, 8192] {
		let name = format!("{start}-{}.pbf", start + 255);
		assert_eq!(
			fs::read(font_directory.join(&name)).unwrap(),
			fs::read(golden_directory().join(name)).unwrap(),
		);
	}
}

#[test]
fn build_writes_covered_ranges_matching_core_golden_files() {
	let temporary = TempDirectory::new("build");
	let output = temporary.path().join("output");
	let result = run_cli(&[
		"build",
		fixture_directory().to_str().unwrap(),
		"-o",
		output.to_str().unwrap(),
	]);
	assert_success(&result);
	assert_eq!(result.stdout, b"Noto Sans Regular: 22 ranges\n");
	assert_complete_output(&output);
}

#[test]
fn info_matches_the_font_info_snapshot() {
	let font = fixture_font();
	let result = run_cli(&["info", font.to_str().unwrap(), "--json"]);
	assert_success(&result);
	assert_eq!(String::from_utf8(result.stdout).unwrap(), INFO_JSON);

	let result = run_cli(&["info", font.to_str().unwrap()]);
	assert_success(&result);
	assert_eq!(
		String::from_utf8(result.stdout).unwrap(),
		concat!(
			"fontstack: Noto Sans Regular\n",
			"family: Noto Sans\n",
			"style: Regular\n",
			"glyphs: 2188\n",
			"covered ranges: 22\n",
		),
	);
}

#[test]
fn invalid_fonts_fail_without_output_and_can_be_skipped() {
	let temporary = TempDirectory::new("invalid");
	let input = temporary.path().join("input");
	fs::create_dir(&input).unwrap();
	fs::copy(fixture_font(), input.join("NotoSans-Regular.ttf")).unwrap();
	fs::write(input.join("broken.ttf"), "not a font").unwrap();

	let failed_output = temporary.path().join("failed-output");
	let result = run_cli(&[
		"build",
		input.to_str().unwrap(),
		"-o",
		failed_output.to_str().unwrap(),
	]);
	assert_eq!(result.status.code(), Some(1));
	assert!(String::from_utf8_lossy(&result.stderr).contains("broken.ttf"));
	assert!(!failed_output.exists());

	let skipped_output = temporary.path().join("skipped-output");
	let result = run_cli(&[
		"build",
		input.to_str().unwrap(),
		"-o",
		skipped_output.to_str().unwrap(),
		"--skip-invalid",
	]);
	assert_success(&result);
	assert!(String::from_utf8_lossy(&result.stderr).contains("skipping `broken.ttf`"));
	assert_complete_output(&skipped_output);
}

#[test]
fn duplicate_fontstack_names_are_errors() {
	let temporary = TempDirectory::new("duplicate");
	let input = temporary.path().join("input");
	fs::create_dir(&input).unwrap();
	fs::copy(fixture_font(), input.join("first.ttf")).unwrap();
	fs::copy(fixture_font(), input.join("second.otf")).unwrap();

	let output = temporary.path().join("output");
	let result = run_cli(&[
		"build",
		input.to_str().unwrap(),
		"-o",
		output.to_str().unwrap(),
	]);
	assert_eq!(result.status.code(), Some(1));
	assert!(
		String::from_utf8_lossy(&result.stderr).contains("fontstack collision `Noto Sans Regular`")
	);
	assert!(!output.exists());
}