From 7bc3a26e4a03b13b5dad46836b5918ca742dcb21 Mon Sep 17 00:00:00 2001 From: JonRC Date: Tue, 3 Nov 2026 01:51:54 -0300 Subject: [PATCH] type.char: warn about the font the text resolves to (follow-up to #2190) The warning from #2190 looked only at the attributes passed. A composite font made by style.compositeFont.set isn't an installed family, so applying one warned "Missing font: Mixed" even when its entries were installed. And a fontStyle-only call, or a family change that kept a style the new family lacks, said nothing. After a call that sets or clears fontFamily or fontStyle, type.char now tallies the fonts the formatted text resolves to, with the code font.list uses (composite entries per character), and warns about the missing ones and substitute styles. At a caret it checks the font the next typing gets. tally_story becomes tally_range over a byte range, and describe_fonts is the part of used_fonts that type.char reuses. --- crates/engine/src/cmd/fonts.rs | 35 ++++-- crates/engine/src/cmd/text.rs | 2 +- crates/engine/src/cmd/text/format.rs | 156 ++++++++++++++++++++------- 3 files changed, 144 insertions(+), 49 deletions(-) diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 77cebe369..8d78e4b4a 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -94,21 +94,30 @@ fn list(d: &Document) -> Vec { used_fonts(d).into_iter().map(|font| json!(font)).collect() } -type FontTally = BTreeMap<(String, String), (usize, std::collections::BTreeSet)>; +pub(super) type FontTally = BTreeMap<(String, String), (usize, std::collections::BTreeSet)>; /// Count the characters of every run of one story against the font each resolves to. fn tally_story(d: &Document, st: &Story, used: &mut FontTally) { - let ranges = st.para_ranges(); - for (pi, r) in ranges.iter().enumerate() { - let (_, base) = d.styles.resolve_para(&st.paras[pi]); + tally_range(d, st, 0..st.text.len(), used); +} + +/// Count the characters in the byte range `within` of a story against the font each resolves to; +/// a composite font counts each character under the font of its entry. An empty paragraph counts +/// the font of its mark, which still sets its line. +pub(super) fn tally_range(d: &Document, st: &Story, within: std::ops::Range, used: &mut FontTally) { + let to_end = within.end >= st.text.len(); + for (pi, r) in st.para_ranges().iter().enumerate() { + let Some(para) = st.paras.get(pi) else { continue }; + let (_, base) = d.styles.resolve_para(para); + let mark = r.is_empty() && within.start <= r.start && (r.start < within.end || to_end); for (rr, fmt) in st.runs() { - let n = st.text[rr.start.max(r.start)..rr.end.min(r.end).max(rr.start.max(r.start))].chars().count(); - if n == 0 && !(r.is_empty() && rr.contains(&r.start)) { + let start = rr.start.max(r.start).max(within.start); + let text = st.text.get(start..rr.end.min(r.end).min(within.end).max(start)).unwrap_or(""); + if text.is_empty() && !(mark && rr.contains(&r.start)) { continue; } let p = d.styles.resolve_char(&base, fmt); 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) { let entry = used.entry((e.family.clone(), e.style.clone())).or_default(); @@ -118,17 +127,23 @@ fn tally_story(d: &Document, st: &Story, used: &mut FontTally) { } } else { 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()); + entry.0 += text.chars().count(); + entry.1.extend(text.chars()); } } } } pub(super) fn used_fonts(d: &Document) -> Vec { - let db = designcraft_fonts::FontDb::global().scoped(d.font_scope); let mut used = FontTally::new(); for_each_story(d, &mut |st| tally_story(d, st, &mut used)); + describe_fonts(d, used) +} + +/// The tallied fonts as this document's fonts resolve them: missing, a substitute style, or the +/// face itself (missing ones first). +pub(super) fn describe_fonts(d: &Document, used: FontTally) -> Vec { + let db = designcraft_fonts::FontDb::global().scoped(d.font_scope); let mut out: Vec = used .into_iter() .map(|((family, style), (n, characters))| { diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index f94826fef..eecdab5e3 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -211,7 +211,7 @@ pub fn specs() -> Vec { "Character Formatting", [], None, - "{attrs: {fontFamily?, fontStyle?, size?, leading?: {kind:auto}|{kind:points,value}, tracking?, kerning?, hScale?, vScale?, baselineShift?, fill?, capitalization?, underline?, …}} → null, or {warnings} naming a fontFamily or fontStyle that isn't available (still applied; a substitute draws the text)", + "{attrs: {fontFamily?, fontStyle?, size?, leading?: {kind:auto}|{kind:points,value}, tracking?, kerning?, hScale?, vScale?, baselineShift?, fill?, capitalization?, underline?, …}} → null, or, after a fontFamily or fontStyle change, {warnings} naming each font the text now resolves to that isn't available (still applied; a substitute draws the text)", has_text_or_frames, |s, p| format_chars(s, p.get("attrs").unwrap_or(p)) ), diff --git a/crates/engine/src/cmd/text/format.rs b/crates/engine/src/cmd/text/format.rs index edec044b9..d915da91d 100644 --- a/crates/engine/src/cmd/text/format.rs +++ b/crates/engine/src/cmd/text/format.rs @@ -162,9 +162,8 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result { } } validate_char_fill(&s.doc()?.doc, &a, "type.char")?; - let warnings = missing_font_warnings(&s.doc()?.doc, &a); - let result = if warnings.is_empty() { Value::Null } else { json!({ "warnings": warnings }) }; let cleared: Vec = attrs.as_object().map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()).unwrap_or_default(); + let sets_font = a.font_family.is_some() || a.font_style.is_some() || cleared.iter().any(|k| k == "fontFamily" || k == "fontStyle"); let targets = format_targets(s); // A caret in text: the format is for the next typing. On an empty paragraph's hidden mark it // also formats the mark right away, since the mark alone sets that line's height; typing there @@ -187,31 +186,50 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result { })?; } s.typing_format = Some(f); - return Ok(result); + return if sets_font { font_warnings(s, &targets) } else { ok() }; } s.edit(|d, _| { apply_char_attrs(d, &targets, &a, &cleared); - Ok(result) - }) + ok() + })?; + if sets_font { font_warnings(s, &targets) } else { ok() } } /// A font family or style the app doesn't have is still applied, so the document keeps its name -/// and preflight and Find Font list it, but a substitute draws the text. Say so, in preflight's -/// words, instead of succeeding silently. -fn missing_font_warnings(document: &designcraft_doc::Document, attrs: &CharAttrs) -> Vec { - let Some(family) = attrs.font_family.as_deref().filter(|f| !f.trim().is_empty()) else { - return vec![]; - }; - let db = designcraft_fonts::FontDb::global().scoped(document.font_scope); - let style = attrs.font_style.as_deref().unwrap_or("Regular"); - let face = db.face(family, style); - if !db.has_family(family) { - vec![format!("Missing font: {family} (using {})", face.family)] - } else if attrs.font_style.is_some() && !db.has_style(family, style) { - vec![format!("Missing font style: {family} {style} (using {} {})", face.family, face.style)] - } else { - vec![] +/// and preflight and Find Font list it, but a substitute draws the text. After a change to the +/// family or style, say which of the fonts the formatted text now resolves to are missing, in +/// preflight's words: the text's own family with a new style, a new family with the text's style, +/// and a composite font's entries for the characters it sets, as `font.list` resolves them. At a +/// caret, the font the next typing gets (a composite font picks it per character typed). +fn font_warnings(s: &Session, targets: &[Target]) -> Result { + let d = &s.doc()?.doc; + let mut used = crate::cmd::fonts::FontTally::new(); + for t in targets { + let Some(story) = d.text_story(t.story, t.cell) else { continue }; + if !t.range.is_empty() { + crate::cmd::fonts::tally_range(d, story, t.range.clone(), &mut used); + continue; + } + let Some(para) = story.paras.get(story.para_at(t.range.start)) else { continue }; + let (_, base) = d.styles.resolve_para(para); + let next = d.styles.resolve_char(&base, &effective_char_format(s, t)?); + if d.styles.composite_font(&next.font_family).is_none() { + used.entry((next.font_family, next.font_style)).or_default(); + } } + let warnings: Vec = crate::cmd::fonts::describe_fonts(d, used) + .into_iter() + .filter_map(|f| { + if f.missing { + Some(format!("Missing font: {} (using {})", f.family, f.resolved_family)) + } else if f.style_missing { + Some(format!("Missing font style: {} {} (using {} {})", f.family, f.style, f.resolved_family, f.resolved_style)) + } else { + None + } + }) + .collect(); + Ok(if warnings.is_empty() { Value::Null } else { json!({ "warnings": warnings }) }) } /// Kerning spaces a character from the next one, so the pair at a caret is kerned through the @@ -1097,34 +1115,96 @@ mod size_bounds_tests { #[cfg(test)] mod missing_font_tests { - use serde_json::json; + use serde_json::{Value, json}; use crate::Session; /// A font the app doesn't have was applied with no word about it, so a script that asked for /// Courier Prime exported a PDF in the substitute and exited 0. It is still applied (preflight - /// and Find Font list it), and the command now says which font is missing. + /// and Find Font list it), and the command says which font the text now resolves to is + /// missing: the pair of family and style it ends up with, whichever of the two the call set, + /// and a composite font's entries rather than its name. #[test] - fn a_font_that_is_not_available_is_applied_with_a_warning() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 1})).unwrap(); - let f = s.execute("frame.create", &json!({"rect": [20, 20, 400, 300], "content": "text", "text": "Hello world"})).unwrap(); - for (attrs, warning) in [ - (json!({"fontFamily": "Source Serif 4", "fontStyle": "Bold"}), None), - (json!({"fontFamily": "Nonexistent Sans"}), Some("Missing font: Nonexistent Sans (using ")), - (json!({"fontFamily": "Source Serif 4", "fontStyle": "Nope"}), Some("Missing font style: Source Serif 4 Nope (using Source Serif 4 ")), - ] { + fn a_font_change_warns_about_the_font_the_text_resolves_to() { + let composite = |name: &str, family: &str| ("style.compositeFont.set", json!({"name": name, "entries": [{"family": family}]})); + let typed = |attrs: Value| ("type.char", json!({"attrs": attrs})); + let rows: [(&str, Vec<(&str, Value)>, bool, Value, Option<&str>); 11] = [ + ("family and style, both installed", vec![], false, json!({"fontFamily": "Source Serif 4", "fontStyle": "Bold"}), None), + ("family only, missing", vec![], false, json!({"fontFamily": "Nonexistent Sans"}), Some("Missing font: Nonexistent Sans (using ")), + ( + "family and a style it lacks", + vec![], + false, + json!({"fontFamily": "Source Serif 4", "fontStyle": "Nope"}), + Some("Missing font style: Source Serif 4 Nope (using Source Serif 4 "), + ), + ( + "style only, one the text's family has", + vec![typed(json!({"fontFamily": "Source Serif 4"}))], + false, + json!({"fontStyle": "Bold"}), + None, + ), + ( + "style only, one the text's family lacks", + vec![typed(json!({"fontFamily": "Source Serif 4"}))], + false, + json!({"fontStyle": "Nope"}), + Some("Missing font style: Source Serif 4 Nope (using "), + ), + ( + "family only, keeping a style the new family lacks", + vec![typed(json!({"fontFamily": "Source Serif 4", "fontStyle": "Nope"}))], + false, + json!({"fontFamily": "Source Sans 3"}), + Some("Missing font style: Source Sans 3 Nope (using "), + ), + ("a composite font of installed fonts", vec![composite("Mixed", "Source Serif 4")], false, json!({"fontFamily": "Mixed"}), None), + ( + "a composite font with a missing entry", + vec![composite("Gone", "Nonexistent Sans")], + false, + json!({"fontFamily": "Gone"}), + Some("Missing font: Nonexistent Sans (using "), + ), + ( + "family only at a caret, for the next typing", + vec![], + true, + json!({"fontFamily": "Nonexistent Sans"}), + Some("Missing font: Nonexistent Sans (using "), + ), + ( + "style only at a caret", + vec![typed(json!({"fontFamily": "Source Serif 4"}))], + true, + json!({"fontStyle": "Nope"}), + Some("Missing font style: Source Serif 4 Nope (using "), + ), + ("a change that leaves the font alone", vec![typed(json!({"fontFamily": "Nonexistent Sans"}))], false, json!({"size": 14}), None), + ]; + for (name, before, caret, attrs, warning) in rows { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [20, 20, 400, 300], "content": "text", "text": "Hello world"})).unwrap(); s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 0, "focus": 5})).unwrap(); + for (command, params) in before { + s.execute(command, ¶ms).unwrap(); + } + if caret { + s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 5, "focus": 5})).unwrap(); + } let r = s.execute("type.char", &json!({"attrs": attrs})).unwrap(); match warning { - None => assert_eq!(r, json!(null), "{attrs}"), - Some(w) => assert!(r["warnings"][0].as_str().is_some_and(|m| m.starts_with(w)), "{attrs}: {r}"), + None => assert_eq!(r, json!(null), "{name}"), + Some(w) => { + assert!(r["warnings"].as_array().is_some_and(|ws| ws.iter().any(|m| m.as_str().is_some_and(|m| m.starts_with(w)))), "{name}: {r}") + } + } + // The font is applied whatever the warning says. + if let Some(family) = attrs.get("fontFamily").filter(|_| !caret) { + assert_eq!(&s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontFamily"], family, "{name}"); } - assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontFamily"], attrs["fontFamily"], "{attrs}"); } - // At a caret the font is for the next typing, and the warning is the same. - s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 5, "focus": 5})).unwrap(); - let r = s.execute("type.char", &json!({"attrs": {"fontFamily": "Nonexistent Sans"}})).unwrap(); - assert!(r["warnings"][0].as_str().is_some_and(|m| m.starts_with("Missing font: Nonexistent Sans")), "{r}"); } }