Merge pull request #2803 from storytold/land/2316

Land #2316: Hebrew SHX fonts, font-change redraw, user font folders (fonts crate)
This commit is contained in:
Tanish Manku
2026-11-04 20:20:01 -07:00
committed by GitHub
12 changed files with 590 additions and 21 deletions
+18 -2
View File
@@ -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<u8>,
records: HashMap<u16, Record>,
cache: RwLock<GlyphCache>,
/// 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<u16>| 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<u16> {
let mut v: Vec<u16> = self.records.keys().copied().filter(|n| *n != 0).collect();
+1
View File
@@ -127,6 +127,7 @@ impl Font {
data,
records: self.records,
cache: RwLock::default(),
hebrew: std::sync::OnceLock::new(),
})
}
}
+39 -3
View File
@@ -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<Arc<dyn FontProvider>>,
drawing_dirs: Vec<PathBuf>,
font_dirs: Vec<PathBuf>,
search_path: Vec<PathBuf>,
map: Vec<(String, String)>,
generation: u64,
@@ -135,6 +136,15 @@ pub fn drawing_dirs() -> Vec<PathBuf> {
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<PathBuf>) {
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<Arc<ShxFont>> {
if let Some(r) = t.resolved.get(&k) {
return r.clone();
}
let dirs: Vec<PathBuf> = t.drawing_dirs.iter().chain(t.search_path.iter()).cloned().collect();
let dirs: Vec<PathBuf> = 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<Arc<ShxFont>> {
#[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<String> {
let (dirs, mut out): (Vec<PathBuf>, Vec<String>) = {
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).
+35
View File
@@ -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<Item = u16>) -> 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);
}
+10
View File
@@ -88,6 +88,11 @@ fn candidates(c: char, font: &ShxFont) -> [Option<u16>; 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<u16>; 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];
+363 -13
View File
@@ -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<String> {
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::<String>()).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<Option<u32>> {
let Some(f) = self.font() else { return vec![None; chars.len()] };
@@ -231,11 +245,28 @@ struct Db {
paths: HashMap<String, Source>,
/// Loaded or registered font file bytes, by lower-case file stem.
files: HashMap<String, Arc<Vec<u8>>>,
/// The keys of `files` given by [`register`]: kept over a file of the same stem, and by a
/// rescan.
registered: std::collections::HashSet<String>,
/// Name lookups already answered, found or not.
resolved: HashMap<String, Option<Face>>,
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<String>,
/// 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<String, Arc<FaceNames>>,
}
/// The names inside one font file.
#[derive(Default)]
struct FaceNames {
/// The first face's family name.
family: Option<String>,
/// Each face's normalized names (family, typographic family, full name).
faces: Vec<Vec<String>>,
}
/// 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<PathBuf> {
fn system_font_dirs() -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = 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<PathBuf> {
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<String, PathBuf> {
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<String, PathBuf> {
let mut out = HashMap::new();
let mut stack = font_dirs();
let mut stack: Vec<PathBuf> = 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<String, PathBuf> {
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<String, PathBuf> {
HashMap::new()
}
#[cfg(target_arch = "wasm32")]
fn scan_dirs(_dirs: &[PathBuf], _in_order: bool) -> HashMap<String, PathBuf> {
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<PathBuf>> {
static DIRS: OnceLock<Mutex<Vec<PathBuf>>> = OnceLock::new();
DIRS.get_or_init(|| Mutex::new(Vec::new())).lock().unwrap_or_else(PoisonError::into_inner)
}
fn user_dirs() -> Vec<PathBuf> {
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<String> {
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<S: AsRef<str>>(dirs: &[S]) -> Vec<String> {
let mut out: Vec<String> = 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<S: AsRef<str>>(dirs: &[S]) {
let dirs: Vec<PathBuf> = 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<PathBuf>, 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<PathBuf> {
let mut out: Vec<PathBuf> = Vec::new();
let mut stack: Vec<PathBuf> = 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<PathBuf> {
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<String>,
/// 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<FontFile> {
/// 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<Vec<u8>> {
/// 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<Arc<Vec<u8>>> {
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<Arc<FaceNames>> {
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<Face> {
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<Face> {
let want = norm(k);
if want.is_empty() {
return None;
}
let mut stems: Vec<String> = 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<String> {
/// Register font bytes under a name (web builds and tests).
pub fn register(name: &str, bytes: Vec<u8>) {
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<String> = (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")]
+36
View File
@@ -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);
}
+65
View File
@@ -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<PathBuf> {
let mut stack: Vec<PathBuf> =
["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);
}
+3
View File
@@ -101,6 +101,9 @@ fn same_content(a: &ListKey, b: &ListKey) -> bool {
#[derive(Default)]
pub struct CanvasState {
pub list: Option<DisplayList>,
/// 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.
+1 -1
View File
@@ -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);
}
+17 -1
View File
@@ -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()
};
+2 -1
View File
@@ -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) {