mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-10 04:19:59 -06:00
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:
@@ -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();
|
||||
|
||||
@@ -127,6 +127,7 @@ impl Font {
|
||||
data,
|
||||
records: self.records,
|
||||
cache: RwLock::default(),
|
||||
hebrew: std::sync::OnceLock::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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")]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user