diff --git a/ROADMAP.md b/ROADMAP.md index ae8fe49ad..70d566d90 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # DesignCraft roadmap +- FONT6: localized and legacy family/style names share canonical resolution across shaping, inventory and replacement. Known compatible and script-based substitutes retain missing status; Find Font shows the resolved face and offers it as the replacement, while preflight reports unavailable styles. + - FONT5: missing-glyph preflight inspects rendered characters in generated labels, including zero-length continuation glyphs, and identifies the owning page frame for nested text. - FONT4: generated note labels resolve composite character mappings and glyph fallback while retaining a single source anchor; composed glyphs retain the actual rendered character for diagnostics. diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 7c59885b2..111444251 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -13,7 +13,7 @@ use crate::{Result, Session}; pub fn specs() -> Vec { vec![ - cmd!(query "font.list", "Fonts in Document", [], None, "{} → [{family, style, characters, missing, styleMissing, source: bundled|installed|document|added (null when missing)}] (missing first)", has_doc, |s, _| { + cmd!(query "font.list", "Fonts in Document", [], None, "{} → [{family, style, characters, missing, styleMissing, source: bundled|installed|document|added (null when missing), matchStatus: exact|styleSubstitute|missing, resolvedFamily, resolvedStyle}] (missing first)", has_doc, |s, _| { Ok(Value::Array(list(&s.doc()?.doc))) }), cmd!( @@ -48,6 +48,9 @@ pub(super) struct UsedFont { pub missing: bool, pub style_missing: bool, pub source: Option<&'static str>, + pub match_status: &'static str, + pub resolved_family: String, + pub resolved_style: String, } fn list(d: &Document) -> Vec { @@ -84,14 +87,31 @@ pub(super) fn used_fonts(d: &Document) -> Vec { .into_iter() .map(|((family, style), n)| { let missing = !db.has_family(&family); - let style_missing = !missing && !db.styles(&family).iter().any(|s| s.eq_ignore_ascii_case(&style)); + let style_missing = !missing && !db.has_style(&family, &style); + let resolved = db.face(&family, &style); let source = (!missing).then(|| match db.face(&family, &style).source { FontSource::Bundled => "bundled", FontSource::Installed(_) => "installed", FontSource::Document(_) => "document", FontSource::Memory => "added", }); - UsedFont { family, style, characters: n, missing, style_missing, source } + UsedFont { + family, + style, + characters: n, + missing, + style_missing, + source, + match_status: if missing { + "missing" + } else if style_missing { + "styleSubstitute" + } else { + "exact" + }, + resolved_family: resolved.family.clone(), + resolved_style: resolved.style.clone(), + } }) .collect(); out.sort_by_key(|font| !(font.missing || font.style_missing)); @@ -122,10 +142,13 @@ fn replace(s: &mut Session, p: &Value) -> Result { return Err(bad("font.replace", "Choose an available replacement font family")); } if let Some(style) = &to_style - && !db.styles(&to_family).iter().any(|available| available.eq_ignore_ascii_case(style)) + && !db.has_style(&to_family, style) { return Err(bad("font.replace", "Choose an available replacement font style")); } + let resolved = db.face(&to_family, to_style.as_deref().or(style.as_deref()).unwrap_or("Regular")); + let to_family = resolved.family.clone(); + let to_style = (to_style.is_some() || style.is_some()).then(|| resolved.style.clone()); let styles_too = p.get("redefineStyles").and_then(Value::as_bool).unwrap_or(true); s.edit(|d, _| { let mut changed = 0usize; @@ -156,16 +179,14 @@ fn replace(s: &mut Session, p: &Value) -> Result { if effective.font_family.eq_ignore_ascii_case(&family) && style.as_ref().is_none_or(|s| effective.font_style.eq_ignore_ascii_case(s)) { - ranges.push(start..end); + ranges.push((start..end, db.face(&to_family, to_style.as_deref().unwrap_or(&effective.font_style)).style.clone())); } } } - for range in ranges { + for (range, replacement_style) in ranges { st.format_chars(range, |format| { format.over.font_family = Some(to_family.clone()); - if let Some(style) = &to_style { - format.over.font_style = Some(style.clone()); - } + format.over.font_style = Some(replacement_style.clone()); }); changed += 1; } @@ -185,9 +206,7 @@ fn replace(s: &mut Session, p: &Value) -> Result { for e in &mut f.entries { if e.family.eq_ignore_ascii_case(&family) && style.as_ref().is_none_or(|s| e.style.eq_ignore_ascii_case(s)) { e.family.clone_from(&to_family); - if let Some(s) = &to_style { - e.style.clone_from(s); - } + e.style = db.face(&to_family, to_style.as_deref().unwrap_or(&e.style)).style.clone(); changed += 1; } } @@ -312,3 +331,33 @@ mod composite_tests { assert_eq!(s.doc().unwrap().doc.styles.composite_fonts[0].entries[1].family, "Missing Note Digits"); } } + +#[cfg(test)] +mod resolution_tests { + use super::*; + + #[test] + fn inventory_distinguishes_missing_styles_and_replacement_chooses_an_available_style() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect":[20,20,300,400],"content":"text","text":"Body"})).unwrap(); + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let sid = *d.stories.keys().next().unwrap(); + d.story_mut(sid).unwrap().format_chars(0..4, |f| { + f.over.font_family = Some("Source Sans 3".into()); + f.over.font_style = Some("Unavailable Style".into()); + }); + let before = s.execute("font.list", &json!({})).unwrap(); + assert_eq!(before[0]["matchStatus"], "styleSubstitute"); + assert_eq!(before[0]["missing"], false); + assert_eq!(before[0]["styleMissing"], true); + assert_eq!(before[0]["resolvedStyle"], "Regular"); + let preflight = s.execute("preflight.run", &json!({})).unwrap(); + assert!(preflight["issues"].as_array().unwrap().iter().any(|i| i["kind"] == "missingFontStyle")); + s.execute("font.replace", &json!({"family":"Source Sans 3","toFamily":"Source Serif 4"})).unwrap(); + let after = s.execute("font.list", &json!({})).unwrap(); + assert_eq!(after[0]["matchStatus"], "exact"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(before, s.execute("font.list", &json!({})).unwrap()); + } +} diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index 03b6412c9..028edd555 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -134,6 +134,16 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { // Use the same effective character-to-font mapping as Find Font, including // anonymous note/cell stories. Unused composite entries are not missing text. for font in super::fonts::used_fonts(d) { + if font.style_missing { + out.push(Issue { + severity: "error", + kind: "missingFontStyle", + message: format!("Missing font style: {} {} (using {} {})", font.family, font.style, font.resolved_family, font.resolved_style), + item: None, + page: None, + }); + } + if font.missing && !missing_fonts.contains(&font.family) { missing_fonts.push(font.family); } diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 4177f941c..169756221 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -121,6 +121,8 @@ pub struct FontFace { em: std::sync::OnceLock<(f64, f64)>, /// The font menu group and native family name, read on first use. group: std::sync::OnceLock<(FontGroup, Option)>, + family_aliases: Vec, + style_aliases: Vec, } /// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of @@ -304,6 +306,7 @@ struct CatalogEntry { /// The font menu group and native family name, read by the scan. group: FontGroup, native: Option, + aliases: Vec, } /// What the scan reads of one face in a font file: its names and font menu group. @@ -314,6 +317,7 @@ struct ScannedFace { style: String, group: FontGroup, native: Option, + aliases: Vec, } /// Process-wide font database: the fonts every document shares (bundled, installed, added), and @@ -492,6 +496,14 @@ static NEXT_ID: AtomicU32 = AtomicU32::new(1); static NEXT_SCOPE: AtomicU32 = AtomicU32::new(1); const OUTLINE_CACHE_MAX: usize = 50_000; +fn aliases(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Vec { + let mut names: Vec = + ids.iter().flat_map(|id| font.localized_strings(*id)).take(256).map(|name| name.to_string()).filter(|name| !name.trim().is_empty()).collect(); + names.sort(); + names.dedup(); + names +} + 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())) } @@ -567,7 +579,7 @@ fn file_face_names(path: &std::path::Path) -> Vec { crate::group::classify(&skrifa::FontRef::new(&font).ok()?, &family) } }; - Some(ScannedFace { family, style, group, native }) + Some(ScannedFace { family, style, group, native, aliases: aliases(&f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME]) }) }) .collect() } @@ -688,6 +700,8 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s .map(|v| v >= 0.5) .or(axis(b"slnt").map(|v| v.abs() > 0.1)) .unwrap_or(!matches!(a.style, skrifa::attribute::Style::Normal) || style_italic(&style)); + let family_aliases = aliases(&f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME]); + let style_aliases = aliases(&f, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]); Some(FontFace { id: NEXT_ID.fetch_add(1, Ordering::Relaxed), family, @@ -708,6 +722,8 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s index, bmp: std::sync::OnceLock::new(), em: std::sync::OnceLock::new(), + family_aliases, + style_aliases, group: std::sync::OnceLock::new(), }) } @@ -916,7 +932,14 @@ impl FontDb { continue; } for f in file_face_names(&p) { - found.push(CatalogEntry { family: f.family, style: f.style, path: p.clone(), group: f.group, native: f.native }); + found.push(CatalogEntry { + family: f.family, + style: f.style, + path: p.clone(), + group: f.group, + native: f.native, + aliases: f.aliases, + }); } } } @@ -1070,6 +1093,7 @@ impl FontDb { self.scoped(0).has_family(family) } + #[cfg(not(target_arch = "wasm32"))] fn is_loaded(&self, family: &str) -> bool { self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family)) } @@ -1185,6 +1209,59 @@ impl ScopedFonts<'_> { self.own.as_deref().unwrap_or(&[]) } + /// Resolve localized and legacy family names without confusing replacement fonts with + /// installed originals. Document-local aliases take precedence over shared fonts. + pub fn canonical_family(&self, family: &str) -> Option { + let matches = + |f: &&Arc| f.family.eq_ignore_ascii_case(family) || f.family_aliases.iter().any(|alias| alias.eq_ignore_ascii_case(family)); + if let Some(face) = self.own().iter().find(matches) { + return Some(face.family.clone()); + } + if let Some(face) = self.db.read_faces().iter().find(matches) { + return Some(face.family.clone()); + } + #[cfg(not(target_arch = "wasm32"))] + if let Some(face) = self + .db + .read_catalog() + .iter() + .find(|f| f.family.eq_ignore_ascii_case(family) || f.aliases.iter().any(|alias| alias.eq_ignore_ascii_case(family))) + { + return Some(face.family.clone()); + } + None + } + + /// Whether the requested style exists, including localized names and axis instances. + pub fn has_style(&self, family: &str, style: &str) -> bool { + if !self.has_family(family) { + return false; + } + let face = self.face(family, style); + norm(&face.style) == norm(style) || (face.coords.is_empty() && face.style_aliases.iter().any(|alias| norm(alias) == norm(style))) + } + + /// Prefer known metric-compatible substitutes, then an appropriate script family. This + /// never changes `has_family`: a usable substitute is still a missing original font. + pub fn substitute_family(&self, family: &str) -> String { + let lower = family.to_lowercase(); + let candidates: &[&str] = match lower.as_str() { + "calibri" | "calibri light" => &["Carlito", "Liberation Sans"], + "cambria" | "cambria math" => &["Caladea", "Liberation Serif"], + "arial" | "helvetica" => &["Liberation Sans", "Arial", "Helvetica"], + "times new roman" | "times" => &["Liberation Serif", "Times New Roman", "Times"], + "courier new" | "courier" => &["Liberation Mono", "Courier New", "Courier"], + _ if lower.contains("arabic") || lower.contains("naskh") => &["Amiri", "Noto Naskh Arabic", "Geeza Pro"], + _ if family.contains('楷') => &["Kaiti SC", "STKaiti", "KaiTi", "Songti SC"], + _ if family.contains("仿宋") => &["STFangsong", "FangSong", "Songti SC"], + _ if family.contains('宋') || family.contains("明朝") => &["Songti SC", "Noto Serif CJK SC", "SimSun"], + _ if family.contains('黑') || family.contains("雅黑") => &["PingFang SC", "Noto Sans CJK SC", "Microsoft YaHei"], + _ if family.chars().any(|c| ('\u{3400}'..='\u{9fff}').contains(&c)) => &["Songti SC", "Noto Serif CJK SC", "PingFang SC"], + _ => &[], + }; + candidates.iter().find_map(|name| self.canonical_family(name)).unwrap_or_else(|| FALLBACK_FAMILY.to_string()) + } + /// Family names available (the document's, loaded and cataloged system fonts), sorted and /// deduplicated. pub fn families(&self) -> Vec { @@ -1218,6 +1295,8 @@ impl ScopedFonts<'_> { /// Style names available for `family` (Regular first, then by weight). pub fn styles(&self, family: &str) -> Vec { + let canonical = self.canonical_family(family); + let family = canonical.as_deref().unwrap_or(family); let mut v: Vec<(bool, f32, String)> = self .own() .iter() @@ -1239,6 +1318,8 @@ impl ScopedFonts<'_> { /// Resolve a family + style to a face, falling back to the closest style of the family, then to /// Source Sans 3 Regular. Installed system fonts are found by name whatever ran before. pub fn face(&self, family: &str, style: &str) -> Arc { + let canonical = self.canonical_family(family).unwrap_or_else(|| self.substitute_family(family)); + let family = canonical.as_str(); if let Some(f) = self.find(family, style) { return f; } @@ -1256,14 +1337,7 @@ impl ScopedFonts<'_> { /// Is `family` available (the document's, loaded, or installed on the system)? pub fn has_family(&self, family: &str) -> bool { - if self.own().iter().any(|f| f.family.eq_ignore_ascii_case(family)) || self.db.is_loaded(family) { - return true; - } - #[cfg(not(target_arch = "wasm32"))] - if self.db.read_catalog().iter().any(|c| c.family.eq_ignore_ascii_case(family)) { - return true; - } - false + self.canonical_family(family).is_some() } /// A variable font's axes: (tag, name, min, default, max), empty for static fonts. @@ -1327,12 +1401,14 @@ impl ScopedFonts<'_> { let faces = self.db.read_faces(); // The document's fonts first: they stand in for other fonts of the same name. let named = |f: &&Arc| f.family.eq_ignore_ascii_case(family); - let cands: Vec<&Arc> = self.own().iter().filter(named).chain(faces.iter().filter(named)).collect(); + let own: Vec<&Arc> = self.own().iter().filter(named).collect(); + let cands: Vec<&Arc> = if own.is_empty() { faces.iter().filter(named).collect() } else { own }; if cands.is_empty() { return None; } let ns = norm(style); - if let Some(f) = cands.iter().find(|f| norm(&f.style) == ns) { + if let Some(f) = cands.iter().find(|f| norm(&f.style) == ns || (f.coords.is_empty() && f.style_aliases.iter().any(|alias| norm(alias) == ns))) + { return Some((*f).clone()); } let (tw, ti) = (style_weight(style), style_italic(style)); diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 8f901e96d..01b8e3bc4 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -530,3 +530,36 @@ mod tests { } } } + +#[cfg(test)] +mod resolution_tests { + use super::*; + + #[test] + fn localized_family_and_style_names_resolve_without_false_missing_status() { + let bytes = testing::font_with("Alias Face", &['A']).unwrap(); + let bytes = testing::with_names( + &bytes, + &[(3, 1, 0x409, 1, "Alias Face"), (3, 1, 0x804, 1, "别名字体"), (3, 1, 0x409, 2, "Regular"), (3, 1, 0x804, 2, "常规")], + ) + .unwrap(); + let db = FontDb::with_font_dirs(Vec::new()); + db.add_font(bytes); + let scope = db.scoped(0); + assert!(scope.has_family("别名字体")); + assert_eq!(scope.face("别名字体", "常规").family, "Alias Face"); + assert!(scope.has_style("别名字体", "常规")); + assert!(!scope.has_style("别名字体", "Unreal Style")); + assert_eq!(scope.styles("别名字体"), ["Regular"]); + } + + #[test] + fn known_substitution_preserves_missing_original_status() { + let db = FontDb::with_font_dirs(Vec::new()); + db.add_font(testing::font_with("Carlito", &['A']).unwrap()); + let scope = db.scoped(0); + assert_eq!(scope.substitute_family("Calibri"), "Carlito"); + assert!(scope.canonical_family("An Unavailable Original Family").is_none()); + assert_eq!(scope.face("An Unavailable Original Family", "Regular").family, FALLBACK_FAMILY); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f4334e77e..7545d44c2 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2652,6 +2652,23 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } } }); + let key = format!("{}\n{}", d.s("family"), d.s("style")); + if let Some(font) = fonts.iter().find(|f| f["family"] == d.s("family") && f["style"] == d.s("style")) { + if d.s("_fontKey") != key { + d.fields.insert("_fontKey".into(), json!(key)); + d.fields.insert("toFamily".into(), font["resolvedFamily"].clone()); + d.fields.insert("toStyle".into(), font["resolvedStyle"].clone()); + } + crate::rtl::label( + ui, + format!( + "{}: {} {}", + crate::i18n::tr(&app.ui.language, "Currently Rendered With"), + font["resolvedFamily"].as_str().unwrap_or(""), + font["resolvedStyle"].as_str().unwrap_or("") + ), + ); + } ui.add_space(8.0); crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Replace With")).font(semibold(12.0))); let db = crate::panels::fonts(app);