1use std::fs;
9use std::path::{Path, PathBuf};
10
11use anyhow::{Context, Result};
12use rayon::prelude::*;
13use rayon::ThreadPoolBuilder;
14use read_fonts::tables::name::NameId;
15use read_fonts::types::Tag;
16use read_fonts::{FontRef, TableProvider};
17use serde::{Deserialize, Serialize};
18use skrifa::{FontRef as SkrifaFontRef, MetadataProvider};
19
20use crate::discovery::{FontDiscovery, PathDiscovery};
21use crate::query::Query;
22use crate::tags::{tag4, tag_to_string};
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct TypgFontFaceMeta {
32 pub names: Vec<String>,
34 #[serde(
36 serialize_with = "serialize_tags",
37 deserialize_with = "deserialize_tags"
38 )]
39 pub axis_tags: Vec<Tag>,
40 #[serde(
42 serialize_with = "serialize_tags",
43 deserialize_with = "deserialize_tags"
44 )]
45 pub feature_tags: Vec<Tag>,
46 #[serde(
48 serialize_with = "serialize_tags",
49 deserialize_with = "deserialize_tags"
50 )]
51 pub script_tags: Vec<Tag>,
52 #[serde(
54 serialize_with = "serialize_tags",
55 deserialize_with = "deserialize_tags"
56 )]
57 pub table_tags: Vec<Tag>,
58 pub codepoints: Vec<char>,
60 pub is_variable: bool,
62 #[serde(default)]
64 pub weight_class: Option<u16>,
65 #[serde(default)]
67 pub width_class: Option<u16>,
68 #[serde(default)]
70 pub family_class: Option<(u8, u8)>,
71 #[serde(default)]
73 pub creator_names: Vec<String>,
74 #[serde(default)]
76 pub license_names: Vec<String>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct TypgFontSource {
86 pub path: PathBuf,
88 pub ttc_index: Option<u32>,
90}
91
92impl TypgFontSource {
93 pub fn path_with_index(&self) -> String {
98 if let Some(idx) = self.ttc_index {
99 format!("{}#{idx}", self.path.display())
100 } else {
101 self.path.display().to_string()
102 }
103 }
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct TypgFontFaceMatch {
113 pub source: TypgFontSource,
115 pub metadata: TypgFontFaceMeta,
117}
118
119#[derive(Debug, Default, Clone)]
125pub struct SearchOptions {
126 pub follow_symlinks: bool,
128 pub jobs: Option<usize>,
130}
131
132pub fn search(
141 paths: &[PathBuf],
142 query: &Query,
143 opts: &SearchOptions,
144) -> Result<Vec<TypgFontFaceMatch>> {
145 let discovery = PathDiscovery::new(paths.iter().cloned()).follow_symlinks(opts.follow_symlinks);
146 let candidates = discovery.discover()?;
147
148 let run_search = || -> Result<Vec<TypgFontFaceMatch>> {
149 let metadata: Result<Vec<Vec<TypgFontFaceMatch>>> = candidates
150 .par_iter()
151 .map(|loc| load_metadata(&loc.path))
152 .collect();
153
154 let mut matches: Vec<TypgFontFaceMatch> = metadata?
155 .into_par_iter()
156 .flatten()
157 .filter(|face| query.matches(&face.metadata))
158 .collect();
159
160 sort_matches(&mut matches);
161 Ok(matches)
162 };
163
164 if let Some(jobs) = opts.jobs {
165 let pool = ThreadPoolBuilder::new().num_threads(jobs).build()?;
166 pool.install(run_search)
167 } else {
168 run_search()
169 }
170}
171
172pub fn filter_cached(entries: &[TypgFontFaceMatch], query: &Query) -> Vec<TypgFontFaceMatch> {
181 let mut matches: Vec<TypgFontFaceMatch> = entries
182 .iter()
183 .filter(|entry| query.matches(&entry.metadata))
184 .cloned()
185 .collect();
186
187 sort_matches(&mut matches);
188 matches
189}
190
191fn load_metadata(path: &Path) -> Result<Vec<TypgFontFaceMatch>> {
200 let data = fs::read(path).with_context(|| format!("reading font {}", path.display()))?;
201 let mut metas = Vec::new();
202
203 for font in FontRef::fonts(&data) {
204 let font = font?;
205 let ttc_index = font.ttc_index();
206 let sfont = if let Some(idx) = ttc_index {
207 SkrifaFontRef::from_index(&data, idx)?
208 } else {
209 SkrifaFontRef::new(&data)?
210 };
211
212 let names = collect_names(&font);
213 let mut axis_tags = collect_axes(&font);
214 let mut feature_tags = collect_features(&font);
215 let mut script_tags = collect_scripts(&font);
216 let mut table_tags = collect_tables(&font);
217 let mut codepoints = collect_codepoints(&sfont);
218 let fvar_tag = Tag::new(b"fvar");
219 let is_variable = table_tags.contains(&fvar_tag);
220 let (weight_class, width_class, family_class) = collect_classification(&font);
221 let mut creator_names = collect_creator_names(&font);
222 let mut license_names = collect_license_names(&font);
223
224 dedup_tags(&mut axis_tags);
225 dedup_tags(&mut feature_tags);
226 dedup_tags(&mut script_tags);
227 dedup_tags(&mut table_tags);
228 dedup_codepoints(&mut codepoints);
229 creator_names.sort_unstable();
230 creator_names.dedup();
231 license_names.sort_unstable();
232 license_names.dedup();
233
234 metas.push(TypgFontFaceMatch {
235 source: TypgFontSource {
236 path: path.to_path_buf(),
237 ttc_index,
238 },
239 metadata: TypgFontFaceMeta {
240 names: dedup_names(names, path),
241 axis_tags,
242 feature_tags,
243 script_tags,
244 table_tags,
245 codepoints,
246 is_variable,
247 weight_class,
248 width_class,
249 family_class,
250 creator_names,
251 license_names,
252 },
253 });
254 }
255
256 Ok(metas)
257}
258
259fn collect_tables(font: &FontRef) -> Vec<Tag> {
260 font.table_directory
261 .table_records()
262 .iter()
263 .map(|rec| rec.tag())
264 .collect()
265}
266
267fn collect_axes(font: &FontRef) -> Vec<Tag> {
268 if let Ok(fvar) = font.fvar() {
269 if let Ok(axes) = fvar.axes() {
270 return axes.iter().map(|axis| axis.axis_tag()).collect();
271 }
272 }
273 Vec::new()
274}
275
276fn collect_features(font: &FontRef) -> Vec<Tag> {
277 let mut tags = Vec::new();
278 if let Ok(gsub) = font.gsub() {
279 if let Ok(list) = gsub.feature_list() {
280 tags.extend(list.feature_records().iter().map(|rec| rec.feature_tag()));
281 }
282 }
283 if let Ok(gpos) = font.gpos() {
284 if let Ok(list) = gpos.feature_list() {
285 tags.extend(list.feature_records().iter().map(|rec| rec.feature_tag()));
286 }
287 }
288 tags
289}
290
291fn collect_scripts(font: &FontRef) -> Vec<Tag> {
292 let mut tags = Vec::new();
293 if let Ok(gsub) = font.gsub() {
294 if let Ok(list) = gsub.script_list() {
295 tags.extend(list.script_records().iter().map(|rec| rec.script_tag()));
296 }
297 }
298 if let Ok(gpos) = font.gpos() {
299 if let Ok(list) = gpos.script_list() {
300 tags.extend(list.script_records().iter().map(|rec| rec.script_tag()));
301 }
302 }
303 tags
304}
305
306fn collect_codepoints(font: &SkrifaFontRef) -> Vec<char> {
307 let mut cps = Vec::new();
308 for (cp, _) in font.charmap().mappings() {
309 if let Some(ch) = char::from_u32(cp) {
310 cps.push(ch);
311 }
312 }
313 cps
314}
315
316fn collect_names(font: &FontRef) -> Vec<String> {
317 let mut names = Vec::new();
318
319 if let Ok(name_table) = font.name() {
320 let data = name_table.string_data();
321 let wanted = [
322 NameId::FAMILY_NAME,
323 NameId::TYPOGRAPHIC_FAMILY_NAME,
324 NameId::SUBFAMILY_NAME,
325 NameId::TYPOGRAPHIC_SUBFAMILY_NAME,
326 NameId::FULL_NAME,
327 NameId::POSTSCRIPT_NAME,
328 ];
329
330 for record in name_table.name_record() {
331 if !record.is_unicode() {
332 continue;
333 }
334 if !wanted.contains(&record.name_id()) {
335 continue;
336 }
337 if let Ok(entry) = record.string(data) {
338 let rendered = entry.to_string();
339 if !rendered.trim().is_empty() {
340 names.push(rendered);
341 }
342 }
343 }
344 }
345
346 names
347}
348
349fn collect_creator_names(font: &FontRef) -> Vec<String> {
350 let mut names = Vec::new();
351
352 if let Ok(name_table) = font.name() {
353 let data = name_table.string_data();
354 let wanted = [
355 NameId::COPYRIGHT_NOTICE,
356 NameId::TRADEMARK,
357 NameId::MANUFACTURER,
358 NameId::DESIGNER,
359 NameId::DESCRIPTION,
360 NameId::VENDOR_URL,
361 NameId::DESIGNER_URL,
362 NameId::LICENSE_DESCRIPTION,
363 NameId::LICENSE_URL,
364 ];
365
366 for record in name_table.name_record() {
367 if !record.is_unicode() {
368 continue;
369 }
370 if !wanted.contains(&record.name_id()) {
371 continue;
372 }
373 if let Ok(entry) = record.string(data) {
374 let rendered = entry.to_string();
375 if !rendered.trim().is_empty() {
376 names.push(rendered);
377 }
378 }
379 }
380 }
381
382 names
383}
384
385fn collect_license_names(font: &FontRef) -> Vec<String> {
386 let mut names = Vec::new();
387
388 if let Ok(name_table) = font.name() {
389 let data = name_table.string_data();
390 let wanted = [
391 NameId::COPYRIGHT_NOTICE,
392 NameId::LICENSE_DESCRIPTION,
393 NameId::LICENSE_URL,
394 ];
395
396 for record in name_table.name_record() {
397 if !record.is_unicode() {
398 continue;
399 }
400 if !wanted.contains(&record.name_id()) {
401 continue;
402 }
403 if let Ok(entry) = record.string(data) {
404 let rendered = entry.to_string();
405 if !rendered.trim().is_empty() {
406 names.push(rendered);
407 }
408 }
409 }
410 }
411
412 names
413}
414
415fn collect_classification(font: &FontRef) -> (Option<u16>, Option<u16>, Option<(u8, u8)>) {
416 match font.os2() {
417 Ok(table) => {
418 let raw_family = table.s_family_class() as u16;
419 let class = (raw_family >> 8) as u8;
420 let subclass = (raw_family & 0x00FF) as u8;
421 (
422 Some(table.us_weight_class()),
423 Some(table.us_width_class()),
424 Some((class, subclass)),
425 )
426 }
427 Err(_) => (None, None, None),
428 }
429}
430
431fn sort_matches(matches: &mut [TypgFontFaceMatch]) {
432 matches.sort_by(|a, b| {
433 a.source
434 .path
435 .cmp(&b.source.path)
436 .then_with(|| a.source.ttc_index.cmp(&b.source.ttc_index))
437 });
438}
439
440fn dedup_tags(tags: &mut Vec<Tag>) {
441 tags.sort_unstable();
442 tags.dedup();
443}
444
445fn dedup_codepoints(codepoints: &mut Vec<char>) {
446 codepoints.sort_unstable();
447 codepoints.dedup();
448}
449
450fn dedup_names(mut names: Vec<String>, path: &Path) -> Vec<String> {
451 names.push(
452 path.file_stem()
453 .map(|s| s.to_string_lossy().to_string())
454 .unwrap_or_else(|| path.display().to_string()),
455 );
456
457 for name in names.iter_mut() {
458 *name = name.trim().to_string();
459 }
460
461 names.retain(|n| !n.is_empty());
462 names.sort_unstable();
463 names.dedup();
464 names
465}
466
467fn serialize_tags<S>(tags: &[Tag], serializer: S) -> Result<S::Ok, S::Error>
468where
469 S: serde::Serializer,
470{
471 let as_strings: Vec<String> = tags.iter().copied().map(tag_to_string).collect();
472 as_strings.serialize(serializer)
473}
474
475fn deserialize_tags<'de, D>(deserializer: D) -> Result<Vec<Tag>, D::Error>
476where
477 D: serde::Deserializer<'de>,
478{
479 let raw: Vec<String> = Vec::<String>::deserialize(deserializer)?;
480 raw.into_iter()
481 .map(|s| tag4(&s).map_err(serde::de::Error::custom))
482 .collect()
483}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488
489 #[test]
490 fn dedup_names_adds_fallback_and_trims() {
491 let names = vec![" Alpha ".to_string(), "Alpha".to_string()];
492 let path = Path::new("/fonts/Beta.ttf");
493 let deduped = dedup_names(names, path);
494
495 assert!(
496 deduped.contains(&"Alpha".to_string()),
497 "original names should be trimmed and kept"
498 );
499 assert!(
500 deduped.contains(&"Beta".to_string()),
501 "file stem should be added as fallback name"
502 );
503 assert_eq!(
504 deduped.len(),
505 2,
506 "dedup should remove duplicate entries and empty strings"
507 );
508 }
509
510 #[test]
511 fn dedup_tags_sorts_and_dedups() {
512 let mut tags = vec![
513 tag4("wght").unwrap(),
514 tag4("wght").unwrap(),
515 tag4("GSUB").unwrap(),
516 ];
517 dedup_tags(&mut tags);
518
519 assert_eq!(tags, vec![tag4("GSUB").unwrap(), tag4("wght").unwrap()]);
520 }
521
522 #[test]
523 fn dedup_codepoints_sorts_and_dedups() {
524 let mut cps = vec!['b', 'a', 'b'];
525 dedup_codepoints(&mut cps);
526 assert_eq!(cps, vec!['a', 'b']);
527 }
528}