Merge pull request #2193

This commit is contained in:
aiongg committed 2026-11-03 19:07:43 +00:00
commit 78139b693e
3 files changed
+144 -49

No files matched your search

+25 -10
View File
@@ -94,21 +94,30 @@ fn list(d: &Document) -> Vec<Value> {
used_fonts(d).into_iter().map(|font| json!(font)).collect()
}
type FontTally = BTreeMap<(String, String), (usize, std::collections::BTreeSet<char>)>;
pub(super) type FontTally = BTreeMap<(String, String), (usize, std::collections::BTreeSet<char>)>;
/// 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<usize>, 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<UsedFont> {
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<UsedFont> {
let db = designcraft_fonts::FontDb::global().scoped(d.font_scope);
let mut out: Vec<UsedFont> = used
.into_iter()
.map(|((family, style), (n, characters))| {
+1 -1
View File
@@ -211,7 +211,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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))
),
+118 -38
View File
@@ -162,9 +162,8 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> {
}
}
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<String> = 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<Value> {
})?;
}
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<String> {
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<Value> {
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<String> = 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, &params).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}");
}
}