From 2dbbf989002cb2254d4f07c1677967d9d1b796c1 Mon Sep 17 00:00:00 2001 From: Shao Wenbin Date: Thu, 8 Oct 2026 18:21:10 +0800 Subject: [PATCH 1/7] FONT1: preserve composite font references and unify usage checks Resolve imported composite fonts by resource identity and preserve literal display names. Export AppliedFont as an object reference instead of registering a composite definition as an ordinary font family. Share composite lookup and typed font usage between composition, font inventory, and preflight. Include nested cells and notes, and report only component fonts selected by the document's characters. Add synthetic coverage for reference round trips, footnote character mapping and metrics, nested font inventory, component replacement, and undo. --- ROADMAP.md | 1 + crates/compose/src/shape.rs | 3 +- crates/compose/src/tests.rs | 39 +++++++++++ crates/doc/src/styles.rs | 6 ++ crates/engine/src/cmd/fonts.rs | 107 ++++++++++++++++++++++++----- crates/engine/src/cmd/preflight.rs | 19 ++--- crates/idml/src/export.rs | 10 ++- crates/idml/src/import.rs | 11 ++- crates/idml/src/tests.rs | 37 ++++++++++ 9 files changed, 197 insertions(+), 36 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index aa4c17396..b301f41c0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,6 +3,7 @@ - AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it. - AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md. +- FONT1: composite-font references resolve by resource identity, retain literal display names, and export as object references. Font inventory and preflight share the character-selected component fonts, including nested notes and cells. - CJK1: IDML composite fonts now retain character mappings and metrics; explicit aki, tsume, jidori, em alignment/leading, named/custom kinsoku boundaries, hanging punctuation, tate-chu-yoko offsets and kenten symbols flow through composition and IDML export. Automatic kerning no longer imports inactive numeric values as enormous manual spacing. Composite definitions are editable through commands with undo/cache invalidation. Mojikumi definitions are retained and unsupported spacing-table / push-in-push-out policies are reported by preflight. Full InDesign CJK composition parity, frame-grid layout and detailed ruby/warichu remain unfinished. DesignCraft aims at full Adobe InDesign parity — and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index 41583b0a9..fa38901dd 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -234,8 +234,7 @@ pub(crate) fn shape_para( None => para_chars, }; let props = styles.resolve_char(para_chars, fmt); - let family = props.font_family.trim_start_matches("CompositeFont/"); - if let Some(font) = styles.composite_fonts.iter().find(|f| f.name == family) { + if let Some(font) = styles.composite_font(&props.font_family) { let mut start = a; let mut selected = None; for (offset, c) in story.text.get(a..b).unwrap_or("").char_indices() { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 549e7bccf..fe779d053 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1877,3 +1877,42 @@ fn vertical_lines_fit_the_em_box() { assert!((l.baseline - col.y0 - ascent).abs() < 1e-9, "{family}: {} {}", l.baseline, col.y0); } } + +#[test] +fn composite_metrics_and_character_mappings_apply_inside_footnotes() { + use designcraft_doc::cjk::{CompositeFont, CompositeFontEntry}; + let (mut d, sid, _) = doc_with("Body", Rect::new(0.0, 0.0, 300.0, 300.0), ParaAttrs::default()); + d.styles_mut().composite_fonts.push(CompositeFont { + name: "Mixed".into(), + entries: vec![ + CompositeFontEntry { family: "Source Serif 4".into(), ..Default::default() }, + CompositeFontEntry { + characters: "12".into(), + family: "Source Sans 3".into(), + relative_size: 0.8, + horizontal_scale: 0.9, + vertical_scale: 1.1, + baseline_shift: 0.1, + scale_option: false, + ..Default::default() + }, + CompositeFontEntry { characters: "2".into(), family: "Inter".into(), ..Default::default() }, + ], + }); + let story = d.story_mut(sid).unwrap(); + story.insert_note(4, "A12", ParaFormat::default()); + let note = Arc::make_mut(&mut story.notes[0]); + note.text.format_chars(0..3, |f| f.over.font_family = Some("CompositeFont/Mixed".into())); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let note = &cs.frames[0].notes[0]; + let glyphs = ¬e.text.frames[0].lines[0].glyphs; + let a = glyphs.iter().find(|g| g.byte == 0 && g.len > 0).unwrap(); + let one = glyphs.iter().find(|g| g.byte == 1 && g.len > 0).unwrap(); + let two = glyphs.iter().find(|g| g.byte == 2 && g.len > 0).unwrap(); + assert_eq!(a.face.family, "Source Serif 4"); + assert_eq!(one.face.family, "Source Sans 3"); + assert_eq!(two.face.family, "Inter"); + let style = ¬e.text.styles[one.style as usize]; + assert!((style.size - 9.6).abs() < 1e-8); + assert!(!note.text.styles.iter().any(|s| s.missing_font)); +} diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 478cf0452..d6cd3cbfb 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -94,6 +94,12 @@ pub struct ExportTag { } impl Styles { + /// Resolve either a display name or a normalized IDML composite-font reference. + pub fn composite_font(&self, family: &str) -> Option<&crate::cjk::CompositeFont> { + let name = family.strip_prefix("CompositeFont/").unwrap_or(family); + self.composite_fonts.iter().find(|f| f.name == name) + } + /// Export tagging of a paragraph (`character` false) or character style. pub fn export_tag(&self, name: &str, character: bool) -> Option<&ExportTag> { self.export_tags.get(&format!("{}:{name}", if character { "c" } else { "p" })) diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index ef7eb9313..ebd60e988 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -30,20 +30,31 @@ pub fn specs() -> Vec { /// Every story's text, including table cells and footnotes. fn for_each_story(d: &Document, f: &mut dyn FnMut(&Story)) { - for st in d.stories.values() { + let mut pending: Vec<&Story> = d.stories.values().map(AsRef::as_ref).collect(); + while let Some(st) = pending.pop() { f(st); - for t in st.tables.values() { - for c in &t.cells { - f(&c.text); - } - } - for n in &st.notes { - f(&n.text); - } + pending.extend(st.tables.values().flat_map(|t| t.cells.iter().map(|c| &c.text))); + pending.extend(st.notes.iter().chain(&st.endnotes).map(|n| &n.text)); } } +/// Effective document font use shared by the command layer and preflight. +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct UsedFont { + pub family: String, + pub style: String, + pub characters: usize, + pub missing: bool, + pub style_missing: bool, + pub source: Option<&'static str>, +} + fn list(d: &Document) -> Vec { + used_fonts(d).into_iter().map(|font| json!(font)).collect() +} + +pub(super) fn used_fonts(d: &Document) -> Vec { let db = designcraft_fonts::FontDb::global().scoped(d.font_scope); let mut used: BTreeMap<(String, String), usize> = BTreeMap::new(); for_each_story(d, &mut |st| { @@ -56,7 +67,7 @@ fn list(d: &Document) -> Vec { continue; } let p = d.styles.resolve_char(&base, fmt); - if let Some(f) = d.styles.composite_fonts.iter().find(|f| f.name == p.font_family.trim_start_matches("CompositeFont/")) { + if let Some(f) = d.styles.composite_font(&p.font_family) { let text = st.text.get(rr.start.max(r.start)..rr.end.min(r.end).max(rr.start.max(r.start))).unwrap_or(""); for c in text.chars() { if let Some(e) = f.entry(c) { @@ -69,7 +80,7 @@ fn list(d: &Document) -> Vec { } } }); - let mut out: Vec<(bool, Value)> = used + let mut out: Vec = used .into_iter() .map(|((family, style), n)| { let missing = !db.has_family(&family); @@ -80,14 +91,11 @@ fn list(d: &Document) -> Vec { FontSource::Document(_) => "document", FontSource::Memory => "added", }); - ( - missing || style_missing, - json!({"family": family, "style": style, "characters": n, "missing": missing, "styleMissing": style_missing, "source": source}), - ) + UsedFont { family, style, characters: n, missing, style_missing, source } }) .collect(); - out.sort_by_key(|(m, _)| !*m); - out.into_iter().map(|(_, v)| v).collect() + out.sort_by_key(|font| !(font.missing || font.style_missing)); + out } /// Replace the font in a sparse attribute set (only where the family is set); true when it changed. @@ -191,3 +199,68 @@ mod tests { assert_eq!(s.doc().unwrap().doc.styles.para("Odd").unwrap().chars.font_family.as_deref(), Some(avail.as_str())); } } + +#[cfg(test)] +mod composite_tests { + use super::*; + use designcraft_doc::{ + ParaFormat, + cjk::{CompositeFont, CompositeFontEntry}, + }; + + #[test] + fn inventory_resolves_composites_in_nested_cell_notes() { + use designcraft_doc::{StoryId, Table, build::NewDocument}; + let mut d = Document::new(&NewDocument::default()); + d.styles_mut().composite_fonts.push(CompositeFont { + name: "Mixed".into(), + entries: vec![ + CompositeFontEntry { family: "Source Serif 4".into(), ..Default::default() }, + CompositeFontEntry { characters: "1".into(), family: "Source Sans 3".into(), ..Default::default() }, + ], + }); + let mut story = Story::new(StoryId(d.alloc())); + let mut table = Table::new(1, 1, 1, 0, 0, 100.0); + let cell = &mut table.cells[0].text; + cell.insert_note(0, "1", ParaFormat::default()); + Arc::make_mut(&mut cell.notes[0]).text.format_chars(0..1, |f| f.over.font_family = Some("Mixed".into())); + story.insert_table(0, table); + d.stories.insert(story.id, Arc::new(story)); + let fonts = list(&d); + assert!(fonts.iter().any(|f| f["family"] == "Source Sans 3" && f["characters"] == 1)); + assert!(!fonts.iter().any(|f| f["family"] == "Mixed")); + } + + #[test] + fn preflight_and_font_list_follow_used_composite_entries_in_notes() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [20,20,400,600], "content":"text", "text":"Body"})).unwrap(); + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + d.styles_mut().composite_fonts.push(CompositeFont { + name: "Mixed".into(), + entries: vec![ + CompositeFontEntry { family: "Source Serif 4".into(), ..Default::default() }, + CompositeFontEntry { characters: "1".into(), family: "Missing Note Digits".into(), ..Default::default() }, + CompositeFontEntry { characters: "2".into(), family: "Unused Missing Digits".into(), ..Default::default() }, + ], + }); + let sid = *d.stories.keys().next().unwrap(); + let st = d.story_mut(sid).unwrap(); + st.insert_note(4, "A1", ParaFormat::default()); + Arc::make_mut(&mut st.notes[0]).text.format_chars(0..2, |f| f.over.font_family = Some("CompositeFont/Mixed".into())); + let list = s.execute("font.list", &json!({})).unwrap(); + assert!(list.as_array().unwrap().iter().any(|f| f["family"] == "Missing Note Digits" && f["characters"] == 1)); + assert!(!list.to_string().contains("Unused Missing Digits")); + let issues = s.execute("preflight.run", &json!({})).unwrap(); + let fonts: Vec<_> = issues["issues"].as_array().unwrap().iter().filter(|i| i["kind"] == "missingFont").collect(); + assert_eq!(fonts.len(), 1, "{issues}"); + assert!(fonts[0]["message"].as_str().unwrap().contains("Missing Note Digits")); + s.execute("font.replace", &json!({"family":"Missing Note Digits", "toFamily":"Source Sans 3", "toStyle":"Regular"})).unwrap(); + let issues = s.execute("preflight.run", &json!({})).unwrap(); + assert!(issues["issues"].as_array().unwrap().iter().all(|i| i["kind"] != "missingFont")); + assert_eq!(s.doc().unwrap().doc.styles.composite_fonts[0].entries[1].characters, "1"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.styles.composite_fonts[0].entries[1].family, "Missing Note Digits"); + } +} diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index 3fc061d7d..7e603206d 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -76,7 +76,6 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { let d = &st.doc; let mut out = Vec::new(); // Stories: overset and fonts. - let db = designcraft_fonts::FontDb::global().scoped(d.font_scope); let mut missing_fonts: Vec = Vec::new(); let mut unsupported_typography = std::collections::BTreeSet::new(); let mut missing: Vec<(FaceRef, Vec)> = Vec::new(); @@ -113,7 +112,6 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { unsupported_typography .insert(format!("Kinsoku priority `{}` is preserved, but push-in/push-out priority is not applied", para.kinsoku_type)); } - let mut fams = vec![base.font_family.clone()]; for (run, f) in story.runs().filter(|(run, _)| run.start < range.end && run.end > range.start) { let props = d.styles.resolve_char(&base, f); if has_rtl @@ -130,19 +128,16 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { unsupported_typography.insert(format!("Unknown positional form `{}` is preserved but not applied", props.positional_form)); } } - fams.push(props.font_family); - } - for fam in fams { - let composite = d.styles.composite_fonts.iter().find(|f| f.name == fam.trim_start_matches("CompositeFont/")); - let families: Vec<&str> = composite.map_or_else(|| vec![fam.as_str()], |f| f.entries.iter().map(|e| e.family.as_str()).collect()); - for family in families { - if !db.has_family(family) && !missing_fonts.iter().any(|f| f == family) { - missing_fonts.push(family.to_string()); - } - } } } } + // 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.missing && !missing_fonts.contains(&font.family) { + missing_fonts.push(font.family); + } + } for f in missing_fonts { out.push(Issue { severity: "error", kind: "missingFont", message: format!("Missing font: {f}"), item: None, page: None }); } diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 919991eb0..8dd965473 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1021,9 +1021,13 @@ impl<'a> Ex<'a> { el.set("CharacterAlignment", crate::cjk::alignment_out(v)); } if let Some(f) = &a.font_family { - props.push(p("AppliedFont", "string", f.clone())); - let style = a.font_style.clone().unwrap_or_else(|| "Regular".into()); - self.note_font(f, &style); + if let Some(composite) = self.d.styles.composite_font(f) { + props.push(p("AppliedFont", "object", format!("CompositeFont/{}", escape_id(&composite.name)))); + } else { + props.push(p("AppliedFont", "string", f.clone())); + let style = a.font_style.clone().unwrap_or_else(|| "Regular".into()); + self.note_font(f, &style); + } } if let Some(v) = &a.font_style { el.set("FontStyle", v); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 3e1c17e28..ba1f3d245 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -104,6 +104,7 @@ struct Importer<'r> { inks: designcraft_doc::InkManager, stroke_styles: Vec, styles: Styles, + composite_names: HashMap, kinsoku: HashMap, para_names: HashMap, char_names: HashMap, @@ -188,6 +189,7 @@ impl<'r> Importer<'r> { inks: Default::default(), stroke_styles: Vec::new(), styles, + composite_names: HashMap::new(), kinsoku: HashMap::new(), para_names: HashMap::new(), char_names: HashMap::new(), @@ -273,6 +275,11 @@ impl<'r> Importer<'r> { } } for e in top.iter().filter(|e| e.local() == "CompositeFont") { + let name = + e.get("Name").map(str::to_string).unwrap_or_else(|| unescape_id(e.get("Self").unwrap_or("").trim_start_matches("CompositeFont/"))); + if let Some(id) = e.get("Self") { + self.composite_names.insert(id.to_string(), format!("CompositeFont/{name}")); + } let entries = e .find_all("CompositeFontEntry") .map(|c| designcraft_doc::cjk::CompositeFontEntry { @@ -287,7 +294,7 @@ impl<'r> Importer<'r> { scale_option: c.boolean("ScaleOption").unwrap_or(true), }) .collect(); - self.styles.composite_fonts.push(designcraft_doc::cjk::CompositeFont { name: unescape_id(e.get("Name").unwrap_or("")), entries }); + self.styles.composite_fonts.push(designcraft_doc::cjk::CompositeFont { name, entries }); } for e in top.iter().filter(|e| e.local() == "KinsokuTable") { if e.get("CantBeginLineChars").is_some() || e.get("CantEndLineChars").is_some() { @@ -1050,7 +1057,7 @@ impl<'r> Importer<'r> { // Some writers append the style after a tab. let fam = f.split('\t').next().unwrap_or(f); if !fam.is_empty() && fam != "$ID/" { - a.font_family = Some(fam.to_string()); + a.font_family = Some(self.composite_names.get(fam).cloned().unwrap_or_else(|| fam.to_string())); } } a.font_style = e.prop("FontStyle"); diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index b636a2ac1..956a5f19f 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -857,3 +857,40 @@ fn arabic_story_and_table_directions_survive_idml_export() { assert_eq!(st.direction, designcraft_doc::TextDirection::RightToLeft); assert_eq!(st.tables.values().next().unwrap().options.direction, designcraft_doc::TextDirection::RightToLeft); } + +#[test] +fn composite_references_resolve_by_identity_and_export_as_objects() { + let map = DESIGNMAP.replace("", r#" + + Source Serif 4 + Source Sans 3 + "#); + let styles = STYLES + .replace("Source Serif 4", "CompositeFont/opaque%3a17"); + let story = r#"A1B2"#; + let bytes = zip_files(&[ + ("designmap.xml", &map), + ("Resources/Styles.xml", &styles), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Preferences.xml", PREFS), + ("MasterSpreads/MasterSpread_m1.xml", MASTER), + ("Spreads/Spread_sp1.xml", SPREAD), + ("Stories/Story_s1.xml", story), + ]); + let d = import_idml(&bytes).unwrap(); + let f = &d.styles.composite_fonts[0]; + assert_eq!(f.name, "Mixed%3a literal", "display names are not encoded identifiers"); + let body = d.stories.values().find(|s| !s.notes.is_empty()).unwrap(); + for text in [body.as_ref(), &body.notes[0].text] { + let (_, base) = d.styles.resolve_para(&text.paras[0]); + let p = d.styles.resolve_char(&base, &text.chars[0].format); + assert_eq!(d.styles.composite_font(&p.font_family), Some(f)); + } + let output = export_idml(&d); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&output)).unwrap(); + let mut xml = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("Resources/Styles.xml").unwrap(), &mut xml).unwrap(); + assert!(xml.contains("CompositeFont/Mixed%253a literal"), "{xml}"); + let back = import_idml(&output).unwrap(); + assert_eq!(back.styles.composite_fonts, d.styles.composite_fonts); +} From 6c18ccdc3bd1bf26baca94ec360e13768e743656 Mon Sep 17 00:00:00 2001 From: Shao Wenbin Date: Thu, 8 Oct 2026 18:33:42 +0800 Subject: [PATCH 2/7] FONT2: resolve inherited fonts during document-wide replacement --- ROADMAP.md | 1 + crates/engine/src/cmd/fonts.rs | 100 ++++++++++++++++++++++++--------- 2 files changed, 75 insertions(+), 26 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b301f41c0..b682b0328 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,6 +3,7 @@ - AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it. - AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md. +- FONT2: font replacements validate available targets and resolve inherited formatting in nested text and empty paragraphs, with undo and composite component preservation. - FONT1: composite-font references resolve by resource identity, retain literal display names, and export as object references. Font inventory and preflight share the character-selected component fonts, including nested notes and cells. - CJK1: IDML composite fonts now retain character mappings and metrics; explicit aki, tsume, jidori, em alignment/leading, named/custom kinsoku boundaries, hanging punctuation, tate-chu-yoko offsets and kenten symbols flow through composition and IDML export. Automatic kerning no longer imports inactive numeric values as enormous manual spacing. Composite definitions are editable through commands with undo/cache invalidation. Mojikumi definitions are retained and unsupported spacing-table / push-in-push-out policies are reported by preflight. Full InDesign CJK composition parity, frame-grid layout and detailed ruby/warichu remain unfinished. diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index ebd60e988..7c59885b2 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -117,9 +117,62 @@ fn replace(s: &mut Session, p: &Value) -> Result { let style = str_param(p, "style").map(str::to_string); let to_family = str_param(p, "toFamily").ok_or_else(|| bad("font.replace", "missing toFamily"))?.to_string(); let to_style = str_param(p, "toStyle").map(str::to_string); + let db = designcraft_fonts::FontDb::global().scoped(s.doc()?.doc.font_scope); + if family.trim().is_empty() || to_family.trim().is_empty() || !db.has_family(&to_family) { + 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)) + { + return Err(bad("font.replace", "Choose an available replacement font style")); + } let styles_too = p.get("redefineStyles").and_then(Value::as_bool).unwrap_or(true); s.edit(|d, _| { let mut changed = 0usize; + // Resolve text against the original styles before redefining them. Font family and + // style can be inherited independently through paragraph and character formatting. + let original_styles = d.styles.clone(); + let ids: Vec<_> = d.stories.keys().copied().collect(); + for sid in ids { + let Some(st) = d.story_mut(sid) else { continue }; + let mut pending = vec![st]; + while let Some(st) = pending.pop() { + let mut ranges = Vec::new(); + for (pi, paragraph) in st.para_ranges().iter().enumerate() { + let Some(format) = st.paras.get(pi) else { continue }; + let (_, base) = original_styles.resolve_para(format); + for (run, format) in st.runs() { + let start = run.start.max(paragraph.start); + // An empty paragraph still uses its paragraph-break character's font. + let end = if paragraph.is_empty() && run.contains(¶graph.start) { + st.text.get(start..).and_then(|tail| tail.chars().next()).map_or(start, |c| start.saturating_add(c.len_utf8())) + } else { + run.end.min(paragraph.end) + }; + if start >= end { + continue; + } + let effective = original_styles.resolve_char(&base, format); + 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); + } + } + } + for range 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()); + } + }); + changed += 1; + } + pending.extend(st.tables.values_mut().flat_map(|t| Arc::make_mut(t).cells.iter_mut().map(|c| &mut c.text))); + pending.extend(st.notes.iter_mut().chain(&mut st.endnotes).map(|n| &mut Arc::make_mut(n).text)); + } + } if styles_too { let st = d.styles_mut(); for ps in &mut st.paragraph { @@ -140,32 +193,6 @@ fn replace(s: &mut Session, p: &Value) -> Result { } } } - // Local formatting in every story (cells and footnotes too). - let fix = |st: &mut Story, changed: &mut usize| { - let mut any = false; - for r in &mut st.chars { - if swap(&mut r.format.over, &family, style.as_deref(), &to_family, to_style.as_deref()) { - any = true; - *changed += 1; - } - } - if any { - st.rev += 1; - } - }; - let ids: Vec<_> = d.stories.keys().copied().collect(); - for sid in ids { - let Some(st) = d.story_mut(sid) else { continue }; - fix(st, &mut changed); - for t in st.tables.values_mut() { - for c in &mut Arc::make_mut(t).cells { - fix(&mut c.text, &mut changed); - } - } - for n in &mut st.notes { - fix(&mut Arc::make_mut(n).text, &mut changed); - } - } Ok(json!({"changed": changed})) }) } @@ -174,6 +201,27 @@ fn replace(s: &mut Session, p: &Value) -> Result { mod tests { use super::*; + #[test] + fn replacement_resolves_inherited_family_and_style_and_rejects_missing_targets() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [0,0,200,200], "content":"text", "text":"Inherited\n\nEnd"})).unwrap(); + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let sid = *d.stories.keys().next().unwrap(); + let story = d.story_mut(sid).unwrap(); + for para in &mut story.paras { + para.chars.font_family = Some("Unavailable Family".into()); + } + let before = s.execute("font.list", &json!({})).unwrap(); + let style = before[0]["style"].clone(); + assert!(s.execute("font.replace", &json!({"family":"Unavailable Family", "toFamily":"Also Missing"})).is_err()); + assert_eq!(before, s.execute("font.list", &json!({})).unwrap()); + s.execute("font.replace", &json!({"family":"Unavailable Family", "style":style, "toFamily":"Source Sans 3", "toStyle":"Regular"})).unwrap(); + assert!(s.execute("font.list", &json!({})).unwrap().as_array().unwrap().iter().all(|f| f["missing"] == false)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(before, s.execute("font.list", &json!({})).unwrap()); + } + #[test] fn find_and_replace_font() { let mut s = Session::new(); From 4f1aa8773d224898e9842cb532017e43c5812d40 Mon Sep 17 00:00:00 2001 From: Shao Wenbin Date: Thu, 8 Oct 2026 18:34:32 +0800 Subject: [PATCH 3/7] FONT3: prompt for missing fonts when opening documents --- ROADMAP.md | 1 + crates/ui-egui/src/dialogs.rs | 65 +++++++++++++++++++++++++++++------ crates/ui-egui/src/lib.rs | 52 ++++++++++++++++++++++++++++ crates/ui-egui/src/theme.rs | 29 +++++++++++++++- 4 files changed, 135 insertions(+), 12 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b682b0328..9a319dc24 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,6 +3,7 @@ - AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it. - AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md. +- FONT3: opening documents with unavailable font families or styles automatically offers Find/Replace Font. The dialog supports repeated replacements, refreshed inventory and visible validation errors; native builds without craft-fonts use system CJK fonts for readable names. - FONT2: font replacements validate available targets and resolve inherited formatting in nested text and empty paragraphs, with undo and composite component preservation. - FONT1: composite-font references resolve by resource identity, retain literal display names, and export as object references. Font inventory and preflight share the character-selected component fonts, including nested notes and cells. - CJK1: IDML composite fonts now retain character mappings and metrics; explicit aki, tsume, jidori, em alignment/leading, named/custom kinsoku boundaries, hanging punctuation, tate-chu-yoko offsets and kenten symbols flow through composition and IDML export. Automatic kerning no longer imports inactive numeric values as enormous manual spacing. Composite definitions are editable through commands with undo/cache invalidation. Mojikumi definitions are retained and unsupported spacing-table / push-in-push-out policies are reported by preflight. Full InDesign CJK composition parity, frame-grid layout and detailed ruby/warichu remain unfinished. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 34832db14..f4334e77e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1373,7 +1373,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui .add( - egui::Button::new(crate::rtl::widget(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, " OK ")).color(egui::Color32::WHITE))) + egui::Button::new(crate::rtl::widget(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, if d.id == "findFont" { "Replace All" } else { " OK " })).color(egui::Color32::WHITE))) .fill(crate::theme::Tokens::get(ui.ctx()).accent_strong), ) .clicked() @@ -1381,7 +1381,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { { result = Some(true); } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Cancel"))).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, if d.id == "findFont" { "Done" } else { "Cancel" }))).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { result = Some(false); } }); @@ -1627,11 +1627,19 @@ pub fn confirm(app: &mut DesignApp) -> Result { } "polygonSettings" => app.run("tool.polygonSettings", json!({"sides": d.n("sides").unwrap_or(6.0) as u64, "starInset": d.n("starInset").unwrap_or(0.0)})), "findFont" => { - let (f, st) = (d.s("family"), d.s("style")); - if f.is_empty() || d.s("toFamily").is_empty() { - return Ok(Value::Null); + let result = app.run("font.replace", json!({"family": d.s("family"), "style": d.s("style"), "toFamily": d.s("toFamily"), "toStyle": d.s("toStyle")})); + let mut d = d; + match &result { + Ok(_) => { + d.fields.remove("_fonts"); + d.fields.remove("family"); + d.fields.remove("style"); + d.fields.remove("error"); + } + Err(error) => { d.fields.insert("error".into(), json!(error)); } } - app.run("font.replace", json!({"family": f, "style": st, "toFamily": d.s("toFamily"), "toStyle": d.s("toStyle")})) + app.ui.dialog = Some(d); + result } "insertXref" => { let format = d.s("format"); @@ -2603,6 +2611,24 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { d.fields.insert("_fonts".into(), list); } let fonts = d.fields.get("_fonts").and_then(Value::as_array).cloned().unwrap_or_default(); + if d.s("family").is_empty() + && let Some(font) = fonts.first() + { + d.fields.insert("family".into(), font["family"].clone()); + d.fields.insert("style".into(), font["style"].clone()); + } + if d.b("onOpen") { + crate::rtl::label( + ui, + crate::i18n::tr( + &app.ui.language, + "This document uses unavailable fonts. Choose replacements, or select Done to continue with fallback fonts.", + ), + ); + } + if !d.s("error").is_empty() { + crate::rtl::label(ui, d.s("error")); + } let missing = fonts.iter().filter(|f| f["missing"] == true || f["styleMissing"] == true).count(); crate::rtl::label( ui, @@ -2617,8 +2643,8 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let label = format!("{}{fam} {st}", if warn { "\u{26A0} " } else { "" }); let on = d.s("family") == fam && d.s("style") == st; let mut text = egui::RichText::new(label); - if warn { - text = text.color(egui::Color32::from_rgb(0xe5, 0x4b, 0x4b)); + if warn && !on { + text = text.color(crate::theme::Tokens::get(ui.ctx()).text_strong); } if ui.selectable_label(on, text).clicked() { d.fields.insert("family".into(), json!(fam)); @@ -2630,6 +2656,9 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { 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); let menu = crate::panels::font_menu(app); + if d.s("toFamily").is_empty() { + d.fields.insert("toFamily".into(), json!(designcraft_fonts::FALLBACK_FAMILY)); + } let english = app.session.prefs.show_font_names_in_english; let fam_opts: Vec<(&str, &str)> = menu.iter().map(|f| (f.family.as_str(), f.label(english))).collect(); egui::Grid::new("ff_to").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { @@ -2637,6 +2666,17 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { combo(ui, d, "toFamily", &fam_opts); ui.end_row(); let styles = db.styles(&d.s("toFamily")); + if !styles.iter().any(|style| style == &d.s("toStyle")) { + let preferred = d.s("style"); + let style = styles + .iter() + .find(|s| s.eq_ignore_ascii_case(&preferred)) + .or_else(|| styles.iter().find(|s| s.eq_ignore_ascii_case("Regular"))) + .or_else(|| styles.first()); + if let Some(style) = style { + d.fields.insert("toStyle".into(), json!(style)); + } + } let st_opts: Vec<(&str, &str)> = styles.iter().map(|s| (s.as_str(), s.as_str())).collect(); crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Font Style:")); combo(ui, d, "toStyle", &st_opts); @@ -2644,9 +2684,12 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { }); ui.label(crate::rtl::widget( ui, - egui::RichText::new(crate::i18n::tr(&app.ui.language, "OK changes all: text and paragraph/character styles.")) - .color(crate::theme::Tokens::get(ui.ctx()).text_dim) - .size(11.0), + egui::RichText::new(crate::i18n::tr( + &app.ui.language, + "Replace All updates this font throughout text and styles. Done keeps remaining fallback fonts.", + )) + .color(crate::theme::Tokens::get(ui.ctx()).text_dim) + .size(11.0), )); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7b474fa21..0d7bb8a0b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -499,6 +499,24 @@ impl DesignApp { } let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string()); self.after_engine(); + if r.is_ok() && matches!(id, "file.open" | "file.openIdml" | "file.openBytes") { + if self.ui.dialog.as_ref().is_some_and(|d| d.id == "findFont") { + self.ui.dialog = None; + } + match self.session.execute("font.list", &json!({})) { + Ok(fonts) if fonts.as_array().is_some_and(|fonts| fonts.iter().any(|f| f["missing"] == true || f["styleMissing"] == true)) => { + self.ui.dialog = Some(dialogs::Dialog::new("findFont", json!({"_fonts": fonts, "onOpen": true}))); + } + Err(e) => self.status(e.to_string()), + _ => {} + } + } + if r.is_ok() + && matches!(id, "font.replace" | "edit.undo" | "edit.redo") + && let Some(dialog) = self.ui.dialog.as_mut().filter(|d| d.id == "findFont") + { + dialog.fields.remove("_fonts"); + } if let Err(e) = &r { self.status(e.clone()); } @@ -872,6 +890,40 @@ pub fn now_ms() -> f64 { mod tests { use super::*; + #[test] + fn opening_missing_fonts_offers_repeated_replacement_and_keeps_errors_visible() { + let mut app = DesignApp::new(Session::new(), Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect":[0,0,200,200], "content":"text", "text":"Sample"})).unwrap(); + let d = std::sync::Arc::make_mut(&mut app.session.doc_mut().unwrap().doc); + let sid = *d.stories.keys().next().unwrap(); + d.story_mut(sid).unwrap().format_chars(0..6, |f| { + f.over.font_family = Some("Unavailable Test Family".into()); + f.over.font_style = Some("Regular".into()); + }); + let path = std::env::temp_dir().join(format!("designcraft-font-open-{}.designcraft", std::process::id())); + app.run("file.save", json!({"path":path})).unwrap(); + app.run("file.open", json!({"path":path})).unwrap(); + let dialog = app.ui.dialog.as_mut().unwrap(); + assert_eq!(dialog.id, "findFont"); + assert_eq!(dialog.fields["onOpen"], true); + dialog.fields.insert("family".into(), json!("Unavailable Test Family")); + dialog.fields.insert("style".into(), json!("Regular")); + dialog.fields.insert("toFamily".into(), json!("Unavailable Target")); + dialog.fields.insert("toStyle".into(), json!("Regular")); + assert!(dialogs::confirm(&mut app).is_err()); + assert!(app.ui.dialog.as_ref().unwrap().fields.contains_key("error")); + app.ui.dialog.as_mut().unwrap().fields.insert("toFamily".into(), json!("Source Sans 3")); + dialogs::confirm(&mut app).unwrap(); + assert!(!app.ui.dialog.as_ref().unwrap().fields.contains_key("_fonts")); + assert!(app.run("font.list", json!({})).unwrap().as_array().unwrap().iter().all(|f| f["missing"] == false)); + app.ui.dialog = None; + app.run("file.save", json!({"path":path})).unwrap(); + app.run("file.open", json!({"path":path})).unwrap(); + assert!(app.ui.dialog.is_none()); + std::fs::remove_file(path).unwrap(); + } + #[test] fn snap_view_uses_the_saved_switches() { let mut ui = UiState::default(); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 6d86bb565..948e45c51 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -255,7 +255,34 @@ impl Tokens { /// Install the UI fonts for the interface language `lang` (it orders the CJK fallbacks). pub fn install_fonts(ctx: &egui::Context, lang: &str) { - ctx.set_fonts(font_definitions(designcraft_fonts::CRAFT_FONTS, lang)); + let fonts = font_definitions(designcraft_fonts::CRAFT_FONTS, lang); + #[cfg(not(target_arch = "wasm32"))] + let fonts = { + let mut fonts = fonts; + // Local builds may omit craft-fonts. Use the shared resolver for readable document + // names and font menus without bundling or copying installed fonts into the project. + if designcraft_fonts::CRAFT_FONTS.is_empty() { + let db = designcraft_fonts::FontDb::global(); + for c in ['中', 'あ', '한'] { + if let Some(face) = db.fallback_for(c, u32::MAX, Some(lang)) { + let name = format!("system-ui-{}", face.id()); + if fonts.font_data.contains_key(&name) { + continue; + } + let mut data = FontData::from_owned(face.data().to_vec()); + data.index = face.index(); + fonts.font_data.insert(name.clone(), Arc::new(data)); + for (family, stack) in &mut fonts.families { + if !matches!(family, FontFamily::Name(name) if name.starts_with("arabic")) { + stack.push(name.clone()); + } + } + } + } + } + fonts + }; + ctx.set_fonts(fonts); } /// The UI fonts: the app's own, then the craft-fonts faces from `craft` (empty without From 06ed81e547d5672eb5678551fa9cc00b6d7ade78 Mon Sep 17 00:00:00 2001 From: Shao Wenbin Date: Thu, 8 Oct 2026 18:54:26 +0800 Subject: [PATCH 4/7] FONT4: resolve composite fonts and fallback for generated note labels --- ROADMAP.md | 2 ++ crates/compose/src/lib.rs | 4 +++ crates/compose/src/ruby.rs | 1 + crates/compose/src/shape.rs | 58 +++++++++++++++++++++++++++++++++++-- crates/compose/src/tests.rs | 43 +++++++++++++++++++++++++++ 5 files changed, 106 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9a319dc24..2fc7e107c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # DesignCraft roadmap +- 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. + - AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it. - AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 5a3056d8f..0a67a3186 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -94,6 +94,8 @@ impl RunStyle { /// A positioned glyph. `x` is absolute in frame inner space; `y` is relative to the line baseline. #[derive(Clone, Debug)] pub struct PlacedGlyph { + /// Actual character drawn, including generated labels. + pub rendered_char: char, pub face: designcraft_fonts::FaceRef, pub gid: u32, pub x: f64, @@ -1867,6 +1869,7 @@ fn tab_leader(tab: &Glyph, leader: &str, x: f64, w: f64, origin: f64, out: &mut while at + pw <= end + 0.01 && n < 2000 { for &(gid, adv) in &glyphs { out.push(PlacedGlyph { + rendered_char: '\t', face: tab.face, gid, x: at, @@ -1970,6 +1973,7 @@ fn place(g: &Glyph, x: f64) -> PlacedGlyph { || g.ch == shape::SOFT_HYPHEN || g.ch == shape::HIDDEN); PlacedGlyph { + rendered_char: g.rendered_char, face: g.face, gid: g.gid, x: x + g.dx, diff --git a/crates/compose/src/ruby.rs b/crates/compose/src/ruby.rs index cea4a5545..b39f11eef 100644 --- a/crates/compose/src/ruby.rs +++ b/crates/compose/src/ruby.rs @@ -22,6 +22,7 @@ fn set(base: &PlacedGlyph, text: &str, size: f64, base_size: f64) -> (Vec)> = Vec::new(); + for (at, c) in label.char_indices() { + let entry = composite.entry(c); + if let Some((_, end, _)) = segments.last_mut().filter(|(_, _, previous)| *previous == entry) { + *end = at + c.len_utf8(); + } else { + segments.push((at, at + c.len_utf8(), entry)); + } + } + for (a, b, entry) in segments { + let props = composite_props(db, &rprops, entry); + let style = table.intern(db, &props); + let env = table.env_for(auto_leading, style); + let face = db.face(&props.font_family, &props.font_style); + shape_segment(db, &story.text, i..e, label.get(a..b), &props, &face, env, style, &mut glyphs, sub.vertical); + } + for (index, glyph) in glyphs[begin..].iter_mut().enumerate() { + glyph.len = if index == 0 { e - i } else { 0 }; + glyph.no_break = true; + } + } else { + shape_run(db, &story.text, i..e, &rprops, renv, rstyle, sub, &mut glyphs); + } } k = e; } @@ -778,6 +805,7 @@ fn control_glyph(face: &Arc, p: &CharProps, auto_leading: TypeEnv, sty character_alignment: p.character_alignment, leading_model: p.leading_model, character_direction: p.character_direction, + rendered_char: ch, display_char: ch, bidi_offset: 0, bidi_end: 0, @@ -802,7 +830,32 @@ fn shape_segment( out: &mut Vec, vertical: bool, ) { - let _ = db; + // Generated labels need the same coverage fallback as ordinary text. Keep the + // original marker address and one carrier even when several faces draw the label. + if let Some(label) = replacement.filter(|_| auto_leading.glyph_fallback) { + let lang = designcraft_doc::language_tag(&p.language); + let mut segments: Vec<(usize, usize, Arc)> = Vec::new(); + for (at, c) in label.char_indices() { + let selected = if face.covers(c) { face.clone() } else { db.fallback_for(c, face.id(), lang).unwrap_or_else(|| face.clone()) }; + if let Some((_, end, previous)) = segments.last_mut().filter(|(_, _, previous)| previous.id() == selected.id()) { + *end = at + c.len_utf8(); + let _ = previous; + } else { + segments.push((at, at + c.len_utf8(), selected)); + } + } + if segments.iter().any(|(_, _, selected)| selected.id() != face.id()) { + let begin = out.len(); + for (a, b, selected) in segments { + shape_segment(db, text, range.clone(), label.get(a..b), p, &selected, auto_leading, style, out, vertical); + } + for (i, glyph) in out[begin..].iter_mut().enumerate() { + glyph.len = if i == 0 { range.len() } else { 0 }; + glyph.no_break = true; + } + return; + } + } let (size, k, ascent, descent, leading, cap, xh, shift) = metrics(face, p, auto_leading); let hs = p.h_scale; let tracking = p.tracking / 1000.0 * p.size; @@ -896,6 +949,7 @@ fn shape_segment( character_alignment: p.character_alignment, leading_model: p.leading_model, character_direction: p.character_direction, + rendered_char: src.get(sg.cluster..).and_then(|s| s.chars().next()).unwrap_or(ch), display_char: if replacement.is_some() && matches!(ch, '0'..='9' | '\u{0660}'..='\u{0669}' | '\u{06F0}'..='\u{06F9}') { src.chars().next().unwrap_or(ch) } else { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index fe779d053..860f9284c 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1916,3 +1916,46 @@ fn composite_metrics_and_character_mappings_apply_inside_footnotes() { assert!((style.size - 9.6).abs() < 1e-8); assert!(!note.text.styles.iter().any(|s| s.missing_font)); } + +#[test] +fn generated_note_labels_resolve_composite_components_and_keep_one_anchor() { + use designcraft_doc::cjk::{CompositeFont, CompositeFontEntry}; + let (mut d, sid, _) = doc_with("Body", Rect::new(0.0, 0.0, 300.0, 300.0), ParaAttrs::default()); + d.styles_mut().composite_fonts.push(CompositeFont { + name: "Generated Mixed".into(), + entries: vec![ + CompositeFontEntry { family: "Source Serif 4".into(), ..Default::default() }, + CompositeFontEntry { characters: "[]".into(), family: "Inter".into(), relative_size: 0.8, ..Default::default() }, + ], + }); + d.footnote_options.affix_in = designcraft_doc::notes::AffixIn::Both; + d.footnote_options.prefix = "[".into(); + d.footnote_options.suffix = "]".into(); + let story = d.story_mut(sid).unwrap(); + story.insert_note(4, "Note", ParaFormat::default()); + story.format_chars(4..7, |f| f.over.font_family = Some("Generated Mixed".into())); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let glyphs: Vec<_> = cs.frames[0].lines.iter().flat_map(|l| &l.glyphs).filter(|g| g.byte == 4).collect(); + assert_eq!(glyphs.iter().filter(|g| g.len > 0).count(), 1); + assert_eq!(glyphs.iter().map(|g| g.rendered_char).collect::(), "[1]"); + assert!(glyphs.iter().all(|g| g.gid != 0)); + assert!(glyphs.iter().filter(|g| matches!(g.rendered_char, '[' | ']')).all(|g| g.face.family == "Inter")); + assert_eq!(glyphs.iter().find(|g| g.rendered_char == '1').unwrap().face.family, "Source Serif 4"); + assert_eq!(cs.frames[0].notes.len(), 1); +} + +#[test] +fn generated_note_labels_use_coverage_fallback() { + let font = designcraft_fonts::testing::font_with("Generated Label Coverage", &['\u{E888}']).unwrap(); + designcraft_fonts::FontDb::global().add_font(font); + let (mut d, sid, _) = doc_with("Body", Rect::new(0.0, 0.0, 300.0, 300.0), ParaAttrs::default()); + d.footnote_options.affix_in = designcraft_doc::notes::AffixIn::Both; + d.footnote_options.prefix = "\u{E888}".into(); + d.footnote_options.suffix = "".into(); + d.settings.glyph_fallback = true; + d.story_mut(sid).unwrap().insert_note(4, "Note", ParaFormat::default()); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let glyph = cs.frames[0].lines.iter().flat_map(|l| &l.glyphs).find(|g| g.rendered_char == '\u{E888}').unwrap(); + assert_ne!(glyph.gid, 0); + assert_eq!(glyph.face.family, "Generated Label Coverage"); +} From 39482d1b8c5d68919fbc7bf22f3e8f25b1e6b196 Mon Sep 17 00:00:00 2001 From: Shao Wenbin Date: Thu, 8 Oct 2026 18:56:14 +0800 Subject: [PATCH 5/7] FONT5: diagnose missing glyphs in generated text with frame locations --- ROADMAP.md | 2 + crates/engine/src/cmd/preflight.rs | 65 ++++++++++++++++++++---------- 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2fc7e107c..ae8fe49ad 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # DesignCraft roadmap +- 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. - AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it. diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index 7e603206d..03b6412c9 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -38,35 +38,35 @@ fn page_of(d: &Document, sr: SpreadRef, it: &Item) -> Option { /// Characters a missing-glyph report names before "and N more". const MISSING_GLYPHS_LISTED: usize = 20; -/// Add the characters drawn as their font's missing glyph (.notdef) in `cs`, composed from -/// `text` (table cells and footnotes included), to `out` by face, each once. Text in a missing -/// font is left to the missing-font report; generated text (list labels, page numbers) and -/// markers have no character of their own to name. -fn missing_glyphs(cs: &ComposedStory, text: &str, depth: usize, out: &mut Vec<(FaceRef, Vec)>) { - // Cells and footnotes hold composed stories of their own; tables nest only so deep. +/// Inspect rendered characters, including zero-length glyphs generated by a source marker. +/// Nested notes and cells report the owning page frame, not their synthetic frame zero. +fn missing_glyphs( + cs: &ComposedStory, + owner: Option, + depth: usize, + out: &mut Vec<(FaceRef, Vec, designcraft_doc::ItemId)>, +) { if depth > 16 { return; } for f in &cs.frames { - for g in f.lines.iter().flat_map(|l| &l.glyphs).filter(|g| g.visible && g.gid == 0 && g.len > 0) { - if cs.styles.get(g.style as usize).is_none_or(|st| st.missing_font) { - continue; - } - let Some(c) = text.get(g.byte..).and_then(|t| t.chars().next()) else { continue }; + let owner = owner.unwrap_or(f.frame); + for g in f.lines.iter().flat_map(|l| &l.glyphs).filter(|g| g.visible && g.gid == 0) { + let c = g.rendered_char; if c.is_control() || c.is_whitespace() || ('\u{E000}'..='\u{E1FF}').contains(&c) { continue; } - match out.iter_mut().find(|(face, _)| *face == g.face) { - Some((_, chars)) if !chars.contains(&c) => chars.push(c), + match out.iter_mut().find(|(face, _, item)| *face == g.face && *item == owner) { + Some((_, chars, _)) if !chars.contains(&c) => chars.push(c), Some(_) => {} - None => out.push((g.face, vec![c])), + None => out.push((g.face, vec![c], owner)), } } for c in f.tables.iter().flat_map(|t| &t.cells) { - missing_glyphs(&c.text, &c.source, depth + 1, out); + missing_glyphs(&c.text, Some(owner), depth + 1, out); } for n in &f.notes { - missing_glyphs(&n.text, &n.source, depth + 1, out); + missing_glyphs(&n.text, Some(owner), depth + 1, out); } } } @@ -78,10 +78,10 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { // Stories: overset and fonts. let mut missing_fonts: Vec = Vec::new(); let mut unsupported_typography = std::collections::BTreeSet::new(); - let mut missing: Vec<(FaceRef, Vec)> = Vec::new(); + let mut missing: Vec<(FaceRef, Vec, designcraft_doc::ItemId)> = Vec::new(); for story in d.stories.values() { let cs = s.cache.get(d, story.id, None); - missing_glyphs(&cs, &story.text, 0, &mut missing); + missing_glyphs(&cs, None, 0, &mut missing); if cs.is_overset() { let last = story.frames.last().copied(); let page = last.and_then(|f| d.find(f).zip(d.item(f))).and_then(|(loc, it)| page_of(d, loc.spread, it)); @@ -144,7 +144,7 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { for message in unsupported_typography { out.push(Issue { severity: "warning", kind: "unsupportedTypography", message, item: None, page: None }); } - for (face, chars) in missing { + for (face, chars, item) in missing { let listed: Vec = chars.iter().take(MISSING_GLYPHS_LISTED).map(char::to_string).collect(); let more = chars.len().saturating_sub(MISSING_GLYPHS_LISTED); let more = if more > 0 { format!(" and {more} more") } else { String::new() }; @@ -152,8 +152,8 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { severity: "error", kind: "missingGlyphs", message: format!("Missing glyphs in {} {}: {}{more}", face.family, face.style, listed.join(" ")), - item: None, - page: None, + item: Some(item.0), + page: d.find(item).zip(d.item(item)).and_then(|(loc, it)| page_of(d, loc.spread, it)), }); } // Items. @@ -317,3 +317,26 @@ mod missing_glyph_tests { assert!(found[0].1.ends_with(" and 10 more"), "{}", found[0].1); } } + +#[cfg(test)] +mod generated_glyph_tests { + use super::*; + use std::sync::Arc; + + #[test] + fn generated_suffix_missing_glyph_is_reported_at_its_page_frame() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let created = 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.footnote_options.affix_in = designcraft_doc::notes::AffixIn::Reference; + d.footnote_options.suffix = "\u{10fffd}".into(); + d.story_mut(sid).unwrap().insert_note(4, "Note", designcraft_doc::ParaFormat::default()); + let report = s.execute("preflight.run", &json!({})).unwrap(); + let issue = report["issues"].as_array().unwrap().iter().find(|i| i["kind"] == "missingGlyphs").unwrap(); + assert!(issue["message"].as_str().unwrap().contains('\u{10fffd}')); + assert_eq!(issue["item"], created["id"]); + assert_eq!(issue["page"], 0); + } +} From f36636b95f796f1b94eef16e3e1f37878cafad03 Mon Sep 17 00:00:00 2001 From: Shao Wenbin Date: Thu, 8 Oct 2026 19:02:39 +0800 Subject: [PATCH 6/7] FONT6: unify localized font matching and replacement recommendations --- ROADMAP.md | 2 + crates/engine/src/cmd/fonts.rs | 73 +++++++++++++++++---- crates/engine/src/cmd/preflight.rs | 10 +++ crates/fonts/src/fontdb.rs | 100 +++++++++++++++++++++++++---- crates/fonts/src/lib.rs | 33 ++++++++++ crates/ui-egui/src/dialogs.rs | 17 +++++ 6 files changed, 211 insertions(+), 24 deletions(-) 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); From af43dd88c9fbd77c07309a24ee4a9d6345dc726a Mon Sep 17 00:00:00 2001 From: Shao Wenbin Date: Thu, 8 Oct 2026 19:12:11 +0800 Subject: [PATCH 7/7] FONT7: validate replacement coverage and reject placeholder font fallbacks --- ROADMAP.md | 2 ++ crates/engine/src/cmd/fonts.rs | 23 +++++++++++---- crates/engine/src/cmd/preflight.rs | 2 +- crates/fonts/src/fontdb.rs | 46 +++++++++++++++++++++++++++--- crates/fonts/src/lib.rs | 26 +++++++++++++++++ crates/ui-egui/src/dialogs.rs | 8 +++--- 6 files changed, 93 insertions(+), 14 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 70d566d90..d9ae42fe9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # DesignCraft roadmap +- FONT7: replacement recommendations check the characters actually used; placeholder LastResort fonts and glyph-zero mappings cannot masquerade as glyph coverage or suppress missing-glyph diagnostics. + - 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. diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 111444251..83acd685b 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), matchStatus: exact|styleSubstitute|missing, resolvedFamily, resolvedStyle}] (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, replacementFamily, replacementStyle}] (missing first)", has_doc, |s, _| { Ok(Value::Array(list(&s.doc()?.doc))) }), cmd!( @@ -51,6 +51,8 @@ pub(super) struct UsedFont { pub match_status: &'static str, pub resolved_family: String, pub resolved_style: String, + pub replacement_family: String, + pub replacement_style: String, } fn list(d: &Document) -> Vec { @@ -59,7 +61,7 @@ fn list(d: &Document) -> Vec { pub(super) fn used_fonts(d: &Document) -> Vec { let db = designcraft_fonts::FontDb::global().scoped(d.font_scope); - let mut used: BTreeMap<(String, String), usize> = BTreeMap::new(); + let mut used: BTreeMap<(String, String), (usize, std::collections::BTreeSet)> = BTreeMap::new(); for_each_story(d, &mut |st| { let ranges = st.para_ranges(); for (pi, r) in ranges.iter().enumerate() { @@ -74,21 +76,27 @@ pub(super) fn used_fonts(d: &Document) -> Vec { let text = st.text.get(rr.start.max(r.start)..rr.end.min(r.end).max(rr.start.max(r.start))).unwrap_or(""); for c in text.chars() { if let Some(e) = f.entry(c) { - *used.entry((e.family.clone(), e.style.clone())).or_default() += 1; + let entry = used.entry((e.family.clone(), e.style.clone())).or_default(); + entry.0 += 1; + entry.1.insert(c); } } } else { - *used.entry((p.font_family, p.font_style)).or_default() += n; + let entry = used.entry((p.font_family, p.font_style)).or_default(); + entry.0 += n; + entry.1.extend(st.text.get(rr.start.max(r.start)..rr.end.min(r.end).max(rr.start.max(r.start))).unwrap_or("").chars()); } } } }); let mut out: Vec = used .into_iter() - .map(|((family, style), n)| { + .map(|((family, style), (n, characters))| { let missing = !db.has_family(&family); let style_missing = !missing && !db.has_style(&family, &style); let resolved = db.face(&family, &style); + let characters: Vec = characters.into_iter().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).collect(); + let replacement = db.replacement_face(&family, &style, &characters); let source = (!missing).then(|| match db.face(&family, &style).source { FontSource::Bundled => "bundled", FontSource::Installed(_) => "installed", @@ -111,6 +119,8 @@ pub(super) fn used_fonts(d: &Document) -> Vec { }, resolved_family: resolved.family.clone(), resolved_style: resolved.style.clone(), + replacement_family: replacement.family.clone(), + replacement_style: replacement.style.clone(), } }) .collect(); @@ -147,6 +157,9 @@ fn replace(s: &mut Session, p: &Value) -> Result { 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")); + if resolved.is_placeholder() { + return Err(bad("font.replace", "A placeholder font cannot replace document text")); + } 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); diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index 028edd555..d02fc1cd5 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -51,7 +51,7 @@ fn missing_glyphs( } for f in &cs.frames { let owner = owner.unwrap_or(f.frame); - for g in f.lines.iter().flat_map(|l| &l.glyphs).filter(|g| g.visible && g.gid == 0) { + for g in f.lines.iter().flat_map(|l| &l.glyphs).filter(|g| g.visible && (g.gid == 0 || g.face.is_placeholder())) { let c = g.rendered_char; if c.is_control() || c.is_whitespace() || ('\u{E000}'..='\u{E1FF}').contains(&c) { continue; diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 169756221..2bc2b00ed 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -209,15 +209,23 @@ impl FontFace { pub fn id(&self) -> u32 { self.id } - /// Does the face map `c` to a glyph? + /// System placeholder fonts draw generic boxes rather than the requested character. + pub fn is_placeholder(&self) -> bool { + matches!(norm(self.family.trim_start_matches('.')).as_str(), "lastresort") + } + + /// Does the face map `c` to a real glyph? pub fn covers(&self, c: char) -> bool { + if self.is_placeholder() { + return false; + } let cp = c as u32; if cp < 0x1_0000 { let bits = self.bmp.get_or_init(|| { let mut b = vec![0u64; 1024].into_boxed_slice(); if let Some(f) = self.skrifa() { - for (cp, _) in f.charmap().mappings() { - if cp < 0x1_0000 { + for (cp, gid) in f.charmap().mappings() { + if cp < 0x1_0000 && gid.to_u32() != 0 { b[(cp / 64) as usize] |= 1 << (cp % 64); } } @@ -226,7 +234,7 @@ impl FontFace { }); return bits[(cp / 64) as usize] & (1 << (cp % 64)) != 0; } - self.skrifa().is_some_and(|f| f.charmap().map(c).is_some()) + self.skrifa().is_some_and(|f| f.charmap().map(c).is_some_and(|gid| gid.to_u32() != 0)) } /// Units per em. pub fn units_per_em(&self) -> f64 { @@ -1262,6 +1270,36 @@ impl ScopedFonts<'_> { candidates.iter().find_map(|name| self.canonical_family(name)).unwrap_or_else(|| FALLBACK_FAMILY.to_string()) } + /// A replacement suggestion must cover the text being replaced, not just its main script. + /// Ties retain the preferred substitute; this does not install or redefine any font. + pub fn replacement_face(&self, family: &str, style: &str, characters: &[char]) -> Arc { + let mut best = self.face(family, style); + let coverage = |face: &FontFace| characters.iter().filter(|&&c| c.is_whitespace() || c.is_control() || face.covers(c)).count(); + let mut score = coverage(&best); + let candidates: &[&str] = if characters.iter().any(|c| ('\u{0600}'..='\u{08ff}').contains(c)) { + &["Amiri", "Noto Naskh Arabic", "Al Bayan", "Damascus", "Geeza Pro", "Arial"] + } else if characters.iter().any(|c| ('\u{3400}'..='\u{9fff}').contains(c)) { + &["Songti SC", "Kaiti SC", "Noto Serif CJK SC", "PingFang SC", "Noto Sans CJK SC"] + } else { + &["Source Serif 4", "Source Sans 3", "Inter"] + }; + for candidate in candidates { + if score == characters.len() { + break; + } + if !self.has_family(candidate) { + continue; + } + let face = self.face(candidate, style); + let next = coverage(&face); + if next > score { + best = face; + score = next; + } + } + best + } + /// Family names available (the document's, loaded and cataloged system fonts), sorted and /// deduplicated. pub fn families(&self) -> Vec { diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 01b8e3bc4..60a474b3b 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -563,3 +563,29 @@ mod resolution_tests { assert_eq!(scope.face("An Unavailable Original Family", "Regular").family, FALLBACK_FAMILY); } } + +#[cfg(test)] +mod coverage_recommendation_tests { + use super::*; + + #[test] + fn placeholder_fonts_do_not_satisfy_glyph_coverage() { + let db = FontDb::with_font_dirs(Vec::new()); + db.add_font(testing::font_with(".LastResort", &['\u{10fffd}']).unwrap()); + let face = db.face(".LastResort", "Regular"); + assert!(face.is_placeholder()); + assert!(!face.covers('\u{10fffd}')); + assert!(db.fallback_for('\u{10fffd}', u32::MAX, None).is_none()); + } + + #[test] + fn arabic_replacement_also_covers_surrounding_punctuation() { + let db = FontDb::with_font_dirs(Vec::new()); + db.add_font(testing::font_with("Geeza Pro", &['ع']).unwrap()); + db.add_font(testing::font_with("Al Bayan", &['ع', '“']).unwrap()); + let scope = db.scoped(0); + assert_eq!(scope.face("Traditional Arabic", "Regular").family, "Geeza Pro"); + assert_eq!(scope.replacement_face("Traditional Arabic", "Regular", &['ع', '“']).family, "Al Bayan"); + assert!(!scope.has_family("Traditional Arabic")); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7545d44c2..2ef15a776 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2611,13 +2611,13 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { d.fields.insert("_fonts".into(), list); } let fonts = d.fields.get("_fonts").and_then(Value::as_array).cloned().unwrap_or_default(); - if d.s("family").is_empty() + if !fonts.iter().any(|font| font["family"] == d.s("family") && font["style"] == d.s("style")) && let Some(font) = fonts.first() { d.fields.insert("family".into(), font["family"].clone()); d.fields.insert("style".into(), font["style"].clone()); } - if d.b("onOpen") { + if d.b("onOpen") && fonts.iter().any(|f| f["missing"] == true || f["styleMissing"] == true) { crate::rtl::label( ui, crate::i18n::tr( @@ -2656,8 +2656,8 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { 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()); + d.fields.insert("toFamily".into(), font["replacementFamily"].clone()); + d.fields.insert("toStyle".into(), font["replacementStyle"].clone()); } crate::rtl::label( ui,