Skip to main content

typg_core/
search.rs

1/// The gentle art of font discovery and conversation
2///
3/// Like a curious detective who never stops asking questions, this module
4/// extracts secrets hidden inside font files. We listen carefully to what
5/// each font has to say, then help you find the ones that are singing your tune.
6///
7/// Made with care at FontLab https://www.fontlab.com/
8use 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/// Every font's personal biography in convenient story form
25///
26/// We gather all the delightful details that make each font unique -
27/// their names, talents, family connections, and secret abilities.
28/// Think of this as the font's dating profile, showing what makes them
29/// special and what conversations they enjoy having.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct TypgFontFaceMeta {
32    /// All the names this font goes by - family, full, postscript, and nicknames
33    pub names: Vec<String>,
34    /// The dance moves this variable font can perform (weight, width, optical size...)
35    #[serde(
36        serialize_with = "serialize_tags",
37        deserialize_with = "deserialize_tags"
38    )]
39    pub axis_tags: Vec<Tag>,
40    /// Special typographic tricks and typographic talents tucked away inside
41    #[serde(
42        serialize_with = "serialize_tags",
43        deserialize_with = "deserialize_tags"
44    )]
45    pub feature_tags: Vec<Tag>,
46    /// Languages and scripts this font can speak fluently
47    #[serde(
48        serialize_with = "serialize_tags",
49        deserialize_with = "deserialize_tags"
50    )]
51    pub script_tags: Vec<Tag>,
52    /// The building blocks available in this font's toolkit
53    #[serde(
54        serialize_with = "serialize_tags",
55        deserialize_with = "deserialize_tags"
56    )]
57    pub table_tags: Vec<Tag>,
58    /// Every character this font knows how to draw - their complete vocabulary
59    pub codepoints: Vec<char>,
60    /// Can this font change shape like a chameleon, or stay true to one form?
61    pub is_variable: bool,
62    /// How bold does this font think it is? (100-900 typographic scale)
63    #[serde(default)]
64    pub weight_class: Option<u16>,
65    /// How wide does this font like to stretch? (1-9 condensed to expanded)
66    #[serde(default)]
67    pub width_class: Option<u16>,
68    /// What typographic family does this font belong to? (class and subgroup)
69    #[serde(default)]
70    pub family_class: Option<(u8, u8)>,
71    /// Strings from name IDs relevant to creator/maker (copyright, trademark, manufacturer, designer, description, vendor URL, designer URL, license, license URL)
72    #[serde(default)]
73    pub creator_names: Vec<String>,
74    /// Strings from name IDs relevant to licensing (copyright, license, license URL)
75    #[serde(default)]
76    pub license_names: Vec<String>,
77}
78
79/// Where each font calls home and how to find them at the party
80///
81/// Some fonts live alone in their own apartment (TTF/OTF files),
82/// while others share a house with roommates (TTC/OTC collections).
83/// We keep track of both the address and which door to knock on.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct TypgFontSource {
86    /// The street address where this font lives on your filesystem
87    pub path: PathBuf,
88    /// Which door in the font collection apartment complex to knock on
89    pub ttc_index: Option<u32>,
90}
91
92impl TypgFontSource {
93    /// Creates a friendly address that includes apartment numbers for collections
94    ///
95    /// Regular fonts get their regular address, but fonts in collections
96    /// get a helpful "#0", "#1", etc. suffix to show which roommate we mean.
97    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/// The perfect matchmaker pairing: who they are and where to find them
107///
108/// We bring together the font's personal story (metadata) with their
109/// actual living situation (source). It's like handing someone a
110/// dating profile along with directions to the coffee shop.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct TypgFontFaceMatch {
113    /// Where this font hangs out and how to visit them
114    pub source: TypgFontSource,
115    /// All the juicy details about what makes this font special
116    pub metadata: TypgFontFaceMeta,
117}
118
119/// How we like to conduct our search expeditions
120///
121/// These options let you fine-tune your font-finding adventure.
122/// Want to follow mysterious pathways (symlinks)? Need more hands
123/// on deck for a big search expedition? We've got you covered.
124#[derive(Debug, Default, Clone)]
125pub struct SearchOptions {
126    /// Should we follow those mysterious shortcut pathways that symlinks create?
127    pub follow_symlinks: bool,
128    /// How many search elves should we hire for this expedition? (None = let the system decide)
129    pub jobs: Option<usize>,
130}
131
132/// The grand orchestrator of font discovery expeditions
133///
134/// We take your list of neighborhoods to explore (paths), your specific
135/// criteria for the perfect font match (query), and your preferred style
136/// of exploration (opts). Then we venture forth, chat up all the fonts
137/// we meet, and return with the ones that caught your eye.
138///
139/// Returns: A tastefully arranged collection of font matches, sorted by neighborhood.
140pub 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
172/// Speed dating with fonts you've already met (no file system required)
173///
174/// When you have a list of fonts you've already gotten to know, sometimes
175/// you just want to filter them by new criteria without re-reading all those
176/// font files. This is like having address cards for everyone at the party
177/// and quickly finding who matches your new interests.
178///
179/// Returns: A curated subset of your existing font acquaintances.
180pub 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
191/// The gentle interrogation of a font file to learn all its secrets
192///
193/// We knock on the font's door, politely ask to come in, and then
194/// carefully extract every interesting detail about what makes it
195/// special. Like a good interviewer, we know exactly which questions
196/// to ask to get the font to open up and share its story.
197///
198/// For font collections, we chat with each roommate individually.
199fn 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}