mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 19:57:05 -06:00
Type > Font, the Character, Properties and Glyphs panels and Find Font list every installed font by default, as the reference app does, instead of the four bundled families. The family dropdowns get a search field that filters the list as you type (Enter picks the first match) and open on the current font. Show System Fonts, which no longer means anything, becomes Refresh Font List (text.rescanFonts): it picks up fonts installed since the app started and redraws type set in them without editing the document. text.fontList lets MCP agents list the families and a family's styles. Root cause: the font lists came from FontDb::families(), which only held the bundled fonts until something scanned the system fonts (fixed with #130). The Type > Font menu, rebuilt every frame, now builds its items once per font change, and renderers lay type out again when the fonts change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
300 lines
12 KiB
Rust
300 lines
12 KiB
Rust
//! Type → Find Font: the fonts a document uses (flagging missing ones), replacing one font with
|
|
//! another everywhere or in the selection, and selecting the text that uses a font. Also the fonts
|
|
//! available (the bundled and installed ones) and rescanning the installed fonts.
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
use serde_json::{Value, json};
|
|
use vectorcraft_doc::{Document, NodeId, NodeKind};
|
|
|
|
use super::typecmd::refresh_bounds;
|
|
use super::*;
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
vec![
|
|
cmd!(
|
|
query "text.fonts",
|
|
"Fonts in Document",
|
|
[],
|
|
None,
|
|
"{selectionOnly?} → [{family, style, runs, objects, missing}] sorted by name",
|
|
has_doc,
|
|
fonts
|
|
),
|
|
cmd!(
|
|
"text.replaceFont",
|
|
"Replace Font",
|
|
[],
|
|
None,
|
|
"{from: {family, style?}, to: {family, style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style) → {runs}",
|
|
has_doc,
|
|
replace
|
|
),
|
|
cmd!(
|
|
"select.font",
|
|
"Select Text by Font",
|
|
[],
|
|
None,
|
|
"{family, style?} select the text objects using the font → {count}",
|
|
has_doc,
|
|
select_font
|
|
),
|
|
cmd!(
|
|
query "text.fontList",
|
|
"Font List",
|
|
[],
|
|
None,
|
|
"{family?} → {families: [names]} sorted: the bundled fonts, fonts added and the fonts installed on the system (none on the web); with family: {family, styles: [names]} (upright styles by weight, then italics; an error when the family isn't available)",
|
|
always,
|
|
font_list
|
|
),
|
|
cmd!(
|
|
"text.rescanFonts",
|
|
"Refresh Font List",
|
|
[],
|
|
None,
|
|
"{} scan the system font folders again, for fonts installed or removed since the app started; text in a font that became available redraws in it (no fonts are installed on the web) → {families, faces} (families available, installed faces found)",
|
|
always,
|
|
rescan
|
|
),
|
|
]
|
|
}
|
|
|
|
/// Text objects in scope: the selection's (and their descendants') or the whole document's.
|
|
fn scope(s: &Session, selection_only: bool) -> Result<Vec<NodeId>> {
|
|
let st = s.doc()?;
|
|
let mut v = vec![];
|
|
let mut add = |n: &vectorcraft_doc::Node| {
|
|
n.walk(&mut |c| {
|
|
if matches!(c.kind, NodeKind::Text(_)) && !v.contains(&c.id) {
|
|
v.push(c.id);
|
|
}
|
|
})
|
|
};
|
|
if selection_only {
|
|
for id in &st.selection.objects {
|
|
if let Some(n) = st.doc.node(*id) {
|
|
add(n);
|
|
}
|
|
}
|
|
} else {
|
|
for l in &st.doc.layers {
|
|
add(l);
|
|
}
|
|
}
|
|
Ok(v)
|
|
}
|
|
|
|
fn text(d: &Document, id: NodeId) -> Option<&vectorcraft_doc::TextObject> {
|
|
match d.node(id).map(|n| &n.kind) {
|
|
Some(NodeKind::Text(t)) => Some(t),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn fonts(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let ids = scope(s, bool_or(p, "selectionOnly", false))?;
|
|
let d = &s.doc()?.doc;
|
|
let db = vectorcraft_text::FontDb::global();
|
|
let families = db.families();
|
|
let mut found: BTreeMap<(String, String), (usize, Vec<NodeId>)> = BTreeMap::new();
|
|
for id in ids {
|
|
for r in text(d, id).map(|t| t.runs.as_slice()).unwrap_or_default() {
|
|
let e = found.entry((r.style.font_family.clone(), r.style.font_style.clone())).or_default();
|
|
e.0 += 1;
|
|
if !e.1.contains(&id) {
|
|
e.1.push(id);
|
|
}
|
|
}
|
|
}
|
|
let list: Vec<Value> = found
|
|
.into_iter()
|
|
.map(|((family, style), (runs, objects))| {
|
|
let have_family = families.iter().any(|f| f.eq_ignore_ascii_case(&family));
|
|
let missing = !have_family || !db.styles(&family).iter().any(|st| st.eq_ignore_ascii_case(&style));
|
|
json!({ "family": family, "style": style, "runs": runs, "objects": objects.len(), "missing": missing })
|
|
})
|
|
.collect();
|
|
Ok(json!(list))
|
|
}
|
|
|
|
fn font_param(p: &Value, k: &str, c: &str) -> Result<(String, Option<String>)> {
|
|
let v = p.get(k).ok_or_else(|| bad(c, format!("missing `{k}`")))?;
|
|
let family =
|
|
v.get("family").and_then(Value::as_str).filter(|f| !f.trim().is_empty()).ok_or_else(|| bad(c, format!("`{k}.family` is required")))?;
|
|
Ok((family.to_string(), v.get("style").and_then(Value::as_str).map(str::to_string)))
|
|
}
|
|
|
|
fn replace(s: &mut Session, p: &Value) -> Result<Value> {
|
|
const C: &str = "text.replaceFont";
|
|
let (ff, fs) = font_param(p, "from", C)?;
|
|
let (tf, ts) = font_param(p, "to", C)?;
|
|
let db = vectorcraft_text::FontDb::global();
|
|
if !db.families().iter().any(|f| f.eq_ignore_ascii_case(&tf)) {
|
|
return Err(bad(C, format!("font family `{tf}` is not available")));
|
|
}
|
|
let ids = scope(s, bool_or(p, "selectionOnly", false))?;
|
|
let matches = |st: &vectorcraft_doc::CharStyle| {
|
|
st.font_family.eq_ignore_ascii_case(&ff) && fs.as_ref().is_none_or(|x| st.font_style.eq_ignore_ascii_case(x))
|
|
};
|
|
let n = s.edit("Replace Font", |d, _| {
|
|
let mut n = 0;
|
|
for id in &ids {
|
|
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
|
let mut changed = false;
|
|
for r in &mut t.runs {
|
|
if matches(&r.style) {
|
|
// An explicit style, else the closest the new family has to the old one.
|
|
let style = ts
|
|
.clone()
|
|
.or_else(|| db.face(&tf, &r.style.font_style).map(|f| f.style.clone()))
|
|
.unwrap_or_else(|| r.style.font_style.clone());
|
|
r.style.font_family = tf.clone();
|
|
r.style.font_style = style;
|
|
changed = true;
|
|
n += 1;
|
|
}
|
|
}
|
|
if changed {
|
|
vectorcraft_text::edit::normalize(&mut t.runs);
|
|
refresh_bounds(t);
|
|
}
|
|
}
|
|
Ok(n)
|
|
})?;
|
|
Ok(json!({ "runs": n }))
|
|
}
|
|
|
|
fn select_font(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let family = str_param(p, "family").ok_or_else(|| bad("select.font", "missing `family`"))?.to_string();
|
|
let style = str_param(p, "style").map(str::to_string);
|
|
let ids = scope(s, false)?;
|
|
let d = &s.doc()?.doc;
|
|
let hits: Vec<NodeId> = ids
|
|
.into_iter()
|
|
.filter(|id| {
|
|
text(d, *id).is_some_and(|t| {
|
|
t.runs.iter().any(|r| {
|
|
r.style.font_family.eq_ignore_ascii_case(&family) && style.as_ref().is_none_or(|x| r.style.font_style.eq_ignore_ascii_case(x))
|
|
})
|
|
})
|
|
})
|
|
.collect();
|
|
let n = hits.len();
|
|
s.select(|_, sel| sel.set(hits))?;
|
|
Ok(json!({ "count": n }))
|
|
}
|
|
|
|
fn font_list(_: &mut Session, p: &Value) -> Result<Value> {
|
|
let db = vectorcraft_text::FontDb::global();
|
|
match str_param(p, "family") {
|
|
Some(family) => {
|
|
if !db.has_family(family) {
|
|
return Err(bad("text.fontList", format!("font family `{family}` is not available")));
|
|
}
|
|
// The name as the font spells it.
|
|
let name = db.family_list().iter().find(|f| f.eq_ignore_ascii_case(family)).cloned().unwrap_or_else(|| family.to_string());
|
|
Ok(json!({ "family": name, "styles": db.styles(family) }))
|
|
}
|
|
None => Ok(json!({ "families": *db.family_list() })),
|
|
}
|
|
}
|
|
|
|
fn rescan(s: &mut Session, _: &Value) -> Result<Value> {
|
|
let db = vectorcraft_text::FontDb::global();
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
let faces = db.load_system_fonts();
|
|
#[cfg(target_arch = "wasm32")]
|
|
let faces = 0;
|
|
// The canvas redraws type in the fonts available now (renderers lay it out again when the
|
|
// fonts change), and the bounds follow; that isn't an edit to save.
|
|
for d in &mut s.docs {
|
|
let mut texts = vec![];
|
|
d.doc.walk(|n| {
|
|
if matches!(n.kind, NodeKind::Text(_)) {
|
|
texts.push(n.id);
|
|
}
|
|
});
|
|
if !texts.is_empty() {
|
|
let saved = !d.is_dirty();
|
|
let doc = std::sync::Arc::make_mut(&mut d.doc);
|
|
for id in texts {
|
|
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
|
|
refresh_bounds(t);
|
|
}
|
|
}
|
|
if saved {
|
|
d.mark_saved();
|
|
}
|
|
}
|
|
d.revision += 1;
|
|
}
|
|
Ok(json!({ "families": db.family_list().len(), "faces": faces }))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn list_flag_missing_replace_and_select() {
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
|
let a = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Serif", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap();
|
|
let b = s.execute("text.create", &json!({"x": 10, "y": 60, "text": "Gone", "font": "No Such Font Family"})).unwrap()["id"].as_u64().unwrap();
|
|
let list = s.execute("text.fonts", &json!({})).unwrap();
|
|
let missing: Vec<&str> = list.as_array().unwrap().iter().filter(|f| f["missing"] == true).map(|f| f["family"].as_str().unwrap()).collect();
|
|
assert_eq!(missing, ["No Such Font Family"]);
|
|
assert_eq!(s.execute("select.font", &json!({"family": "No Such Font Family"})).unwrap()["count"], 1);
|
|
assert_eq!(s.doc().unwrap().selection.objects, [NodeId(b)]);
|
|
let r = s.execute("text.replaceFont", &json!({"from": {"family": "No Such Font Family"}, "to": {"family": "Source Sans 3"}})).unwrap();
|
|
assert_eq!(r["runs"], 1);
|
|
let fams: Vec<String> =
|
|
s.execute("text.fonts", &json!({})).unwrap().as_array().unwrap().iter().map(|f| f["family"].as_str().unwrap().to_string()).collect();
|
|
assert_eq!(fams, ["Source Sans 3", "Source Serif 4"]);
|
|
assert!(s.execute("text.replaceFont", &json!({"from": {"family": "Source Serif 4"}, "to": {"family": "Nope"}})).is_err());
|
|
let _ = a;
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod open_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn opened_documents_have_exact_text_bounds() {
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"width": 400, "height": 200})).unwrap();
|
|
let id = s.execute("text.create", &json!({"x": 10, "y": 60, "text": "WIDE TRACKED", "size": 40})).unwrap()["id"].as_u64().unwrap();
|
|
s.execute("text.setStyle", &json!({"tracking": 400})).unwrap();
|
|
let want = s.doc().unwrap().doc.node(NodeId(id)).unwrap().geometric_bounds().unwrap();
|
|
// Save and reopen: the cache isn't in the file, yet the bounds must match.
|
|
let bytes = vectorcraft_format::save(&s.doc().unwrap().doc, false);
|
|
let reopened = vectorcraft_format::load(&bytes).unwrap();
|
|
let mut t = Session::new();
|
|
t.add_document(reopened, None);
|
|
let got = t.doc().unwrap().doc.node(NodeId(id)).unwrap().geometric_bounds().unwrap();
|
|
assert!((got.width() - want.width()).abs() < 1e-6, "{got:?} vs {want:?}");
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod dirty_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn selection_never_marks_modified_and_undo_to_saved_is_clean() {
|
|
let mut s = Session::new();
|
|
s.add_document(Document::new(100.0, 100.0), None);
|
|
let id = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
|
|
s.doc_mut().unwrap().mark_saved();
|
|
s.execute("select.none", &json!({})).unwrap();
|
|
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
|
assert!(!s.doc().unwrap().is_dirty(), "selecting is not an edit");
|
|
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
|
|
assert!(s.doc().unwrap().is_dirty());
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
assert!(!s.doc().unwrap().is_dirty(), "back to the saved state");
|
|
}
|
|
}
|