Skip to main content

oxifont_adapter_pure/
lib.rs

1#![forbid(unsafe_code)]
2#![warn(missing_docs)]
3
4//! `oxifont-adapter-pure` — Pure Rust [`FontDatabase`] built on filesystem
5//! scanning.
6//!
7//! Composes [`oxifont_discovery`] (directory scan) and [`oxifont_parser`]
8//! (TTF/OTF/TTC parsing) into a [`FontCatalog`]
9//! implementation that requires no native libraries.
10//!
11//! # Features
12//! - `cache`: Enable disk-based face-metadata caching. Uses `serde_json` to
13//!   persist [`FaceInfo`] records; requires `oxifont-core/serde`. When enabled,
14//!   [`FontDatabase::system_cached`] and [`FontDatabase::scan_cached`] are
15//!   available.
16//! - `db`: Enable the bridge to [`oxifont_db`]. Adds [`FontDatabase::into_db`]
17//!   and [`FontDatabase::as_db`] which convert this catalog to an
18//!   [`oxifont_db::FontDatabase`] for CSS Fonts Level 4 matching via
19//!   [`oxifont_db::Query`].
20//!
21//! # Integration with oxitext
22//!
23//! [`FontDatabase`] can serve as the font backend for `oxitext`'s pipeline.
24//! The `oxitext::Pipeline::new(font_db)` constructor accepts an
25//! `&oxifont::FontDatabase` (which is a type alias for
26//! `oxifont_adapter_pure::FontDatabase` when using the `pure` Cargo feature).
27//!
28//! ```ignore
29//! use oxifont_adapter_pure::FontDatabase;
30//! use oxifont::FontDatabase as OxiFont;  // requires oxifont `pure` feature
31//! // This block is only illustrative; oxitext is in a separate crate
32//! ```
33//!
34//! # Subsetting integration
35//!
36//! Use [`FontDatabase::font_bytes`] to retrieve raw SFNT bytes for a face,
37//! then pass them to `oxifont_subset::subset_font` for glyph subsetting:
38//!
39//! ```no_run
40//! use oxifont_adapter_pure::FontDatabase;
41//! use oxifont_core::FontCatalog as _;
42//!
43//! let db = FontDatabase::system().unwrap();
44//! if let Some(info) = db.faces().first() {
45//!     let bytes = db.font_bytes(info).unwrap();
46//!     // Pass `bytes` to `oxifont_subset::subset_font(&bytes, &codepoints)`.
47//! }
48//! ```
49//!
50//! # Example
51//! ```no_run
52//! use oxifont_adapter_pure::FontDatabase;
53//! use oxifont_core::{FontCatalog as _, FontQuery};
54//!
55//! let db = FontDatabase::system().unwrap();
56//! println!("found {} faces", db.faces().len());
57//!
58//! if let Some(face) = db.find(&FontQuery::new().family("Helvetica")) {
59//!     println!("found: {}", face.family);
60//! }
61//! ```
62
63use std::collections::HashMap;
64use std::path::Path;
65
66use oxifont_core::{FaceInfo, FontCatalog, FontError, FontQuery, FontStyle};
67use oxifont_parser::ParsedFace;
68
69// ---------------------------------------------------------------------------
70// Generic family alias table (CSS Fonts Level 4 §3.1)
71// ---------------------------------------------------------------------------
72
73/// Static map from CSS generic family names to ordered lists of concrete
74/// family names to try as fallbacks.
75const GENERIC_FAMILIES: &[(&str, &[&str])] = &[
76    (
77        "sans-serif",
78        &[
79            "Arial",
80            "Helvetica",
81            "DejaVu Sans",
82            "Liberation Sans",
83            "Nimbus Sans",
84            "FreeSans",
85            "Noto Sans",
86        ],
87    ),
88    (
89        "serif",
90        &[
91            "Times New Roman",
92            "Georgia",
93            "DejaVu Serif",
94            "Liberation Serif",
95            "Nimbus Roman",
96            "FreeSerif",
97            "Noto Serif",
98        ],
99    ),
100    (
101        "monospace",
102        &[
103            "Courier New",
104            "Courier",
105            "DejaVu Sans Mono",
106            "Liberation Mono",
107            "Nimbus Mono",
108            "FreeMono",
109            "Noto Sans Mono",
110        ],
111    ),
112    (
113        "cursive",
114        &["Comic Sans MS", "Zapf Chancery", "URW Chancery L"],
115    ),
116    ("fantasy", &["Impact", "Copperplate", "Papyrus"]),
117];
118
119// ---------------------------------------------------------------------------
120// CSS §4.5 weight priority helper
121// ---------------------------------------------------------------------------
122
123/// Compute the priority ordering key for weight matching per CSS Fonts Level 4
124/// §4.5.5. Returns a `(u32, u32)` sort key where a smaller value means higher
125/// preference: `(tier, distance)`.
126///
127/// Tier 0 = exact match, Tier 1 = first fallback group, Tier 2 = second
128/// fallback group. Within a tier the `distance` is the absolute distance
129/// between query weight and face weight.
130fn weight_priority(query_w: u16, face_w: u16) -> (u32, u32) {
131    let distance = (query_w as i32 - face_w as i32).unsigned_abs();
132    match query_w {
133        // Exact match is always best regardless of query value.
134        q if q == face_w => (0, 0),
135
136        // weight == 400: prefer 400, then 500, then 300→200→100, then 600→900
137        400 => match face_w {
138            500 => (1, 0),
139            w if w < 400 => (2, (400 - w) as u32),
140            w => (3, (w - 400) as u32),
141        },
142
143        // weight == 500: prefer 500, then 400, then 300→200→100, then 600→900
144        500 => match face_w {
145            400 => (1, 0),
146            w if w < 400 => (2, (500 - w) as u32),
147            w => (3, (w - 500) as u32),
148        },
149
150        // weight < 400: nearest below first, then ascending above
151        q if q < 400 => {
152            if face_w <= q {
153                (1, distance)
154            } else {
155                (2, distance)
156            }
157        }
158
159        // weight > 500: nearest above first, then descending below
160        q => {
161            if face_w >= q {
162                (1, distance)
163            } else {
164                (2, distance)
165            }
166        }
167    }
168}
169
170// ---------------------------------------------------------------------------
171// CSS §4.5.4 style priority helper
172// ---------------------------------------------------------------------------
173
174/// Returns a priority value for style matching per CSS Fonts Level 4 §4.5.4.
175/// Lower value = higher preference.
176fn style_priority(query_style: &FontStyle, face_style: &FontStyle) -> u32 {
177    match query_style {
178        FontStyle::Italic => match face_style {
179            FontStyle::Italic => 0,
180            FontStyle::Oblique => 1,
181            FontStyle::Normal => 2,
182        },
183        FontStyle::Oblique => match face_style {
184            FontStyle::Oblique => 0,
185            FontStyle::Italic => 1,
186            FontStyle::Normal => 2,
187        },
188        FontStyle::Normal => match face_style {
189            FontStyle::Normal => 0,
190            FontStyle::Oblique => 1,
191            FontStyle::Italic => 2,
192        },
193    }
194}
195
196// ---------------------------------------------------------------------------
197// CSS §4.5.3 stretch priority helper
198// ---------------------------------------------------------------------------
199
200/// Returns a `(tier, distance)` sort key for stretch matching per CSS Fonts
201/// Level 4 §4.5.3. Lower is better.
202fn stretch_priority(query_s: u8, face_s: u8) -> (u32, u32) {
203    let distance = (query_s as i32 - face_s as i32).unsigned_abs();
204    match query_s {
205        q if q == face_s => (0, 0),
206        // S ≤ 5 (normal or narrower): prefer ≤ S (nearest below), then > S
207        q if q <= 5 => {
208            if face_s <= q {
209                (1, distance)
210            } else {
211                (2, distance)
212            }
213        }
214        // S > 5: prefer ≥ S (nearest above), then < S
215        q => {
216            if face_s >= q {
217                (1, distance)
218            } else {
219                (2, distance)
220            }
221        }
222    }
223}
224
225// ---------------------------------------------------------------------------
226// FontDatabase
227// ---------------------------------------------------------------------------
228
229/// An in-memory catalog of font faces discovered by scanning directories.
230///
231/// Backed by a `Vec<FaceInfo>` (for ordered storage) and a
232/// `HashMap<String, Vec<usize>>` index (for O(1) family lookup by exact
233/// lowercase name). A linear substring scan is used as fallback so that the
234/// documented case-insensitive substring semantics of [`FontCatalog::find`]
235/// are preserved.
236///
237/// Thread-safe (immutable after construction via `scan`/`system`; mutations
238/// are single-threaded builder calls).
239#[derive(Debug)]
240pub struct FontDatabase {
241    faces: Vec<FaceInfo>,
242    /// Lowercase family name → indices into `faces`.
243    by_family: HashMap<String, Vec<usize>>,
244}
245
246impl FontDatabase {
247    // -----------------------------------------------------------------------
248    // Private helpers
249    // -----------------------------------------------------------------------
250
251    /// Push one face record and update the `by_family` index.
252    fn add_face(&mut self, face: FaceInfo) {
253        let idx = self.faces.len();
254        let key = face.family.to_lowercase();
255        self.by_family.entry(key).or_default().push(idx);
256        self.faces.push(face);
257    }
258
259    /// Rebuild `by_family` from scratch after bulk removals.
260    fn rebuild_index(&mut self) {
261        self.by_family.clear();
262        for (idx, face) in self.faces.iter().enumerate() {
263            let key = face.family.to_lowercase();
264            self.by_family.entry(key).or_default().push(idx);
265        }
266    }
267
268    // -----------------------------------------------------------------------
269    // Constructors
270    // -----------------------------------------------------------------------
271
272    /// Builds an empty database with no faces.
273    pub fn new() -> Self {
274        Self {
275            faces: Vec::new(),
276            by_family: HashMap::new(),
277        }
278    }
279
280    /// Builds a database pre-populated with the given `FaceInfo` records.
281    ///
282    /// Useful for constructing test catalogs or programmatically assembled
283    /// databases without scanning the filesystem.
284    pub fn from_faces(faces: Vec<FaceInfo>) -> Self {
285        let mut db = Self::new();
286        for face in faces {
287            db.add_face(face);
288        }
289        db
290    }
291
292    /// Builds a catalog by recursively scanning `paths` for font files.
293    ///
294    /// # Errors
295    /// Returns [`FontError`] only in exceptional cases; individual font-parse
296    /// failures are silently skipped.
297    pub fn scan(paths: &[impl AsRef<Path>]) -> Result<Self, FontError> {
298        let discovered = oxifont_discovery::scan_dirs(paths);
299        let mut db = Self::new();
300        for face in discovered {
301            db.add_face(face);
302        }
303        Ok(db)
304    }
305
306    /// Builds a catalog from the OS system font directories.
307    ///
308    /// Calls [`oxifont_discovery::system_font_dirs`] to determine the search
309    /// paths, then delegates to [`FontDatabase::scan`].
310    ///
311    /// Returns an empty catalog (not an error) when no system font directories
312    /// exist (e.g. on a minimal CI container).
313    ///
314    /// # Errors
315    /// Returns [`FontError`] only in exceptional cases; individual font-parse
316    /// failures are silently skipped.
317    pub fn system() -> Result<Self, FontError> {
318        let dirs = oxifont_discovery::system_font_dirs();
319        Self::scan(&dirs)
320    }
321
322    /// Builds a catalog from the OS system font directories using metadata-only
323    /// scanning (no full font parse).
324    ///
325    /// Only the `name`, `OS/2`, and `cmap` SFNT tables are read per file.
326    /// This is typically 10–50× faster than [`system`](Self::system) on systems
327    /// with many large fonts because the `glyf`/`loca`/`hmtx` tables (which
328    /// make up 90–99% of most font files) are never loaded.
329    ///
330    /// Actual glyph-level data can be loaded on demand via [`load_face`](Self::load_face).
331    ///
332    /// Returns an empty catalog (not an error) when no system font directories
333    /// exist.
334    ///
335    /// # Errors
336    /// Returns [`FontError`] only in exceptional cases; individual font-parse
337    /// failures are silently skipped.
338    pub fn system_lazy() -> Result<Self, FontError> {
339        let dirs = oxifont_discovery::system_font_dirs();
340        Self::scan_lazy(&dirs)
341    }
342
343    /// Builds a catalog from the given directories using metadata-only scanning.
344    ///
345    /// Equivalent to [`scan`](Self::scan) but reads only `name`, `OS/2`, and
346    /// `cmap` tables per font file. All [`FaceInfo`] fields derivable from
347    /// those three tables (family, PostScript name, style, weight, stretch) are
348    /// populated. Fields requiring other tables (e.g. variation axes from
349    /// `fvar`) are left at their zero/default values.
350    ///
351    /// # Errors
352    /// Returns [`FontError`] only in exceptional cases; individual font-parse
353    /// failures are silently skipped.
354    pub fn scan_lazy(dirs: &[impl AsRef<std::path::Path>]) -> Result<Self, FontError> {
355        let paths: Vec<std::path::PathBuf> =
356            dirs.iter().map(|p| p.as_ref().to_path_buf()).collect();
357        let result = oxifont_discovery::scan_dirs_metadata_only(&paths);
358        let mut db = Self::new();
359        for face in result.faces {
360            db.add_face(face);
361        }
362        Ok(db)
363    }
364
365    // -----------------------------------------------------------------------
366    // Mutation methods
367    // -----------------------------------------------------------------------
368
369    /// Scans a directory and adds all found font faces to this database.
370    ///
371    /// Uses [`oxifont_discovery::scan_dirs`] internally; malformed font files
372    /// are silently skipped. Returns `&mut Self` for builder-style chaining.
373    pub fn add_dir(&mut self, path: &Path) -> &mut Self {
374        let found = oxifont_discovery::scan_dirs(&[path.to_path_buf()]);
375        for face in found {
376            self.add_face(face);
377        }
378        self
379    }
380
381    /// Parses a font from in-memory bytes and adds all contained faces.
382    ///
383    /// For TTC collections every sub-face is added. For TTF/OTF only face 0 is
384    /// added. Returns the number of faces added.
385    ///
386    /// If `family_hint` is `Some`, the hint is used as the family name for any
387    /// face whose parsed family name is empty or `"Unknown"` (e.g. a
388    /// hand-crafted test font with no `name` table entries).
389    ///
390    /// # Errors
391    /// Returns [`FontError::ParseError`] when not a single face can be parsed
392    /// from the provided bytes. Faces that fail individually are skipped; the
393    /// error is only propagated when **all** faces fail.
394    pub fn add_bytes(
395        &mut self,
396        bytes: Vec<u8>,
397        family_hint: Option<&str>,
398    ) -> Result<usize, FontError> {
399        let count = oxifont_parser::face_count(&bytes);
400        let arc: std::sync::Arc<[u8]> = bytes.into();
401        let mut added = 0usize;
402        let mut last_err: Option<FontError> = None;
403
404        for idx in 0..count {
405            match ParsedFace::parse(arc.clone(), idx) {
406                Ok(parsed) => {
407                    let mut info = parsed.as_face_info();
408                    // Apply hint when the parsed name is absent.
409                    if let Some(hint) = family_hint {
410                        if info.family.is_empty() || info.family.as_ref() == "Unknown" {
411                            info.family = std::sync::Arc::from(hint);
412                        }
413                    }
414                    self.add_face(info);
415                    added += 1;
416                }
417                Err(e) => {
418                    last_err = Some(e);
419                }
420            }
421        }
422
423        if added == 0 {
424            Err(last_err.unwrap_or(FontError::UnsupportedFormat))
425        } else {
426            Ok(added)
427        }
428    }
429
430    /// Removes all faces whose `path` matches `path`.
431    ///
432    /// Returns the number of faces removed. The internal index is rebuilt after
433    /// removal.
434    pub fn remove(&mut self, path: &Path) -> usize {
435        let before = self.faces.len();
436        self.faces.retain(|f| f.path != path);
437        let removed = before - self.faces.len();
438        if removed > 0 {
439            self.rebuild_index();
440        }
441        removed
442    }
443
444    /// Merges all faces from `other` into this database.
445    ///
446    /// Returns `&mut Self` for builder-style chaining.
447    pub fn merge(&mut self, other: FontDatabase) -> &mut Self {
448        for face in other.faces {
449            self.add_face(face);
450        }
451        self
452    }
453
454    // -----------------------------------------------------------------------
455    // Query methods
456    // -----------------------------------------------------------------------
457
458    /// Returns all faces whose family name matches `family` (case-insensitive,
459    /// exact match against the full family name).
460    ///
461    /// For substring matching use [`FontCatalog::find`] with a
462    /// [`FontQuery::family`] query instead.
463    pub fn find_all(&self, family: &str) -> Vec<&FaceInfo> {
464        let key = family.to_lowercase();
465        match self.by_family.get(&key) {
466            Some(indices) => indices.iter().filter_map(|&i| self.faces.get(i)).collect(),
467            None => Vec::new(),
468        }
469    }
470
471    /// Returns candidate faces for a given family name using the `by_family`
472    /// index. Returns an empty slice when the family is not found.
473    fn candidates_for_family<'a>(&'a self, family: &str) -> Vec<&'a FaceInfo> {
474        let key = family.to_lowercase();
475        match self.by_family.get(&key) {
476            Some(indices) => indices.iter().filter_map(|&i| self.faces.get(i)).collect(),
477            None => Vec::new(),
478        }
479    }
480
481    /// Returns the best matching face for `query` using CSS Fonts Level 4
482    /// §4.5 priority ordering (stretch → style → weight).
483    ///
484    /// Unlike [`FontCatalog::find`], this method uses **exact** case-insensitive
485    /// family matching (not substring matching), and applies the full CSS
486    /// font-matching algorithm for stretch, style, and weight.
487    ///
488    /// If the `query.family` is a CSS generic family keyword (`"sans-serif"`,
489    /// `"serif"`, `"monospace"`, `"cursive"`, `"fantasy"`), the generic alias
490    /// table is consulted and the first matching concrete family is returned.
491    ///
492    /// Returns `None` when no face in the database matches the family.
493    pub fn find_css(&self, query: &FontQuery) -> Option<&FaceInfo> {
494        // Collect the candidate pool from the family field.
495        let mut candidates: Vec<&FaceInfo> = match &query.family {
496            None => self.faces.iter().collect(),
497            Some(family) => {
498                let direct = self.candidates_for_family(family);
499                if !direct.is_empty() {
500                    direct
501                } else {
502                    // Not a direct match — try generic family resolution.
503                    return self.resolve_generic_family(family, query);
504                }
505            }
506        };
507
508        if candidates.is_empty() {
509            return None;
510        }
511
512        // Stage 1 — Stretch narrowing (CSS §4.5.3).
513        if let Some(query_stretch) = &query.stretch {
514            let q = query_stretch.to_width_class();
515            // Find the best (minimum) stretch key among candidates.
516            let best_stretch_key = candidates
517                .iter()
518                .map(|f| stretch_priority(q, f.stretch.to_width_class()))
519                .min();
520            if let Some(best) = best_stretch_key {
521                candidates.retain(|f| stretch_priority(q, f.stretch.to_width_class()) == best);
522            }
523        }
524
525        // Stage 2 — Style narrowing (CSS §4.5.4).
526        if let Some(query_style) = &query.style {
527            let best_style_key = candidates
528                .iter()
529                .map(|f| style_priority(query_style, &f.style))
530                .min();
531            if let Some(best) = best_style_key {
532                candidates.retain(|f| style_priority(query_style, &f.style) == best);
533            }
534        }
535
536        // Stage 3 — Weight narrowing (CSS §4.5.5).
537        if let Some(query_weight) = query.weight {
538            let best_weight_key = candidates
539                .iter()
540                .map(|f| weight_priority(query_weight, f.weight))
541                .min();
542            if let Some(best) = best_weight_key {
543                candidates.retain(|f| weight_priority(query_weight, f.weight) == best);
544            }
545        }
546
547        // Stage 4 — PostScript name exact filter (optional refinement).
548        if let Some(ps_name) = &query.postscript_name {
549            let ps_filtered: Vec<&FaceInfo> = candidates
550                .iter()
551                .copied()
552                .filter(|f| &f.post_script_name == ps_name)
553                .collect();
554            if !ps_filtered.is_empty() {
555                return ps_filtered.into_iter().next();
556            }
557        }
558
559        candidates.into_iter().next()
560    }
561
562    /// Attempts to resolve a CSS generic family name to a concrete face.
563    ///
564    /// Looks up `name` in the static generic-family table, then tries each
565    /// concrete family in order by delegating back to
566    /// [`FontDatabase::find_css`] with the same style/weight/stretch
567    /// constraints. Returns the first match.
568    pub fn resolve_generic_family<'a>(
569        &'a self,
570        name: &str,
571        query: &FontQuery,
572    ) -> Option<&'a FaceInfo> {
573        let lower = name.to_lowercase();
574        let concrete_families = GENERIC_FAMILIES
575            .iter()
576            .find(|(generic, _)| *generic == lower.as_str())
577            .map(|(_, families)| *families)?;
578
579        for &family in concrete_families {
580            // Build a new query with the resolved concrete family but the
581            // same style/weight/stretch constraints from the original query.
582            let resolved_query = FontQuery {
583                family: Some(family.to_string()),
584                style: query.style.clone(),
585                weight: query.weight,
586                stretch: query.stretch,
587                postscript_name: query.postscript_name.clone(),
588            };
589            // Collect candidates directly (avoid infinite recursion through
590            // find_css by going to candidates_for_family directly).
591            let mut candidates = self.candidates_for_family(family);
592            if candidates.is_empty() {
593                continue;
594            }
595
596            // Apply same CSS narrowing stages.
597            if let Some(query_stretch) = &resolved_query.stretch {
598                let q = query_stretch.to_width_class();
599                let best = candidates
600                    .iter()
601                    .map(|f| stretch_priority(q, f.stretch.to_width_class()))
602                    .min();
603                if let Some(best) = best {
604                    candidates.retain(|f| stretch_priority(q, f.stretch.to_width_class()) == best);
605                }
606            }
607            if let Some(query_style) = &resolved_query.style {
608                let best = candidates
609                    .iter()
610                    .map(|f| style_priority(query_style, &f.style))
611                    .min();
612                if let Some(best) = best {
613                    candidates.retain(|f| style_priority(query_style, &f.style) == best);
614                }
615            }
616            if let Some(query_weight) = resolved_query.weight {
617                let best = candidates
618                    .iter()
619                    .map(|f| weight_priority(query_weight, f.weight))
620                    .min();
621                if let Some(best) = best {
622                    candidates.retain(|f| weight_priority(query_weight, f.weight) == best);
623                }
624            }
625
626            if let Some(face) = candidates.into_iter().next() {
627                return Some(face);
628            }
629        }
630        None
631    }
632
633    /// Loads and fully parses the face described by `info`.
634    ///
635    /// # Errors
636    /// Returns [`FontError::IoError`] if the file cannot be read, or
637    /// [`FontError::ParseError`] if the bytes are malformed.
638    pub fn load_face(&self, info: &FaceInfo) -> Result<ParsedFace, FontError> {
639        ParsedFace::from_face_info(info)
640    }
641
642    /// Returns the raw font file bytes for the face described by `info`.
643    ///
644    /// This method provides access to the raw SFNT bytes needed for subsetting
645    /// operations (e.g. via [`oxifont_subset::subset_font`]) or WOFF2 encoding.
646    /// It simply reads the file from disk — no parsing is performed.
647    ///
648    /// # Example
649    ///
650    /// ```no_run
651    /// use oxifont_adapter_pure::FontDatabase;
652    /// use oxifont_core::FontCatalog as _;
653    ///
654    /// let db = FontDatabase::system().unwrap();
655    /// if let Some(info) = db.faces().first() {
656    ///     let bytes = db.font_bytes(info).unwrap();
657    ///     println!("loaded {} bytes for {:?}", bytes.len(), info.path);
658    /// }
659    /// ```
660    ///
661    /// # Errors
662    /// Returns [`FontError::IoError`] if the file at `info.path` cannot be read.
663    pub fn font_bytes(&self, info: &FaceInfo) -> Result<Vec<u8>, FontError> {
664        std::fs::read(&info.path).map_err(FontError::from)
665    }
666
667    // -----------------------------------------------------------------------
668    // Fallback chain query
669    // -----------------------------------------------------------------------
670
671    /// Try each family name in `families` until one has a matching face in the
672    /// database, and return the first match.
673    ///
674    /// Each family is resolved with the style, weight, and stretch constraints
675    /// taken from `base_query`. The `text` parameter is reserved for future
676    /// cmap-coverage checking (e.g. verifying that the face can render every
677    /// codepoint in the string); in this implementation it is accepted but not
678    /// yet used, because `FaceInfo` carries no pre-computed unicode range
679    /// bitmask and loading every candidate font from disk would be prohibitively
680    /// expensive in a hot path.
681    ///
682    /// Returns the first face whose family name resolves via [`find_css`], or
683    /// `None` when no family in `families` is present in the database.
684    ///
685    /// # Example
686    /// ```
687    /// use oxifont_adapter_pure::FontDatabase;
688    /// use oxifont_core::{FaceInfo, FontQuery, FontStretch, FontStyle};
689    /// use std::path::PathBuf;
690    /// use std::sync::Arc;
691    ///
692    /// let face = FaceInfo {
693    ///     family: Arc::from("Arial"),
694    ///     post_script_name: String::new(),
695    ///     style: FontStyle::Normal,
696    ///     weight: 400,
697    ///     stretch: FontStretch::Normal,
698    ///     path: PathBuf::from("/dev/null"),
699    ///     face_index: 0,
700    ///     localized_families: Vec::new(),
701    /// };
702    /// let db = FontDatabase::from_faces(vec![face]);
703    /// let base = FontQuery::new().weight(400);
704    /// let result = db.find_with_fallback(&["Arial", "Helvetica", "sans-serif"], &base, "Hello");
705    /// assert!(result.is_some());
706    /// ```
707    ///
708    /// [`find_css`]: FontDatabase::find_css
709    pub fn find_with_fallback<'a>(
710        &'a self,
711        families: &[&str],
712        base_query: &FontQuery,
713        _text: &str,
714    ) -> Option<&'a FaceInfo> {
715        for &family in families {
716            // Build a per-family query that preserves all constraints from
717            // the caller's base query but pins the family field.
718            let query = FontQuery {
719                family: Some(family.to_string()),
720                style: base_query.style.clone(),
721                weight: base_query.weight,
722                stretch: base_query.stretch,
723                postscript_name: base_query.postscript_name.clone(),
724            };
725            if let Some(face) = self.find_css(&query) {
726                return Some(face);
727            }
728        }
729        None
730    }
731
732    /// Find the best face for a [`FontQuery`] and optional text, using the
733    /// query's `family` field as the primary family, resolved through
734    /// [`find_css`] (which handles generic family keywords such as
735    /// `"sans-serif"` and applies full CSS §4.5 narrowing).
736    ///
737    /// This is a convenience wrapper around [`find_with_fallback`] that accepts
738    /// a single `&FontQuery` instead of an explicit `&[&str]` families slice.
739    /// It is the idiomatic entry-point when the call-site already has a
740    /// `FontQuery` and a text string:
741    ///
742    /// - If `query.family` is `Some(name)`, the search is driven by `name`
743    ///   (possibly a CSS generic keyword) with the remaining query constraints
744    ///   forwarded verbatim.
745    /// - If `query.family` is `None`, the method delegates directly to
746    ///   [`find_css`] over the whole database (equivalent to an unconstrained
747    ///   family query).
748    ///
749    /// The `text` parameter mirrors the same semantics as
750    /// [`find_with_fallback`]: it is accepted for future cmap-coverage
751    /// checking but is not yet used to filter candidates.
752    ///
753    /// # Example
754    /// ```
755    /// use oxifont_adapter_pure::FontDatabase;
756    /// use oxifont_core::{FaceInfo, FontQuery, FontStretch, FontStyle};
757    /// use std::path::PathBuf;
758    /// use std::sync::Arc;
759    ///
760    /// let face = FaceInfo {
761    ///     family: Arc::from("Arial"),
762    ///     post_script_name: String::new(),
763    ///     style: FontStyle::Normal,
764    ///     weight: 400,
765    ///     stretch: FontStretch::Normal,
766    ///     path: PathBuf::from("/dev/null"),
767    ///     face_index: 0,
768    ///     localized_families: Vec::new(),
769    /// };
770    /// let db = FontDatabase::from_faces(vec![face]);
771    /// let query = FontQuery::new().family("Arial").weight(400);
772    /// let result = db.find_best_for_text(&query, "Hello");
773    /// assert!(result.is_some());
774    /// ```
775    ///
776    /// [`find_css`]: FontDatabase::find_css
777    /// [`find_with_fallback`]: FontDatabase::find_with_fallback
778    pub fn find_best_for_text<'a>(&'a self, query: &FontQuery, text: &str) -> Option<&'a FaceInfo> {
779        match &query.family {
780            Some(family) => {
781                // Build a single-element families slice and delegate to the
782                // existing fallback implementation; this reuses generic
783                // resolution and CSS narrowing without code duplication.
784                self.find_with_fallback(&[family.as_str()], query, text)
785            }
786            None => {
787                // No family constraint — fall through to the full CSS matcher
788                // which treats a `None` family as a wildcard.
789                self.find_css(query)
790            }
791        }
792    }
793
794    // -----------------------------------------------------------------------
795    // Capacity / size
796    // -----------------------------------------------------------------------
797
798    /// Returns the total number of font faces in the database.
799    pub fn len(&self) -> usize {
800        self.faces.len()
801    }
802
803    /// Returns `true` when the database contains no faces.
804    pub fn is_empty(&self) -> bool {
805        self.faces.is_empty()
806    }
807}
808
809impl Default for FontDatabase {
810    fn default() -> Self {
811        Self::new()
812    }
813}
814
815impl<'a> IntoIterator for &'a FontDatabase {
816    type Item = &'a FaceInfo;
817    type IntoIter = std::slice::Iter<'a, FaceInfo>;
818
819    fn into_iter(self) -> Self::IntoIter {
820        self.faces.iter()
821    }
822}
823
824impl FontCatalog for FontDatabase {
825    fn faces(&self) -> &[FaceInfo] {
826        &self.faces
827    }
828
829    fn find(&self, query: &FontQuery) -> Option<&FaceInfo> {
830        // Fast path: if the query is an exact family-name lookup (no wildcards
831        // from the other fields) we check the index first.  This path only
832        // applies when every supplied field matches exactly one of the index
833        // entries; substring queries fall through to the linear scan below.
834        if let Some(family_q) = &query.family {
835            let key = family_q.to_lowercase();
836            if let Some(indices) = self.by_family.get(&key) {
837                // Exact index hit — check the remaining fields linearly within
838                // the (usually small) set of index matches.
839                let candidate = indices.iter().filter_map(|&i| self.faces.get(i)).find(|f| {
840                    let style_ok = query.style.as_ref().map(|q| &f.style == q).unwrap_or(true);
841                    let weight_ok = query.weight.map(|q| f.weight == q).unwrap_or(true);
842                    let stretch_ok = query
843                        .stretch
844                        .as_ref()
845                        .map(|q| &f.stretch == q)
846                        .unwrap_or(true);
847                    let ps_ok = query
848                        .postscript_name
849                        .as_ref()
850                        .map(|q| &f.post_script_name == q)
851                        .unwrap_or(true);
852                    style_ok && weight_ok && stretch_ok && ps_ok
853                });
854                if candidate.is_some() {
855                    return candidate;
856                }
857                // Exact match missed — fall through to substring scan.
858            }
859        }
860
861        // Fallback: linear substring scan preserves the original documented
862        // behavior (case-insensitive substring match on family name).
863        self.faces.iter().find(|f| {
864            let family_ok = query
865                .family
866                .as_ref()
867                .map(|q| f.family.to_lowercase().contains(&q.to_lowercase()))
868                .unwrap_or(true);
869
870            let style_ok = query.style.as_ref().map(|q| &f.style == q).unwrap_or(true);
871
872            let weight_ok = query.weight.map(|q| f.weight == q).unwrap_or(true);
873
874            let stretch_ok = query
875                .stretch
876                .as_ref()
877                .map(|q| &f.stretch == q)
878                .unwrap_or(true);
879
880            let ps_ok = query
881                .postscript_name
882                .as_ref()
883                .map(|q| &f.post_script_name == q)
884                .unwrap_or(true);
885
886            family_ok && style_ok && weight_ok && stretch_ok && ps_ok
887        })
888    }
889}
890
891// ---------------------------------------------------------------------------
892// oxifont-db bridge (feature = "db")
893// ---------------------------------------------------------------------------
894//
895// When the `db` feature is enabled, `FontDatabase` gains a conversion method
896// `into_db()` that migrates all `FaceInfo` records into an `oxifont_db::FontDatabase`,
897// enabling access to its CSS Level 4 query engine (`oxifont_db::Query`).
898//
899// The conversion uses the `From<oxifont_core::FaceInfo> for oxifont_db::FaceInfo`
900// bridge already provided by `oxifont-db/src/bridge.rs`, so each face record
901// round-trips without data loss for all fields that `oxifont-core::FaceInfo`
902// carries (family, weight, style, stretch, path, face_index).
903
904#[cfg(feature = "db")]
905impl FontDatabase {
906    /// Converts this catalog into an [`oxifont_db::FontDatabase`], enabling
907    /// full CSS Fonts Level 4 query access via [`oxifont_db::Query`].
908    ///
909    /// All face records currently stored in this catalog are converted to
910    /// [`oxifont_db::FaceInfo`] using the standard `From` bridge (see
911    /// `oxifont-db/src/bridge.rs`). The resulting database is independent of
912    /// this one — both can be used simultaneously.
913    ///
914    /// # CSS Level 4 queries after conversion
915    ///
916    /// ```no_run
917    /// use oxifont_adapter_pure::FontDatabase;
918    /// use oxifont_db::Query;
919    ///
920    /// let pure_db = FontDatabase::system().unwrap();
921    /// let db = pure_db.into_db();
922    ///
923    /// if let Some(face) = Query::new(&db)
924    ///     .family("sans-serif")
925    ///     .weight(700)
926    ///     .italic(false)
927    ///     .match_best()
928    /// {
929    ///     println!("CSS match: {} weight={}", face.family, face.weight);
930    /// }
931    /// ```
932    pub fn into_db(self) -> oxifont_db::FontDatabase {
933        let mut db = oxifont_db::FontDatabase::new();
934        for face in self.faces {
935            let db_face = oxifont_db::FaceInfo::from(face);
936            db.add_face(db_face);
937        }
938        db
939    }
940
941    /// Produces an [`oxifont_db::FontDatabase`] from a reference to this
942    /// catalog, cloning each face record during conversion.
943    ///
944    /// Prefer [`FontDatabase::into_db`] when the pure database is no longer
945    /// needed after the conversion.
946    ///
947    /// # Example
948    ///
949    /// ```no_run
950    /// use oxifont_adapter_pure::FontDatabase;
951    ///
952    /// let pure_db = FontDatabase::system().unwrap();
953    /// let db = pure_db.as_db();
954    /// // `pure_db` remains usable.
955    /// println!("{} faces in CSS db", db.stats().face_count);
956    /// ```
957    pub fn as_db(&self) -> oxifont_db::FontDatabase {
958        let mut db = oxifont_db::FontDatabase::new();
959        for face in &self.faces {
960            let db_face = oxifont_db::FaceInfo::from(face);
961            db.add_face(db_face);
962        }
963        db
964    }
965}
966
967// ---------------------------------------------------------------------------
968// oxifont-subset bridge (feature = "subset")
969// ---------------------------------------------------------------------------
970//
971// When the `subset` feature is enabled, `FontDatabase` gains two convenience
972// methods that chain `font_bytes()` with the `oxifont_subset` subsetting
973// pipeline:
974//
975//   - `subset_face(info, codepoints)` — subset with default options.
976//   - `subset_face_for_web(info, codepoints)` — subset with web-friendly
977//     presets (hints stripped, names trimmed).
978//
979// These methods provide font data access for `oxifont-subset` operations
980// without requiring callers to explicitly read file bytes or import the
981// `oxifont_subset` crate directly.
982
983#[cfg(feature = "subset")]
984impl FontDatabase {
985    /// Reads the font file described by `info`, subsets it to the given
986    /// `codepoints`, and returns the resulting SFNT bytes.
987    ///
988    /// This is a convenience wrapper around [`FontDatabase::font_bytes`] +
989    /// [`oxifont_subset::subset_font`]. It uses the default [`oxifont_subset::SubsetOptions`]:
990    /// hints are retained, layout tables (`GSUB`/`GPOS`/`GDEF`) are kept, and
991    /// the full `name` table is preserved.
992    ///
993    /// Use [`subset_face_for_web`](Self::subset_face_for_web) for a
994    /// web-optimised preset (strip hints, trim name table).
995    ///
996    /// # Errors
997    /// - [`FontError::IoError`] if the font file cannot be read.
998    /// - [`FontError::ParseError`] if the font bytes are structurally invalid
999    ///   or subsetting fails.
1000    ///
1001    /// # Example
1002    /// ```no_run
1003    /// use oxifont_adapter_pure::FontDatabase;
1004    /// use oxifont_core::FontCatalog as _;
1005    /// use std::collections::BTreeSet;
1006    ///
1007    /// let db = FontDatabase::system().unwrap();
1008    /// if let Some(info) = db.faces().first() {
1009    ///     let cps: BTreeSet<char> = "Hello, world!".chars().collect();
1010    ///     let subset_bytes = db.subset_face(info, &cps).unwrap();
1011    ///     println!("subset: {} bytes", subset_bytes.len());
1012    /// }
1013    /// ```
1014    pub fn subset_face(
1015        &self,
1016        info: &FaceInfo,
1017        codepoints: &std::collections::BTreeSet<char>,
1018    ) -> Result<Vec<u8>, FontError> {
1019        let bytes = self.font_bytes(info)?;
1020        oxifont_subset::subset_font(&bytes, codepoints)
1021            .map_err(|e| FontError::ParseError(format!("subset failed: {e}")))
1022    }
1023
1024    /// Reads the font file described by `info`, subsets it to the given
1025    /// `codepoints` using web-optimised presets, and returns the resulting
1026    /// SFNT bytes.
1027    ///
1028    /// Equivalent to [`subset_face`](Self::subset_face) but with
1029    /// `strip_hints = true` and `retain_names = false` — suitable for web
1030    /// fonts where hint data is rarely beneficial and name records inflate the
1031    /// download size.
1032    ///
1033    /// # Errors
1034    /// - [`FontError::IoError`] if the font file cannot be read.
1035    /// - [`FontError::ParseError`] if the font bytes are structurally invalid
1036    ///   or subsetting fails.
1037    ///
1038    /// # Example
1039    /// ```no_run
1040    /// use oxifont_adapter_pure::FontDatabase;
1041    /// use oxifont_core::FontCatalog as _;
1042    /// use std::collections::BTreeSet;
1043    ///
1044    /// let db = FontDatabase::system().unwrap();
1045    /// if let Some(info) = db.faces().first() {
1046    ///     let cps: BTreeSet<char> = "Hello".chars().collect();
1047    ///     let web_bytes = db.subset_face_for_web(info, &cps).unwrap();
1048    ///     println!("web-subset: {} bytes", web_bytes.len());
1049    /// }
1050    /// ```
1051    pub fn subset_face_for_web(
1052        &self,
1053        info: &FaceInfo,
1054        codepoints: &std::collections::BTreeSet<char>,
1055    ) -> Result<Vec<u8>, FontError> {
1056        let bytes = self.font_bytes(info)?;
1057        oxifont_subset::subset_font_for_web(&bytes, codepoints)
1058            .map_err(|e| FontError::ParseError(format!("subset_for_web failed: {e}")))
1059    }
1060}
1061
1062// ---------------------------------------------------------------------------
1063// Disk cache (feature = "cache")
1064// ---------------------------------------------------------------------------
1065//
1066// The cache is a JSON file that stores a list of `FaceInfo` records together
1067// with the mtime (seconds since UNIX epoch) of each source font file.  On
1068// subsequent starts the cache is considered valid for a given font file only
1069// when its mtime has not changed; otherwise the face is re-parsed from disk.
1070//
1071// Layout of a cache entry:
1072//   { "path": "/path/to/Font.ttf", "mtime": 1716000000, "faces": [...FaceInfo...] }
1073//
1074// The cache is stored at `<cache_dir>/oxifont_face_cache.json` where
1075// `<cache_dir>` is `dirs::cache_dir()` (e.g. `~/.cache` on Linux,
1076// `~/Library/Caches` on macOS).
1077
1078#[cfg(feature = "cache")]
1079pub(crate) mod cache {
1080    use super::FontDatabase;
1081    use oxifont_core::{FaceInfo, FontError};
1082    use serde::{Deserialize, Serialize};
1083    use std::collections::HashMap;
1084    use std::path::{Path, PathBuf};
1085    use std::time::UNIX_EPOCH;
1086
1087    // -----------------------------------------------------------------------
1088    // Cache file types
1089    // -----------------------------------------------------------------------
1090
1091    /// A single cache record: all faces parsed from one source font file
1092    /// together with the file's mtime.
1093    #[derive(Debug, Serialize, Deserialize)]
1094    pub(crate) struct CacheRecord {
1095        /// Modification time of the source font file in seconds since UNIX
1096        /// epoch. Used to detect staleness.
1097        pub mtime: u64,
1098        /// All `FaceInfo` records extracted from this file.
1099        pub faces: Vec<FaceInfo>,
1100    }
1101
1102    /// The complete on-disk cache: a map from source-font file path
1103    /// (as a UTF-8 string) to its [`CacheRecord`].
1104    #[derive(Debug, Default, Serialize, Deserialize)]
1105    pub(crate) struct CacheFile {
1106        /// Map: canonical UTF-8 path → per-file record.
1107        pub entries: HashMap<String, CacheRecord>,
1108    }
1109
1110    // -----------------------------------------------------------------------
1111    // Helpers
1112    // -----------------------------------------------------------------------
1113
1114    /// Returns the path to the on-disk cache file, or `None` if the platform
1115    /// has no accessible cache directory.
1116    fn cache_file_path() -> Option<PathBuf> {
1117        // Use the `OXIFONT_CACHE_DIR` env var as an override (useful in tests).
1118        if let Ok(dir) = std::env::var("OXIFONT_CACHE_DIR") {
1119            let dir = PathBuf::from(dir);
1120            if dir.exists() || std::fs::create_dir_all(&dir).is_ok() {
1121                return Some(dir.join("oxifont_face_cache.json"));
1122            }
1123        }
1124        let dir = dirs::cache_dir()?.join("oxifont");
1125        std::fs::create_dir_all(&dir).ok()?;
1126        Some(dir.join("oxifont_face_cache.json"))
1127    }
1128
1129    /// Returns the mtime of `path` in whole seconds since UNIX epoch.
1130    /// Returns `None` on any I/O error.
1131    fn mtime_secs(path: &Path) -> Option<u64> {
1132        std::fs::metadata(path)
1133            .ok()?
1134            .modified()
1135            .ok()?
1136            .duration_since(UNIX_EPOCH)
1137            .ok()
1138            .map(|d| d.as_secs())
1139    }
1140
1141    /// Reads and deserialises the cache file from disk.  Returns an empty
1142    /// [`CacheFile`] on any read or parse error (treats errors as a cold start).
1143    pub(crate) fn load_cache(path: &Path) -> CacheFile {
1144        std::fs::read_to_string(path)
1145            .ok()
1146            .and_then(|s| serde_json::from_str(&s).ok())
1147            .unwrap_or_default()
1148    }
1149
1150    /// Serialises `cache` and writes it atomically (write to a `.tmp` file,
1151    /// then rename) to `path`.  Silently ignores write errors so that cache
1152    /// failures never surface as fatal errors.
1153    pub(crate) fn save_cache(path: &Path, cache: &CacheFile) {
1154        let json = match serde_json::to_string(cache) {
1155            Ok(j) => j,
1156            Err(_) => return,
1157        };
1158        // Write to a sibling temp file then rename for atomicity.
1159        let tmp = path.with_extension("tmp");
1160        if std::fs::write(&tmp, &json).is_err() {
1161            return;
1162        }
1163        let _ = std::fs::rename(&tmp, path);
1164    }
1165
1166    // -----------------------------------------------------------------------
1167    // Public API additions on FontDatabase
1168    // -----------------------------------------------------------------------
1169
1170    impl FontDatabase {
1171        /// Builds a catalog by recursively scanning `paths`, using a
1172        /// JSON disk cache to avoid re-parsing unchanged font files.
1173        ///
1174        /// For each font file discovered:
1175        /// - If a cache entry exists **and** the file's mtime matches the
1176        ///   stored mtime, the cached [`FaceInfo`] records are loaded
1177        ///   directly without parsing the file.
1178        /// - Otherwise the file is parsed via [`oxifont_parser`], and the
1179        ///   resulting records are written back to the cache.
1180        ///
1181        /// The cache is stored at `<platform_cache_dir>/oxifont/oxifont_face_cache.json`.
1182        /// Set the `OXIFONT_CACHE_DIR` environment variable to override the
1183        /// cache directory (useful in tests).
1184        ///
1185        /// Cache failures (unreadable file, stale permissions, serialisation
1186        /// errors) are silently treated as a cold start; the database is
1187        /// always built correctly even without a working cache.
1188        ///
1189        /// # Errors
1190        /// Returns [`FontError`] only in the same exceptional cases as
1191        /// [`FontDatabase::scan`]; individual font-parse failures are skipped.
1192        pub fn scan_cached(paths: &[impl AsRef<Path>]) -> Result<Self, FontError> {
1193            let cache_path = cache_file_path();
1194
1195            // Load the existing cache (empty if absent or corrupt).
1196            let mut disk_cache = cache_path.as_deref().map(load_cache).unwrap_or_default();
1197
1198            // Collect all font file paths from the discovery layer.
1199            // `scan_dirs` returns fully-hydrated `FaceInfo` records but we
1200            // only need the file paths here; we discard the records and
1201            // re-drive caching ourselves.
1202            let font_paths: Vec<PathBuf> = {
1203                let discovered = oxifont_discovery::scan_dirs(paths);
1204                let mut seen = std::collections::HashSet::new();
1205                discovered
1206                    .into_iter()
1207                    .filter_map(|fi| {
1208                        if seen.insert(fi.path.clone()) {
1209                            Some(fi.path)
1210                        } else {
1211                            None
1212                        }
1213                    })
1214                    .collect()
1215            };
1216
1217            let mut db = Self::new();
1218            let mut cache_dirty = false;
1219
1220            for font_path in &font_paths {
1221                let key = font_path.to_string_lossy().into_owned();
1222                let current_mtime = mtime_secs(font_path);
1223
1224                // Try to serve from cache.
1225                if let (Some(record), Some(mtime)) = (disk_cache.entries.get(&key), current_mtime) {
1226                    if record.mtime == mtime {
1227                        // Cache hit: add stored faces directly.
1228                        for face in &record.faces {
1229                            db.add_face(face.clone());
1230                        }
1231                        continue;
1232                    }
1233                }
1234
1235                // Cache miss or stale: parse the file.
1236                let bytes = match std::fs::read(font_path) {
1237                    Ok(b) => b,
1238                    Err(_) => continue, // unreadable — skip silently
1239                };
1240
1241                let arc: std::sync::Arc<[u8]> = bytes.into();
1242                let face_count = oxifont_parser::face_count(&arc);
1243                let mut new_faces: Vec<FaceInfo> = Vec::with_capacity(face_count as usize);
1244
1245                for idx in 0..face_count {
1246                    if let Ok(parsed) = oxifont_parser::ParsedFace::parse(arc.clone(), idx) {
1247                        new_faces.push(parsed.as_face_info());
1248                    }
1249                }
1250
1251                if new_faces.is_empty() {
1252                    continue;
1253                }
1254
1255                // Add to database.
1256                for face in &new_faces {
1257                    db.add_face(face.clone());
1258                }
1259
1260                // Update cache entry.
1261                if let Some(mtime) = current_mtime {
1262                    disk_cache.entries.insert(
1263                        key,
1264                        CacheRecord {
1265                            mtime,
1266                            faces: new_faces,
1267                        },
1268                    );
1269                    cache_dirty = true;
1270                }
1271            }
1272
1273            // Prune stale entries (paths no longer discovered).
1274            let path_set: std::collections::HashSet<String> = font_paths
1275                .iter()
1276                .map(|p| p.to_string_lossy().into_owned())
1277                .collect();
1278            let before = disk_cache.entries.len();
1279            disk_cache.entries.retain(|k, _| path_set.contains(k));
1280            if disk_cache.entries.len() != before {
1281                cache_dirty = true;
1282            }
1283
1284            // Persist updated cache.
1285            if cache_dirty {
1286                if let Some(ref cp) = cache_path {
1287                    save_cache(cp, &disk_cache);
1288                }
1289            }
1290
1291            Ok(db)
1292        }
1293
1294        /// Builds a cached catalog from the OS system font directories.
1295        ///
1296        /// Equivalent to calling [`scan_cached`] with the paths returned by
1297        /// [`oxifont_discovery::system_font_dirs`].
1298        ///
1299        /// [`scan_cached`]: FontDatabase::scan_cached
1300        ///
1301        /// # Errors
1302        /// Returns [`FontError`] only in the same exceptional cases as
1303        /// [`FontDatabase::system`]; individual font-parse failures are skipped.
1304        pub fn system_cached() -> Result<Self, FontError> {
1305            let dirs = oxifont_discovery::system_font_dirs();
1306            Self::scan_cached(&dirs)
1307        }
1308    }
1309}