@@ -1,5 +1,6 @@ |
| 1 | 1 | use fontdue::{Font, FontSettings}; |
| 2 | | -use std::collections::HashMap; |
| 2 | +use std::cell::RefCell; |
| 3 | +use std::collections::{HashMap, HashSet}; |
| 3 | 4 | use std::process::Command; |
| 4 | 5 | use std::sync::Arc; |
| 5 | 6 | use thiserror::Error; |
@@ -26,6 +27,10 @@ pub struct FontCache { |
| 26 | 27 | fonts: HashMap<FontStyle, Arc<Font>>, |
| 27 | 28 | /// Fallback fonts for missing glyphs (symbols, icons, etc.) |
| 28 | 29 | fallback_fonts: Vec<Arc<Font>>, |
| 30 | + /// Fonts discovered dynamically via fontconfig charset queries |
| 31 | + dynamic_fallbacks: RefCell<Vec<Arc<Font>>>, |
| 32 | + /// Codepoints we've already attempted fontconfig discovery for |
| 33 | + tried_codepoints: RefCell<HashSet<char>>, |
| 29 | 34 | size: f32, |
| 30 | 35 | cell_width: f32, |
| 31 | 36 | cell_height: f32, |
@@ -143,6 +148,8 @@ impl FontCache { |
| 143 | 148 | Ok(Self { |
| 144 | 149 | fonts, |
| 145 | 150 | fallback_fonts, |
| 151 | + dynamic_fallbacks: RefCell::new(Vec::new()), |
| 152 | + tried_codepoints: RefCell::new(HashSet::new()), |
| 146 | 153 | size, |
| 147 | 154 | cell_width, |
| 148 | 155 | cell_height, |
@@ -254,6 +261,8 @@ impl FontCache { |
| 254 | 261 | Self { |
| 255 | 262 | fonts: self.fonts.clone(), |
| 256 | 263 | fallback_fonts: self.fallback_fonts.clone(), |
| 264 | + dynamic_fallbacks: RefCell::new(self.dynamic_fallbacks.borrow().clone()), |
| 265 | + tried_codepoints: RefCell::new(self.tried_codepoints.borrow().clone()), |
| 257 | 266 | size: new_size, |
| 258 | 267 | cell_width, |
| 259 | 268 | cell_height, |
@@ -268,7 +277,9 @@ impl FontCache { |
| 268 | 277 | }) |
| 269 | 278 | } |
| 270 | 279 | |
| 271 | | - /// Rasterize a character, using fallback fonts if needed |
| 280 | + /// Rasterize a character, using fallback fonts if needed. |
| 281 | + /// Falls back through static fallbacks, then dynamically-discovered fonts, |
| 282 | + /// and finally queries fontconfig for a font containing the codepoint. |
| 272 | 283 | pub fn rasterize(&self, c: char, style: FontStyle) -> (fontdue::Metrics, Vec<u8>) { |
| 273 | 284 | let primary_font = self.font(style); |
| 274 | 285 | |
@@ -277,23 +288,88 @@ impl FontCache { |
| 277 | 288 | return primary_font.rasterize(c, self.size); |
| 278 | 289 | } |
| 279 | 290 | |
| 280 | | - // Try fallback fonts |
| 291 | + // Try static fallback fonts |
| 281 | 292 | for fallback in &self.fallback_fonts { |
| 282 | 293 | if fallback.lookup_glyph_index(c) != 0 { |
| 283 | 294 | return fallback.rasterize(c, self.size); |
| 284 | 295 | } |
| 285 | 296 | } |
| 286 | 297 | |
| 298 | + // Try already-discovered dynamic fallbacks |
| 299 | + for fallback in self.dynamic_fallbacks.borrow().iter() { |
| 300 | + if fallback.lookup_glyph_index(c) != 0 { |
| 301 | + return fallback.rasterize(c, self.size); |
| 302 | + } |
| 303 | + } |
| 304 | + |
| 305 | + // Ask fontconfig to find a font for this codepoint (once per codepoint) |
| 306 | + if !self.tried_codepoints.borrow().contains(&c) { |
| 307 | + self.tried_codepoints.borrow_mut().insert(c); |
| 308 | + if let Some(font) = self.discover_font_for_char(c) { |
| 309 | + let result = font.rasterize(c, self.size); |
| 310 | + self.dynamic_fallbacks.borrow_mut().push(font); |
| 311 | + return result; |
| 312 | + } |
| 313 | + } |
| 314 | + |
| 287 | 315 | // No font has this glyph - return from primary (will be placeholder/tofu) |
| 288 | 316 | primary_font.rasterize(c, self.size) |
| 289 | 317 | } |
| 290 | 318 | |
| 319 | + /// Query fontconfig for a font that contains the given character |
| 320 | + fn discover_font_for_char(&self, c: char) -> Option<Arc<Font>> { |
| 321 | + let codepoint = c as u32; |
| 322 | + let query = format!(":charset={:04x}", codepoint); |
| 323 | + |
| 324 | + let output = Command::new("fc-list") |
| 325 | + .args(["-f", "%{file}\n", &query]) |
| 326 | + .output() |
| 327 | + .ok()?; |
| 328 | + |
| 329 | + if !output.status.success() { |
| 330 | + return None; |
| 331 | + } |
| 332 | + |
| 333 | + let stdout = String::from_utf8_lossy(&output.stdout); |
| 334 | + let mut seen = HashSet::new(); |
| 335 | + |
| 336 | + for line in stdout.lines() { |
| 337 | + let path = line.trim(); |
| 338 | + if path.is_empty() || !seen.insert(path.to_string()) { |
| 339 | + continue; |
| 340 | + } |
| 341 | + |
| 342 | + // Skip color emoji fonts (fontdue can't rasterize bitmap/COLR fonts) |
| 343 | + if path.contains("ColorEmoji") { |
| 344 | + continue; |
| 345 | + } |
| 346 | + |
| 347 | + if let Ok(data) = std::fs::read(path) { |
| 348 | + if let Ok(font) = Font::from_bytes(data, FontSettings::default()) { |
| 349 | + if font.lookup_glyph_index(c) != 0 { |
| 350 | + tracing::debug!( |
| 351 | + "Dynamic font fallback: U+{:04X} '{}' -> {}", |
| 352 | + codepoint, c, path |
| 353 | + ); |
| 354 | + return Some(Arc::new(font)); |
| 355 | + } |
| 356 | + } |
| 357 | + } |
| 358 | + } |
| 359 | + |
| 360 | + tracing::debug!("No font found for U+{:04X} '{}'", codepoint, c); |
| 361 | + None |
| 362 | + } |
| 363 | + |
| 291 | 364 | /// Check if any font can render this character |
| 292 | 365 | pub fn has_glyph(&self, c: char) -> bool { |
| 293 | 366 | let primary = self.font(FontStyle::Regular); |
| 294 | 367 | if primary.lookup_glyph_index(c) != 0 { |
| 295 | 368 | return true; |
| 296 | 369 | } |
| 297 | | - self.fallback_fonts.iter().any(|f| f.lookup_glyph_index(c) != 0) |
| 370 | + if self.fallback_fonts.iter().any(|f| f.lookup_glyph_index(c) != 0) { |
| 371 | + return true; |
| 372 | + } |
| 373 | + self.dynamic_fallbacks.borrow().iter().any(|f| f.lookup_glyph_index(c) != 0) |
| 298 | 374 | } |
| 299 | 375 | } |