diff --git a/crates/eps/src/import/mod.rs b/crates/eps/src/import/mod.rs index 127046b4..90d00485 100644 --- a/crates/eps/src/import/mod.rs +++ b/crates/eps/src/import/mod.rs @@ -24,6 +24,8 @@ mod text; #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_fontnames; use std::sync::Arc; diff --git a/crates/eps/src/import/tests_fontnames.rs b/crates/eps/src/import/tests_fontnames.rs new file mode 100644 index 00000000..01061bbb --- /dev/null +++ b/crates/eps/src/import/tests_fontnames.rs @@ -0,0 +1,38 @@ +//! Type names the available family as the font spells it, not as the PostScript name splits into +//! words (#130). + +use vectorcraft_doc::NodeKind; +use vectorcraft_text::FontDb; + +use crate::import::import; + +/// The bundled Source Sans 3 renamed `family` (as long as the original name). +fn renamed(family: &str) -> Vec { + let mut data = include_bytes!("../../../../assets/fonts/SourceSans3-Regular.ttf").to_vec(); + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + for (from, to) in [(utf16("Source Sans 3"), utf16(family)), (b"Source Sans 3".to_vec(), family.as_bytes().to_vec())] { + assert_eq!(from.len(), to.len()); + let mut i = 0; + while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) { + data[i + at..i + at + to.len()].copy_from_slice(&to); + i += at + to.len(); + } + } + data +} + +#[test] +fn postscript_names_resolve_to_the_available_family() { + // Split into words, its PostScript name would read "My Face Ya Hei3". + assert!(FontDb::global().add_font(renamed("MyFaceYaHei 3")) > 0); + let body = "/MyFaceYaHei3-Bold findfont 12 scalefont setfont 10 20 moveto (Hi) show"; + let eps = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{body}\nshowpage\n%%EOF\n"); + let r = import(eps.as_bytes()).unwrap(); + let mut styles = vec![]; + r.document.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + styles.extend(t.runs.iter().map(|r| (r.style.font_family.clone(), r.style.font_style.clone()))); + } + }); + assert_eq!(styles, [("MyFaceYaHei 3".to_string(), "Bold".to_string())]); +} diff --git a/crates/eps/src/import/text.rs b/crates/eps/src/import/text.rs index 7869f41b..d1ce6170 100644 --- a/crates/eps/src/import/text.rs +++ b/crates/eps/src/import/text.rs @@ -223,7 +223,9 @@ impl Interp<'_> { if !(size.is_finite() && size > 1e-3 && size < 1e5) { return ps_err("undefinedresult", "a font size"); } - let (font_family, font_style) = family_style(&name); + let (family, font_style) = family_style(&name); + // The available family's own spelling: `MicrosoftYaHei` is Microsoft YaHei, not "Microsoft Ya Hei". + let font_family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family); let text: String = s.iter().map(|b| char::from(*b)).filter(|c| !c.is_control()).collect(); let fill = if self.g.paint.is_none() { Paint::solid(vectorcraft_color::Color::BLACK) } else { self.g.paint.clone() }; let style = CharStyle { font_family, font_style, size, fill, stroke: Paint::None, ..CharStyle::default() }; diff --git a/crates/pdf/tests/system_fonts.rs b/crates/pdf/tests/system_fonts.rs new file mode 100644 index 00000000..262c4bde --- /dev/null +++ b/crates/pdf/tests/system_fonts.rs @@ -0,0 +1,34 @@ +//! PDF import names type by the installed fonts in a new session (#130). One test in its own +//! process, so the process-wide font database starts fresh, as it does when the app starts. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use vectorcraft_doc::NodeKind; +use vectorcraft_pdf::{ImportOptions, import_with_report}; +use vectorcraft_testkit::pdf::{PdfPage, pdf_with}; +use vectorcraft_text::{FontDb, system_font_dirs}; + +#[test] +fn a_fresh_session_resolves_pdf_fonts_to_installed_families() { + let bundled = FontDb::with_font_dirs(vec![]).families(); + let probe = FontDb::with_font_dirs(system_font_dirs()); + probe.load_system_fonts(); + let installed = probe.families(); + // A family whose PDF name is its name without the spaces (as "TimesNewRoman"). + let Some(family) = installed.iter().find(|f| !bundled.contains(f) && f.contains(' ') && f.chars().all(|c| c.is_ascii_alphanumeric() || c == ' ')) + else { + eprintln!("no system fonts installed: nothing to check"); + return; + }; + let resources = format!("/Font << /F1 << /Type /Font /Subtype /TrueType /BaseFont /{} >> >>", family.replace(' ', "")); + let page = PdfPage { resources, ..PdfPage::new(100.0, 100.0, "BT /F1 12 Tf 10 30 Td (Hi) Tj ET") }; + let r = import_with_report(&pdf_with(&[page], &[], None), &ImportOptions::default()).unwrap(); + let mut families = vec![]; + r.document.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + families.extend(t.runs.iter().map(|r| r.style.font_family.clone())); + } + }); + assert_eq!(families, std::slice::from_ref(family)); + assert!(!r.warnings.iter().any(|w| w.contains(family.as_str())), "{:?}", r.warnings); +} diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 63c9e2b4..d8227bcb 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -1,7 +1,9 @@ -//! Font database: bundled OFL fonts, user fonts, optional system font catalog, outline cache. +//! Font database: bundled OFL fonts, user fonts, the installed system fonts (cataloged once, loaded +//! on demand), outline cache. use std::collections::HashMap; -use std::sync::atomic::{AtomicU32, Ordering}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock}; use kurbo::BezPath; @@ -139,21 +141,34 @@ impl FontFace { } } -#[cfg(not(target_arch = "wasm32"))] -#[derive(Clone, Debug)] -struct CatalogEntry { - family: String, - style: String, - path: std::path::PathBuf, +/// One installed family found by the system font scan: its name and the file of each style. +#[derive(Debug, Default)] +struct CatalogFamily { + name: String, + /// (style, file) of each face. + faces: Vec<(String, PathBuf)>, } +/// The installed fonts by ASCII-lowercased family name (lookups ignore ASCII case, as +/// [`FontDb::face`] does). Always empty on wasm, which has no system fonts. +type Catalog = HashMap; + /// Process-wide font database. pub struct FontDb { faces: RwLock>>, outlines: Mutex>>, + catalog: RwLock, + /// The folders the system font scan reads (the platform's font folders for [`FontDb::global`]). + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + font_dirs: Vec, + /// Set once the font folders have been scanned. Lookups by family name wait for the first + /// scan, so what they find never depends on what ran before them. #[cfg(not(target_arch = "wasm32"))] - catalog: RwLock>, - /// System fallback state: scanned yet, and characters no system font covers. + cataloged: std::sync::OnceLock<()>, + /// [`FontDb::family_list`], built on demand and dropped when fonts are added or rescanned. + family_cache: Mutex>>, + generation: AtomicU64, + /// System fallback state: characters no system font covers. #[cfg(not(target_arch = "wasm32"))] sys: Mutex, } @@ -162,7 +177,6 @@ pub struct FontDb { #[derive(Default)] struct SysFallback { enabled: bool, - scanned: bool, misses: std::collections::HashSet, } @@ -203,6 +217,13 @@ fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option { ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty())) } +/// A face's (family, style) names. +fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String)> { + let family = name(f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME])?; + let style = name(f, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into()); + Some((family, style)) +} + /// Parse every face in `data` (a font file or collection). Returns `(index, family, style)`. fn enumerate_faces(data: &[u8]) -> Vec<(u32, String, String)> { let count = match FileRef::new(data) { @@ -212,14 +233,90 @@ fn enumerate_faces(data: &[u8]) -> Vec<(u32, String, String)> { }; (0..count) .filter_map(|i| { - let f = skrifa::FontRef::from_index(data, i).ok()?; - let family = name(&f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME])?; - let style = name(&f, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into()); + let (family, style) = face_names(&skrifa::FontRef::from_index(data, i).ok()?)?; Some((i, family, style)) }) .collect() } +/// (family, style) of every face in the font file at `path`, reading only its table directories +/// and `name` tables: a scan opens hundreds of font files, many of them megabytes long. +#[cfg(not(target_arch = "wasm32"))] +fn file_face_names(path: &Path) -> Vec<(String, String)> { + use std::io::{Read, Seek, SeekFrom}; + /// Caps on what a (possibly damaged) file can make the scan read. + const MAX_FACES: u32 = 256; + const MAX_NAME_TABLE: u32 = 1 << 20; + let Ok(mut file) = std::fs::File::open(path) else { return vec![] }; + let mut read_at = |offset: u64, len: usize| -> Option> { + let mut buf = vec![0; len]; + file.seek(SeekFrom::Start(offset)).ok()?; + file.read_exact(&mut buf).ok()?; + Some(buf) + }; + let be32 = |b: &[u8], at: usize| b.get(at..at + 4).and_then(|s| s.try_into().ok()).map(u32::from_be_bytes); + let Some(head) = read_at(0, 12) else { return vec![] }; + // A collection lists where each face's table directory starts. + let starts: Vec = if head.starts_with(b"ttcf") { + let n = be32(&head, 8).unwrap_or(0).min(MAX_FACES) as usize; + read_at(12, n * 4).map(|b| b.chunks_exact(4).filter_map(|c| be32(c, 0)).collect()).unwrap_or_default() + } else { + vec![0] + }; + starts + .into_iter() + .filter_map(|start| { + let dir = read_at(start.into(), 12)?; + let tables = u16::from_be_bytes(dir.get(4..6)?.try_into().ok()?) as usize; + let records = read_at(u64::from(start) + 12, tables * 16)?; + let rec = records.chunks_exact(16).find(|r| r.starts_with(b"name"))?; + let (offset, len) = (be32(rec, 8)?, be32(rec, 12)?); + if len > MAX_NAME_TABLE { + return None; + } + let table = read_at(offset.into(), len as usize)?; + // A one-table font holding just the `name` table, to read it as the font itself would. + let mut font = Vec::with_capacity(28 + table.len()); + font.extend_from_slice(&0x0001_0000_u32.to_be_bytes()); + font.extend_from_slice(&[0, 1, 0, 16, 0, 0, 0, 0]); + font.extend_from_slice(b"name"); + for v in [0, 28, len] { + font.extend_from_slice(&u32::to_be_bytes(v)); + } + font.extend_from_slice(&table); + face_names(&skrifa::FontRef::new(&font).ok()?) + }) + .collect() +} + +/// The platform's font folders (the system's and the user's), scanned by [`FontDb::global`]. +pub fn system_font_dirs() -> Vec { + let mut dirs: Vec = Vec::new(); + if cfg!(target_arch = "wasm32") { + return dirs; + } + let home = std::env::var_os("HOME").map(PathBuf::from); + if cfg!(target_os = "macos") { + dirs.extend(["/System/Library/Fonts", "/Library/Fonts"].map(Into::into)); + if let Some(h) = &home { + dirs.push(h.join("Library/Fonts")); + } + } else if cfg!(windows) { + let root = std::env::var_os("WINDIR").map(PathBuf::from).unwrap_or_else(|| "C:\\Windows".into()); + dirs.push(root.join("Fonts")); + if let Some(l) = std::env::var_os("LOCALAPPDATA") { + dirs.push(PathBuf::from(l).join("Microsoft\\Windows\\Fonts")); + } + } else { + dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(Into::into)); + if let Some(h) = &home { + dirs.push(h.join(".fonts")); + dirs.push(h.join(".local/share/fonts")); + } + } + dirs +} + fn make_face(bytes: FontBytes, index: u32, family: String, style: String, path: Option) -> Option { let data: &[u8] = match &bytes { FontBytes::Static(b) => b, @@ -247,8 +344,13 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, path: }) } +/// `s` lowercased, without anything but letters and digits. +fn norm_chars(s: &str) -> impl Iterator + '_ { + s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase) +} + fn norm(s: &str) -> String { - s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect() + norm_chars(s).collect() } /// Weight implied by a style name (400 = regular). @@ -278,7 +380,8 @@ fn style_italic(style: &str) -> bool { } impl FontDb { - fn new_bundled() -> Self { + /// A database holding the bundled fonts, whose system font scan reads `font_dirs`. + pub fn with_font_dirs(font_dirs: Vec) -> Self { let mut faces = Vec::new(); for data in BUNDLED { for (i, family, style) in enumerate_faces(data) { @@ -290,8 +393,12 @@ impl FontDb { Self { faces: RwLock::new(faces), outlines: Mutex::new(HashMap::new()), + catalog: RwLock::new(Catalog::new()), + font_dirs, #[cfg(not(target_arch = "wasm32"))] - catalog: RwLock::new(Vec::new()), + cataloged: std::sync::OnceLock::new(), + family_cache: Mutex::new(None), + generation: AtomicU64::new(0), #[cfg(not(target_arch = "wasm32"))] sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }), } @@ -308,34 +415,65 @@ impl FontDb { let _ = on; } - /// Process-wide database preloaded with the bundled fonts. + /// Process-wide database: the bundled fonts, then the installed system fonts, cataloged the + /// first time a lookup by family name needs them (or ahead of time by + /// [`FontDb::scan_in_background`]). pub fn global() -> &'static FontDb { static DB: std::sync::OnceLock = std::sync::OnceLock::new(); - DB.get_or_init(FontDb::new_bundled) + DB.get_or_init(|| FontDb::with_font_dirs(system_font_dirs())) } fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec>> { self.faces.read().unwrap_or_else(|e| e.into_inner()) } - /// Family names available (loaded plus cataloged system fonts), sorted and deduplicated. - pub fn families(&self) -> Vec { - let mut v: Vec = self.read_faces().iter().map(|f| f.family.clone()).collect(); - #[cfg(not(target_arch = "wasm32"))] - v.extend(self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|c| c.family.clone())); - v.sort_by_key(|a| a.to_lowercase()); - v.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); - v + fn read_catalog(&self) -> std::sync::RwLockReadGuard<'_, Catalog> { + self.ensure_catalog(); + self.catalog.read().unwrap_or_else(|e| e.into_inner()) } - /// Style names available for `family` (Regular first, then by weight). + /// Fonts were added or the catalog changed: cached family lists are stale. + fn changed(&self) { + *self.family_cache.lock().unwrap_or_else(|e| e.into_inner()) = None; + self.generation.fetch_add(1, Ordering::Relaxed); + } + + /// A number that changes whenever the available fonts do (fonts added, system fonts + /// rescanned): lists built from [`FontDb::family_list`] are current while it doesn't. + pub fn generation(&self) -> u64 { + self.generation.load(Ordering::Relaxed) + } + + /// Family names available (loaded plus installed system fonts), sorted and deduplicated. + pub fn families(&self) -> Vec { + self.family_list().to_vec() + } + + /// [`FontDb::families`], shared: cheap to call every frame. + pub fn family_list(&self) -> Arc<[String]> { + let catalog = self.read_catalog(); + let mut cache = self.family_cache.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(list) = cache.as_ref() { + return list.clone(); + } + let mut v: Vec = self.read_faces().iter().map(|f| f.family.clone()).collect(); + v.extend(catalog.values().map(|c| c.name.clone())); + v.sort_by_key(|a| a.to_lowercase()); + v.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); + let list: Arc<[String]> = v.into(); + *cache = Some(list.clone()); + list + } + + /// Style names available for `family`: upright styles by weight, then italics. pub fn styles(&self, family: &str) -> Vec { let mut v: Vec<(bool, f32, String)> = self.read_faces().iter().filter(|f| f.family.eq_ignore_ascii_case(family)).map(|f| (f.italic, f.weight, f.style.clone())).collect(); - #[cfg(not(target_arch = "wasm32"))] - for c in self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter() { - if c.family.eq_ignore_ascii_case(family) && !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&c.style)) { - v.push((style_italic(&c.style), style_weight(&c.style), c.style.clone())); + if let Some(c) = self.read_catalog().get(&family.to_ascii_lowercase()) { + for (style, _) in &c.faces { + if !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(style)) { + v.push((style_italic(style), style_weight(style), style.clone())); + } } } v.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)).then(a.2.cmp(&b.2))); @@ -350,49 +488,66 @@ impl FontDb { } /// [`FontDb::add_font`] for the bytes of the file at `path` (when known). - fn add_font_from(&self, bytes: Vec, path: Option<&std::path::Path>) -> usize { + fn add_font_from(&self, bytes: Vec, path: Option<&Path>) -> usize { let data = Arc::new(bytes); let mut added = 0; for (i, family, style) in enumerate_faces(&data) { if self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(&family) && f.style.eq_ignore_ascii_case(&style)) { continue; } - if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, family, style, path.map(std::path::Path::to_path_buf)) { + if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, family, style, path.map(Path::to_path_buf)) { self.faces.write().unwrap_or_else(|e| e.into_inner()).push(Arc::new(f)); added += 1; } } + if added > 0 { + self.changed(); + } added } - /// Scan the platform's font directories and catalog the faces found (native only; not called by - /// default). Font data is loaded lazily when a cataloged family is first resolved. Returns the - /// number of faces cataloged. + /// Catalog the installed fonts unless that has been done: the first caller scans the font + /// folders, any other waits for that scan to finish. A no-op on wasm. + fn ensure_catalog(&self) { + #[cfg(not(target_arch = "wasm32"))] + self.cataloged.get_or_init(|| { + self.scan_font_dirs(); + }); + } + + /// Catalog the installed fonts on a background thread, so the first lookup by family name + /// (opening a file, the font menus) doesn't wait for the scan. A no-op once they are + /// cataloged, and on wasm. + pub fn scan_in_background(&'static self) { + #[cfg(not(target_arch = "wasm32"))] + if self.cataloged.get().is_none() { + // A failed spawn leaves the scan to the first lookup that needs it. + let _ = std::thread::Builder::new().name("font-scan".into()).spawn(move || self.ensure_catalog()); + } + } + + /// Scan the font folders again (fonts installed or removed since), cataloging the faces + /// found (native only). Font data is loaded when a cataloged family is first resolved. + /// Returns the number of faces cataloged. #[cfg(not(target_arch = "wasm32"))] pub fn load_system_fonts(&self) -> usize { - let mut dirs: Vec = Vec::new(); - let home = std::env::var_os("HOME").map(std::path::PathBuf::from); - if cfg!(target_os = "macos") { - dirs.extend(["/System/Library/Fonts", "/Library/Fonts"].map(Into::into)); - if let Some(h) = &home { - dirs.push(h.join("Library/Fonts")); - } - } else if cfg!(windows) { - let root = std::env::var_os("WINDIR").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into()); - dirs.push(root.join("Fonts")); - if let Some(l) = std::env::var_os("LOCALAPPDATA") { - dirs.push(std::path::PathBuf::from(l).join("Microsoft\\Windows\\Fonts")); - } - } else { - dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(Into::into)); - if let Some(h) = &home { - dirs.push(h.join(".fonts")); - dirs.push(h.join(".local/share/fonts")); - } - } - let mut found = Vec::new(); - let mut stack = dirs; + // The first scan, or a rescan once it is done (never both at once). + let mut first = None; + self.cataloged.get_or_init(|| first = Some(self.scan_font_dirs())); + first.unwrap_or_else(|| self.scan_font_dirs()) + } + + #[cfg(not(target_arch = "wasm32"))] + fn scan_font_dirs(&self) -> usize { + let mut catalog = Catalog::new(); + let mut n = 0; + let mut stack = self.font_dirs.clone(); + // Each folder once, however links lead back to it. + let mut visited = std::collections::HashSet::new(); while let Some(d) = stack.pop() { + if !visited.insert(std::fs::canonicalize(&d).unwrap_or_else(|_| d.clone())) { + continue; + } let Ok(rd) = std::fs::read_dir(&d) else { continue }; for e in rd.flatten() { let p = e.path(); @@ -404,26 +559,29 @@ impl FontDb { if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) { continue; } - let Ok(data) = std::fs::read(&p) else { continue }; - for (_, family, style) in enumerate_faces(&data) { - found.push(CatalogEntry { family, style, path: p.clone() }); + for (family, style) in file_face_names(&p) { + let entry = catalog.entry(family.to_ascii_lowercase()).or_default(); + if entry.name.is_empty() { + entry.name = family; + } + entry.faces.push((style, p.clone())); + n += 1; } } } - let n = found.len(); log::debug!("cataloged {n} system font faces"); - *self.catalog.write().unwrap_or_else(|e| e.into_inner()) = found; + *self.catalog.write().unwrap_or_else(|e| e.into_inner()) = catalog; + self.changed(); n } + /// Load the files of the installed `family`. Returns whether any face was added. #[cfg(not(target_arch = "wasm32"))] fn load_cataloged(&self, family: &str) -> bool { - let paths: Vec = { - let cat = self.catalog.read().unwrap_or_else(|e| e.into_inner()); - let mut p: Vec<_> = cat.iter().filter(|c| c.family.eq_ignore_ascii_case(family)).map(|c| c.path.clone()).collect(); - p.dedup(); - p - }; + let mut paths: Vec = + self.read_catalog().get(&family.to_ascii_lowercase()).map(|c| c.faces.iter().map(|(_, p)| p.clone()).collect()).unwrap_or_default(); + paths.sort(); + paths.dedup(); let mut any = false; for p in paths { if let Ok(data) = std::fs::read(&p) { @@ -434,19 +592,33 @@ impl FontDb { } /// Resolve a family + style to a face, falling back to the closest style of the family, then to - /// Source Sans 3 Regular. `None` only if no font at all is loaded (the bundled fonts failed to - /// parse), in which case text has no glyphs. + /// Source Sans 3 Regular. Installed system fonts are found by name whatever ran before. `None` + /// only if no font at all is loaded (the bundled fonts failed to parse), in which case text has + /// no glyphs. pub fn face(&self, family: &str, style: &str) -> Option> { - if let Some(f) = self.find(family, style) { - return Some(f); + let found = self.find(family, style); + if found.as_ref().is_some_and(|f| norm(&f.style) == norm(style)) { + return found; } + // The family, or this style of it, is installed but not loaded yet. #[cfg(not(target_arch = "wasm32"))] - if self.load_cataloged(family) + if (found.is_none() || self.is_cataloged(family, style)) + && self.load_cataloged(family) && let Some(f) = self.find(family, style) { return Some(f); } - self.find(FALLBACK_FAMILY, style).or_else(|| self.find(FALLBACK_FAMILY, "Regular")).or_else(|| self.read_faces().first().cloned()) + found + .or_else(|| self.find(FALLBACK_FAMILY, style)) + .or_else(|| self.find(FALLBACK_FAMILY, "Regular")) + .or_else(|| self.read_faces().first().cloned()) + } + + /// Is `style` of `family` among the installed fonts? + #[cfg(not(target_arch = "wasm32"))] + fn is_cataloged(&self, family: &str, style: &str) -> bool { + let ns = norm(style); + self.read_catalog().get(&family.to_ascii_lowercase()).is_some_and(|c| c.faces.iter().any(|(s, _)| norm(s) == ns)) } /// The loaded face with [`FontFace::id`] `id` (the face a laid-out glyph came from). @@ -454,12 +626,22 @@ impl FontDb { self.read_faces().iter().find(|f| f.id == id).cloned() } - /// Is `family` available (loaded)? + /// The available family `name` names, ignoring case and anything but letters and digits, as + /// PostScript names write families ("MicrosoftYaHei" is Microsoft YaHei). + pub fn find_family(&self, name: &str) -> Option { + self.family_list().iter().find(|f| norm_chars(f).eq(norm_chars(name))).cloned() + } + + /// Is `family` available (loaded, or installed on the system)? pub fn has_family(&self, family: &str) -> bool { + self.is_loaded(family) || self.read_catalog().contains_key(&family.to_ascii_lowercase()) + } + + pub(crate) fn is_loaded(&self, family: &str) -> bool { self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family)) } - fn find(&self, family: &str, style: &str) -> Option> { + pub(crate) fn find(&self, family: &str, style: &str) -> Option> { let faces = self.read_faces(); let cands: Vec<&Arc> = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).collect(); if cands.is_empty() { @@ -481,7 +663,7 @@ impl FontDb { } /// First face (fallback family first, then load order) that covers `c`; on native, system - /// fonts are cataloged and loaded lazily the first time no loaded face covers a character. + /// fonts are loaded lazily the first time no loaded face covers a character. pub(crate) fn fallback_for(&self, c: char, exclude: u32) -> Option> { if let Some(f) = self.loaded_fallback(c, exclude) { return Some(f); @@ -508,29 +690,18 @@ impl FontDb { return false; } { - let mut sys = self.sys.lock().unwrap_or_else(|e| e.into_inner()); + let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner()); if !sys.enabled || sys.misses.contains(&c) { return false; } - if !sys.scanned { - sys.scanned = true; - if self.catalog.read().unwrap_or_else(|e| e.into_inner()).is_empty() { - drop(sys); - self.load_system_fonts(); - } - } } let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c)); - let cataloged: Vec = self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|e| e.family.clone()).collect(); for fam in SYSTEM_FALLBACKS { - if !self.has_family(fam) && cataloged.iter().any(|f| f.eq_ignore_ascii_case(fam)) { - self.load_cataloged(fam); - if covered(self) { - return true; - } + if !self.is_loaded(fam) && self.load_cataloged(fam) && covered(self) { + return true; } } - let mut paths: Vec = self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|e| e.path.clone()).collect(); + let mut paths: Vec = self.read_catalog().values().flat_map(|c| c.faces.iter().map(|(_, p)| p.clone())).collect(); paths.sort(); paths.dedup(); for p in paths { diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 65f51b0d..b3268c4a 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -20,7 +20,7 @@ mod shape; pub mod thread; pub use features::OtFeatures; -pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace, style_weight}; +pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace, style_weight, system_font_dirs}; use kurbo::{Affine, BezPath, Point, Rect, Vec2}; pub use layout::{layout, layout_with}; pub use vectorcraft_doc::TextObject; @@ -366,4 +366,6 @@ mod tests_scripts; #[cfg(test)] mod tests_snap; #[cfg(test)] +mod tests_sysfonts; +#[cfg(test)] mod tests_typo; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index e0bd49e9..588bc19c 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -3,8 +3,10 @@ use kurbo::{Affine, Shape}; use vectorcraft_doc::{CharStyle, Justify, TextKind, TextRun}; use vectorcraft_geom::PathData; +/// The bundled fonts only: what these tests measure doesn't change with the fonts installed. fn db() -> &'static FontDb { - FontDb::global() + static DB: std::sync::OnceLock = std::sync::OnceLock::new(); + DB.get_or_init(|| FontDb::with_font_dirs(vec![])) } fn style(size: f64) -> CharStyle { diff --git a/crates/text/src/tests_sysfonts.rs b/crates/text/src/tests_sysfonts.rs new file mode 100644 index 00000000..5b9c96e1 --- /dev/null +++ b/crates/text/src/tests_sysfonts.rs @@ -0,0 +1,144 @@ +//! Installed system fonts: found by family name by every lookup, whatever ran before it (#130). + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use super::*; + +const FAMILY: &str = "Sysfont Sans3"; + +/// A bundled Source Sans 3 file renamed [`FAMILY`] (as long as the original name). +fn renamed(file: &str) -> Vec { + let mut data = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap(); + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + for (from, to) in [(utf16("Source Sans 3"), utf16(FAMILY)), (b"Source Sans 3".to_vec(), FAMILY.as_bytes().to_vec())] { + let mut i = 0; + while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) { + data[i + at..i + at + to.len()].copy_from_slice(&to); + i += at + to.len(); + } + } + data +} + +/// The fonts in `faces` as one collection (TTC) file. +fn collection(faces: &[Vec]) -> Vec { + let mut out = b"ttcf".to_vec(); + out.extend_from_slice(&0x0001_0000_u32.to_be_bytes()); + out.extend_from_slice(&(faces.len() as u32).to_be_bytes()); + let mut base = 12 + 4 * faces.len(); + for f in faces { + out.extend_from_slice(&(base as u32).to_be_bytes()); + base += f.len(); + } + for f in faces { + // Table offsets count from the start of the collection. + let start = out.len() as u32; + let mut f = f.clone(); + let tables = u16::from_be_bytes([f[4], f[5]]) as usize; + for r in 0..tables { + let at = 12 + r * 16 + 8; + let off = u32::from_be_bytes(f[at..at + 4].try_into().unwrap()) + start; + f[at..at + 4].copy_from_slice(&off.to_be_bytes()); + } + out.extend_from_slice(&f); + } + out +} + +/// A font folder (with a subfolder, a damaged font and a file that isn't a font) holding +/// [`FAMILY`] Regular and Bold. +fn font_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("vc-sysfonts-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("Sub")).unwrap(); + std::fs::write(dir.join("Sysfont-Regular.ttf"), renamed("SourceSans3-Regular.ttf")).unwrap(); + std::fs::write(dir.join("Sub/Sysfont-Bold.TTF"), renamed("SourceSans3-Bold.ttf")).unwrap(); + std::fs::write(dir.join("damaged.ttf"), b"ttcf\0\x01\0\0\xff\xff\xff\xff").unwrap(); + std::fs::write(dir.join("readme.txt"), FAMILY).unwrap(); + dir +} + +fn has(list: &[String], family: &str) -> bool { + list.iter().any(|f| f == family) +} + +#[test] +fn every_lookup_by_name_finds_installed_fonts_in_a_fresh_database() { + let dir = font_dir("fresh"); + // Each lookup is the first thing a new database is asked. A folder listed twice is read once. + let db = || FontDb::with_font_dirs(vec![dir.clone(), dir.join("Sub/..")]); + let f = db().face("sysfont sans3", "Bold").unwrap(); + assert_eq!((f.family.as_str(), f.style.as_str()), (FAMILY, "Bold")); + assert_eq!(f.path().map(std::fs::canonicalize).unwrap().unwrap(), std::fs::canonicalize(dir.join("Sub/Sysfont-Bold.TTF")).unwrap()); + assert!(db().has_family(FAMILY)); + assert!(has(&db().families(), FAMILY)); + assert_eq!(db().styles(FAMILY), ["Regular", "Bold"]); + assert_eq!(db().load_system_fonts(), 2); + assert_eq!(db().find_family("SysfontSans3").as_deref(), Some(FAMILY), "a PostScript-style name"); + // Missing fonts stay missing. + let db = db(); + assert!(!db.has_family("No Such Font")); + assert_eq!(db.face("No Such Font", "Regular").unwrap().family, FALLBACK_FAMILY); +} + +#[test] +fn the_scan_reads_collections_and_loads_fonts_only_when_used() { + let dir = font_dir("collection"); + std::fs::remove_file(dir.join("Sysfont-Regular.ttf")).unwrap(); + std::fs::remove_file(dir.join("Sub/Sysfont-Bold.TTF")).unwrap(); + std::fs::write(dir.join("Sysfont.ttc"), collection(&[renamed("SourceSans3-Regular.ttf"), renamed("SourceSans3-Bold.ttf")])).unwrap(); + let db = FontDb::with_font_dirs(vec![dir.clone()]); + assert_eq!(db.styles(FAMILY), ["Regular", "Bold"]); + // Cataloged, not loaded. + assert!(!db.is_loaded(FAMILY)); + let bold = db.face(FAMILY, "Bold").unwrap(); + assert_eq!((bold.style.as_str(), bold.face_index()), ("Bold", 1)); + assert_eq!(db.find(FAMILY, "Regular").unwrap().face_index(), 0, "the collection's faces load together"); +} + +#[test] +fn rescanning_finds_fonts_installed_since() { + let dir = font_dir("rescan"); + let later = dir.join("Later"); + let db = FontDb::with_font_dirs(vec![later.clone()]); + assert!(!db.has_family(FAMILY)); + let (generation, families) = (db.generation(), db.family_list()); + std::fs::create_dir_all(&later).unwrap(); + std::fs::copy(dir.join("Sysfont-Regular.ttf"), later.join("Sysfont-Regular.ttf")).unwrap(); + assert!(!db.has_family(FAMILY), "the folders are scanned once, not on every miss"); + assert_eq!(db.load_system_fonts(), 1); + assert!(db.has_family(FAMILY) && has(&db.family_list(), FAMILY) && !has(&families, FAMILY)); + assert_ne!(db.generation(), generation); +} + +#[test] +fn a_background_scan_serves_the_first_lookup() { + let dir = font_dir("background"); + let db: &'static FontDb = Box::leak(Box::new(FontDb::with_font_dirs(vec![dir]))); + db.scan_in_background(); + // Waits for the scan when it is still running. + assert!(has(&db.families(), FAMILY)); + assert_eq!(db.load_system_fonts(), 2, "a rescan catalogs the two faces again"); +} + +#[test] +fn the_family_list_is_shared_until_fonts_change() { + let db = FontDb::with_font_dirs(vec![]); + let a = db.family_list(); + assert!(Arc::ptr_eq(&a, &db.family_list())); + assert!(db.add_font(renamed("SourceSans3-Regular.ttf")) > 0); + let b = db.family_list(); + assert!(!Arc::ptr_eq(&a, &b) && has(&b, FAMILY) && !has(&a, FAMILY)); +} + +#[test] +fn installed_styles_of_a_loaded_family_load_when_asked_for() { + let dir = font_dir("styles"); + let db = FontDb::with_font_dirs(vec![dir]); + // The family is loaded with only its Regular face (as when a bundled family is also installed). + assert_eq!(db.add_font(renamed("SourceSans3-Regular.ttf")), 1); + assert_eq!(db.face(FAMILY, "Bold").unwrap().style, "Bold"); + // A style nobody has still gets the closest one. + assert_eq!(db.face(FAMILY, "Black").unwrap().style, "Bold"); +} diff --git a/crates/text/tests/system_fonts.rs b/crates/text/tests/system_fonts.rs new file mode 100644 index 00000000..d711bfab --- /dev/null +++ b/crates/text/tests/system_fonts.rs @@ -0,0 +1,22 @@ +//! A new session finds the installed system fonts by family name (#130). One test in its own +//! process, so the process-wide database starts fresh, as it does when the app starts. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use vectorcraft_text::{FontDb, system_font_dirs}; + +#[test] +fn a_fresh_session_finds_installed_fonts_by_name() { + let bundled = FontDb::with_font_dirs(vec![]).families(); + let probe = FontDb::with_font_dirs(system_font_dirs()); + probe.load_system_fonts(); + let installed = probe.families(); + let Some(family) = installed.iter().find(|f| !bundled.contains(f)) else { + eprintln!("no system fonts installed: nothing to check"); + return; + }; + // The first lookup in the session: nothing has scanned the font folders yet. + let face = FontDb::global().face(family, "Regular").unwrap(); + assert_eq!(&face.family, family); + assert!(FontDb::global().families().contains(family)); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6215d7e7..efba0180 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -296,6 +296,8 @@ const SYSTEM_CLIPBOARD_POLL: f64 = 0.25; impl VectorcraftApp { pub fn new(mut session: Session, services: Services) -> Self { + // The font menus and the first file opened need the installed fonts: catalog them now. + vectorcraft_text::FontDb::global().scan_in_background(); if let Some(store) = &services.recovery_store { session.recovery.set_store(store.clone()); } diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs index bf722e4e..7c43a2a8 100644 --- a/crates/ui-egui/src/panels/glyphs.rs +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -197,7 +197,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let fnames: Vec<&str> = fams.iter().map(String::as_str).collect(); ui.horizontal(|ui| { if let Some(i) = widgets::dropdown(ui, "gl-family", &family, &fnames, (w * 0.6).max(80.0)) { - let st = db.styles(fnames[i]).into_iter().next().unwrap_or_else(|| "Regular".into()); + let st = db.face(fnames[i], "Regular").map_or_else(|| "Regular".into(), |f| f.style.clone()); set_pstate(ui.ctx(), "gl-font", Some((fnames[i].to_string(), st))); } let styles = db.styles(&family);