mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Fix #443: installed fonts outside the font folders and fonts installed while the app runs reach the font menus
The scan read only the platform's font folders, once per session (or on Character panel menu > Refresh Font List). Fonts Windows registers outside its font folders (installed as shortcuts, or by programs into their own folders) never listed, nor did fonts installed while the app was open. - Windows: the font files registered under the local machine's and the user's Fonts registry keys outside the scanned folders are scanned too (windows-registry, the windows-rs project's safe registry API). - Linux/BSD: the XDG data folders' fonts (XDG_DATA_HOME, XDG_DATA_DIRS: Flatpak, Snap, NixOS, Guix) and a Flatpak sandbox's host fonts (/run/host/fonts, local-fonts, user-fonts); macOS: /Network/Library/Fonts. - The global database looks the platform's paths up again at each scan, and a scan's roots may be font files as well as folders. - FontDb::installed_fonts_changed compares the folders the last scan read (their modification times, and the paths it started from); the app checks it when its window comes back to the front and rescans (text.rescanFonts) only when fonts were installed or removed meanwhile. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
e2a4173337
commit
64cbee2a96
7 files changed
+239
-37
No files matched your search
Generated
+12
@@ -4649,6 +4649,7 @@ dependencies = [
|
||||
"unicode-script",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"windows-registry",
|
||||
"write-fonts",
|
||||
]
|
||||
|
||||
@@ -5421,6 +5422,17 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
|
||||
|
||||
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
|
||||
|
||||
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439); see [Honest assessment](#honest-assessment-2026-10-05))._
|
||||
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443); see [Honest assessment](#honest-assessment-2026-10-05))._
|
||||
|
||||
## Where we are
|
||||
|
||||
@@ -169,7 +169,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 |
|
||||
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
|
||||
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel, Hebrew/Arabic bidirectional text and Paragraph Direction done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel, Hebrew/Arabic bidirectional text and Paragraph Direction done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
|
||||
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
|
||||
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
|
||||
|
||||
@@ -31,3 +31,8 @@ test-fonts = ["dep:write-fonts"]
|
||||
|
||||
[dev-dependencies]
|
||||
write-fonts = "0.48"
|
||||
|
||||
# Fonts registered with Windows outside its font folders ("install as shortcut", installers);
|
||||
# MIT OR Apache-2.0, a safe wrapper of the registry API from the windows-rs project.
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-registry = "0.6"
|
||||
+149
-32
@@ -238,9 +238,9 @@ pub struct FontDb {
|
||||
catalog: RwLock<Catalog>,
|
||||
/// The installed faces by PostScript name (documents name fonts so: PDF, EPS, .ai).
|
||||
postscript: RwLock<PostScriptNames>,
|
||||
/// The folders the system font scan reads (the platform's font folders for [`FontDb::global`]).
|
||||
/// Where the system font scan looks.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
font_dirs: Vec<PathBuf>,
|
||||
font_paths: FontPaths,
|
||||
/// The installed fonts' other names (localized, legacy, PostScript) by normalized name.
|
||||
aliases: RwLock<HashMap<String, Vec<Alias>>>,
|
||||
/// Set once the font folders have been scanned. Lookups by family name wait for the first
|
||||
@@ -254,6 +254,41 @@ pub struct FontDb {
|
||||
/// System fallback state: characters no system font covers.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
sys: Mutex<SysFallback>,
|
||||
/// What the last scan read, to tell when fonts were installed or removed since.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
stamp: Mutex<Option<ScanStamp>>,
|
||||
}
|
||||
|
||||
/// Where a database's system font scan looks: folders (read with their subfolders) and font files.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
enum FontPaths {
|
||||
Fixed(Vec<PathBuf>),
|
||||
/// The platform's ([`system_font_dirs`]), looked up again by each scan: fonts registered and
|
||||
/// font folders added since count too.
|
||||
System,
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
impl FontPaths {
|
||||
fn get(&self) -> Vec<PathBuf> {
|
||||
match self {
|
||||
FontPaths::Fixed(paths) => paths.clone(),
|
||||
FontPaths::System => system_font_dirs(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a system font scan read: the paths it started from, and when each folder it listed (and
|
||||
/// font file named by itself) last changed. Installing or removing a font changes its folder's.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
struct ScanStamp {
|
||||
paths: Vec<PathBuf>,
|
||||
modified: Vec<(PathBuf, Option<std::time::SystemTime>)>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn modified(path: &Path) -> Option<std::time::SystemTime> {
|
||||
std::fs::metadata(path).and_then(|m| m.modified()).ok()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -747,7 +782,8 @@ pub(crate) fn sfnt_of(tables: &[(&[u8; 4], &[u8])]) -> Option<Vec<u8>> {
|
||||
Some(font)
|
||||
}
|
||||
|
||||
/// The platform's font folders (the system's and the user's), scanned by [`FontDb::global`].
|
||||
/// The platform's font folders (the system's and the user's) and, on Windows, the font files
|
||||
/// registered with it outside them, scanned by [`FontDb::global`].
|
||||
pub fn system_font_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = Vec::new();
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
@@ -755,7 +791,7 @@ pub fn system_font_dirs() -> Vec<PathBuf> {
|
||||
}
|
||||
let home = std::env::var_os("HOME").map(PathBuf::from);
|
||||
if cfg!(target_os = "macos") {
|
||||
dirs.extend(["/System/Library/Fonts", "/Library/Fonts"].map(Into::into));
|
||||
dirs.extend(["/System/Library/Fonts", "/Library/Fonts", "/Network/Library/Fonts"].map(Into::into));
|
||||
if let Some(h) = &home {
|
||||
dirs.push(h.join("Library/Fonts"));
|
||||
}
|
||||
@@ -769,19 +805,65 @@ pub fn system_font_dirs() -> Vec<PathBuf> {
|
||||
} else if cfg!(windows) {
|
||||
let root = std::env::var_os("WINDIR").map(PathBuf::from).unwrap_or_else(|| "C:\\Windows".into());
|
||||
dirs.push(root.join("Fonts"));
|
||||
// Fonts installed for the current user only (what Install does without administrator rights).
|
||||
if let Some(l) = std::env::var_os("LOCALAPPDATA") {
|
||||
dirs.push(PathBuf::from(l).join("Microsoft\\Windows\\Fonts"));
|
||||
}
|
||||
#[cfg(windows)]
|
||||
dirs.extend(registered_font_files(&dirs));
|
||||
} else {
|
||||
dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(Into::into));
|
||||
// The XDG data folders' `fonts` (fontconfig's user folder; Flatpak, Snap, NixOS and Guix
|
||||
// list their own data folders), relative paths ignored as the XDG spec says.
|
||||
let xdg = |var| std::env::var_os(var).map(|v| std::env::split_paths(&v).filter(|p| p.is_absolute()).collect::<Vec<_>>()).unwrap_or_default();
|
||||
let data_home = xdg("XDG_DATA_HOME").into_iter().next().or_else(|| home.as_ref().map(|h| h.join(".local/share")));
|
||||
dirs.extend(data_home.into_iter().chain(xdg("XDG_DATA_DIRS")).map(|d| d.join("fonts")));
|
||||
if let Some(h) = &home {
|
||||
dirs.push(h.join(".fonts"));
|
||||
dirs.push(h.join(".local/share/fonts"));
|
||||
}
|
||||
// In a Flatpak sandbox, the host's fonts (system, local and the user's).
|
||||
dirs.extend(["/run/host/fonts", "/run/host/local-fonts", "/run/host/user-fonts"].map(Into::into));
|
||||
}
|
||||
dirs
|
||||
}
|
||||
|
||||
/// The registry key listing the fonts installed on Windows (for all users under the local
|
||||
/// machine's key, for the current user under theirs): one value per face, holding its file's
|
||||
/// name in the Windows font folder or its full path.
|
||||
#[cfg(windows)]
|
||||
const REGISTERED_FONTS: &str = r"Software\Microsoft\Windows NT\CurrentVersion\Fonts";
|
||||
|
||||
/// The font files registered with Windows outside `font_dirs` (fonts installed as shortcuts to
|
||||
/// where they are, or by programs into their own folders), which apps find by the registry.
|
||||
#[cfg(windows)]
|
||||
fn registered_font_files(font_dirs: &[PathBuf]) -> Vec<PathBuf> {
|
||||
use windows_registry::{CURRENT_USER, LOCAL_MACHINE};
|
||||
let values = [LOCAL_MACHINE, CURRENT_USER].into_iter().filter_map(|root| root.open(REGISTERED_FONTS).ok()).flat_map(|key| {
|
||||
// A value that isn't a string isn't a font file.
|
||||
key.values().map(|vs| vs.filter_map(|(_, v)| String::try_from(v).ok()).collect::<Vec<_>>()).unwrap_or_default()
|
||||
});
|
||||
fonts_outside(values, font_dirs)
|
||||
}
|
||||
|
||||
/// The full paths among `registered` (font registrations' data) that aren't in `font_dirs`
|
||||
/// (ignoring case, as Windows paths do), sorted and deduplicated. File names alone are files in
|
||||
/// the Windows font folder, which is scanned anyway. The count is capped.
|
||||
#[cfg(any(windows, test))]
|
||||
pub(crate) fn fonts_outside(registered: impl IntoIterator<Item = String>, font_dirs: &[PathBuf]) -> Vec<PathBuf> {
|
||||
const MAX_REGISTERED: usize = 1 << 16;
|
||||
let lower = |p: &Path| PathBuf::from(p.to_string_lossy().to_lowercase());
|
||||
let dirs: Vec<PathBuf> = font_dirs.iter().map(|d| lower(d)).collect();
|
||||
let mut files: Vec<PathBuf> = registered
|
||||
.into_iter()
|
||||
.take(MAX_REGISTERED)
|
||||
.map(PathBuf::from)
|
||||
.filter(|p| p.is_absolute() && !dirs.iter().any(|d| lower(p).starts_with(d)))
|
||||
.collect();
|
||||
files.sort();
|
||||
files.dedup();
|
||||
files
|
||||
}
|
||||
|
||||
fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option<std::path::PathBuf>) -> Option<FontFace> {
|
||||
let data: &[u8] = match &bytes {
|
||||
FontBytes::Static(b) => b,
|
||||
@@ -860,8 +942,13 @@ fn style_italic(style: &str) -> bool {
|
||||
type MenuFamilies = (Arc<[String]>, Arc<[String]>);
|
||||
|
||||
impl FontDb {
|
||||
/// A database holding the bundled fonts, whose system font scan reads `font_dirs`.
|
||||
/// A database holding the bundled fonts, whose system font scan reads `font_dirs` (folders,
|
||||
/// read with their subfolders, and font files).
|
||||
pub fn with_font_dirs(font_dirs: Vec<PathBuf>) -> Self {
|
||||
Self::new(FontPaths::Fixed(font_dirs))
|
||||
}
|
||||
|
||||
fn new(font_paths: FontPaths) -> Self {
|
||||
let mut faces = Vec::new();
|
||||
// The bundled fonts, then the Japanese craft-fonts faces (when built with them), Mincho
|
||||
// first: fallbacks for Japanese text after the requested and bundled fonts.
|
||||
@@ -878,7 +965,7 @@ impl FontDb {
|
||||
outlines: Mutex::new(HashMap::new()),
|
||||
catalog: RwLock::new(Catalog::new()),
|
||||
postscript: RwLock::new(PostScriptNames::new()),
|
||||
font_dirs,
|
||||
font_paths,
|
||||
aliases: RwLock::new(HashMap::new()),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cataloged: std::sync::OnceLock::new(),
|
||||
@@ -887,6 +974,8 @@ impl FontDb {
|
||||
generation: AtomicU64::new(0),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
stamp: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -906,7 +995,7 @@ impl FontDb {
|
||||
/// [`FontDb::scan_in_background`]).
|
||||
pub fn global() -> &'static FontDb {
|
||||
static DB: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
|
||||
DB.get_or_init(|| FontDb::with_font_dirs(system_font_dirs()))
|
||||
DB.get_or_init(|| FontDb::new(FontPaths::System))
|
||||
}
|
||||
|
||||
fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
|
||||
@@ -1042,50 +1131,78 @@ impl FontDb {
|
||||
first.unwrap_or_else(|| self.scan_font_dirs())
|
||||
}
|
||||
|
||||
/// Whether fonts were installed or removed since the last scan (a font folder or font file it
|
||||
/// read changed, or a new one is to be read), for [`FontDb::load_system_fonts`] to catalog
|
||||
/// them. Costs a look at each folder the scan read, not a scan. `false` before the first scan
|
||||
/// and on wasm.
|
||||
pub fn installed_fonts_changed(&self) -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let paths = self.font_paths.get();
|
||||
let stamp = self.stamp.lock().unwrap_or_else(|e| e.into_inner());
|
||||
stamp.as_ref().is_some_and(|s| s.paths != paths || s.modified.iter().any(|(p, t)| modified(p) != *t))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn scan_font_dirs(&self) -> usize {
|
||||
let mut catalog = Catalog::new();
|
||||
let mut aliases: HashMap<String, Vec<Alias>> = HashMap::new();
|
||||
let mut postscript = PostScriptNames::new();
|
||||
let mut n = 0;
|
||||
let mut stack = self.font_dirs.clone();
|
||||
let paths = self.font_paths.get();
|
||||
let mut modified_at = Vec::new();
|
||||
let mut files = Vec::new();
|
||||
let mut stack = paths.clone();
|
||||
// Each folder once, however links lead back to it.
|
||||
let mut visited = std::collections::HashSet::new();
|
||||
while let Some(d) = stack.pop() {
|
||||
if !visited.insert(std::fs::canonicalize(&d).unwrap_or_else(|_| d.clone())) {
|
||||
continue;
|
||||
}
|
||||
let Ok(rd) = std::fs::read_dir(&d) else { continue };
|
||||
// Before listing it: a font installed meanwhile changes it after this.
|
||||
modified_at.push((d.clone(), modified(&d)));
|
||||
// Not a folder: a font file named by itself (or nothing, yet).
|
||||
let Ok(rd) = std::fs::read_dir(&d) else {
|
||||
files.push(d);
|
||||
continue;
|
||||
};
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
continue;
|
||||
}
|
||||
let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
|
||||
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
|
||||
continue;
|
||||
}
|
||||
for FaceStyle { family, style, keys, weight, italic, traits, .. } in file_face_names(&p) {
|
||||
for (k, a) in Alias::of(&family, &style, &keys) {
|
||||
aliases.entry(k).or_default().push(a);
|
||||
}
|
||||
// `keys.postscript` is normalized ([`norm`]), as `by_postscript_name` looks it up.
|
||||
for ps in &keys.postscript {
|
||||
postscript.insert(ps.clone(), (family.clone(), style.clone()));
|
||||
}
|
||||
let entry = catalog.entry(family.to_ascii_lowercase()).or_default();
|
||||
entry.traits.get_or_insert(traits);
|
||||
if entry.name.is_empty() {
|
||||
entry.name = family;
|
||||
}
|
||||
let (weight, italic) = (weight.unwrap_or_else(|| style_weight(&style)), italic || style_italic(&style));
|
||||
entry.faces.push(CatalogFace { style, path: p.clone(), weight, italic });
|
||||
n += 1;
|
||||
} else {
|
||||
files.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
for p in files {
|
||||
let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
|
||||
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
|
||||
continue;
|
||||
}
|
||||
for FaceStyle { family, style, keys, weight, italic, traits, .. } in file_face_names(&p) {
|
||||
for (k, a) in Alias::of(&family, &style, &keys) {
|
||||
aliases.entry(k).or_default().push(a);
|
||||
}
|
||||
// `keys.postscript` is normalized ([`norm`]), as `by_postscript_name` looks it up.
|
||||
for ps in &keys.postscript {
|
||||
postscript.insert(ps.clone(), (family.clone(), style.clone()));
|
||||
}
|
||||
let entry = catalog.entry(family.to_ascii_lowercase()).or_default();
|
||||
entry.traits.get_or_insert(traits);
|
||||
if entry.name.is_empty() {
|
||||
entry.name = family;
|
||||
}
|
||||
let (weight, italic) = (weight.unwrap_or_else(|| style_weight(&style)), italic || style_italic(&style));
|
||||
entry.faces.push(CatalogFace { style, path: p.clone(), weight, italic });
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
log::debug!("cataloged {n} system font faces");
|
||||
*self.stamp.lock().unwrap_or_else(|e| e.into_inner()) = Some(ScanStamp { paths, modified: modified_at });
|
||||
*self.catalog.write().unwrap_or_else(|e| e.into_inner()) = catalog;
|
||||
*self.aliases.write().unwrap_or_else(|e| e.into_inner()) = aliases;
|
||||
*self.postscript.write().unwrap_or_else(|e| e.into_inner()) = postscript;
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
use crate::fontdb::fonts_outside;
|
||||
|
||||
const FAMILY: &str = "Sysfont Sans3";
|
||||
|
||||
@@ -184,3 +185,55 @@ fn installed_faces_are_found_by_postscript_name() {
|
||||
assert_eq!(db.by_postscript_name("sourcesans3-regular"), Some((FAMILY.to_string(), "Regular".to_string())), "any case");
|
||||
assert_eq!(db.by_postscript_name("Rounded-X-Mplus-1c-black"), None);
|
||||
}
|
||||
|
||||
/// Windows lists fonts installed as shortcuts, or by programs into their own folders, by their
|
||||
/// files' full paths: those files are scanned too (#443).
|
||||
#[test]
|
||||
fn a_font_file_named_by_itself_is_scanned() {
|
||||
let dir = font_dir("file");
|
||||
let db = FontDb::with_font_dirs(vec![dir.join("Sub/Sysfont-Bold.TTF"), dir.join("No Such Font.ttf")]);
|
||||
assert_eq!(db.styles(FAMILY), ["Bold"]);
|
||||
assert_eq!(db.load_system_fonts(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registered_fonts_outside_the_font_folders_are_their_full_paths() {
|
||||
let dir = std::env::temp_dir().join("Fonts");
|
||||
let elsewhere = std::env::temp_dir().join("Downloads").join("Montserrat-Regular.ttf");
|
||||
let registered = [
|
||||
// A file in the Windows font folder, by name.
|
||||
"arial.ttf".into(),
|
||||
// A file in a font folder (ignoring case).
|
||||
dir.join("Lato-Regular.ttf").to_string_lossy().to_uppercase(),
|
||||
elsewhere.to_string_lossy().into_owned(),
|
||||
elsewhere.to_string_lossy().into_owned(),
|
||||
];
|
||||
assert_eq!(fonts_outside(registered, &[dir]), [elsewhere]);
|
||||
}
|
||||
|
||||
/// Fonts installed while the app runs are found when it comes back to the front, without
|
||||
/// scanning every time (#443).
|
||||
#[test]
|
||||
fn the_database_tells_when_fonts_were_installed_or_removed_since_its_scan() {
|
||||
let dir = font_dir("changed");
|
||||
let db = FontDb::with_font_dirs(vec![dir.clone(), dir.join("Later")]);
|
||||
assert!(!db.installed_fonts_changed(), "nothing to compare with before the first scan");
|
||||
assert!(!db.has_family("Other Sans 33"));
|
||||
assert!(!db.installed_fonts_changed());
|
||||
// Past the file system's clock tick, so the folders' times differ from the scan's.
|
||||
let tick = || std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
tick();
|
||||
std::fs::write(dir.join("Sub/Other.ttf"), renamed_to("SourceSans3-Regular.ttf", "Other Sans 33")).unwrap();
|
||||
assert!(db.installed_fonts_changed(), "a font installed into a subfolder");
|
||||
assert_eq!(db.load_system_fonts(), 3);
|
||||
assert!(db.has_family("Other Sans 33") && !db.installed_fonts_changed());
|
||||
tick();
|
||||
std::fs::create_dir_all(dir.join("Later")).unwrap();
|
||||
assert!(db.installed_fonts_changed(), "a font folder that didn't exist");
|
||||
db.load_system_fonts();
|
||||
tick();
|
||||
std::fs::remove_file(dir.join("Sysfont-Regular.ttf")).unwrap();
|
||||
assert!(db.installed_fonts_changed(), "a font removed");
|
||||
assert_eq!(db.load_system_fonts(), 2);
|
||||
assert_eq!(db.styles(FAMILY), ["Bold"]);
|
||||
}
|
||||
@@ -808,6 +808,15 @@ impl VectorcraftApp {
|
||||
place::drop_files(self, files, target);
|
||||
}
|
||||
|
||||
/// Fonts installed or removed while the app was in the background are listed when it comes
|
||||
/// back (Refresh Font List by itself): a look at the font folders, a scan only when they changed.
|
||||
fn refresh_installed_fonts(&mut self) {
|
||||
if vectorcraft_text::FontDb::global().installed_fonts_changed() {
|
||||
// A failure shows in the status bar, as the menu item's does.
|
||||
let _ = self.run("text.rescanFonts", json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject synthetic events (one press/release step or wheel turn per frame). Handlers read the
|
||||
/// modifiers egui holds (`i.modifiers`), so a synthetic key, button or wheel turn holds its own
|
||||
/// for the frames it spans (a drag's moves included); the keyboard's come back after.
|
||||
@@ -816,6 +825,7 @@ impl VectorcraftApp {
|
||||
match e {
|
||||
egui::Event::ModifiersChanged(m) => self.host_modifiers = *m,
|
||||
egui::Event::WindowFocused(false) => self.host_modifiers = egui::Modifiers::NONE,
|
||||
egui::Event::WindowFocused(true) => self.refresh_installed_fonts(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-3
@@ -2096,15 +2096,20 @@ tone (mono, or CMYK screens multiplied over each other), clipped to the art's ou
|
||||
|
||||
## Fonts
|
||||
|
||||
Type can use the bundled fonts, fonts added to the session and the fonts installed on the system (none on the web).
|
||||
The installed fonts are cataloged once per session (in the background when the app starts, else on the first lookup
|
||||
Type can use the bundled fonts, fonts added to the session and the fonts installed on the system (none on the web):
|
||||
the system's and the user's font folders (Windows: `Fonts` and `%LOCALAPPDATA%\Microsoft\Windows\Fonts`, plus fonts
|
||||
registered outside them, such as fonts installed as shortcuts; macOS: `/System/Library/Fonts`, `/Library/Fonts`,
|
||||
`/Network/Library/Fonts`, `~/Library/Fonts` and downloaded system fonts; Linux and BSD: `/usr/share/fonts`,
|
||||
`/usr/local/share/fonts`, `~/.fonts` and the XDG data folders' `fonts`, `~/.local/share/fonts` among them, and in a
|
||||
Flatpak sandbox the host's fonts). The installed fonts are cataloged once per session (in the background when the app starts, else on the first lookup
|
||||
by family name), so opening, placing, pasting and importing files find them whatever ran before. `text.fontList`
|
||||
lists every family available, the installed ones included, as the font menus do: without the system's hidden
|
||||
families, whose names start with "." (macOS's ".SF NS", ".LastResort"), which still resolve when a document names
|
||||
them. With `family` it gives that family's styles (upright by weight, then italics) and fails when the family isn't
|
||||
available. `text.rescanFonts` (Character panel menu ›
|
||||
Refresh Font List) scans the font folders again, for fonts installed or removed since the app started: type set in a
|
||||
font that became available redraws in it, without editing the document.
|
||||
font that became available redraws in it, without editing the document. The app does so by itself when its window
|
||||
comes to the front and a font folder changed meanwhile.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"text.fontList","params":{}}}
|
||||
|
||||
Reference in new issue
Block a user