Use Fontconfig for Linux font discovery (#2330) (#2961)

* Fix #2330: discover installed fonts through Fontconfig

* Fix wasm cfg for Fontconfig fallback

* Add Linux font discovery regression tests

* Reject Fontconfig matches without CJK coverage
This commit is contained in:
Sir Starch 2026-10-12 07:05:56 +02:00 committed by GitHub
parent 5e0a6d404d
commit 82c58fabab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 267 additions and 21 deletions

View File

@ -220,7 +220,147 @@ pub fn families(script: CjkScript) -> &'static [&'static str] {
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FontFile {
pub path: std::path::PathBuf,
pub family: &'static str,
pub family: String,
}
#[cfg(target_os = "linux")]
struct FontconfigCjkScan {
started: std::sync::atomic::AtomicBool,
result: std::sync::Mutex<Option<[Option<FontFile>; 4]>>,
}
#[cfg(target_os = "linux")]
fn fontconfig_cjk_scan() -> &'static FontconfigCjkScan {
static SCAN: std::sync::OnceLock<FontconfigCjkScan> = std::sync::OnceLock::new();
SCAN.get_or_init(|| FontconfigCjkScan { started: std::sync::atomic::AtomicBool::new(false), result: std::sync::Mutex::new(None) })
}
#[cfg(target_os = "linux")]
/// Start the optional Fontconfig CJK-family lookup in the background.
///
/// The scan uses `fc-match` when available and leaves the built-in path candidates unchanged
/// when it is missing or cannot resolve a font.
pub fn start_fontconfig_cjk_scan() {
use std::sync::atomic::Ordering;
let scan = fontconfig_cjk_scan();
if scan.started.swap(true, Ordering::Relaxed) {
return;
}
let scan = fontconfig_cjk_scan();
if std::thread::Builder::new()
.name("photocraft-fontconfig-cjk".into())
.spawn(move || {
let result = [CjkScript::Japanese, CjkScript::SimplifiedChinese, CjkScript::TraditionalChinese, CjkScript::Korean].map(fontconfig_match);
*scan.result.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(result);
})
.is_err()
{
*fontconfig_cjk_scan().result.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(std::array::from_fn(|_| None));
}
}
#[cfg(not(target_os = "linux"))]
/// No-op outside Linux, where the Fontconfig lookup is unavailable.
pub fn start_fontconfig_cjk_scan() {}
#[cfg(target_os = "linux")]
/// Whether the optional background Fontconfig CJK lookup is still running.
pub fn fontconfig_cjk_scan_pending() -> bool {
let scan = fontconfig_cjk_scan();
scan.started.load(std::sync::atomic::Ordering::Relaxed) && scan.result.lock().unwrap_or_else(std::sync::PoisonError::into_inner).is_none()
}
#[cfg(not(target_os = "linux"))]
/// Fontconfig lookup is unavailable outside Linux.
pub fn fontconfig_cjk_scan_pending() -> bool {
false
}
#[cfg(target_os = "linux")]
fn fontconfig_match(script: CjkScript) -> Option<FontFile> {
use std::ffi::OsString;
use std::io::Read as _;
use std::os::unix::ffi::OsStringExt;
const MAX_FONT_BYTES: u64 = 128 << 20;
let language = match script {
CjkScript::Japanese => "ja",
CjkScript::SimplifiedChinese => "zh-cn",
CjkScript::TraditionalChinese => "zh-tw",
CjkScript::Korean => "ko",
};
let output = std::process::Command::new("fc-match").args(["--format=%{file}\\t%{family[0]}\\n", &format!(":lang={language}")]).output().ok()?;
if !output.status.success() {
return None;
}
let line = output.stdout.split(|byte| *byte == b'\n').find(|line| !line.is_empty())?;
let separator = line.iter().position(|byte| *byte == b'\t')?;
let path = line.get(..separator)?;
let family = line.get(separator.checked_add(1)?..)?;
let path = std::path::PathBuf::from(OsString::from_vec(path.to_vec()));
if !path.is_absolute()
|| !path.extension().and_then(|ext| ext.to_str()).is_some_and(|ext| matches!(ext.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc"))
|| !path.is_file()
{
return None;
}
let family = String::from_utf8_lossy(family).trim().to_string();
if family.is_empty() {
return None;
}
let file = std::fs::File::open(&path).ok()?;
let length = file.metadata().ok()?.len();
if length == 0 || length > MAX_FONT_BYTES {
return None;
}
let mut bytes = Vec::new();
bytes.try_reserve_exact(usize::try_from(length).ok()?).ok()?;
file.take(MAX_FONT_BYTES + 1).read_to_end(&mut bytes).ok()?;
if u64::try_from(bytes.len()).ok()? > MAX_FONT_BYTES || !fontconfig_font_covers_sample(&bytes, &family, script) {
return None;
}
Some(FontFile { path: path.canonicalize().unwrap_or(path), family })
}
#[cfg(target_os = "linux")]
fn fontconfig_font_covers_sample(bytes: &[u8], family: &str, script: CjkScript) -> bool {
use skrifa::MetadataProvider;
let index = face_index_for_family(bytes, family);
let Ok(font) = skrifa::FontRef::from_index(bytes, index) else { return false };
let sample = match script {
CjkScript::Japanese => "レイヤー図",
CjkScript::SimplifiedChinese => "图层",
CjkScript::TraditionalChinese => "圖層",
CjkScript::Korean => "카드 배경",
};
sample.chars().all(|character| font.charmap().map(character).is_some())
}
#[cfg(target_os = "linux")]
fn fontconfig_cjk_font(script: CjkScript) -> Option<FontFile> {
use std::sync::atomic::Ordering;
let scan = fontconfig_cjk_scan();
if !scan.started.load(Ordering::Relaxed) {
return None;
}
let index = match script {
CjkScript::Japanese => 0,
CjkScript::SimplifiedChinese => 1,
CjkScript::TraditionalChinese => 2,
CjkScript::Korean => 3,
};
scan.result.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref()?.get(index)?.clone()
}
#[cfg(all(not(target_os = "linux"), not(target_arch = "wasm32")))]
fn fontconfig_cjk_font(_: CjkScript) -> Option<FontFile> {
None
}
/// System font files for `script`, most preferred first. Paths that don't exist are included;
@ -228,21 +368,21 @@ pub struct FontFile {
#[cfg(not(target_arch = "wasm32"))]
pub fn font_files(script: CjkScript) -> Vec<FontFile> {
use CjkScript::*;
let f = |p: &str, family: &'static str| FontFile { path: p.into(), family };
let f = |p: &str, family: &'static str| FontFile { path: p.into(), family: family.to_string() };
if cfg!(target_os = "macos") {
let pingfang = |family| mac_pingfang().map(|path| FontFile { path, family });
match script {
Japanese => vec![f("/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc", ""), f("/System/Library/Fonts/ヒラギノ角ゴシック W4.ttc", "")],
SimplifiedChinese => pingfang("PingFang SC")
SimplifiedChinese => pingfang("PingFang SC".to_string())
.into_iter()
.chain([f("/System/Library/Fonts/Hiragino Sans GB.ttc", ""), f("/System/Library/Fonts/STHeiti Light.ttc", "Heiti SC")])
.collect(),
TraditionalChinese => pingfang("PingFang TC").into_iter().chain([f("/System/Library/Fonts/STHeiti Light.ttc", "Heiti TC")]).collect(),
TraditionalChinese => pingfang("PingFang TC".to_string()).into_iter().chain([f("/System/Library/Fonts/STHeiti Light.ttc", "Heiti TC")]).collect(),
Korean => vec![f("/System/Library/Fonts/AppleSDGothicNeo.ttc", "Apple SD Gothic Neo")],
}
} else if cfg!(target_os = "windows") {
let dir = std::env::var("WINDIR").unwrap_or_else(|_| "C:\\Windows".into());
let w = |file: &str, family: &'static str| FontFile { path: std::path::Path::new(&dir).join("Fonts").join(file), family };
let w = |file: &str, family: &'static str| FontFile { path: std::path::Path::new(&dir).join("Fonts").join(file), family: family.to_string() };
match script {
Japanese => vec![w("YuGothR.ttc", "Yu Gothic"), w("YuGothM.ttc", "Yu Gothic"), w("meiryo.ttc", "Meiryo"), w("msgothic.ttc", "")],
SimplifiedChinese => vec![w("msyh.ttc", "Microsoft YaHei"), w("msyh.ttf", ""), w("simsun.ttc", "")],
@ -292,7 +432,13 @@ pub fn font_files(script: CjkScript) -> Vec<FontFile> {
f("/usr/share/fonts/un-core/UnDotum.ttf", ""),
],
});
with_flatpak_host_fonts(v)
let mut files = with_flatpak_host_fonts(v);
if let Some(font) = fontconfig_cjk_font(script)
&& !files.iter().any(|file| file.path == font.path)
{
files.push(font);
}
files
}
}
@ -306,7 +452,7 @@ fn with_flatpak_host_fonts(files: Vec<FontFile>) -> Vec<FontFile> {
file.path
.strip_prefix("/usr/share/fonts")
.ok()
.map(|relative| FontFile { path: std::path::Path::new("/run/host/fonts").join(relative), family: file.family })
.map(|relative| FontFile { path: std::path::Path::new("/run/host/fonts").join(relative), family: file.family.clone() })
} else {
None
};
@ -320,12 +466,12 @@ fn with_flatpak_host_fonts(files: Vec<FontFile>) -> Vec<FontFile> {
/// but look worse than the script fonts).
#[cfg(not(target_arch = "wasm32"))]
pub fn last_resort_files() -> Vec<FontFile> {
let f = |p: &str| FontFile { path: p.into(), family: "" };
let f = |p: &str| FontFile { path: p.into(), family: String::new() };
if cfg!(target_os = "macos") {
vec![f("/System/Library/Fonts/Supplemental/Arial Unicode.ttf"), f("/Library/Fonts/Arial Unicode.ttf")]
} else if cfg!(target_os = "windows") {
let dir = std::env::var("WINDIR").unwrap_or_else(|_| "C:\\Windows".into());
vec![FontFile { path: std::path::Path::new(&dir).join("Fonts").join("ARIALUNI.TTF"), family: "" }]
vec![FontFile { path: std::path::Path::new(&dir).join("Fonts").join("ARIALUNI.TTF"), family: String::new() }]
} else {
with_flatpak_host_fonts(vec![
f("/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf"),
@ -490,6 +636,25 @@ mod tests {
assert_eq!(face_index_for_family(inter, ""), 0);
}
#[cfg(target_os = "linux")]
#[test]
fn fontconfig_candidates_must_cover_the_requested_script() {
let inter = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
for script in [Japanese, SimplifiedChinese, TraditionalChinese, Korean] {
assert!(!fontconfig_font_covers_sample(inter, "Inter", script), "{script:?}");
}
}
#[cfg(target_os = "linux")]
#[test]
fn fontconfig_matches_cover_the_requested_script() {
for script in [Japanese, SimplifiedChinese, TraditionalChinese, Korean] {
let Some(file) = fontconfig_match(script) else { continue };
let bytes = std::fs::read(&file.path).expect("Fontconfig match remains readable");
assert!(fontconfig_font_covers_sample(&bytes, &file.family, script), "{script:?}: {}", file.path.display());
}
}
/// On a Mac with PingFang, the SC and TC faces are found by name (not a hard-coded index).
#[cfg(target_os = "macos")]
#[test]

View File

@ -1,9 +1,11 @@
//! Font database: bundled fonts (always available, also on the web), the optional craft-fonts
//! Japanese fonts ([`crate::craft_fonts`], when built with `CRAFT_FONTS_DIR`), optional system
//! fonts found by scanning the platform font directories (no fontconfig), user-registered font
//! data, and PostScript-name lookup for PSD import.
//! fonts found by scanning platform directories (plus Fontconfig's file list on Linux),
//! user-registered font data, and PostScript-name lookup for PSD import.
use std::collections::HashMap;
#[cfg(not(target_arch = "wasm32"))]
use std::collections::HashSet;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, LazyLock};
@ -179,8 +181,8 @@ impl FontDb {
db
}
/// Scans the platform font directories (once). Font files are memory-mapped lazily by
/// fontique when a face is first used.
/// Scans the platform font directories once, plus Fontconfig's file list on Linux. Font files
/// are memory-mapped lazily by fontique when a face is first used.
pub fn load_system_fonts(&mut self) {
if self.system_loaded {
return;
@ -193,12 +195,18 @@ impl FontDb {
for d in system_font_dirs() {
collect_font_files(&d, 0, &mut files);
}
#[cfg(target_os = "linux")]
files.extend(fontconfig_font_paths());
// PingFang (the macOS Chinese UI font) lives outside the font folders.
if cfg!(target_os = "macos") {
files.extend(crate::cjk::mac_pingfang());
}
for f in files {
self.fcx.collection.load_fonts_from_paths([f]);
let mut seen = HashSet::new();
for path in files {
let key = path.canonicalize().unwrap_or_else(|_| path.clone());
if seen.insert(key) {
self.fcx.collection.load_fonts_from_paths([path]);
}
}
self.ps_cache.clear();
self.face_cache.clear();
@ -560,6 +568,54 @@ pub(crate) fn collect_font_files(dir: &std::path::Path, depth: u32, out: &mut Ve
}
}
/// Fontconfig knows about configured font directories that are not in our portable path list.
/// Keep this optional and shell-free: directory scans remain the fallback when `fc-list` is not
/// installed or Fontconfig cannot enumerate fonts.
#[cfg(all(target_os = "linux", not(target_arch = "wasm32")))]
fn fontconfig_font_paths() -> Vec<std::path::PathBuf> {
let Ok(output) = std::process::Command::new("fc-list").arg("--format=%{file}\\n").output() else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
fontconfig_paths_from_output(&output.stdout).into_iter().filter(|path| path.is_file()).collect()
}
#[cfg(all(target_os = "linux", not(target_arch = "wasm32")))]
fn fontconfig_paths_from_output(output: &[u8]) -> Vec<std::path::PathBuf> {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let mut seen = HashSet::new();
output
.split(|byte| *byte == b'\n')
.filter(|line| !line.is_empty())
.map(|line| std::path::PathBuf::from(OsString::from_vec(line.to_vec())))
.filter(|path| {
path.is_absolute()
&& path.extension().and_then(|ext| ext.to_str()).is_some_and(|ext| matches!(ext.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc"))
})
.map(|path| path.canonicalize().unwrap_or(path))
.filter(|path| seen.insert(path.clone()))
.collect()
}
#[cfg(all(test, target_os = "linux", not(target_arch = "wasm32")))]
mod fontconfig_tests {
use super::fontconfig_paths_from_output;
use std::path::PathBuf;
#[test]
fn parses_unique_absolute_font_paths_from_fontconfig() {
let paths = fontconfig_paths_from_output(
b"/usr/share/fonts/example.ttf\nrelative.otf\n/usr/share/fonts/README.txt\n/usr/local/fonts/collection.OTC\n/usr/share/fonts/example.ttf\n\n",
);
assert_eq!(paths, vec![PathBuf::from("/usr/share/fonts/example.ttf"), PathBuf::from("/usr/local/fonts/collection.OTC")]);
}
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn system_font_dirs() -> Vec<std::path::PathBuf> {
use std::path::PathBuf;

View File

@ -1,8 +1,9 @@
//! Photocraft's type engine.
//!
//! * [`fonts::FontDb`]: bundled fonts (Inter, JetBrains Mono; always available, including on the
//! web), optional system fonts from a directory scan (no fontconfig), user font data, TrueType
//! collections, PostScript-name lookup. Builds made with the optional craft-fonts input
//! web), optional system fonts from platform directory scans plus Fontconfig's Linux file list,
//! user font data, TrueType collections, PostScript-name lookup. Builds made with the optional
//! craft-fonts input
//! (`CRAFT_FONTS_DIR`, see [`craft_fonts`]) also carry its Japanese fonts as fallbacks.
//! * [`layout`]: shaping and line layout with [parley] (HarfRust shaping, bidi, line breaking),
//! point and paragraph (box) text, per-run styles, Photoshop leading/indent/spacing rules.

View File

@ -65,6 +65,7 @@ pub fn no_embedded(_: CjkScript) -> Vec<&'static CraftFont> {
impl Sources {
#[cfg(not(target_arch = "wasm32"))]
pub fn system() -> Self {
cjk::start_fontconfig_cjk_scan();
Self {
locale: || match crate::i18n::current().code() {
code @ ("ja" | "ko" | "zh-hans" | "zh-hant") => Some(code.to_string()),
@ -90,6 +91,18 @@ impl Sources {
}
}
/// Headless test frame pumps do not wait for `request_repaint_after`, so let the optional
/// Fontconfig worker finish before asserting on installed system fonts.
#[cfg(all(test, target_os = "linux"))]
pub(crate) fn wait_for_fontconfig_cjk_scan() {
cjk::start_fontconfig_cjk_scan();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while cjk::fontconfig_cjk_scan_pending() && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(!cjk::fontconfig_cjk_scan_pending(), "Fontconfig CJK lookup did not finish within 10 seconds");
}
/// The lazy loader's state: which scripts were tried and which files were read.
pub struct CjkFallback {
sources: Sources,
@ -139,14 +152,20 @@ impl CjkFallback {
if self.tried.contains(&s) {
continue;
}
self.tried.push(s);
if let Some(f) = self.load_embedded(s) {
self.tried.push(s);
return Some(f);
}
let fontconfig_pending = cjk::fontconfig_cjk_scan_pending();
let files = (self.sources.files)(s);
if let Some(f) = self.load_first(&files) {
self.tried.push(s);
return Some(f);
}
if fontconfig_pending {
return None;
}
self.tried.push(s);
}
if !self.last_resort_tried {
self.last_resort_tried = true;
@ -211,7 +230,7 @@ impl CjkFallback {
continue;
}
let Some(bytes) = read_font(&f.path) else { continue };
let index = cjk::face_index_for_family(&bytes, f.family);
let index = cjk::face_index_for_family(&bytes, &f.family);
self.loaded.push(f.path.clone());
let name = format!("{FONT_PREFIX}-{}", self.registered.len());
log::info!("UI font fallback: registered {} (face {index}) as {name}", f.path.display());
@ -348,6 +367,8 @@ impl egui::Plugin for CjkFontPlugin {
if let Some((name, data)) = font {
add_to_all_families(ctx, name, data);
ctx.request_repaint();
} else if cjk::classify(c).is_some() && cjk::fontconfig_cjk_scan_pending() {
ctx.request_repaint_after(std::time::Duration::from_millis(50));
} else if !self.0.exhausted() || !self.0.scripts_exhausted() {
// Nothing readable for this script; try the next one on the next frame.
ctx.request_repaint();
@ -375,7 +396,6 @@ pub fn install_with(ctx: &egui::Context, sources: Sources) {
ctx.add_plugin(CjkFontPlugin(fallback));
}
}
#[cfg(test)]
#[cfg(not(target_arch = "wasm32"))]
mod tests {
@ -447,7 +467,7 @@ mod tests {
}
fn fake(name: &str) -> FontFile {
FontFile { path: fake_dir().join(name), family: "" }
FontFile { path: fake_dir().join(name), family: String::new() }
}
fn fake_sources(locale: fn() -> Option<String>) -> Sources {
@ -805,6 +825,8 @@ mod tests {
/// machine has no font for it, e.g. CI Linux without Noto CJK).
#[test]
fn system_fonts_cover_cjk_samples() {
#[cfg(target_os = "linux")]
wait_for_fontconfig_cjk_scan();
let available = |s: CjkScript| cjk::font_files(s).iter().any(|f| f.path.is_file());
let samples = [
("图层", CjkScript::SimplifiedChinese),

View File

@ -166,6 +166,8 @@ fn cjk_font_order_follows_the_selected_language() {
#[test]
fn korean_glyphs_remain_available_after_repeated_language_switches() {
use photocraft_text::cjk::{self, CjkChar, CjkScript};
#[cfg(target_os = "linux")]
crate::cjk_fonts::wait_for_fontconfig_cjk_scan();
if !cjk::font_files(CjkScript::Korean).iter().any(|font| font.path.is_file()) {
eprintln!("skipping: no installed Korean font");
return;