Skip to main content

flash_font/
lib.rs

1//! A library for managing a font database, including scanning, parsing, and searching fonts.
2//!
3//! Provides utilities to synchronize font files on disk with a SQLite database
4//! and search for font files by their family names.
5
6use std::{collections::HashSet, fs::File, io, sync::mpsc, thread};
7
8use camino::Utf8Path;
9use diesel::prelude::*;
10use rayon::prelude::*;
11
12use crate::error::{AppError, AppResult};
13
14mod db;
15pub mod error;
16pub mod parser;
17pub mod scanner;
18mod schema;
19
20/// Synchronizes the font files in the database with the files currently on disk.
21///
22/// This function identifies files that have been removed from the disk and deletes their
23/// corresponding records from the database. It then returns a list of new file paths
24/// that need to be parsed and added.
25fn gather_and_clean_font_paths(
26    tx: &mut diesel::SqliteConnection,
27    font_root: &Utf8Path,
28) -> AppResult<Vec<String>> {
29    // 1. Get all current paths in the database
30    let db_paths: HashSet<String> = schema::font_files::table
31        .select(schema::font_files::path)
32        .load(tx)?
33        .into_iter()
34        .collect();
35
36    let disk_paths: HashSet<String> = scanner::scan_font_directory(font_root)
37        .map(|p| p.into_string())
38        .collect();
39
40    // 3. Compute difference in memory (extremely fast)
41    // In DB but not on disk -> needs deletion
42    let to_delete: Vec<String> = db_paths.difference(&disk_paths).cloned().collect();
43    // On disk but not in DB -> needs addition
44    let to_add: Vec<String> = disk_paths.difference(&db_paths).cloned().collect();
45
46    // 4. Clean up invalid records in the database
47    // Delete in chunks to avoid hitting the SQLite parameter limit of 32766
48    for chunk in to_delete.chunks(10000) {
49        diesel::delete(schema::font_files::table.filter(schema::font_files::path.eq_any(chunk)))
50            .execute(tx)?;
51    }
52
53    // Cascade clean orphaned font family name records
54    diesel::sql_query(
55        "DELETE FROM font_family_names WHERE file_id NOT IN (SELECT id FROM font_files)",
56    )
57    .execute(tx)?;
58
59    // 5. Return the list of paths that truly need to be parsed and added
60    Ok(to_add)
61}
62
63/// Opens a file with performance-optimized flags for memory mapping.
64///
65/// On Windows, it uses `FILE_FLAG_SEQUENTIAL_SCAN` to hint to the OS that
66/// the file will be read sequentially.
67fn open_for_mmap(path: &str) -> io::Result<File> {
68    #[cfg(target_os = "windows")]
69    {
70        use std::os::windows::fs::OpenOptionsExt;
71
72        use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_SEQUENTIAL_SCAN;
73
74        std::fs::OpenOptions::new()
75            .read(true)
76            .custom_flags(FILE_FLAG_SEQUENTIAL_SCAN)
77            .open(path)
78    }
79    #[cfg(not(target_os = "windows"))]
80    {
81        let f = File::open(path)?;
82        #[cfg(target_os = "linux")]
83        unsafe {
84            use std::os::unix::io::AsRawFd;
85            libc::posix_fadvise(f.as_raw_fd(), 0, 0, libc::POSIX_FADV_WILLNEED);
86        }
87        Ok(f)
88    }
89}
90
91/// Updates the font database by scanning the provided root directory.
92///
93/// This function synchronizes the database with the disk, removes stale entries,
94/// parses new font files in parallel to extract family names, and inserts them
95/// into the database.
96///
97/// Best effort, fs open / mmap errors will be skipped.
98pub fn update_font_database(font_root: &Utf8Path, db_url: &str) -> AppResult<()> {
99    let mut conn = db::initialize_db_connection(db_url)?;
100
101    conn.transaction::<_, AppError, _>(|tx| {
102        let new_paths = gather_and_clean_font_paths(tx, font_root)?;
103
104        if new_paths.is_empty() {
105            return Ok(());
106        }
107
108        let (sender, receiver) = mpsc::sync_channel(10000);
109
110        thread::scope(|s| -> AppResult<()> {
111            s.spawn(|| {
112                new_paths.into_par_iter().for_each_with(sender, |ch, path| {
113                    if let Ok(data_file) = open_for_mmap(&path)
114                        && let Ok(data) = unsafe { memmap2::Mmap::map(&data_file) }
115                    {
116                        let families = parser::get_font_family_names(&data);
117                        // Send parsing results back to the main thread
118                        let _ = ch.send((path, families.into_iter().collect::<Vec<_>>()));
119                    }
120                });
121            });
122
123            for (path, families) in receiver {
124                // Get the generated primary key ID
125                let file_id: i32 = diesel::insert_into(schema::font_files::table)
126                    .values(db::FontFile { path })
127                    .returning(schema::font_files::id)
128                    .get_result(tx)?;
129
130                // Write the extracted names to the database
131                for name in families {
132                    diesel::insert_into(schema::font_family_names::table)
133                        .values(db::FontFamilyName { file_id, name })
134                        .execute(tx)?;
135                }
136            }
137
138            Ok(())
139        })?;
140
141        Ok(())
142    })
143}
144
145/// Searches the database for font file paths matching a specific font family name.
146pub fn select_font_by_name(name: &str, db_url: &str) -> AppResult<Vec<String>> {
147    let mut conn = db::initialize_db_connection(db_url)?;
148    let fonts: Vec<String> = schema::font_files::table
149        .inner_join(
150            schema::font_family_names::table
151                .on(schema::font_files::id.eq(schema::font_family_names::file_id)),
152        )
153        .filter(schema::font_family_names::name.eq(name))
154        .select(schema::font_files::path)
155        .load(&mut conn)?;
156    Ok(fonts)
157}