Skip to main content

par_term_fonts/font_manager/
mod.rs

1//! Font management with fallback chain for comprehensive Unicode coverage.
2//!
3//! This module provides font loading, fallback chain management, and text shaping
4//! capabilities. It supports:
5//! - Primary font with bold/italic/bold-italic variants
6//! - Unicode range-specific fonts (e.g., CJK, emoji)
7//! - Automatic fallback chain for missing glyphs
8//! - HarfBuzz-based text shaping via rustybuzz
9
10mod fallbacks;
11mod loader;
12mod types;
13
14use std::sync::Arc;
15
16use anyhow::Result;
17use fontdb::Database;
18use swash::FontRef;
19
20use crate::text_shaper::{ShapedRun, ShapingOptions, TextShaper};
21
22pub use fallbacks::FALLBACK_FAMILIES;
23pub use types::{FontData, UnicodeRangeFont};
24
25/// Manages multiple fonts with fallback chain.
26///
27/// Font indices are assigned as follows:
28/// - 0: Primary/regular font
29/// - 1: Bold font (if available)
30/// - 2: Italic font (if available)
31/// - 3: Bold-italic font (if available)
32/// - 4..4+N: Unicode range fonts (N = number of range fonts)
33/// - 4+N..: Fallback fonts
34pub struct FontManager {
35    /// Primary font (regular weight, from config or embedded)
36    primary: FontData,
37
38    /// Bold font (optional, falls back to primary if not specified)
39    bold: Option<FontData>,
40
41    /// Italic font (optional, falls back to primary if not specified)
42    italic: Option<FontData>,
43
44    /// Bold italic font (optional, falls back to primary if not specified)
45    bold_italic: Option<FontData>,
46
47    /// Unicode range-specific fonts (checked before fallbacks)
48    range_fonts: Vec<UnicodeRangeFont>,
49
50    /// Fallback fonts in priority order
51    fallbacks: Vec<FontData>,
52
53    /// Font database for system font queries
54    #[allow(dead_code)]
55    font_db: Database,
56
57    /// Text shaper for ligatures and complex scripts
58    text_shaper: TextShaper,
59}
60
61impl FontManager {
62    /// Create a new FontManager with primary font and system fallbacks.
63    ///
64    /// # Arguments
65    /// * `primary_family` - Regular/normal weight font family name
66    /// * `bold_family` - Bold weight font family name (optional)
67    /// * `italic_family` - Italic font family name (optional)
68    /// * `bold_italic_family` - Bold italic font family name (optional)
69    /// * `font_ranges` - Unicode range-specific font mappings
70    pub fn new(
71        primary_family: Option<&str>,
72        bold_family: Option<&str>,
73        italic_family: Option<&str>,
74        bold_italic_family: Option<&str>,
75        font_ranges: &[par_term_config::FontRange],
76    ) -> Result<Self> {
77        let mut font_db = Database::new();
78
79        // Load system fonts
80        font_db.load_system_fonts();
81        log::info!("Loaded {} system fonts", font_db.len());
82
83        // Load primary font
84        let primary = Self::load_primary_font(&mut font_db, primary_family)?;
85
86        // Build fallback chain
87        let fallbacks = Self::build_fallback_chain(&mut font_db);
88        log::info!("Loaded {} fallback fonts", fallbacks.len());
89
90        // Load styled font variants
91        let bold = Self::load_styled_font(
92            &mut font_db,
93            bold_family,
94            "bold",
95            fontdb::Weight::BOLD,
96            None,
97        );
98        let italic = Self::load_styled_font(
99            &mut font_db,
100            italic_family,
101            "italic",
102            fontdb::Weight::NORMAL,
103            Some(fontdb::Style::Italic),
104        );
105        let bold_italic = Self::load_styled_font(
106            &mut font_db,
107            bold_italic_family,
108            "bold italic",
109            fontdb::Weight::BOLD,
110            Some(fontdb::Style::Italic),
111        );
112
113        // Load Unicode range-specific fonts
114        let range_fonts = Self::load_range_fonts(&mut font_db, font_ranges);
115
116        Ok(FontManager {
117            primary,
118            bold,
119            italic,
120            bold_italic,
121            range_fonts,
122            fallbacks,
123            font_db,
124            text_shaper: TextShaper::new(),
125        })
126    }
127
128    /// Load the primary font from system or embedded.
129    fn load_primary_font(font_db: &mut Database, family: Option<&str>) -> Result<FontData> {
130        if let Some(family_name) = family {
131            log::info!("Attempting to load primary font: {}", family_name);
132            if let Some(font_data) = loader::load_font_from_db(font_db, family_name) {
133                log::info!("Successfully loaded primary font: {}", family_name);
134                return Ok(font_data);
135            }
136            log::warn!(
137                "Primary font '{}' not found, using embedded DejaVu Sans Mono",
138                family_name
139            );
140        } else {
141            log::info!("No primary font specified, using embedded DejaVu Sans Mono");
142        }
143        loader::load_embedded_font()
144    }
145
146    /// Build the fallback font chain from available system fonts.
147    fn build_fallback_chain(font_db: &mut Database) -> Vec<FontData> {
148        let mut fallbacks = Vec::new();
149        for family_name in FALLBACK_FAMILIES {
150            if let Some(font_data) = loader::load_font_from_db(font_db, family_name) {
151                log::debug!("Added fallback font: {}", family_name);
152                fallbacks.push(font_data);
153            }
154        }
155        fallbacks
156    }
157
158    /// Load a styled font variant (bold, italic, or bold-italic).
159    fn load_styled_font(
160        font_db: &mut Database,
161        family: Option<&str>,
162        style_name: &str,
163        weight: fontdb::Weight,
164        style: Option<fontdb::Style>,
165    ) -> Option<FontData> {
166        family.and_then(|family_name| {
167            log::info!("Attempting to load {} font: {}", style_name, family_name);
168            let font_data =
169                loader::load_font_from_db_with_style(font_db, family_name, Some(weight), style);
170            if font_data.is_some() {
171                log::info!("Successfully loaded {} font: {}", style_name, family_name);
172            } else {
173                log::warn!(
174                    "{} font '{}' not found, will use primary font",
175                    style_name
176                        .chars()
177                        .next()
178                        .unwrap()
179                        .to_uppercase()
180                        .chain(style_name.chars().skip(1))
181                        .collect::<String>(),
182                    family_name
183                );
184            }
185            font_data
186        })
187    }
188
189    /// Load Unicode range-specific fonts.
190    fn load_range_fonts(
191        font_db: &mut Database,
192        font_ranges: &[par_term_config::FontRange],
193    ) -> Vec<UnicodeRangeFont> {
194        let mut range_fonts = Vec::new();
195        let mut next_font_index = 4; // After styled fonts (0-3)
196
197        for range in font_ranges {
198            log::info!(
199                "Loading range font for U+{:04X}-U+{:04X}: {}",
200                range.start,
201                range.end,
202                range.font_family
203            );
204
205            if let Some(font_data) = loader::load_font_from_db(font_db, &range.font_family) {
206                range_fonts.push(UnicodeRangeFont {
207                    start: range.start,
208                    end: range.end,
209                    font: font_data,
210                    font_index: next_font_index,
211                });
212                log::info!(
213                    "Successfully loaded range font: {} (index {})",
214                    range.font_family,
215                    next_font_index
216                );
217                next_font_index += 1;
218            } else {
219                log::warn!(
220                    "Range font '{}' not found for U+{:04X}-U+{:04X}, skipping",
221                    range.font_family,
222                    range.start,
223                    range.end
224                );
225            }
226        }
227        range_fonts
228    }
229
230    /// Get the appropriate font based on bold and italic attributes.
231    fn get_styled_font(&self, bold: bool, italic: bool) -> &FontRef<'static> {
232        match (bold, italic) {
233            (true, true) => self
234                .bold_italic
235                .as_ref()
236                .map(|f| &f.font_ref)
237                .unwrap_or(&self.primary.font_ref),
238            (true, false) => self
239                .bold
240                .as_ref()
241                .map(|f| &f.font_ref)
242                .unwrap_or(&self.primary.font_ref),
243            (false, true) => self
244                .italic
245                .as_ref()
246                .map(|f| &f.font_ref)
247                .unwrap_or(&self.primary.font_ref),
248            (false, false) => &self.primary.font_ref,
249        }
250    }
251
252    /// Find a glyph for a character across the font fallback chain.
253    ///
254    /// # Arguments
255    /// * `character` - Character to find glyph for
256    /// * `bold` - Whether text should be bold
257    /// * `italic` - Whether text should be italic
258    ///
259    /// # Returns
260    /// `(font_index, glyph_id)` where font_index identifies which font contains the glyph.
261    pub fn find_glyph(&self, character: char, bold: bool, italic: bool) -> Option<(usize, u16)> {
262        // Try styled font first
263        let styled_font = self.get_styled_font(bold, italic);
264        let glyph_id = styled_font.charmap().map(character);
265        if glyph_id != 0 {
266            let font_idx = match (bold, italic) {
267                (true, true) if self.bold_italic.is_some() => 3,
268                (true, false) if self.bold.is_some() => 1,
269                (false, true) if self.italic.is_some() => 2,
270                _ => 0,
271            };
272            return Some((font_idx, glyph_id));
273        }
274
275        // Check Unicode range-specific fonts
276        let char_code = character as u32;
277        for range_font in &self.range_fonts {
278            if char_code >= range_font.start && char_code <= range_font.end {
279                let glyph_id = range_font.font.font_ref.charmap().map(character);
280                if glyph_id != 0 {
281                    log::info!(
282                        "βœ“ Character '{}' (U+{:04X}) found in range font U+{:04X}-U+{:04X} (index {})",
283                        character,
284                        char_code,
285                        range_font.start,
286                        range_font.end,
287                        range_font.font_index
288                    );
289                    return Some((range_font.font_index, glyph_id));
290                } else {
291                    log::warn!(
292                        "βœ— Character '{}' (U+{:04X}) in range U+{:04X}-U+{:04X} but glyph_id=0 (not in font)",
293                        character,
294                        char_code,
295                        range_font.start,
296                        range_font.end
297                    );
298                }
299            }
300        }
301
302        // Try fallback fonts
303        let fallback_start_index = 4 + self.range_fonts.len();
304        for (idx, fallback) in self.fallbacks.iter().enumerate() {
305            let glyph_id = fallback.font_ref.charmap().map(character);
306            if glyph_id != 0 {
307                if !character.is_ascii()
308                    || character.is_ascii_punctuation()
309                    || character.is_ascii_graphic()
310                {
311                    log::debug!(
312                        "Character '{}' (U+{:04X}) found in fallback font index {}",
313                        character,
314                        character as u32,
315                        fallback_start_index + idx
316                    );
317                }
318                return Some((fallback_start_index + idx, glyph_id));
319            }
320        }
321
322        log::debug!(
323            "Character '{}' (U+{:04X}) not found in any font ({} total fonts)",
324            character,
325            character as u32,
326            self.font_count()
327        );
328        None
329    }
330
331    /// Find the font index for a character in range fonts.
332    #[allow(dead_code)]
333    pub fn find_range_font_index(&self, char_code: u32) -> Option<(usize, u16)> {
334        for range_font in &self.range_fonts {
335            if char_code >= range_font.start && char_code <= range_font.end {
336                let character = char::from_u32(char_code)?;
337                let glyph_id = range_font.font.font_ref.charmap().map(character);
338                if glyph_id != 0 {
339                    return Some((range_font.font_index, glyph_id));
340                }
341            }
342        }
343        None
344    }
345
346    /// Get font reference by index.
347    ///
348    /// # Arguments
349    /// * `font_index` - Font index (see struct documentation for layout)
350    pub fn get_font(&self, font_index: usize) -> Option<&FontRef<'static>> {
351        match font_index {
352            0 => Some(&self.primary.font_ref),
353            1 => self.bold.as_ref().map(|f| &f.font_ref),
354            2 => self.italic.as_ref().map(|f| &f.font_ref),
355            3 => self.bold_italic.as_ref().map(|f| &f.font_ref),
356            idx if idx >= 4 => {
357                let range_offset = idx - 4;
358                if range_offset < self.range_fonts.len() {
359                    Some(&self.range_fonts[range_offset].font.font_ref)
360                } else {
361                    let fallback_offset = range_offset - self.range_fonts.len();
362                    self.fallbacks.get(fallback_offset).map(|fd| &fd.font_ref)
363                }
364            }
365            _ => None,
366        }
367    }
368
369    /// Get the primary font reference.
370    #[allow(dead_code)]
371    pub fn primary_font(&self) -> &FontRef<'static> {
372        &self.primary.font_ref
373    }
374
375    /// Get number of fonts loaded (primary + styled + range + fallbacks).
376    pub fn font_count(&self) -> usize {
377        let styled_count = 1
378            + self.bold.is_some() as usize
379            + self.italic.is_some() as usize
380            + self.bold_italic.is_some() as usize;
381        styled_count + self.range_fonts.len() + self.fallbacks.len()
382    }
383
384    /// Get raw font data bytes for a font index.
385    #[allow(dead_code)]
386    pub fn get_font_data(&self, font_index: usize) -> Option<&[u8]> {
387        match font_index {
388            0 => Some(self.primary.data.as_slice()),
389            1 => self.bold.as_ref().map(|f| f.data.as_slice()),
390            2 => self.italic.as_ref().map(|f| f.data.as_slice()),
391            3 => self.bold_italic.as_ref().map(|f| f.data.as_slice()),
392            idx if idx >= 4 => {
393                let range_offset = idx - 4;
394                if range_offset < self.range_fonts.len() {
395                    Some(self.range_fonts[range_offset].font.data.as_slice())
396                } else {
397                    let fallback_offset = range_offset - self.range_fonts.len();
398                    self.fallbacks
399                        .get(fallback_offset)
400                        .map(|fd| fd.data.as_slice())
401                }
402            }
403            _ => None,
404        }
405    }
406
407    /// Get Arc reference to font data for a font index.
408    fn get_font_data_arc(&self, font_index: usize) -> Arc<Vec<u8>> {
409        match font_index {
410            0 => Arc::clone(&self.primary.data),
411            1 => self
412                .bold
413                .as_ref()
414                .map(|f| Arc::clone(&f.data))
415                .unwrap_or_else(|| Arc::clone(&self.primary.data)),
416            2 => self
417                .italic
418                .as_ref()
419                .map(|f| Arc::clone(&f.data))
420                .unwrap_or_else(|| Arc::clone(&self.primary.data)),
421            3 => self
422                .bold_italic
423                .as_ref()
424                .map(|f| Arc::clone(&f.data))
425                .unwrap_or_else(|| Arc::clone(&self.primary.data)),
426            idx if idx >= 4 => {
427                let range_offset = idx - 4;
428                if range_offset < self.range_fonts.len() {
429                    Arc::clone(&self.range_fonts[range_offset].font.data)
430                } else {
431                    let fallback_offset = range_offset - self.range_fonts.len();
432                    self.fallbacks
433                        .get(fallback_offset)
434                        .map(|fd| Arc::clone(&fd.data))
435                        .unwrap_or_else(|| Arc::clone(&self.primary.data))
436                }
437            }
438            _ => Arc::clone(&self.primary.data),
439        }
440    }
441
442    /// Shape text using the appropriate font.
443    ///
444    /// Uses HarfBuzz (via rustybuzz) for ligatures, kerning, and complex script support.
445    #[allow(dead_code)]
446    pub fn shape_text(
447        &mut self,
448        text: &str,
449        bold: bool,
450        italic: bool,
451        options: ShapingOptions,
452    ) -> Arc<ShapedRun> {
453        let font_index = self.get_styled_font_index(bold, italic);
454        let font_data_arc = self.get_font_data_arc(font_index);
455        self.text_shaper
456            .shape_text(text, font_data_arc.as_slice(), font_index, options)
457    }
458
459    /// Shape text using a specific font index.
460    #[allow(dead_code)]
461    pub fn shape_text_with_font_index(
462        &mut self,
463        text: &str,
464        font_index: usize,
465        options: ShapingOptions,
466    ) -> Arc<ShapedRun> {
467        let font_data_arc = self.get_font_data_arc(font_index);
468        self.text_shaper
469            .shape_text(text, font_data_arc.as_slice(), font_index, options)
470    }
471
472    /// Clear the text shaping cache.
473    #[allow(dead_code)]
474    pub fn clear_shape_cache(&mut self) {
475        self.text_shaper.clear_cache();
476    }
477
478    /// Get the current size of the shape cache.
479    #[allow(dead_code)]
480    pub fn shape_cache_size(&self) -> usize {
481        self.text_shaper.cache_size()
482    }
483
484    /// Find glyph(s) for an entire grapheme cluster.
485    ///
486    /// This is essential for rendering multi-character sequences like:
487    /// - Flag emoji (πŸ‡ΊπŸ‡Έ) - regional indicator pairs
488    /// - ZWJ sequences (πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦) - family emoji
489    /// - Skin tone modifiers (πŸ‘‹πŸ½)
490    /// - Combining characters (Γ© = e + acute accent)
491    ///
492    /// # Arguments
493    /// * `grapheme` - The grapheme cluster string (may be multiple Unicode codepoints)
494    /// * `bold` - Whether text should be bold
495    /// * `italic` - Whether text should be italic
496    ///
497    /// # Returns
498    /// `Some((font_index, glyph_id))` for the primary glyph representing the grapheme,
499    /// or `None` if no suitable glyph was found.
500    pub fn find_grapheme_glyph(
501        &mut self,
502        grapheme: &str,
503        bold: bool,
504        italic: bool,
505    ) -> Option<(usize, u16)> {
506        let chars: Vec<char> = grapheme.chars().collect();
507
508        // Fast path: single character graphemes use existing lookup
509        if chars.len() == 1 {
510            return self.find_glyph(chars[0], bold, italic);
511        }
512
513        // Multi-character grapheme: use text shaping to find the composed glyph
514        // First, determine which font to use based on the first character
515        let first_char = chars[0];
516        let char_code = first_char as u32;
517
518        // Check Unicode range-specific fonts first (emoji fonts)
519        for range_font in &self.range_fonts {
520            if char_code >= range_font.start && char_code <= range_font.end {
521                // Shape the grapheme with this font
522                let font_data = range_font.font.data.as_slice();
523                let options = ShapingOptions::default();
524                let shaped = self.text_shaper.shape_text(
525                    grapheme,
526                    font_data,
527                    range_font.font_index,
528                    options,
529                );
530
531                // Check if shaping produced a valid glyph
532                if !shaped.glyphs.is_empty() && shaped.glyphs[0].glyph_id != 0 {
533                    log::debug!(
534                        "Grapheme '{}' ({} chars) shaped to glyph {} in range font index {}",
535                        grapheme,
536                        chars.len(),
537                        shaped.glyphs[0].glyph_id,
538                        range_font.font_index
539                    );
540                    return Some((range_font.font_index, shaped.glyphs[0].glyph_id as u16));
541                }
542            }
543        }
544
545        // Try styled font
546        let font_index = self.get_styled_font_index(bold, italic);
547        let font_data_arc = self.get_font_data_arc(font_index);
548        let options = ShapingOptions::default();
549        let shaped =
550            self.text_shaper
551                .shape_text(grapheme, font_data_arc.as_slice(), font_index, options);
552
553        if !shaped.glyphs.is_empty() && shaped.glyphs[0].glyph_id != 0 {
554            log::debug!(
555                "Grapheme '{}' ({} chars) shaped to glyph {} in styled font index {}",
556                grapheme,
557                chars.len(),
558                shaped.glyphs[0].glyph_id,
559                font_index
560            );
561            return Some((font_index, shaped.glyphs[0].glyph_id as u16));
562        }
563
564        // Try fallback fonts
565        let fallback_start_index = 4 + self.range_fonts.len();
566        for (idx, fallback) in self.fallbacks.iter().enumerate() {
567            let font_idx = fallback_start_index + idx;
568            let options = ShapingOptions::default();
569            let shaped =
570                self.text_shaper
571                    .shape_text(grapheme, fallback.data.as_slice(), font_idx, options);
572
573            if !shaped.glyphs.is_empty() && shaped.glyphs[0].glyph_id != 0 {
574                log::debug!(
575                    "Grapheme '{}' ({} chars) shaped to glyph {} in fallback font index {}",
576                    grapheme,
577                    chars.len(),
578                    shaped.glyphs[0].glyph_id,
579                    font_idx
580                );
581                return Some((font_idx, shaped.glyphs[0].glyph_id as u16));
582            }
583        }
584
585        // Fallback: try to render just the first character
586        log::debug!(
587            "Grapheme '{}' ({} chars) not found as composed glyph, falling back to first char",
588            grapheme,
589            chars.len()
590        );
591        self.find_glyph(first_char, bold, italic)
592    }
593
594    /// Get the font index for a given style combination.
595    fn get_styled_font_index(&self, bold: bool, italic: bool) -> usize {
596        match (bold, italic) {
597            (true, true) if self.bold_italic.is_some() => 3,
598            (true, false) if self.bold.is_some() => 1,
599            (false, true) if self.italic.is_some() => 2,
600            _ => 0,
601        }
602    }
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608
609    #[test]
610    fn test_embedded_font_loads() {
611        let fm = FontManager::new(None, None, None, None, &[]);
612        assert!(fm.is_ok(), "FontManager should load with embedded font");
613        let fm = fm.unwrap();
614        assert!(fm.font_count() >= 1, "Should have at least one font");
615    }
616
617    #[test]
618    fn test_primary_font_glyph_lookup() {
619        let fm = FontManager::new(None, None, None, None, &[]).unwrap();
620        // ASCII characters should be found in the embedded font
621        let result = fm.find_glyph('A', false, false);
622        assert!(result.is_some(), "Should find glyph for 'A'");
623        let (font_idx, glyph_id) = result.unwrap();
624        assert_eq!(font_idx, 0, "Should be in primary font");
625        assert!(glyph_id > 0, "Glyph ID should be nonzero");
626    }
627
628    #[test]
629    fn test_get_font_by_index() {
630        let fm = FontManager::new(None, None, None, None, &[]).unwrap();
631        assert!(
632            fm.get_font(0).is_some(),
633            "Primary font should exist at index 0"
634        );
635    }
636}