diff --git a/crates/fonts/src/shx/mod.rs b/crates/fonts/src/shx/mod.rs index 9ad352a2e..016fe9821 100644 --- a/crates/fonts/src/shx/mod.rs +++ b/crates/fonts/src/shx/mod.rs @@ -41,8 +41,8 @@ use cadcraft_geom::Vec2; pub use compile::compile; pub use table::{ - FontProvider, add_drawing_dir, clear_registered, drawing_dirs, find, font_map, generation, register, search_path, set_font_map, set_provider, - set_search_path, + FontProvider, add_drawing_dir, clear_registered, drawing_dirs, find, font_map, generation, installed, register, search_path, set_font_dirs, + set_font_map, set_provider, set_search_path, }; pub use text::ShxText; pub(crate) use text::{shape, width}; @@ -128,6 +128,9 @@ pub struct ShxFont { data: Vec, records: HashMap, cache: RwLock, + /// Whether a one-byte font holds Hebrew letters at their Windows-1255 / DOS 862 places, + /// worked out on first use ([`ShxFont::hebrew_places`]). + hebrew: std::sync::OnceLock<(bool, bool)>, } impl std::fmt::Debug for ShxFont { @@ -194,6 +197,19 @@ impl ShxFont { pub fn has_shape(&self, number: u16) -> bool { number != 0 && self.records.contains_key(&number) } + /// Whether this one-byte font draws Hebrew letters at their Windows-1255 places (0xE0..0xFA) + /// and at their DOS 862 places (0x80..0x9A), as Israeli SHX fonts do. A Cyrillic font fills + /// the same places with its letters, so it must not also fill 0xC0..0xDF (Windows-1251) or + /// 0xA0..0xAF (DOS 866): Hebrew text never turns into Russian. + pub fn hebrew_places(&self) -> (bool, bool) { + *self.hebrew.get_or_init(|| { + if self.kind != ShxKind::Shapes { + return (false, false); + } + let count = |r: std::ops::RangeInclusive| r.filter(|c| self.has_shape(*c)).count(); + (count(0xE0..=0xFA) >= 20 && count(0xC0..=0xDF) < 16, count(0x80..=0x9A) >= 20 && count(0xA0..=0xAF) < 8) + }) + } /// The shape numbers in the file, sorted (not shape 0). pub fn shape_numbers(&self) -> Vec { let mut v: Vec = self.records.keys().copied().filter(|n| *n != 0).collect(); diff --git a/crates/fonts/src/shx/parse.rs b/crates/fonts/src/shx/parse.rs index b10e8142e..086857af0 100644 --- a/crates/fonts/src/shx/parse.rs +++ b/crates/fonts/src/shx/parse.rs @@ -127,6 +127,7 @@ impl Font { data, records: self.records, cache: RwLock::default(), + hebrew: std::sync::OnceLock::new(), }) } } diff --git a/crates/fonts/src/shx/table.rs b/crates/fonts/src/shx/table.rs index 4b44927d7..efcca16b6 100644 --- a/crates/fonts/src/shx/table.rs +++ b/crates/fonts/src/shx/table.rs @@ -5,8 +5,8 @@ //! Order: the font map ([`set_font_map`]) renames the file; then fonts registered by the app //! ([`register`]: the web build's files opened alongside a drawing, tests); then the app's //! [`FontProvider`]; then, natively, the folders of opened drawings ([`add_drawing_dir`], most -//! recent first) and the support search path ([`set_search_path`], from `CADCRAFT_SHXPATH` at -//! start). Results, found or not, are cached until one of these changes ([`generation`] +//! recent first), the user's font folders ([`set_font_dirs`], from Options) and the support +//! search path ([`set_search_path`], from `CADCRAFT_SHXPATH` at start). Results, found or not, are cached until one of these changes ([`generation`] //! counts the changes, so a viewer knows to regenerate). use std::collections::HashMap; @@ -38,6 +38,7 @@ struct Table { loaded_bytes: usize, provider: Option>, drawing_dirs: Vec, + font_dirs: Vec, search_path: Vec, map: Vec<(String, String)>, generation: u64, @@ -135,6 +136,15 @@ pub fn drawing_dirs() -> Vec { table().drawing_dirs.clone() } +/// Search the user's font folders (and the folders under them that hold SHX files, as the +/// font list finds them) after the drawings' folders. Looks again even when the folders are the +/// same: files may have been added. +pub fn set_font_dirs(dirs: Vec) { + let mut t = table(); + t.font_dirs = dirs.into_iter().filter(|d| !d.as_os_str().is_empty()).take(MAX_DIRS).collect(); + t.changed(); +} + /// Set the support search path for SHX files: folders separated by `;` (or the platform's /// path separator). Empty clears it. pub fn set_search_path(list: &str) { @@ -196,7 +206,7 @@ pub fn find(name: &str) -> Option> { if let Some(r) = t.resolved.get(&k) { return r.clone(); } - let dirs: Vec = t.drawing_dirs.iter().chain(t.search_path.iter()).cloned().collect(); + let dirs: Vec = t.drawing_dirs.iter().chain(t.font_dirs.iter()).chain(t.search_path.iter()).cloned().collect(); (t.provider.clone(), dirs, t.generation) }; let font = provider @@ -228,6 +238,32 @@ pub fn find(name: &str) -> Option> { #[cfg(not(target_arch = "wasm32"))] const MAX_DIR_ENTRIES: usize = 10_000; +/// The SHX fonts in the user's font folders ([`set_font_dirs`]) and the registered ones, by file +/// stem, sorted without repeats (for font lists). Not read: only their names are listed. +pub fn installed() -> Vec { + let (dirs, mut out): (Vec, Vec) = { + let t = table(); + (t.font_dirs.clone(), t.registered.keys().map(|k| k.trim_end_matches(".shx").to_string()).collect()) + }; + #[cfg(not(target_arch = "wasm32"))] + for d in &dirs { + let Ok(entries) = std::fs::read_dir(d) else { continue }; + for e in entries.take(MAX_DIR_ENTRIES).filter_map(Result::ok) { + let p = e.path(); + if p.extension().is_some_and(|x| x.eq_ignore_ascii_case("shx")) + && let Some(stem) = p.file_stem().and_then(|s| s.to_str()) + { + out.push(stem.to_string()); + } + } + } + #[cfg(target_arch = "wasm32")] + let _ = dirs; + out.sort_by_key(|n| n.to_lowercase()); + out.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); + out +} + /// The file `name` (a lower-case key) in the first of `dirs` that has it, whatever the case of /// its name: lower-case, upper-case, else any other case found in the directory (`Simplex.shx` /// on a case-sensitive file system). diff --git a/crates/fonts/src/shx/tests.rs b/crates/fonts/src/shx/tests.rs index a6ab40c99..bb7436b64 100644 --- a/crates/fonts/src/shx/tests.rs +++ b/crates/fonts/src/shx/tests.rs @@ -448,3 +448,38 @@ fn arcs_follow_the_circle() { assert!(close(g.advance, (dir(135.0 + 180.0) - dir(135.0)) * 5.0)); let _ = PI; } + +/// A one-byte font with Hebrew letters at `codes`, each shape advancing by a different amount +/// (its code's low byte in units), and a font record 6 units high. +fn hebrew_font(codes: impl Iterator) -> ShxFont { + let mut src = String::from("*0,4,hebrew\n6,2,0,0\n"); + for c in codes { + src.push_str(&format!("*0{c:X},3,h\n8,({},0),0\n", c & 0x1F)); + } + font(&src) +} + +/// Israeli SHX fonts keep Hebrew letters at their Windows-1255 (0xE0..0xFA) or DOS 862 +/// (0x80..0x9A) places: Hebrew text draws from them, while a Cyrillic font with letters at the +/// same places is not taken for Hebrew (#2316, rebuilt on #1944). +#[test] +fn hebrew_letters_draw_from_their_one_byte_places() { + let draws = |f: ShxFont, c: char| { + let t = ShxText::new(Some(std::sync::Arc::new(f)), None, "", false); + super::text::width(&t, &c.to_string(), 6.0, 1.0) + }; + // Windows-1255: alef at 0xE0 advances 0xE0 & 0x1F = 0 units; bet at 0xE1 one unit. + assert!((draws(hebrew_font(0xE0..=0xFA), 'ב') - 1.0).abs() < 1e-9); + // DOS 862: bet at 0x81 advances one unit. + assert!((draws(hebrew_font(0x80..=0x9A), 'ב') - 1.0).abs() < 1e-9); + assert_eq!(hebrew_font(0x80..=0x9A).hebrew_places(), (false, true)); + // The old degree sign at 127 isn't a letter in DOS 862: `%%d` still draws from it + // (127 & 0x1F = 31 units). + assert!((draws(hebrew_font((0x80..=0x9A).chain([127])), '°') - 31.0).abs() < 1e-9); + // A Cyrillic-style font (0xC0..0xFF all drawn) is not read as Hebrew: the letter comes + // from the built-in stroke font instead of a Cyrillic shape. + let cyr = hebrew_font(0xC0..=0xFF); + assert_eq!(cyr.hebrew_places(), (false, false)); + let stroke = crate::stroke_advance('ב', 6.0, 1.0); + assert!((draws(cyr, 'ב') - stroke).abs() < 1e-9); +} diff --git a/crates/fonts/src/shx/text.rs b/crates/fonts/src/shx/text.rs index 0e7e6513e..c7da188ed 100644 --- a/crates/fonts/src/shx/text.rs +++ b/crates/fonts/src/shx/text.rs @@ -88,6 +88,11 @@ fn candidates(c: char, font: &ShxFont) -> [Option; 4] { _ => None, }; if let Some((s, latin, old)) = special { + // In a DOS 862 Hebrew font 128–129 are letters and 0xB0.. box drawing: shape 256–258, + // and the old degree sign 127 (not a letter there). + if !unicode && font.kind() == ShxKind::Shapes && font.hebrew_places().1 { + return [Some(s), (old == 127).then_some(old), None, None]; + } return if unicode { [code(u), code(if c == '⌀' { 0x2205 } else { latin }), Some(s), Some(old)] } else { @@ -97,6 +102,11 @@ fn candidates(c: char, font: &ShxFont) -> [Option; 4] { if unicode || u < 0x80 { return [code(u), None, None, None]; } + // A Hebrew letter in an Israeli one-byte font: its Windows-1255 or DOS 862 place. + if (0x05D0..=0x05EA).contains(&u) && font.kind() == ShxKind::Shapes { + let (win, dos) = font.hebrew_places(); + return [win.then(|| code(u - 0x05D0 + 0xE0)).flatten(), dos.then(|| code(u - 0x05D0 + 0x80)).flatten(), None, None]; + } // A single-byte font: the Windows Latin-1 code of the character (not the old special // codes 127–129). let mut buf = [0u8; 4]; diff --git a/crates/fonts/src/ttf.rs b/crates/fonts/src/ttf.rs index bcf610e0a..2f4ffab24 100644 --- a/crates/fonts/src/ttf.rs +++ b/crates/fonts/src/ttf.rs @@ -17,6 +17,10 @@ //! lacks (CJK in an SHX big font, for example), before the automatic system font search. //! //! Both start empty; the host sets its starting values ([`set_font_alt`], [`set_fallback_fonts`]). +//! +//! Besides the system font folders, the user's own font folders ([`set_font_dirs`], Options ▸ +//! Fonts) are searched first: a font placed there wins over an installed one with the same file +//! name. use std::collections::HashMap; use std::path::PathBuf; @@ -79,6 +83,16 @@ impl Face { pub fn has_glyph(&self, c: char) -> bool { self.font().is_some_and(|f| f.charmap().map(c).is_some()) } + /// The family name, as the font gives it (the typographic family, else the family when that + /// is missing or blank). + pub fn family_name(&self) -> Option { + let f = self.font()?; + [skrifa::string::StringId::TYPOGRAPHIC_FAMILY_NAME, skrifa::string::StringId::FAMILY_NAME].into_iter().find_map(|id| { + let s = f.localized_strings(id).next()?.to_string(); + Some(s.chars().filter(|c| !c.is_control()).take(80).collect::()).filter(|s| !s.trim().is_empty()) + }) + } + /// The glyph ids the face's character map gives `chars` (`None` for an unmapped one). pub(crate) fn glyph_ids(&self, chars: &[char]) -> Vec> { let Some(f) = self.font() else { return vec![None; chars.len()] }; @@ -231,11 +245,28 @@ struct Db { paths: HashMap, /// Loaded or registered font file bytes, by lower-case file stem. files: HashMap>>, + /// The keys of `files` given by [`register`]: kept over a file of the same stem, and by a + /// rescan. + registered: std::collections::HashSet, /// Name lookups already answered, found or not. resolved: HashMap>, fallback: Fallback, /// Bytes of font files loaded from disk so far (bounded by `MAX_LOADED_BYTES`). loaded_bytes: usize, + /// Stems of the fonts found in the user's font folders. + user: std::collections::HashSet, + /// Names read from user font files for lists and family searches, by stem. Reading them + /// does not keep the file's bytes, so it does not use up `MAX_LOADED_BYTES`. + face_names: HashMap>, +} + +/// The names inside one font file. +#[derive(Default)] +struct FaceNames { + /// The first face's family name. + family: Option, + /// Each face's normalized names (family, typographic family, full name). + faces: Vec>, } /// State of the missing-glyph search. @@ -266,10 +297,18 @@ fn lock() -> MutexGuard<'static, Db> { let mut g = DB.get_or_init(|| Mutex::new(Db::default())).lock().unwrap_or_else(PoisonError::into_inner); if !g.scanned { g.scanned = true; - g.paths = scan().into_iter().map(|(stem, p)| (stem, Source::File(p))).collect(); - // Embedded craft-fonts fonts take the place of an installed file with the same stem. + let user = scan_dirs(&user_dirs(), true); + g.user = user.keys().cloned().collect(); + let mut all = scan(); + all.extend(user); + g.paths = all.into_iter().map(|(stem, p)| (stem, Source::File(p))).collect(); + // Embedded craft-fonts fonts take the place of an installed file with the same stem, + // but not of a font in the user's own folders (which they chose to use). for f in crate::CRAFT_FONTS { - g.paths.insert(f.stem(), Source::Static(f.bytes)); + let stem = f.stem(); + if !g.user.contains(&stem) { + g.paths.insert(stem, Source::Static(f.bytes)); + } } } g @@ -277,7 +316,7 @@ fn lock() -> MutexGuard<'static, Db> { /// The platform's system font directories, in search order. #[cfg(not(target_arch = "wasm32"))] -fn font_dirs() -> Vec { +fn system_font_dirs() -> Vec { let mut dirs: Vec = Vec::new(); if cfg!(target_os = "macos") { dirs.extend(["/System/Library/Fonts", "/System/Library/Fonts/Supplemental", "/Library/Fonts"].map(Into::into)); @@ -304,13 +343,24 @@ fn font_dirs() -> Vec { dirs } -/// Every font file under [`font_dirs`], by lower-case file stem. +/// Every font file under [`system_font_dirs`], by lower-case file stem. #[cfg(not(target_arch = "wasm32"))] fn scan() -> HashMap { + scan_dirs(&system_font_dirs(), false) +} + +/// Font files under `dirs` by lower-case stem; the first file found with a stem wins +/// (`in_order`: a folder listed earlier wins over a later one). +#[cfg(not(target_arch = "wasm32"))] +fn scan_dirs(dirs: &[PathBuf], in_order: bool) -> HashMap { let mut out = HashMap::new(); - let mut stack = font_dirs(); + let mut stack: Vec = if in_order { dirs.iter().rev().cloned().collect() } else { dirs.to_vec() }; let mut seen = 0usize; + let mut visited = std::collections::HashSet::new(); while let Some(d) = stack.pop() { + if !first_visit(&mut visited, &d) { + continue; + } let Ok(rd) = std::fs::read_dir(&d) else { continue }; for e in rd.flatten() { seen += 1; @@ -318,6 +368,8 @@ fn scan() -> HashMap { return out; } let p = e.path(); + // Linked folders are followed (font folders often are links); `first_visit` keeps a + // circle of links from reading a folder twice. if p.is_dir() { stack.push(p); continue; @@ -339,6 +391,188 @@ fn scan() -> HashMap { HashMap::new() } +#[cfg(target_arch = "wasm32")] +fn scan_dirs(_dirs: &[PathBuf], _in_order: bool) -> HashMap { + HashMap::new() +} + +/// Most user font folders. +pub const MAX_FONT_DIRS: usize = 16; +/// The longest font folder path kept. +const MAX_DIR_CHARS: usize = 1024; + +fn dirs_lock() -> MutexGuard<'static, Vec> { + static DIRS: OnceLock>> = OnceLock::new(); + DIRS.get_or_init(|| Mutex::new(Vec::new())).lock().unwrap_or_else(PoisonError::into_inner) +} + +fn user_dirs() -> Vec { + dirs_lock().clone() +} + +/// Counts changes to the set of fonts ([`set_font_dirs`], [`rescan`]): text drawn before a change +/// may have been drawn with another font. +static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// The font set's change count (see [`GENERATION`]). +pub fn generation() -> u64 { + GENERATION.load(std::sync::atomic::Ordering::Relaxed) +} + +/// The user's font folders, searched before the system's. +pub fn font_dirs() -> Vec { + user_dirs().iter().map(|p| p.to_string_lossy().to_string()).collect() +} + +/// Clean a font folder list: trimmed, without empty, repeated or over-long entries, at most +/// [`MAX_FONT_DIRS`]. +pub fn clean_dirs>(dirs: &[S]) -> Vec { + let mut out: Vec = Vec::new(); + for d in dirs.iter().take(MAX_FONT_DIRS * 4) { + let d = d.as_ref().trim(); + if d.is_empty() || d.chars().count() > MAX_DIR_CHARS || d.chars().any(char::is_control) || out.iter().any(|o| o == d) { + continue; + } + out.push(d.to_string()); + if out.len() >= MAX_FONT_DIRS { + break; + } + } + out +} + +/// Set the user's font folders (Options ▸ Fonts) and look for fonts again. +pub fn set_font_dirs>(dirs: &[S]) { + let dirs: Vec = clean_dirs(dirs).into_iter().map(PathBuf::from).collect(); + { + let mut current = dirs_lock(); + if *current == dirs { + return; + } + *current = dirs; + } + rescan(); +} + +/// Forget what was found and look for fonts again (files added to a font folder). +pub fn rescan() { + let mut g = lock(); + // Fonts registered by name stay, also where a found file has the same stem; bytes read + // from found files go. + let paths = std::mem::take(&mut g.paths); + let Db { files, registered, .. } = &mut *g; + files.retain(|k, _| registered.contains(k) || !paths.contains_key(k)); + g.scanned = false; + g.resolved.clear(); + g.fallback = Fallback::default(); + g.loaded_bytes = 0; + g.user.clear(); + g.face_names.clear(); + drop(g); + GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + // SHX files in the same folders: the SHX font table looks there too. + crate::shx::set_font_dirs(shx_dirs(&user_dirs())); +} + +/// Whether folder `d` is read for the first time (by its real path: a link to a folder already +/// read, or a circle of links, is not read again). +#[cfg(not(target_arch = "wasm32"))] +fn first_visit(visited: &mut std::collections::HashSet, d: &std::path::Path) -> bool { + visited.insert(std::fs::canonicalize(d).unwrap_or_else(|_| d.to_path_buf())) +} + +/// The folders under `dirs` (them included) that hold SHX files, in order, bounded like the +/// font scan. +#[cfg(not(target_arch = "wasm32"))] +fn shx_dirs(dirs: &[PathBuf]) -> Vec { + let mut out: Vec = Vec::new(); + let mut stack: Vec = dirs.iter().rev().cloned().collect(); + let mut seen = 0usize; + let mut visited = std::collections::HashSet::new(); + while let Some(d) = stack.pop() { + if !first_visit(&mut visited, &d) { + continue; + } + let Ok(rd) = std::fs::read_dir(&d) else { continue }; + let mut subs = Vec::new(); + let mut has_shx = false; + for e in rd.flatten() { + seen += 1; + if seen > 20_000 { + return out; + } + if e.path().is_dir() { + subs.push(e.path()); + } else if e.path().extension().is_some_and(|x| x.eq_ignore_ascii_case("shx")) { + has_shx = true; + } + } + if has_shx && !out.contains(&d) { + out.push(d); + } + stack.extend(subs.into_iter().rev()); + } + out +} + +#[cfg(target_arch = "wasm32")] +fn shx_dirs(_dirs: &[PathBuf]) -> Vec { + Vec::new() +} + +/// A font file CADCraft can use, for font lists. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FontFile { + /// The name a text style stores: the file name (`arial.ttf`), or the name of a font built + /// into CADCraft. + pub name: String, + /// The family name inside the file, read for fonts in the user's folders. + pub family: Option, + /// Found in one of the user's font folders. + pub user: bool, +} + +/// The fonts CADCraft can use: those in the user's folders first (with their family names), +/// then the installed and built-in ones, by name. +pub fn installed() -> Vec { + /// Fonts whose family name is read for the list. + const MAX_NAMED: usize = 200; + let mut g = lock(); + let mut list: Vec<(String, String, bool)> = g + .paths + .iter() + .map(|(stem, src)| { + let name = match src { + Source::File(p) => p.file_name().map_or_else(|| stem.clone(), |n| n.to_string_lossy().to_string()), + Source::Static(_) => stem.clone(), + }; + (stem.clone(), name, g.user.contains(stem)) + }) + .collect(); + list.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.1.to_lowercase().cmp(&b.1.to_lowercase()))); + let mut named = 0usize; + list.into_iter() + .map(|(stem, name, user)| { + let family = if user && named < MAX_NAMED { + named += 1; + g.face_names(&stem).and_then(|n| n.family.clone()) + } else { + None + }; + FontFile { name, family, user } + }) + .collect() +} + +/// Whether a text style font is drawn as named: a font CADCraft found, or its own stroke font. +/// Other names (an SHX file, a missing TTF) are drawn with a substitute ([`crate::TextFont::resolve`]). +pub fn available(name: &str) -> bool { + let k = key(name); + // The built-in stroke font is CADCraft's own stand-in for the basic stroke fonts. + let stand_in = matches!(k.trim_end_matches(".shx"), "txt" | "simplex" | "romans"); + k.is_empty() || stand_in || k == crate::BUILTIN_FONT.to_ascii_lowercase() || crate::shx::find(name).is_some() || find(name).is_some() +} + #[cfg(not(target_arch = "wasm32"))] fn read_file(path: &std::path::Path) -> Option> { /// Font files larger than this are never read. @@ -363,15 +597,35 @@ impl Db { if self.loaded_bytes >= MAX_LOADED_BYTES { return None; } - let bytes = Arc::new(match self.paths.get(stem)? { - Source::File(path) => read_file(path)?, - Source::Static(bytes) => bytes.to_vec(), - }); + let bytes = self.peek(stem)?; self.loaded_bytes = self.loaded_bytes.saturating_add(bytes.len()); self.files.insert(stem.to_string(), bytes.clone()); Some(bytes) } + /// Bytes of the font file with this stem, without keeping them or charging the load budget. + fn peek(&self, stem: &str) -> Option>> { + if let Some(b) = self.files.get(stem) { + return Some(b.clone()); + } + Some(Arc::new(match self.paths.get(stem)? { + Source::File(path) => read_file(path)?, + Source::Static(bytes) => bytes.to_vec(), + })) + } + + /// The names inside the font file with this stem, read once (the bytes are dropped after). + fn face_names(&mut self, stem: &str) -> Option> { + if let Some(n) = self.face_names.get(stem) { + return Some(n.clone()); + } + let bytes = self.peek(stem)?; + let all = faces(&bytes); + let names = Arc::new(FaceNames { family: all.first().and_then(Face::family_name), faces: all.iter().map(Face::names).collect() }); + self.face_names.insert(stem.to_string(), names.clone()); + Some(names) + } + fn lookup(&mut self, k: &str) -> Option { let candidates = [k.to_string(), k.replace(' ', ""), format!("{k} regular"), format!("{}-regular", k.replace(' ', ""))]; for c in &candidates { @@ -379,7 +633,28 @@ impl Db { return Some(face); } } - self.by_family(k) + self.by_family(k).or_else(|| self.user_family(k)) + } + + /// A face in the user's font folders whose family or full name is `k`, whatever its file is + /// called (the folders are small; at most 200 files are looked at). + fn user_family(&mut self, k: &str) -> Option { + let want = norm(k); + if want.is_empty() { + return None; + } + let mut stems: Vec = self.user.iter().cloned().collect(); + stems.sort(); + for stem in stems.into_iter().take(200) { + let Some(names) = self.face_names(&stem) else { continue }; + let Some(i) = names.faces.iter().position(|f| f.iter().any(|n| *n == want || format!("{n}regular") == want)) else { continue }; + // Only the file that has the face is kept, charged to the load budget like any other + // (once it is used up, no more faces are loaded). A file that cannot be read now + // (deleted, too large) does not end the search: another file may hold the family. + let Some(bytes) = self.file(&stem) else { continue }; + return faces(&bytes).into_iter().nth(i); + } + None } /// A face whose family or full name is `k`, among files with a similar stem, then among the @@ -527,7 +802,9 @@ pub(crate) fn rank_fallback(stems: &[String]) -> Vec { /// Register font bytes under a name (web builds and tests). pub fn register(name: &str, bytes: Vec) { let mut g = lock(); - g.files.insert(key(name), Arc::new(bytes)); + let k = key(name); + g.files.insert(k.clone(), Arc::new(bytes)); + g.registered.insert(k); g.resolved.clear(); g.fallback = Fallback::default(); } @@ -539,6 +816,7 @@ pub(crate) fn register_static(name: &str, bytes: &'static [u8]) { let mut g = lock(); let k = key(name); g.files.remove(&k); + g.registered.remove(&k); g.paths.insert(k, Source::Static(bytes)); g.resolved.clear(); g.fallback = Fallback::default(); @@ -986,6 +1264,78 @@ pub fn layout_line(face: &Face, s: &str, height: f64, width_factor: f64, oblique mod family_tests { use super::*; + #[test] + fn font_folders_are_cleaned_and_unknown_shx_names_are_not_available() { + let long = "x".repeat(MAX_DIR_CHARS + 1); + let many: Vec = (0..40).map(|i| format!("/fonts/{i}")).collect(); + assert_eq!(clean_dirs(&[" /a ", "", "/a", "/b", long.as_str(), "bad\u{0}dir"]), vec!["/a".to_string(), "/b".to_string()]); + assert_eq!(clean_dirs(&many).len(), MAX_FONT_DIRS); + assert!(!available("gost_cadcraft.shx")); + assert!(available("txt.shx") && available("SIMPLEX.SHX"), "the built-in stroke font stands in for these"); + assert!(available("")); + assert!(available(crate::BUILTIN_FONT)); + } + + /// Reading a user font's names for a list keeps none of its bytes and uses none of the load + /// budget. (Font folders themselves are process-wide: `tests/ttf_font_folders.rs`.) + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn reading_a_user_fonts_names_keeps_no_font_bytes() { + let Some(src) = scan().into_values().find(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("ttf"))) else { return }; + let mut db = Db::default(); + db.user.insert("face".into()); + db.paths.insert("face".into(), Source::File(src)); + assert!(db.face_names("face").is_some_and(|n| n.family.is_some())); + assert!(db.files.is_empty() && db.loaded_bytes == 0); + } + + /// One unreadable user font file with the family does not stop the search: the next file + /// that has the family is still read. + #[test] + fn an_unreadable_user_font_does_not_end_the_family_search() { + let mut db = Db::default(); + let names = || Arc::new(FaceNames { family: Some("Foo".into()), faces: vec![vec!["foo".into()]] }); + for stem in ["a", "b"] { + db.user.insert(stem.into()); + db.face_names.insert(stem.into(), names()); + } + // "a" has no file left on disk; "b" can be read. + db.paths.insert("b".into(), Source::Static(b"not a font")); + let _ = db.user_family("Foo"); + assert!(db.files.contains_key("b"), "the second file with the family was tried"); + } + + /// A face found by family name is loaded within the font-byte budget: once it is used up, + /// none is read past it (#2316 review). + #[test] + fn a_family_search_keeps_to_the_load_budget() { + let mut db = Db::default(); + db.user.insert("b".into()); + db.face_names.insert("b".into(), Arc::new(FaceNames { family: Some("Foo".into()), faces: vec![vec!["foo".into()]] })); + db.paths.insert("b".into(), Source::Static(b"not a font")); + db.loaded_bytes = MAX_LOADED_BYTES; + assert!(db.user_family("Foo").is_none()); + assert!(db.files.is_empty(), "nothing read past the budget"); + } + + /// Linked font folders are followed, and a circle of links reads each folder once. + #[cfg(unix)] + #[test] + fn linked_font_folders_are_followed_once() { + let root = std::env::temp_dir().join(format!("cadcraft-fontlinks-{}", std::process::id())); + let real = root.join("real"); + let _ = std::fs::create_dir_all(&real); + let Ok(()) = std::fs::write(real.join("LinkedFace.ttf"), b"x") else { return }; + let _ = std::fs::write(real.join("linked.shx"), b"x"); + let _ = std::os::unix::fs::symlink(&real, root.join("link")); + // A link back up: a circle. + let _ = std::os::unix::fs::symlink(&root, real.join("up")); + let found = scan_dirs(&[root.join("link")], true); + assert!(found.contains_key("linkedface"), "{found:?}"); + assert_eq!(shx_dirs(std::slice::from_ref(&root)).len(), 1, "the folder with the SHX file, once"); + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn thai_looks_in_thai_fonts_before_the_general_search() { // Letters, vowels, tone marks and digits: the general search loads a bounded number of @@ -1107,7 +1457,7 @@ mod scan_tests { /// here rather than discovered by a drawing full of missing-glyph boxes. #[test] fn the_platforms_system_font_directory_is_searched() { - let dirs = font_dirs(); + let dirs = system_font_dirs(); #[cfg(target_os = "macos")] assert!(dirs.iter().any(|d| d == std::path::Path::new("/System/Library/Fonts")), "{dirs:?}"); #[cfg(target_os = "android")] diff --git a/crates/fonts/tests/shx_font_folders.rs b/crates/fonts/tests/shx_font_folders.rs new file mode 100644 index 000000000..a911b9382 --- /dev/null +++ b/crates/fonts/tests/shx_font_folders.rs @@ -0,0 +1,36 @@ +//! SHX files in the user's font folders (Options ▸ Fonts): found by name, in the folder itself +//! or a folder under it, and gone again when the folder is removed. Process-wide settings, so +//! one test in its own binary. The font is compiled here from shape source written for it. + +use cadcraft_fonts::{shx, ttf}; + +#[test] +fn shx_fonts_in_the_users_font_folders_are_found() { + let bytes = shx::compile("*0,4,folder test\n6,2,0,0\n*65,4,UCA\n1,064,0\n").unwrap(); + let dir = std::env::temp_dir().join(format!("cadcraft-shx-folders-{}", std::process::id())); + let sub = dir.join("shx").join("old"); + std::fs::create_dir_all(&sub).unwrap(); + std::fs::write(dir.join("cadcraft-top.shx"), &bytes).unwrap(); + std::fs::write(sub.join("cadcraft-deep.shx"), &bytes).unwrap(); + assert!(shx::find("cadcraft-top.shx").is_none() && shx::find("cadcraft-deep").is_none()); + + let g = shx::generation(); + ttf::set_font_dirs(&[dir.to_string_lossy().to_string()]); + assert_ne!(shx::generation(), g, "viewers draw the text again"); + assert!(shx::find("cadcraft-top.shx").is_some_and(|f| f.has_shape(65))); + assert!(shx::find("CADCRAFT-DEEP").is_some(), "in a folder under the font folder"); + assert!(ttf::available("cadcraft-deep.shx")); + // Font lists name them. + let names = shx::installed(); + assert!(names.iter().any(|n| n == "cadcraft-top") && names.iter().any(|n| n == "cadcraft-deep"), "{names:?}"); + + // A file added later is found after Look for Fonts Again. + std::fs::write(dir.join("cadcraft-later.shx"), &bytes).unwrap(); + ttf::rescan(); + assert!(shx::find("cadcraft-later.shx").is_some()); + + ttf::set_font_dirs::<&str>(&[]); + assert!(shx::find("cadcraft-top.shx").is_none()); + assert!(!shx::installed().iter().any(|n| n.starts_with("cadcraft-"))); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/fonts/tests/ttf_font_folders.rs b/crates/fonts/tests/ttf_font_folders.rs new file mode 100644 index 000000000..e8e3debda --- /dev/null +++ b/crates/fonts/tests/ttf_font_folders.rs @@ -0,0 +1,65 @@ +//! TrueType fonts in the user's font folders (Options ▸ Fonts): found by file and family name, +//! listed first, and gone again when the folder is removed. Font folders are process-wide and a +//! change looks for fonts again, so this is one test in its own binary (run beside other tests, +//! it would empty the font table under them). + +use std::path::{Path, PathBuf}; + +use cadcraft_fonts::ttf; + +/// A TrueType file from the system's fonts to copy, if there is one (a bounded walk). +fn a_system_ttf() -> Option { + let mut stack: Vec = + ["C:/Windows/Fonts", "/System/Library/Fonts/Supplemental", "/Library/Fonts", "/usr/share/fonts", "/usr/local/share/fonts"] + .map(PathBuf::from) + .to_vec(); + let mut seen = 0; + while let Some(d) = stack.pop() { + for e in std::fs::read_dir(&d).into_iter().flatten().flatten() { + seen += 1; + if seen > 5_000 { + return None; + } + let p = e.path(); + if p.is_dir() { + stack.push(p); + } else if p.extension().is_some_and(|x| x.eq_ignore_ascii_case("ttf")) { + return Some(p); + } + } + } + None +} + +fn copy_into(src: &Path, dir: &Path) -> bool { + std::fs::create_dir_all(dir).is_ok() && std::fs::copy(src, dir.join("CadcraftTestFace.ttf")).is_ok() +} + +#[test] +fn a_font_in_a_user_folder_is_found_listed_and_dropped_with_the_folder() { + let Some(src) = a_system_ttf() else { return }; + let dir = std::env::temp_dir().join(format!("cadcraft-fontdir-{}", std::process::id())); + if !copy_into(&src, &dir) { + return; + } + // A font registered by name, with a file of the same stem in the folder: looking for fonts + // again (twice, as the folder comes and goes) keeps the registered bytes. + let Ok(bytes) = std::fs::read(&src) else { return }; + let _ = std::fs::copy(&src, dir.join("CadcraftRegistered.ttf")); + ttf::register("CadcraftRegistered.ttf", bytes); + let before = ttf::generation(); + ttf::set_font_dirs(&[dir.to_string_lossy().to_string()]); + assert!(ttf::generation() > before); + assert!(ttf::find("CadcraftTestFace.ttf").is_some()); + let listed = ttf::installed(); + let entry = listed.iter().find(|f| f.name == "CadcraftTestFace.ttf"); + assert!(entry.is_some_and(|f| f.user && f.family.is_some()), "{entry:?}"); + assert!(listed.first().is_some_and(|f| f.user), "the user's fonts come first"); + if let Some(family) = entry.and_then(|f| f.family.clone()) { + assert!(ttf::find(&family).is_some(), "found by its family name: {family}"); + } + ttf::set_font_dirs::<&str>(&[]); + assert!(ttf::find("CadcraftTestFace.ttf").is_none()); + assert!(ttf::find("CadcraftRegistered.ttf").is_some(), "a registered font outlives a rescan"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5d3bc449d..612dfa738 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -101,6 +101,9 @@ fn same_content(a: &ListKey, b: &ListKey) -> bool { #[derive(Default)] pub struct CanvasState { pub list: Option, + /// The font set the list was drawn with ([`cadcraft_fonts::ttf::generation`] and + /// [`cadcraft_fonts::shx::generation`]): a new font folder redraws the text. + font_generation: u64, key: ListKey, /// The area `list` was built for (hatch patterns exist only inside it); a view leaving it /// rebuilds the list. diff --git a/crates/ui-egui/src/canvas/draw.rs b/crates/ui-egui/src/canvas/draw.rs index 34316374a..17da084f7 100644 --- a/crates/ui-egui/src/canvas/draw.rs +++ b/crates/ui-egui/src/canvas/draw.rs @@ -226,7 +226,7 @@ pub(super) fn draw_drag_gpu( let key = { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); - (c.key, band, bg.0, bg.1, bg.2, &drag.handles, transparency).hash(&mut h); + (c.key, band, bg.0, bg.1, bg.2, &drag.handles, transparency, super::list::font_generation()).hash(&mut h); if drag.clipboard { clipboard.1.hash(&mut h); } diff --git a/crates/ui-egui/src/canvas/list.rs b/crates/ui-egui/src/canvas/list.rs index 6a3360409..6b4aefa9a 100644 --- a/crates/ui-egui/src/canvas/list.rs +++ b/crates/ui-egui/src/canvas/list.rs @@ -63,8 +63,23 @@ pub(super) fn rebuilt_preview<'a>(doc: &cadcraft_doc::Drawing, ents: impl IntoIt /// A rebuild only the view asks for (a new zoom band, a pan out of the built area) runs in the /// background while the current list is drawn (see [`rebuild`]); a changed drawing, or no list /// yet, is built at once. +/// The font set text is drawn with: TrueType fonts (font folders, rescans) and SHX fonts +/// (drawing folders, font folders, the search path) can each change what text looks like. Every +/// cache of drawn geometry keys on it. +pub(super) fn font_generation() -> u64 { + cadcraft_fonts::ttf::generation().wrapping_add(cadcraft_fonts::shx::generation() << 32) +} + pub(super) fn ensure_list(app: &mut CadApp, xf: &Xf, views: &[(Bounds2, f64)], ctx: &egui::Context) { let px = 1.0 / xf.scale; + let fonts = font_generation(); + if app.canvas.font_generation != fonts { + app.canvas.font_generation = fonts; + app.canvas.list = None; + app.canvas.job = None; + // The model space seen through a layout's active viewport is drawn apart: rebuilt too. + app.canvas.vp.key = None; + } let Ok(st) = app.session.state() else { return }; // Rebuild when the drawing changes, the zoom moves by more than 2x (tessellation band) or a // view leaves the area the list was built for. @@ -179,7 +194,8 @@ impl OnlyList { let key = { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); - (st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, band, &only.handles, format!("{:?}", only.extra)).hash(&mut h); + (st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, band, &only.handles, format!("{:?}", only.extra), font_generation()) + .hash(&mut h); outline.into_iter().flatten().for_each(|q| (q.x.to_bits(), q.y.to_bits()).hash(&mut h)); h.finish() }; diff --git a/crates/ui-egui/src/canvas/viewport.rs b/crates/ui-egui/src/canvas/viewport.rs index 3e065f810..202fab8c2 100644 --- a/crates/ui-egui/src/canvas/viewport.rs +++ b/crates/ui-egui/src/canvas/viewport.rs @@ -286,7 +286,8 @@ impl VpHighlight { let key = { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); - (st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, tolerance.to_bits(), handles).hash(&mut h); + (st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, tolerance.to_bits(), handles, super::list::font_generation()) + .hash(&mut h); h.finish() }; if self.key != Some(key) {