Skip to main content

wow_sharedmedia/converter/
font.rs

1//! Font metadata extraction via ttf-parser.
2
3use std::path::Path;
4
5use crate::Error;
6
7/// Valid locale flag names recognized by LSM.
8pub const LOCALE_NAMES: &[&str] = &["koKR", "ruRU", "zhCN", "zhTW", "western"];
9
10/// Default locale list when none is specified for a font.
11pub const DEFAULT_LOCALES: &[&str] = &["western"];
12
13/// Validate that all locale names in the list are recognized.
14pub fn validate_locale_names(names: &[&str]) -> Result<Vec<String>, Error> {
15	let valid: std::collections::HashSet<&str> = LOCALE_NAMES.iter().copied().collect();
16	let mut invalid = Vec::new();
17	for name in names {
18		if !valid.contains(name) {
19			invalid.push(name.to_string());
20		}
21	}
22	if invalid.is_empty() {
23		Ok(names.iter().map(|s| s.to_string()).collect())
24	} else {
25		Err(Error::InvalidLocale(format!(
26			"Invalid locale names: {}",
27			invalid.join(", ")
28		)))
29	}
30}
31
32/// Extract metadata from a font file.
33pub fn extract_font_metadata(path: &Path) -> Result<FontMetadata, Error> {
34	let data = std::fs::read(path).map_err(|e| Error::Io {
35		source: e,
36		path: path.to_path_buf(),
37	})?;
38
39	if data.is_empty() {
40		return Err(Error::InvalidFont(format!("Font file is empty: {}", path.display())));
41	}
42
43	let face =
44		ttf_parser::Face::parse(&data, 0).map_err(|e| Error::InvalidFont(format!("Failed to parse font: {e}")))?;
45
46	let family_name = face
47		.names()
48		.into_iter()
49		.find(|n| n.name_id == ttf_parser::name_id::FAMILY && n.is_unicode())
50		.and_then(|n| n.to_string())
51		.unwrap_or_default();
52
53	let style_name = face
54		.names()
55		.into_iter()
56		.find(|n| n.name_id == ttf_parser::name_id::SUBFAMILY && n.is_unicode())
57		.and_then(|n| n.to_string())
58		.unwrap_or_default();
59
60	Ok(FontMetadata {
61		family_name,
62		style_name,
63		is_monospace: face.is_monospaced(),
64		num_glyphs: face.number_of_glyphs() as u32,
65		is_variable_font: face.is_variable(),
66	})
67}
68
69/// Validate that a font file is a valid TTF or OTF.
70///
71/// Checks: file extension, non-empty, ttf-parser can parse header.
72pub fn validate_font(path: &Path) -> Result<(), Error> {
73	let ext = path
74		.extension()
75		.and_then(|e| e.to_str())
76		.map(|e| e.to_lowercase())
77		.unwrap_or_default();
78
79	if ext != "ttf" && ext != "otf" {
80		return Err(Error::InvalidFont(format!(
81			"Unsupported font extension: .{} (expected .ttf or .otf)",
82			ext
83		)));
84	}
85
86	let data = std::fs::read(path).map_err(|e| Error::Io {
87		source: e,
88		path: path.to_path_buf(),
89	})?;
90
91	if data.is_empty() {
92		return Err(Error::InvalidFont("Font file is empty".to_string()));
93	}
94
95	ttf_parser::Face::parse(&data, 0).map_err(|e| Error::InvalidFont(format!("Failed to parse font: {e}")))?;
96
97	Ok(())
98}
99
100/// Metadata extracted from a valid font file.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct FontMetadata {
103	/// Font family name.
104	pub family_name: String,
105	/// Font style name.
106	pub style_name: String,
107	/// Whether the font reports itself as monospaced.
108	pub is_monospace: bool,
109	/// Number of glyphs reported by the face.
110	pub num_glyphs: u32,
111	/// Whether the font is a variable font.
112	pub is_variable_font: bool,
113}
114
115#[cfg(test)]
116mod tests {
117	use super::*;
118	use tempfile::TempDir;
119
120	#[test]
121	fn test_validate_locale_names_valid() {
122		assert!(validate_locale_names(&["western", "zhCN"]).is_ok());
123	}
124
125	#[test]
126	fn test_validate_locale_names_invalid() {
127		assert!(validate_locale_names(&["western", "invalid"]).is_err());
128	}
129
130	#[test]
131	fn test_validate_locale_names_empty() {
132		let result = validate_locale_names(&[]);
133		assert!(result.is_ok());
134		assert!(result.unwrap().is_empty());
135	}
136
137	#[test]
138	fn test_default_locales() {
139		let locales: Vec<String> = DEFAULT_LOCALES.iter().map(|s| s.to_string()).collect();
140		assert_eq!(locales, vec!["western"]);
141	}
142
143	#[test]
144	fn test_validate_font_rejects_wrong_extension() {
145		let dir = TempDir::new().unwrap();
146		let path = dir.path().join("fake.txt");
147		std::fs::write(&path, b"not a font").unwrap();
148
149		let result = validate_font(&path);
150		assert!(result.is_err());
151		match result.unwrap_err() {
152			Error::InvalidFont(msg) => assert!(msg.contains("Unsupported font extension")),
153			other => panic!("Expected InvalidFont, got: {other}"),
154		}
155	}
156
157	#[test]
158	fn test_validate_font_rejects_empty_file() {
159		let dir = TempDir::new().unwrap();
160		let path = dir.path().join("empty.ttf");
161		std::fs::write(&path, b"").unwrap();
162
163		let result = validate_font(&path);
164		assert!(result.is_err());
165		match result.unwrap_err() {
166			Error::InvalidFont(msg) => assert!(msg.contains("empty")),
167			other => panic!("Expected InvalidFont, got: {other}"),
168		}
169	}
170
171	#[cfg(target_os = "windows")]
172	#[test]
173	fn test_extract_font_metadata_from_system_font_smoke() {
174		let candidates = [
175			std::path::Path::new(r"C:\Windows\Fonts\arial.ttf"),
176			std::path::Path::new(r"C:\Windows\Fonts\calibri.ttf"),
177			std::path::Path::new(r"C:\Windows\Fonts\consola.ttf"),
178		];
179
180		let font = candidates
181			.iter()
182			.find(|p| p.exists())
183			.expect("expected at least one standard Windows font to exist");
184
185		validate_font(font).unwrap();
186		let meta = extract_font_metadata(font).unwrap();
187
188		assert!(!meta.family_name.is_empty() || !meta.style_name.is_empty());
189		assert!(meta.num_glyphs > 0);
190	}
191}