Skip to main content

rust_fontconfig/
lib.rs

1//! # rust-fontconfig
2//!
3//! Pure-Rust rewrite of the Linux fontconfig library (no system dependencies). Enable the `parsing` feature to parse `.woff`, `.woff2`, `.ttc`, `.otf` and `.ttf` with allsorts.
4//!
5//! **NOTE**: Also works on Windows, macOS and WASM - without external dependencies!
6//!
7//! ## Usage
8//!
9//! ### Basic Font Query
10//!
11//! ```rust,no_run
12//! use rust_fontconfig::{FcFontCache, FcPattern};
13//!
14//! fn main() {
15//!     // Build the font cache
16//!     let cache = FcFontCache::build();
17//!
18//!     // Query a font by name
19//!     let results = cache.query(
20//!         &FcPattern {
21//!             name: Some(String::from("Arial")),
22//!             ..Default::default()
23//!         },
24//!         &mut Vec::new() // Trace messages container
25//!     );
26//!
27//!     if let Some(font_match) = results {
28//!         println!("Font match ID: {:?}", font_match.id);
29//!         println!("Font unicode ranges: {:?}", font_match.unicode_ranges);
30//!     } else {
31//!         println!("No matching font found");
32//!     }
33//! }
34//! ```
35//!
36//! ### Resolve Font Chain and Query for Text
37//!
38//! ```rust,no_run
39//! use rust_fontconfig::{FcFontCache, FcWeight, PatternMatch};
40//!
41//! fn main() {
42//!     # #[cfg(feature = "std")]
43//!     # {
44//!     let cache = FcFontCache::build();
45//!
46//!     // Build font fallback chain (without text parameter)
47//!     let font_chain = cache.resolve_font_chain(
48//!         &["Arial".to_string(), "sans-serif".to_string()],
49//!         FcWeight::Normal,
50//!         PatternMatch::DontCare,
51//!         PatternMatch::DontCare,
52//!         &mut Vec::new(),
53//!     );
54//!
55//!     // Query which fonts to use for specific text
56//!     let text = "Hello 你好 Здравствуйте";
57//!     let font_runs = font_chain.query_for_text(&cache, text);
58//!
59//!     println!("Text split into {} font runs:", font_runs.len());
60//!     for run in font_runs {
61//!         println!("  '{}' -> font {:?}", run.text, run.font_id);
62//!     }
63//!     # }
64//! }
65//! ```
66
67#![allow(non_snake_case)]
68
69// As of v4.1 this crate is std-only. The v4.0 `no_std` path is gone —
70// it never supported the registry / multi-thread parsing anyway, and
71// the shared-state `FcFontCache` refactor depends on `std::sync::RwLock`
72// which is unavailable without std. Keeping the `alloc::` import paths
73// means the existing call sites in this file and submodules keep
74// compiling — in std builds `alloc` is just `core::alloc`'s companion
75// crate already linked by the standard library.
76extern crate alloc;
77
78use alloc::collections::btree_map::BTreeMap;
79use alloc::string::{String, ToString};
80use alloc::vec::Vec;
81#[cfg(all(feature = "std", feature = "parsing"))]
82use allsorts::binary::read::ReadScope;
83#[cfg(all(feature = "std", feature = "parsing"))]
84use allsorts::get_name::fontcode_get_name;
85#[cfg(all(feature = "std", feature = "parsing"))]
86use allsorts::tables::os2::Os2;
87#[cfg(all(feature = "std", feature = "parsing"))]
88use allsorts::tables::{FontTableProvider, HheaTable, HmtxTable, MaxpTable};
89#[cfg(all(feature = "std", feature = "parsing"))]
90use allsorts::tag;
91#[cfg(feature = "std")]
92use std::path::PathBuf;
93
94pub mod utils;
95#[cfg(feature = "std")]
96pub mod config;
97
98#[cfg(feature = "ffi")]
99pub mod ffi;
100
101#[cfg(feature = "async-registry")]
102pub mod scoring;
103#[cfg(feature = "async-registry")]
104pub mod registry;
105#[cfg(feature = "async-registry")]
106pub mod multithread;
107#[cfg(feature = "cache")]
108pub mod disk_cache;
109
110#[cfg(all(target_os = "ios", feature = "std", feature = "parsing"))]
111mod mobile_ios;
112
113/// Operating system type for generic font family resolution
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
115pub enum OperatingSystem {
116    Windows,
117    Linux,
118    MacOS,
119    IOS,
120    Android,
121    Wasm,
122}
123
124impl OperatingSystem {
125    /// Detect the current operating system at compile time
126    pub fn current() -> Self {
127        #[cfg(target_os = "windows")]
128        return OperatingSystem::Windows;
129
130        #[cfg(target_os = "linux")]
131        return OperatingSystem::Linux;
132
133        #[cfg(target_os = "macos")]
134        return OperatingSystem::MacOS;
135
136        #[cfg(target_os = "ios")]
137        return OperatingSystem::IOS;
138
139        #[cfg(target_os = "android")]
140        return OperatingSystem::Android;
141
142        #[cfg(target_family = "wasm")]
143        return OperatingSystem::Wasm;
144
145        #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos", target_os = "ios", target_os = "android", target_family = "wasm")))]
146        return OperatingSystem::Linux; // Default fallback
147    }
148    
149    /// Get system-specific fonts for the "serif" generic family
150    /// Prioritizes fonts based on Unicode range coverage
151    pub fn get_serif_fonts(&self, unicode_ranges: &[UnicodeRange]) -> Vec<String> {
152        let has_cjk = has_cjk_ranges(unicode_ranges);
153        let has_arabic = has_arabic_ranges(unicode_ranges);
154        let _has_cyrillic = has_cyrillic_ranges(unicode_ranges);
155        
156        match self {
157            OperatingSystem::Windows => {
158                let mut fonts = Vec::new();
159                if has_cjk {
160                    fonts.extend_from_slice(&["MS Mincho", "SimSun", "MingLiU"]);
161                }
162                if has_arabic {
163                    fonts.push("Traditional Arabic");
164                }
165                fonts.push("Times New Roman");
166                fonts.iter().map(|s| s.to_string()).collect()
167            }
168            OperatingSystem::Linux => {
169                let mut fonts = Vec::new();
170                if has_cjk {
171                    fonts.extend_from_slice(&["Noto Serif CJK SC", "Noto Serif CJK JP", "Noto Serif CJK KR"]);
172                }
173                if has_arabic {
174                    fonts.push("Noto Serif Arabic");
175                }
176                fonts.extend_from_slice(&[
177                    "Times", "Times New Roman", "DejaVu Serif", "Free Serif", 
178                    "Noto Serif", "Bitstream Vera Serif", "Roman", "Regular"
179                ]);
180                fonts.iter().map(|s| s.to_string()).collect()
181            }
182            OperatingSystem::MacOS | OperatingSystem::IOS => {
183                let mut fonts = Vec::new();
184                if has_cjk {
185                    fonts.extend_from_slice(&["Hiragino Mincho ProN", "STSong", "AppleMyungjo"]);
186                }
187                if has_arabic {
188                    fonts.push("Geeza Pro");
189                }
190                fonts.extend_from_slice(&["Times New Roman", "Times", "New York", "Palatino"]);
191                fonts.iter().map(|s| s.to_string()).collect()
192            }
193            OperatingSystem::Android => {
194                let mut fonts = Vec::new();
195                if has_cjk {
196                    fonts.extend_from_slice(&["Noto Serif CJK SC", "Noto Serif CJK JP", "Noto Serif CJK KR"]);
197                }
198                if has_arabic {
199                    fonts.push("Noto Naskh Arabic");
200                }
201                fonts.extend_from_slice(&["Noto Serif", "Roboto Serif", "Droid Serif"]);
202                fonts.iter().map(|s| s.to_string()).collect()
203            }
204            OperatingSystem::Wasm => Vec::new(),
205        }
206    }
207
208    /// Get system-specific fonts for the "sans-serif" generic family
209    /// Prioritizes fonts based on Unicode range coverage
210    pub fn get_sans_serif_fonts(&self, unicode_ranges: &[UnicodeRange]) -> Vec<String> {
211        let has_cjk = has_cjk_ranges(unicode_ranges);
212        let has_arabic = has_arabic_ranges(unicode_ranges);
213        let _has_cyrillic = has_cyrillic_ranges(unicode_ranges);
214        let has_hebrew = has_hebrew_ranges(unicode_ranges);
215        let has_thai = has_thai_ranges(unicode_ranges);
216        
217        match self {
218            OperatingSystem::Windows => {
219                let mut fonts = Vec::new();
220                if has_cjk {
221                    fonts.extend_from_slice(&["Microsoft YaHei", "MS Gothic", "Malgun Gothic", "SimHei"]);
222                }
223                if has_arabic {
224                    fonts.push("Segoe UI Arabic");
225                }
226                if has_hebrew {
227                    fonts.push("Segoe UI Hebrew");
228                }
229                if has_thai {
230                    fonts.push("Leelawadee UI");
231                }
232                fonts.extend_from_slice(&["Segoe UI", "Tahoma", "Microsoft Sans Serif", "MS Sans Serif", "Helv"]);
233                fonts.iter().map(|s| s.to_string()).collect()
234            }
235            OperatingSystem::Linux => {
236                let mut fonts = Vec::new();
237                if has_cjk {
238                    fonts.extend_from_slice(&[
239                        "Noto Sans CJK SC", "Noto Sans CJK JP", "Noto Sans CJK KR",
240                        "WenQuanYi Micro Hei", "Droid Sans Fallback"
241                    ]);
242                }
243                if has_arabic {
244                    fonts.push("Noto Sans Arabic");
245                }
246                if has_hebrew {
247                    fonts.push("Noto Sans Hebrew");
248                }
249                if has_thai {
250                    fonts.push("Noto Sans Thai");
251                }
252                fonts.extend_from_slice(&["Ubuntu", "Arial", "DejaVu Sans", "Noto Sans", "Liberation Sans"]);
253                fonts.iter().map(|s| s.to_string()).collect()
254            }
255            OperatingSystem::MacOS | OperatingSystem::IOS => {
256                let mut fonts = Vec::new();
257                if has_cjk {
258                    fonts.extend_from_slice(&[
259                        "Hiragino Sans", "Hiragino Kaku Gothic ProN",
260                        "PingFang SC", "PingFang TC", "Apple SD Gothic Neo"
261                    ]);
262                }
263                if has_arabic {
264                    fonts.push("Geeza Pro");
265                }
266                if has_hebrew {
267                    fonts.push("Arial Hebrew");
268                }
269                if has_thai {
270                    fonts.push("Thonburi");
271                }
272                fonts.extend_from_slice(&[
273                    "San Francisco", ".AppleSystemUIFont", ".SFUIText", ".SFUI-Regular",
274                    "Helvetica Neue", "Helvetica", "Lucida Grande",
275                ]);
276                fonts.iter().map(|s| s.to_string()).collect()
277            }
278            OperatingSystem::Android => {
279                let mut fonts = Vec::new();
280                if has_cjk {
281                    fonts.extend_from_slice(&[
282                        "Noto Sans CJK SC", "Noto Sans CJK JP", "Noto Sans CJK KR",
283                        "Droid Sans Fallback",
284                    ]);
285                }
286                if has_arabic {
287                    fonts.push("Noto Sans Arabic");
288                }
289                if has_hebrew {
290                    fonts.push("Noto Sans Hebrew");
291                }
292                if has_thai {
293                    fonts.push("Noto Sans Thai");
294                }
295                fonts.extend_from_slice(&[
296                    "Roboto", "Roboto-Regular", "Noto Sans", "Droid Sans",
297                ]);
298                fonts.iter().map(|s| s.to_string()).collect()
299            }
300            OperatingSystem::Wasm => Vec::new(),
301        }
302    }
303
304    /// Get system-specific fonts for the "monospace" generic family
305    /// Prioritizes fonts based on Unicode range coverage
306    pub fn get_monospace_fonts(&self, unicode_ranges: &[UnicodeRange]) -> Vec<String> {
307        let has_cjk = has_cjk_ranges(unicode_ranges);
308        
309        match self {
310            OperatingSystem::Windows => {
311                let mut fonts = Vec::new();
312                if has_cjk {
313                    fonts.extend_from_slice(&["MS Gothic", "SimHei"]);
314                }
315                fonts.extend_from_slice(&["Segoe UI Mono", "Courier New", "Cascadia Code", "Cascadia Mono", "Consolas"]);
316                fonts.iter().map(|s| s.to_string()).collect()
317            }
318            OperatingSystem::Linux => {
319                let mut fonts = Vec::new();
320                if has_cjk {
321                    fonts.extend_from_slice(&["Noto Sans Mono CJK SC", "Noto Sans Mono CJK JP", "WenQuanYi Zen Hei Mono"]);
322                }
323                fonts.extend_from_slice(&[
324                    "Source Code Pro", "Cantarell", "DejaVu Sans Mono", 
325                    "Roboto Mono", "Ubuntu Monospace", "Droid Sans Mono"
326                ]);
327                fonts.iter().map(|s| s.to_string()).collect()
328            }
329            OperatingSystem::MacOS | OperatingSystem::IOS => {
330                let mut fonts = Vec::new();
331                if has_cjk {
332                    fonts.extend_from_slice(&["Hiragino Sans", "PingFang SC"]);
333                }
334                fonts.extend_from_slice(&["SF Mono", "Menlo", "Monaco", "Courier", "Oxygen Mono", "Source Code Pro", "Fira Mono"]);
335                fonts.iter().map(|s| s.to_string()).collect()
336            }
337            OperatingSystem::Android => {
338                let mut fonts = Vec::new();
339                if has_cjk {
340                    fonts.extend_from_slice(&["Noto Sans Mono CJK SC", "Noto Sans Mono CJK JP"]);
341                }
342                fonts.extend_from_slice(&["Roboto Mono", "Droid Sans Mono", "Noto Sans Mono", "DejaVu Sans Mono"]);
343                fonts.iter().map(|s| s.to_string()).collect()
344            }
345            OperatingSystem::Wasm => Vec::new(),
346        }
347    }
348    
349    /// Expand a generic CSS font family to system-specific font names
350    /// Returns the original name if not a generic family
351    /// Prioritizes fonts based on Unicode range coverage
352    pub fn expand_generic_family(&self, family: &str, unicode_ranges: &[UnicodeRange]) -> Vec<String> {
353        match family.to_ascii_lowercase().as_str() {
354            "serif" => self.get_serif_fonts(unicode_ranges),
355            "sans-serif" => self.get_sans_serif_fonts(unicode_ranges),
356            "monospace" => self.get_monospace_fonts(unicode_ranges),
357            "cursive" | "fantasy" | "system-ui" => {
358                // Use sans-serif as fallback for these
359                self.get_sans_serif_fonts(unicode_ranges)
360            }
361            _ => vec![family.to_string()],
362        }
363    }
364}
365
366/// Expand a CSS font-family stack with generic families resolved to OS-specific fonts
367/// Prioritizes fonts based on Unicode range coverage
368/// Example: ["Arial", "sans-serif"] on macOS with CJK ranges -> ["Arial", "PingFang SC", "Hiragino Sans", ...]
369///
370/// NOTE: this free function only sees the per-OS LAST-RESORT lists. When a
371/// system font configuration is available, prefer
372/// [`FcFontCache::expand_font_families_config_first`], which consults the
373/// parsed `<alias>`/`<prefer>` preferences (the machine's ACTUAL
374/// configuration) before any built-in list.
375pub fn expand_font_families(families: &[String], os: OperatingSystem, unicode_ranges: &[UnicodeRange]) -> Vec<String> {
376    let mut expanded = Vec::new();
377    
378    for family in families {
379        expanded.extend(os.expand_generic_family(family, unicode_ranges));
380    }
381    
382    expanded
383}
384
385/// UUID to identify a font (collections are broken up into separate fonts)
386#[derive(Clone, Copy, PartialOrd, Ord, PartialEq, Eq, Hash)]
387#[cfg_attr(feature = "cache", derive(serde::Serialize, serde::Deserialize))]
388pub struct FontId(pub u128);
389
390impl core::fmt::Debug for FontId {
391    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
392        core::fmt::Display::fmt(self, f)
393    }
394}
395
396impl core::fmt::Display for FontId {
397    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
398        let id = self.0;
399        write!(
400            f,
401            "{:08x}-{:04x}-{:04x}-{:04x}-{:012x}",
402            (id >> 96) & 0xFFFFFFFF,
403            (id >> 80) & 0xFFFF,
404            (id >> 64) & 0xFFFF,
405            (id >> 48) & 0xFFFF,
406            id & 0xFFFFFFFFFFFF
407        )
408    }
409}
410
411impl FontId {
412    /// Generate a new unique FontId using an atomic counter
413    pub fn new() -> Self {
414        use core::sync::atomic::{AtomicU64, Ordering};
415        static COUNTER: AtomicU64 = AtomicU64::new(1);
416        let id = COUNTER.fetch_add(1, Ordering::Relaxed) as u128;
417        FontId(id)
418    }
419}
420
421/// Whether a field is required to match (yes / no / don't care)
422#[derive(Debug, Default, Copy, Clone, PartialOrd, Ord, PartialEq, Eq, Hash)]
423#[cfg_attr(feature = "cache", derive(serde::Serialize, serde::Deserialize))]
424#[repr(C)]
425pub enum PatternMatch {
426    /// Default: don't particularly care whether the requirement matches
427    #[default]
428    DontCare,
429    /// Requirement has to be true for the selected font
430    True,
431    /// Requirement has to be false for the selected font
432    False,
433}
434
435impl PatternMatch {
436    fn needs_to_match(&self) -> bool {
437        matches!(self, PatternMatch::True | PatternMatch::False)
438    }
439
440    fn matches(&self, other: &PatternMatch) -> bool {
441        match (self, other) {
442            (PatternMatch::DontCare, _) => true,
443            (_, PatternMatch::DontCare) => true,
444            (a, b) => a == b,
445        }
446    }
447}
448
449/// Font weight values as defined in CSS specification
450#[derive(Debug, Clone, Copy, PartialOrd, Ord, PartialEq, Eq, Hash)]
451#[cfg_attr(feature = "cache", derive(serde::Serialize, serde::Deserialize))]
452#[repr(C)]
453pub enum FcWeight {
454    Thin = 100,
455    ExtraLight = 200,
456    Light = 300,
457    Normal = 400,
458    Medium = 500,
459    SemiBold = 600,
460    Bold = 700,
461    ExtraBold = 800,
462    Black = 900,
463}
464
465impl FcWeight {
466    pub fn from_u16(weight: u16) -> Self {
467        match weight {
468            0..=149 => FcWeight::Thin,
469            150..=249 => FcWeight::ExtraLight,
470            250..=349 => FcWeight::Light,
471            350..=449 => FcWeight::Normal,
472            450..=549 => FcWeight::Medium,
473            550..=649 => FcWeight::SemiBold,
474            650..=749 => FcWeight::Bold,
475            750..=849 => FcWeight::ExtraBold,
476            _ => FcWeight::Black,
477        }
478    }
479
480    pub fn find_best_match(&self, available: &[FcWeight]) -> Option<FcWeight> {
481        if available.is_empty() {
482            return None;
483        }
484
485        // Exact match
486        if available.contains(self) {
487            return Some(*self);
488        }
489
490        // Get numeric value
491        let self_value = *self as u16;
492
493        match *self {
494            FcWeight::Normal => {
495                // For Normal (400), try Medium (500) first
496                if available.contains(&FcWeight::Medium) {
497                    return Some(FcWeight::Medium);
498                }
499                // Then try lighter weights
500                for weight in &[FcWeight::Light, FcWeight::ExtraLight, FcWeight::Thin] {
501                    if available.contains(weight) {
502                        return Some(*weight);
503                    }
504                }
505                // Last, try heavier weights
506                for weight in &[
507                    FcWeight::SemiBold,
508                    FcWeight::Bold,
509                    FcWeight::ExtraBold,
510                    FcWeight::Black,
511                ] {
512                    if available.contains(weight) {
513                        return Some(*weight);
514                    }
515                }
516            }
517            FcWeight::Medium => {
518                // For Medium (500), try Normal (400) first
519                if available.contains(&FcWeight::Normal) {
520                    return Some(FcWeight::Normal);
521                }
522                // Then try lighter weights
523                for weight in &[FcWeight::Light, FcWeight::ExtraLight, FcWeight::Thin] {
524                    if available.contains(weight) {
525                        return Some(*weight);
526                    }
527                }
528                // Last, try heavier weights
529                for weight in &[
530                    FcWeight::SemiBold,
531                    FcWeight::Bold,
532                    FcWeight::ExtraBold,
533                    FcWeight::Black,
534                ] {
535                    if available.contains(weight) {
536                        return Some(*weight);
537                    }
538                }
539            }
540            FcWeight::Thin | FcWeight::ExtraLight | FcWeight::Light => {
541                // For lightweight fonts (<400), first try lighter or equal weights
542                let mut best_match = None;
543                let mut smallest_diff = u16::MAX;
544
545                // Find the closest lighter weight
546                for weight in available {
547                    let weight_value = *weight as u16;
548                    // Only consider weights <= self (per test expectation)
549                    if weight_value <= self_value {
550                        let diff = self_value - weight_value;
551                        if diff < smallest_diff {
552                            smallest_diff = diff;
553                            best_match = Some(*weight);
554                        }
555                    }
556                }
557
558                if best_match.is_some() {
559                    return best_match;
560                }
561
562                // If no lighter weight, find the closest heavier weight
563                best_match = None;
564                smallest_diff = u16::MAX;
565
566                for weight in available {
567                    let weight_value = *weight as u16;
568                    if weight_value > self_value {
569                        let diff = weight_value - self_value;
570                        if diff < smallest_diff {
571                            smallest_diff = diff;
572                            best_match = Some(*weight);
573                        }
574                    }
575                }
576
577                return best_match;
578            }
579            FcWeight::SemiBold | FcWeight::Bold | FcWeight::ExtraBold | FcWeight::Black => {
580                // For heavyweight fonts (>500), first try heavier or equal weights
581                let mut best_match = None;
582                let mut smallest_diff = u16::MAX;
583
584                // Find the closest heavier weight
585                for weight in available {
586                    let weight_value = *weight as u16;
587                    // Only consider weights >= self
588                    if weight_value >= self_value {
589                        let diff = weight_value - self_value;
590                        if diff < smallest_diff {
591                            smallest_diff = diff;
592                            best_match = Some(*weight);
593                        }
594                    }
595                }
596
597                if best_match.is_some() {
598                    return best_match;
599                }
600
601                // If no heavier weight, find the closest lighter weight
602                best_match = None;
603                smallest_diff = u16::MAX;
604
605                for weight in available {
606                    let weight_value = *weight as u16;
607                    if weight_value < self_value {
608                        let diff = self_value - weight_value;
609                        if diff < smallest_diff {
610                            smallest_diff = diff;
611                            best_match = Some(*weight);
612                        }
613                    }
614                }
615
616                return best_match;
617            }
618        }
619
620        // If nothing matches by now, return the first available weight
621        Some(available[0])
622    }
623}
624
625impl Default for FcWeight {
626    fn default() -> Self {
627        FcWeight::Normal
628    }
629}
630
631/// CSS font-stretch values
632#[derive(Debug, Clone, Copy, PartialOrd, Ord, PartialEq, Eq, Hash)]
633#[cfg_attr(feature = "cache", derive(serde::Serialize, serde::Deserialize))]
634#[repr(C)]
635pub enum FcStretch {
636    UltraCondensed = 1,
637    ExtraCondensed = 2,
638    Condensed = 3,
639    SemiCondensed = 4,
640    Normal = 5,
641    SemiExpanded = 6,
642    Expanded = 7,
643    ExtraExpanded = 8,
644    UltraExpanded = 9,
645}
646
647impl FcStretch {
648    pub fn is_condensed(&self) -> bool {
649        use self::FcStretch::*;
650        match self {
651            UltraCondensed => true,
652            ExtraCondensed => true,
653            Condensed => true,
654            SemiCondensed => true,
655            Normal => false,
656            SemiExpanded => false,
657            Expanded => false,
658            ExtraExpanded => false,
659            UltraExpanded => false,
660        }
661    }
662    pub fn from_u16(width_class: u16) -> Self {
663        match width_class {
664            1 => FcStretch::UltraCondensed,
665            2 => FcStretch::ExtraCondensed,
666            3 => FcStretch::Condensed,
667            4 => FcStretch::SemiCondensed,
668            5 => FcStretch::Normal,
669            6 => FcStretch::SemiExpanded,
670            7 => FcStretch::Expanded,
671            8 => FcStretch::ExtraExpanded,
672            9 => FcStretch::UltraExpanded,
673            _ => FcStretch::Normal,
674        }
675    }
676
677    /// Follows CSS spec for stretch matching
678    pub fn find_best_match(&self, available: &[FcStretch]) -> Option<FcStretch> {
679        if available.is_empty() {
680            return None;
681        }
682
683        if available.contains(self) {
684            return Some(*self);
685        }
686
687        // For 'normal' or condensed values, narrower widths are checked first, then wider values
688        if *self <= FcStretch::Normal {
689            // Find narrower values first
690            let mut closest_narrower = None;
691            for stretch in available.iter() {
692                if *stretch < *self
693                    && (closest_narrower.is_none() || *stretch > closest_narrower.unwrap())
694                {
695                    closest_narrower = Some(*stretch);
696                }
697            }
698
699            if closest_narrower.is_some() {
700                return closest_narrower;
701            }
702
703            // Otherwise, find wider values
704            let mut closest_wider = None;
705            for stretch in available.iter() {
706                if *stretch > *self
707                    && (closest_wider.is_none() || *stretch < closest_wider.unwrap())
708                {
709                    closest_wider = Some(*stretch);
710                }
711            }
712
713            return closest_wider;
714        } else {
715            // For expanded values, wider values are checked first, then narrower values
716            let mut closest_wider = None;
717            for stretch in available.iter() {
718                if *stretch > *self
719                    && (closest_wider.is_none() || *stretch < closest_wider.unwrap())
720                {
721                    closest_wider = Some(*stretch);
722                }
723            }
724
725            if closest_wider.is_some() {
726                return closest_wider;
727            }
728
729            // Otherwise, find narrower values
730            let mut closest_narrower = None;
731            for stretch in available.iter() {
732                if *stretch < *self
733                    && (closest_narrower.is_none() || *stretch > closest_narrower.unwrap())
734                {
735                    closest_narrower = Some(*stretch);
736                }
737            }
738
739            return closest_narrower;
740        }
741    }
742}
743
744impl Default for FcStretch {
745    fn default() -> Self {
746        FcStretch::Normal
747    }
748}
749
750/// Unicode range representation for font matching
751#[repr(C)]
752#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
753#[cfg_attr(feature = "cache", derive(serde::Serialize, serde::Deserialize))]
754pub struct UnicodeRange {
755    pub start: u32,
756    pub end: u32,
757}
758
759/// The default set of Unicode-block fallback scripts that
760/// [`FcFontCache::resolve_font_chain`] pulls in when no explicit
761/// `scripts_hint` is supplied.
762///
763/// Keeping this exposed lets callers that *do* want the default
764/// behaviour build the set explicitly — typically by union-ing it
765/// with a detected-from-document set before calling
766/// [`FcFontCache::resolve_font_chain_with_scripts`].
767pub const DEFAULT_UNICODE_FALLBACK_SCRIPTS: &[UnicodeRange] = &[
768    UnicodeRange { start: 0x0400, end: 0x04FF }, // Cyrillic
769    UnicodeRange { start: 0x0600, end: 0x06FF }, // Arabic
770    UnicodeRange { start: 0x0900, end: 0x097F }, // Devanagari
771    UnicodeRange { start: 0x3040, end: 0x309F }, // Hiragana
772    UnicodeRange { start: 0x30A0, end: 0x30FF }, // Katakana
773    UnicodeRange { start: 0x4E00, end: 0x9FFF }, // CJK Unified Ideographs
774    UnicodeRange { start: 0xAC00, end: 0xD7A3 }, // Hangul Syllables
775];
776
777impl UnicodeRange {
778    pub fn contains(&self, c: char) -> bool {
779        let c = c as u32;
780        c >= self.start && c <= self.end
781    }
782
783    pub fn overlaps(&self, other: &UnicodeRange) -> bool {
784        self.start <= other.end && other.start <= self.end
785    }
786
787    pub fn is_subset_of(&self, other: &UnicodeRange) -> bool {
788        self.start >= other.start && self.end <= other.end
789    }
790}
791
792/// Check if any range covers CJK Unified Ideographs, Hiragana, Katakana, or Hangul
793pub fn has_cjk_ranges(ranges: &[UnicodeRange]) -> bool {
794    ranges.iter().any(|r| {
795        (r.start >= 0x4E00 && r.start <= 0x9FFF) ||
796        (r.start >= 0x3040 && r.start <= 0x309F) ||
797        (r.start >= 0x30A0 && r.start <= 0x30FF) ||
798        (r.start >= 0xAC00 && r.start <= 0xD7AF)
799    })
800}
801
802/// Check if any range covers the Arabic block
803pub fn has_arabic_ranges(ranges: &[UnicodeRange]) -> bool {
804    ranges.iter().any(|r| r.start >= 0x0600 && r.start <= 0x06FF)
805}
806
807/// Check if any range covers the Cyrillic block
808pub fn has_cyrillic_ranges(ranges: &[UnicodeRange]) -> bool {
809    ranges.iter().any(|r| r.start >= 0x0400 && r.start <= 0x04FF)
810}
811
812/// Check if any range covers the Hebrew block
813pub fn has_hebrew_ranges(ranges: &[UnicodeRange]) -> bool {
814    ranges.iter().any(|r| r.start >= 0x0590 && r.start <= 0x05FF)
815}
816
817/// Check if any range covers the Thai block
818pub fn has_thai_ranges(ranges: &[UnicodeRange]) -> bool {
819    ranges.iter().any(|r| r.start >= 0x0E00 && r.start <= 0x0E7F)
820}
821
822/// Log levels for trace messages
823#[derive(Debug, Clone, Copy, PartialOrd, Ord, PartialEq, Eq, Hash)]
824pub enum TraceLevel {
825    Debug,
826    Info,
827    Warning,
828    Error,
829}
830
831/// Reason for font matching failure or success
832#[derive(Debug, Clone, PartialEq, Eq, Hash)]
833pub enum MatchReason {
834    NameMismatch {
835        requested: Option<String>,
836        found: Option<String>,
837    },
838    FamilyMismatch {
839        requested: Option<String>,
840        found: Option<String>,
841    },
842    StyleMismatch {
843        property: &'static str,
844        requested: String,
845        found: String,
846    },
847    WeightMismatch {
848        requested: FcWeight,
849        found: FcWeight,
850    },
851    StretchMismatch {
852        requested: FcStretch,
853        found: FcStretch,
854    },
855    UnicodeRangeMismatch {
856        character: char,
857        ranges: Vec<UnicodeRange>,
858    },
859    Success,
860}
861
862/// Trace message for debugging font matching
863#[derive(Debug, Clone, PartialEq, Eq)]
864pub struct TraceMsg {
865    pub level: TraceLevel,
866    pub path: String,
867    pub reason: MatchReason,
868}
869
870/// Hinting style for font rendering.
871#[repr(C)]
872#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
873#[cfg_attr(feature = "cache", derive(serde::Serialize, serde::Deserialize))]
874pub enum FcHintStyle {
875    #[default]
876    None = 0,
877    Slight = 1,
878    Medium = 2,
879    Full = 3,
880}
881
882/// Subpixel rendering order.
883#[repr(C)]
884#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
885#[cfg_attr(feature = "cache", derive(serde::Serialize, serde::Deserialize))]
886pub enum FcRgba {
887    #[default]
888    Unknown = 0,
889    Rgb = 1,
890    Bgr = 2,
891    Vrgb = 3,
892    Vbgr = 4,
893    None = 5,
894}
895
896/// LCD filter mode for subpixel rendering.
897#[repr(C)]
898#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
899#[cfg_attr(feature = "cache", derive(serde::Serialize, serde::Deserialize))]
900pub enum FcLcdFilter {
901    #[default]
902    None = 0,
903    Default = 1,
904    Light = 2,
905    Legacy = 3,
906}
907
908/// Per-font rendering configuration from system font config (Linux fonts.conf).
909///
910/// All fields are `Option<T>` -- `None` means "use system default".
911/// On non-Linux platforms, this is always all-None (no per-font overrides).
912#[derive(Debug, Default, Clone, PartialEq, PartialOrd)]
913#[cfg_attr(feature = "cache", derive(serde::Serialize, serde::Deserialize))]
914pub struct FcFontRenderConfig {
915    pub antialias: Option<bool>,
916    pub hinting: Option<bool>,
917    pub hintstyle: Option<FcHintStyle>,
918    pub autohint: Option<bool>,
919    pub rgba: Option<FcRgba>,
920    pub lcdfilter: Option<FcLcdFilter>,
921    pub embeddedbitmap: Option<bool>,
922    pub embolden: Option<bool>,
923    pub dpi: Option<f64>,
924    pub scale: Option<f64>,
925    pub minspace: Option<bool>,
926}
927
928/// Helper newtype to provide Eq/Ord for Option<f64> via total-order bit comparison.
929/// This allows FcFontRenderConfig to be used inside FcPattern which derives Eq + Ord.
930impl Eq for FcFontRenderConfig {}
931
932impl Ord for FcFontRenderConfig {
933    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
934        // Compare all non-f64 fields first
935        let ord = self.antialias.cmp(&other.antialias)
936            .then_with(|| self.hinting.cmp(&other.hinting))
937            .then_with(|| self.hintstyle.cmp(&other.hintstyle))
938            .then_with(|| self.autohint.cmp(&other.autohint))
939            .then_with(|| self.rgba.cmp(&other.rgba))
940            .then_with(|| self.lcdfilter.cmp(&other.lcdfilter))
941            .then_with(|| self.embeddedbitmap.cmp(&other.embeddedbitmap))
942            .then_with(|| self.embolden.cmp(&other.embolden))
943            .then_with(|| self.minspace.cmp(&other.minspace));
944
945        // For f64 fields, use to_bits() for total ordering
946        let ord = ord.then_with(|| {
947            let a = self.dpi.map(|v| v.to_bits());
948            let b = other.dpi.map(|v| v.to_bits());
949            a.cmp(&b)
950        });
951        ord.then_with(|| {
952            let a = self.scale.map(|v| v.to_bits());
953            let b = other.scale.map(|v| v.to_bits());
954            a.cmp(&b)
955        })
956    }
957}
958
959/// Font pattern for matching
960#[derive(Default, Clone, PartialOrd, Ord, PartialEq, Eq)]
961#[cfg_attr(feature = "cache", derive(serde::Serialize, serde::Deserialize))]
962#[repr(C)]
963pub struct FcPattern {
964    // font name
965    pub name: Option<String>,
966    // family name
967    pub family: Option<String>,
968    // "italic" property
969    pub italic: PatternMatch,
970    // "oblique" property
971    pub oblique: PatternMatch,
972    // "bold" property
973    pub bold: PatternMatch,
974    // "monospace" property
975    pub monospace: PatternMatch,
976    // "condensed" property
977    pub condensed: PatternMatch,
978    // font weight
979    pub weight: FcWeight,
980    // font stretch
981    pub stretch: FcStretch,
982    // unicode ranges to match
983    pub unicode_ranges: Vec<UnicodeRange>,
984    // extended font metadata
985    pub metadata: FcFontMetadata,
986    // per-font rendering configuration (from system fonts.conf on Linux)
987    pub render_config: FcFontRenderConfig,
988}
989
990impl core::fmt::Debug for FcPattern {
991    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
992        let mut d = f.debug_struct("FcPattern");
993
994        if let Some(name) = &self.name {
995            d.field("name", name);
996        }
997
998        if let Some(family) = &self.family {
999            d.field("family", family);
1000        }
1001
1002        if self.italic != PatternMatch::DontCare {
1003            d.field("italic", &self.italic);
1004        }
1005
1006        if self.oblique != PatternMatch::DontCare {
1007            d.field("oblique", &self.oblique);
1008        }
1009
1010        if self.bold != PatternMatch::DontCare {
1011            d.field("bold", &self.bold);
1012        }
1013
1014        if self.monospace != PatternMatch::DontCare {
1015            d.field("monospace", &self.monospace);
1016        }
1017
1018        if self.condensed != PatternMatch::DontCare {
1019            d.field("condensed", &self.condensed);
1020        }
1021
1022        if self.weight != FcWeight::Normal {
1023            d.field("weight", &self.weight);
1024        }
1025
1026        if self.stretch != FcStretch::Normal {
1027            d.field("stretch", &self.stretch);
1028        }
1029
1030        if !self.unicode_ranges.is_empty() {
1031            d.field("unicode_ranges", &self.unicode_ranges);
1032        }
1033
1034        // Only show non-empty metadata fields
1035        let empty_metadata = FcFontMetadata::default();
1036        if self.metadata != empty_metadata {
1037            d.field("metadata", &self.metadata);
1038        }
1039
1040        // Only show render_config when it differs from default
1041        let empty_render_config = FcFontRenderConfig::default();
1042        if self.render_config != empty_render_config {
1043            d.field("render_config", &self.render_config);
1044        }
1045
1046        d.finish()
1047    }
1048}
1049
1050/// Font metadata from the OS/2 table
1051#[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord)]
1052#[cfg_attr(feature = "cache", derive(serde::Serialize, serde::Deserialize))]
1053pub struct FcFontMetadata {
1054    pub copyright: Option<String>,
1055    pub designer: Option<String>,
1056    pub designer_url: Option<String>,
1057    pub font_family: Option<String>,
1058    pub font_subfamily: Option<String>,
1059    pub full_name: Option<String>,
1060    pub id_description: Option<String>,
1061    pub license: Option<String>,
1062    pub license_url: Option<String>,
1063    pub manufacturer: Option<String>,
1064    pub manufacturer_url: Option<String>,
1065    pub postscript_name: Option<String>,
1066    pub preferred_family: Option<String>,
1067    pub preferred_subfamily: Option<String>,
1068    pub trademark: Option<String>,
1069    pub unique_id: Option<String>,
1070    pub version: Option<String>,
1071}
1072
1073impl FcPattern {
1074    /// Check if this pattern would match the given character
1075    pub fn contains_char(&self, c: char) -> bool {
1076        if self.unicode_ranges.is_empty() {
1077            return true; // No ranges specified means match all characters
1078        }
1079
1080        for range in &self.unicode_ranges {
1081            if range.contains(c) {
1082                return true;
1083            }
1084        }
1085
1086        false
1087    }
1088}
1089
1090/// Font match result with UUID
1091#[derive(Debug, Clone, PartialEq, Eq)]
1092pub struct FontMatch {
1093    pub id: FontId,
1094    pub unicode_ranges: Vec<UnicodeRange>,
1095    pub fallbacks: Vec<FontMatchNoFallback>,
1096}
1097
1098/// Font match result with UUID (without fallback)
1099#[derive(Debug, Clone, PartialEq, Eq)]
1100pub struct FontMatchNoFallback {
1101    pub id: FontId,
1102    pub unicode_ranges: Vec<UnicodeRange>,
1103}
1104
1105/// A run of text that uses the same font
1106/// Returned by FontFallbackChain::query_for_text()
1107#[derive(Debug, Clone, PartialEq, Eq)]
1108pub struct ResolvedFontRun {
1109    /// The text content of this run
1110    pub text: String,
1111    /// Start byte index in the original text
1112    pub start_byte: usize,
1113    /// End byte index in the original text (exclusive)
1114    pub end_byte: usize,
1115    /// The font to use for this run (None if no font found)
1116    pub font_id: Option<FontId>,
1117    /// Which CSS font-family this came from
1118    pub css_source: String,
1119}
1120
1121/// Resolved font fallback chain for a CSS font-family stack
1122/// This represents the complete chain of fonts to use for rendering text
1123#[derive(Debug, Clone, PartialEq, Eq)]
1124pub struct FontFallbackChain {
1125    /// CSS-based fallbacks: Each CSS font expanded to its system fallbacks
1126    /// Example: ["NotoSansJP" -> [Hiragino Sans, PingFang SC], "sans-serif" -> [Helvetica]]
1127    pub css_fallbacks: Vec<CssFallbackGroup>,
1128    
1129    /// Unicode-based fallbacks: Fonts added to cover missing Unicode ranges
1130    /// Only populated if css_fallbacks don't cover all requested characters
1131    pub unicode_fallbacks: Vec<FontMatch>,
1132    
1133    /// The original CSS font-family stack that was requested
1134    pub original_stack: Vec<String>,
1135}
1136
1137impl FontFallbackChain {
1138    /// Resolve which font should be used for a specific character
1139    /// Returns (FontId, css_source_name) where css_source_name indicates which CSS font matched
1140    /// Returns None if no font in the chain can render this character
1141    pub fn resolve_char(&self, cache: &FcFontCache, ch: char) -> Option<(FontId, String)> {
1142        let codepoint = ch as u32;
1143
1144        // Check CSS fallbacks in order
1145        for group in &self.css_fallbacks {
1146            for font in &group.fonts {
1147                let Some(meta) = cache.get_metadata_by_id(&font.id) else { continue };
1148                if meta.unicode_ranges.is_empty() {
1149                    continue; // No range info — don't assume it covers everything
1150                }
1151                if meta.unicode_ranges.iter().any(|r| codepoint >= r.start && codepoint <= r.end) {
1152                    return Some((font.id, group.css_name.clone()));
1153                }
1154            }
1155        }
1156
1157        // Check Unicode fallbacks
1158        for font in &self.unicode_fallbacks {
1159            let Some(meta) = cache.get_metadata_by_id(&font.id) else { continue };
1160            if meta.unicode_ranges.iter().any(|r| codepoint >= r.start && codepoint <= r.end) {
1161                return Some((font.id, "(unicode-fallback)".to_string()));
1162            }
1163        }
1164
1165        // WEB-LIFT LAST-RESORT (re-added 2026-06-03; the `with_memory_fonts` trap that
1166        // previously made touching this file fatal is now fixed by the byte-atomic remill
1167        // fork support). The lifted web path fails coverage-based resolution above for TWO
1168        // reasons that both mis-lift: the chain mis-builds to empty AND/OR `get_metadata_by_id`
1169        // (a HashMap<FontId,_> lookup) returns None in the lift. So instead of gating on the
1170        // chain being empty, fire whenever NOTHING matched above AND the cache holds exactly
1171        // the single registered fallback font — the headless/web case. This bypasses BOTH the
1172        // chain and the metadata HashMap, returning the only font's id directly. Native caches
1173        // hold many system fonts, so `len()==1` is false there → native is unaffected.
1174        let registered = cache.list();
1175        if registered.len() == 1 {
1176            return Some((registered[0].1, "(web-last-resort)".to_string()));
1177        }
1178
1179        None
1180    }
1181    
1182    /// Resolve all characters in a text string to their fonts
1183    /// Returns a vector of (character, FontId, css_source) tuples
1184    pub fn resolve_text(&self, cache: &FcFontCache, text: &str) -> Vec<(char, Option<(FontId, String)>)> {
1185        text.chars()
1186            .map(|ch| (ch, self.resolve_char(cache, ch)))
1187            .collect()
1188    }
1189    
1190    /// Query which fonts should be used for a text string, grouped by font
1191    /// Returns runs of consecutive characters that use the same font
1192    /// This is the main API for text shaping - call this to get font runs, then shape each run
1193    pub fn query_for_text(&self, cache: &FcFontCache, text: &str) -> Vec<ResolvedFontRun> {
1194        if text.is_empty() {
1195            return Vec::new();
1196        }
1197        
1198        let mut runs: Vec<ResolvedFontRun> = Vec::new();
1199        let mut current_font: Option<FontId> = None;
1200        let mut current_css_source: Option<String> = None;
1201        let mut current_start_byte: usize = 0;
1202        
1203        for (byte_idx, ch) in text.char_indices() {
1204            let resolved = self.resolve_char(cache, ch);
1205            let (font_id, css_source) = match &resolved {
1206                Some((id, source)) => (Some(*id), Some(source.clone())),
1207                None => (None, None),
1208            };
1209            
1210            // Check if we need to start a new run
1211            let font_changed = font_id != current_font;
1212            
1213            if font_changed && byte_idx > 0 {
1214                // Finalize the current run
1215                let run_text = &text[current_start_byte..byte_idx];
1216                runs.push(ResolvedFontRun {
1217                    text: run_text.to_string(),
1218                    start_byte: current_start_byte,
1219                    end_byte: byte_idx,
1220                    font_id: current_font,
1221                    css_source: current_css_source.clone().unwrap_or_default(),
1222                });
1223                current_start_byte = byte_idx;
1224            }
1225            
1226            current_font = font_id;
1227            current_css_source = css_source;
1228        }
1229        
1230        // Finalize the last run
1231        if current_start_byte < text.len() {
1232            let run_text = &text[current_start_byte..];
1233            runs.push(ResolvedFontRun {
1234                text: run_text.to_string(),
1235                start_byte: current_start_byte,
1236                end_byte: text.len(),
1237                font_id: current_font,
1238                css_source: current_css_source.unwrap_or_default(),
1239            });
1240        }
1241        
1242        runs
1243    }
1244}
1245
1246/// A group of fonts that are fallbacks for a single CSS font-family name
1247#[derive(Debug, Clone, PartialEq, Eq)]
1248pub struct CssFallbackGroup {
1249    /// The CSS font name (e.g., "NotoSansJP", "sans-serif")
1250    pub css_name: String,
1251    
1252    /// System fonts that match this CSS name
1253    /// First font in list is the best match
1254    pub fonts: Vec<FontMatch>,
1255}
1256
1257/// Cache key for font fallback chain queries
1258///
1259/// IMPORTANT: This key intentionally does NOT include per-text unicode
1260/// ranges — fallback chains are cached by CSS properties only. Different
1261/// texts with the same CSS font-stack share the same chain.
1262///
1263/// `scripts_hint_hash` distinguishes *which set of Unicode-fallback
1264/// scripts* the caller asked for. `None` means "the default set of 7
1265/// major scripts" (Cyrillic/Arabic/Devanagari/Hiragana/Katakana/CJK/Hangul,
1266/// back-compat behaviour of `resolve_font_chain`). `Some(h)` is a
1267/// stable hash of a caller-supplied script list so an ASCII-only
1268/// query doesn't collide with a CJK-aware one.
1269#[cfg(feature = "std")]
1270#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1271pub(crate) struct FontChainCacheKey {
1272    /// CSS font stack (expanded to OS-specific fonts)
1273    pub(crate) font_families: Vec<String>,
1274    /// Font weight
1275    pub(crate) weight: FcWeight,
1276    /// Font style flags
1277    pub(crate) italic: PatternMatch,
1278    pub(crate) oblique: PatternMatch,
1279    /// Hash of the caller-supplied script hint (or `None` for the default set).
1280    pub(crate) scripts_hint_hash: Option<u64>,
1281}
1282
1283/// Hash a `scripts_hint` slice into a stable u64 for use as a
1284/// [`FontChainCacheKey`] component. Order-insensitive: we sort a
1285/// local copy before hashing so `[CJK, Arabic]` and `[Arabic, CJK]`
1286/// key into the same cache slot.
1287#[cfg(feature = "std")]
1288fn hash_scripts_hint(ranges: &[UnicodeRange]) -> u64 {
1289    let mut sorted: Vec<UnicodeRange> = ranges.to_vec();
1290    sorted.sort();
1291    let mut buf = Vec::with_capacity(sorted.len() * 8);
1292    for r in &sorted {
1293        buf.extend_from_slice(&r.start.to_le_bytes());
1294        buf.extend_from_slice(&r.end.to_le_bytes());
1295    }
1296    crate::utils::content_hash_u64(&buf)
1297}
1298
1299/// Path to a font file
1300///
1301/// `bytes_hash` is a deterministic 64-bit hash of the file's full
1302/// byte contents (see [`crate::utils::content_hash_u64`]). All faces
1303/// of a given `.ttc` file share the same `bytes_hash`, and two
1304/// different paths pointing at the same file contents also do —
1305/// so the cache can share a single `Arc<[u8]>` across them via
1306/// [`FcFontCache::get_font_bytes`]. A value of `0` means "hash
1307/// not computed" (e.g. built from a filename-only scan, or loaded
1308/// from a legacy v1 disk cache); callers must treat `0` as opaque
1309/// and fall back to unshared reads.
1310#[derive(Debug, Clone, PartialOrd, Ord, PartialEq, Eq)]
1311#[cfg_attr(feature = "cache", derive(serde::Serialize, serde::Deserialize))]
1312#[repr(C)]
1313pub struct FcFontPath {
1314    pub path: String,
1315    pub font_index: usize,
1316    /// 64-bit content hash of the file's bytes. 0 = not computed.
1317    #[cfg_attr(feature = "cache", serde(default))]
1318    pub bytes_hash: u64,
1319}
1320
1321/// In-memory font data
1322#[derive(Debug, Clone, PartialEq, Eq)]
1323#[repr(C)]
1324pub struct FcFont {
1325    pub bytes: Vec<u8>,
1326    pub font_index: usize,
1327    pub id: String, // For identification in tests
1328}
1329
1330/// Owned font-source descriptor, returned by
1331/// [`FcFontCache::get_font_by_id`].
1332///
1333/// In v4.0 this was a borrowed enum (`FontSource<'a>` with refs into
1334/// the pattern map). With v4.1's shared-state cache, the map lives
1335/// behind an `RwLock`, so returning a reference would require the
1336/// caller to hold a read guard for the full lifetime of the result —
1337/// which bleeds the locking strategy into every call site. The owned
1338/// variant clones the small `FcFont` / `FcFontPath` struct and
1339/// releases the lock immediately. Bytes/mmap are not cloned — those
1340/// go through `get_font_bytes` which hands out `Arc<FontBytes>`.
1341#[derive(Debug, Clone)]
1342pub enum OwnedFontSource {
1343    /// Font loaded from memory (small metadata + owned `Vec<u8>`).
1344    Memory(FcFont),
1345    /// Font loaded from disk.
1346    Disk(FcFontPath),
1347}
1348
1349/// A handle to font bytes returned by [`FcFontCache::get_font_bytes`].
1350///
1351/// On disk, an `Mmap` is used so untouched pages don't count toward
1352/// process RSS. In-memory fonts (`FcFont`) come back as `Owned` since
1353/// they're already on the heap.
1354///
1355/// `FontBytes` derefs to `[u8]` and implements `AsRef<[u8]>`, so any
1356/// existing API that wants `&[u8]` (allsorts, ttf-parser, …) can
1357/// accept it without code changes.
1358///
1359/// Both variants are `Send + Sync` (mmaps and `Arc<[u8]>` are both
1360/// safe to share across threads).
1361#[cfg(feature = "std")]
1362pub enum FontBytes {
1363    /// Heap-owned bytes. Used for `FontSource::Memory` and as a
1364    /// fallback when mmap is unavailable.
1365    Owned(std::sync::Arc<[u8]>),
1366    /// File-backed mmap. Read-only; pages are demand-loaded by the
1367    /// kernel. Absent on wasm targets, where `mmapio` is unavailable
1368    /// (the optional dep is gated to `cfg(not(target_family="wasm"))`).
1369    #[cfg(not(target_family = "wasm"))]
1370    Mmapped(mmapio::Mmap),
1371}
1372
1373#[cfg(feature = "std")]
1374impl FontBytes {
1375    /// Borrow the underlying byte slice.
1376    #[inline]
1377    pub fn as_slice(&self) -> &[u8] {
1378        match self {
1379            FontBytes::Owned(arc) => arc,
1380            #[cfg(not(target_family = "wasm"))]
1381            FontBytes::Mmapped(m) => &m[..],
1382        }
1383    }
1384}
1385
1386#[cfg(feature = "std")]
1387impl core::ops::Deref for FontBytes {
1388    type Target = [u8];
1389    #[inline]
1390    fn deref(&self) -> &[u8] {
1391        self.as_slice()
1392    }
1393}
1394
1395#[cfg(feature = "std")]
1396impl AsRef<[u8]> for FontBytes {
1397    #[inline]
1398    fn as_ref(&self) -> &[u8] {
1399        self.as_slice()
1400    }
1401}
1402
1403#[cfg(feature = "std")]
1404impl core::fmt::Debug for FontBytes {
1405    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1406        let kind = match self {
1407            FontBytes::Owned(_) => "Owned",
1408            #[cfg(not(target_family = "wasm"))]
1409            FontBytes::Mmapped(_) => "Mmapped",
1410        };
1411        write!(f, "FontBytes::{}({} bytes)", kind, self.as_slice().len())
1412    }
1413}
1414
1415/// Open a font file as an mmap-backed [`FontBytes`]. Falls back to a
1416/// heap read if mmap fails (e.g. the file is on a network share that
1417/// doesn't support mmap, or we're on a target without `std`-mmap).
1418#[cfg(feature = "std")]
1419fn open_font_bytes_mmap(path: &str) -> Option<std::sync::Arc<FontBytes>> {
1420    use std::fs::File;
1421    use std::sync::Arc;
1422
1423    #[cfg(not(target_family = "wasm"))]
1424    {
1425        if let Ok(file) = File::open(path) {
1426            // Safety: `Mmap::map` requires that the file is not
1427            // mutated while mapped. For system fonts that's the
1428            // overwhelming common case; if a user replaces the file
1429            // we accept reading the snapshot we mapped earlier.
1430            if let Ok(mmap) = unsafe { mmapio::MmapOptions::new().map(&file) } {
1431                return Some(Arc::new(FontBytes::Mmapped(mmap)));
1432            }
1433        }
1434    }
1435    let bytes = std::fs::read(path).ok()?;
1436    Some(Arc::new(FontBytes::Owned(Arc::from(bytes))))
1437}
1438
1439/// A named font to be added to the font cache from memory.
1440/// This is the primary way to supply custom fonts to the application.
1441#[derive(Debug, Clone)]
1442pub struct NamedFont {
1443    /// Human-readable name for this font (e.g., "My Custom Font")
1444    pub name: String,
1445    /// The raw font file bytes (TTF, OTF, WOFF, WOFF2, TTC)
1446    pub bytes: Vec<u8>,
1447}
1448
1449impl NamedFont {
1450    /// Create a new named font from bytes
1451    pub fn new(name: impl Into<String>, bytes: Vec<u8>) -> Self {
1452        Self {
1453            name: name.into(),
1454            bytes,
1455        }
1456    }
1457}
1458
1459/// Font cache, initialized at startup.
1460///
1461/// Thread-safe, shared font cache.
1462///
1463/// As of v4.1 the cache internally owns its state via
1464/// `Arc<RwLock<FcFontCacheInner>>`: cloning an `FcFontCache` returns
1465/// a handle that shares the same underlying data. Writes by one holder
1466/// (typically the background builder inside `FcFontRegistry`) become
1467/// immediately visible to every other holder (layout engines,
1468/// shape-time resolvers, etc.).
1469///
1470/// Before 4.1 the clone deep-copied every map, so external holders
1471/// were frozen at the moment they took the snapshot — the mismatch
1472/// between "live registry cache" and "frozen font manager cache"
1473/// was the root of the silent-text regression when lazy scout mode
1474/// was enabled. The shared-state design eliminates that entire class
1475/// of staleness bugs by construction.
1476pub struct FcFontCache {
1477    pub(crate) shared: std::sync::Arc<FcFontCacheShared>,
1478}
1479
1480/// Shared interior of `FcFontCache`. Always accessed through an
1481/// `Arc` — never referenced directly by external callers.
1482// Internal lock wrapper for the cache state. Two implementations selected by feature:
1483//
1484// DEFAULT (general builds): backed by std `RwLock`. `read`/`write`/`lock` return
1485// `Result<_, Infallible>` for a uniform call site (a poisoned lock is recovered via
1486// `into_inner` — a memoisation cache is still valid to read after a panic).
1487//
1488// `single-thread-unsafe-locks` feature: a bare `UnsafeCell` with NO atomics; `read`/`write`/
1489// `lock` hand out a guard immediately. UNSOUND in a multi-threaded program — enable ONLY for a
1490// known single-threaded environment. Exists for the azul remill-lifted web backend
1491// (single-threaded wasm), where std's queue-based RwLock `lock_contended` path spins forever
1492// (no other thread ever unparks it) and hangs the layout solver.
1493
1494#[cfg(not(feature = "single-thread-unsafe-locks"))]
1495pub struct StLock<T> {
1496    lock: std::sync::RwLock<T>,
1497}
1498#[cfg(not(feature = "single-thread-unsafe-locks"))]
1499impl<T> core::fmt::Debug for StLock<T> {
1500    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1501        f.write_str("StLock(..)")
1502    }
1503}
1504#[cfg(not(feature = "single-thread-unsafe-locks"))]
1505impl<T> StLock<T> {
1506    pub fn new(v: T) -> Self {
1507        Self { lock: std::sync::RwLock::new(v) }
1508    }
1509    pub fn read(&self) -> Result<StReadGuard<'_, T>, core::convert::Infallible> {
1510        Ok(StReadGuard { g: self.lock.read().unwrap_or_else(|e| e.into_inner()) })
1511    }
1512    pub fn write(&self) -> Result<StWriteGuard<'_, T>, core::convert::Infallible> {
1513        Ok(StWriteGuard { g: self.lock.write().unwrap_or_else(|e| e.into_inner()) })
1514    }
1515    pub fn lock(&self) -> Result<StWriteGuard<'_, T>, core::convert::Infallible> {
1516        self.write()
1517    }
1518}
1519#[cfg(not(feature = "single-thread-unsafe-locks"))]
1520pub struct StReadGuard<'a, T> {
1521    g: std::sync::RwLockReadGuard<'a, T>,
1522}
1523#[cfg(not(feature = "single-thread-unsafe-locks"))]
1524impl<'a, T> core::ops::Deref for StReadGuard<'a, T> {
1525    type Target = T;
1526    fn deref(&self) -> &T { &self.g }
1527}
1528#[cfg(not(feature = "single-thread-unsafe-locks"))]
1529pub struct StWriteGuard<'a, T> {
1530    g: std::sync::RwLockWriteGuard<'a, T>,
1531}
1532#[cfg(not(feature = "single-thread-unsafe-locks"))]
1533impl<'a, T> core::ops::Deref for StWriteGuard<'a, T> {
1534    type Target = T;
1535    fn deref(&self) -> &T { &self.g }
1536}
1537#[cfg(not(feature = "single-thread-unsafe-locks"))]
1538impl<'a, T> core::ops::DerefMut for StWriteGuard<'a, T> {
1539    fn deref_mut(&mut self) -> &mut T { &mut self.g }
1540}
1541
1542#[cfg(feature = "single-thread-unsafe-locks")]
1543pub struct StLock<T> {
1544    cell: std::cell::UnsafeCell<T>,
1545}
1546#[cfg(feature = "single-thread-unsafe-locks")]
1547unsafe impl<T> Sync for StLock<T> {}
1548#[cfg(feature = "single-thread-unsafe-locks")]
1549unsafe impl<T> Send for StLock<T> {}
1550#[cfg(feature = "single-thread-unsafe-locks")]
1551impl<T> core::fmt::Debug for StLock<T> {
1552    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1553        f.write_str("StLock(..)")
1554    }
1555}
1556#[cfg(feature = "single-thread-unsafe-locks")]
1557impl<T> StLock<T> {
1558    pub fn new(v: T) -> Self {
1559        Self { cell: std::cell::UnsafeCell::new(v) }
1560    }
1561    pub fn read(&self) -> Result<StReadGuard<'_, T>, core::convert::Infallible> {
1562        Ok(StReadGuard { r: unsafe { &*self.cell.get() } })
1563    }
1564    pub fn write(&self) -> Result<StWriteGuard<'_, T>, core::convert::Infallible> {
1565        Ok(StWriteGuard { r: unsafe { &mut *self.cell.get() } })
1566    }
1567    pub fn lock(&self) -> Result<StWriteGuard<'_, T>, core::convert::Infallible> {
1568        Ok(StWriteGuard { r: unsafe { &mut *self.cell.get() } })
1569    }
1570}
1571#[cfg(feature = "single-thread-unsafe-locks")]
1572pub struct StReadGuard<'a, T> {
1573    r: &'a T,
1574}
1575#[cfg(feature = "single-thread-unsafe-locks")]
1576impl<'a, T> core::ops::Deref for StReadGuard<'a, T> {
1577    type Target = T;
1578    fn deref(&self) -> &T { self.r }
1579}
1580#[cfg(feature = "single-thread-unsafe-locks")]
1581pub struct StWriteGuard<'a, T> {
1582    r: &'a mut T,
1583}
1584#[cfg(feature = "single-thread-unsafe-locks")]
1585impl<'a, T> core::ops::Deref for StWriteGuard<'a, T> {
1586    type Target = T;
1587    fn deref(&self) -> &T { self.r }
1588}
1589#[cfg(feature = "single-thread-unsafe-locks")]
1590impl<'a, T> core::ops::DerefMut for StWriteGuard<'a, T> {
1591    fn deref_mut(&mut self) -> &mut T { self.r }
1592}
1593
1594pub(crate) struct FcFontCacheShared {
1595    /// Main pattern/metadata state, guarded by a reader-writer lock.
1596    /// Builder threads take the write lock to insert a parsed font;
1597    /// all query paths take the read lock.
1598    pub(crate) state: StLock<FcFontCacheInner>,
1599    /// Font fallback chain cache. Not part of the RwLock-guarded
1600    /// state because cache insertions happen under `&self` on read
1601    /// paths (they're a memoisation, not observable state).
1602    pub(crate) chain_cache: StLock<std::collections::HashMap<FontChainCacheKey, FontFallbackChain>>,
1603    /// Shared file-bytes cache: content-hash → weak [`FontBytes`].
1604    ///
1605    /// [`FcFontCache::get_font_bytes`] populates this so that multiple
1606    /// FontIds backed by the same file (e.g. every face of a `.ttc`)
1607    /// return the same `Arc<FontBytes>` — and therefore the same mmap
1608    /// — instead of each allocating their own buffer. We hold `Weak`
1609    /// references so the mmap unmap as soon as no parsed font holds
1610    /// it alive.
1611    pub(crate) shared_bytes: StLock<std::collections::HashMap<u64, std::sync::Weak<FontBytes>>>,
1612}
1613
1614/// The actual font-pattern state, held behind the RwLock in
1615/// `FcFontCacheShared`. Private — all access goes through
1616/// `FcFontCache` methods which lock transparently.
1617#[derive(Default, Debug)]
1618pub(crate) struct FcFontCacheInner {
1619    /// Pattern to FontId mapping (query index)
1620    pub(crate) patterns: BTreeMap<FcPattern, FontId>,
1621    /// On-disk font paths
1622    pub(crate) disk_fonts: BTreeMap<FontId, FcFontPath>,
1623    /// In-memory fonts
1624    pub(crate) memory_fonts: BTreeMap<FontId, FcFont>,
1625    /// Metadata cache (patterns stored by ID for quick lookup)
1626    pub(crate) metadata: BTreeMap<FontId, FcPattern>,
1627    /// Token index: maps lowercase tokens ("noto", "sans", "jp") to sets of FontIds.
1628    /// Enables fast fuzzy search by intersecting token sets.
1629    pub(crate) token_index: BTreeMap<String, alloc::collections::BTreeSet<FontId>>,
1630    /// Pre-tokenized font names (lowercase): FontId -> Vec<lowercase tokens>.
1631    /// Avoids re-tokenization during fuzzy search.
1632    pub(crate) font_tokens: BTreeMap<FontId, Vec<String>>,
1633    /// Normalized family/name -> the fonts that carry it.
1634    ///
1635    /// `query_by_family_normalized` used to answer "which fonts are called
1636    /// X?" by walking EVERY registered pattern and allocating a normalized
1637    /// `String` per face per call. That is O(fonts) with two allocations
1638    /// each, and it is the ONLY path a specific family name can take,
1639    /// because `index_pattern_tokens` is a no-op on the azul web fork so
1640    /// `fuzzy_query_by_name` always comes back empty. Measured from azul:
1641    /// ~0.52 ms per lookup, and a CSS stack with generic expansion asks
1642    /// ~150 times.
1643    ///
1644    /// Built once at insertion instead, so a lookup is a single map probe
1645    /// and a MISS costs nothing.
1646    pub(crate) family_index: BTreeMap<String, alloc::vec::Vec<FontId>>,
1647    /// System-configured family alias preferences, parsed from the
1648    /// platform font configuration (Linux: `$FONTCONFIG_FILE` or
1649    /// `/etc/fonts/fonts.conf` + included conf.d files, `<alias>` /
1650    /// `<prefer>` blocks). Keyed by the normalized alias family
1651    /// ("sans-serif", "arial", ...), values are the preferred concrete
1652    /// families in configuration order. THE authority for generic-family
1653    /// resolution: the hard-coded per-OS lists are only consulted when
1654    /// this map has no entry (e.g. no fontconfig installed).
1655    pub(crate) system_aliases: BTreeMap<String, Vec<String>>,
1656}
1657
1658impl FcFontCacheInner {
1659    /// Add a font pattern to the token index. Called under the
1660    /// write lock by insertion paths.
1661    /// Record `id` under the normalized spellings of its family and name.
1662    ///
1663    /// Deliberately NOT part of `index_pattern_tokens`: that one is a no-op
1664    /// on the azul web fork (its unicode tokenizer traps under the lift),
1665    /// and the family index must exist everywhere or every specific family
1666    /// name silently stops resolving. This only calls
1667    /// `normalize_family_name`, which the linear scan it replaces already
1668    /// ran once per font per lookup.
1669    pub(crate) fn index_pattern_family(&mut self, pattern: &FcPattern, id: FontId) {
1670        for key in [pattern.family.as_deref(), pattern.name.as_deref()]
1671            .into_iter()
1672            .flatten()
1673            .map(crate::utils::normalize_family_name)
1674            .filter(|k| !k.is_empty())
1675        {
1676            let slot = self.family_index.entry(key).or_default();
1677            if !slot.contains(&id) {
1678                slot.push(id);
1679            }
1680        }
1681    }
1682
1683    pub(crate) fn index_pattern_tokens(&mut self, _pattern: &FcPattern, _id: FontId) {
1684        // WEB-LIFT (2026-06-02): no-op on the azul web fork. The tokenizer
1685        // (`extract_font_name_tokens` char-classification + lowercasing) pulls unicode tables
1686        // whose jump-tables the remill/web lift leaves un-devirt'd → MISSING_BLOCK trap inside
1687        // `with_memory_fonts`. `token_index`/`font_tokens` feed ONLY the separate token-fuzzy
1688        // search path (query_fuzzy); the main `query`→`query_internal_locked` scores by
1689        // unicode-compatibility + style over the registered patterns/metadata (populated before
1690        // this call), so leaving the token index empty does not affect normal font matching.
1691    }
1692}
1693
1694impl Clone for FcFontCache {
1695    /// Shallow clone — the returned handle shares the same underlying
1696    /// state as `self`. Writes through either are visible to both.
1697    /// This is the whole point of the v4.1 redesign; callers that need
1698    /// an isolated frozen copy must explicitly request one (e.g. via
1699    /// `snapshot_state`, which is intentionally not provided because
1700    /// we no longer have a use case for it).
1701    fn clone(&self) -> Self {
1702        Self {
1703            shared: std::sync::Arc::clone(&self.shared),
1704        }
1705    }
1706}
1707
1708impl core::fmt::Debug for FcFontCache {
1709    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1710        let state = self.state_read();
1711        f.debug_struct("FcFontCache")
1712            .field("patterns_len", &state.patterns.len())
1713            .field("metadata_len", &state.metadata.len())
1714            .field("disk_fonts_len", &state.disk_fonts.len())
1715            .field("memory_fonts_len", &state.memory_fonts.len())
1716            .finish()
1717    }
1718}
1719
1720impl Default for FcFontCache {
1721    fn default() -> Self {
1722        Self {
1723            shared: std::sync::Arc::new(FcFontCacheShared {
1724                state: StLock::new(FcFontCacheInner::default()),
1725                chain_cache: StLock::new(std::collections::HashMap::new()),
1726                shared_bytes: StLock::new(std::collections::HashMap::new()),
1727            }),
1728        }
1729    }
1730}
1731
1732impl FcFontCache {
1733    /// The system-configured preferred families for `family` (normalized
1734    /// lookup), parsed from the platform font configuration at build time.
1735    /// Empty when the platform has no such configuration.
1736    pub fn system_alias_prefs(&self, family: &str) -> Vec<String> {
1737        let norm = crate::utils::normalize_family_name(family);
1738        self.state_read()
1739            .system_aliases
1740            .get(&norm)
1741            .cloned()
1742            .unwrap_or_default()
1743    }
1744
1745    /// Expand a CSS font-family stack, resolving each entry through the
1746    /// SYSTEM configuration first and only falling back to the built-in
1747    /// per-OS lists when the configuration is silent.
1748    ///
1749    /// Resolution per family, in order:
1750    /// 1. `<alias>`/`<prefer>` preferences parsed from the platform font
1751    ///    configuration (generic families like `sans-serif` AND named
1752    ///    substitutions like `Arial` -> `Liberation Sans`). The machine's
1753    ///    actual configuration is the authority — this is what real
1754    ///    fontconfig does, and what makes azul agree with every other
1755    ///    application on the box.
1756    /// 2. For generic families with no configured preference: the built-in
1757    ///    per-OS candidates ([`OperatingSystem::expand_generic_family`]) as
1758    ///    a LAST resort (containers without any fontconfig installed).
1759    /// 3. Named families always keep themselves FIRST, before any
1760    ///    configured substitution (CSS: exact match wins when present;
1761    ///    the alias only helps when the named family is missing).
1762    pub fn expand_font_families_config_first(
1763        &self,
1764        families: &[String],
1765        os: OperatingSystem,
1766        unicode_ranges: &[UnicodeRange],
1767    ) -> Vec<String> {
1768        let mut expanded: Vec<String> = Vec::new();
1769        let mut push_unique = |v: &mut Vec<String>, f: String| {
1770            if !v.iter().any(|e| e.eq_ignore_ascii_case(&f)) {
1771                v.push(f);
1772            }
1773        };
1774        for family in families {
1775            let is_generic = matches!(
1776                family.to_ascii_lowercase().as_str(),
1777                "serif" | "sans-serif" | "monospace" | "cursive" | "fantasy" | "system-ui"
1778            );
1779            if !is_generic {
1780                push_unique(&mut expanded, family.clone());
1781            }
1782            let prefs = self.system_alias_prefs(family);
1783            if !prefs.is_empty() {
1784                for pref in prefs {
1785                    push_unique(&mut expanded, pref);
1786                }
1787            } else if is_generic {
1788                for fallback in os.expand_generic_family(family, unicode_ranges) {
1789                    push_unique(&mut expanded, fallback);
1790                }
1791            }
1792        }
1793        expanded
1794    }
1795
1796    /// Acquire a read guard on the cache's state. Panics if the lock
1797    /// was poisoned by a panic inside the write guard — same
1798    /// contract as `RwLock::read().expect(..)`.
1799    #[inline]
1800    pub(crate) fn state_read(
1801        &self,
1802    ) -> StReadGuard<'_, FcFontCacheInner> {
1803        // [az-web-lift] StLock::read() is Infallible (never poisons/spins).
1804        match self.shared.state.read() {
1805            Ok(g) => g,
1806            Err(e) => match e {},
1807        }
1808    }
1809
1810    /// Acquire a write guard on the cache's state. Panics on
1811    /// poisoning, same as `state_read`.
1812    #[inline]
1813    pub(crate) fn state_write(
1814        &self,
1815    ) -> StWriteGuard<'_, FcFontCacheInner> {
1816        // [az-web-lift] StLock::write() is Infallible (never poisons/spins).
1817        match self.shared.state.write() {
1818            Ok(g) => g,
1819            Err(e) => match e {},
1820        }
1821    }
1822
1823    /// Adds in-memory font files.
1824    ///
1825    /// Note: takes `&self` — the shared cache handles interior
1826    /// mutability via the RwLock.
1827    pub fn with_memory_fonts(&self, fonts: Vec<(FcPattern, FcFont)>) -> &Self {
1828        // Auto-detect Unicode coverage for any naively-registered font
1829        // (empty `unicode_ranges`) BEFORE taking the write lock, so we don't
1830        // hold it across font parsing. See `populate_memory_font_ranges`.
1831        let fonts: Vec<(FcPattern, FcFont)> = fonts
1832            .into_iter()
1833            .map(|(pattern, font)| (Self::populate_memory_font_ranges(pattern, &font), font))
1834            .collect();
1835        let mut state = self.state_write();
1836        for (pattern, font) in fonts {
1837            let id = FontId::new();
1838            state.patterns.insert(pattern.clone(), id);
1839            state.metadata.insert(id, pattern.clone());
1840            state.memory_fonts.insert(id, font);
1841            state.index_pattern_tokens(&pattern, id);
1842                    state.index_pattern_family(&pattern, id);
1843        }
1844        self
1845    }
1846
1847    /// Adds a memory font with a specific ID (for testing).
1848    pub fn with_memory_font_with_id(
1849        &self,
1850        id: FontId,
1851        pattern: FcPattern,
1852        font: FcFont,
1853    ) -> &Self {
1854        let pattern = Self::populate_memory_font_ranges(pattern, &font);
1855        let mut state = self.state_write();
1856        state.patterns.insert(pattern.clone(), id);
1857        state.metadata.insert(id, pattern.clone());
1858        state.memory_fonts.insert(id, font);
1859        state.index_pattern_tokens(&pattern, id);
1860                    state.index_pattern_family(&pattern, id);
1861        self
1862    }
1863
1864    /// Fill in a memory font's `unicode_ranges` from its raw bytes when the
1865    /// caller left them empty.
1866    ///
1867    /// A normal caller of [`FcFontCache::with_memory_fonts`] just hands over
1868    /// a name and the font bytes — they don't hand-compute the cmap. But
1869    /// [`FontFallbackChain::resolve_char`] deliberately skips any font that
1870    /// reports *no* coverage (it refuses to assume a blank range list means
1871    /// "covers everything"). Without this step a naively-registered bundled
1872    /// font could never be selected for any character — the exact bug that
1873    /// bites headless / wasm / embedder-bundled-font setups.
1874    ///
1875    /// With the `parsing` feature we reuse the *same* OS/2 + cmap detection
1876    /// pipeline the on-disk builder uses (via [`FcParseFontBytes`] →
1877    /// `parse_font_faces`). Without `parsing` the pattern is returned
1878    /// unchanged and the caller must populate `unicode_ranges` themselves.
1879    #[cfg(all(feature = "std", feature = "parsing"))]
1880    fn populate_memory_font_ranges(mut pattern: FcPattern, font: &FcFont) -> FcPattern {
1881        if !pattern.unicode_ranges.is_empty() {
1882            return pattern;
1883        }
1884        if let Some(faces) = FcParseFontBytes(&font.bytes, &font.id) {
1885            // A `.ttc` yields several faces; pick the one matching this
1886            // font's index, else fall back to the first parsed face. All
1887            // patterns of a single face share the same `unicode_ranges`.
1888            let ranges = faces
1889                .iter()
1890                .find(|(_, f)| f.font_index == font.font_index)
1891                .or_else(|| faces.first())
1892                .map(|(p, _)| p.unicode_ranges.clone())
1893                .unwrap_or_default();
1894            if !ranges.is_empty() {
1895                pattern.unicode_ranges = ranges;
1896            }
1897        }
1898        pattern
1899    }
1900
1901    /// Without the `parsing` feature there is no cmap/OS2 parser available,
1902    /// so the caller-provided pattern is stored verbatim.
1903    #[cfg(not(all(feature = "std", feature = "parsing")))]
1904    fn populate_memory_font_ranges(pattern: FcPattern, _font: &FcFont) -> FcPattern {
1905        pattern
1906    }
1907
1908    /// Register a newly-parsed on-disk font. Called by the builder
1909    /// thread inside `FcFontRegistry`. Allocates a fresh `FontId`,
1910    /// inserts the pattern + path + metadata in one write lock, and
1911    /// invalidates the chain cache so subsequent resolutions pick
1912    /// up the new font.
1913    pub fn insert_builder_font(&self, pattern: FcPattern, path: FcFontPath) {
1914        let id = FontId::new();
1915        {
1916            let mut state = self.state_write();
1917            state.index_pattern_tokens(&pattern, id);
1918                    state.index_pattern_family(&pattern, id);
1919            state.patterns.insert(pattern.clone(), id);
1920            state.disk_fonts.insert(id, path);
1921            state.metadata.insert(id, pattern);
1922        }
1923        // Invalidate chain cache so callers see the new font on the
1924        // next resolve. Scoped after the state write to keep lock
1925        // nesting shallow.
1926        if let Ok(mut cc) = self.shared.chain_cache.lock() {
1927            cc.clear();
1928        }
1929    }
1930
1931    #[cfg(feature = "std")]
1932    #[doc(hidden)]
1933    pub fn chain_cache_len(&self) -> usize {
1934        self.shared.chain_cache.lock().map(|c| c.len()).unwrap_or(0)
1935    }
1936
1937    /// Insert a *fast-probed* pattern into the cache and return its
1938    /// fresh `FontId`. Used by [`FcFontRegistry::request_fonts_fast`]
1939    /// when a cmap probe discovers a font that covers some subset of
1940    /// the requested codepoints. Unlike [`insert_builder_font`] this
1941    /// does **not** populate the token index (we don't have NAME
1942    /// table data), so fuzzy-name lookups on fast-probed fonts fall
1943    /// through to the filename-guess in `known_paths`.
1944    pub fn insert_fast_pattern(&self, pattern: FcPattern, path: FcFontPath) -> FontId {
1945        let id = FontId::new();
1946        let mut state = self.state_write();
1947        state.patterns.insert(pattern.clone(), id);
1948        state.disk_fonts.insert(id, path);
1949        state.metadata.insert(id, pattern);
1950        id
1951    }
1952
1953    /// Look up all `FontId`s whose `FcFontPath` matches `path`.
1954    /// Cheap way for `request_fonts_fast` to reuse fast-probed
1955    /// entries across layout passes without re-reading the cmap.
1956    ///
1957    /// O(n) over the disk_fonts map; fine for the typical case of
1958    /// <100 parsed fonts, and we skip the scan entirely when a
1959    /// stack's first candidate covers.
1960    pub fn lookup_paths_cached(&self, path: &str) -> Option<Vec<FontId>> {
1961        let state = self.state_read();
1962        let mut out = Vec::new();
1963        for (id, font_path) in &state.disk_fonts {
1964            if font_path.path == path {
1965                out.push(*id);
1966            }
1967        }
1968        if out.is_empty() { None } else { Some(out) }
1969    }
1970
1971    /// Get font data for a given font ID.
1972    ///
1973    /// Returns owned values (not references) because the underlying
1974    /// maps live behind an RwLock — a reference could not outlive
1975    /// the read guard. In-memory fonts come back as cloned `FcFont`
1976    /// instances; disk fonts return their `FcFontPath`.
1977    pub fn get_font_by_id(&self, id: &FontId) -> Option<OwnedFontSource> {
1978        let state = self.state_read();
1979        if let Some(font) = state.memory_fonts.get(id) {
1980            return Some(OwnedFontSource::Memory(font.clone()));
1981        }
1982        if let Some(path) = state.disk_fonts.get(id) {
1983            return Some(OwnedFontSource::Disk(path.clone()));
1984        }
1985        None
1986    }
1987
1988    /// Get metadata for a font ID. Returns an owned `FcPattern`
1989    /// (cloned out of the shared map) because we can't return a
1990    /// reference across the RwLock boundary.
1991    pub fn get_metadata_by_id(&self, id: &FontId) -> Option<FcPattern> {
1992        self.state_read().metadata.get(id).cloned()
1993    }
1994
1995    /// Get the font bytes for `id` as a shared [`FontBytes`].
1996    ///
1997    /// On disk the returned `Arc<FontBytes>` wraps an mmap of the file
1998    /// (`FontBytes::Mmapped`). Untouched pages of the file never count
1999    /// toward the process's RSS — for a font where layout shapes only
2000    /// a handful of glyphs, this is the difference between paying for
2001    /// the whole 4 MiB `.ttc` and paying for the cmap + a few glyf
2002    /// pages.
2003    ///
2004    /// In-memory fonts (`FontSource::Memory`) come back as
2005    /// `FontBytes::Owned`, since the bytes are already on the heap.
2006    ///
2007    /// Multiple `FontId`s backed by the same file content (every face
2008    /// of a `.ttc`, or two paths with identical bytes) return the
2009    /// *same* `Arc<FontBytes>` thanks to a content-hash → `Weak`
2010    /// cache. Bytes get unmapped automatically when the last consumer
2011    /// drops the Arc.
2012    ///
2013    /// `FontBytes` derefs to `[u8]`, so callers that only need
2014    /// `&[u8]` (allsorts, ttf-parser, …) can pass it through without
2015    /// thinking about the backing.
2016    ///
2017    /// Failure modes: returns `None` if the path is unknown, or the
2018    /// file no longer exists / cannot be opened, or the mmap call
2019    /// fails. Callers may retry with a fresh `get_font_bytes` if they
2020    /// suspect the file was replaced underneath them; the next call
2021    /// re-opens cleanly.
2022    #[cfg(feature = "std")]
2023    pub fn get_font_bytes(&self, id: &FontId) -> Option<std::sync::Arc<FontBytes>> {
2024        use std::sync::Arc;
2025        match self.get_font_by_id(id)? {
2026            OwnedFontSource::Memory(font) => Some(Arc::new(FontBytes::Owned(
2027                Arc::from(font.bytes.as_slice()),
2028            ))),
2029            OwnedFontSource::Disk(path) => {
2030                let hash = path.bytes_hash;
2031                if hash != 0 {
2032                    if let Ok(guard) = self.shared.shared_bytes.lock() {
2033                        if let Some(weak) = guard.get(&hash) {
2034                            if let Some(arc) = weak.upgrade() {
2035                                return Some(arc);
2036                            }
2037                        }
2038                    }
2039                }
2040
2041                let arc = open_font_bytes_mmap(&path.path)?;
2042                if hash != 0 {
2043                    if let Ok(mut guard) = self.shared.shared_bytes.lock() {
2044                        // Overwrite any stale weak ref that failed to upgrade.
2045                        guard.insert(hash, Arc::downgrade(&arc));
2046                    }
2047                }
2048                Some(arc)
2049            }
2050        }
2051    }
2052
2053    /// Returns an empty font cache (no_std / no filesystem).
2054    #[cfg(not(feature = "std"))]
2055    pub fn build() -> Self { Self::default() }
2056
2057    /// Scans system font directories using filename heuristics (no allsorts).
2058    #[cfg(all(feature = "std", not(feature = "parsing")))]
2059    pub fn build() -> Self { Self::build_from_filenames() }
2060
2061    /// Scans and parses all system fonts via allsorts for full metadata.
2062    #[cfg(all(feature = "std", feature = "parsing"))]
2063    pub fn build() -> Self { Self::build_inner(None) }
2064
2065    /// Filename-only scan: discovers fonts on disk, guesses metadata from
2066    /// the filename using [`config::tokenize_font_stem`].
2067    #[cfg(all(feature = "std", not(feature = "parsing")))]
2068    fn build_from_filenames() -> Self {
2069        let cache = Self::default();
2070        {
2071            let mut state = cache.state_write();
2072            for dir in crate::config::font_directories(OperatingSystem::current()) {
2073                for path in FcCollectFontFilesRecursive(dir) {
2074                    let pattern = match pattern_from_filename(&path) {
2075                        Some(p) => p,
2076                        None => continue,
2077                    };
2078                    let id = FontId::new();
2079                    state.disk_fonts.insert(id, FcFontPath {
2080                        path: path.to_string_lossy().to_string(),
2081                        font_index: 0,
2082                        // Filename-only scan — we never read the bytes,
2083                        // so there's no dedup key. Leave as 0.
2084                        bytes_hash: 0,
2085                    });
2086                    state.index_pattern_tokens(&pattern, id);
2087                    state.index_pattern_family(&pattern, id);
2088                    state.metadata.insert(id, pattern.clone());
2089                    state.patterns.insert(pattern, id);
2090                }
2091            }
2092        }
2093        cache
2094    }
2095    
2096    /// Builds a font cache with only specific font families (and their fallbacks).
2097    /// 
2098    /// This is a performance optimization for applications that know ahead of time
2099    /// which fonts they need. Instead of scanning all system fonts (which can be slow
2100    /// on systems with many fonts), only fonts matching the specified families are loaded.
2101    /// 
2102    /// Generic family names like "sans-serif", "serif", "monospace" are expanded
2103    /// to OS-specific font names (e.g., "sans-serif" on macOS becomes "Helvetica Neue", 
2104    /// "San Francisco", etc.).
2105    /// 
2106    /// **Note**: This will NOT automatically load fallback fonts for scripts not covered
2107    /// by the requested families. If you need Arabic, CJK, or emoji support, either:
2108    /// - Add those families explicitly to the filter
2109    /// - Use `with_memory_fonts()` to add bundled fonts
2110    /// - Use `build()` to load all system fonts
2111    /// 
2112    /// # Arguments
2113    /// * `families` - Font family names to load (e.g., ["Arial", "sans-serif"])
2114    /// 
2115    /// # Example
2116    /// ```ignore
2117    /// // Only load Arial and sans-serif fallback fonts
2118    /// let cache = FcFontCache::build_with_families(&["Arial", "sans-serif"]);
2119    /// ```
2120    #[cfg(all(feature = "std", feature = "parsing"))]
2121    pub fn build_with_families(families: &[impl AsRef<str>]) -> Self {
2122        // Expand generic families to OS-specific names. This runs BEFORE the
2123        // cache exists, so only the built-in lists are available here — the
2124        // filter is a superset selector (which files to parse), not the
2125        // final resolution, which goes config-first at query time.
2126        let os = OperatingSystem::current();
2127        let mut target_families: Vec<String> = Vec::new();
2128        
2129        for family in families {
2130            let family_str = family.as_ref();
2131            let expanded = os.expand_generic_family(family_str, &[]);
2132            if expanded.is_empty() || (expanded.len() == 1 && expanded[0] == family_str) {
2133                target_families.push(family_str.to_string());
2134            } else {
2135                target_families.extend(expanded);
2136            }
2137        }
2138        
2139        Self::build_inner(Some(&target_families))
2140    }
2141    
2142    /// Inner build function that handles both filtered and unfiltered font loading.
2143    /// 
2144    /// # Arguments
2145    /// * `family_filter` - If Some, only load fonts matching these family names.
2146    ///                     If None, load all fonts.
2147    #[cfg(all(feature = "std", feature = "parsing"))]
2148    fn build_inner(family_filter: Option<&[String]>) -> Self {
2149        let cache = FcFontCache::default();
2150
2151        // Normalize filter families for matching
2152        let filter_normalized: Option<Vec<String>> = family_filter.map(|families| {
2153            families
2154                .iter()
2155                .map(|f| crate::utils::normalize_family_name(f))
2156                .collect()
2157        });
2158
2159        // Helper closure to check if a pattern matches the filter
2160        let matches_filter = |pattern: &FcPattern| -> bool {
2161            match &filter_normalized {
2162                None => true, // No filter = accept all
2163                Some(targets) => {
2164                    pattern.name.as_ref().map_or(false, |name| {
2165                        let name_norm = crate::utils::normalize_family_name(name);
2166                        targets.iter().any(|target| name_norm.contains(target))
2167                    }) || pattern.family.as_ref().map_or(false, |family| {
2168                        let family_norm = crate::utils::normalize_family_name(family);
2169                        targets.iter().any(|target| family_norm.contains(target))
2170                    })
2171                }
2172            }
2173        };
2174
2175        let mut state = cache.state_write();
2176
2177        #[cfg(target_os = "linux")]
2178        {
2179            if let Some((font_entries, render_configs, system_aliases)) = FcScanDirectories() {
2180                state.system_aliases = system_aliases;
2181                for (mut pattern, path) in font_entries {
2182                    if matches_filter(&pattern) {
2183                        // Apply per-font render config if a matching family rule exists
2184                        if let Some(family) = pattern.name.as_ref().or(pattern.family.as_ref()) {
2185                            if let Some(rc) = render_configs.get(family) {
2186                                pattern.render_config = rc.clone();
2187                            }
2188                        }
2189                        let id = FontId::new();
2190                        state.patterns.insert(pattern.clone(), id);
2191                        state.metadata.insert(id, pattern.clone());
2192                        state.disk_fonts.insert(id, path);
2193                        state.index_pattern_tokens(&pattern, id);
2194                    state.index_pattern_family(&pattern, id);
2195                    }
2196                }
2197            }
2198        }
2199
2200        #[cfg(target_os = "windows")]
2201        {
2202            let system_root = std::env::var("SystemRoot")
2203                .or_else(|_| std::env::var("WINDIR"))
2204                .unwrap_or_else(|_| "C:\\Windows".to_string());
2205
2206            let user_profile = std::env::var("USERPROFILE")
2207                .unwrap_or_else(|_| "C:\\Users\\Default".to_string());
2208
2209            let font_dirs = vec![
2210                (None, format!("{}\\Fonts\\", system_root)),
2211                (None, format!("{}\\AppData\\Local\\Microsoft\\Windows\\Fonts\\", user_profile)),
2212            ];
2213
2214            let font_entries = FcScanDirectoriesInner(&font_dirs);
2215            for (pattern, path) in font_entries {
2216                if matches_filter(&pattern) {
2217                    let id = FontId::new();
2218                    state.patterns.insert(pattern.clone(), id);
2219                    state.metadata.insert(id, pattern.clone());
2220                    state.disk_fonts.insert(id, path);
2221                    state.index_pattern_tokens(&pattern, id);
2222                    state.index_pattern_family(&pattern, id);
2223                }
2224            }
2225        }
2226
2227        #[cfg(target_os = "macos")]
2228        {
2229            let font_dirs = vec![
2230                (None, "~/Library/Fonts".to_owned()),
2231                (None, "/System/Library/Fonts".to_owned()),
2232                (None, "/Library/Fonts".to_owned()),
2233                (None, "/System/Library/AssetsV2".to_owned()),
2234            ];
2235
2236            let font_entries = FcScanDirectoriesInner(&font_dirs);
2237            for (pattern, path) in font_entries {
2238                if matches_filter(&pattern) {
2239                    let id = FontId::new();
2240                    state.patterns.insert(pattern.clone(), id);
2241                    state.metadata.insert(id, pattern.clone());
2242                    state.disk_fonts.insert(id, path);
2243                    state.index_pattern_tokens(&pattern, id);
2244                    state.index_pattern_family(&pattern, id);
2245                }
2246            }
2247        }
2248
2249        // iOS: the app sandbox denies a plain `read_dir` on `/System/Library/...`,
2250        // but `CTFontManagerCopyAvailableFontURLs` returns sandbox-mediated
2251        // `CFURL`s that *are* openable. We enumerate via CoreText, then feed
2252        // each URL into the same `FcParseFont` path the desktop arms use.
2253        #[cfg(target_os = "ios")]
2254        {
2255            let font_files = crate::mobile_ios::copy_available_font_urls();
2256            let font_entries = FcParseFontFiles(&font_files);
2257            for (pattern, path) in font_entries {
2258                if matches_filter(&pattern) {
2259                    let id = FontId::new();
2260                    state.patterns.insert(pattern.clone(), id);
2261                    state.metadata.insert(id, pattern.clone());
2262                    state.disk_fonts.insert(id, path);
2263                    state.index_pattern_tokens(&pattern, id);
2264                    state.index_pattern_family(&pattern, id);
2265                }
2266            }
2267        }
2268
2269        // Android: system fonts live at world-readable paths. Vendor partitions
2270        // (`/product/fonts`, `/system_ext/fonts`) carry OEM-specific families
2271        // on Samsung One UI / MIUI / EMUI; `/data/fonts` is the per-user font
2272        // dir on recent ROMs.
2273        #[cfg(target_os = "android")]
2274        {
2275            let font_dirs = vec![
2276                (None, "/system/fonts".to_owned()),
2277                (None, "/product/fonts".to_owned()),
2278                (None, "/system_ext/fonts".to_owned()),
2279                (None, "/data/fonts".to_owned()),
2280            ];
2281
2282            let font_entries = FcScanDirectoriesInner(&font_dirs);
2283            for (pattern, path) in font_entries {
2284                if matches_filter(&pattern) {
2285                    let id = FontId::new();
2286                    state.patterns.insert(pattern.clone(), id);
2287                    state.metadata.insert(id, pattern.clone());
2288                    state.disk_fonts.insert(id, path);
2289                    state.index_pattern_tokens(&pattern, id);
2290                    state.index_pattern_family(&pattern, id);
2291                }
2292            }
2293        }
2294
2295        drop(state);
2296        cache
2297    }
2298    
2299    /// Check if a font ID is a memory font (preferred over disk fonts)
2300    pub fn is_memory_font(&self, id: &FontId) -> bool {
2301        self.state_read().memory_fonts.contains_key(id)
2302    }
2303
2304    /// Returns the list of fonts and font patterns.
2305    ///
2306    /// Returns owned `FcPattern` values (cloned out of the shared
2307    /// state) — this is the v4.1 API change described on
2308    /// [`FcFontCache`]. Callers that need to iterate without
2309    /// cloning should use [`FcFontCache::for_each_pattern`].
2310    pub fn list(&self) -> Vec<(FcPattern, FontId)> {
2311        self.state_read()
2312            .patterns
2313            .iter()
2314            .map(|(pattern, id)| (pattern.clone(), *id))
2315            .collect()
2316    }
2317
2318    /// Iterate over every `(pattern, id)` pair under a single read
2319    /// guard. `f` is called once per entry — avoids the per-entry
2320    /// clone that [`list`] incurs.
2321    pub fn for_each_pattern<F: FnMut(&FcPattern, &FontId)>(&self, mut f: F) {
2322        let state = self.state_read();
2323        for (pattern, id) in &state.patterns {
2324            f(pattern, id);
2325        }
2326    }
2327
2328    /// Returns true if the cache contains no font patterns
2329    pub fn is_empty(&self) -> bool {
2330        self.state_read().patterns.is_empty()
2331    }
2332
2333    /// Returns the number of font patterns in the cache
2334    pub fn len(&self) -> usize {
2335        self.state_read().patterns.len()
2336    }
2337
2338    /// Like [`FcFontCache::query`], but **total**: it returns `None` only when the
2339    /// cache holds no fonts at all.
2340    ///
2341    /// This is the `fc-match` contract. `fc-match` never fails — fontconfig
2342    /// substitutes through its config chain, which is why `fc-match Cantarell`
2343    /// answers with e.g. `NotoSans-Regular.ttf` on a machine that has no
2344    /// Cantarell. [`FcFontCache::query`] deliberately does NOT do that: it is the
2345    /// honest "was this exact request satisfiable?" answer, and a caller that
2346    /// wants to report an unresolved family needs it.
2347    ///
2348    /// A *rendering* caller must use this one instead. Handing a renderer `None`
2349    /// means one of two things, and both are bugs the caller usually discovers
2350    /// far from here: text silently vanishes, or the caller invents its own
2351    /// fallback whose font is not registered where the renderer later looks it
2352    /// up by hash — so layout succeeds and rendering cannot resolve what layout
2353    /// produced.
2354    ///
2355    /// Resolution order, mirroring fontconfig's own relaxation:
2356    ///   1. the pattern exactly as given;
2357    ///   2. the same pattern with `name`/`family` cleared — keeps weight, slant,
2358    ///      monospace and the requested unicode coverage, so a Bold request does
2359    ///      not silently become Regular;
2360    ///   3. coverage only — the last-resort "any font that can draw this text".
2361    ///
2362    /// Each step is a strictly wider query than the last, so this never returns a
2363    /// *worse* match than `query` would have.
2364    pub fn query_with_fallback(
2365        &self,
2366        pattern: &FcPattern,
2367        trace: &mut Vec<TraceMsg>,
2368    ) -> Option<FontMatch> {
2369        if let Some(m) = self.query(pattern, trace) {
2370            return Some(m);
2371        }
2372
2373        // 2. Drop the family/name constraint, keep how it should LOOK.
2374        if pattern.name.is_some() || pattern.family.is_some() {
2375            let relaxed = FcPattern {
2376                name: None,
2377                family: None,
2378                ..pattern.clone()
2379            };
2380            if let Some(m) = self.query(&relaxed, trace) {
2381                return Some(m);
2382            }
2383        }
2384
2385        // 3. Coverage only. Anything that can render the requested ranges.
2386        let bare = FcPattern {
2387            unicode_ranges: pattern.unicode_ranges.clone(),
2388            ..FcPattern::default()
2389        };
2390        self.query(&bare, trace)
2391    }
2392
2393    /// Queries a font from the in-memory cache, returns the first found font (early return)
2394    /// Memory fonts are always preferred over disk fonts with the same match quality.
2395    ///
2396    /// This is FALLIBLE by design — see [`FcFontCache::query_with_fallback`] for the
2397    /// `fc-match`-style total variant that a renderer should use.
2398    pub fn query(&self, pattern: &FcPattern, trace: &mut Vec<TraceMsg>) -> Option<FontMatch> {
2399        let state = self.state_read();
2400        let mut matches = Vec::new();
2401
2402        for (stored_pattern, id) in &state.patterns {
2403            if Self::query_matches_internal(stored_pattern, pattern, trace) {
2404                let metadata = state.metadata.get(id).unwrap_or(stored_pattern);
2405
2406                // Calculate Unicode compatibility score
2407                let unicode_compatibility = if pattern.unicode_ranges.is_empty() {
2408                    // No specific Unicode requirements, use general coverage
2409                    Self::calculate_unicode_coverage(&metadata.unicode_ranges) as i32
2410                } else {
2411                    // Calculate how well this font covers the requested Unicode ranges
2412                    Self::calculate_unicode_compatibility(&pattern.unicode_ranges, &metadata.unicode_ranges)
2413                };
2414
2415                let style_score = Self::calculate_style_score(pattern, metadata);
2416
2417                // Memory fonts get a bonus to prefer them over disk fonts
2418                let is_memory = state.memory_fonts.contains_key(id);
2419
2420                matches.push((*id, unicode_compatibility, style_score, metadata.clone(), is_memory));
2421            }
2422        }
2423
2424        // Sort by: 1. Memory font (preferred), 2. Unicode compatibility, 3. Style score
2425        matches.sort_by(|a, b| {
2426            // Memory fonts first
2427            b.4.cmp(&a.4)
2428                .then_with(|| b.1.cmp(&a.1)) // Unicode compatibility (higher is better)
2429                .then_with(|| a.2.cmp(&b.2)) // Style score (lower is better)
2430        });
2431
2432        matches.first().map(|(id, _, _, metadata, _)| {
2433            FontMatch {
2434                id: *id,
2435                unicode_ranges: metadata.unicode_ranges.clone(),
2436                fallbacks: Vec::new(), // Fallbacks computed lazily via compute_fallbacks()
2437            }
2438        })
2439    }
2440
2441    /// Queries all fonts matching a pattern (internal use only).
2442    ///
2443    /// Note: This function is now private. Use resolve_font_chain() to build a font fallback chain,
2444    /// then call FontFallbackChain::query_for_text() to resolve fonts for specific text.
2445    fn query_internal(&self, pattern: &FcPattern, trace: &mut Vec<TraceMsg>) -> Vec<FontMatch> {
2446        let state = self.state_read();
2447        self.query_internal_locked(&state, pattern, trace)
2448    }
2449
2450    /// Internal variant used when the caller already holds a read
2451    /// guard on the state. Avoids re-locking.
2452    fn query_internal_locked(
2453        &self,
2454        state: &FcFontCacheInner,
2455        pattern: &FcPattern,
2456        trace: &mut Vec<TraceMsg>,
2457    ) -> Vec<FontMatch> {
2458        let mut matches = Vec::new();
2459
2460        for (stored_pattern, id) in &state.patterns {
2461            if Self::query_matches_internal(stored_pattern, pattern, trace) {
2462                let metadata = state.metadata.get(id).unwrap_or(stored_pattern);
2463
2464                // Calculate Unicode compatibility score
2465                let unicode_compatibility = if pattern.unicode_ranges.is_empty() {
2466                    Self::calculate_unicode_coverage(&metadata.unicode_ranges) as i32
2467                } else {
2468                    Self::calculate_unicode_compatibility(&pattern.unicode_ranges, &metadata.unicode_ranges)
2469                };
2470
2471                let style_score = Self::calculate_style_score(pattern, metadata);
2472                matches.push((*id, unicode_compatibility, style_score, metadata.clone()));
2473            }
2474        }
2475
2476        // Sort by style score (lowest first), THEN by Unicode compatibility (highest first)
2477        // Style matching (weight, italic, etc.) is now the primary criterion
2478        // Deterministic tiebreaker: prefer non-italic, then alphabetical by name
2479        matches.sort_by(|a, b| {
2480            a.2.cmp(&b.2) // Style score (lower is better)
2481                .then_with(|| b.1.cmp(&a.1)) // Unicode compatibility (higher is better)
2482                .then_with(|| a.3.italic.cmp(&b.3.italic)) // Prefer non-italic
2483                .then_with(|| a.3.name.cmp(&b.3.name)) // Alphabetical tiebreaker
2484        });
2485
2486        matches
2487            .into_iter()
2488            .map(|(id, _, _, metadata)| {
2489                FontMatch {
2490                    id,
2491                    unicode_ranges: metadata.unicode_ranges.clone(),
2492                    fallbacks: Vec::new(), // Fallbacks computed lazily via compute_fallbacks()
2493                }
2494            })
2495            .collect()
2496    }
2497
2498    /// Compute fallback fonts for a given font
2499    /// This is a lazy operation that can be expensive - only call when actually needed
2500    /// (e.g., for FFI or debugging, not needed for resolve_char)
2501    pub fn compute_fallbacks(
2502        &self,
2503        font_id: &FontId,
2504        trace: &mut Vec<TraceMsg>,
2505    ) -> Vec<FontMatchNoFallback> {
2506        let state = self.state_read();
2507        let pattern = match state.metadata.get(font_id) {
2508            Some(p) => p.clone(),
2509            None => return Vec::new(),
2510        };
2511        drop(state);
2512
2513        self.compute_fallbacks_for_pattern(&pattern, Some(font_id), trace)
2514    }
2515
2516    fn compute_fallbacks_for_pattern(
2517        &self,
2518        pattern: &FcPattern,
2519        exclude_id: Option<&FontId>,
2520        _trace: &mut Vec<TraceMsg>,
2521    ) -> Vec<FontMatchNoFallback> {
2522        let state = self.state_read();
2523        let mut candidates = Vec::new();
2524
2525        // Collect all potential fallbacks (excluding original pattern)
2526        for (stored_pattern, id) in &state.patterns {
2527            // Skip if this is the original font
2528            if exclude_id.is_some() && exclude_id.unwrap() == id {
2529                continue;
2530            }
2531
2532            // Check if this font supports any of the unicode ranges
2533            if !stored_pattern.unicode_ranges.is_empty() && !pattern.unicode_ranges.is_empty() {
2534                // Calculate Unicode compatibility
2535                let unicode_compatibility = Self::calculate_unicode_compatibility(
2536                    &pattern.unicode_ranges,
2537                    &stored_pattern.unicode_ranges
2538                );
2539
2540                // Only include if there's actual overlap
2541                if unicode_compatibility > 0 {
2542                    let style_score = Self::calculate_style_score(pattern, stored_pattern);
2543                    candidates.push((
2544                        FontMatchNoFallback {
2545                            id: *id,
2546                            unicode_ranges: stored_pattern.unicode_ranges.clone(),
2547                        },
2548                        unicode_compatibility,
2549                        style_score,
2550                        stored_pattern.clone(),
2551                    ));
2552                }
2553            } else if pattern.unicode_ranges.is_empty() && !stored_pattern.unicode_ranges.is_empty() {
2554                // No specific Unicode requirements, use general coverage
2555                let coverage = Self::calculate_unicode_coverage(&stored_pattern.unicode_ranges) as i32;
2556                let style_score = Self::calculate_style_score(pattern, stored_pattern);
2557                candidates.push((
2558                    FontMatchNoFallback {
2559                        id: *id,
2560                        unicode_ranges: stored_pattern.unicode_ranges.clone(),
2561                    },
2562                    coverage,
2563                    style_score,
2564                    stored_pattern.clone(),
2565                ));
2566            }
2567        }
2568
2569        drop(state);
2570
2571        // Sort by Unicode compatibility (highest first), THEN by style score (lowest first)
2572        candidates.sort_by(|a, b| {
2573            b.1.cmp(&a.1)
2574                .then_with(|| a.2.cmp(&b.2))
2575        });
2576
2577        // Deduplicate by keeping only the best match per unique unicode range
2578        let mut seen_ranges = Vec::new();
2579        let mut deduplicated = Vec::new();
2580
2581        for (id, _, _, pattern) in candidates {
2582            let mut is_new_range = false;
2583
2584            for range in &pattern.unicode_ranges {
2585                if !seen_ranges.iter().any(|r: &UnicodeRange| r.overlaps(range)) {
2586                    seen_ranges.push(*range);
2587                    is_new_range = true;
2588                }
2589            }
2590
2591            if is_new_range {
2592                deduplicated.push(id);
2593            }
2594        }
2595
2596        deduplicated
2597    }
2598
2599    /// Get in-memory font data (cloned out of the shared state).
2600    pub fn get_memory_font(&self, id: &FontId) -> Option<FcFont> {
2601        self.state_read().memory_fonts.get(id).cloned()
2602    }
2603
2604    /// Check if a pattern matches the query, with detailed tracing
2605    fn trace_path(k: &FcPattern) -> String {
2606        k.name.as_ref().cloned().unwrap_or_else(|| "<unknown>".to_string())
2607    }
2608
2609    pub fn query_matches_internal(
2610        k: &FcPattern,
2611        pattern: &FcPattern,
2612        trace: &mut Vec<TraceMsg>,
2613    ) -> bool {
2614        // Check name - substring match
2615        if let Some(ref name) = pattern.name {
2616            if !k.name.as_ref().map_or(false, |kn| kn.contains(name)) {
2617                trace.push(TraceMsg {
2618                    level: TraceLevel::Info,
2619                    path: Self::trace_path(k),
2620                    reason: MatchReason::NameMismatch {
2621                        requested: pattern.name.clone(),
2622                        found: k.name.clone(),
2623                    },
2624                });
2625                return false;
2626            }
2627        }
2628
2629        // Check family - substring match
2630        if let Some(ref family) = pattern.family {
2631            if !k.family.as_ref().map_or(false, |kf| kf.contains(family)) {
2632                trace.push(TraceMsg {
2633                    level: TraceLevel::Info,
2634                    path: Self::trace_path(k),
2635                    reason: MatchReason::FamilyMismatch {
2636                        requested: pattern.family.clone(),
2637                        found: k.family.clone(),
2638                    },
2639                });
2640                return false;
2641            }
2642        }
2643
2644        // Check style properties
2645        let style_properties = [
2646            (
2647                "italic",
2648                pattern.italic.needs_to_match(),
2649                pattern.italic.matches(&k.italic),
2650            ),
2651            (
2652                "oblique",
2653                pattern.oblique.needs_to_match(),
2654                pattern.oblique.matches(&k.oblique),
2655            ),
2656            (
2657                "bold",
2658                pattern.bold.needs_to_match(),
2659                pattern.bold.matches(&k.bold),
2660            ),
2661            (
2662                "monospace",
2663                pattern.monospace.needs_to_match(),
2664                pattern.monospace.matches(&k.monospace),
2665            ),
2666            (
2667                "condensed",
2668                pattern.condensed.needs_to_match(),
2669                pattern.condensed.matches(&k.condensed),
2670            ),
2671        ];
2672
2673        for (property_name, needs_to_match, matches) in style_properties {
2674            if needs_to_match && !matches {
2675                let (requested, found) = match property_name {
2676                    "italic" => (format!("{:?}", pattern.italic), format!("{:?}", k.italic)),
2677                    "oblique" => (format!("{:?}", pattern.oblique), format!("{:?}", k.oblique)),
2678                    "bold" => (format!("{:?}", pattern.bold), format!("{:?}", k.bold)),
2679                    "monospace" => (
2680                        format!("{:?}", pattern.monospace),
2681                        format!("{:?}", k.monospace),
2682                    ),
2683                    "condensed" => (
2684                        format!("{:?}", pattern.condensed),
2685                        format!("{:?}", k.condensed),
2686                    ),
2687                    _ => (String::new(), String::new()),
2688                };
2689
2690                trace.push(TraceMsg {
2691                    level: TraceLevel::Info,
2692                    path: Self::trace_path(k),
2693                    reason: MatchReason::StyleMismatch {
2694                        property: property_name,
2695                        requested,
2696                        found,
2697                    },
2698                });
2699                return false;
2700            }
2701        }
2702
2703        // Check weight - hard filter if non-normal weight is requested
2704        if pattern.weight != FcWeight::Normal && pattern.weight != k.weight {
2705            trace.push(TraceMsg {
2706                level: TraceLevel::Info,
2707                path: Self::trace_path(k),
2708                reason: MatchReason::WeightMismatch {
2709                    requested: pattern.weight,
2710                    found: k.weight,
2711                },
2712            });
2713            return false;
2714        }
2715
2716        // Check stretch - hard filter if non-normal stretch is requested
2717        if pattern.stretch != FcStretch::Normal && pattern.stretch != k.stretch {
2718            trace.push(TraceMsg {
2719                level: TraceLevel::Info,
2720                path: Self::trace_path(k),
2721                reason: MatchReason::StretchMismatch {
2722                    requested: pattern.stretch,
2723                    found: k.stretch,
2724                },
2725            });
2726            return false;
2727        }
2728
2729        // Check unicode ranges if specified
2730        if !pattern.unicode_ranges.is_empty() {
2731            let mut has_overlap = false;
2732
2733            for p_range in &pattern.unicode_ranges {
2734                for k_range in &k.unicode_ranges {
2735                    if p_range.overlaps(k_range) {
2736                        has_overlap = true;
2737                        break;
2738                    }
2739                }
2740                if has_overlap {
2741                    break;
2742                }
2743            }
2744
2745            if !has_overlap {
2746                trace.push(TraceMsg {
2747                    level: TraceLevel::Info,
2748                    path: Self::trace_path(k),
2749                    reason: MatchReason::UnicodeRangeMismatch {
2750                        character: '\0', // No specific character to report
2751                        ranges: k.unicode_ranges.clone(),
2752                    },
2753                });
2754                return false;
2755            }
2756        }
2757
2758        true
2759    }
2760    
2761    /// Resolve a complete font fallback chain for a CSS font-family stack
2762    /// This is the main entry point for font resolution with caching
2763    /// Automatically expands generic CSS families (serif, sans-serif, monospace) to OS-specific fonts
2764    /// 
2765    /// # Arguments
2766    /// * `font_families` - CSS font-family stack (e.g., ["Arial", "sans-serif"])
2767    /// * `text` - The text to render (used to extract Unicode ranges)
2768    /// * `weight` - Font weight
2769    /// * `italic` - Italic style requirement
2770    /// * `oblique` - Oblique style requirement
2771    /// * `trace` - Debug trace messages
2772    /// 
2773    /// # Returns
2774    /// A complete font fallback chain with CSS fallbacks and Unicode fallbacks
2775    /// 
2776    /// # Example
2777    /// ```no_run
2778    /// # use rust_fontconfig::{FcFontCache, FcWeight, PatternMatch};
2779    /// let cache = FcFontCache::build();
2780    /// let families = vec!["Arial".to_string(), "sans-serif".to_string()];
2781    /// let chain = cache.resolve_font_chain(&families, FcWeight::Normal, 
2782    ///                                       PatternMatch::DontCare, PatternMatch::DontCare, 
2783    ///                                       &mut Vec::new());
2784    /// // On macOS: families expanded to ["Arial", "San Francisco", "Helvetica Neue", "Lucida Grande"]
2785    /// ```
2786    #[cfg(feature = "std")]
2787    pub fn resolve_font_chain(
2788        &self,
2789        font_families: &[String],
2790        weight: FcWeight,
2791        italic: PatternMatch,
2792        oblique: PatternMatch,
2793        trace: &mut Vec<TraceMsg>,
2794    ) -> FontFallbackChain {
2795        self.resolve_font_chain_with_os(font_families, weight, italic, oblique, trace, OperatingSystem::current())
2796    }
2797    
2798    /// Resolve font chain with explicit OS specification (useful for testing)
2799    #[cfg(feature = "std")]
2800    pub fn resolve_font_chain_with_os(
2801        &self,
2802        font_families: &[String],
2803        weight: FcWeight,
2804        italic: PatternMatch,
2805        oblique: PatternMatch,
2806        trace: &mut Vec<TraceMsg>,
2807        os: OperatingSystem,
2808    ) -> FontFallbackChain {
2809        self.resolve_font_chain_impl(font_families, weight, italic, oblique, None, trace, os)
2810    }
2811
2812    /// Resolve a font fallback chain, restricting Unicode fallbacks to the
2813    /// caller-supplied set of scripts (usually derived from the actual
2814    /// text content of the document).
2815    ///
2816    /// - `scripts_hint: None` → back-compat behaviour, equivalent to
2817    ///   [`FcFontCache::resolve_font_chain`]: pulls in fallback fonts for
2818    ///   the full [`DEFAULT_UNICODE_FALLBACK_SCRIPTS`] set.
2819    /// - `scripts_hint: Some(&[])` → no Unicode fallbacks attached. For
2820    ///   an ASCII-only page this avoids pulling Arial Unicode MS,
2821    ///   CJK fonts, etc. into memory when they're not needed.
2822    /// - `scripts_hint: Some(&[CJK])` → only CJK fallback attached.
2823    ///
2824    /// The chain cache is keyed so an ASCII-only resolution cannot be
2825    /// served from a slot populated by a default/all-scripts resolution.
2826    #[cfg(feature = "std")]
2827    pub fn resolve_font_chain_with_scripts(
2828        &self,
2829        font_families: &[String],
2830        weight: FcWeight,
2831        italic: PatternMatch,
2832        oblique: PatternMatch,
2833        scripts_hint: Option<&[UnicodeRange]>,
2834        trace: &mut Vec<TraceMsg>,
2835    ) -> FontFallbackChain {
2836        self.resolve_font_chain_impl(
2837            font_families, weight, italic, oblique, scripts_hint,
2838            trace, OperatingSystem::current(),
2839        )
2840    }
2841
2842    /// Shared entry used by [`resolve_font_chain_with_os`] and
2843    /// [`resolve_font_chain_with_scripts`]. Handles the cache lookup,
2844    /// generic-family expansion, and delegation to the uncached builder.
2845    #[cfg(feature = "std")]
2846    fn resolve_font_chain_impl(
2847        &self,
2848        font_families: &[String],
2849        weight: FcWeight,
2850        italic: PatternMatch,
2851        oblique: PatternMatch,
2852        scripts_hint: Option<&[UnicodeRange]>,
2853        trace: &mut Vec<TraceMsg>,
2854        os: OperatingSystem,
2855    ) -> FontFallbackChain {
2856        // Check cache FIRST - key uses original (unexpanded) families
2857        // plus a hash over the scripts_hint so ASCII-only callers don't
2858        // consume a slot filled by a default-scripts caller.
2859        let scripts_hint_hash = scripts_hint.map(hash_scripts_hint);
2860        let cache_key = FontChainCacheKey {
2861            font_families: font_families.to_vec(),
2862            weight,
2863            italic,
2864            oblique,
2865            scripts_hint_hash,
2866        };
2867
2868        if let Some(cached) = self
2869            .shared
2870            .chain_cache
2871            .lock()
2872            .ok()
2873            .and_then(|c| c.get(&cache_key).cloned())
2874        {
2875            return cached;
2876        }
2877
2878        // Expand generic CSS families to OS-specific fonts
2879        let expanded_families = expand_font_families(font_families, os, &[]);
2880
2881        // Keep the originally-requested generic families ("serif",
2882        // "sans-serif", "monospace", ...) around. The expansion above turns
2883        // them into a hardcoded list of real OS font names and drops the
2884        // generic name itself; the chain builder uses this list to fall back
2885        // to *registered* fonts when none of those OS names exist (wasm,
2886        // headless caches, or an embedder that only registered an in-memory
2887        // bundled font). See `resolve_font_chain_uncached`.
2888        let generic_fallbacks: Vec<String> = font_families
2889            .iter()
2890            .filter(|f| config::is_generic_family(f))
2891            .cloned()
2892            .collect();
2893
2894        // Build the chain
2895        let chain = self.resolve_font_chain_uncached(
2896            &expanded_families,
2897            &generic_fallbacks,
2898            weight,
2899            italic,
2900            oblique,
2901            scripts_hint,
2902            trace,
2903        );
2904
2905        // Cache the result
2906        if let Ok(mut cache) = self.shared.chain_cache.lock() {
2907            cache.insert(cache_key, chain.clone());
2908        }
2909
2910        chain
2911    }
2912    
2913    /// Internal implementation without caching.
2914    ///
2915    /// `scripts_hint`:
2916    /// - `None` pulls in the full [`DEFAULT_UNICODE_FALLBACK_SCRIPTS`]
2917    ///   set (the original, back-compat behaviour).
2918    /// - `Some(&[])` attaches no Unicode fallbacks.
2919    /// - `Some(ranges)` attaches fallbacks only for those ranges.
2920    #[cfg(feature = "std")]
2921    fn resolve_font_chain_uncached(
2922        &self,
2923        font_families: &[String],
2924        generic_fallbacks: &[String],
2925        weight: FcWeight,
2926        italic: PatternMatch,
2927        oblique: PatternMatch,
2928        scripts_hint: Option<&[UnicodeRange]>,
2929        trace: &mut Vec<TraceMsg>,
2930    ) -> FontFallbackChain {
2931        let mut css_fallbacks = Vec::new();
2932        
2933        // Resolve each CSS font-family to its system fallbacks
2934        for (_i, family) in font_families.iter().enumerate() {
2935            // Check if this is a generic font family
2936            let (pattern, is_generic) = if config::is_generic_family(family) {
2937                let monospace = if family.eq_ignore_ascii_case("monospace") {
2938                    PatternMatch::True
2939                } else {
2940                    PatternMatch::False
2941                };
2942                let pattern = FcPattern {
2943                    name: None,
2944                    weight,
2945                    italic,
2946                    oblique,
2947                    monospace,
2948                    unicode_ranges: Vec::new(),
2949                    ..Default::default()
2950                };
2951                (pattern, true)
2952            } else {
2953                // Specific font family name
2954                let pattern = FcPattern {
2955                    name: Some(family.clone()),
2956                    weight,
2957                    italic,
2958                    oblique,
2959                    unicode_ranges: Vec::new(),
2960                    ..Default::default()
2961                };
2962                (pattern, false)
2963            };
2964            
2965            // Use fuzzy matching for specific fonts (fast token-based lookup)
2966            // For generic families, use query (slower but necessary for property matching)
2967            let mut matches = if is_generic {
2968                // Generic families need full pattern matching
2969                self.query_internal(&pattern, trace)
2970            } else {
2971                // Specific font names: use fast token-based fuzzy matching.
2972                let mut m = self.fuzzy_query_by_name(family, weight, italic, oblique, &[], trace);
2973                // The token-fuzzy index is a no-op on the azul web-lift fork
2974                // (`index_pattern_tokens`), so `fuzzy_query_by_name` returns nothing
2975                // for every specific family name. Without a fallback here the whole
2976                // expanded CSS stack ("DejaVu Sans", "Noto Sans", "Liberation Sans",
2977                // …) resolves to NOTHING, and generic families collapse to the
2978                // coverage/style-ranked `name: None` fallback below — which grabs the
2979                // highest-Unicode-coverage CJK megafont (Noto Sans JP/CJK) for plain
2980                // Latin body text and picks arbitrary weights (a Bold-Italic for a
2981                // Regular request). Fall back to a normalized exact-family lookup so
2982                // the real Latin fallback names actually match. Normalized equality
2983                // ("noto sans" -> "notosans") also fixes the substring leak where
2984                // "Noto Sans" would otherwise latch onto "Noto Sans JP".
2985                if m.is_empty() {
2986                    m = self.query_by_family_normalized(family, weight, italic, oblique);
2987                }
2988                m
2989            };
2990            
2991            // For generic families, limit to top 5 fonts to avoid too many matches
2992            if is_generic && matches.len() > 5 {
2993                matches.truncate(5);
2994            }
2995            
2996            // Always add the CSS fallback group to preserve CSS ordering
2997            // even if no fonts were found for this family
2998            css_fallbacks.push(CssFallbackGroup {
2999                css_name: family.clone(),
3000                fonts: matches,
3001            });
3002        }
3003
3004        // Headless / wasm / memory-only fallback.
3005        //
3006        // Generic CSS families ("serif"/"sans-serif"/"monospace"/...) were
3007        // expanded by the caller to a hardcoded list of real OS font names.
3008        // On a system that actually has those fonts the loop above matched
3009        // them and we're done. But on wasm, a headless cache, or an embedder
3010        // that only registered an in-memory bundled font, NONE of those OS
3011        // names exist — and the original generic name was dropped, so a
3012        // registered font (whatever its family name) would never be reached.
3013        //
3014        // So: if the whole expanded stack matched nothing at all, retry each
3015        // originally-requested generic family as a generic `name: None`
3016        // query, which any registered font can satisfy. This runs ONLY when
3017        // nothing else matched, so on systems with real fonts it adds nothing
3018        // and never reorders real matches (any such fallback must come AFTER
3019        // real matches).
3020        if !generic_fallbacks.is_empty()
3021            && css_fallbacks.iter().all(|g| g.fonts.is_empty())
3022        {
3023            for generic in generic_fallbacks {
3024                let monospace = if generic.eq_ignore_ascii_case("monospace") {
3025                    PatternMatch::True
3026                } else {
3027                    PatternMatch::False
3028                };
3029                let pattern = FcPattern {
3030                    name: None,
3031                    weight,
3032                    italic,
3033                    oblique,
3034                    monospace,
3035                    unicode_ranges: Vec::new(),
3036                    ..Default::default()
3037                };
3038                let mut matches = self.query_internal(&pattern, trace);
3039                if matches.len() > 5 {
3040                    matches.truncate(5);
3041                }
3042                if !matches.is_empty() {
3043                    css_fallbacks.push(CssFallbackGroup {
3044                        css_name: generic.clone(),
3045                        fonts: matches,
3046                    });
3047                }
3048            }
3049        }
3050
3051        // Populate unicode_fallbacks. CSS fallback fonts may falsely claim
3052        // coverage of a script via the OS/2 unicode-range bits without
3053        // actually having glyphs, so we supplement the CSS chain with an
3054        // explicit lookup for each requested script block. resolve_char()
3055        // prefers CSS fallbacks first (earlier in the chain wins).
3056        //
3057        // The set of script blocks to cover is caller-controlled via
3058        // `scripts_hint`: `None` keeps the back-compat DEFAULT_UNICODE_FALLBACK_SCRIPTS
3059        // behaviour (7 scripts) so existing `resolve_font_chain` consumers
3060        // stay unchanged; `Some(&[])` opts into "no unicode fallbacks at all"
3061        // for ASCII-only documents, eliminating the big CJK / Arabic fonts
3062        // from the resolved chain (and therefore from eager downstream parses).
3063        let important_ranges: &[UnicodeRange] =
3064            scripts_hint.unwrap_or(DEFAULT_UNICODE_FALLBACK_SCRIPTS);
3065        let unicode_fallbacks = if important_ranges.is_empty() {
3066            Vec::new()
3067        } else {
3068            let all_uncovered = vec![false; important_ranges.len()];
3069            self.find_unicode_fallbacks(
3070                important_ranges,
3071                &all_uncovered,
3072                &css_fallbacks,
3073                weight,
3074                italic,
3075                oblique,
3076                trace,
3077            )
3078        };
3079
3080        // WEB-LIFT LAST-RESORT (2026-06-03; the `with_memory_fonts` trap that previously made
3081        // editing this file fatal is now fixed by the byte-atomic remill fork support). In the
3082        // lifted web backend `find_unicode_fallbacks` returns 0 fonts even though one IS
3083        // registered (the matching/iteration mis-lifts), so BOTH chain lists come back empty →
3084        // every consumer (resolve_char, query_for_text, prune_chain_to_used_chars) sees no font
3085        // → the layout unwraps a None → OOB. When the chain would be empty, append the first
3086        // registered font so the chain is non-empty. Native chains are never empty here.
3087        let mut unicode_fallbacks = unicode_fallbacks;
3088        if css_fallbacks.is_empty() && unicode_fallbacks.is_empty() {
3089            let st = self.state_read();
3090            if let Some((pat, id)) = st.patterns.iter().next() {
3091                unicode_fallbacks.push(FontMatch {
3092                    id: *id,
3093                    unicode_ranges: pat.unicode_ranges.clone(),
3094                    fallbacks: Vec::new(),
3095                });
3096            }
3097        }
3098
3099        FontFallbackChain {
3100            css_fallbacks,
3101            unicode_fallbacks,
3102            original_stack: font_families.to_vec(),
3103        }
3104    }
3105
3106    /// Extract Unicode ranges from text
3107    #[allow(dead_code)]
3108    fn extract_unicode_ranges(text: &str) -> Vec<UnicodeRange> {
3109        let mut chars: Vec<char> = text.chars().collect();
3110        chars.sort_unstable();
3111        chars.dedup();
3112        
3113        if chars.is_empty() {
3114            return Vec::new();
3115        }
3116        
3117        let mut ranges = Vec::new();
3118        let mut range_start = chars[0] as u32;
3119        let mut range_end = range_start;
3120        
3121        for &c in &chars[1..] {
3122            let codepoint = c as u32;
3123            if codepoint == range_end + 1 {
3124                range_end = codepoint;
3125            } else {
3126                ranges.push(UnicodeRange { start: range_start, end: range_end });
3127                range_start = codepoint;
3128                range_end = codepoint;
3129            }
3130        }
3131        
3132        ranges.push(UnicodeRange { start: range_start, end: range_end });
3133        ranges
3134    }
3135    
3136    /// Fuzzy query for fonts by name when exact match fails
3137    /// Uses intelligent token-based matching with inverted index for speed:
3138    /// 1. Break name into tokens (e.g., "NotoSansJP" -> ["noto", "sans", "jp"])
3139    /// 2. Use token_index to find candidate fonts via BTreeSet intersection
3140    /// 3. Score only the candidate fonts (instead of all 800+ patterns)
3141    /// 4. Prioritize fonts matching more tokens + Unicode coverage
3142    #[cfg(feature = "std")]
3143    fn fuzzy_query_by_name(
3144        &self,
3145        requested_name: &str,
3146        weight: FcWeight,
3147        italic: PatternMatch,
3148        oblique: PatternMatch,
3149        unicode_ranges: &[UnicodeRange],
3150        _trace: &mut Vec<TraceMsg>,
3151    ) -> Vec<FontMatch> {
3152        // Extract tokens from the requested name (e.g., "NotoSansJP" -> ["noto", "sans", "jp"])
3153        let tokens = Self::extract_font_name_tokens(requested_name);
3154        
3155        if tokens.is_empty() {
3156            return Vec::new();
3157        }
3158        
3159        // Convert tokens to lowercase for case-insensitive lookup
3160        let tokens_lower: Vec<String> = tokens.iter().map(|t| t.to_ascii_lowercase()).collect();
3161        
3162        // Progressive token matching strategy:
3163        // Start with first token, then progressively narrow down with each additional token
3164        // If adding a token results in 0 matches, use the previous (broader) set
3165        // Example: ["Noto"] -> 10 fonts, ["Noto","Sans"] -> 2 fonts, ["Noto","Sans","JP"] -> 0 fonts => use 2 fonts
3166        
3167        let state = self.state_read();
3168
3169        // Start with the first token
3170        let first_token = &tokens_lower[0];
3171        let mut candidate_ids = match state.token_index.get(first_token) {
3172            Some(ids) if !ids.is_empty() => ids.clone(),
3173            _ => {
3174                // First token not found - no fonts match, quit immediately
3175                return Vec::new();
3176            }
3177        };
3178
3179        // Progressively narrow down with each additional token
3180        for token in &tokens_lower[1..] {
3181            if let Some(token_ids) = state.token_index.get(token) {
3182                // Calculate intersection
3183                let intersection: alloc::collections::BTreeSet<FontId> =
3184                    candidate_ids.intersection(token_ids).copied().collect();
3185
3186                if intersection.is_empty() {
3187                    // Adding this token results in 0 matches - keep previous set and stop
3188                    break;
3189                } else {
3190                    // Successfully narrowed down - use intersection
3191                    candidate_ids = intersection;
3192                }
3193            } else {
3194                // Token not in index - keep current set and stop
3195                break;
3196            }
3197        }
3198
3199        // Now score only the candidate fonts (HUGE speedup!)
3200        let mut candidates = Vec::new();
3201
3202        for id in candidate_ids {
3203            let pattern = match state.metadata.get(&id) {
3204                Some(p) => p,
3205                None => continue,
3206            };
3207            
3208            // Get pre-tokenized font name (already lowercase)
3209            let font_tokens_lower = match state.font_tokens.get(&id) {
3210                Some(tokens) => tokens,
3211                None => continue,
3212            };
3213            
3214            if font_tokens_lower.is_empty() {
3215                continue;
3216            }
3217            
3218            // Calculate token match score (how many requested tokens appear in font name)
3219            // Both tokens_lower and font_tokens_lower are already lowercase, so direct comparison
3220            let token_matches = tokens_lower.iter()
3221                .filter(|req_token| {
3222                    font_tokens_lower.iter().any(|font_token| {
3223                        // Both already lowercase — exact token match (index guarantees candidates)
3224                        font_token == *req_token
3225                    })
3226                })
3227                .count();
3228            
3229            // Skip if no tokens match (shouldn't happen due to index, but safety check)
3230            if token_matches == 0 {
3231                continue;
3232            }
3233            
3234            // Calculate token similarity score (0-100)
3235            let token_similarity = (token_matches * 100 / tokens.len()) as i32;
3236            
3237            // Calculate Unicode range similarity
3238            let unicode_similarity = if !unicode_ranges.is_empty() && !pattern.unicode_ranges.is_empty() {
3239                Self::calculate_unicode_compatibility(unicode_ranges, &pattern.unicode_ranges)
3240            } else {
3241                0
3242            };
3243            
3244            // CRITICAL: If we have Unicode requirements, ONLY accept fonts that cover them
3245            // A font with great name match but no Unicode coverage is useless
3246            if !unicode_ranges.is_empty() && unicode_similarity == 0 {
3247                continue;
3248            }
3249            
3250            let style_score = Self::calculate_style_score(&FcPattern {
3251                weight,
3252                italic,
3253                oblique,
3254                ..Default::default()
3255            }, pattern);
3256            
3257            candidates.push((
3258                id,
3259                token_similarity,
3260                unicode_similarity,
3261                style_score,
3262                pattern.clone(),
3263            ));
3264        }
3265        
3266        // Sort by:
3267        // 1. Token matches (more matches = better)
3268        // 2. Unicode compatibility (if ranges provided)
3269        // 3. Style score (lower is better)
3270        // 4. Deterministic tiebreaker: prefer non-italic, then by font name
3271        candidates.sort_by(|a, b| {
3272            if !unicode_ranges.is_empty() {
3273                // When we have Unicode requirements, prioritize coverage
3274                b.1.cmp(&a.1) // Token similarity (higher is better) - PRIMARY
3275                    .then_with(|| b.2.cmp(&a.2)) // Unicode similarity (higher is better) - SECONDARY
3276                    .then_with(|| a.3.cmp(&b.3)) // Style score (lower is better) - TERTIARY
3277                    .then_with(|| a.4.italic.cmp(&b.4.italic)) // Prefer non-italic (False < True)
3278                    .then_with(|| a.4.name.cmp(&b.4.name)) // Alphabetical by name
3279            } else {
3280                // No Unicode requirements, token similarity is primary
3281                b.1.cmp(&a.1) // Token similarity (higher is better)
3282                    .then_with(|| a.3.cmp(&b.3)) // Style score (lower is better)
3283                    .then_with(|| a.4.italic.cmp(&b.4.italic)) // Prefer non-italic (False < True)
3284                    .then_with(|| a.4.name.cmp(&b.4.name)) // Alphabetical by name
3285            }
3286        });
3287        
3288        // Take top 5 matches
3289        candidates.truncate(5);
3290        
3291        // Convert to FontMatch
3292        candidates
3293            .into_iter()
3294            .map(|(id, _token_sim, _unicode_sim, _style, pattern)| {
3295                FontMatch {
3296                    id,
3297                    unicode_ranges: pattern.unicode_ranges.clone(),
3298                    fallbacks: Vec::new(), // Fallbacks computed lazily via compute_fallbacks()
3299                }
3300            })
3301            .collect()
3302    }
3303
3304    /// Resolve a specific CSS family name to registered faces by NORMALIZED
3305    /// family equality, ranked by style (weight/italic/oblique) closeness.
3306    ///
3307    /// This is the correct, stable matcher for a concrete `font-family` name
3308    /// (as opposed to a generic like `sans-serif`): it matches
3309    /// `font-family: "DejaVu Sans"` to the family whose normalized name is
3310    /// exactly `dejavusans` — never to `dejavusansmono` or `dejavusanscondensed`,
3311    /// and never `"Noto Sans"` to `"Noto Sans JP"`. `normalize_family_name`
3312    /// strips spaces/hyphens/case so the CSS spelling and the stored family
3313    /// spelling line up regardless of formatting.
3314    ///
3315    /// Among faces of the matched family the best style score wins (exact
3316    /// weight, then nearest weight; correct slant), so `font-weight: bold`
3317    /// selects the Bold face and a Regular request avoids Bold/Italic faces.
3318    /// Falls back to matching the stored `name` by the same normalized rule for
3319    /// fonts that carry no family field.
3320    fn query_by_family_normalized(
3321        &self,
3322        family: &str,
3323        weight: FcWeight,
3324        italic: PatternMatch,
3325        oblique: PatternMatch,
3326    ) -> Vec<FontMatch> {
3327        let target = crate::utils::normalize_family_name(family);
3328        if target.is_empty() {
3329            return Vec::new();
3330        }
3331        let query = FcPattern {
3332            weight,
3333            italic,
3334            oblique,
3335            ..Default::default()
3336        };
3337        let state = self.state_read();
3338        // ONE map probe. This used to walk every registered pattern and
3339        // allocate a normalized String per face per call — O(fonts) with two
3340        // allocations each, on the only path a specific family name can take
3341        // (`fuzzy_query_by_name` is a no-op on the azul web fork, so it
3342        // always falls through to here). Measured from azul at ~0.52 ms per
3343        // lookup against a system font set, and a CSS stack with generic
3344        // expansion asks ~150 times.
3345        //
3346        // A family nobody has is now free: the probe misses and returns.
3347        let Some(ids) = state.family_index.get(&target) else {
3348            return Vec::new();
3349        };
3350        let mut candidates: Vec<(FontId, i32, FcPattern)> = Vec::new();
3351        for id in ids {
3352            let Some(meta) = state.metadata.get(id).or_else(|| {
3353                state.patterns.iter().find(|(_, pid)| *pid == id).map(|(p, _)| p)
3354            }) else {
3355                continue;
3356            };
3357            let style_score = Self::calculate_style_score(&query, meta);
3358            candidates.push((*id, style_score, meta.clone()));
3359        }
3360        drop(state);
3361
3362        // Lowest style score first; deterministic tiebreak: non-italic, then name.
3363        candidates.sort_by(|a, b| {
3364            a.1.cmp(&b.1)
3365                .then_with(|| a.2.italic.cmp(&b.2.italic))
3366                .then_with(|| a.2.name.cmp(&b.2.name))
3367        });
3368        candidates.truncate(5);
3369        candidates
3370            .into_iter()
3371            .map(|(id, _, pattern)| FontMatch {
3372                id,
3373                unicode_ranges: pattern.unicode_ranges.clone(),
3374                fallbacks: Vec::new(),
3375            })
3376            .collect()
3377    }
3378
3379    /// Extract tokens from a font name
3380    /// E.g., "NotoSansJP" -> ["Noto", "Sans", "JP"]
3381    /// E.g., "Noto Sans CJK JP" -> ["Noto", "Sans", "CJK", "JP"]
3382    pub fn extract_font_name_tokens(name: &str) -> Vec<String> {
3383        let mut tokens = Vec::new();
3384        let mut current_token = String::new();
3385        let mut last_was_lower = false;
3386        
3387        for c in name.chars() {
3388            if c.is_whitespace() || c == '-' || c == '_' {
3389                // Word separator
3390                if !current_token.is_empty() {
3391                    tokens.push(current_token.clone());
3392                    current_token.clear();
3393                }
3394                last_was_lower = false;
3395            } else if c.is_uppercase() && last_was_lower && !current_token.is_empty() {
3396                // CamelCase boundary (e.g., "Noto" | "Sans")
3397                tokens.push(current_token.clone());
3398                current_token.clear();
3399                current_token.push(c);
3400                last_was_lower = false;
3401            } else {
3402                current_token.push(c);
3403                last_was_lower = c.is_lowercase();
3404            }
3405        }
3406        
3407        if !current_token.is_empty() {
3408            tokens.push(current_token);
3409        }
3410        
3411        tokens
3412    }
3413    
3414    /// Find fonts to cover missing Unicode ranges
3415    /// Uses intelligent matching: prefers fonts with similar names to existing ones
3416    /// Early quits once all Unicode ranges are covered for performance
3417    fn find_unicode_fallbacks(
3418        &self,
3419        unicode_ranges: &[UnicodeRange],
3420        covered_chars: &[bool],
3421        existing_groups: &[CssFallbackGroup],
3422        _weight: FcWeight,
3423        _italic: PatternMatch,
3424        _oblique: PatternMatch,
3425        trace: &mut Vec<TraceMsg>,
3426    ) -> Vec<FontMatch> {
3427        // Extract uncovered ranges
3428        let mut uncovered_ranges = Vec::new();
3429        for (i, &covered) in covered_chars.iter().enumerate() {
3430            if !covered && i < unicode_ranges.len() {
3431                uncovered_ranges.push(unicode_ranges[i].clone());
3432            }
3433        }
3434        
3435        if uncovered_ranges.is_empty() {
3436            return Vec::new();
3437        }
3438
3439        // Query for fonts that cover these ranges.
3440        // Use DontCare for weight/italic/oblique — we want ANY font that covers
3441        // the missing characters, regardless of style. The similarity sort below
3442        // will prefer fonts matching the existing chain's style anyway.
3443        let pattern = FcPattern {
3444            name: None,
3445            weight: FcWeight::Normal, // Normal weight is not filtered by query_matches_internal (line 1836)
3446            italic: PatternMatch::DontCare,
3447            oblique: PatternMatch::DontCare,
3448            unicode_ranges: uncovered_ranges.clone(),
3449            ..Default::default()
3450        };
3451        
3452        let mut candidates = self.query_internal(&pattern, trace);
3453
3454        // Intelligent sorting: prefer fonts with similar names to existing ones
3455        // Extract font family prefixes from existing fonts (e.g., "Noto Sans" from "Noto Sans JP")
3456        let existing_prefixes: Vec<String> = existing_groups
3457            .iter()
3458            .flat_map(|group| {
3459                group.fonts.iter().filter_map(|font| {
3460                    self.get_metadata_by_id(&font.id)
3461                        .and_then(|meta| meta.family.clone())
3462                        .and_then(|family| {
3463                            // Extract prefix (e.g., "Noto Sans" from "Noto Sans JP")
3464                            family.split_whitespace()
3465                                .take(2)
3466                                .collect::<Vec<_>>()
3467                                .join(" ")
3468                                .into()
3469                        })
3470                })
3471            })
3472            .collect();
3473        
3474        // Sort candidates by:
3475        // 1. Name similarity to existing fonts (highest priority)
3476        // 2. Unicode coverage (secondary)
3477        candidates.sort_by(|a, b| {
3478            let a_meta = self.get_metadata_by_id(&a.id);
3479            let b_meta = self.get_metadata_by_id(&b.id);
3480
3481            let a_score = Self::calculate_font_similarity_score(a_meta.as_ref(), &existing_prefixes);
3482            let b_score = Self::calculate_font_similarity_score(b_meta.as_ref(), &existing_prefixes);
3483            
3484            b_score.cmp(&a_score) // Higher score = better match
3485                .then_with(|| {
3486                    let a_coverage = Self::calculate_unicode_compatibility(&uncovered_ranges, &a.unicode_ranges);
3487                    let b_coverage = Self::calculate_unicode_compatibility(&uncovered_ranges, &b.unicode_ranges);
3488                    b_coverage.cmp(&a_coverage)
3489                })
3490        });
3491        
3492        // Early quit optimization: only take fonts until all ranges are covered
3493        let mut result = Vec::new();
3494        let mut remaining_uncovered: Vec<bool> = vec![true; uncovered_ranges.len()];
3495        
3496        for candidate in candidates {
3497            // Check which ranges this font covers
3498            let mut covers_new_range = false;
3499            
3500            for (i, range) in uncovered_ranges.iter().enumerate() {
3501                if remaining_uncovered[i] {
3502                    // Check if this font covers this range
3503                    for font_range in &candidate.unicode_ranges {
3504                        if font_range.overlaps(range) {
3505                            remaining_uncovered[i] = false;
3506                            covers_new_range = true;
3507                            break;
3508                        }
3509                    }
3510                }
3511            }
3512            
3513            // Only add fonts that cover at least one new range
3514            if covers_new_range {
3515                result.push(candidate);
3516                
3517                // Early quit: if all ranges are covered, stop
3518                if remaining_uncovered.iter().all(|&uncovered| !uncovered) {
3519                    break;
3520                }
3521            }
3522        }
3523        
3524        result
3525    }
3526    
3527    /// Calculate similarity score between a font and existing font prefixes
3528    /// Higher score = more similar
3529    fn calculate_font_similarity_score(
3530        font_meta: Option<&FcPattern>,
3531        existing_prefixes: &[String],
3532    ) -> i32 {
3533        let Some(meta) = font_meta else { return 0; };
3534        let Some(family) = &meta.family else { return 0; };
3535        
3536        // Check if this font's family matches any existing prefix
3537        for prefix in existing_prefixes {
3538            if family.starts_with(prefix) {
3539                return 100; // Strong match
3540            }
3541            if family.contains(prefix) {
3542                return 50; // Partial match
3543            }
3544        }
3545        
3546        0 // No match
3547    }
3548    
3549    /// Find fallback fonts for a given pattern
3550    // Helper to calculate total unicode coverage
3551    pub fn calculate_unicode_coverage(ranges: &[UnicodeRange]) -> u64 {
3552        ranges
3553            .iter()
3554            .map(|range| (range.end - range.start + 1) as u64)
3555            .sum()
3556    }
3557
3558    /// Coalesce ranges into a sorted, **disjoint** set.
3559    ///
3560    /// [`FcFontCache::calculate_unicode_coverage`] sums `end - start + 1` with no
3561    /// overlap handling, and that sum ranks fallback candidates. A font's coverage
3562    /// is built from two sources whose block boundaries do not align — the OS/2
3563    /// `ulUnicodeRange` bit mappings and the cmap block probe — so merging them
3564    /// naively double-counts the overlap and inflates the score. That is exactly
3565    /// how a CJK megafont wins a Latin run it has no business winning.
3566    ///
3567    /// Touching ranges (`prev.end + 1 == next.start`) are merged as well: they
3568    /// describe the same contiguous coverage, and leaving them split would make
3569    /// one set compare unequal to another purely by which source produced it.
3570    pub fn normalize_unicode_ranges(mut ranges: Vec<UnicodeRange>) -> Vec<UnicodeRange> {
3571        if ranges.len() < 2 {
3572            return ranges;
3573        }
3574
3575        ranges.sort_unstable();
3576
3577        let mut out: Vec<UnicodeRange> = Vec::with_capacity(ranges.len());
3578        for range in ranges {
3579            match out.last_mut() {
3580                // Overlapping or touching: extend. `saturating_add` so an `end` of
3581                // u32::MAX cannot wrap around into a bogus failure-to-merge.
3582                Some(prev) if range.start <= prev.end.saturating_add(1) => {
3583                    prev.end = prev.end.max(range.end);
3584                }
3585                _ => out.push(range),
3586            }
3587        }
3588        out
3589    }
3590
3591    /// Calculate how well a font's Unicode ranges cover the requested ranges
3592    /// Returns a compatibility score (higher is better, 0 means no overlap)
3593    pub fn calculate_unicode_compatibility(
3594        requested: &[UnicodeRange],
3595        available: &[UnicodeRange],
3596    ) -> i32 {
3597        if requested.is_empty() {
3598            // No specific requirements, return total coverage
3599            return Self::calculate_unicode_coverage(available) as i32;
3600        }
3601        
3602        let mut total_coverage = 0u32;
3603        
3604        for req_range in requested {
3605            for avail_range in available {
3606                // Calculate overlap between requested and available ranges
3607                let overlap_start = req_range.start.max(avail_range.start);
3608                let overlap_end = req_range.end.min(avail_range.end);
3609                
3610                if overlap_start <= overlap_end {
3611                    // There is overlap
3612                    let overlap_size = overlap_end - overlap_start + 1;
3613                    total_coverage += overlap_size;
3614                }
3615            }
3616        }
3617        
3618        total_coverage as i32
3619    }
3620
3621    pub fn calculate_style_score(original: &FcPattern, candidate: &FcPattern) -> i32 {
3622
3623        let mut score = 0_i32;
3624
3625        // Weight calculation with special handling for bold property
3626        if (original.bold == PatternMatch::True && candidate.weight == FcWeight::Bold)
3627            || (original.bold == PatternMatch::False && candidate.weight != FcWeight::Bold)
3628        {
3629            // No weight penalty when bold is requested and font has Bold weight
3630            // No weight penalty when non-bold is requested and font has non-Bold weight
3631        } else {
3632            // Apply normal weight difference penalty
3633            let weight_diff = (original.weight as i32 - candidate.weight as i32).abs();
3634            score += weight_diff as i32;
3635        }
3636
3637        // Exact weight match bonus: reward fonts whose weight matches the request exactly,
3638        // with an extra bonus when both are Normal (the most common case for body text)
3639        if original.weight == candidate.weight {
3640            score -= 15;
3641            if original.weight == FcWeight::Normal {
3642                score -= 10; // Extra bonus for Normal-Normal match
3643            }
3644        }
3645
3646        // Stretch calculation with special handling for condensed property
3647        if (original.condensed == PatternMatch::True && candidate.stretch.is_condensed())
3648            || (original.condensed == PatternMatch::False && !candidate.stretch.is_condensed())
3649        {
3650            // No stretch penalty when condensed is requested and font has condensed stretch
3651            // No stretch penalty when non-condensed is requested and font has non-condensed stretch
3652        } else {
3653            // Apply normal stretch difference penalty
3654            let stretch_diff = (original.stretch as i32 - candidate.stretch as i32).abs();
3655            score += (stretch_diff * 100) as i32;
3656        }
3657
3658        // Handle style properties with standard penalties and bonuses
3659        let style_props = [
3660            (original.italic, candidate.italic, 300, 150),
3661            (original.oblique, candidate.oblique, 200, 100),
3662            (original.bold, candidate.bold, 300, 150),
3663            (original.monospace, candidate.monospace, 100, 50),
3664            (original.condensed, candidate.condensed, 100, 50),
3665        ];
3666
3667        for (orig, cand, mismatch_penalty, dontcare_penalty) in style_props {
3668            if orig.needs_to_match() {
3669                if orig == PatternMatch::False && cand == PatternMatch::DontCare {
3670                    // Requesting non-italic but font doesn't declare: small penalty
3671                    // (less than a full mismatch but more than a perfect match)
3672                    score += dontcare_penalty / 2;
3673                } else if !orig.matches(&cand) {
3674                    if cand == PatternMatch::DontCare {
3675                        score += dontcare_penalty;
3676                    } else {
3677                        score += mismatch_penalty;
3678                    }
3679                } else if orig == PatternMatch::True && cand == PatternMatch::True {
3680                    // Give bonus for exact True match
3681                    score -= 20;
3682                } else if orig == PatternMatch::False && cand == PatternMatch::False {
3683                    // Give bonus for exact False match (prefer explicitly non-italic
3684                    // over fonts with unknown/DontCare italic status)
3685                    score -= 20;
3686                }
3687            } else {
3688                // orig == DontCare: prefer "normal" fonts over styled ones.
3689                // When the caller doesn't specify italic/bold/etc., a font
3690                // that IS italic/bold should score slightly worse than one
3691                // that isn't, so Regular is chosen over Italic by default.
3692                if cand == PatternMatch::True {
3693                    score += dontcare_penalty / 3;
3694                }
3695            }
3696        }
3697
3698        // ── Name-based "base font" detection ──
3699        // The shorter the font name relative to its family, the more "basic" the
3700        // variant.  E.g. "System Font" (the base) should score better than
3701        // "System Font Regular Italic" (a variant) when the user hasn't
3702        // explicitly requested italic.
3703        if let (Some(name), Some(family)) = (&candidate.name, &candidate.family) {
3704            let name_lower = name.to_ascii_lowercase();
3705            let family_lower = family.to_ascii_lowercase();
3706
3707            // Strip the family prefix from the name to get the "extra" part
3708            let extra = if name_lower.starts_with(&family_lower) {
3709                name_lower[family_lower.len()..].to_string()
3710            } else {
3711                String::new()
3712            };
3713
3714            // Strip common neutral descriptors that don't indicate a style variant
3715            let stripped = extra
3716                .replace("regular", "")
3717                .replace("normal", "")
3718                .replace("book", "")
3719                .replace("roman", "");
3720            let stripped = stripped.trim();
3721
3722            if stripped.is_empty() {
3723                // This is a "base font" – name is just the family (± "Regular")
3724                score -= 50;
3725            } else {
3726                // Name has extra style descriptors – add a penalty per extra word
3727                let extra_words = stripped.split_whitespace().count();
3728                score += (extra_words as i32) * 25;
3729            }
3730        }
3731
3732        // ── Subfamily "Regular" bonus ──
3733        // Fonts whose OpenType subfamily is exactly "Regular" are the canonical
3734        // base variant and should be strongly preferred.
3735        if let Some(ref subfamily) = candidate.metadata.font_subfamily {
3736            let sf_lower = subfamily.to_ascii_lowercase();
3737            if sf_lower == "regular" {
3738                score -= 30;
3739            }
3740        }
3741
3742        score
3743    }
3744}
3745
3746#[cfg(all(feature = "std", feature = "parsing", target_os = "linux"))]
3747fn FcScanDirectories() -> Option<(
3748    Vec<(FcPattern, FcFontPath)>,
3749    BTreeMap<String, FcFontRenderConfig>,
3750    BTreeMap<String, Vec<String>>,
3751)> {
3752    use std::fs;
3753    use std::path::Path;
3754
3755    // Real fontconfig honors $FONTCONFIG_FILE as the root config; so do we
3756    // (hermetic test setups and sandboxes depend on it).
3757    let base_path = std::env::var("FONTCONFIG_FILE")
3758        .ok()
3759        .filter(|p| !p.is_empty())
3760        .unwrap_or_else(|| "/etc/fonts/fonts.conf".to_string());
3761
3762    if !Path::new(&base_path).exists() {
3763        return None;
3764    }
3765
3766    let mut font_paths = Vec::with_capacity(32);
3767    let mut paths_to_visit = vec![(None, PathBuf::from(&base_path))];
3768    let mut render_configs: BTreeMap<String, FcFontRenderConfig> = BTreeMap::new();
3769    let mut system_aliases: BTreeMap<String, Vec<String>> = BTreeMap::new();
3770
3771    while let Some((prefix, path_to_visit)) = paths_to_visit.pop() {
3772        let path = match process_path(&prefix, path_to_visit, true) {
3773            Some(path) => path,
3774            None => continue,
3775        };
3776
3777        let metadata = match fs::metadata(&path) {
3778            Ok(metadata) => metadata,
3779            Err(_) => continue,
3780        };
3781
3782        if metadata.is_file() {
3783            let xml_utf8 = match fs::read_to_string(&path) {
3784                Ok(xml_utf8) => xml_utf8,
3785                Err(_) => continue,
3786            };
3787
3788            if ParseFontsConf(&xml_utf8, &mut paths_to_visit, &mut font_paths).is_none() {
3789                continue;
3790            }
3791
3792            // Also parse render config blocks from this file
3793            ParseFontsConfRenderConfig(&xml_utf8, &mut render_configs);
3794
3795            // And <alias>/<prefer> preference blocks (generic families and
3796            // named substitutions alike).
3797            ParseFontsConfAliases(&xml_utf8, &mut system_aliases);
3798        } else if metadata.is_dir() {
3799            let dir_entries = match fs::read_dir(&path) {
3800                Ok(dir_entries) => dir_entries,
3801                Err(_) => continue,
3802            };
3803
3804            for entry_result in dir_entries {
3805                let entry = match entry_result {
3806                    Ok(entry) => entry,
3807                    Err(_) => continue,
3808                };
3809
3810                let entry_path = entry.path();
3811
3812                // `fs::metadata` traverses symbolic links
3813                let entry_metadata = match fs::metadata(&entry_path) {
3814                    Ok(metadata) => metadata,
3815                    Err(_) => continue,
3816                };
3817
3818                if !entry_metadata.is_file() {
3819                    continue;
3820                }
3821
3822                let file_name = match entry_path.file_name() {
3823                    Some(name) => name,
3824                    None => continue,
3825                };
3826
3827                let file_name_str = file_name.to_string_lossy();
3828                if file_name_str.starts_with(|c: char| c.is_ascii_digit())
3829                    && file_name_str.ends_with(".conf")
3830                {
3831                    paths_to_visit.push((None, entry_path));
3832                }
3833            }
3834        }
3835    }
3836
3837    if font_paths.is_empty() {
3838        return None;
3839    }
3840
3841    Some((FcScanDirectoriesInner(&font_paths), render_configs, system_aliases))
3842}
3843
3844/// Parse `<alias><family>NAME</family><prefer><family>...</family>...</prefer></alias>`
3845/// blocks from a fontconfig XML file into `aliases`.
3846///
3847/// Keys are normalized with [`crate::utils::normalize_family_name`];
3848/// preferred families keep their configured order, appended across files
3849/// in include order (fontconfig semantics), deduplicated.
3850#[cfg(all(feature = "std", feature = "parsing", target_os = "linux"))]
3851fn ParseFontsConfAliases(input: &str, aliases: &mut BTreeMap<String, Vec<String>>) {
3852    use xmlparser::Token::*;
3853    use xmlparser::Tokenizer;
3854
3855    #[derive(Clone, Copy, PartialEq)]
3856    enum State {
3857        Idle,
3858        InAlias,
3859        InAliasFamily,
3860        InPrefer,
3861        InPreferFamily,
3862    }
3863
3864    let mut state = State::Idle;
3865    let mut alias_key: Option<String> = None;
3866    let mut preferred: Vec<String> = Vec::new();
3867    let mut text_buf = String::new();
3868
3869    for token_result in Tokenizer::from(input) {
3870        let token = match token_result {
3871            Ok(token) => token,
3872            Err(_) => continue,
3873        };
3874        match token {
3875            ElementStart { local, .. } => match local.as_str() {
3876                "alias" => {
3877                    state = State::InAlias;
3878                    alias_key = None;
3879                    preferred.clear();
3880                }
3881                "family" if state == State::InAlias => {
3882                    state = State::InAliasFamily;
3883                    text_buf.clear();
3884                }
3885                "prefer" if state == State::InAlias => {
3886                    state = State::InPrefer;
3887                }
3888                "family" if state == State::InPrefer => {
3889                    state = State::InPreferFamily;
3890                    text_buf.clear();
3891                }
3892                _ => {}
3893            },
3894            Text { text } => {
3895                if state == State::InAliasFamily || state == State::InPreferFamily {
3896                    text_buf.push_str(text.as_str());
3897                }
3898            }
3899            ElementEnd { end, .. } => {
3900                use xmlparser::ElementEnd;
3901                let closed = match end {
3902                    ElementEnd::Close(_, local) => Some(local.as_str().to_owned()),
3903                    _ => None,
3904                };
3905                let Some(closed) = closed else { continue };
3906                match closed.as_str() {
3907                    "family" => match state {
3908                        State::InAliasFamily => {
3909                            let t = text_buf.trim();
3910                            if !t.is_empty() && alias_key.is_none() {
3911                                alias_key = Some(t.to_owned());
3912                            }
3913                            state = State::InAlias;
3914                        }
3915                        State::InPreferFamily => {
3916                            let t = text_buf.trim();
3917                            if !t.is_empty() {
3918                                preferred.push(t.to_owned());
3919                            }
3920                            state = State::InPrefer;
3921                        }
3922                        _ => {}
3923                    },
3924                    "prefer" if state == State::InPrefer => {
3925                        state = State::InAlias;
3926                    }
3927                    "alias" => {
3928                        if let Some(key) = alias_key.take() {
3929                            if !preferred.is_empty() {
3930                                let norm = crate::utils::normalize_family_name(&key);
3931                                let entry = aliases.entry(norm).or_default();
3932                                for fam in preferred.drain(..) {
3933                                    if !entry.iter().any(|e| e == &fam) {
3934                                        entry.push(fam);
3935                                    }
3936                                }
3937                            }
3938                        }
3939                        state = State::Idle;
3940                    }
3941                    _ => {}
3942                }
3943            }
3944            _ => {}
3945        }
3946    }
3947}
3948
3949// Parses the fonts.conf file
3950#[cfg(all(feature = "std", feature = "parsing", target_os = "linux"))]
3951fn ParseFontsConf(
3952    input: &str,
3953    paths_to_visit: &mut Vec<(Option<String>, PathBuf)>,
3954    font_paths: &mut Vec<(Option<String>, String)>,
3955) -> Option<()> {
3956    use xmlparser::Token::*;
3957    use xmlparser::Tokenizer;
3958
3959    const TAG_INCLUDE: &str = "include";
3960    const TAG_DIR: &str = "dir";
3961    const ATTRIBUTE_PREFIX: &str = "prefix";
3962
3963    let mut current_prefix: Option<&str> = None;
3964    let mut current_path: Option<&str> = None;
3965    let mut is_in_include = false;
3966    let mut is_in_dir = false;
3967
3968    for token_result in Tokenizer::from(input) {
3969        let token = match token_result {
3970            Ok(token) => token,
3971            Err(_) => return None,
3972        };
3973
3974        match token {
3975            ElementStart { local, .. } => {
3976                if is_in_include || is_in_dir {
3977                    return None; /* error: nested tags */
3978                }
3979
3980                match local.as_str() {
3981                    TAG_INCLUDE => {
3982                        is_in_include = true;
3983                    }
3984                    TAG_DIR => {
3985                        is_in_dir = true;
3986                    }
3987                    _ => continue,
3988                }
3989
3990                current_path = None;
3991            }
3992            Text { text, .. } => {
3993                let text = text.as_str().trim();
3994                if text.is_empty() {
3995                    continue;
3996                }
3997                if is_in_include || is_in_dir {
3998                    current_path = Some(text);
3999                }
4000            }
4001            Attribute { local, value, .. } => {
4002                if !is_in_include && !is_in_dir {
4003                    continue;
4004                }
4005                // attribute on <include> or <dir> node
4006                if local.as_str() == ATTRIBUTE_PREFIX {
4007                    current_prefix = Some(value.as_str());
4008                }
4009            }
4010            ElementEnd { end, .. } => {
4011                let end_tag = match end {
4012                    xmlparser::ElementEnd::Close(_, a) => a,
4013                    _ => continue,
4014                };
4015
4016                match end_tag.as_str() {
4017                    TAG_INCLUDE => {
4018                        if !is_in_include {
4019                            continue;
4020                        }
4021
4022                        if let Some(current_path) = current_path.as_ref() {
4023                            paths_to_visit.push((
4024                                current_prefix.map(ToOwned::to_owned),
4025                                PathBuf::from(*current_path),
4026                            ));
4027                        }
4028                    }
4029                    TAG_DIR => {
4030                        if !is_in_dir {
4031                            continue;
4032                        }
4033
4034                        if let Some(current_path) = current_path.as_ref() {
4035                            font_paths.push((
4036                                current_prefix.map(ToOwned::to_owned),
4037                                (*current_path).to_owned(),
4038                            ));
4039                        }
4040                    }
4041                    _ => continue,
4042                }
4043
4044                is_in_include = false;
4045                is_in_dir = false;
4046                current_path = None;
4047                current_prefix = None;
4048            }
4049            _ => {}
4050        }
4051    }
4052
4053    Some(())
4054}
4055
4056/// Parses `<match target="font">` blocks from fonts.conf XML and returns
4057/// a map from family name to per-font rendering configuration.
4058///
4059/// Example fonts.conf snippet that this handles:
4060/// ```xml
4061/// <match target="font">
4062///   <test name="family"><string>Inconsolata</string></test>
4063///   <edit name="antialias" mode="assign"><bool>true</bool></edit>
4064///   <edit name="hintstyle" mode="assign"><const>hintslight</const></edit>
4065/// </match>
4066/// ```
4067#[cfg(all(feature = "std", feature = "parsing", target_os = "linux"))]
4068fn ParseFontsConfRenderConfig(
4069    input: &str,
4070    configs: &mut BTreeMap<String, FcFontRenderConfig>,
4071) {
4072    use xmlparser::Token::*;
4073    use xmlparser::Tokenizer;
4074
4075    // Parser state machine
4076    #[derive(Clone, Copy, PartialEq)]
4077    enum State {
4078        /// Outside any relevant block
4079        Idle,
4080        /// Inside <match target="font">
4081        InMatchFont,
4082        /// Inside <test name="family"> within a match block
4083        InTestFamily,
4084        /// Inside <edit name="..."> within a match block
4085        InEdit,
4086        /// Inside a value element (<bool>, <double>, <const>, <string>) within <edit> or <test>
4087        InValue,
4088    }
4089
4090    let mut state = State::Idle;
4091    let mut match_is_font_target = false;
4092    let mut current_family: Option<String> = None;
4093    let mut current_edit_name: Option<String> = None;
4094    let mut current_value: Option<String> = None;
4095    let mut value_tag: Option<String> = None;
4096    let mut config = FcFontRenderConfig::default();
4097    let mut in_test = false;
4098    let mut test_name: Option<String> = None;
4099
4100    for token_result in Tokenizer::from(input) {
4101        let token = match token_result {
4102            Ok(token) => token,
4103            Err(_) => continue,
4104        };
4105
4106        match token {
4107            ElementStart { local, .. } => {
4108                let tag = local.as_str();
4109                match tag {
4110                    "match" => {
4111                        // Reset state for a new match block
4112                        match_is_font_target = false;
4113                        current_family = None;
4114                        config = FcFontRenderConfig::default();
4115                    }
4116                    "test" if state == State::InMatchFont => {
4117                        in_test = true;
4118                        test_name = None;
4119                    }
4120                    "edit" if state == State::InMatchFont => {
4121                        current_edit_name = None;
4122                    }
4123                    "bool" | "double" | "const" | "string" | "int" => {
4124                        if state == State::InTestFamily || state == State::InEdit {
4125                            value_tag = Some(tag.to_owned());
4126                            current_value = None;
4127                        }
4128                    }
4129                    _ => {}
4130                }
4131            }
4132            Attribute { local, value, .. } => {
4133                let attr_name = local.as_str();
4134                let attr_value = value.as_str();
4135
4136                match attr_name {
4137                    "target" => {
4138                        if attr_value == "font" {
4139                            match_is_font_target = true;
4140                        }
4141                    }
4142                    "name" => {
4143                        if in_test && state == State::InMatchFont {
4144                            test_name = Some(attr_value.to_owned());
4145                        } else if state == State::InMatchFont {
4146                            current_edit_name = Some(attr_value.to_owned());
4147                        }
4148                    }
4149                    _ => {}
4150                }
4151            }
4152            Text { text, .. } => {
4153                let text = text.as_str().trim();
4154                if !text.is_empty() && (state == State::InTestFamily || state == State::InEdit) {
4155                    current_value = Some(text.to_owned());
4156                }
4157            }
4158            ElementEnd { end, .. } => {
4159                match end {
4160                    xmlparser::ElementEnd::Open => {
4161                        // Tag just opened (after attributes processed)
4162                        if match_is_font_target && state == State::Idle {
4163                            state = State::InMatchFont;
4164                            match_is_font_target = false;
4165                        } else if in_test {
4166                            if test_name.as_deref() == Some("family") {
4167                                state = State::InTestFamily;
4168                            }
4169                            in_test = false;
4170                        } else if current_edit_name.is_some() && state == State::InMatchFont {
4171                            state = State::InEdit;
4172                        }
4173                    }
4174                    xmlparser::ElementEnd::Close(_, local) => {
4175                        let tag = local.as_str();
4176                        match tag {
4177                            "match" => {
4178                                // End of match block: store config if we have a family
4179                                if let Some(family) = current_family.take() {
4180                                    let empty = FcFontRenderConfig::default();
4181                                    if config != empty {
4182                                        configs.insert(family, config.clone());
4183                                    }
4184                                }
4185                                state = State::Idle;
4186                                config = FcFontRenderConfig::default();
4187                            }
4188                            "test" => {
4189                                if state == State::InTestFamily {
4190                                    // Extract the family name from the value we collected
4191                                    if let Some(ref val) = current_value {
4192                                        current_family = Some(val.clone());
4193                                    }
4194                                    state = State::InMatchFont;
4195                                }
4196                                current_value = None;
4197                                value_tag = None;
4198                            }
4199                            "edit" => {
4200                                if state == State::InEdit {
4201                                    // Apply the collected value to the config
4202                                    if let (Some(ref name), Some(ref val)) = (&current_edit_name, &current_value) {
4203                                        apply_edit_value(&mut config, name, val, value_tag.as_deref());
4204                                    }
4205                                    state = State::InMatchFont;
4206                                }
4207                                current_edit_name = None;
4208                                current_value = None;
4209                                value_tag = None;
4210                            }
4211                            "bool" | "double" | "const" | "string" | "int" => {
4212                                // value_tag and current_value already set by Text handler
4213                            }
4214                            _ => {}
4215                        }
4216                    }
4217                    xmlparser::ElementEnd::Empty => {
4218                        // Self-closing tags: nothing to do
4219                    }
4220                }
4221            }
4222            _ => {}
4223        }
4224    }
4225}
4226
4227/// Apply a parsed edit value to the render config.
4228#[cfg(all(feature = "std", feature = "parsing", target_os = "linux"))]
4229fn apply_edit_value(
4230    config: &mut FcFontRenderConfig,
4231    edit_name: &str,
4232    value: &str,
4233    value_tag: Option<&str>,
4234) {
4235    match edit_name {
4236        "antialias" => {
4237            config.antialias = parse_bool_value(value);
4238        }
4239        "hinting" => {
4240            config.hinting = parse_bool_value(value);
4241        }
4242        "autohint" => {
4243            config.autohint = parse_bool_value(value);
4244        }
4245        "embeddedbitmap" => {
4246            config.embeddedbitmap = parse_bool_value(value);
4247        }
4248        "embolden" => {
4249            config.embolden = parse_bool_value(value);
4250        }
4251        "minspace" => {
4252            config.minspace = parse_bool_value(value);
4253        }
4254        "hintstyle" => {
4255            config.hintstyle = parse_hintstyle_const(value);
4256        }
4257        "rgba" => {
4258            config.rgba = parse_rgba_const(value);
4259        }
4260        "lcdfilter" => {
4261            config.lcdfilter = parse_lcdfilter_const(value);
4262        }
4263        "dpi" => {
4264            if let Ok(v) = value.parse::<f64>() {
4265                config.dpi = Some(v);
4266            }
4267        }
4268        "scale" => {
4269            if let Ok(v) = value.parse::<f64>() {
4270                config.scale = Some(v);
4271            }
4272        }
4273        _ => {
4274            // Unknown edit property, ignore
4275        }
4276    }
4277}
4278
4279#[cfg(all(feature = "std", feature = "parsing", target_os = "linux"))]
4280fn parse_bool_value(value: &str) -> Option<bool> {
4281    match value {
4282        "true" => Some(true),
4283        "false" => Some(false),
4284        _ => None,
4285    }
4286}
4287
4288#[cfg(all(feature = "std", feature = "parsing", target_os = "linux"))]
4289fn parse_hintstyle_const(value: &str) -> Option<FcHintStyle> {
4290    match value {
4291        "hintnone" => Some(FcHintStyle::None),
4292        "hintslight" => Some(FcHintStyle::Slight),
4293        "hintmedium" => Some(FcHintStyle::Medium),
4294        "hintfull" => Some(FcHintStyle::Full),
4295        _ => None,
4296    }
4297}
4298
4299#[cfg(all(feature = "std", feature = "parsing", target_os = "linux"))]
4300fn parse_rgba_const(value: &str) -> Option<FcRgba> {
4301    match value {
4302        "unknown" => Some(FcRgba::Unknown),
4303        "rgb" => Some(FcRgba::Rgb),
4304        "bgr" => Some(FcRgba::Bgr),
4305        "vrgb" => Some(FcRgba::Vrgb),
4306        "vbgr" => Some(FcRgba::Vbgr),
4307        "none" => Some(FcRgba::None),
4308        _ => None,
4309    }
4310}
4311
4312#[cfg(all(feature = "std", feature = "parsing", target_os = "linux"))]
4313fn parse_lcdfilter_const(value: &str) -> Option<FcLcdFilter> {
4314    match value {
4315        "lcdnone" => Some(FcLcdFilter::None),
4316        "lcddefault" => Some(FcLcdFilter::Default),
4317        "lcdlight" => Some(FcLcdFilter::Light),
4318        "lcdlegacy" => Some(FcLcdFilter::Legacy),
4319        _ => None,
4320    }
4321}
4322
4323// Unicode range bit positions to actual ranges (full table from OpenType spec).
4324// Based on: https://learn.microsoft.com/en-us/typography/opentype/spec/os2#ur
4325#[cfg(all(feature = "std", feature = "parsing"))]
4326const UNICODE_RANGE_MAPPINGS: &[(usize, u32, u32)] = &[
4327    // ulUnicodeRange1 (bits 0-31)
4328    (0, 0x0000, 0x007F), // Basic Latin
4329    (1, 0x0080, 0x00FF), // Latin-1 Supplement
4330    (2, 0x0100, 0x017F), // Latin Extended-A
4331    (3, 0x0180, 0x024F), // Latin Extended-B
4332    (4, 0x0250, 0x02AF), // IPA Extensions
4333    (5, 0x02B0, 0x02FF), // Spacing Modifier Letters
4334    (6, 0x0300, 0x036F), // Combining Diacritical Marks
4335    (7, 0x0370, 0x03FF), // Greek and Coptic
4336    (8, 0x2C80, 0x2CFF), // Coptic
4337    (9, 0x0400, 0x04FF), // Cyrillic
4338    (10, 0x0530, 0x058F), // Armenian
4339    (11, 0x0590, 0x05FF), // Hebrew
4340    (12, 0x0600, 0x06FF), // Arabic
4341    (13, 0x0700, 0x074F), // Syriac
4342    (14, 0x0780, 0x07BF), // Thaana
4343    (15, 0x0900, 0x097F), // Devanagari
4344    (16, 0x0980, 0x09FF), // Bengali
4345    (17, 0x0A00, 0x0A7F), // Gurmukhi
4346    (18, 0x0A80, 0x0AFF), // Gujarati
4347    (19, 0x0B00, 0x0B7F), // Oriya
4348    (20, 0x0B80, 0x0BFF), // Tamil
4349    (21, 0x0C00, 0x0C7F), // Telugu
4350    (22, 0x0C80, 0x0CFF), // Kannada
4351    (23, 0x0D00, 0x0D7F), // Malayalam
4352    (24, 0x0E00, 0x0E7F), // Thai
4353    (25, 0x0E80, 0x0EFF), // Lao
4354    (26, 0x10A0, 0x10FF), // Georgian
4355    (27, 0x1B00, 0x1B7F), // Balinese
4356    (28, 0x1100, 0x11FF), // Hangul Jamo
4357    (29, 0x1E00, 0x1EFF), // Latin Extended Additional
4358    (30, 0x1F00, 0x1FFF), // Greek Extended
4359    (31, 0x2000, 0x206F), // General Punctuation
4360    // ulUnicodeRange2 (bits 32-63)
4361    (32, 0x2070, 0x209F), // Superscripts And Subscripts
4362    (33, 0x20A0, 0x20CF), // Currency Symbols
4363    (34, 0x20D0, 0x20FF), // Combining Diacritical Marks For Symbols
4364    (35, 0x2100, 0x214F), // Letterlike Symbols
4365    (36, 0x2150, 0x218F), // Number Forms
4366    (37, 0x2190, 0x21FF), // Arrows
4367    (38, 0x2200, 0x22FF), // Mathematical Operators
4368    (39, 0x2300, 0x23FF), // Miscellaneous Technical
4369    (40, 0x2400, 0x243F), // Control Pictures
4370    (41, 0x2440, 0x245F), // Optical Character Recognition
4371    (42, 0x2460, 0x24FF), // Enclosed Alphanumerics
4372    (43, 0x2500, 0x257F), // Box Drawing
4373    (44, 0x2580, 0x259F), // Block Elements
4374    (45, 0x25A0, 0x25FF), // Geometric Shapes
4375    (46, 0x2600, 0x26FF), // Miscellaneous Symbols
4376    (47, 0x2700, 0x27BF), // Dingbats
4377    (48, 0x3000, 0x303F), // CJK Symbols And Punctuation
4378    (49, 0x3040, 0x309F), // Hiragana
4379    (50, 0x30A0, 0x30FF), // Katakana
4380    (51, 0x3100, 0x312F), // Bopomofo
4381    (52, 0x3130, 0x318F), // Hangul Compatibility Jamo
4382    (53, 0x3190, 0x319F), // Kanbun
4383    (54, 0x31A0, 0x31BF), // Bopomofo Extended
4384    (55, 0x31C0, 0x31EF), // CJK Strokes
4385    (56, 0x31F0, 0x31FF), // Katakana Phonetic Extensions
4386    (57, 0x3200, 0x32FF), // Enclosed CJK Letters And Months
4387    (58, 0x3300, 0x33FF), // CJK Compatibility
4388    (59, 0x4E00, 0x9FFF), // CJK Unified Ideographs
4389    (60, 0xA000, 0xA48F), // Yi Syllables
4390    (61, 0xA490, 0xA4CF), // Yi Radicals
4391    (62, 0xAC00, 0xD7AF), // Hangul Syllables
4392    (63, 0xD800, 0xDFFF), // Non-Plane 0 (note: surrogates, not directly usable)
4393    // ulUnicodeRange3 (bits 64-95)
4394    (64, 0x10000, 0x10FFFF), // Phoenician and other non-BMP (bit 64 indicates non-BMP support)
4395    (65, 0xF900, 0xFAFF), // CJK Compatibility Ideographs
4396    (66, 0xFB00, 0xFB4F), // Alphabetic Presentation Forms
4397    (67, 0xFB50, 0xFDFF), // Arabic Presentation Forms-A
4398    (68, 0xFE00, 0xFE0F), // Variation Selectors
4399    (69, 0xFE10, 0xFE1F), // Vertical Forms
4400    (70, 0xFE20, 0xFE2F), // Combining Half Marks
4401    (71, 0xFE30, 0xFE4F), // CJK Compatibility Forms
4402    (72, 0xFE50, 0xFE6F), // Small Form Variants
4403    (73, 0xFE70, 0xFEFF), // Arabic Presentation Forms-B
4404    (74, 0xFF00, 0xFFEF), // Halfwidth And Fullwidth Forms
4405    (75, 0xFFF0, 0xFFFF), // Specials
4406    (76, 0x0F00, 0x0FFF), // Tibetan
4407    (77, 0x0700, 0x074F), // Syriac
4408    (78, 0x0780, 0x07BF), // Thaana
4409    (79, 0x0D80, 0x0DFF), // Sinhala
4410    (80, 0x1000, 0x109F), // Myanmar
4411    (81, 0x1200, 0x137F), // Ethiopic
4412    (82, 0x13A0, 0x13FF), // Cherokee
4413    (83, 0x1400, 0x167F), // Unified Canadian Aboriginal Syllabics
4414    (84, 0x1680, 0x169F), // Ogham
4415    (85, 0x16A0, 0x16FF), // Runic
4416    (86, 0x1780, 0x17FF), // Khmer
4417    (87, 0x1800, 0x18AF), // Mongolian
4418    (88, 0x2800, 0x28FF), // Braille Patterns
4419    (89, 0xA000, 0xA48F), // Yi Syllables
4420    (90, 0x1680, 0x169F), // Ogham
4421    (91, 0x16A0, 0x16FF), // Runic
4422    (92, 0x1700, 0x171F), // Tagalog
4423    (93, 0x1720, 0x173F), // Hanunoo
4424    (94, 0x1740, 0x175F), // Buhid
4425    (95, 0x1760, 0x177F), // Tagbanwa
4426    // ulUnicodeRange4 (bits 96-127)
4427    (96, 0x1900, 0x194F), // Limbu
4428    (97, 0x1950, 0x197F), // Tai Le
4429    (98, 0x1980, 0x19DF), // New Tai Lue
4430    (99, 0x1A00, 0x1A1F), // Buginese
4431    (100, 0x2C00, 0x2C5F), // Glagolitic
4432    (101, 0x2D30, 0x2D7F), // Tifinagh
4433    (102, 0x4DC0, 0x4DFF), // Yijing Hexagram Symbols
4434    (103, 0xA800, 0xA82F), // Syloti Nagri
4435    (104, 0x10000, 0x1007F), // Linear B Syllabary
4436    (105, 0x10080, 0x100FF), // Linear B Ideograms
4437    (106, 0x10100, 0x1013F), // Aegean Numbers
4438    (107, 0x10140, 0x1018F), // Ancient Greek Numbers
4439    (108, 0x10300, 0x1032F), // Old Italic
4440    (109, 0x10330, 0x1034F), // Gothic
4441    (110, 0x10380, 0x1039F), // Ugaritic
4442    (111, 0x103A0, 0x103DF), // Old Persian
4443    (112, 0x10400, 0x1044F), // Deseret
4444    (113, 0x10450, 0x1047F), // Shavian
4445    (114, 0x10480, 0x104AF), // Osmanya
4446    (115, 0x10800, 0x1083F), // Cypriot Syllabary
4447    (116, 0x10A00, 0x10A5F), // Kharoshthi
4448    (117, 0x1D000, 0x1D0FF), // Byzantine Musical Symbols
4449    (118, 0x1D100, 0x1D1FF), // Musical Symbols
4450    (119, 0x1D200, 0x1D24F), // Ancient Greek Musical Notation
4451    (120, 0x1D300, 0x1D35F), // Tai Xuan Jing Symbols
4452    (121, 0x1D400, 0x1D7FF), // Mathematical Alphanumeric Symbols
4453    (122, 0x1F000, 0x1F02F), // Mahjong Tiles
4454    (123, 0x1F030, 0x1F09F), // Domino Tiles
4455    (124, 0x1F300, 0x1F9FF), // Miscellaneous Symbols And Pictographs (Emoji)
4456    (125, 0x1F680, 0x1F6FF), // Transport And Map Symbols
4457    (126, 0x1F700, 0x1F77F), // Alchemical Symbols
4458    (127, 0x1F900, 0x1F9FF), // Supplemental Symbols and Pictographs
4459];
4460
4461/// Intermediate parsed data from a single font face within a font file.
4462/// Used to share parsing logic between `FcParseFont` and `FcParseFontBytesInner`.
4463#[cfg(all(feature = "std", feature = "parsing"))]
4464struct ParsedFontFace {
4465    pattern: FcPattern,
4466    font_index: usize,
4467}
4468
4469/// Parse all font table data from a single font face and return the extracted patterns.
4470///
4471/// This is the shared core of `FcParseFont` and `FcParseFontBytesInner`:
4472/// TTC detection, font table parsing, OS/2/head/post reading, unicode range extraction,
4473/// CMAP verification, monospace detection, metadata extraction, and pattern creation.
4474#[cfg(all(feature = "std", feature = "parsing"))]
4475fn parse_font_faces(font_bytes: &[u8]) -> Option<Vec<ParsedFontFace>> {
4476    use allsorts::{
4477        binary::read::ReadScope,
4478        font_data::FontData,
4479        get_name::fontcode_get_name,
4480        post::PostTable,
4481        tables::{
4482            os2::Os2, HeadTable, NameTable,
4483        },
4484        tag,
4485    };
4486    use std::collections::BTreeSet;
4487
4488    const FONT_SPECIFIER_NAME_ID: u16 = 4;
4489    const FONT_SPECIFIER_FAMILY_ID: u16 = 1;
4490
4491    let max_fonts = if font_bytes.len() >= 12 && &font_bytes[0..4] == b"ttcf" {
4492        // Read numFonts from TTC header (offset 8, 4 bytes)
4493        let num_fonts =
4494            u32::from_be_bytes([font_bytes[8], font_bytes[9], font_bytes[10], font_bytes[11]]);
4495        // Cap at a reasonable maximum as a safety measure
4496        std::cmp::min(num_fonts as usize, 100)
4497    } else {
4498        // Not a collection, just one font
4499        1
4500    };
4501
4502    let scope = ReadScope::new(font_bytes);
4503    let font_file = scope.read::<FontData<'_>>().ok()?;
4504
4505    // Handle collections properly by iterating through all fonts
4506    let mut results = Vec::new();
4507
4508    for font_index in 0..max_fonts {
4509        let provider = font_file.table_provider(font_index).ok()?;
4510        let head_data = provider.table_data(tag::HEAD).ok()??.into_owned();
4511        let head_table = ReadScope::new(&head_data).read::<HeadTable>().ok()?;
4512
4513        let is_bold = head_table.is_bold();
4514        let is_italic = head_table.is_italic();
4515        let mut detected_monospace = None;
4516
4517        let post_data = provider.table_data(tag::POST).ok()??;
4518        if let Ok(post_table) = ReadScope::new(&post_data).read::<PostTable>() {
4519            // isFixedPitch here - https://learn.microsoft.com/en-us/typography/opentype/spec/post#header
4520            detected_monospace = Some(post_table.header.is_fixed_pitch != 0);
4521        }
4522
4523        // Get font properties from OS/2 table.
4524        //
4525        // OS/2 is OPTIONAL in TrueType - only OpenType requires it - and plenty
4526        // of real fonts ship without one, including the base-14 PDF font subsets
4527        // printpdf embeds. This used to be `.ok()??`, which turned "no OS/2" into
4528        // "not a font" and made the whole face invisible to the cache even though
4529        // allsorts parses it perfectly well.
4530        //
4531        // Nothing below actually needs OS/2: `head.macStyle` already gave us bold
4532        // and italic, `post`/`hmtx` cover monospace, and coverage has been
4533        // cmap-authoritative since 4.4.8. So treat it as the hint it is.
4534        let os2_data = provider.table_data(tag::OS_2).ok().flatten();
4535        let os2_table = os2_data
4536            .as_deref()
4537            .and_then(|data| ReadScope::new(data).read_dep::<Os2>(data.len()).ok());
4538
4539        // Extract additional style information
4540        let is_oblique = os2_table.as_ref().is_some_and(|os2| {
4541            os2.fs_selection
4542                .contains(allsorts::tables::os2::FsSelectionFlag::OBLIQUE)
4543        });
4544        // Without OS/2 the only weight signal is the `head.macStyle` bold bit, so
4545        // the face lands on Bold or Normal rather than a precise class.
4546        let weight = os2_table.as_ref().map_or(
4547            if is_bold { FcWeight::Bold } else { FcWeight::Normal },
4548            |os2| FcWeight::from_u16(os2.us_weight_class),
4549        );
4550        let stretch = os2_table
4551            .as_ref()
4552            .map_or(FcStretch::Normal, |os2| FcStretch::from_u16(os2.us_width_class));
4553
4554        // Extract unicode ranges from OS/2 table (fast, but may be inaccurate)
4555        // These are hints about what the font *should* support
4556        // For actual glyph coverage verification, query the font file directly
4557        let mut unicode_ranges = Vec::new();
4558
4559        // Process the 4 Unicode range bitfields from OS/2 table. All-zero when
4560        // there is no OS/2 table, which claims nothing and leaves the cmap union
4561        // below to supply the whole coverage set.
4562        let os2_ranges = os2_table.as_ref().map_or([0u32; 4], |os2| {
4563            [
4564                os2.ul_unicode_range1,
4565                os2.ul_unicode_range2,
4566                os2.ul_unicode_range3,
4567                os2.ul_unicode_range4,
4568            ]
4569        });
4570
4571        for &(bit, start, end) in UNICODE_RANGE_MAPPINGS {
4572            let range_idx = bit / 32;
4573            let bit_pos = bit % 32;
4574            if range_idx < 4 && (os2_ranges[range_idx] & (1 << bit_pos)) != 0 {
4575                unicode_ranges.push(UnicodeRange { start, end });
4576            }
4577        }
4578
4579        // OS/2's ulUnicodeRange bits are a HINT, never an upper bound.
4580        //
4581        // Fonts get these bits wrong in BOTH directions. Over-claiming is the
4582        // well-known one: a font advertises a block it has no glyphs for, so
4583        // verify against the cmap and drop what it cannot actually draw.
4584        //
4585        // Under-claiming is the one that used to be invisible here. Noto Sans
4586        // CJK's JP face has Hangul glyphs in its cmap but leaves the Hangul bits
4587        // clear; gating coverage on OS/2 made those codepoints permanently
4588        // unmatchable, so 한국어 resolved to no font at all even with the covering
4589        // face installed. fontconfig does not have this failure mode because it
4590        // builds FcCharSet by walking the cmap itself and never consults
4591        // ulUnicodeRange for coverage.
4592        //
4593        // So: prune what OS/2 over-claims, then union in everything the cmap
4594        // actually covers. Coverage becomes cmap-authoritative, and OS/2 is
4595        // reduced to a hint that can only ever lose an argument with the cmap.
4596        unicode_ranges = verify_unicode_ranges_with_cmap(&provider, unicode_ranges);
4597
4598        if let Some(cmap_ranges) = analyze_cmap_coverage(&provider) {
4599            unicode_ranges.extend(cmap_ranges);
4600        }
4601
4602        // The two sources use different block boundaries, so the union overlaps.
4603        // `calculate_unicode_coverage` sums range widths to rank fallbacks —
4604        // leaving overlaps in would double-count and inflate this font's score.
4605        unicode_ranges = FcFontCache::normalize_unicode_ranges(unicode_ranges);
4606
4607        // Use the shared detect_monospace helper for PANOSE + hmtx fallback
4608        let is_monospace = detect_monospace(&provider, os2_table.as_ref(), detected_monospace)
4609            .unwrap_or(false);
4610
4611        let name_data = provider.table_data(tag::NAME).ok()??.into_owned();
4612        let name_table = ReadScope::new(&name_data).read::<NameTable>().ok()?;
4613
4614        // Extract metadata from name table
4615        let mut metadata = FcFontMetadata::default();
4616
4617        const NAME_ID_COPYRIGHT: u16 = 0;
4618        const NAME_ID_FAMILY: u16 = 1;
4619        const NAME_ID_SUBFAMILY: u16 = 2;
4620        const NAME_ID_UNIQUE_ID: u16 = 3;
4621        const NAME_ID_FULL_NAME: u16 = 4;
4622        const NAME_ID_VERSION: u16 = 5;
4623        const NAME_ID_POSTSCRIPT_NAME: u16 = 6;
4624        const NAME_ID_TRADEMARK: u16 = 7;
4625        const NAME_ID_MANUFACTURER: u16 = 8;
4626        const NAME_ID_DESIGNER: u16 = 9;
4627        const NAME_ID_DESCRIPTION: u16 = 10;
4628        const NAME_ID_VENDOR_URL: u16 = 11;
4629        const NAME_ID_DESIGNER_URL: u16 = 12;
4630        const NAME_ID_LICENSE: u16 = 13;
4631        const NAME_ID_LICENSE_URL: u16 = 14;
4632        const NAME_ID_PREFERRED_FAMILY: u16 = 16;
4633        const NAME_ID_PREFERRED_SUBFAMILY: u16 = 17;
4634
4635        metadata.copyright = get_name_string(&name_data, NAME_ID_COPYRIGHT);
4636        metadata.font_family = get_name_string(&name_data, NAME_ID_FAMILY);
4637        metadata.font_subfamily = get_name_string(&name_data, NAME_ID_SUBFAMILY);
4638        metadata.full_name = get_name_string(&name_data, NAME_ID_FULL_NAME);
4639        metadata.unique_id = get_name_string(&name_data, NAME_ID_UNIQUE_ID);
4640        metadata.version = get_name_string(&name_data, NAME_ID_VERSION);
4641        metadata.postscript_name = get_name_string(&name_data, NAME_ID_POSTSCRIPT_NAME);
4642        metadata.trademark = get_name_string(&name_data, NAME_ID_TRADEMARK);
4643        metadata.manufacturer = get_name_string(&name_data, NAME_ID_MANUFACTURER);
4644        metadata.designer = get_name_string(&name_data, NAME_ID_DESIGNER);
4645        metadata.id_description = get_name_string(&name_data, NAME_ID_DESCRIPTION);
4646        metadata.designer_url = get_name_string(&name_data, NAME_ID_DESIGNER_URL);
4647        metadata.manufacturer_url = get_name_string(&name_data, NAME_ID_VENDOR_URL);
4648        metadata.license = get_name_string(&name_data, NAME_ID_LICENSE);
4649        metadata.license_url = get_name_string(&name_data, NAME_ID_LICENSE_URL);
4650        metadata.preferred_family = get_name_string(&name_data, NAME_ID_PREFERRED_FAMILY);
4651        metadata.preferred_subfamily = get_name_string(&name_data, NAME_ID_PREFERRED_SUBFAMILY);
4652
4653        // One font can support multiple patterns
4654        let mut f_family = None;
4655
4656        let patterns = name_table
4657            .name_records
4658            .iter()
4659            .filter_map(|name_record| {
4660                let name_id = name_record.name_id;
4661                if name_id == FONT_SPECIFIER_FAMILY_ID {
4662                    if let Ok(Some(family)) =
4663                        fontcode_get_name(&name_data, FONT_SPECIFIER_FAMILY_ID)
4664                    {
4665                        f_family = Some(family);
4666                    }
4667                    None
4668                } else if name_id == FONT_SPECIFIER_NAME_ID {
4669                    let family = f_family.as_ref()?;
4670                    let name = fontcode_get_name(&name_data, FONT_SPECIFIER_NAME_ID).ok()??;
4671                    if name.to_bytes().is_empty() {
4672                        None
4673                    } else {
4674                        let mut name_str =
4675                            String::from_utf8_lossy(name.to_bytes()).to_string();
4676                        let mut family_str =
4677                            String::from_utf8_lossy(family.as_bytes()).to_string();
4678                        if name_str.starts_with('.') {
4679                            name_str = name_str[1..].to_string();
4680                        }
4681                        if family_str.starts_with('.') {
4682                            family_str = family_str[1..].to_string();
4683                        }
4684                        Some((
4685                            FcPattern {
4686                                name: Some(name_str),
4687                                family: Some(family_str),
4688                                bold: if is_bold {
4689                                    PatternMatch::True
4690                                } else {
4691                                    PatternMatch::False
4692                                },
4693                                italic: if is_italic {
4694                                    PatternMatch::True
4695                                } else {
4696                                    PatternMatch::False
4697                                },
4698                                oblique: if is_oblique {
4699                                    PatternMatch::True
4700                                } else {
4701                                    PatternMatch::False
4702                                },
4703                                monospace: if is_monospace {
4704                                    PatternMatch::True
4705                                } else {
4706                                    PatternMatch::False
4707                                },
4708                                condensed: if stretch <= FcStretch::Condensed {
4709                                    PatternMatch::True
4710                                } else {
4711                                    PatternMatch::False
4712                                },
4713                                weight,
4714                                stretch,
4715                                unicode_ranges: unicode_ranges.clone(),
4716                                metadata: metadata.clone(),
4717                                render_config: FcFontRenderConfig::default(),
4718                            },
4719                            font_index,
4720                        ))
4721                    }
4722                } else {
4723                    None
4724                }
4725            })
4726            .collect::<BTreeSet<_>>();
4727
4728        results.extend(patterns.into_iter().map(|(pat, idx)| ParsedFontFace {
4729            pattern: pat,
4730            font_index: idx,
4731        }));
4732    }
4733
4734    if results.is_empty() {
4735        None
4736    } else {
4737        Some(results)
4738    }
4739}
4740
4741// Remaining implementation for font scanning, parsing, etc.
4742#[cfg(all(feature = "std", feature = "parsing"))]
4743pub(crate) fn FcParseFont(filepath: &PathBuf) -> Option<Vec<(FcPattern, FcFontPath)>> {
4744    #[cfg(all(not(target_family = "wasm"), feature = "std"))]
4745    use mmapio::MmapOptions;
4746    use std::fs::File;
4747
4748    // Try parsing the font file and see if the postscript name matches
4749    let file = File::open(filepath).ok()?;
4750
4751    #[cfg(all(not(target_family = "wasm"), feature = "std"))]
4752    let font_bytes = unsafe { MmapOptions::new().map(&file).ok()? };
4753
4754    #[cfg(not(all(not(target_family = "wasm"), feature = "std")))]
4755    let font_bytes = std::fs::read(filepath).ok()?;
4756
4757    let faces = parse_font_faces(&font_bytes[..])?;
4758    let path_str = filepath.to_string_lossy().to_string();
4759    // Hash once per file — every face of a .ttc shares this value,
4760    // so the shared-bytes cache can return the same Arc<[u8]> for
4761    // all of them. Use the cheap sampled variant so the scout doesn't
4762    // page-fault the full file into RSS just to produce a dedup key.
4763    let bytes_hash = crate::utils::content_dedup_hash_u64(&font_bytes[..]);
4764
4765    Some(
4766        faces
4767            .into_iter()
4768            .map(|face| {
4769                (
4770                    face.pattern,
4771                    FcFontPath {
4772                        path: path_str.clone(),
4773                        font_index: face.font_index,
4774                        bytes_hash,
4775                    },
4776                )
4777            })
4778            .collect(),
4779    )
4780}
4781
4782/// Coverage info returned by a fast-probe parse.
4783///
4784/// Produced by [`FcParseFontFaceFast`] / [`FcProbeCoverage`] — the
4785/// v4.2 "cheap cmap-only" entry point. Unlike `parse_font_faces`,
4786/// this path does **not** read NAME, OS/2, POST, HHEA, HMTX, HEAD's
4787/// style metadata, or anything else. It only reads the table
4788/// directory, `head.macStyle` (2 bytes), and the cmap subtable that
4789/// matches the codepoints we care about. ~1 ms/face on warm FS
4790/// cache vs ~13 ms for the full parse.
4791///
4792/// The `pattern.unicode_ranges` is populated from the *actual* cmap
4793/// contents (one `UnicodeRange` per covered codepoint in the input
4794/// set) rather than the OS/2 `ulUnicodeRange` bitfield. That's more
4795/// precise (OS/2 bits lie on many fonts — they're hints, not ground
4796/// truth) and means `FontFallbackChain::resolve_char`'s coverage
4797/// check matches what the shaper can actually render.
4798#[cfg(all(feature = "std", feature = "parsing"))]
4799#[derive(Debug, Clone)]
4800pub struct FastCoverage {
4801    /// Metadata pattern with `unicode_ranges` populated from the
4802    /// codepoints this face covered from the request set. `name` /
4803    /// `family` fields are left empty — callers already have the
4804    /// filename-guessed family in [`FcFontRegistry.known_paths`];
4805    /// we avoid the NAME table read entirely.
4806    pub pattern: FcPattern,
4807    /// Subset of the input codepoints that this face covers (maps
4808    /// to a non-zero gid via the best cmap subtable). May be empty
4809    /// if the face covers none, in which case callers should fall
4810    /// through to the next candidate path.
4811    pub covered: alloc::collections::BTreeSet<char>,
4812    /// `head.macStyle.bold` (bit 0).
4813    pub is_bold: bool,
4814    /// `head.macStyle.italic` (bit 1).
4815    pub is_italic: bool,
4816}
4817
4818/// Fast per-face coverage probe.
4819///
4820/// Opens the provided font bytes as a `FontData` (detects TTC
4821/// collections), walks the given face, reads `head.macStyle` for
4822/// bold/italic flags, picks the best cmap subtable, and records
4823/// which of the requested codepoints have a non-zero gid.
4824///
4825/// Cost: table-dir parse + head (54 bytes) + cmap (5-100 KiB,
4826/// faulted in from mmap). No heap allocation besides the
4827/// covered-codepoints set and the returned `FcPattern`.
4828///
4829/// Returns `None` only if the font bytes are structurally bad or
4830/// the face index is out of range — empty coverage returns
4831/// `Some` with `covered.is_empty()`, so the caller can distinguish
4832/// "this face doesn't have the char we want" (try next face) from
4833/// "this file is corrupt" (give up on the whole file).
4834#[cfg(all(feature = "std", feature = "parsing"))]
4835#[allow(non_snake_case)]
4836pub fn FcParseFontFaceFast(
4837    font_bytes: &[u8],
4838    font_index: usize,
4839    codepoints: &alloc::collections::BTreeSet<char>,
4840) -> Option<FastCoverage> {
4841    use allsorts::{
4842        binary::read::ReadScope,
4843        font_data::FontData,
4844        tables::{
4845            cmap::{Cmap, CmapSubtable},
4846            FontTableProvider, HeadTable,
4847        },
4848        tag,
4849    };
4850
4851    let scope = ReadScope::new(font_bytes);
4852    let font_file = scope.read::<FontData<'_>>().ok()?;
4853    let provider = font_file.table_provider(font_index).ok()?;
4854
4855    // head — 54 bytes, macStyle at offset 44. Cheap.
4856    let head_data = provider.table_data(tag::HEAD).ok()??;
4857    let head_table = ReadScope::new(&head_data).read::<HeadTable>().ok()?;
4858    let is_bold = head_table.is_bold();
4859    let is_italic = head_table.is_italic();
4860
4861    // cmap — find the best Unicode subtable, probe each codepoint.
4862    // The mmap page-cache only faults in the bytes we touch.
4863    let cmap_data = provider.table_data(tag::CMAP).ok()??;
4864    let cmap = ReadScope::new(&cmap_data).read::<Cmap<'_>>().ok()?;
4865    let encoding_record = find_best_cmap_subtable(&cmap)?;
4866    let cmap_subtable = ReadScope::new(&cmap_data)
4867        .offset(encoding_record.offset as usize)
4868        .read::<CmapSubtable<'_>>()
4869        .ok()?;
4870
4871    let mut covered: alloc::collections::BTreeSet<char> =
4872        alloc::collections::BTreeSet::new();
4873    let mut covered_ranges: Vec<UnicodeRange> = Vec::new();
4874    for ch in codepoints {
4875        let cp = *ch as u32;
4876        if let Ok(Some(gid)) = cmap_subtable.map_glyph(cp) {
4877            if gid != 0 {
4878                covered.insert(*ch);
4879                // Accumulate into ranges for the FcPattern. Merge
4880                // adjacent codepoints so `unicode_ranges` stays
4881                // compact (common case on Western text: one range).
4882                if let Some(last) = covered_ranges.last_mut() {
4883                    if cp == last.end + 1 {
4884                        last.end = cp;
4885                        continue;
4886                    }
4887                }
4888                covered_ranges.push(UnicodeRange { start: cp, end: cp });
4889            }
4890        }
4891    }
4892
4893    let weight = if is_bold {
4894        FcWeight::Bold
4895    } else {
4896        FcWeight::Normal
4897    };
4898    let italic_match = if is_italic {
4899        PatternMatch::True
4900    } else {
4901        PatternMatch::False
4902    };
4903
4904    let pattern = FcPattern {
4905        name: None,
4906        family: None,
4907        weight,
4908        italic: italic_match,
4909        oblique: PatternMatch::DontCare,
4910        monospace: PatternMatch::DontCare,
4911        unicode_ranges: covered_ranges,
4912        ..Default::default()
4913    };
4914
4915    Some(FastCoverage {
4916        pattern,
4917        covered,
4918        is_bold,
4919        is_italic,
4920    })
4921}
4922
4923/// Count the number of faces inside a TTC, or `1` for a single-face
4924/// font file. Used by [`FcFontRegistry::request_fonts_fast`] to
4925/// iterate every face in a `.ttc` without paying the full-parse
4926/// cost (the TTC header is 12 bytes).
4927#[cfg(all(feature = "std", feature = "parsing"))]
4928#[allow(non_snake_case)]
4929pub fn FcCountFontFaces(font_bytes: &[u8]) -> usize {
4930    if font_bytes.len() >= 12 && &font_bytes[0..4] == b"ttcf" {
4931        let num_fonts = u32::from_be_bytes([
4932            font_bytes[8], font_bytes[9], font_bytes[10], font_bytes[11],
4933        ]);
4934        // Same cap as parse_font_faces, for safety.
4935        std::cmp::min(num_fonts as usize, 100).max(1)
4936    } else {
4937        1
4938    }
4939}
4940
4941/// Parse font bytes and extract font patterns for in-memory fonts.
4942///
4943/// This is the public API for parsing in-memory font data to create
4944/// `(FcPattern, FcFont)` tuples that can be added to an `FcFontCache`
4945/// via `with_memory_fonts()`.
4946///
4947/// # Arguments
4948/// * `font_bytes` - The raw bytes of a TrueType/OpenType font file
4949/// * `font_id` - An identifier string for this font (used internally)
4950///
4951/// # Returns
4952/// A vector of `(FcPattern, FcFont)` tuples, one for each font face in the file.
4953/// Returns `None` if the font could not be parsed.
4954///
4955/// # Example
4956/// ```ignore
4957/// use rust_fontconfig::{FcFontCache, FcParseFontBytes};
4958///
4959/// let font_bytes = include_bytes!("path/to/font.ttf");
4960/// let mut cache = FcFontCache::default();
4961///
4962/// if let Some(fonts) = FcParseFontBytes(font_bytes, "MyFont") {
4963///     cache.with_memory_fonts(fonts);
4964/// }
4965/// ```
4966#[cfg(all(feature = "std", feature = "parsing"))]
4967#[allow(non_snake_case)]
4968pub fn FcParseFontBytes(font_bytes: &[u8], font_id: &str) -> Option<Vec<(FcPattern, FcFont)>> {
4969    FcParseFontBytesInner(font_bytes, font_id)
4970}
4971
4972/// Internal implementation for parsing font bytes.
4973/// Delegates to `parse_font_faces` for shared parsing logic and wraps results as `FcFont`.
4974#[cfg(all(feature = "std", feature = "parsing"))]
4975fn FcParseFontBytesInner(font_bytes: &[u8], font_id: &str) -> Option<Vec<(FcPattern, FcFont)>> {
4976    let faces = parse_font_faces(font_bytes)?;
4977    let id = font_id.to_string();
4978    let bytes = font_bytes.to_vec();
4979
4980    Some(
4981        faces
4982            .into_iter()
4983            .map(|face| {
4984                (
4985                    face.pattern,
4986                    FcFont {
4987                        bytes: bytes.clone(),
4988                        font_index: face.font_index,
4989                        id: id.clone(),
4990                    },
4991                )
4992            })
4993            .collect(),
4994    )
4995}
4996
4997#[cfg(all(feature = "std", feature = "parsing"))]
4998fn FcScanDirectoriesInner(paths: &[(Option<String>, String)]) -> Vec<(FcPattern, FcFontPath)> {
4999    #[cfg(all(feature = "multithreading", not(target_family = "wasm")))]
5000    {
5001        use rayon::prelude::*;
5002
5003        // scan directories in parallel
5004        paths
5005            .par_iter()
5006            .filter_map(|(prefix, p)| {
5007                process_path(prefix, PathBuf::from(p), false).map(FcScanSingleDirectoryRecursive)
5008            })
5009            .flatten()
5010            .collect()
5011    }
5012    // wasm has no rayon (it's target-gated off), so even with `multithreading`
5013    // enabled wasm falls back to the sequential path.
5014    #[cfg(not(all(feature = "multithreading", not(target_family = "wasm"))))]
5015    {
5016        paths
5017            .iter()
5018            .filter_map(|(prefix, p)| {
5019                process_path(prefix, PathBuf::from(p), false).map(FcScanSingleDirectoryRecursive)
5020            })
5021            .flatten()
5022            .collect()
5023    }
5024}
5025
5026/// Recursively collect all files from a directory (no parsing, no allsorts).
5027#[cfg(feature = "std")]
5028fn FcCollectFontFilesRecursive(dir: PathBuf) -> Vec<PathBuf> {
5029    let mut files = Vec::new();
5030    let mut dirs_to_parse = vec![dir];
5031
5032    loop {
5033        let mut new_dirs = Vec::new();
5034        for dir in &dirs_to_parse {
5035            let entries = match std::fs::read_dir(dir) {
5036                Ok(o) => o,
5037                Err(_) => continue,
5038            };
5039            for entry in entries.flatten() {
5040                let path = entry.path();
5041                if path.is_dir() {
5042                    new_dirs.push(path);
5043                } else {
5044                    files.push(path);
5045                }
5046            }
5047        }
5048        if new_dirs.is_empty() {
5049            break;
5050        }
5051        dirs_to_parse = new_dirs;
5052    }
5053
5054    files
5055}
5056
5057#[cfg(all(feature = "std", feature = "parsing"))]
5058fn FcScanSingleDirectoryRecursive(dir: PathBuf) -> Vec<(FcPattern, FcFontPath)> {
5059    let files = FcCollectFontFilesRecursive(dir);
5060    FcParseFontFiles(&files)
5061}
5062
5063#[cfg(all(feature = "std", feature = "parsing"))]
5064fn FcParseFontFiles(files_to_parse: &[PathBuf]) -> Vec<(FcPattern, FcFontPath)> {
5065    let result = {
5066        #[cfg(all(feature = "multithreading", not(target_family = "wasm")))]
5067        {
5068            use rayon::prelude::*;
5069
5070            files_to_parse
5071                .par_iter()
5072                .filter_map(|file| FcParseFont(file))
5073                .collect::<Vec<Vec<_>>>()
5074        }
5075        #[cfg(not(all(feature = "multithreading", not(target_family = "wasm"))))]
5076        {
5077            files_to_parse
5078                .iter()
5079                .filter_map(|file| FcParseFont(file))
5080                .collect::<Vec<Vec<_>>>()
5081        }
5082    };
5083
5084    result.into_iter().flat_map(|f| f.into_iter()).collect()
5085}
5086
5087#[cfg(all(feature = "std", feature = "parsing"))]
5088/// Takes a path & prefix and resolves them to a usable path, or `None` if they're unsupported/unavailable.
5089///
5090/// Behaviour is based on: https://www.freedesktop.org/software/fontconfig/fontconfig-user.html
5091fn process_path(
5092    prefix: &Option<String>,
5093    mut path: PathBuf,
5094    is_include_path: bool,
5095) -> Option<PathBuf> {
5096    use std::env::var;
5097
5098    const HOME_SHORTCUT: &str = "~";
5099    const CWD_PATH: &str = ".";
5100
5101    const HOME_ENV_VAR: &str = "HOME";
5102    const XDG_CONFIG_HOME_ENV_VAR: &str = "XDG_CONFIG_HOME";
5103    const XDG_CONFIG_HOME_DEFAULT_PATH_SUFFIX: &str = ".config";
5104    const XDG_DATA_HOME_ENV_VAR: &str = "XDG_DATA_HOME";
5105    const XDG_DATA_HOME_DEFAULT_PATH_SUFFIX: &str = ".local/share";
5106
5107    const PREFIX_CWD: &str = "cwd";
5108    const PREFIX_DEFAULT: &str = "default";
5109    const PREFIX_XDG: &str = "xdg";
5110
5111    // These three could, in theory, be cached, but the work required to do so outweighs the minor benefits
5112    fn get_home_value() -> Option<PathBuf> {
5113        var(HOME_ENV_VAR).ok().map(PathBuf::from)
5114    }
5115    fn get_xdg_config_home_value() -> Option<PathBuf> {
5116        var(XDG_CONFIG_HOME_ENV_VAR)
5117            .ok()
5118            .map(PathBuf::from)
5119            .or_else(|| {
5120                get_home_value()
5121                    .map(|home_path| home_path.join(XDG_CONFIG_HOME_DEFAULT_PATH_SUFFIX))
5122            })
5123    }
5124    fn get_xdg_data_home_value() -> Option<PathBuf> {
5125        var(XDG_DATA_HOME_ENV_VAR)
5126            .ok()
5127            .map(PathBuf::from)
5128            .or_else(|| {
5129                get_home_value().map(|home_path| home_path.join(XDG_DATA_HOME_DEFAULT_PATH_SUFFIX))
5130            })
5131    }
5132
5133    // Resolve the tilde character in the path, if present
5134    if path.starts_with(HOME_SHORTCUT) {
5135        if let Some(home_path) = get_home_value() {
5136            path = home_path.join(
5137                path.strip_prefix(HOME_SHORTCUT)
5138                    .expect("already checked that it starts with the prefix"),
5139            );
5140        } else {
5141            return None;
5142        }
5143    }
5144
5145    // Resolve prefix values
5146    match prefix {
5147        Some(prefix) => match prefix.as_str() {
5148            PREFIX_CWD | PREFIX_DEFAULT => {
5149                let mut new_path = PathBuf::from(CWD_PATH);
5150                new_path.push(path);
5151
5152                Some(new_path)
5153            }
5154            PREFIX_XDG => {
5155                if is_include_path {
5156                    get_xdg_config_home_value()
5157                        .map(|xdg_config_home_path| xdg_config_home_path.join(path))
5158                } else {
5159                    get_xdg_data_home_value()
5160                        .map(|xdg_data_home_path| xdg_data_home_path.join(path))
5161                }
5162            }
5163            _ => None, // Unsupported prefix
5164        },
5165        None => Some(path),
5166    }
5167}
5168
5169// Helper function to extract a string from the name table
5170#[cfg(all(feature = "std", feature = "parsing"))]
5171fn get_name_string(name_data: &[u8], name_id: u16) -> Option<String> {
5172    fontcode_get_name(name_data, name_id)
5173        .ok()
5174        .flatten()
5175        .map(|name| String::from_utf8_lossy(name.to_bytes()).to_string())
5176}
5177
5178/// Representative test codepoints for each Unicode block.
5179/// These are carefully chosen to be actual script characters (not punctuation/symbols)
5180/// that a font claiming to support this script should definitely have.
5181#[cfg(all(feature = "std", feature = "parsing"))]
5182fn get_verification_codepoints(start: u32, end: u32) -> Vec<u32> {
5183    match start {
5184        // Basic Latin - test uppercase, lowercase, and digits
5185        0x0000 => vec!['A' as u32, 'M' as u32, 'Z' as u32, 'a' as u32, 'm' as u32, 'z' as u32],
5186        // Latin-1 Supplement - common accented letters
5187        0x0080 => vec![0x00C0, 0x00C9, 0x00D1, 0x00E0, 0x00E9, 0x00F1], // À É Ñ à é ñ
5188        // Latin Extended-A
5189        0x0100 => vec![0x0100, 0x0110, 0x0141, 0x0152, 0x0160], // Ā Đ Ł Œ Š
5190        // Latin Extended-B
5191        0x0180 => vec![0x0180, 0x01A0, 0x01B0, 0x01CD], // ƀ Ơ ư Ǎ
5192        // IPA Extensions
5193        0x0250 => vec![0x0250, 0x0259, 0x026A, 0x0279], // ɐ ə ɪ ɹ
5194        // Greek and Coptic
5195        0x0370 => vec![0x0391, 0x0392, 0x0393, 0x03B1, 0x03B2, 0x03C9], // Α Β Γ α β ω
5196        // Cyrillic
5197        0x0400 => vec![0x0410, 0x0411, 0x0412, 0x0430, 0x0431, 0x042F], // А Б В а б Я
5198        // Armenian
5199        0x0530 => vec![0x0531, 0x0532, 0x0533, 0x0561, 0x0562], // Ա Բ Գ ա բ
5200        // Hebrew
5201        0x0590 => vec![0x05D0, 0x05D1, 0x05D2, 0x05E9, 0x05EA], // א ב ג ש ת
5202        // Arabic
5203        0x0600 => vec![0x0627, 0x0628, 0x062A, 0x062C, 0x0645], // ا ب ت ج م
5204        // Syriac
5205        0x0700 => vec![0x0710, 0x0712, 0x0713, 0x0715], // ܐ ܒ ܓ ܕ
5206        // Devanagari
5207        0x0900 => vec![0x0905, 0x0906, 0x0915, 0x0916, 0x0939], // अ आ क ख ह
5208        // Bengali
5209        0x0980 => vec![0x0985, 0x0986, 0x0995, 0x0996], // অ আ ক খ
5210        // Gurmukhi
5211        0x0A00 => vec![0x0A05, 0x0A06, 0x0A15, 0x0A16], // ਅ ਆ ਕ ਖ
5212        // Gujarati
5213        0x0A80 => vec![0x0A85, 0x0A86, 0x0A95, 0x0A96], // અ આ ક ખ
5214        // Oriya
5215        0x0B00 => vec![0x0B05, 0x0B06, 0x0B15, 0x0B16], // ଅ ଆ କ ଖ
5216        // Tamil
5217        0x0B80 => vec![0x0B85, 0x0B86, 0x0B95, 0x0BA4], // அ ஆ க த
5218        // Telugu
5219        0x0C00 => vec![0x0C05, 0x0C06, 0x0C15, 0x0C16], // అ ఆ క ఖ
5220        // Kannada
5221        0x0C80 => vec![0x0C85, 0x0C86, 0x0C95, 0x0C96], // ಅ ಆ ಕ ಖ
5222        // Malayalam
5223        0x0D00 => vec![0x0D05, 0x0D06, 0x0D15, 0x0D16], // അ ആ ക ഖ
5224        // Thai
5225        0x0E00 => vec![0x0E01, 0x0E02, 0x0E04, 0x0E07, 0x0E40], // ก ข ค ง เ
5226        // Lao
5227        0x0E80 => vec![0x0E81, 0x0E82, 0x0E84, 0x0E87], // ກ ຂ ຄ ງ
5228        // Myanmar
5229        0x1000 => vec![0x1000, 0x1001, 0x1002, 0x1010, 0x1019], // က ခ ဂ တ မ
5230        // Georgian
5231        0x10A0 => vec![0x10D0, 0x10D1, 0x10D2, 0x10D3], // ა ბ გ დ
5232        // Hangul Jamo
5233        0x1100 => vec![0x1100, 0x1102, 0x1103, 0x1161, 0x1162], // ᄀ ᄂ ᄃ ᅡ ᅢ
5234        // Ethiopic
5235        0x1200 => vec![0x1200, 0x1208, 0x1210, 0x1218], // ሀ ለ ሐ መ
5236        // Cherokee
5237        0x13A0 => vec![0x13A0, 0x13A1, 0x13A2, 0x13A3], // Ꭰ Ꭱ Ꭲ Ꭳ
5238        // Khmer
5239        0x1780 => vec![0x1780, 0x1781, 0x1782, 0x1783], // ក ខ គ ឃ
5240        // Mongolian
5241        0x1800 => vec![0x1820, 0x1821, 0x1822, 0x1823], // ᠠ ᠡ ᠢ ᠣ
5242        // Hiragana
5243        0x3040 => vec![0x3042, 0x3044, 0x3046, 0x304B, 0x304D, 0x3093], // あ い う か き ん
5244        // Katakana
5245        0x30A0 => vec![0x30A2, 0x30A4, 0x30A6, 0x30AB, 0x30AD, 0x30F3], // ア イ ウ カ キ ン
5246        // Bopomofo
5247        0x3100 => vec![0x3105, 0x3106, 0x3107, 0x3108], // ㄅ ㄆ ㄇ ㄈ
5248        // CJK Unified Ideographs - common characters
5249        0x4E00 => vec![0x4E00, 0x4E2D, 0x4EBA, 0x5927, 0x65E5, 0x6708], // 一 中 人 大 日 月
5250        // Hangul Syllables
5251        0xAC00 => vec![0xAC00, 0xAC01, 0xAC04, 0xB098, 0xB2E4], // 가 각 간 나 다
5252        // CJK Compatibility Ideographs
5253        0xF900 => vec![0xF900, 0xF901, 0xF902], // 豈 更 車
5254        // Arabic Presentation Forms-A
5255        0xFB50 => vec![0xFB50, 0xFB51, 0xFB52, 0xFB56], // ﭐ ﭑ ﭒ ﭖ
5256        // Arabic Presentation Forms-B
5257        0xFE70 => vec![0xFE70, 0xFE72, 0xFE74, 0xFE76], // ﹰ ﹲ ﹴ ﹶ
5258        // Halfwidth and Fullwidth Forms
5259        0xFF00 => vec![0xFF01, 0xFF21, 0xFF41, 0xFF61], // ! A a 。
5260        // Default: sample at regular intervals
5261        _ => {
5262            let range_size = end - start;
5263            if range_size > 20 {
5264                vec![
5265                    start + range_size / 5,
5266                    start + 2 * range_size / 5,
5267                    start + 3 * range_size / 5,
5268                    start + 4 * range_size / 5,
5269                ]
5270            } else {
5271                vec![start, start + range_size / 2]
5272            }
5273        }
5274    }
5275}
5276
5277/// Find the best Unicode CMAP subtable from a font provider.
5278/// Tries multiple platform/encoding combinations in priority order.
5279#[cfg(all(feature = "std", feature = "parsing"))]
5280fn find_best_cmap_subtable<'a>(
5281    cmap: &allsorts::tables::cmap::Cmap<'a>,
5282) -> Option<allsorts::tables::cmap::EncodingRecord> {
5283    use allsorts::tables::cmap::{PlatformId, EncodingId};
5284
5285    cmap.find_subtable(PlatformId::UNICODE, EncodingId(3))
5286        .or_else(|| cmap.find_subtable(PlatformId::UNICODE, EncodingId(4)))
5287        .or_else(|| cmap.find_subtable(PlatformId::WINDOWS, EncodingId(1)))
5288        .or_else(|| cmap.find_subtable(PlatformId::WINDOWS, EncodingId(10)))
5289        .or_else(|| cmap.find_subtable(PlatformId::UNICODE, EncodingId(0)))
5290        .or_else(|| cmap.find_subtable(PlatformId::UNICODE, EncodingId(1)))
5291}
5292
5293/// Verify OS/2 reported Unicode ranges against actual CMAP support.
5294/// Returns only ranges that are actually supported by the font's CMAP table.
5295#[cfg(all(feature = "std", feature = "parsing"))]
5296fn verify_unicode_ranges_with_cmap(
5297    provider: &impl FontTableProvider,
5298    os2_ranges: Vec<UnicodeRange>
5299) -> Vec<UnicodeRange> {
5300    use allsorts::tables::cmap::{Cmap, CmapSubtable};
5301
5302    if os2_ranges.is_empty() {
5303        return Vec::new();
5304    }
5305
5306    // Try to get CMAP subtable
5307    let cmap_data = match provider.table_data(tag::CMAP) {
5308        Ok(Some(data)) => data,
5309        _ => return os2_ranges, // Can't verify, trust OS/2
5310    };
5311
5312    let cmap = match ReadScope::new(&cmap_data).read::<Cmap<'_>>() {
5313        Ok(c) => c,
5314        Err(_) => return os2_ranges,
5315    };
5316
5317    let encoding_record = match find_best_cmap_subtable(&cmap) {
5318        Some(r) => r,
5319        None => return os2_ranges, // No suitable subtable, trust OS/2
5320    };
5321
5322    let cmap_subtable = match ReadScope::new(&cmap_data)
5323        .offset(encoding_record.offset as usize)
5324        .read::<CmapSubtable<'_>>()
5325    {
5326        Ok(st) => st,
5327        Err(_) => return os2_ranges,
5328    };
5329
5330    // Verify each range
5331    let mut verified_ranges = Vec::new();
5332
5333    for range in os2_ranges {
5334        let test_codepoints = get_verification_codepoints(range.start, range.end);
5335
5336        // Require at least 50% of test codepoints to have valid glyphs
5337        // This is stricter than before to avoid false positives
5338        let required_hits = (test_codepoints.len() + 1) / 2; // ceil(len/2)
5339        let mut hits = 0;
5340
5341        for cp in test_codepoints {
5342            if cp >= range.start && cp <= range.end {
5343                if let Ok(Some(gid)) = cmap_subtable.map_glyph(cp) {
5344                    if gid != 0 {
5345                        hits += 1;
5346                        if hits >= required_hits {
5347                            break;
5348                        }
5349                    }
5350                }
5351            }
5352        }
5353
5354        if hits >= required_hits {
5355            verified_ranges.push(range);
5356        }
5357    }
5358
5359    verified_ranges
5360}
5361
5362/// Analyze CMAP table to discover font coverage when OS/2 provides no info.
5363/// This is the fallback when OS/2 ulUnicodeRange bits are all zero.
5364#[cfg(all(feature = "std", feature = "parsing"))]
5365fn analyze_cmap_coverage(provider: &impl FontTableProvider) -> Option<Vec<UnicodeRange>> {
5366    use allsorts::tables::cmap::{Cmap, CmapSubtable};
5367
5368    let cmap_data = provider.table_data(tag::CMAP).ok()??;
5369    let cmap = ReadScope::new(&cmap_data).read::<Cmap<'_>>().ok()?;
5370
5371    let encoding_record = find_best_cmap_subtable(&cmap)?;
5372
5373    let cmap_subtable = ReadScope::new(&cmap_data)
5374        .offset(encoding_record.offset as usize)
5375        .read::<CmapSubtable<'_>>()
5376        .ok()?;
5377
5378    // Standard Unicode blocks to probe
5379    let blocks_to_check: &[(u32, u32)] = &[
5380        (0x0000, 0x007F), // Basic Latin
5381        (0x0080, 0x00FF), // Latin-1 Supplement
5382        (0x0100, 0x017F), // Latin Extended-A
5383        (0x0180, 0x024F), // Latin Extended-B
5384        (0x0250, 0x02AF), // IPA Extensions
5385        (0x0300, 0x036F), // Combining Diacritical Marks
5386        (0x0370, 0x03FF), // Greek and Coptic
5387        (0x0400, 0x04FF), // Cyrillic
5388        (0x0500, 0x052F), // Cyrillic Supplement
5389        (0x0530, 0x058F), // Armenian
5390        (0x0590, 0x05FF), // Hebrew
5391        (0x0600, 0x06FF), // Arabic
5392        (0x0700, 0x074F), // Syriac
5393        (0x0900, 0x097F), // Devanagari
5394        (0x0980, 0x09FF), // Bengali
5395        (0x0A00, 0x0A7F), // Gurmukhi
5396        (0x0A80, 0x0AFF), // Gujarati
5397        (0x0B00, 0x0B7F), // Oriya
5398        (0x0B80, 0x0BFF), // Tamil
5399        (0x0C00, 0x0C7F), // Telugu
5400        (0x0C80, 0x0CFF), // Kannada
5401        (0x0D00, 0x0D7F), // Malayalam
5402        (0x0E00, 0x0E7F), // Thai
5403        (0x0E80, 0x0EFF), // Lao
5404        (0x1000, 0x109F), // Myanmar
5405        (0x10A0, 0x10FF), // Georgian
5406        (0x1100, 0x11FF), // Hangul Jamo
5407        (0x1200, 0x137F), // Ethiopic
5408        (0x13A0, 0x13FF), // Cherokee
5409        (0x1780, 0x17FF), // Khmer
5410        (0x1800, 0x18AF), // Mongolian
5411        (0x2000, 0x206F), // General Punctuation
5412        (0x20A0, 0x20CF), // Currency Symbols
5413        (0x2100, 0x214F), // Letterlike Symbols
5414        (0x2190, 0x21FF), // Arrows
5415        (0x2200, 0x22FF), // Mathematical Operators
5416        (0x2500, 0x257F), // Box Drawing
5417        (0x25A0, 0x25FF), // Geometric Shapes
5418        (0x2600, 0x26FF), // Miscellaneous Symbols
5419        (0x3000, 0x303F), // CJK Symbols and Punctuation
5420        (0x3040, 0x309F), // Hiragana
5421        (0x30A0, 0x30FF), // Katakana
5422        (0x3100, 0x312F), // Bopomofo
5423        (0x3130, 0x318F), // Hangul Compatibility Jamo
5424        (0x4E00, 0x9FFF), // CJK Unified Ideographs
5425        (0xAC00, 0xD7AF), // Hangul Syllables
5426        (0xF900, 0xFAFF), // CJK Compatibility Ideographs
5427        (0xFB50, 0xFDFF), // Arabic Presentation Forms-A
5428        (0xFE70, 0xFEFF), // Arabic Presentation Forms-B
5429        (0xFF00, 0xFFEF), // Halfwidth and Fullwidth Forms
5430    ];
5431
5432    let mut ranges = Vec::new();
5433
5434    for &(start, end) in blocks_to_check {
5435        let test_codepoints = get_verification_codepoints(start, end);
5436        let required_hits = (test_codepoints.len() + 1) / 2;
5437        // Blocks the font does NOT have are the common case: a Latin face covers a
5438        // handful of the ~50 probed here. Stop as soon as the remaining probes
5439        // cannot reach `required_hits` rather than testing every codepoint to
5440        // confirm a foregone conclusion. Same verdict, fewer cmap lookups.
5441        let allowed_misses = test_codepoints.len() - required_hits;
5442        let mut hits = 0;
5443        let mut misses = 0;
5444
5445        for cp in test_codepoints {
5446            if matches!(cmap_subtable.map_glyph(cp), Ok(Some(gid)) if gid != 0) {
5447                hits += 1;
5448                if hits >= required_hits {
5449                    break;
5450                }
5451            } else {
5452                misses += 1;
5453                if misses > allowed_misses {
5454                    break;
5455                }
5456            }
5457        }
5458
5459        if hits >= required_hits {
5460            ranges.push(UnicodeRange { start, end });
5461        }
5462    }
5463
5464    if ranges.is_empty() {
5465        None
5466    } else {
5467        Some(ranges)
5468    }
5469}
5470
5471// Helper function to extract unicode ranges (unused, kept for reference)
5472#[cfg(all(feature = "std", feature = "parsing"))]
5473#[allow(dead_code)]
5474fn extract_unicode_ranges(os2_table: &Os2) -> Vec<UnicodeRange> {
5475    let mut unicode_ranges = Vec::new();
5476
5477    let ranges = [
5478        os2_table.ul_unicode_range1,
5479        os2_table.ul_unicode_range2,
5480        os2_table.ul_unicode_range3,
5481        os2_table.ul_unicode_range4,
5482    ];
5483
5484    for &(bit, start, end) in UNICODE_RANGE_MAPPINGS {
5485        let range_idx = bit / 32;
5486        let bit_pos = bit % 32;
5487        if range_idx < 4 && (ranges[range_idx] & (1 << bit_pos)) != 0 {
5488            unicode_ranges.push(UnicodeRange { start, end });
5489        }
5490    }
5491
5492    unicode_ranges
5493}
5494
5495// Helper function to detect if a font is monospace
5496#[cfg(all(feature = "std", feature = "parsing"))]
5497fn detect_monospace(
5498    provider: &impl FontTableProvider,
5499    os2_table: Option<&Os2>,
5500    detected_monospace: Option<bool>,
5501) -> Option<bool> {
5502    if let Some(is_monospace) = detected_monospace {
5503        return Some(is_monospace);
5504    }
5505
5506    // Try using PANOSE classification, when there is an OS/2 table to read it
5507    // from; otherwise fall straight through to the hmtx width check.
5508    if let Some(os2_table) = os2_table {
5509        if os2_table.panose[0] == 2 {
5510            // 2 = Latin Text
5511            return Some(os2_table.panose[3] == 9); // 9 = Monospaced
5512        }
5513    }
5514
5515    // Check glyph widths in hmtx table
5516    let hhea_data = provider.table_data(tag::HHEA).ok()??;
5517    let hhea_table = ReadScope::new(&hhea_data).read::<HheaTable>().ok()?;
5518    let maxp_data = provider.table_data(tag::MAXP).ok()??;
5519    let maxp_table = ReadScope::new(&maxp_data).read::<MaxpTable>().ok()?;
5520    let hmtx_data = provider.table_data(tag::HMTX).ok()??;
5521    let hmtx_table = ReadScope::new(&hmtx_data)
5522        .read_dep::<HmtxTable<'_>>((
5523            usize::from(maxp_table.num_glyphs),
5524            usize::from(hhea_table.num_h_metrics),
5525        ))
5526        .ok()?;
5527
5528    let mut monospace = true;
5529    let mut last_advance = 0;
5530
5531    // Check if all advance widths are the same
5532    for i in 0..hhea_table.num_h_metrics as usize {
5533        let advance = hmtx_table.h_metrics.read_item(i).ok()?.advance_width;
5534        if i > 0 && advance != last_advance {
5535            monospace = false;
5536            break;
5537        }
5538        last_advance = advance;
5539    }
5540
5541    Some(monospace)
5542}
5543
5544/// Guess font metadata from a filename using the existing tokenizer.
5545///
5546/// Uses [`config::tokenize_font_stem`] and [`config::FONT_STYLE_TOKENS`]
5547/// to extract the family name and detect style hints from the filename.
5548///
5549/// Only compiled for the filename-only (`not(parsing)`) scan path — its
5550/// sole caller is [`FcFontCache::build_from_filenames`]. With `parsing`
5551/// on, allsorts reads real metadata and this fallback is unused.
5552#[cfg(all(feature = "std", not(feature = "parsing")))]
5553fn pattern_from_filename(path: &std::path::Path) -> Option<FcPattern> {
5554    let ext = path.extension()?.to_str()?.to_ascii_lowercase();
5555    match ext.as_str() {
5556        "ttf" | "otf" | "ttc" | "woff" | "woff2" => {}
5557        _ => return None,
5558    }
5559
5560    let stem = path.file_stem()?.to_str()?;
5561    let all_tokens = crate::config::tokenize_lowercase(stem);
5562
5563    // Style detection: check if any token matches a known style keyword
5564    let has_token = |kw: &str| all_tokens.iter().any(|t| t == kw);
5565    let is_bold = has_token("bold") || has_token("heavy");
5566    let is_italic = has_token("italic");
5567    let is_oblique = has_token("oblique");
5568    let is_mono = has_token("mono") || has_token("monospace");
5569    let is_condensed = has_token("condensed");
5570
5571    // Family = non-style tokens joined
5572    let family_tokens = crate::config::tokenize_font_stem(stem);
5573    if family_tokens.is_empty() { return None; }
5574    let family = family_tokens.join(" ");
5575
5576    Some(FcPattern {
5577        name: Some(stem.to_string()),
5578        family: Some(family),
5579        bold: if is_bold { PatternMatch::True } else { PatternMatch::False },
5580        italic: if is_italic { PatternMatch::True } else { PatternMatch::False },
5581        oblique: if is_oblique { PatternMatch::True } else { PatternMatch::DontCare },
5582        monospace: if is_mono { PatternMatch::True } else { PatternMatch::DontCare },
5583        condensed: if is_condensed { PatternMatch::True } else { PatternMatch::DontCare },
5584        weight: if is_bold { FcWeight::Bold } else { FcWeight::Normal },
5585        stretch: if is_condensed { FcStretch::Condensed } else { FcStretch::Normal },
5586        unicode_ranges: Vec::new(),
5587        metadata: FcFontMetadata::default(),
5588        render_config: FcFontRenderConfig::default(),
5589    })
5590}
5591
5592#[cfg(all(test, feature = "std", feature = "parsing", target_os = "linux"))]
5593mod system_alias_tests {
5594    use super::*;
5595
5596    const SAMPLE: &str = r#"<?xml version="1.0"?>
5597<fontconfig>
5598  <alias>
5599    <family>sans-serif</family>
5600    <prefer>
5601      <family>Noto Sans</family>
5602      <family>DejaVu Sans</family>
5603    </prefer>
5604  </alias>
5605  <alias>
5606    <family>Arial</family>
5607    <prefer><family>Liberation Sans</family></prefer>
5608  </alias>
5609  <alias binding="same">
5610    <family>monospace</family>
5611    <prefer><family>Noto Sans Mono</family></prefer>
5612  </alias>
5613</fontconfig>"#;
5614
5615    const SECOND_FILE: &str = r#"<fontconfig>
5616  <alias>
5617    <family>sans-serif</family>
5618    <prefer>
5619      <family>Ubuntu</family>
5620      <family>Noto Sans</family>
5621    </prefer>
5622  </alias>
5623</fontconfig>"#;
5624
5625    #[test]
5626    fn alias_blocks_parse_with_order_and_dedup_across_files() {
5627        let mut aliases = BTreeMap::new();
5628        ParseFontsConfAliases(SAMPLE, &mut aliases);
5629        ParseFontsConfAliases(SECOND_FILE, &mut aliases);
5630        let key = crate::utils::normalize_family_name("sans-serif");
5631        assert_eq!(
5632            aliases.get(&key).map(Vec::as_slice),
5633            Some(&["Noto Sans".to_string(), "DejaVu Sans".to_string(), "Ubuntu".to_string()][..]),
5634            "prefer entries append across files in include order, deduplicated"
5635        );
5636        assert_eq!(
5637            aliases.get("arial").map(Vec::as_slice),
5638            Some(&["Liberation Sans".to_string()][..]),
5639            "named-family aliases parse too (key normalized)"
5640        );
5641        assert_eq!(
5642            aliases.get("monospace").map(Vec::as_slice),
5643            Some(&["Noto Sans Mono".to_string()][..]),
5644            "alias attributes (binding=...) do not confuse the parser"
5645        );
5646    }
5647
5648    #[test]
5649    fn config_first_expansion_beats_the_builtin_lists() {
5650        let cache = FcFontCache::default();
5651        {
5652            let mut state = cache.state_write();
5653            let mut aliases = BTreeMap::new();
5654            ParseFontsConfAliases(SAMPLE, &mut aliases);
5655            state.system_aliases = aliases;
5656        }
5657        let out = cache.expand_font_families_config_first(
5658            &["Arial".to_string(), "sans-serif".to_string()],
5659            OperatingSystem::Linux,
5660            &[],
5661        );
5662        assert_eq!(
5663            out,
5664            vec![
5665                "Arial".to_string(),            // named family keeps itself first
5666                "Liberation Sans".to_string(),  // its configured substitution
5667                "Noto Sans".to_string(),        // sans-serif configured prefer list
5668                "DejaVu Sans".to_string(),
5669            ],
5670            "configured preferences resolve the stack; no built-in list entries leak in"
5671        );
5672    }
5673
5674    #[test]
5675    fn generic_family_without_config_falls_back_to_builtin_lists() {
5676        let cache = FcFontCache::default();
5677        let out = cache.expand_font_families_config_first(
5678            &["sans-serif".to_string()],
5679            OperatingSystem::Linux,
5680            &[],
5681        );
5682        assert!(
5683            !out.is_empty() && out.iter().any(|f| f == "DejaVu Sans"),
5684            "no configuration parsed -> the built-in candidates are the last resort: {out:?}"
5685        );
5686    }
5687}