Part of #3229: setting type in a font or style that isn't available warns

Squashed from #4866 onto main.
This commit is contained in:
dotCooCoo
2026-10-31 19:43:15 +07:00
committed by bflatastic
parent 1f31b6ed4a
commit 65b6606d90
4 changed files with 118 additions and 11 deletions
+12 -2
View File
@@ -85,7 +85,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Text",
[],
None,
"{x, y, text, rotation?: deg (about x, y), vertical?: bool = false, size?: pt, font?: family, style? (default Regular, or the family's closest style when it has no Regular), color?, keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
"{x, y, text, rotation?: deg (about x, y), vertical?: bool = false, size?: pt, font?: family, style? (default Regular, or the family's closest style when it has no Regular), color?, keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits), warning?: text (the font or style given isn't available: the type is drawn in the fallback font or the family's closest style)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
has_doc,
text_create
),
@@ -739,7 +739,17 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
let made = insert_look_turned(s, "Type", NodeKind::Text(Box::new(t)), NewArt::plain(Appearance::default()), None, true, bbox_turn(rotation))?;
let id = made.get("id").and_then(Value::as_u64).map(NodeId);
let made = super::typecmd::with_bounds(s, id, made);
Ok(super::typecmd::with_overflow(s, id.as_slice(), made))
let mut made = super::typecmd::with_overflow(s, id.as_slice(), made);
if p.get("font").is_some() || p.get("style").is_some() {
let runs = match id.and_then(|i| s.doc().ok()?.doc.node(i)).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => t.runs.iter().map(|r| super::fonts::font_of(&r.style)).collect(),
_ => Default::default(),
};
if let Some(w) = super::fonts::set_font_warning(runs) {
made["warning"] = json!(w);
}
}
Ok(made)
}
/// The paragraph attributes new type gets (the Type tools, `text.create`): aligned to the start of
+73 -5
View File
@@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Replace Font",
[],
None,
"{from: {family, style?}, to: {family (any of its names; stored as the font's own family name), style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style); text that is hidden or locked, in a hidden or locked group or layer, or on a locked artboard keeps its font → {runs, skipped?: the text objects left in the font, warning?}",
"{from: {family, style?}, to: {family (any of its names; stored as the font's own family name), style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style); text that is hidden or locked, in a hidden or locked group or layer, or on a locked artboard keeps its font → {runs, skipped?: the text objects left in the font, warning?: text (some text was left in its font, or the style set isn't available: its type is drawn in the family's closest style)}",
has_doc,
replace
),
@@ -318,6 +318,24 @@ pub(super) fn substitution_warning(d: &Document) -> Option<String> {
/// [`substitution_warning`] for type set in `fonts`.
pub(super) fn substitution_warning_of(fonts: BTreeSet<UsedFont>) -> Option<String> {
substitutions(fonts, ("were written", "was drawn"))
}
/// The font (family, style, version) of character style `st`.
pub(super) fn font_of(st: &vectorcraft_doc::CharStyle) -> UsedFont {
(st.font_family.clone(), st.font_style.clone(), st.font_version.clone())
}
/// What `text.create`, `text.setStyle`, `text.setRangeStyle` and `text.replaceFont` say when the
/// fonts they set type in (`fonts`) don't draw it: the family isn't available (the fallback font
/// is drawn) or doesn't have that style (its closest style is drawn). `None` when all draw it.
pub(super) fn set_font_warning(fonts: BTreeSet<UsedFont>) -> Option<String> {
substitutions(fonts, ("are drawn", "is drawn"))
}
/// The warning for type set in `fonts`, with the verbs `written` and `drawn` (an export's are
/// `were written` and `was drawn`).
fn substitutions(fonts: BTreeSet<UsedFont>, (written, drawn): (&str, &str)) -> Option<String> {
let db = vectorcraft_text::FontDb::global();
let mut missing: Vec<String> = Vec::new();
let mut styles: BTreeSet<String> = BTreeSet::new();
@@ -325,7 +343,7 @@ pub(super) fn substitution_warning_of(fonts: BTreeSet<UsedFont>) -> Option<Strin
match db.resolve(&family, &style) {
None | Some((_, vectorcraft_text::FontMatch::Missing)) => missing.push(family),
Some((face, vectorcraft_text::FontMatch::Style)) => {
styles.insert(format!("{family} {style} was drawn in {} {}", face.family, face.style));
styles.insert(format!("{family} {style} {drawn} in {} {}", face.family, face.style));
}
Some(_) => {}
}
@@ -335,14 +353,14 @@ pub(super) fn substitution_warning_of(fonts: BTreeSet<UsedFont>) -> Option<Strin
let mut parts = Vec::new();
if !missing.is_empty() {
parts.push(format!(
"fonts that aren't available were written in the fallback font, {}: {} (install them, or replace them with Find Font)",
"fonts that aren't available {written} in the fallback font, {}: {} (install them, or replace them with Find Font)",
vectorcraft_text::FALLBACK_FAMILY,
missing.join(", ")
));
}
if !styles.is_empty() {
parts.push(format!(
"font styles that aren't available were written in the closest style: {} (pick a style the family has)",
"font styles that aren't available {written} in the closest style: {} (pick a style the family has)",
styles.into_iter().collect::<Vec<_>>().join(", ")
));
}
@@ -426,6 +444,8 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
let skipped = other.into_iter().filter(|id| text(d, *id).is_some_and(|t| t.runs.iter().any(|r| matches(&r.style)))).count();
(ids, skipped)
};
// The fonts the replaced runs are set in, for the warning.
let mut set = BTreeSet::new();
let n = s.edit("Replace Font", |d, _| {
let mut n = 0;
for id in &ids {
@@ -443,6 +463,7 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
// Another font: none of the old one's versions or Variable Font settings.
r.style.font_version = None;
r.style.font_axes.clear();
set.insert(font_of(&r.style));
changed = true;
n += 1;
}
@@ -455,9 +476,14 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
Ok(n)
})?;
let mut out = json!({ "runs": n });
let mut warnings = Vec::new();
if skipped > 0 {
out["skipped"] = json!(skipped);
out["warning"] = json!(REPLACE_SKIPPED);
warnings.push(REPLACE_SKIPPED.to_string());
}
warnings.extend(set_font_warning(set));
if !warnings.is_empty() {
out["warning"] = json!(warnings.join("; "));
}
Ok(out)
}
@@ -751,6 +777,48 @@ mod open_tests {
assert!(warnings.contains("Source Sans 3 Black Wide was drawn in Source Sans 3"), "{out}");
}
#[test]
fn setting_type_in_a_font_it_cant_draw_warns() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let warning = |out: &Value| out["warning"].as_str().map(str::to_string);
let made = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Hi", "font": "Source Sans 3", "style": "Black Wide"})).unwrap();
assert!(warning(&made).is_some_and(|w| w.contains("Source Sans 3 Black Wide is drawn in Source Sans 3")), "{made}");
let gone = s.execute("text.create", &json!({"x": 10, "y": 60, "text": "Gone", "font": "No Such Font Family"})).unwrap();
let w = warning(&gone).unwrap_or_default();
assert!(w.contains("No Such Font Family") && w.contains(vectorcraft_text::FALLBACK_FAMILY), "{gone}");
let plain = s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Fine", "font": "Source Sans 3", "style": "Semibold"})).unwrap();
assert_eq!(warning(&plain), None, "{plain}");
let id = plain["id"].as_u64().unwrap();
let set = s.execute("text.setStyle", &json!({"ids": [id], "style": "Black Wide"})).unwrap();
assert!(warning(&set).is_some_and(|w| w.contains("Source Sans 3 Black Wide is drawn in Source Sans 3")), "{set}");
let back = s.execute("text.setStyle", &json!({"ids": [id], "style": "Bold"})).unwrap();
assert_eq!(warning(&back), None, "{back}");
// Only the font the command set: the other objects' substitutes aren't its to report.
let size = s.execute("text.setStyle", &json!({"ids": [id], "size": 30})).unwrap();
assert_eq!(warning(&size), None, "{size}");
// Only the characters the command styles: with "Fi" in Black Wide, styling "ne" doesn't warn.
let first = s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 2, "style": "Black Wide"})).unwrap();
assert!(warning(&first).is_some_and(|w| w.contains("Source Sans 3 Black Wide is drawn in Source Sans 3")), "{first}");
let rest = s.execute("text.setRangeStyle", &json!({"id": id, "start": 2, "end": 4, "font": "Source Sans 3"})).unwrap();
assert_eq!(warning(&rest), None, "{rest}");
let range = s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 2, "font": "No Such Font Family"})).unwrap();
assert!(warning(&range).is_some_and(|w| w.contains("No Such Font Family")), "{range}");
let replaced = s
.execute(
"text.replaceFont",
&json!({"from": {"family": "No Such Font Family"}, "to": {"family": "Source Sans 3", "style": "Black Wide"}}),
)
.unwrap();
assert!(warning(&replaced).is_some_and(|w| w.contains("Source Sans 3 Black Wide is drawn in Source Sans 3")), "{replaced}");
// Empty type: the font set is the one the next characters typed get.
let empty = s.execute("text.create", &json!({"x": 10, "y": 140, "text": ""})).unwrap()["id"].as_u64().unwrap();
let set = s.execute("text.setStyle", &json!({"ids": [empty], "style": "Black Wide"})).unwrap();
assert!(warning(&set).is_some_and(|w| w.contains("Black Wide")), "{set}");
let range = s.execute("text.setRangeStyle", &json!({"id": empty, "font": "No Such Font Family"})).unwrap();
assert!(warning(&range).is_some_and(|w| w.contains("No Such Font Family")), "{range}");
}
#[test]
fn opened_documents_have_exact_text_bounds() {
let mut s = Session::new();
+14 -2
View File
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
has_doc,
set_range_style
),
@@ -708,6 +708,9 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
let t = text_ref(s, id)?;
change.localize(&t, C)?;
let (a, b) = range_of(p, edit::runs_len(&t.runs), C)?;
// The fonts the styled characters are set in, for the warning.
let mut set = std::collections::BTreeSet::new();
let sets_font = change.font.is_some() || change.style.is_some();
let n = s.edit("Character", |d, _| {
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
if a == b && !t.plain_text().is_empty() {
@@ -719,10 +722,19 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(before) = before {
protect_missing_glyphs(&before, &mut t.runs);
}
if sets_font {
// Empty text has one run, which the empty range styles.
let styled = if a == b { t.runs.clone() } else { edit::slice_runs(&t.runs, a, b) };
set.extend(styled.iter().map(|r| super::fonts::font_of(&r.style)));
}
refresh_bounds(t);
Ok(t.runs.len())
})?;
Ok(json!({"id": id.0, "runs": n}))
let mut out = json!({"id": id.0, "runs": n});
if let Some(w) = super::fonts::set_font_warning(set) {
out["warning"] = json!(w);
}
Ok(out)
}
/// The em a laid-out glyph's kerning and tracking are measured in, in text space: the size it
+19 -2
View File
@@ -123,7 +123,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, keepModel?: bool (as paint.setFill), features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits)}",
"{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, keepModel?: bool (as paint.setFill), features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
has_doc,
set_style
),
@@ -805,6 +805,8 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
let range = TextRange::parse(p, C)?;
let ids = text_targets(s, p, C)?;
let protect = s.prefs.missing_glyph_protection && (font.is_some() || style.is_some());
// The fonts the styled characters are set in, for the warning.
let mut set = std::collections::BTreeSet::new();
s.edit("Character", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
@@ -846,6 +848,17 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(before) = before {
super::textedit::protect_missing_glyphs(&before, &mut t.runs);
}
if font.is_some() || style.is_some() {
// All the text: every run, the one run of empty text included.
let styled = match range {
None => t.runs.clone(),
Some(r) => {
let (a, b) = r.bytes(t);
vectorcraft_text::edit::slice_runs(&t.runs, a, b)
}
};
set.extend(styled.iter().map(|r| super::fonts::font_of(&r.style)));
}
if let Some(j) = justify {
let span = para_span(range, t);
t.edit_paras(span, |pa| pa.justify = j);
@@ -888,7 +901,11 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(())
})?;
Ok(with_overflow(s, &ids, json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })))
let mut out = with_overflow(s, &ids, json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }));
if let Some(w) = super::fonts::set_font_warning(set) {
out["warning"] = json!(w);
}
Ok(out)
}
/// Reshape area type's frame with `f` ([`TextObject::transform_area`],