diff --git a/ROADMAP.md b/ROADMAP.md index c75a7983..65882845 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -127,6 +127,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. - **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/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 d797e07f..14c4f8d4 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -35,6 +35,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, @@ -153,7 +155,7 @@ struct CatalogFamily { /// [`FontDb::face`] does). Always empty on wasm, which has no system fonts. type Catalog = HashMap; -/// The installed faces' (family, style) by ASCII-lowercased PostScript name. +/// The installed faces' (family, style) by normalized PostScript name ([`norm`]). type PostScriptNames = HashMap; /// Process-wide font database. @@ -166,6 +168,8 @@ 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, + /// 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"))] @@ -222,6 +226,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])?; @@ -229,13 +343,6 @@ fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String)> { Some((family, style)) } -/// A face's (family, style, PostScript name). -#[cfg(not(target_arch = "wasm32"))] -fn face_names_ps(f: &skrifa::FontRef<'_>) -> Option<(String, String, Option)> { - let (family, style) = face_names(f)?; - Some((family, style, name(f, &[StringId::POSTSCRIPT_NAME]))) -} - /// Parse every face in `data` (a font file or collection). Returns `(index, family, style)`. fn enumerate_faces(data: &[u8]) -> Vec<(u32, String, String)> { let count = match FileRef::new(data) { @@ -255,7 +362,7 @@ fn enumerate_faces(data: &[u8]) -> Vec<(u32, String, String)> { /// 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, Option)> { +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; @@ -297,7 +404,10 @@ fn file_face_names(path: &Path) -> Vec<(String, String, Option)> { font.extend_from_slice(&u32::to_be_bytes(v)); } font.extend_from_slice(&table); - face_names_ps(&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() } @@ -314,6 +424,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")); @@ -339,7 +456,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, @@ -412,6 +531,7 @@ impl FontDb { catalog: RwLock::new(Catalog::new()), postscript: RwLock::new(PostScriptNames::new()), font_dirs, + aliases: RwLock::new(HashMap::new()), #[cfg(not(target_arch = "wasm32"))] cataloged: std::sync::OnceLock::new(), family_cache: Mutex::new(None), @@ -484,6 +604,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()) { @@ -557,6 +679,7 @@ 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 postscript = PostScriptNames::new(); let mut n = 0; let mut stack = self.font_dirs.clone(); @@ -577,9 +700,13 @@ impl FontDb { if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) { continue; } - for (family, style, ps) in file_face_names(&p) { - if let Some(ps) = ps { - postscript.insert(ps.to_ascii_lowercase(), (family.clone(), style.clone())); + 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); + } + // `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(); if entry.name.is_empty() { @@ -592,17 +719,83 @@ 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.postscript.write().unwrap_or_else(|e| e.into_inner()) = postscript; self.changed(); n } + /// 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) + } + /// The (family, style) of the installed face whose PostScript name is `name` (ignoring ASCII /// case). Documents name fonts this way, and a family can hold hyphens /// ("Rounded-X-Mplus-1c-black" is Rounded-X M+ 1c, black), so the name can't be split. pub fn by_postscript_name(&self, name: &str) -> Option<(String, String)> { self.ensure_catalog(); - self.postscript.read().unwrap_or_else(|e| e.into_inner()).get(&name.to_ascii_lowercase()).cloned() + self.postscript.read().unwrap_or_else(|e| e.into_inner()).get(&norm(name)).cloned() } /// Load the files of the installed `family`. Returns whether any face was added. @@ -626,17 +819,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)) @@ -664,7 +861,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 { @@ -797,3 +995,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 bf251e69..7749d60d 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -22,7 +22,7 @@ pub mod thread; pub use craft_fonts::{CRAFT_FONTS, CraftFont}; pub use features::OtFeatures; -pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace, style_weight, system_font_dirs}; +pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace, FontMatch, 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()); + } }