From 340cae7b62009376f04820be62f6efc37a22c9c2 Mon Sep 17 00:00:00 2001 From: valadaptive Date: Sun, 8 Jun 2025 13:34:26 -0400 Subject: [PATCH 1/8] Reimplement fontconfig backend with FFI --- Cargo.lock | 23 +- fontique/Cargo.toml | 6 +- fontique/src/backend/fontconfig.rs | 682 ++++++++++++++++++++++ fontique/src/backend/fontconfig/cache.rs | 188 ------ fontique/src/backend/fontconfig/config.rs | 224 ------- fontique/src/backend/fontconfig/mod.rs | 511 ---------------- fontique/src/backend/mod.rs | 2 +- 7 files changed, 698 insertions(+), 938 deletions(-) create mode 100644 fontique/src/backend/fontconfig.rs delete mode 100644 fontique/src/backend/fontconfig/cache.rs delete mode 100644 fontique/src/backend/fontconfig/config.rs delete mode 100644 fontique/src/backend/fontconfig/mod.rs diff --git a/Cargo.lock b/Cargo.lock index f4ecaf1e1..38d227ffb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1153,23 +1153,12 @@ dependencies = [ "bytemuck", ] -[[package]] -name = "fontconfig-cache-parser" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7f8afb20c8069fd676d27b214559a337cc619a605d25a87baa90b49a06f3b18" -dependencies = [ - "bytemuck", - "thiserror 1.0.69", -] - [[package]] name = "fontique" version = "0.5.0" dependencies = [ "bytemuck", "core_maths", - "fontconfig-cache-parser", "hashbrown", "icu_locid", "icu_properties", @@ -1185,6 +1174,7 @@ dependencies = [ "unicode-script", "windows 0.58.0", "windows-core 0.58.0", + "yeslogic-fontconfig-sys", ] [[package]] @@ -4665,6 +4655,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503a066b4c037c440169d995b869046827dbc71263f6e8f3be6d77d4f3229dbd" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + [[package]] name = "yoke" version = "0.7.5" diff --git a/fontique/Cargo.toml b/fontique/Cargo.toml index ace03442d..e5320957d 100644 --- a/fontique/Cargo.toml +++ b/fontique/Cargo.toml @@ -30,7 +30,7 @@ system = [ "dep:objc2-core-foundation", "dep:objc2-core-text", "dep:objc2-foundation", - "dep:fontconfig-cache-parser", + "dep:yeslogic-fontconfig-sys", "dep:roxmltree", ] @@ -74,7 +74,7 @@ objc2-core-text = { version = "0.3.1", optional = true, default-features = false ] } [target.'cfg(target_os = "linux")'.dependencies] -fontconfig-cache-parser = { version = "0.2.0", optional = true } +yeslogic-fontconfig-sys = { version = "6.0.0", optional = true, features = ["dlopen"] } -[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies] +[target.'cfg(target_os = "android")'.dependencies] roxmltree = { version = "0.20.0", optional = true } diff --git a/fontique/src/backend/fontconfig.rs b/fontique/src/backend/fontconfig.rs new file mode 100644 index 000000000..c71df07b6 --- /dev/null +++ b/fontique/src/backend/fontconfig.rs @@ -0,0 +1,682 @@ +use core::{ + ffi::{CStr, c_char}, + iter::once, + marker::PhantomData, + ptr::NonNull, +}; +use std::{ + borrow::Cow, + ffi::{CString, OsStr}, + os::unix::ffi::OsStrExt, + path::Path, + sync::Arc, +}; + +use fontconfig_sys::{ + FcChar8, FcCharSet, FcConfig, FcFontSet, FcLangSet, FcMatchKind, FcMatchPattern, FcPattern, + FcResult, FcResultMatch, FcResultNoId, FcResultNoMatch, FcResultOutOfMemory, + FcResultTypeMismatch, FcSetSystem, + constants::{FC_CHARSET, FC_FAMILY, FC_FILE, FC_INDEX, FC_LANG, FC_SLANT, FC_WEIGHT, FC_WIDTH}, + statics::{LIB, LIB_RESULT}, +}; +use hashbrown::{HashMap, HashSet, hash_map::Entry}; +use smallvec::SmallVec; + +use crate::{ + FallbackKey, FamilyId, FamilyInfo, FontInfo, FontStyle, FontWeight, FontWidth, GenericFamily, + Script, + family_name::{FamilyName, FamilyNameMap}, + generic::GenericFamilyMap, + source::SourcePathMap, +}; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum MatchErr { + NoMatch, + TypeMismatch, + NoId, + OutOfMemory, + Other, +} + +impl MatchErr { + fn from_raw(raw: FcResult) -> Self { + #[allow(non_upper_case_globals)] + match raw { + FcResultNoMatch => Self::NoMatch, + FcResultTypeMismatch => Self::TypeMismatch, + FcResultNoId => Self::NoId, + FcResultOutOfMemory => Self::OutOfMemory, + _ => Self::Other, + } + } +} + +type MatchResult = Result; + +/// Ownership for refcounted Fontconfig objects. Used to track if a given +/// fontconfig function returns an object that it owns or is passing its +/// ownership to us. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Ownership { + /// This object is owned by Fontconfig and needs to be freed by it. + Fontconfig, + /// This object is owned by the application. + Application, +} + +/// Wrapper for an `FcPattern`. +struct Pattern { + inner: NonNull, +} + +impl Pattern { + fn new() -> Option { + Some(unsafe { Self::from_raw((LIB.FcPatternCreate)(), Ownership::Application)? }) + } + + unsafe fn from_raw(raw: *mut FcPattern, ownership: Ownership) -> Option { + let inner = NonNull::new(raw)?; + // Don't free this object when we are dropped. + if ownership == Ownership::Fontconfig { + unsafe { + (LIB.FcPatternReference)(inner.as_ptr()); + } + } + Some(Self { inner }) + } + + fn add_string(&mut self, object: &CStr, s: &CStr) -> bool { + // All objects passed to FcPatternAddWhatever are cloned. + unsafe { + (LIB.FcPatternAddString)(self.inner.as_ptr(), object.as_ptr(), s.as_ptr() as *const _) + != 0 + } + } + + fn add_charset(&mut self, object: &CStr, s: &CharSet) -> bool { + unsafe { + (LIB.FcPatternAddCharSet)(self.inner.as_ptr(), object.as_ptr(), s.inner.as_ptr()) != 0 + } + } + + fn add_langset(&mut self, object: &CStr, s: &LangSet) -> bool { + unsafe { + (LIB.FcPatternAddLangSet)(self.inner.as_ptr(), object.as_ptr(), s.inner.as_ptr()) != 0 + } + } + + fn get_string<'a>(&'a self, object: &CStr, n: u32) -> MatchResult> { + Ok(self.get_c_string(object, n)?.to_string_lossy()) + } + + fn get_c_string<'a>(&'a self, object: &CStr, n: u32) -> MatchResult<&'a CStr> { + let mut dest: *mut FcChar8 = std::ptr::null_mut(); + let result = unsafe { + (LIB.FcPatternGetString)( + self.inner.as_ptr(), + object.as_ptr(), + n.try_into().map_err(|_| MatchErr::Other)?, + &raw mut dest, + ) + }; + if result != FcResultMatch { + return Err(MatchErr::from_raw(result)); + } + let dest = NonNull::new(dest).ok_or(MatchErr::Other)?; + Ok(unsafe { CStr::from_ptr(dest.as_ptr() as *const _) }) + } + + fn get_int(&self, object: &CStr, n: u32) -> MatchResult { + let mut dest = 0; + let result = unsafe { + (LIB.FcPatternGetInteger)( + self.inner.as_ptr(), + object.as_ptr(), + n.try_into().map_err(|_| MatchErr::Other)?, + &raw mut dest, + ) + }; + if result != FcResultMatch { + return Err(MatchErr::from_raw(result)); + } + Ok(dest) + } +} + +impl Clone for Pattern { + fn clone(&self) -> Self { + unsafe { (LIB.FcPatternReference)(self.inner.as_ptr()) }; + Self { inner: self.inner } + } +} + +impl Drop for Pattern { + fn drop(&mut self) { + unsafe { (LIB.FcPatternDestroy)(self.inner.as_ptr()) }; + } +} + +impl std::fmt::Debug for Pattern { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match NonNull::new(unsafe { (LIB.FcNameUnparse)(self.inner.as_ptr()) }) { + Some(unparsed) => { + let res = f.write_str(unsafe { + &CStr::from_ptr(unparsed.as_ptr() as *const c_char).to_string_lossy() + }); + unsafe { (LIB.FcStrFree)(unparsed.as_ptr()) }; + res + } + None => f.debug_struct("Pattern").finish(), + } + } +} + +struct FontSet<'a> { + inner: NonNull, + ownership: Ownership, + // If an `FcFontSet` is created from an `FcPattern`, it will reference that + // pattern's data. Well, maybe. The docs say "The returned FcFontSet + // references FcPattern structures which may be shared by the return value + // from multiple FcFontSort calls, applications cannot modify these + // patterns." It's unclear whether this refers to actual lifetime/ownership + // semantics or if everything's properly refcounted and you're just not + // allowed to mutate them. + _parent: PhantomData<&'a ()>, +} + +impl FontSet<'_> { + unsafe fn from_raw(raw: *mut FcFontSet, ownership: Ownership) -> Option { + let inner = NonNull::new(raw)?; + Some(Self { + inner, + ownership, + _parent: PhantomData, + }) + } + + fn iter(&self) -> FontSetIter<'_> { + FontSetIter { + i: 0, + font_set: self, + } + } +} + +impl Drop for FontSet<'_> { + fn drop(&mut self) { + if self.ownership == Ownership::Application { + unsafe { (LIB.FcFontSetDestroy)(self.inner.as_ptr()) }; + } + } +} + +struct FontSetIter<'a> { + i: usize, + font_set: &'a FontSet<'a>, +} + +impl Iterator for FontSetIter<'_> { + type Item = Pattern; + + fn next(&mut self) -> Option { + let font_set = self.font_set.inner.as_ptr(); + if self.i >= unsafe { (*font_set).nfont }.try_into().ok()? { + None + } else { + let pattern: *mut FcPattern = unsafe { *(*font_set).fonts.add(self.i) }; + self.i += 1; + Some(unsafe { Pattern::from_raw(pattern, Ownership::Fontconfig) }?) + } + } + + fn size_hint(&self) -> (usize, Option) { + let nfont: Result = unsafe { (*self.font_set.inner.as_ptr()).nfont }.try_into(); + let Ok(nfont) = nfont else { + return (0, None); + }; + (nfont, Some(nfont)) + } +} + +struct LangSet { + inner: NonNull, +} + +impl LangSet { + fn new() -> Option { + let inner = NonNull::new(unsafe { (LIB.FcLangSetCreate)() })?; + Some(Self { inner }) + } + + fn add(&mut self, lang: &CStr) -> bool { + unsafe { (LIB.FcLangSetAdd)(self.inner.as_ptr(), lang.as_ptr() as *const _) != 0 } + } +} + +impl Drop for LangSet { + fn drop(&mut self) { + unsafe { (LIB.FcLangSetDestroy)(self.inner.as_ptr()) }; + } +} + +impl Clone for LangSet { + fn clone(&self) -> Self { + Self { + inner: unsafe { NonNull::new((LIB.FcLangSetCopy)(self.inner.as_ptr())).unwrap() }, + } + } +} + +struct CharSet { + inner: NonNull, +} + +impl CharSet { + fn new() -> Option { + let inner = NonNull::new(unsafe { (LIB.FcCharSetCreate)() })?; + Some(Self { inner }) + } + + fn add(&mut self, c: char) -> bool { + unsafe { (LIB.FcCharSetAddChar)(self.inner.as_ptr(), c as u32) != 0 } + } +} + +impl Drop for CharSet { + fn drop(&mut self) { + unsafe { (LIB.FcCharSetDestroy)(self.inner.as_ptr()) }; + } +} + +impl Clone for CharSet { + fn clone(&self) -> Self { + Self { + inner: unsafe { NonNull::new((LIB.FcCharSetCopy)(self.inner.as_ptr())).unwrap() }, + } + } +} + +struct Config { + inner: NonNull, +} + +impl Config { + unsafe fn from_raw(raw: *mut FcConfig, ownership: Ownership) -> Option { + let inner = NonNull::new(raw)?; + // Don't free this object when we are dropped. + if ownership == Ownership::Fontconfig { + unsafe { + (LIB.FcConfigReference)(inner.as_ptr()); + } + } + Some(Self { inner }) + } + + fn substitute(&self, pattern: &mut Pattern, kind: FcMatchKind) { + unsafe { (LIB.FcConfigSubstitute)(self.inner.as_ptr(), pattern.inner.as_ptr(), kind) }; + } + + fn font_sort<'me, 'ret, 'pat: 'ret>( + &'me self, + pattern: &'pat Pattern, + trim: bool, + ) -> MatchResult> { + let mut result = 0; + // The returned FcFontSet is for us to free + let font_set = unsafe { + FontSet::from_raw( + (LIB.FcFontSort)( + self.inner.as_ptr(), + pattern.inner.as_ptr(), + trim as i32, + std::ptr::null_mut(), + &raw mut result, + ), + Ownership::Application, + ) + } + .ok_or(MatchErr::Other)?; + if result != FcResultMatch { + return Err(MatchErr::from_raw(result)); + } + + Ok(font_set) + } + + fn font_match(&self, pattern: &Pattern) -> MatchResult { + let mut result = 0; + let pattern = unsafe { + Pattern::from_raw( + (LIB.FcFontMatch)(self.inner.as_ptr(), pattern.inner.as_ptr(), &raw mut result), + Ownership::Application, + ) + } + .ok_or(MatchErr::Other)?; + if result != FcResultMatch { + return Err(MatchErr::from_raw(result)); + } + + Ok(pattern) + } + + fn font_render_prepare(&self, pat: &Pattern, font: &Pattern) -> Option { + unsafe { + Pattern::from_raw( + (LIB.FcFontRenderPrepare)( + self.inner.as_ptr(), + pat.inner.as_ptr(), + font.inner.as_ptr(), + ), + Ownership::Application, + ) + } + } +} + +impl Clone for Config { + fn clone(&self) -> Self { + unsafe { (LIB.FcConfigReference)(self.inner.as_ptr()) }; + Self { inner: self.inner } + } +} + +impl Drop for Config { + fn drop(&mut self) { + unsafe { (LIB.FcConfigDestroy)(self.inner.as_ptr()) }; + } +} + +/// Cache wrapper that maps Unicode scripts to fontconfig [`CharSet`]s. +#[derive(Default)] +struct ScriptCharSetMap(HashMap>); + +impl ScriptCharSetMap { + fn charset_for_script(&mut self, script: Script) -> Option<&CharSet> { + match self.0.entry(script) { + Entry::Occupied(e) => e.into_mut().as_ref(), + Entry::Vacant(e) => { + let Some(sample) = script.sample() else { + return e.insert(None).as_ref(); + }; + + let mut charset = CharSet::new()?; + for c in sample.chars() { + charset.add(c); + } + e.insert(Some(charset)).as_ref() + } + } + } +} + +/// Raw access to the collection of local system fonts. +#[derive(Default)] +pub(crate) struct SystemFonts { + pub(crate) name_map: Arc, + pub(crate) generic_families: Arc, + source_cache: SourcePathMap, + family_map: HashMap>, + config: Option, + script_charsets: ScriptCharSetMap, +} + +unsafe impl Send for SystemFonts {} + +impl SystemFonts { + pub(crate) fn new() -> Self { + let library_exists = LIB_RESULT.as_ref().ok().is_some(); + // We couldn't find the fontconfig library; maybe it doesn't exist. Just + // return a `SystemFonts` with no `config`. All our methods will return + // `None` and shouldn't attempt any FFI calls because the first thing we + // do is check for `config`. + if !library_exists { + return Default::default(); + } + + // Initialize the config + let config = unsafe { (LIB.FcInitLoadConfig)() }; + // fontconfig returns a new config object each time we call FcInitLoadConfig + let Some(config) = (unsafe { Config::from_raw(config, Ownership::Application) }) else { + return Default::default(); + }; + unsafe { + (LIB.FcConfigBuildFonts)(config.inner.as_ptr()); + } + + // Get all the fonts + + // The fontconfig docs say this "isn't threadsafe", but this seems to be + // related to refcounting: + // https://gitlab.freedesktop.org/fontconfig/fontconfig/-/commit/b5bcf61fe789e66df2de609ec246cb7e4d326180 + // The source code for this function just calls `FcConfigGetCurrent` if + // none is provided (which we do, and it's atomic anyway) and then + // dereferences a pointer. I *think* the safety issue they're referring + // to is that if we destroyed this config on another thread and then + // tried to access what it returns, it would dereference a null pointer. + // But we're not doing that. + let font_set = + NonNull::new(unsafe { (LIB.FcConfigGetFonts)(config.inner.as_ptr(), FcSetSystem) }) + .unwrap(); + let fonts = unsafe { (*font_set.as_ptr()).fonts }; + let n_fonts = unsafe { (*font_set.as_ptr()).nfont }; + + // Populate the family name map + let mut name_map = FamilyNameMap::default(); + for i in 0..n_fonts as usize { + let pattern: *mut FcPattern = unsafe { *fonts.add(i) }; + let pattern = unsafe { Pattern::from_raw(pattern, Ownership::Fontconfig) }.unwrap(); + let mut i = 0; + + let mut first_name_id = None; + // For fonts with more than one family name, the second one is + // *often* (but not always) an RBIZ name + while let Ok(name) = pattern.get_string(FC_FAMILY, i) { + if i == 0 { + // First name + first_name_id = Some(name_map.get_or_insert(strip_rbiz(&name)).id()); + } else if let Some(first_name_id) = first_name_id { + name_map.add_alias(first_name_id, strip_rbiz(&name)); + } + i += 1; + } + } + + // Populate the generic family map + let mut generic_families = GenericFamilyMap::default(); + for (generic_family, name) in GENERIC_FAMILY_NAMES { + let mut pattern = Pattern::new().unwrap(); + pattern.add_string(FC_FAMILY, name); + // TODO: do we need FcConfigSetDefaultSubstitute? + + config.substitute(&mut pattern, FcMatchPattern); + + // We enable the "trim" option here which ignores later fonts if + // they provide no new Unicode coverage. + let font_set = config.font_sort(&pattern, true).unwrap(); + + // There are a lot of duplicate font names in the substituted + // pattern. Keep track of which ones have already been added to the + // list. + let mut added_names = HashSet::new(); + + for font in font_set.iter() { + // Not sure if FcFontRenderPrepare performs any substitutions + // relevant to fallback family name matching, but it's a good + // idea to call it just in case + let Some(font) = config.font_render_prepare(&pattern, &font) else { + continue; + }; + // Generic families can have more than one name, but the only + // one we care about is the first one + let Ok(name) = font.get_string(FC_FAMILY, 0) else { + continue; + }; + + let name = strip_rbiz(&name); + if added_names.contains(name) { + continue; + } + let Some(family_name) = name_map.get(name) else { + continue; + }; + + added_names.insert(name.to_owned()); + generic_families.append(*generic_family, once(family_name.id())); + } + } + + Self { + name_map: Arc::new(name_map), + generic_families: Arc::new(generic_families), + source_cache: Default::default(), + family_map: Default::default(), + config: Some(config), + script_charsets: Default::default(), + } + } + + pub(crate) fn family(&mut self, id: FamilyId) -> Option { + match self.family_map.get(&id) { + Some(Some(family)) => return Some(family.clone()), + Some(None) => return None, + None => {} + } + + let family = self.family_uncached(id); + self.family_map.insert(id, family.clone()); + family + } + + pub(crate) fn fallback(&mut self, key: impl Into) -> Option { + let config = self.config.as_ref()?; + let key: FallbackKey = key.into(); + + let mut pattern = Pattern::new()?; + + let locale_lang_set = key.locale().and_then(|locale| { + let mut lang_set = LangSet::new()?; + lang_set.add(CString::new(locale).ok()?.as_c_str()); + Some(lang_set) + }); + let script_char_set = self.script_charsets.charset_for_script(key.script()); + + if let Some(set) = locale_lang_set { + pattern.add_langset(FC_LANG, &set); + } + if let Some(set) = script_char_set { + pattern.add_charset(FC_CHARSET, set); + } + + config.substitute(&mut pattern, FcMatchPattern); + + // This calls FcFontRenderPrepare for us + let font = config.font_match(&pattern).ok()?; + + let family_name = font.get_string(FC_FAMILY, 0).ok()?; + self.name_map.get(&family_name).map(FamilyName::id) + } +} + +impl SystemFonts { + fn family_uncached(&mut self, id: FamilyId) -> Option { + let config = self.config.as_ref()?; + let name = self.name_map.get_by_id(id).cloned()?; + + // Match by family name + let mut pattern = Pattern::new()?; + pattern.add_string(FC_FAMILY, CString::new(name.name()).ok()?.as_c_str()); + config.substitute(&mut pattern, FcMatchPattern); + + let fc_fonts = config.font_sort(&pattern, false).ok()?; + let mut font_infos = SmallVec::<[FontInfo; 4]>::new(); + for font in fc_fonts.iter() { + let Some(font) = config.font_render_prepare(&pattern, &font) else { + continue; + }; + let Ok(family_name) = font.get_string(FC_FAMILY, 0) else { + continue; + }; + // We've performed font substitution and then sorted everything by + // "closeness", so the good fonts should be at the top. Once we see + // a fallback font (one that's not part of the family we explicitly + // asked for), we can stop. + if family_name != name.name() { + break; + } + + if let Some(font_info) = (|| { + let path = font.get_c_string(FC_FILE, 0).ok()?; + // This part is Unix-specific. Sorry, Windows fontconfig user. + let path = Path::new(OsStr::from_bytes(path.to_bytes())); + let source_info = self.source_cache.get_or_insert(path); + + let weight = font + .get_int(FC_WEIGHT, 0) + .map(FontWeight::from_fontconfig) + .unwrap_or_default(); + let width = font + .get_int(FC_WIDTH, 0) + .map(FontWidth::from_fontconfig) + .unwrap_or_default(); + let style = font + .get_int(FC_SLANT, 0) + .map(FontStyle::from_fontconfig) + .unwrap_or_default(); + let index = font.get_int(FC_INDEX, 0).map_or(0, |idx| idx.max(0) as u32); + + let mut font_info = FontInfo::from_source(source_info, index)?; + // TODO(valadaptive): does this do anything anymore? + font_info.maybe_override_attributes(width, style, weight); + Some(font_info) + })() { + font_infos.push(font_info); + } + } + + if font_infos.is_empty() { + return None; + } + + Some(FamilyInfo::new(name.clone(), font_infos)) + } +} + +const GENERIC_FAMILY_NAMES: &[(GenericFamily, &CStr)] = &[ + (GenericFamily::Serif, c"serif"), + (GenericFamily::SansSerif, c"sans-serif"), + (GenericFamily::Monospace, c"monospace"), + (GenericFamily::Cursive, c"cursive"), + (GenericFamily::Fantasy, c"fantasy"), + (GenericFamily::SystemUi, c"system-ui"), + (GenericFamily::Emoji, c"emoji"), + (GenericFamily::Math, c"math"), +]; + +/// Fontconfig seems to force RBIZ (regular, bold, italic, bold italic) when +/// categorizing fonts. This removes those suffixes from family names so that +/// we can match on all attributes. +fn strip_rbiz(name: &str) -> &str { + // TODO(valadaptive): this seems incomplete. check fcname.c for their + // constants + const SUFFIXES: &[&str] = &[ + " Thin", + " ExtraLight", + " DemiLight", + " Light", + " Medium", + " Black", + " SemiBold", + " Semibold", + " ExtraBold", + " Extra Bold", + " Black", + " Narrow", + ]; + for suffix in SUFFIXES { + if let Some(name) = name.strip_suffix(suffix) { + return name; + } + } + name +} diff --git a/fontique/src/backend/fontconfig/cache.rs b/fontique/src/backend/fontconfig/cache.rs deleted file mode 100644 index 2308e5f0e..000000000 --- a/fontique/src/backend/fontconfig/cache.rs +++ /dev/null @@ -1,188 +0,0 @@ -// Copyright 2024 the Parley Authors -// SPDX-License-Identifier: Apache-2.0 OR MIT - -use super::{FontStyle, FontWeight, FontWidth}; -use fontconfig_cache_parser::{Cache, CharSetLeaf, Object, Pattern, Value}; -use std::io::Read; -use std::path::PathBuf; - -#[derive(Default)] -pub struct CachedFont { - pub family: Vec, - pub path: PathBuf, - pub index: u32, - pub width: FontWidth, - pub style: FontStyle, - pub weight: FontWeight, - pub coverage: Coverage, -} - -impl CachedFont { - fn clear(&mut self) { - self.family.clear(); - self.path.clear(); - self.index = 0; - self.coverage.clear(); - self.weight = FontWeight::default(); - self.style = FontStyle::default(); - self.width = FontWidth::default(); - } -} - -pub fn parse_caches(paths: &[PathBuf], mut f: impl FnMut(&CachedFont)) { - let mut buffer = vec![]; - let mut name_free_list = vec![]; - let mut cached_font = CachedFont::default(); - for path in paths { - let Ok(dir) = path.canonicalize().and_then(std::fs::read_dir) else { - return; - }; - for path in dir.filter_map(|entry| entry.ok()).map(|entry| entry.path()) { - buffer.clear(); - let Ok(file_size) = path.metadata() else { - continue; - }; - buffer.resize(file_size.len() as usize, 0); - let Ok(mut file) = std::fs::OpenOptions::new().read(true).open(&path) else { - continue; - }; - let Ok(_) = file.read(&mut buffer) else { - continue; - }; - let Ok(set) = Cache::from_bytes(&buffer).and_then(|cache| cache.set()) else { - continue; - }; - let Ok(fonts) = set.fonts() else { continue }; - for font in fonts.flatten() { - if parse_font(&font, &mut name_free_list, &mut cached_font).is_some() { - f(&cached_font); - } - } - } - } -} - -fn parse_font( - pattern: &Pattern<'_>, - name_free_list: &mut Vec, - font: &mut CachedFont, -) -> Option<()> { - name_free_list.append(&mut font.family); - font.clear(); - for elt in pattern.elts().ok()? { - let Ok(obj) = elt.object() else { - continue; - }; - match obj { - Object::Family => { - for val in elt.values().ok()? { - let val = val.ok()?; - if let Value::String(s) = val { - let mut name = name_free_list.pop().unwrap_or_default(); - name.clear(); - name.push_str(core::str::from_utf8(s.str().ok()?).ok()?); - font.family.push(name); - } - } - } - Object::File => { - for val in elt.values().ok()? { - let val = val.ok()?; - if let Value::String(s) = val { - font.path.clear(); - font.path.push(core::str::from_utf8(s.str().ok()?).ok()?); - if font.path.extension() == Some(std::ffi::OsStr::new("t1")) { - return None; - } - } - } - } - Object::Slant => { - for val in elt.values().ok()? { - if let Value::Int(i) = val.ok()? { - font.style = FontStyle::from_fontconfig(i); - } - } - } - Object::Weight => { - for val in elt.values().ok()? { - if let Value::Int(i) = val.ok()? { - font.weight = FontWeight::from_fontconfig(i); - } - } - } - Object::Width => { - for val in elt.values().ok()? { - if let Value::Int(i) = val.ok()? { - font.width = FontWidth::from_fontconfig(i); - } - } - } - Object::Index => { - for val in elt.values().ok()? { - let val = val.ok()?; - if let Value::Int(i) = val { - font.index = i as u32; - // Ignore named instances - if font.index >> 16 != 0 { - return None; - } - } - } - } - Object::CharSet => { - for val in elt.values().ok()? { - let val = val.ok()?; - if let Value::CharSet(set) = val { - font.coverage.clear(); - font.coverage - .numbers - .extend_from_slice(set.numbers().ok()?.as_slice().ok()?); - for leaf in set.leaves().ok()? { - let leaf = leaf.ok()?; - font.coverage.leaves.push(leaf); - } - } - } - } - _ => {} - } - } - if !font.family.is_empty() && !font.path.as_os_str().is_empty() { - Some(()) - } else { - None - } -} - -#[derive(Clone, Default)] -pub struct Coverage { - numbers: Vec, - leaves: Vec, -} - -impl Coverage { - pub fn compute_for_str(&self, s: &str) -> usize { - s.chars() - .map(|ch| self.contains(ch as _).unwrap_or(false) as usize) - .sum() - } - - pub fn contains(&self, ch: u32) -> Option { - let hi = ((ch >> 8) & 0xffff) as u16; - match self.numbers.binary_search(&hi) { - // The unwrap will succeed because numbers and leaves have the same length. - Ok(idx) => { - let leaf = self.leaves.get(idx)?; - let lo = (ch & 0xff) as u8; - Some(leaf.contains_byte(lo)) - } - Err(_) => Some(false), - } - } - - fn clear(&mut self) { - self.numbers.clear(); - self.leaves.clear(); - } -} diff --git a/fontique/src/backend/fontconfig/config.rs b/fontique/src/backend/fontconfig/config.rs deleted file mode 100644 index 2ea728ce8..000000000 --- a/fontique/src/backend/fontconfig/config.rs +++ /dev/null @@ -1,224 +0,0 @@ -// Copyright 2024 the Parley Authors -// SPDX-License-Identifier: Apache-2.0 OR MIT - -//! Extremely naive fontconfig xml parser to extract the data we need. - -use roxmltree::Node; -use std::path::{Path, PathBuf}; - -pub trait ParserSink { - fn include_path(&mut self, path: &Path); - fn cache_path(&mut self, path: &Path); - fn alias(&mut self, family: &str, prefer: &[&str]); - fn lang_map(&mut self, lang: &str, from_family: Option<&str>, family: &str); -} - -pub fn parse_config(path: &Path, sink: &mut impl ParserSink) { - let Ok(text) = std::fs::read_to_string(path) else { - return; - }; - let Ok(doc) = roxmltree::Document::parse_with_options( - &text, - roxmltree::ParsingOptions { - allow_dtd: true, - nodes_limit: u32::MAX, - }, - ) else { - return; - }; - let root = doc.root_element(); - if root.tag_name().name() != "fontconfig" { - return; - } - let mut prefer = vec![]; - 'outer: for child in root.children() { - match child.tag_name().name() { - "alias" => { - let mut family = None; - for child in child.children() { - match child.tag_name().name() { - "family" => { - family = child.text(); - if !family.map(is_alias_family).unwrap_or(false) { - continue 'outer; - } - } - "prefer" => { - prefer.clear(); - prefer.extend(child.children().filter_map(|family| { - match family.tag_name().name() { - "family" => family.text(), - _ => None, - } - })); - } - _ => {} - } - } - match family { - Some(family) if !prefer.is_empty() => { - sink.alias(family, &prefer); - } - _ => {} - } - } - "cachedir" => { - if let Some(path) = resolve_dir(child, path) { - sink.cache_path(&path); - } - } - "include" => { - if let Some(path) = resolve_dir(child, path) { - drop(include_config(&path, sink)); - } - } - "match" => { - // We only care about pattern matches - if !matches!(child.attribute("target"), Some("pattern") | None) { - continue; - } - let mut test_lang = None; - let mut test_family = None; - let mut edit_family = None; - for child in child.children() { - match child.tag_name().name() { - "test" => { - if !matches!( - child.attribute("compare"), - Some("eq") | Some("contains") | None - ) { - continue 'outer; - } - match child.attribute("name") { - Some("lang") => { - test_lang = - child.first_element_child().and_then(|inner| inner.text()); - } - Some("family") => { - test_family = - child.first_element_child().and_then(|inner| inner.text()); - if !test_family.map(is_match_family).unwrap_or(true) { - continue 'outer; - } - } - _ => continue 'outer, - } - } - "edit" => { - if child.attribute("name") == Some("family") { - edit_family = - child.first_element_child().and_then(|inner| inner.text()); - } - } - "" => {} - _ => continue 'outer, - } - } - if let (Some(lang), Some(family)) = (test_lang, edit_family) { - sink.lang_map(lang, test_family, family); - } - } - _ => {} - } - } -} - -/// Families we care about for aliases. -const ALIAS_FAMILIES: &[&str] = &[ - "cursive", - "emoji", - "fantasy", - "math", - "monospace", - "sans-serif", - "serif", - "system-ui", -]; - -fn is_alias_family(family: &str) -> bool { - ALIAS_FAMILIES.binary_search(&family).is_ok() -} - -/// Families we care about for lang matches. -const MATCH_FAMILIES: &[&str] = &["monospace", "sans-serif", "serif"]; - -fn is_match_family(family: &str) -> bool { - MATCH_FAMILIES.binary_search(&family).is_ok() -} - -fn include_config(path: &Path, sink: &mut impl ParserSink) -> std::io::Result<()> { - let meta = std::fs::metadata(path)?; - let ty = meta.file_type(); - // fs::metadata follow symlink so ty is never symlink - if ty.is_file() { - parse_config(path, sink); - } else if ty.is_dir() { - let dir = std::fs::read_dir(path)?; - let mut config_paths = dir - .filter_map(|entry| { - let entry = entry.ok()?; - let ty = entry.file_type().ok()?; - - if ty.is_file() || ty.is_symlink() { - Some(entry.path()) - } else { - None - } - }) - .collect::>(); - config_paths.sort_unstable(); - for config_path in &config_paths { - sink.include_path(config_path); - parse_config(config_path, sink); - } - } - Ok(()) -} - -fn resolve_dir(node: Node<'_, '_>, config_file_path: impl AsRef) -> Option { - let dir_path = node.text()?; - let (xdg_env, xdg_fallback) = match node.tag_name().name() { - "include" => ("XDG_CONFIG_HOME", "~/.config"), - "cachedir" => ("XDG_CACHE_HOME", "~/.cache"), - _ => return None, - }; - let path = match node.attribute("prefix") { - Some("xdg") => PathBuf::from( - std::env::var(xdg_env) - .ok() - .filter(|v| !v.is_empty()) - .unwrap_or_else(|| xdg_fallback.into()), - ) - .join(dir_path), - _ => { - if dir_path.starts_with('/') { - dir_path.into() - } else { - match config_file_path.as_ref().parent() { - Some(parent) => parent.join(dir_path), - None => Path::new(".").join(dir_path), - } - } - } - }; - Some(if let Ok(stripped_path) = path.strip_prefix("~") { - let home = config_home().unwrap_or("/".to_string()); - Path::new(&home).join(stripped_path) - } else { - path - }) -} - -/// Get the location to user home directory. -/// -/// This implementation follows `FcConfigHome` function of freedesktop.org's -/// Fontconfig library. -#[allow(unused_mut, clippy::let_and_return)] -fn config_home() -> Result { - let mut home = std::env::var("HOME"); - #[cfg(target_os = "windows")] - { - home = home.or_else(|_| std::env::var("USERPROFILE")); - } - home -} diff --git a/fontique/src/backend/fontconfig/mod.rs b/fontique/src/backend/fontconfig/mod.rs deleted file mode 100644 index cedcfb705..000000000 --- a/fontique/src/backend/fontconfig/mod.rs +++ /dev/null @@ -1,511 +0,0 @@ -// Copyright 2024 the Parley Authors -// SPDX-License-Identifier: Apache-2.0 OR MIT - -use hashbrown::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::Arc; - -use super::{ - super::{FontStyle, FontWeight, FontWidth}, - FallbackKey, FamilyId, FamilyInfo, FamilyName, FamilyNameMap, FontInfo, GenericFamily, - GenericFamilyMap, Script, SourceInfo, SourcePathMap, -}; - -mod cache; -mod config; - -/// Raw access to the collection of local system fonts. -pub(crate) struct SystemFonts { - pub(crate) name_map: Arc, - pub(crate) generic_families: Arc, - raw_families: HashMap, - family_map: HashMap>, - fallback_map: HashMap, -} - -impl SystemFonts { - pub(crate) fn new() -> Self { - Self::try_new().unwrap_or_else(|| Self { - name_map: Default::default(), - generic_families: Default::default(), - raw_families: Default::default(), - family_map: Default::default(), - fallback_map: Default::default(), - }) - } - - pub(crate) fn family(&mut self, id: FamilyId) -> Option { - match self.family_map.get(&id) { - Some(Some(family)) => return Some(family.clone()), - Some(None) => return None, - None => {} - } - let raw_family = self.raw_families.get(&id)?; - if raw_family.fonts.is_empty() { - // TODO: maybe catch this earlier? - return None; - } - let mut fonts: smallvec::SmallVec<[FontInfo; 4]> = Default::default(); - fonts.reserve(raw_family.fonts.len()); - fonts.extend(raw_family.fonts.iter().filter_map(|font| { - let mut info = FontInfo::from_source(font.source.clone(), font.index); - if let Some(info) = info.as_mut() { - info.maybe_override_attributes(font.width, font.style, font.weight); - } - info - })); - if fonts.is_empty() { - self.family_map.insert(id, None); - return None; - } - let family = FamilyInfo::new(raw_family.name.clone(), fonts); - self.family_map.insert(id, Some(family.clone())); - Some(family) - } - - pub(crate) fn fallback(&mut self, key: impl Into) -> Option { - let key = key.into(); - let script = key.script(); - let locale = key.locale(); - let families = self.fallback_map.get(&script)?; - let style = StyleClass::SansSerif; - if let Some(locale) = locale { - if !key.is_default() { - if let Some(family) = families.select_lang(locale, style) { - return Some(family); - } - } - } - families.select_default(style) - } -} - -impl SystemFonts { - pub(crate) fn try_new() -> Option { - let mut name_map = FamilyNameMap::default(); - let mut generic_families = GenericFamilyMap::default(); - let mut source_map = SourcePathMap::default(); - let mut raw_families: HashMap<_, _> = Default::default(); - let mut fallback_map: HashMap = Default::default(); - - // First, parse the raw config files. We attempt to replicate fontconfig - // behaviour as specified in the user guide: - // https://www.freedesktop.org/software/fontconfig/fontconfig-user.html - let mut config = Config::default(); - config::parse_config("/etc/fonts/fonts.conf".as_ref(), &mut config); - if let Some(dir) = std::env::var("XDG_CONFIG_HOME") - .ok() - .filter(|v| !v.is_empty()) - { - let path = PathBuf::from(dir).join("fontconfig/fonts.conf"); - config::parse_config(&path, &mut config); - } else if let Some(dir) = std::env::var("HOME").ok().filter(|v| !v.is_empty()) { - let path = PathBuf::from(dir).join(".config/fontconfig/fonts.conf"); - config::parse_config(&path, &mut config); - } - - // Extract all font/family metadata from the cache files - cache::parse_caches(&config.cache_dirs, |font| { - // Only accept OpenType fonts - if let Some(ext) = font.path.extension().and_then(|ext| ext.to_str()) { - if !["ttf", "otf", "ttc", "otc"].contains(&ext) { - return; - } - } else { - return; - } - let [first_name, other_names @ ..] = font.family.as_slice() else { - return; - }; - let family_name = name_map.get_or_insert(strip_rbiz(first_name)); - let id = family_name.id(); - for other_name in other_names { - name_map.add_alias(id, strip_rbiz(other_name)); - } - let raw_family = raw_families.entry(id).or_insert_with(|| RawFamily { - name: family_name, - fonts: vec![], - }); - let source = source_map.get_or_insert(&font.path); - if raw_family - .fonts - .iter() - .any(|raw_font| raw_font.source.id == source.id && raw_font.index == font.index) - { - return; - } - raw_family.fonts.push(RawFont { - source, - index: font.index, - width: font.width, - style: font.style, - weight: font.weight, - coverage: font.coverage.clone(), - }); - }); - // Build the fallback map, dropping non-existent families - for (lang, class, family) in &config.lang_maps { - let Some(family_id) = name_map.get(strip_rbiz(family)).map(|f| f.id()) else { - continue; - }; - let class = *class; - let Some(scripts) = lang_to_scripts(lang) else { - continue; - }; - for &script in scripts { - let script = Script(*script); - - // check if fallback family has any coverage, if not skip adding it - let any_coverage = script - .sample() - .and_then(|sample| { - raw_families.get(&family_id).map(|raw_family| { - raw_family - .fonts - .iter() - .any(|raw_font| raw_font.coverage.compute_for_str(sample) > 0) - }) - }) - // if we cannot check for coverage, assume it has coverage - .unwrap_or(true); - - // skip adding font family only if it has zero coverage across all fonts - if !any_coverage { - continue; - } - - let key: FallbackKey = (script, lang.as_str()).into(); - let families = fallback_map.entry(script).or_default(); - if key.is_default() || key.locale().is_none() { - families.default.push((class, family_id)); - } else if let Some(locale) = key.locale() { - families.languages.push((locale, class, family_id)); - } - } - } - // Build the generic map, also dropping non-existent families - for family in GenericFamily::all() { - let i = *family as usize; - generic_families.append( - *family, - config.generics[i] - .iter() - .filter_map(|name| name_map.get(strip_rbiz(name))) - .map(|name| name.id()), - ); - } - let mut result = Self { - name_map: Arc::new(name_map), - generic_families: Arc::new(generic_families), - raw_families, - family_map: Default::default(), - fallback_map, - }; - result.load_additional_fallbacks(); - Some(result) - } - - fn load_additional_fallbacks(&mut self) { - // Check for missing scripts and extend the fallbacks based on coverage - for (script, sample_text) in Script::all_samples() { - if self.fallback_map.contains_key(script) { - continue; - } - if let Some(family) = self.find_best_family(sample_text) { - self.fallback_map - .entry(*script) - .or_default() - .default - .push((StyleClass::None, family)); - } - } - } - - fn find_best_family(&self, text: &str) -> Option { - for family in [GenericFamily::SansSerif, GenericFamily::Serif] { - if let Some(family) = find_best_family( - self.generic_families - .get(family) - .iter() - .filter_map(|id| self.raw_families.get(id)), - text, - ) { - return Some(family); - } - } - find_best_family(self.raw_families.values(), text) - } -} - -/// Fontconfig seems to force RBIZ (regular, bold, italic, bold italic) when -/// categorizing fonts. This removes those suffixes from family names so that -/// we can match on all attributes. -fn strip_rbiz(name: &str) -> &str { - const SUFFIXES: &[&str] = &[ - " Thin", - " ExtraLight", - " DemiLight", - " Light", - " Medium", - " Black", - " Light", - " ExtraLight", - " Medium", - " SemiBold", - " Semibold", - " ExtraBold", - " Extra Bold", - " Black", - " Narrow", - ]; - for suffix in SUFFIXES { - if let Some(name) = name.strip_suffix(suffix) { - return name; - } - } - name -} - -fn find_best_family<'a>( - raw_families: impl Iterator, - text: &str, -) -> Option { - let char_count = text.chars().count(); - let mut best_id = None; - let mut best_coverage = 0; - for family in raw_families { - let id = family.name.id(); - for font in &family.fonts { - let coverage = font.coverage.compute_for_str(text); - if coverage == char_count { - return Some(id); - } - if coverage > best_coverage { - best_id = Some(id); - best_coverage = coverage; - } - } - } - best_id -} - -struct RawFamily { - name: FamilyName, - fonts: Vec, -} - -struct RawFont { - source: SourceInfo, - index: u32, - width: FontWidth, - style: FontStyle, - weight: FontWeight, - coverage: cache::Coverage, -} - -#[derive(Default)] -struct Config { - cache_dirs: Vec, - generics: [Vec; 13], - lang_maps: Vec<(String, StyleClass, String)>, -} - -impl config::ParserSink for Config { - fn alias(&mut self, family: &str, prefer: &[&str]) { - if let Some(generic) = GenericFamily::parse(family) { - // Ensure additions are unique - let list = &mut self.generics[generic as usize]; - for pref in prefer { - if list.iter().all(|item| item != pref) { - list.push(pref.to_string()); - } - } - } - } - - fn include_path(&mut self, _path: &Path) {} - - fn cache_path(&mut self, path: &Path) { - self.cache_dirs.push(path.into()); - } - - fn lang_map(&mut self, lang: &str, from_family: Option<&str>, family: &str) { - let class = match from_family { - Some("sans-serif") => StyleClass::SansSerif, - Some("serif") => StyleClass::Serif, - Some("monospace") => StyleClass::Monospace, - _ => StyleClass::None, - }; - self.lang_maps.push((lang.into(), class, family.into())); - } -} - -fn lang_to_scripts(lang: &str) -> Option<&'static [&'static [u8; 4]]> { - let ix = LANG_TO_SCRIPTS.binary_search_by(|x| x.0.cmp(lang)).ok()?; - Some(LANG_TO_SCRIPTS.get(ix)?.1) -} - -const LANG_TO_SCRIPTS: &[(&str, &[&[u8; 4]])] = &[ - ("am", &[b"Ethi"]), - ("ar", &[b"Arab"]), - ("as", &[b"Beng"]), - ("az-ir", &[b"Arab"]), - ("ber-ma", &[b"Tfng"]), - ("bh", &[b"Deva"]), - ("bho", &[b"Deva"]), - ("bn", &[b"Beng"]), - ("bo", &[b"Tibt"]), - ("brx", &[b"Deva"]), - ("byn", &[b"Ethi"]), - ("chr", &[b"Cher"]), - ("doi", &[b"Deva"]), - ("dv", &[b"Thaa"]), - ("dz", &[b"Tibt"]), - ("el", &[b"Grek"]), - ("fa", &[b"Arab"]), - ("gez", &[b"Ethi"]), - ("gu", &[b"Gujr"]), - ("he", &[b"Hebr"]), - ("hi", &[b"Deva"]), - ("hne", &[b"Deva"]), - ("hy", &[b"Armn"]), - ("ii", &[b"Yiii"]), - ("iu", &[b"Cans"]), - ("ja", &[b"Hani", b"Kana", b"Hira"]), - ("ka", &[b"Geor"]), - ("km", &[b"Khmr"]), - ("kn", &[b"Knda"]), - ("ko", &[b"Hani", b"Hang"]), - ("kok", &[b"Deva"]), - ("ks", &[b"Arab"]), - ("ku-iq", &[b"Arab"]), - ("ku-ir", &[b"Arab"]), - ("lah", &[b"Arab"]), - ("lo", &[b"Laoo"]), - ("mai", &[b"Deva"]), - ("ml", &[b"Mlym"]), - ("mn-cn", &[b"Mong"]), - ("mni", &[b"Beng"]), - ("mr", &[b"Deva"]), - ("my", &[b"Mymr"]), - ("ne", &[b"Deva"]), - ("nqo", &[b"Nkoo"]), - ("or", &[b"Orya"]), - ("ota", &[b"Arab"]), - ("pa", &[b"Guru"]), - ("pa-pk", &[b"Arab"]), - ("ps-af", &[b"Arab"]), - ("ps-pk", &[b"Arab"]), - ("sa", &[b"Deva"]), - ("sat", &[b"Deva"]), - ("sd", &[b"Arab"]), - ("si", &[b"Sinh"]), - ("sid", &[b"Ethi"]), - ("syr", &[b"Syrc"]), - ("ta", &[b"Taml"]), - ("te", &[b"Telu"]), - ("th", &[b"Thai"]), - ("ti-er", &[b"Ethi"]), - ("ti-et", &[b"Ethi"]), - ("tig", &[b"Ethi"]), - ("ug", &[b"Arab"]), - ("ur", &[b"Arab"]), - ("wal", &[b"Ethi"]), - ("yi", &[b"Hebr"]), - ("zh-cn", &[b"Hani"]), - ("zh-hk", &[b"Hani"]), - ("zh-mo", &[b"Hani"]), - ("zh-sg", &[b"Hani"]), - ("zh-tw", &[b"Hani"]), -]; - -#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)] -#[repr(u8)] -pub enum StyleClass { - None, - SansSerif, - Serif, - Monospace, -} - -impl StyleClass { - fn rank(self, requested: Self) -> u32 { - let from = if self == Self::None { - Self::SansSerif - } else { - self - }; - let requested = if requested == Self::None { - Self::SansSerif - } else { - requested - }; - match from { - Self::SansSerif => match requested { - Self::SansSerif => 3, - Self::Serif => 2, - _ => 1, - }, - Self::Serif => match requested { - Self::Serif => 3, - Self::SansSerif => 2, - _ => 1, - }, - Self::Monospace => match requested { - Self::Monospace => 3, - Self::SansSerif => 2, - _ => 1, - }, - _ => 1, - } - } -} - -#[derive(Default, Debug)] -pub struct FallbackFamilies { - /// Default list of font families for the script. - pub default: Vec<(StyleClass, FamilyId)>, - /// Language specific font families for the script. - pub languages: Vec<(&'static str, StyleClass, FamilyId)>, -} - -impl FallbackFamilies { - fn select_default(&self, style: StyleClass) -> Option { - let mut selected_rank = 0; - let mut selected_ix = 0; - for (i, family) in self.default.iter().enumerate() { - let rank = family.0.rank(style); - if rank > selected_rank { - selected_rank = rank; - selected_ix = i; - } - } - if selected_rank != 0 { - Some(self.default[selected_ix].1) - } else { - None - } - } - - fn select_lang(&self, lang: &str, style: StyleClass) -> Option { - let mut selected_rank = 0; - let mut selected_ix = 0; - for (i, family) in self - .languages - .iter() - .enumerate() - .filter(|(_, family)| family.0 == lang) - { - let rank = family.1.rank(style); - if rank > selected_rank { - selected_rank = rank; - selected_ix = i; - } - } - if selected_rank != 0 { - Some(self.languages[selected_ix].2) - } else { - None - } - } -} diff --git a/fontique/src/backend/mod.rs b/fontique/src/backend/mod.rs index d5f9a671b..4772ff1d3 100644 --- a/fontique/src/backend/mod.rs +++ b/fontique/src/backend/mod.rs @@ -12,7 +12,7 @@ mod system; mod system; #[cfg(all(feature = "system", target_os = "linux"))] -#[path = "fontconfig/mod.rs"] +#[path = "fontconfig.rs"] mod system; #[cfg(all(feature = "system", target_os = "android"))] From 75304c60d80cf7085532613b9fb4369b62c2936b Mon Sep 17 00:00:00 2001 From: valadaptive Date: Sun, 8 Jun 2025 14:00:45 -0400 Subject: [PATCH 2/8] Add copyright notice --- fontique/src/backend/fontconfig.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/fontique/src/backend/fontconfig.rs b/fontique/src/backend/fontconfig.rs index c71df07b6..a3f3ec6ad 100644 --- a/fontique/src/backend/fontconfig.rs +++ b/fontique/src/backend/fontconfig.rs @@ -1,3 +1,6 @@ +// Copyright 2025 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + use core::{ ffi::{CStr, c_char}, iter::once, From 57060a67d43a545bf4055b8f475369d2296394c4 Mon Sep 17 00:00:00 2001 From: valadaptive Date: Sun, 8 Jun 2025 18:10:44 -0400 Subject: [PATCH 3/8] Use FontSet::iter more I added this abstraction later and forgot to clean this up --- fontique/src/backend/fontconfig.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/fontique/src/backend/fontconfig.rs b/fontique/src/backend/fontconfig.rs index a3f3ec6ad..d59f52c2b 100644 --- a/fontique/src/backend/fontconfig.rs +++ b/fontique/src/backend/fontconfig.rs @@ -458,17 +458,18 @@ impl SystemFonts { // to is that if we destroyed this config on another thread and then // tried to access what it returns, it would dereference a null pointer. // But we're not doing that. - let font_set = - NonNull::new(unsafe { (LIB.FcConfigGetFonts)(config.inner.as_ptr(), FcSetSystem) }) - .unwrap(); - let fonts = unsafe { (*font_set.as_ptr()).fonts }; - let n_fonts = unsafe { (*font_set.as_ptr()).nfont }; + let Some(font_set) = (unsafe { + FontSet::from_raw( + (LIB.FcConfigGetFonts)(config.inner.as_ptr(), FcSetSystem), + Ownership::Fontconfig, + ) + }) else { + return Default::default(); + }; // Populate the family name map let mut name_map = FamilyNameMap::default(); - for i in 0..n_fonts as usize { - let pattern: *mut FcPattern = unsafe { *fonts.add(i) }; - let pattern = unsafe { Pattern::from_raw(pattern, Ownership::Fontconfig) }.unwrap(); + for pattern in font_set.iter() { let mut i = 0; let mut first_name_id = None; From 8ce8462aa7a834ae13d8d224d157b9e4b9338cbf Mon Sep 17 00:00:00 2001 From: valadaptive Date: Tue, 10 Jun 2025 20:52:47 -0400 Subject: [PATCH 4/8] Add periods to comments --- fontique/src/backend/fontconfig.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/fontique/src/backend/fontconfig.rs b/fontique/src/backend/fontconfig.rs index d59f52c2b..0a2dacda3 100644 --- a/fontique/src/backend/fontconfig.rs +++ b/fontique/src/backend/fontconfig.rs @@ -326,7 +326,7 @@ impl Config { trim: bool, ) -> MatchResult> { let mut result = 0; - // The returned FcFontSet is for us to free + // The returned FcFontSet is for us to free. let font_set = unsafe { FontSet::from_raw( (LIB.FcFontSort)( @@ -447,7 +447,7 @@ impl SystemFonts { (LIB.FcConfigBuildFonts)(config.inner.as_ptr()); } - // Get all the fonts + // Get all the fonts. // The fontconfig docs say this "isn't threadsafe", but this seems to be // related to refcounting: @@ -467,14 +467,14 @@ impl SystemFonts { return Default::default(); }; - // Populate the family name map + // Populate the family name map. let mut name_map = FamilyNameMap::default(); for pattern in font_set.iter() { let mut i = 0; let mut first_name_id = None; // For fonts with more than one family name, the second one is - // *often* (but not always) an RBIZ name + // *often* (but not always) an RBIZ name. while let Ok(name) = pattern.get_string(FC_FAMILY, i) { if i == 0 { // First name @@ -486,7 +486,7 @@ impl SystemFonts { } } - // Populate the generic family map + // Populate the generic family map. let mut generic_families = GenericFamilyMap::default(); for (generic_family, name) in GENERIC_FAMILY_NAMES { let mut pattern = Pattern::new().unwrap(); @@ -507,12 +507,12 @@ impl SystemFonts { for font in font_set.iter() { // Not sure if FcFontRenderPrepare performs any substitutions // relevant to fallback family name matching, but it's a good - // idea to call it just in case + // idea to call it just in case. let Some(font) = config.font_render_prepare(&pattern, &font) else { continue; }; // Generic families can have more than one name, but the only - // one we care about is the first one + // one we care about is the first one. let Ok(name) = font.get_string(FC_FAMILY, 0) else { continue; }; @@ -574,7 +574,7 @@ impl SystemFonts { config.substitute(&mut pattern, FcMatchPattern); - // This calls FcFontRenderPrepare for us + // This calls FcFontRenderPrepare for us. let font = config.font_match(&pattern).ok()?; let family_name = font.get_string(FC_FAMILY, 0).ok()?; @@ -587,7 +587,7 @@ impl SystemFonts { let config = self.config.as_ref()?; let name = self.name_map.get_by_id(id).cloned()?; - // Match by family name + // Match by family name. let mut pattern = Pattern::new()?; pattern.add_string(FC_FAMILY, CString::new(name.name()).ok()?.as_c_str()); config.substitute(&mut pattern, FcMatchPattern); From a3969cd72d1fd796dfed92a6f41618b037a2f1b6 Mon Sep 17 00:00:00 2001 From: valadaptive Date: Thu, 12 Jun 2025 10:55:48 -0400 Subject: [PATCH 5/8] Add comment about RBIZ weirdness --- fontique/src/backend/fontconfig.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/fontique/src/backend/fontconfig.rs b/fontique/src/backend/fontconfig.rs index 0a2dacda3..b5b9366ee 100644 --- a/fontique/src/backend/fontconfig.rs +++ b/fontique/src/backend/fontconfig.rs @@ -661,8 +661,25 @@ const GENERIC_FAMILY_NAMES: &[(GenericFamily, &CStr)] = &[ /// categorizing fonts. This removes those suffixes from family names so that /// we can match on all attributes. fn strip_rbiz(name: &str) -> &str { - // TODO(valadaptive): this seems incomplete. check fcname.c for their - // constants + // TODO: Figure out a more robust way to do this. + // + // This list of RBIZ suffixes is preexisting, and doesn't match fontconfig's + // list of weight and width names. However, fontconfig's list is also + // incomplete, and fonts can in general have arbitrary RBIZ names. + // + // Fontconfig provides the `FC_STYLE` property on fonts which *should* + // provide the RBIZ suffix, but this doesn't always return what we need. For + // instance, Lato Hairline Italic has two listed `FC_FAMILY` names: "Lato" + // (with platform_id: 1, encoding_id: 0, language_id: 0) and "Lato Hairline" + // (with platform_id: 3, encoding_id: 1, language_id: 1033). Note that there + // is no "Italic" in either name. + // + // However, that font's `FC_STYLE` values are "Hairline Italic" and + // "Italic". Note that neither of those are suffixes of "Lato Hairline", and + // so neither lets us strip the "Hairline" suffix. + // + // What we really want is the OpenType "typographic family name", which + // fontconfig doesn't give us. For now, we're keeping this existing code. const SUFFIXES: &[&str] = &[ " Thin", " ExtraLight", From 8efaa93e4616c2656b79547484a11e879bdebced Mon Sep 17 00:00:00 2001 From: valadaptive Date: Fri, 13 Jun 2025 03:37:27 -0400 Subject: [PATCH 6/8] Remove `strip_rbiz` from fontconfig backend Web browsers let you use named fonts with various suffixes. For instance, `font-family: "Lato Hairline"` works just fine. We should match that behavior, which lets us get rid of this hack. --- fontique/src/backend/fontconfig.rs | 56 +++--------------------------- 1 file changed, 5 insertions(+), 51 deletions(-) diff --git a/fontique/src/backend/fontconfig.rs b/fontique/src/backend/fontconfig.rs index b5b9366ee..f4d3ae9c6 100644 --- a/fontique/src/backend/fontconfig.rs +++ b/fontique/src/backend/fontconfig.rs @@ -478,9 +478,9 @@ impl SystemFonts { while let Ok(name) = pattern.get_string(FC_FAMILY, i) { if i == 0 { // First name - first_name_id = Some(name_map.get_or_insert(strip_rbiz(&name)).id()); + first_name_id = Some(name_map.get_or_insert(&name).id()); } else if let Some(first_name_id) = first_name_id { - name_map.add_alias(first_name_id, strip_rbiz(&name)); + name_map.add_alias(first_name_id, &name); } i += 1; } @@ -517,15 +517,14 @@ impl SystemFonts { continue; }; - let name = strip_rbiz(&name); - if added_names.contains(name) { + if added_names.contains(name.as_ref()) { continue; } - let Some(family_name) = name_map.get(name) else { + let Some(family_name) = name_map.get(name.as_ref()) else { continue; }; - added_names.insert(name.to_owned()); + added_names.insert(name.into_owned()); generic_families.append(*generic_family, once(family_name.id())); } } @@ -656,48 +655,3 @@ const GENERIC_FAMILY_NAMES: &[(GenericFamily, &CStr)] = &[ (GenericFamily::Emoji, c"emoji"), (GenericFamily::Math, c"math"), ]; - -/// Fontconfig seems to force RBIZ (regular, bold, italic, bold italic) when -/// categorizing fonts. This removes those suffixes from family names so that -/// we can match on all attributes. -fn strip_rbiz(name: &str) -> &str { - // TODO: Figure out a more robust way to do this. - // - // This list of RBIZ suffixes is preexisting, and doesn't match fontconfig's - // list of weight and width names. However, fontconfig's list is also - // incomplete, and fonts can in general have arbitrary RBIZ names. - // - // Fontconfig provides the `FC_STYLE` property on fonts which *should* - // provide the RBIZ suffix, but this doesn't always return what we need. For - // instance, Lato Hairline Italic has two listed `FC_FAMILY` names: "Lato" - // (with platform_id: 1, encoding_id: 0, language_id: 0) and "Lato Hairline" - // (with platform_id: 3, encoding_id: 1, language_id: 1033). Note that there - // is no "Italic" in either name. - // - // However, that font's `FC_STYLE` values are "Hairline Italic" and - // "Italic". Note that neither of those are suffixes of "Lato Hairline", and - // so neither lets us strip the "Hairline" suffix. - // - // What we really want is the OpenType "typographic family name", which - // fontconfig doesn't give us. For now, we're keeping this existing code. - const SUFFIXES: &[&str] = &[ - " Thin", - " ExtraLight", - " DemiLight", - " Light", - " Medium", - " Black", - " SemiBold", - " Semibold", - " ExtraBold", - " Extra Bold", - " Black", - " Narrow", - ]; - for suffix in SUFFIXES { - if let Some(name) = name.strip_suffix(suffix) { - return name; - } - } - name -} From b12e9981d866a6a179dc496d199b734e317800da Mon Sep 17 00:00:00 2001 From: valadaptive Date: Fri, 13 Jun 2025 08:09:32 -0400 Subject: [PATCH 7/8] Clean up fontconfig generic family map population Track duplicate families by ID instead of name. Also use filter_map to add all the generic families in one go. This also lets us use the question mark operator in the iterator body, leading to much cleaner code. --- fontique/src/backend/fontconfig.rs | 51 ++++++++++++++---------------- 1 file changed, 23 insertions(+), 28 deletions(-) diff --git a/fontique/src/backend/fontconfig.rs b/fontique/src/backend/fontconfig.rs index f4d3ae9c6..b12405397 100644 --- a/fontique/src/backend/fontconfig.rs +++ b/fontique/src/backend/fontconfig.rs @@ -3,7 +3,6 @@ use core::{ ffi::{CStr, c_char}, - iter::once, marker::PhantomData, ptr::NonNull, }; @@ -488,7 +487,7 @@ impl SystemFonts { // Populate the generic family map. let mut generic_families = GenericFamilyMap::default(); - for (generic_family, name) in GENERIC_FAMILY_NAMES { + for (generic_family, name) in GENERIC_FAMILY_NAMES.iter().copied() { let mut pattern = Pattern::new().unwrap(); pattern.add_string(FC_FAMILY, name); // TODO: do we need FcConfigSetDefaultSubstitute? @@ -499,34 +498,30 @@ impl SystemFonts { // they provide no new Unicode coverage. let font_set = config.font_sort(&pattern, true).unwrap(); - // There are a lot of duplicate font names in the substituted + // There are a lot of duplicate font families in the substituted // pattern. Keep track of which ones have already been added to the // list. - let mut added_names = HashSet::new(); - - for font in font_set.iter() { - // Not sure if FcFontRenderPrepare performs any substitutions - // relevant to fallback family name matching, but it's a good - // idea to call it just in case. - let Some(font) = config.font_render_prepare(&pattern, &font) else { - continue; - }; - // Generic families can have more than one name, but the only - // one we care about is the first one. - let Ok(name) = font.get_string(FC_FAMILY, 0) else { - continue; - }; - - if added_names.contains(name.as_ref()) { - continue; - } - let Some(family_name) = name_map.get(name.as_ref()) else { - continue; - }; - - added_names.insert(name.into_owned()); - generic_families.append(*generic_family, once(family_name.id())); - } + let mut added_families = HashSet::new(); + + generic_families.append( + generic_family, + font_set.iter().filter_map(|font| { + // Not sure if FcFontRenderPrepare performs any substitutions + // relevant to fallback family name matching, but it's a good + // idea to call it just in case. + let font = config.font_render_prepare(&pattern, &font)?; + // Generic families can have more than one name, but the only + // one we care about is the first one. + let name = font.get_string(FC_FAMILY, 0).ok()?; + let family_name = name_map.get(name.as_ref())?; + + if !added_families.insert(family_name.id()) { + return None; + } + + Some(family_name.id()) + }), + ); } Self { From 14efd0c36899bd0b44e41d37303b44f3d38e600c Mon Sep 17 00:00:00 2001 From: valadaptive Date: Thu, 3 Jul 2025 17:42:14 -0400 Subject: [PATCH 8/8] Add CHANGELOG entry for fontconfig changes --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cd9456eb..d3ae70ea2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ This release has an [MSRV] of 1.82. ### Changed +#### Fontique + +- The fontconfig backend, used to enumerate system fonts on Linux, has been rewritten to call into the system's fontconfig library instead of parsing fontconfig's configuration files itself. This should significantly improve the behavior of system fonts and generic families on Linux. ([#378][] by [@valadaptive][]) + ### Fixed #### Fontique @@ -305,6 +309,7 @@ This release has an [MSRV][] of 1.70. [#353]: https://github.com/linebender/parley/pull/353 [#362]: https://github.com/linebender/parley/pull/362 [#369]: https://github.com/linebender/parley/pull/369 +[#378]: https://github.com/linebender/parley/pull/378 [#380]: https://github.com/linebender/parley/pull/380 [#385]: https://github.com/linebender/parley/pull/385