mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-02 06:36:51 -06:00
FONT6: unify localized font matching and replacement recommendations
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
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<Value> {
|
||||
@@ -84,14 +87,31 @@ pub(super) fn used_fonts(d: &Document) -> Vec<UsedFont> {
|
||||
.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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +134,16 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
|
||||
// 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);
|
||||
}
|
||||
|
||||
+88
-12
@@ -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<String>)>,
|
||||
family_aliases: Vec<String>,
|
||||
style_aliases: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
aliases: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
aliases: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
let mut names: Vec<String> =
|
||||
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<String> {
|
||||
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<ScannedFace> {
|
||||
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<String> {
|
||||
let matches =
|
||||
|f: &&Arc<FontFace>| 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<String> {
|
||||
@@ -1218,6 +1295,8 @@ impl ScopedFonts<'_> {
|
||||
|
||||
/// Style names available for `family` (Regular first, then by weight).
|
||||
pub fn styles(&self, family: &str) -> Vec<String> {
|
||||
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<FontFace> {
|
||||
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<FontFace>| f.family.eq_ignore_ascii_case(family);
|
||||
let cands: Vec<&Arc<FontFace>> = self.own().iter().filter(named).chain(faces.iter().filter(named)).collect();
|
||||
let own: Vec<&Arc<FontFace>> = self.own().iter().filter(named).collect();
|
||||
let cands: Vec<&Arc<FontFace>> = 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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user