From 8dc2fd077fc851f3d60ad99169e41d9a13e96e8b Mon Sep 17 00:00:00 2001 From: imachus <51533227+imachus@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:06:18 +0900 Subject: [PATCH] Type: find fonts by any of their names; say how each one resolved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents made elsewhere often name a font differently from the family name VectorCraft lists: a localized family name (ヒラギノ角ゴシック), a legacy family/style pair (Hiragino Sans + W3) or a PostScript name (HiraginoSans-W3). FontDb now indexes every name in a face's name table and resolves a request to the right face, telling the caller whether it matched exactly, by another name, by substitution or not at all (FontMatch). - text: FaceKeys/aliases, FontDb::resolve, canonical, find_postscript; fix a race where a face loaded by another thread was reported missing. - engine: text.fonts reports status, the resolved face and missing glyphs; Replace Font resolves the same way. - render: substituted-font highlighting uses resolve, so a font found by another name is no longer shown as missing. - Find Font notes how each font was found. - Preferences › Type › Include Fonts of Other Design Apps' Folders (macOS, on by default). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/fonts.rs | 67 ++++- crates/engine/src/cmd/prefscmds.rs | 2 + crates/engine/src/lib.rs | 4 + crates/render/src/lib.rs | 10 +- crates/text/src/fontdb.rs | 378 ++++++++++++++++++++++++++++- crates/text/src/lib.rs | 2 +- crates/ui-egui/src/find_font.rs | 38 ++- 8 files changed, 467 insertions(+), 35 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 61448519..8baf91c6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -126,6 +126,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`. - **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names. - **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize. +- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. On macOS, fonts activated by other design apps (their subscription fonts and own font folders) are found too (Preferences › Type › Include Fonts of Other Design Apps' Folders). - **Files (M4):** - `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash, background save and export. - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units; PDF and PDF-compatible `.ai` in and out (layers, masks, editable text, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 424c274f..9f1e016d 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -17,7 +17,7 @@ pub fn specs() -> Vec { "Fonts in Document", [], None, - "{selectionOnly?} → [{family, style, runs, objects, missing}] sorted by name", + "{selectionOnly?} → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font)", has_doc, fonts ), @@ -26,7 +26,7 @@ pub fn specs() -> Vec { "Replace Font", [], None, - "{from: {family, style?}, to: {family, style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style) → {runs}", + "{from: {family, style?}, to: {family (any of its names; stored as the font's own family name), style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style) → {runs}", has_doc, replace ), @@ -96,12 +96,12 @@ fn fonts(s: &mut Session, p: &Value) -> Result { let ids = scope(s, bool_or(p, "selectionOnly", false))?; let d = &s.doc()?.doc; let db = vectorcraft_text::FontDb::global(); - let families = db.families(); - let mut found: BTreeMap<(String, String), (usize, Vec)> = BTreeMap::new(); + let mut found: BTreeMap<(String, String), (usize, Vec, String)> = BTreeMap::new(); for id in ids { for r in text(d, id).map(|t| t.runs.as_slice()).unwrap_or_default() { let e = found.entry((r.style.font_family.clone(), r.style.font_style.clone())).or_default(); e.0 += 1; + e.2.push_str(&r.text); if !e.1.contains(&id) { e.1.push(id); } @@ -109,10 +109,20 @@ fn fonts(s: &mut Session, p: &Value) -> Result { } let list: Vec = found .into_iter() - .map(|((family, style), (runs, objects))| { - let have_family = families.iter().any(|f| f.eq_ignore_ascii_case(&family)); - let missing = !have_family || !db.styles(&family).iter().any(|st| st.eq_ignore_ascii_case(&style)); - json!({ "family": family, "style": style, "runs": runs, "objects": objects.len(), "missing": missing }) + .map(|((family, style), (runs, objects, chars))| { + let resolved = db.resolve(&family, &style); + let status = resolved.as_ref().map(|(_, m)| *m).unwrap_or(vectorcraft_text::FontMatch::Missing); + let mut lacking: Vec = chars.chars().filter(|c| !c.is_whitespace() && !c.is_control()).collect(); + lacking.sort_unstable(); + lacking.dedup(); + lacking.retain(|c| !resolved.as_ref().is_some_and(|(f, _)| f.covers(*c))); + json!({ + "family": family, "style": style, "runs": runs, "objects": objects.len(), + "missing": status == vectorcraft_text::FontMatch::Missing, + "status": status.as_str(), + "resolved": resolved.as_ref().map(|(f, _)| json!({ "family": f.family, "style": f.style })), + "missingGlyphs": lacking.len(), + }) }) .collect(); Ok(json!(list)) @@ -130,9 +140,11 @@ fn replace(s: &mut Session, p: &Value) -> Result { let (ff, fs) = font_param(p, "from", C)?; let (tf, ts) = font_param(p, "to", C)?; let db = vectorcraft_text::FontDb::global(); - if !db.families().iter().any(|f| f.eq_ignore_ascii_case(&tf)) { - return Err(bad(C, format!("font family `{tf}` is not available"))); - } + // Any name of the family (ヒラギノ角ゴシック) is stored as its own name (Hiragino Sans). + let tf = match db.resolve(&tf, ts.as_deref().unwrap_or("Regular")) { + Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => f.family.clone(), + _ => return Err(bad(C, format!("font family `{tf}` is not available"))), + }; let ids = scope(s, bool_or(p, "selectionOnly", false))?; let matches = |st: &vectorcraft_doc::CharStyle| { st.font_family.eq_ignore_ascii_case(&ff) && fs.as_ref().is_none_or(|x| st.font_style.eq_ignore_ascii_case(x)) @@ -261,6 +273,39 @@ mod tests { mod open_tests { use super::*; + #[test] + fn list_says_how_each_font_resolved() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + for (text, font, style) in + [("Exact", "Source Sans 3", "Semibold"), ("Closest", "Source Sans 3", "Black"), ("Gone", "No Such Font Family", "Regular")] + { + s.execute("text.create", &json!({"x": 10, "y": 20, "text": text, "font": font, "style": style})).unwrap(); + } + let list = s.execute("text.fonts", &json!({})).unwrap(); + let rows: Vec<(String, String, String, String)> = list + .as_array() + .unwrap() + .iter() + .map(|f| { + ( + f["style"].as_str().unwrap().into(), + f["status"].as_str().unwrap().into(), + f["resolved"]["family"].as_str().unwrap().into(), + f["resolved"]["style"].as_str().unwrap().into(), + ) + }) + .collect(); + let row = |style: &str| rows.iter().find(|r| r.0 == style).cloned().unwrap(); + assert_eq!(row("Semibold").1, "exact"); + assert_eq!(row("Black").1, "substitute", "{rows:?}"); + assert_eq!(row("Black").2, "Source Sans 3"); + assert_eq!((row("Regular").1.as_str(), row("Regular").2.as_str()), ("missing", vectorcraft_text::FALLBACK_FAMILY)); + s.execute("text.create", &json!({"x": 10, "y": 90, "text": "Ab 雅楽", "font": "Inter"})).unwrap(); + let inter = s.execute("text.fonts", &json!({})).unwrap().as_array().unwrap().iter().find(|f| f["family"] == "Inter").cloned().unwrap(); + assert_eq!(inter["missingGlyphs"], 2, "雅 and 楽 come from a fallback font: {inter}"); + } + #[test] fn opened_documents_have_exact_text_bounds() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index 80cde868..d175de67 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -176,6 +176,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("fontNamesInEnglish", "Type", "Options", "Show Font Names in English", bool), p!("autoSizeAreaType", "Type", "Options", "Auto Size New Area Type", bool), p!("fontPreview", "Type", "Options", "Enable in-menu font previews", bool), + p!("adobeFonts", "Type", "Options", "Include Fonts of Other Design Apps' Folders", bool), p!("fontPreviewSize", "Type", "Options", "Font Preview Size", choice(&[("small", "Small"), ("medium", "Medium"), ("large", "Large")])), p!("recentFontsCount", "Type", "Options", "Number of Recent Fonts", int(1, 15)), p!("missingGlyphProtection", "Type", "Options", "Enable Missing Glyph Protection", bool), @@ -448,6 +449,7 @@ impl Session { let tile_edge_changed = p.pattern_tile_edge_color != self.prefs.pattern_tile_edge_color; self.prefs = p; vectorcraft_render::set_default_threads(u16::try_from(self.prefs.render_threads).ok()); + vectorcraft_text::FontDb::global().set_adobe_fonts(self.prefs.adobe_fonts); for st in &mut self.docs { if history_changed { st.history.limit = self.prefs.history_states as usize; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8095cbf2..79260f69 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -443,6 +443,9 @@ pub struct Prefs { pub width_profiles: Vec, /// General → Use Japanese Crop Marks: the style of Create Trim Marks and Effect → Crop Marks. pub japanese_crop_marks: bool, + /// Type → Include Fonts of Other Design Apps' Folders (on; macOS): fonts activated from Adobe Fonts and + /// those in Adobe applications' own font folder are found like installed fonts. + pub adobe_fonts: bool, /// Appearance panel → New Art Has Basic Appearance (on): new art takes one fill and stroke; /// off, the whole appearance of the last selection (`appearance.setNewArtBasic`). pub new_art_basic: bool, @@ -625,6 +628,7 @@ impl Default for Prefs { flattener_presets: vec![], width_profiles: vec![], japanese_crop_marks: false, + adobe_fonts: true, new_art_basic: true, color_themes: vec![], new_doc_presets: vec![], diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b89e498b..baae4cd1 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1488,8 +1488,14 @@ fn text_geom_snapped(t: &TextObject, snap: Option) -> TextGeom { let mut runs = vec![BezPath::new(); t.runs.len()]; let mut all = BezPath::new(); // Per run: is its family missing, and the face its glyphs should come from. - let faces: Vec<(bool, Option)> = - t.runs.iter().map(|r| (!db.has_family(&r.style.font_family), db.face(&r.style.font_family, &r.style.font_style).map(|f| f.id()))).collect(); + let faces: Vec<(bool, Option)> = t + .runs + .iter() + .map(|r| match db.resolve(&r.style.font_family, &r.style.font_style) { + Some((f, m)) => (m == vectorcraft_text::FontMatch::Missing, Some(f.id())), + None => (true, None), + }) + .collect(); let (mut substituted_fonts, mut substituted_glyphs) = (BezPath::new(), BezPath::new()); for g in &layout.glyphs { if let Some(r) = runs.get_mut(g.run) { diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 08765f4a..fa1ccd52 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -39,6 +39,8 @@ enum FontBytes { /// One loaded font face. pub struct FontFace { + /// Every name the face answers to (normalized), in every language its name table has. + keys: FaceKeys, id: u32, /// Typographic family name (e.g. "Source Sans 3"). pub family: String, @@ -165,6 +167,13 @@ pub struct FontDb { /// The folders the system font scan reads (the platform's font folders for [`FontDb::global`]). #[cfg_attr(target_arch = "wasm32", allow(dead_code))] font_dirs: Vec, + /// Adobe applications' font folders, scanned with `font_dirs` while `adobe` is on. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + adobe_dirs: Vec, + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + adobe: std::sync::atomic::AtomicBool, + /// The installed fonts' other names (localized, legacy, PostScript) by normalized name. + aliases: RwLock>>, /// Set once the font folders have been scanned. Lookups by family name wait for the first /// scan, so what they find never depends on what ran before them. #[cfg(not(target_arch = "wasm32"))] @@ -221,6 +230,116 @@ fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option { ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty())) } +/// Every localized string of `id`, with its language. +fn localized(font: &skrifa::FontRef<'_>, id: StringId) -> Vec<(Option, String)> { + font.localized_strings(id).map(|s| (s.language().map(str::to_owned), s.to_string())).filter(|(_, s)| !s.is_empty()).collect() +} + +/// Every localized string of `id`. +fn all_names(font: &skrifa::FontRef<'_>, id: StringId) -> Vec { + localized(font, id).into_iter().map(|(_, s)| s).collect() +} + +/// The names a face answers to besides its (English) family and style, normalized ([`norm`]): +/// its family in every language (typographic, else legacy: `ヒラギノ角ゴシック`), its style likewise +/// (`ミディアム`), its legacy family names (`Hiragino Sans W3`) with their legacy styles, paired per +/// language record (the Mac and Windows records of a face differ), and its PostScript names +/// (`HiraginoSans-W3`, as PDF and `.ai` files name fonts). +#[derive(Clone, Debug, Default)] +struct FaceKeys { + families: Vec, + styles: Vec, + legacy: Vec<(String, String)>, + postscript: Vec, +} + +impl FaceKeys { + fn of(font: &skrifa::FontRef<'_>, family: &str, style: &str) -> Self { + let (typo_family, typo_style) = (all_names(font, StringId::TYPOGRAPHIC_FAMILY_NAME), all_names(font, StringId::TYPOGRAPHIC_SUBFAMILY_NAME)); + let (legacy_family, legacy_style) = (localized(font, StringId::FAMILY_NAME), localized(font, StringId::SUBFAMILY_NAME)); + // Without typographic names, the legacy ones are the family's. + let family_names: Vec<&str> = if typo_family.is_empty() { + legacy_family.iter().map(|(_, f)| f.as_str()).collect() + } else { + typo_family.iter().map(String::as_str).collect() + }; + let mut families: Vec = std::iter::once(family).chain(family_names).map(norm).collect(); + // A legacy style only names the face beside its legacy family when the font has a + // typographic style: every weight of `Hiragino Sans` is `Regular` in its own legacy family. + let style_names: Vec<&str> = if typo_style.is_empty() { + legacy_style.iter().map(|(_, s)| s.as_str()).collect() + } else { + typo_style.iter().map(String::as_str).collect() + }; + let mut styles: Vec = std::iter::once(style).chain(style_names).map(norm).collect(); + let mut legacy: Vec<(String, String)> = if typo_family.is_empty() { + vec![] + } else { + legacy_family + .iter() + .flat_map(|(lf, f)| legacy_style.iter().filter(move |(ls, _)| ls == lf).map(move |(_, s)| (norm(f), norm(s)))) + .filter(|(f, s)| !f.is_empty() && !s.is_empty()) + .collect() + }; + let mut postscript: Vec = all_names(font, StringId::POSTSCRIPT_NAME).iter().map(|n| norm(n)).collect(); + for v in [&mut families, &mut styles, &mut postscript] { + v.retain(|k| !k.is_empty()); + v.sort(); + v.dedup(); + } + legacy.sort(); + legacy.dedup(); + FaceKeys { families, styles, legacy, postscript } + } +} + +/// What a name other than a family's own one stands for ([`FontDb::canonical`]). +#[derive(Clone, Debug)] +struct Alias { + /// The family's own name. + family: String, + /// The face the name names, for legacy family names and PostScript names. + style: Option, + /// A legacy family name's legacy style (`Regular` beside `Hiragino Sans W3`). + paired: Option, +} + +impl Alias { + /// The aliases of a face `family` / `style` with `keys`, by normalized name. + fn of(family: &str, style: &str, keys: &FaceKeys) -> Vec<(String, Alias)> { + let mut v: Vec<(String, Alias)> = + keys.families.iter().map(|k| (k.clone(), Alias { family: family.to_string(), style: None, paired: None })).collect(); + v.extend( + keys.legacy + .iter() + .map(|(f, s)| (f.clone(), Alias { family: family.to_string(), style: Some(style.to_string()), paired: Some(s.clone()) })), + ); + v.extend(keys.postscript.iter().map(|k| (k.clone(), Alias { family: family.to_string(), style: Some(style.to_string()), paired: None }))); + v + } +} + +/// How a requested family and style resolved ([`FontDb::resolve`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FontMatch { + /// The face of that family and style (by any of its names, in any language). + Exact, + /// The family is there but not the style: its closest style stands in. + Style, + /// The family is unknown: the fallback family stands in. + Missing, +} + +impl FontMatch { + pub fn as_str(self) -> &'static str { + match self { + FontMatch::Exact => "exact", + FontMatch::Style => "substitute", + FontMatch::Missing => "missing", + } + } +} + /// A face's (family, style) names. fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String)> { let family = name(f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME])?; @@ -246,7 +365,7 @@ fn enumerate_faces(data: &[u8]) -> Vec<(u32, String, String)> { /// (family, style) of every face in the font file at `path`, reading only its table directories /// and `name` tables: a scan opens hundreds of font files, many of them megabytes long. #[cfg(not(target_arch = "wasm32"))] -fn file_face_names(path: &Path) -> Vec<(String, String)> { +fn file_face_names(path: &Path) -> Vec<(String, String, FaceKeys)> { use std::io::{Read, Seek, SeekFrom}; /// Caps on what a (possibly damaged) file can make the scan read. const MAX_FACES: u32 = 256; @@ -288,11 +407,27 @@ fn file_face_names(path: &Path) -> Vec<(String, String)> { font.extend_from_slice(&u32::to_be_bytes(v)); } font.extend_from_slice(&table); - face_names(&skrifa::FontRef::new(&font).ok()?) + let f = skrifa::FontRef::new(&font).ok()?; + let (family, style) = face_names(&f)?; + let keys = FaceKeys::of(&f, &family, &style); + Some((family, style, keys)) }) .collect() } +/// The font folders of Adobe applications (macOS): Adobe Fonts activated from Creative Cloud and +/// the applications' own font folder. Scanned by [`FontDb::global`] unless turned off +/// ([`FontDb::set_adobe_fonts`]); left out of the glyph fallback search (thousands of files). +pub fn adobe_font_dirs() -> Vec { + match std::env::var_os("HOME").map(PathBuf::from) { + Some(h) if cfg!(target_os = "macos") => { + let support = h.join("Library/Application Support/Adobe"); // brand-ok: the folder's real name + vec![support.join("CoreSync/plugins/livetype"), support.join("Fonts")] + } + _ => vec![], + } +} + /// The platform's font folders (the system's and the user's), scanned by [`FontDb::global`]. pub fn system_font_dirs() -> Vec { let mut dirs: Vec = Vec::new(); @@ -305,6 +440,13 @@ pub fn system_font_dirs() -> Vec { if let Some(h) = &home { dirs.push(h.join("Library/Fonts")); } + // Fonts macOS downloads on demand (Yu Gothic, Yu Mincho, Tsukushi Maru Gothic…): one + // asset folder per font, under a catalog folder whose number changes with macOS. + for e in std::fs::read_dir("/System/Library/AssetsV2").into_iter().flatten().flatten() { + if e.file_name().to_string_lossy().starts_with("com_apple_MobileAsset_Font") { + dirs.push(e.path()); + } + } } 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")); @@ -330,7 +472,9 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, path: let m = f.metrics(Size::unscaled(), LocationRef::default()); let a = f.attributes(); let shaper = harfrust::ShaperData::new(&harfrust::FontRef::from_index(data, index).ok()?); + let keys = FaceKeys::of(&f, &family, &style); Some(FontFace { + keys, id: NEXT_ID.fetch_add(1, Ordering::Relaxed), family, style, @@ -399,6 +543,9 @@ impl FontDb { outlines: Mutex::new(HashMap::new()), catalog: RwLock::new(Catalog::new()), font_dirs, + adobe_dirs: vec![], + adobe: std::sync::atomic::AtomicBool::new(true), + aliases: RwLock::new(HashMap::new()), #[cfg(not(target_arch = "wasm32"))] cataloged: std::sync::OnceLock::new(), family_cache: Mutex::new(None), @@ -424,7 +571,7 @@ impl FontDb { /// [`FontDb::scan_in_background`]). pub fn global() -> &'static FontDb { static DB: std::sync::OnceLock = std::sync::OnceLock::new(); - DB.get_or_init(|| FontDb::with_font_dirs(system_font_dirs())) + DB.get_or_init(|| FontDb { adobe_dirs: adobe_font_dirs(), ..FontDb::with_font_dirs(system_font_dirs()) }) } fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec>> { @@ -471,6 +618,8 @@ impl FontDb { /// Style names available for `family`: upright styles by weight, then italics. pub fn styles(&self, family: &str) -> Vec { + let (family, _) = self.canonical(family, ""); + let family = family.as_str(); let mut v: Vec<(bool, f32, String)> = self.read_faces().iter().filter(|f| f.family.eq_ignore_ascii_case(family)).map(|f| (f.italic, f.weight, f.style.clone())).collect(); if let Some(c) = self.read_catalog().get(&family.to_ascii_lowercase()) { @@ -544,8 +693,12 @@ impl FontDb { #[cfg(not(target_arch = "wasm32"))] fn scan_font_dirs(&self) -> usize { let mut catalog = Catalog::new(); + let mut aliases: HashMap> = HashMap::new(); let mut n = 0; let mut stack = self.font_dirs.clone(); + if self.adobe.load(Ordering::Relaxed) { + stack.extend(self.adobe_dirs.iter().cloned()); + } // Each folder once, however links lead back to it. let mut visited = std::collections::HashSet::new(); while let Some(d) = stack.pop() { @@ -563,7 +716,10 @@ impl FontDb { if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) { continue; } - for (family, style) in file_face_names(&p) { + for (family, style, keys) in file_face_names(&p) { + for (k, a) in Alias::of(&family, &style, &keys) { + aliases.entry(k).or_default().push(a); + } let entry = catalog.entry(family.to_ascii_lowercase()).or_default(); if entry.name.is_empty() { entry.name = family; @@ -575,10 +731,87 @@ impl FontDb { } log::debug!("cataloged {n} system font faces"); *self.catalog.write().unwrap_or_else(|e| e.into_inner()) = catalog; + *self.aliases.write().unwrap_or_else(|e| e.into_inner()) = aliases; self.changed(); n } + /// Also catalog the font folders of Adobe applications ([`adobe_font_dirs`]; on by default). + /// A change rescans the catalog if it was made. + pub fn set_adobe_fonts(&self, on: bool) { + #[cfg(not(target_arch = "wasm32"))] + if self.adobe.swap(on, Ordering::Relaxed) != on && !self.adobe_dirs.is_empty() && self.cataloged.get().is_some() { + self.load_system_fonts(); + } + #[cfg(target_arch = "wasm32")] + let _ = on; + } + + /// The family's own name and the style for a name given in a document: any of a family's + /// names (`ヒラギノ角ゴシック` is Hiragino Sans), a legacy family name (`Hiragino Sans W6` with its + /// legacy style names the W6 face) or a PostScript name (`HiraginoSans-W6`, as PDF and `.ai` + /// files name fonts). Case and anything but letters and digits are ignored. A name nothing + /// answers to comes back as it is. + pub fn canonical(&self, family: &str, style: &str) -> (String, String) { + if self.is_loaded(family) || self.read_catalog().contains_key(&family.to_ascii_lowercase()) { + return (family.to_string(), style.to_string()); + } + let key = norm(family); + let mut found: Vec = + self.read_faces().iter().flat_map(|f| Alias::of(&f.family, &f.style, &f.keys)).filter(|(k, _)| *k == key).map(|(_, a)| a).collect(); + if let Some(v) = self.aliases.read().unwrap_or_else(|e| e.into_inner()).get(&key) { + found.extend(v.iter().cloned()); + } + let ns = norm(style); + // A family name keeps the style asked for; a legacy pair names its face; a PostScript name + // its face whatever style is asked for. + if let Some(a) = found.iter().find(|a| a.style.is_none()) { + return (a.family.clone(), style.to_string()); + } + if let Some(a) = found.iter().find(|a| a.paired.as_ref().is_some_and(|p| *p == ns) || a.paired.is_none()) { + return (a.family.clone(), a.style.clone().unwrap_or_else(|| style.to_string())); + } + match found.first() { + Some(a) => (a.family.clone(), style.to_string()), + None => (family.to_string(), style.to_string()), + } + } + + /// [`face`](Self::face) by any of the family's names ([`canonical`](Self::canonical)), and how + /// it matched. + pub fn resolve(&self, family: &str, style: &str) -> Option<(Arc, FontMatch)> { + let (family, style) = self.canonical(family, style); + let f = self.face(&family, &style)?; + let m = if !f.family.eq_ignore_ascii_case(&family) { + FontMatch::Missing + } else if norm(&f.style) == norm(&style) || f.keys.styles.contains(&norm(&style)) { + FontMatch::Exact + } else { + FontMatch::Style + }; + Some((f, m)) + } + + /// The installed face whose PostScript name is `name` (`HiraginoSans-W3`; any case), loaded + /// from the system fonts if need be. `None` when no installed font has the name. + pub fn find_postscript(&self, name: &str) -> Option> { + let key = norm(name); + if key.is_empty() { + return None; + } + let loaded = |db: &FontDb| db.read_faces().iter().find(|f| f.keys.postscript.contains(&key)).cloned(); + if let Some(f) = loaded(self) { + return Some(f); + } + self.ensure_catalog(); + let face = self.aliases.read().unwrap_or_else(|e| e.into_inner()).get(&key).and_then(|v| { + v.iter().find(|a| a.paired.is_none() && a.style.is_some()).map(|a| (a.family.clone(), a.style.clone().unwrap_or_default())) + }); + let (family, style) = face?; + self.face(&family, &style)?; + loaded(self) + } + /// Load the files of the installed `family`. Returns whether any face was added. #[cfg(not(target_arch = "wasm32"))] fn load_cataloged(&self, family: &str) -> bool { @@ -600,17 +833,21 @@ impl FontDb { /// only if no font at all is loaded (the bundled fonts failed to parse), in which case text has /// no glyphs. pub fn face(&self, family: &str, style: &str) -> Option> { + let (family, style) = self.canonical(family, style); + let (family, style) = (family.as_str(), style.as_str()); let found = self.find(family, style); if found.as_ref().is_some_and(|f| norm(&f.style) == norm(style)) { return found; } - // The family, or this style of it, is installed but not loaded yet. + // The family, or this style of it, is installed but not loaded yet. Looked for again + // whether or not this call loaded it: another thread may have just done so (then this + // load adds nothing, but the face is there). #[cfg(not(target_arch = "wasm32"))] - if (found.is_none() || self.is_cataloged(family, style)) - && self.load_cataloged(family) - && let Some(f) = self.find(family, style) - { - return Some(f); + if found.is_none() || self.is_cataloged(family, style) { + self.load_cataloged(family); + if let Some(f) = self.find(family, style) { + return Some(f); + } } found .or_else(|| self.find(FALLBACK_FAMILY, style)) @@ -638,7 +875,8 @@ impl FontDb { /// Is `family` available (loaded, or installed on the system)? pub fn has_family(&self, family: &str) -> bool { - self.is_loaded(family) || self.read_catalog().contains_key(&family.to_ascii_lowercase()) + let (family, _) = self.canonical(family, ""); + self.is_loaded(&family) || self.read_catalog().contains_key(&family.to_ascii_lowercase()) } pub(crate) fn is_loaded(&self, family: &str) -> bool { @@ -712,7 +950,12 @@ impl FontDb { return true; } } - let mut paths: Vec = self.read_catalog().values().flat_map(|c| c.faces.iter().map(|(_, p)| p.clone())).collect(); + let mut paths: Vec = self + .read_catalog() + .values() + .flat_map(|c| c.faces.iter().map(|(_, p)| p.clone())) + .filter(|p| !self.adobe_dirs.iter().any(|d| p.starts_with(d))) + .collect(); paths.sort(); paths.dedup(); for p in paths { @@ -771,3 +1014,114 @@ impl OutlinePen for FlipPen { self.0.close_path(); } } + +#[cfg(test)] +mod alias_tests { + use super::*; + + /// A one-table font holding a name table from (platform, language, name id, string) records: + /// Windows Unicode (UTF-16) or Mac Roman (ASCII here). + fn name_font(records: &[(u16, u16, u16, &str)]) -> Vec { + let (mut head, mut strings) = (vec![], vec![]); + let n = records.len() as u16; + for v in [0, n, 6 + 12 * n] { + head.extend_from_slice(&v.to_be_bytes()); + } + for &(platform, lang, id, s) in records { + let bytes: Vec = if platform == 3 { s.encode_utf16().flat_map(u16::to_be_bytes).collect() } else { s.as_bytes().to_vec() }; + let encoding = if platform == 3 { 1 } else { 0 }; + for v in [platform, encoding, lang, id, bytes.len() as u16, strings.len() as u16] { + head.extend_from_slice(&v.to_be_bytes()); + } + strings.extend(bytes); + } + head.extend(strings); + let mut font = vec![0, 1, 0, 0, 0, 1, 0, 16, 0, 0, 0, 0]; + font.extend_from_slice(b"name"); + for v in [0u32, 28, head.len() as u32] { + font.extend_from_slice(&v.to_be_bytes()); + } + font.extend(head); + font + } + + fn keys(records: &[(u16, u16, u16, &str)]) -> (String, String, FaceKeys) { + let font = name_font(records); + let f = skrifa::FontRef::new(&font).unwrap(); + let (family, style) = face_names(&f).unwrap(); + let k = FaceKeys::of(&f, &family, &style); + (family, style, k) + } + + const EN: u16 = 0x0409; + const JA: u16 = 0x0411; + + /// Laid out like the name table of Hiragino Sans W3. + const HIRAGINO_W3: &[(u16, u16, u16, &str)] = &[ + (1, 0, 1, "Hiragino Sans"), + (1, 0, 2, "W3"), + (3, EN, 1, "Hiragino Sans W3"), + (3, EN, 2, "Regular"), + (3, JA, 1, "ヒラギノ角ゴシック W3"), + (3, JA, 2, "Regular"), + (3, EN, 6, "HiraginoSans-W3"), + (3, EN, 16, "Hiragino Sans"), + (3, JA, 16, "ヒラギノ角ゴシック"), + (3, EN, 17, "W3"), + (3, JA, 17, "W3"), + ]; + + #[test] + fn a_face_answers_to_its_names_in_every_language() { + let (family, style, k) = keys(HIRAGINO_W3); + assert_eq!((family.as_str(), style.as_str()), ("Hiragino Sans", "W3")); + let aliases = Alias::of(&family, &style, &k); + let find = |name: &str| aliases.iter().filter(|(n, _)| *n == norm(name)).map(|(_, a)| a.clone()).collect::>(); + assert!(find("ヒラギノ 角ゴシック").iter().any(|a| a.family == "Hiragino Sans" && a.style.is_none()), "a family name in Japanese"); + let legacy = find("Hiragino Sans W3"); + assert!(legacy.iter().any(|a| a.style.as_deref() == Some("W3") && a.paired.as_deref() == Some("regular")), "{legacy:?}"); + assert!(!legacy.iter().any(|a| a.paired.as_deref() == Some("w3")), "Mac family + Windows style is no pair"); + assert!(find("HiraginoSans-W3").iter().any(|a| a.style.as_deref() == Some("W3") && a.paired.is_none()), "the PostScript name"); + } + + #[test] + fn without_typographic_names_the_legacy_ones_are_the_familys() { + let (family, style, k) = keys(&[(3, EN, 1, "Example Serif"), (3, EN, 2, "Bold Italic"), (3, JA, 1, "例セリフ"), (3, JA, 2, "太字斜体")]); + assert_eq!((family.as_str(), style.as_str()), ("Example Serif", "Bold Italic")); + assert!(k.families.contains(&norm("例セリフ")) && k.styles.contains(&norm("太字斜体")) && k.legacy.is_empty()); + } + + #[test] + fn resolve_says_how_a_font_matched() { + let db = FontDb::with_font_dirs(vec![]); + let m = |f: &str, s: &str| db.resolve(f, s).map(|(face, m)| (face.family.clone(), face.style.clone(), m)).unwrap(); + assert_eq!(m("Source Sans 3", "Semibold"), ("Source Sans 3".into(), "Semibold".into(), FontMatch::Exact)); + assert_eq!(m("Source Sans 3", "Black").2, FontMatch::Style); + assert_eq!(m("No Such Family", "Regular"), (FALLBACK_FAMILY.into(), "Regular".into(), FontMatch::Missing)); + // A PostScript name names its face, whatever style is asked for (text opened from a PDF). + assert_eq!(m("SourceSans3-Semibold", "Regular"), ("Source Sans 3".into(), "Semibold".into(), FontMatch::Exact)); + let semi = db.find_postscript("sourcesans3-semibold").unwrap(); + assert_eq!(semi.style, "Semibold"); + assert!(db.find_postscript("NoSuchFont-Regular").is_none()); + assert!(db.has_family("SourceSans3-Regular")); + } +} + +#[cfg(test)] +mod race_tests { + use super::*; + + #[test] + fn a_family_loaded_by_another_thread_meanwhile_is_still_found() { + // Two threads ask for the same installed family at once: each must get it, not the + // fallback (the second one's load finds every face already added). + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts"); + let db: &'static FontDb = Box::leak(Box::new(FontDb::with_font_dirs(vec![dir]))); + // Only the catalog knows Inter (the bundled faces are dropped). + db.faces.write().unwrap().retain(|f| f.family != "Inter"); + let threads: Vec<_> = (0..8).map(|_| std::thread::spawn(move || db.face("Inter", "Regular").map(|f| f.family.clone()))).collect(); + for t in threads { + assert_eq!(t.join().unwrap().as_deref(), Some("Inter")); + } + } +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index cdcdfa28..7c06c7c7 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -20,7 +20,7 @@ mod shape; pub mod thread; pub use features::OtFeatures; -pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace, SHIPPORI_MINCHO_REGULAR, style_weight, system_font_dirs}; +pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace, FontMatch, SHIPPORI_MINCHO_REGULAR, style_weight, system_font_dirs}; use kurbo::{Affine, BezPath, Point, Rect, Vec2}; pub use layout::{layout, layout_with}; pub use vectorcraft_doc::TextObject; diff --git a/crates/ui-egui/src/find_font.rs b/crates/ui-egui/src/find_font.rs index 6d6758cb..9bef09ff 100644 --- a/crates/ui-egui/src/find_font.rs +++ b/crates/ui-egui/src/find_font.rs @@ -58,15 +58,9 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { ui.set_width(ui.available_width()); egui::ScrollArea::vertical().max_height(160.0).show(ui, |ui| { for (i, f) in list.iter().enumerate() { - let missing = f["missing"] == true; - let label = format!( - "{} {}{} ({})", - f["family"].as_str().unwrap_or(""), - f["style"].as_str().unwrap_or(""), - if missing { " — missing" } else { "" }, - f["runs"] - ); - let text = egui::RichText::new(label).color(if missing { egui::Color32::from_rgb(230, 90, 90) } else { t.text }); + let (note, color) = font_note(f); + let label = format!("{} {}{note} ({})", f["family"].as_str().unwrap_or(""), f["style"].as_str().unwrap_or(""), f["runs"]); + let text = egui::RichText::new(label).color(color.unwrap_or(t.text)); if ui.selectable_label(i == sel, text).clicked() { d.fields.insert("selected".into(), json!(i)); } @@ -126,6 +120,22 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { } } +/// What a Find Font row says after the font's name (`text.fonts` row): a missing family, a style +/// standing in for another, characters the font lacks; and the colour to flag it with. +fn font_note(f: &Value) -> (String, Option) { + let lacking = f["missingGlyphs"].as_u64().unwrap_or(0); + let glyphs = if lacking > 0 { format!(" — {lacking} characters from another font") } else { String::new() }; + match f["status"].as_str() { + Some("missing") => (format!(" — missing{glyphs}"), Some(egui::Color32::from_rgb(230, 90, 90))), + Some("substitute") => { + let used = f["resolved"]["style"].as_str().unwrap_or(""); + (format!(" — substituted by {used}{glyphs}"), Some(egui::Color32::from_rgb(220, 160, 60))) + } + _ if lacking > 0 => (glyphs, Some(egui::Color32::from_rgb(220, 160, 60))), + _ => (String::new(), None), + } +} + #[cfg(test)] mod tests { use super::*; @@ -146,4 +156,14 @@ mod tests { change(&mut app, &d, &from, true).unwrap(); assert_eq!(fonts(&mut app)[0]["family"], "Source Sans 3"); } + + #[test] + fn rows_flag_missing_fonts_substituted_styles_and_lacking_characters() { + let row = |status: &str, glyphs: u64| json!({"status": status, "resolved": {"style": "W4"}, "missingGlyphs": glyphs}); + assert_eq!(font_note(&row("exact", 0)), (String::new(), None)); + assert!(font_note(&row("missing", 0)).0.contains("missing")); + assert!(font_note(&row("substitute", 0)).0.contains("substituted by W4")); + let (note, color) = font_note(&row("exact", 2)); + assert!(note.contains("2 characters") && color.is_some()); + } }