From 65b6606d90e4e3e2250cc0877f20e93def849ffa Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 18:46:57 +0700 Subject: [PATCH] Part of #3229: setting type in a font or style that isn't available warns Squashed from #4866 onto main. --- crates/engine/src/cmd/create.rs | 14 +++++- crates/engine/src/cmd/fonts.rs | 78 +++++++++++++++++++++++++++++-- crates/engine/src/cmd/textedit.rs | 16 ++++++- crates/engine/src/cmd/typecmd.rs | 21 ++++++++- 4 files changed, 118 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index a943ee10d..f41418247 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -85,7 +85,7 @@ pub fn specs() -> Vec { "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 { 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 diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 827875b20..cf3404c31 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -29,7 +29,7 @@ pub fn specs() -> Vec { "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 { /// [`substitution_warning`] for type set in `fonts`. pub(super) fn substitution_warning_of(fonts: BTreeSet) -> Option { + 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) -> Option { + 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, (written, drawn): (&str, &str)) -> Option { let db = vectorcraft_text::FontDb::global(); let mut missing: Vec = Vec::new(); let mut styles: BTreeSet = BTreeSet::new(); @@ -325,7 +343,7 @@ pub(super) fn substitution_warning_of(fonts: BTreeSet) -> Option 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) -> Option>().join(", ") )); } @@ -426,6 +444,8 @@ fn replace(s: &mut Session, p: &Value) -> Result { 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 { // 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 { 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(); diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index ae272f93a..cb4a51354 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -39,7 +39,7 @@ pub fn specs() -> Vec { "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 { 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 { 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 diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 6d49301a8..97ffea356 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -123,7 +123,7 @@ pub fn specs() -> Vec { "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 { 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 { 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 { } Ok(()) })?; - Ok(with_overflow(s, &ids, json!({ "ids": ids.iter().map(|i| i.0).collect::>() }))) + let mut out = with_overflow(s, &ids, json!({ "ids": ids.iter().map(|i| i.0).collect::>() })); + 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`],