Files
OneCAD-Draft/src/scene/text/sysfont.rs
T
Alexey Bagno f954c7f495 fix(text): resolve a style's font file name against installed faces
A DWG text style stores its TrueType font as a *file* name — `GOST2304_TypeA_italic.ttf`,
sometimes with a directory — which is what AutoCAD resolves. The family lives
inside the file and is usually a different string (`GOST2304 Type A`), so looking
the reference up as a family missed an installed font and fell through to the
embedded stroke fonts. Those carry no Cyrillic at all (`simplex`, `standard`,
`romans`, `iso3098` hold no U+04xx glyph), so a Russian drawing came out with
every word missing: the letters went on to the system-font fallback, which
returns nothing on macOS.

Installed faces are now indexed by file name, and a reference — a style's font
file, or an inline `\f` code, which carries either form — resolves by family
first and by file name second. An embedded stroke font still wins, so
`romans.shx` keeps rendering as it did.

`cargo test --lib face_files_resolve_to_their_family`
2026-09-22 09:39:43 +03:00

227 lines
8.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// System TrueType/OpenType font discovery.
//
// Wraps a `fontdb` database loaded with the user's installed system fonts so
// the rest of the app can (a) list available font families for the text-style
// picker and (b) borrow a face's raw bytes to extract glyph outlines (see the
// TTF glyph engine). LFF stroke fonts stay separate — this is purely the
// TrueType side of the renderer.
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
struct SysFonts {
db: fontdb::Database,
/// Sorted, de-duplicated family names for the picker.
families: Vec<String>,
/// Installed face file name (lower-case) → the family that face holds.
/// A drawing references a TrueType style by *file* name, so this is what
/// makes such a style resolve — see [`family_for_reference`].
by_file: HashMap<String, String>,
}
static FONTS: OnceLock<SysFonts> = OnceLock::new();
fn fonts() -> &'static SysFonts {
FONTS.get_or_init(|| {
let mut db = fontdb::Database::new();
db.load_system_fonts();
let mut families: Vec<String> = Vec::new();
let mut by_file: HashMap<String, String> = HashMap::new();
for face in db.faces() {
let Some((family, _)) = face.families.first() else {
continue;
};
families.push(family.clone());
// Remember every face file: a DWG style names the file, and the
// family inside it is usually a different string.
if let fontdb::Source::File(path) = &face.source {
if let Some(name) = path.file_name() {
by_file
.entry(name.to_string_lossy().to_ascii_lowercase())
.or_insert_with(|| family.clone());
}
}
}
families.sort_by_key(|n| n.to_lowercase());
families.dedup();
SysFonts {
db,
families,
by_file,
}
})
}
/// All installed system font families, sorted case-insensitively, de-duped.
pub fn families() -> &'static [String] {
&fonts().families
}
/// Resolve a requested family name to the canonical installed system family name (with exact case).
///
/// Memoised process-wide: `resolve_font` calls this once per word on the MTEXT
/// measure hot path for inline-`\f` TTF runs, and `Face::resolve` re-runs it
/// immediately after — the underlying fontdb query plus linear family scans are
/// not free. The cache keys on the raw request string; results are stable for
/// the process lifetime (the font DB is loaded once via `OnceLock`).
pub fn canonical_family_name(family: &str) -> Option<String> {
static CACHE: OnceLock<Mutex<HashMap<String, Option<String>>>> = OnceLock::new();
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
if let Some(hit) = cache.lock().unwrap().get(family) {
return hit.clone();
}
let resolved = canonical_family_name_uncached(family);
cache
.lock()
.unwrap()
.insert(family.to_string(), resolved.clone());
resolved
}
fn canonical_family_name_uncached(family: &str) -> Option<String> {
let db = &fonts().db;
// 1. Try exact match first
let query = fontdb::Query {
families: &[fontdb::Family::Name(family)],
..Default::default()
};
if db.query(&query).is_some() {
if let Some(canonical) = fonts().families.iter().find(|&f| f.eq_ignore_ascii_case(family)) {
return Some(canonical.clone());
}
return Some(family.to_string());
}
// 2. Try case-insensitive match on the families we have
if let Some(matched) = fonts().families.iter().find(|&f| f.eq_ignore_ascii_case(family)) {
return Some(matched.clone());
}
// 3. Match common prefixes / variations
let family_lower = family.to_lowercase();
let alias = match family_lower.as_str() {
"arialn" => Some("Arial Narrow"),
"gothic" => Some("Century Gothic"),
"times" => Some("Times New Roman"),
"cour" => Some("Courier New"),
_ => None,
};
if let Some(alias_name) = alias {
if let Some(matched) = fonts().families.iter().find(|&f| f.eq_ignore_ascii_case(alias_name)) {
return Some(matched.clone());
}
}
// 4. Try matching prefix/subset case-insensitively. Require at least 3
// chars so a 1–2 letter request can't grab an arbitrary family by the
// first iteration order. Iterating sorted keeps the pick deterministic.
if family_lower.len() >= 3 {
let mut candidates: Vec<&String> = fonts()
.families
.iter()
.filter(|&f| {
let f_low = f.to_lowercase();
f_low.starts_with(&family_lower) || family_lower.starts_with(&f_low)
})
.collect();
candidates.sort();
if let Some(matched) = candidates.first() {
return Some((*matched).clone());
}
}
None
}
/// Resolve a family name to a concrete face id (regular weight/style).
fn face_id(family: &str) -> Option<fontdb::ID> {
let db = &fonts().db;
let canonical = canonical_family_name(family)?;
let query = fontdb::Query {
families: &[fontdb::Family::Name(&canonical)],
..Default::default()
};
db.query(&query)
}
/// Borrow the raw face bytes for `family` and run `f` over them. The byte slice
/// is only valid inside the closure, so callers extract everything they need
/// (e.g. flattened glyph outlines) before returning. `index` is the face index
/// within a TrueType collection. Returns `None` if the family is unknown.
pub fn with_face_data<T>(family: &str, f: impl FnOnce(&[u8], u32) -> T) -> Option<T> {
let id = face_id(family)?;
fonts().db.with_face_data(id, f)
}
/// The family of the installed face whose file is named `name` — a bare file
/// name or any path to one, matched case-insensitively.
fn family_for_file(name: &str) -> Option<String> {
let file = name.rsplit(['/', '\\']).next()?.trim();
if file.is_empty() {
return None;
}
fonts().by_file.get(&file.to_ascii_lowercase()).cloned()
}
/// The installed family a drawing's font reference names, or `None` when
/// nothing installed answers to it.
///
/// A DWG text style stores the TrueType font *file* (`GOST2304_TypeA_italic.ttf`,
/// sometimes with a directory), which is what AutoCAD resolves; the family name
/// lives inside the file and is usually not the same string. Inline `\f` codes
/// carry either form, so both are tried.
pub fn family_for_reference(reference: &str) -> Option<String> {
let reference = reference.trim();
if reference.is_empty() {
return None;
}
canonical_family_name(reference).or_else(|| family_for_file(reference))
}
/// Whether `family` matches an installed system font (case-insensitive via
/// fontdb's own matching).
pub fn has_family(family: &str) -> bool {
face_id(family).is_some()
}
#[cfg(test)]
mod tests {
use super::*;
/// A DWG stores a style's TrueType font as a file name, so every installed
/// face has to be reachable by the name of its file — case-insensitively,
/// and whether it comes as a bare name or as a path.
#[test]
fn face_files_resolve_to_their_family() {
let found = fonts().db.faces().find_map(|face| match &face.source {
fontdb::Source::File(path) => {
let file = path.file_name()?.to_string_lossy().into_owned();
let family = face.families.first().map(|(name, _)| name.clone())?;
Some((file, family))
}
_ => None,
});
let Some((file, family)) = found else {
eprintln!("no installed face files on this host; skipping");
return;
};
assert_eq!(family_for_file(&file).as_deref(), Some(family.as_str()));
assert_eq!(
family_for_file(&file.to_ascii_uppercase()).as_deref(),
Some(family.as_str())
);
assert_eq!(
family_for_reference(&format!("/somewhere/else/{file}")).as_deref(),
Some(family.as_str())
);
assert_eq!(family_for_file("no-such-face-9f3.ttf"), None);
assert_eq!(family_for_reference(" "), None);
}
}