mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-05 18:19:57 +00:00
Merge pull request #2193
This commit is contained in:
commit
78139b693e
3 files changed
+144
-49
No files matched your search
@@ -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))| {
|
||||
|
||||
@@ -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))
|
||||
),
|
||||
|
||||
@@ -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, ¶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}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user