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}"); } }