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}